diff --git a/CMakeLists.txt b/CMakeLists.txt index cfb5f52..551bc2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,9 +47,6 @@ if (ROBOTICS_TOOLBOX_STANDALONE) FetchContent_MakeAvailable(emil) - # Declared before numerical_toolbox is made available: numerical-toolbox-cpp declares ui in - # its own standalone block, and FetchContent honours the first declaration it sees, so this - # keeps the pin here authoritative. if (ROBOTICS_TOOLBOX_BUILD_SIMULATOR) FetchContent_Declare( ui @@ -57,8 +54,6 @@ if (ROBOTICS_TOOLBOX_STANDALONE) GIT_TAG main ) - # Explicit rather than inferred from this repository's simulator flag: e-foc configures - # its Qt tools on every host build, so no consumer flag means the same thing everywhere. set(UI_BUILD_QT_BACKEND On CACHE BOOL "" FORCE) endif() diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index ccada0d..619a7c2 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -1 +1,2 @@ +add_subdirectory(shell) add_subdirectory(dynamics) diff --git a/simulator/dynamics/RobotArm/CMakeLists.txt b/simulator/dynamics/RobotArm/CMakeLists.txt index 7a6aed0..cda79c7 100644 --- a/simulator/dynamics/RobotArm/CMakeLists.txt +++ b/simulator/dynamics/RobotArm/CMakeLists.txt @@ -9,6 +9,7 @@ emil_build_for(robotics.simulator.dynamics.robot_arm HOST All PREREQUISITE_BOOL target_link_libraries(robotics.simulator.dynamics.robot_arm PRIVATE robotics.simulator.dynamics.robotarm.application robotics.simulator.dynamics.robotarm.view + robotics.simulator.shell ) target_sources(robotics.simulator.dynamics.robot_arm PRIVATE diff --git a/simulator/dynamics/RobotArm/Main.cpp b/simulator/dynamics/RobotArm/Main.cpp index cd9a80e..803a540 100644 --- a/simulator/dynamics/RobotArm/Main.cpp +++ b/simulator/dynamics/RobotArm/Main.cpp @@ -1,15 +1,7 @@ #include "simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp" -#include "ui/theme/Theme.hpp" -#include +#include "simulator/shell/AppRunner.hpp" int main(int argc, char* argv[]) { - QApplication app(argc, argv); - - ui::theme::SetCurrent(ui::theme::Instrument()); - - simulator::dynamics::view::RobotArmMainWindow window; - window.show(); - - return app.exec(); + return simulator::shell::Run(argc, argv, ui::theme::Instrument()); } diff --git a/simulator/dynamics/RobotArm/application/CMakeLists.txt b/simulator/dynamics/RobotArm/application/CMakeLists.txt index 18cdb92..50bd0d0 100644 --- a/simulator/dynamics/RobotArm/application/CMakeLists.txt +++ b/simulator/dynamics/RobotArm/application/CMakeLists.txt @@ -8,11 +8,14 @@ target_include_directories(robotics.simulator.dynamics.robotarm.application PUBL target_link_libraries(robotics.simulator.dynamics.robotarm.application PUBLIC robotics.dynamics robotics.kinematics + ui.model ) target_sources(robotics.simulator.dynamics.robotarm.application PRIVATE RobotArmSimulator.cpp RobotArmSimulator.hpp + RobotArmForm.cpp + RobotArmForm.hpp ) if(MSVC) @@ -22,3 +25,7 @@ else() -Wno-error ) endif() + +if (ROBOTICS_TOOLBOX_BUILD_TESTS) + add_subdirectory(test) +endif() diff --git a/simulator/dynamics/RobotArm/application/RobotArmForm.cpp b/simulator/dynamics/RobotArm/application/RobotArmForm.cpp new file mode 100644 index 0000000..d8ad244 --- /dev/null +++ b/simulator/dynamics/RobotArm/application/RobotArmForm.cpp @@ -0,0 +1,122 @@ +#include "simulator/dynamics/RobotArm/application/RobotArmForm.hpp" +#include + +namespace simulator::dynamics +{ + namespace + { + using ui::model::ActionSpec; + using ui::model::Condition; + using ui::model::FieldKind; + using ui::model::FieldSpec; + using ui::model::GroupSpec; + using ui::model::OptionSpec; + + constexpr float torqueScale{ 10.0f }; + constexpr float degreesToRadians{ std::numbers::pi_v / 180.0f }; + + constexpr std::array degreeOptions{ + OptionSpec{ "2 DOF (Planar)", 2 }, + OptionSpec{ "3 DOF (Spatial)", 3 } + }; + + constexpr Condition spatialOnly{ field::degreesOfFreedom, 0b10 }; + + constexpr std::array groups{ + GroupSpec{ field::robot, "Robot Configuration", {} }, + GroupSpec{ field::firstLink, "Link 1", {} }, + GroupSpec{ field::secondLink, "Link 2", {} }, + GroupSpec{ field::thirdLink, "Link 3", spatialOnly }, + GroupSpec{ field::torques, "Joint Torques (N·m)", {} }, + GroupSpec{ field::positions, "Initial Position (deg)", {} }, + GroupSpec{ field::simulation, "Simulation", {} } + }; + + constexpr std::array fields{ + FieldSpec{ field::degreesOfFreedom, field::robot, FieldKind::Choice, "DOF:", "", {}, degreeOptions, {}, {} }, + FieldSpec{ field::firstLength, field::firstLink, FieldKind::Number, "Length (m):", "", { 0.05, 2.0, 0.05, 0.5, 2 }, {}, {}, {} }, + FieldSpec{ field::firstMass, field::firstLink, FieldKind::Number, "Mass (kg):", "", { 0.1, 20.0, 0.1, 1.0, 2 }, {}, {}, {} }, + FieldSpec{ field::secondLength, field::secondLink, FieldKind::Number, "Length (m):", "", { 0.05, 2.0, 0.05, 0.4, 2 }, {}, {}, {} }, + FieldSpec{ field::secondMass, field::secondLink, FieldKind::Number, "Mass (kg):", "", { 0.1, 20.0, 0.1, 0.8, 2 }, {}, {}, {} }, + FieldSpec{ field::thirdLength, field::thirdLink, FieldKind::Number, "Length (m):", "", { 0.05, 2.0, 0.05, 0.3, 2 }, {}, spatialOnly, {} }, + FieldSpec{ field::thirdMass, field::thirdLink, FieldKind::Number, "Mass (kg):", "", { 0.1, 20.0, 0.1, 0.6, 2 }, {}, spatialOnly, {} }, + FieldSpec{ field::firstTorque, field::torques, FieldKind::Slider, "τ1:", "", { -200.0, 200.0, 1.0, 0.0, 0, 50.0 }, {}, {}, {} }, + FieldSpec{ field::secondTorque, field::torques, FieldKind::Slider, "τ2:", "", { -200.0, 200.0, 1.0, 0.0, 0, 50.0 }, {}, {}, {} }, + FieldSpec{ field::thirdTorque, field::torques, FieldKind::Slider, "τ3:", "", { -200.0, 200.0, 1.0, 0.0, 0, 50.0 }, {}, spatialOnly, {} }, + FieldSpec{ field::firstPosition, field::positions, FieldKind::Slider, "q1:", "", { -180.0, 180.0, 1.0, 0.0, 0, 45.0 }, {}, {}, {} }, + FieldSpec{ field::secondPosition, field::positions, FieldKind::Slider, "q2:", "", { -180.0, 180.0, 1.0, 0.0, 0, 45.0 }, {}, {}, {} }, + FieldSpec{ field::thirdPosition, field::positions, FieldKind::Slider, "q3:", "", { -180.0, 180.0, 1.0, 0.0, 0, 45.0 }, {}, spatialOnly, {} }, + FieldSpec{ field::damping, field::simulation, FieldKind::Number, "Damping:", "", { 0.0, 5.0, 0.01, 0.05, 3 }, {}, {}, {} } + }; + + constexpr std::array actions{ + ActionSpec{ field::start, "Start", ui::theme::ButtonRole::Start, 0 }, + ActionSpec{ field::stop, "Stop", ui::theme::ButtonRole::Stop, 0 }, + ActionSpec{ field::reset, "Reset", ui::theme::ButtonRole::Reset, 0 } + }; + + constexpr std::array lengthFields{ field::firstLength, field::secondLength, field::thirdLength }; + constexpr std::array massFields{ field::firstMass, field::secondMass, field::thirdMass }; + constexpr std::array torqueFields{ field::firstTorque, field::secondTorque, field::thirdTorque }; + constexpr std::array positionFields{ field::firstPosition, field::secondPosition, field::thirdPosition }; + } + + RobotArmForm::RobotArmForm() + : spec{ groups, fields, actions, {} } + , model{ spec, values, {} } + {} + + ui::model::FormModel& RobotArmForm::Model() + { + return model; + } + + const ui::model::FormModel& RobotArmForm::Model() const + { + return model; + } + + std::size_t RobotArmForm::DegreesOfFreedom() const + { + return static_cast(model.SelectedData(field::degreesOfFreedom)); + } + + RobotArmConfig RobotArmForm::BuildConfiguration() const + { + RobotArmConfig config; + + config.dof = static_cast(DegreesOfFreedom()); + config.damping = model.Float(field::damping); + + config.linkLengths.resize(DegreesOfFreedom()); + config.linkMasses.resize(DegreesOfFreedom()); + + for (std::size_t i = 0; i < DegreesOfFreedom(); ++i) + { + config.linkLengths[i] = model.Float(lengthFields[i]); + config.linkMasses[i] = model.Float(massFields[i]); + } + + return config; + } + + std::vector RobotArmForm::Torques() const + { + std::vector torques(DegreesOfFreedom()); + + for (std::size_t i = 0; i < torques.size(); ++i) + torques[i] = model.Float(torqueFields[i]) / torqueScale; + + return torques; + } + + std::vector RobotArmForm::InitialPositions() const + { + std::vector positions(DegreesOfFreedom()); + + for (std::size_t i = 0; i < positions.size(); ++i) + positions[i] = model.Float(positionFields[i]) * degreesToRadians; + + return positions; + } +} diff --git a/simulator/dynamics/RobotArm/application/RobotArmForm.hpp b/simulator/dynamics/RobotArm/application/RobotArmForm.hpp new file mode 100644 index 0000000..0003b39 --- /dev/null +++ b/simulator/dynamics/RobotArm/application/RobotArmForm.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "simulator/dynamics/RobotArm/application/RobotArmTypes.hpp" +#include "ui/model/FormModel.hpp" +#include +#include + +namespace simulator::dynamics +{ + namespace field + { + inline constexpr ui::model::FieldId degreesOfFreedom{ 1 }; + inline constexpr ui::model::FieldId firstLength{ 2 }; + inline constexpr ui::model::FieldId firstMass{ 3 }; + inline constexpr ui::model::FieldId secondLength{ 4 }; + inline constexpr ui::model::FieldId secondMass{ 5 }; + inline constexpr ui::model::FieldId thirdLength{ 6 }; + inline constexpr ui::model::FieldId thirdMass{ 7 }; + inline constexpr ui::model::FieldId firstTorque{ 8 }; + inline constexpr ui::model::FieldId secondTorque{ 9 }; + inline constexpr ui::model::FieldId thirdTorque{ 10 }; + inline constexpr ui::model::FieldId firstPosition{ 11 }; + inline constexpr ui::model::FieldId secondPosition{ 12 }; + inline constexpr ui::model::FieldId thirdPosition{ 13 }; + inline constexpr ui::model::FieldId damping{ 14 }; + + inline constexpr ui::model::GroupId robot{ 1 }; + inline constexpr ui::model::GroupId firstLink{ 2 }; + inline constexpr ui::model::GroupId secondLink{ 3 }; + inline constexpr ui::model::GroupId thirdLink{ 4 }; + inline constexpr ui::model::GroupId torques{ 5 }; + inline constexpr ui::model::GroupId positions{ 6 }; + inline constexpr ui::model::GroupId simulation{ 7 }; + + inline constexpr ui::model::ActionId start{ 1 }; + inline constexpr ui::model::ActionId stop{ 2 }; + inline constexpr ui::model::ActionId reset{ 3 }; + } + + class RobotArmForm + { + public: + RobotArmForm(); + + [[nodiscard]] ui::model::FormModel& Model(); + [[nodiscard]] const ui::model::FormModel& Model() const; + + [[nodiscard]] RobotArmConfig BuildConfiguration() const; + [[nodiscard]] std::vector Torques() const; + [[nodiscard]] std::vector InitialPositions() const; + + private: + [[nodiscard]] std::size_t DegreesOfFreedom() const; + + static constexpr std::size_t fieldCount{ 14 }; + + std::array values{}; + + ui::model::FormSpec spec; + ui::model::FormModel model; + }; +} diff --git a/simulator/dynamics/RobotArm/application/test/CMakeLists.txt b/simulator/dynamics/RobotArm/application/test/CMakeLists.txt new file mode 100644 index 0000000..e4177b3 --- /dev/null +++ b/simulator/dynamics/RobotArm/application/test/CMakeLists.txt @@ -0,0 +1,12 @@ +add_executable(robotics.simulator.dynamics.robotarm.application_test) +emil_build_for(robotics.simulator.dynamics.robotarm.application_test HOST All PREREQUISITE_BOOL ROBOTICS_TOOLBOX_BUILD_SIMULATOR) +emil_add_test(robotics.simulator.dynamics.robotarm.application_test) + +target_link_libraries(robotics.simulator.dynamics.robotarm.application_test PUBLIC + gmock_main + robotics.simulator.dynamics.robotarm.application +) + +target_sources(robotics.simulator.dynamics.robotarm.application_test PRIVATE + TestRobotArmForm.cpp +) diff --git a/simulator/dynamics/RobotArm/application/test/TestRobotArmForm.cpp b/simulator/dynamics/RobotArm/application/test/TestRobotArmForm.cpp new file mode 100644 index 0000000..e971344 --- /dev/null +++ b/simulator/dynamics/RobotArm/application/test/TestRobotArmForm.cpp @@ -0,0 +1,127 @@ +#include "simulator/dynamics/RobotArm/application/RobotArmForm.hpp" +#include +#include + +namespace +{ + using simulator::dynamics::RobotArmForm; + namespace field = simulator::dynamics::field; + + class RobotArmFormTest + : public ::testing::Test + { + protected: + RobotArmForm form; + }; +} + +TEST_F(RobotArmFormTest, TheDefaultsMatchThePanelThisReplaced) +{ + const auto config = form.BuildConfiguration(); + + EXPECT_EQ(config.dof, 2); + EXPECT_NEAR(config.damping, 0.05f, 1e-5f); + + ASSERT_EQ(config.linkLengths.size(), 2u); + EXPECT_NEAR(config.linkLengths[0], 0.5f, 1e-4f); + EXPECT_NEAR(config.linkLengths[1], 0.4f, 1e-4f); + EXPECT_NEAR(config.linkMasses[0], 1.0f, 1e-4f); + EXPECT_NEAR(config.linkMasses[1], 0.8f, 1e-4f); +} + +TEST_F(RobotArmFormTest, TheThirdLinkAppearsOnlyForTheSpatialRobot) +{ + EXPECT_FALSE(form.Model().IsGroupVisible(field::thirdLink)); + EXPECT_FALSE(form.Model().IsVisible(field::thirdLength)); + EXPECT_FALSE(form.Model().IsVisible(field::thirdTorque)); + EXPECT_FALSE(form.Model().IsVisible(field::thirdPosition)); + + form.Model().SetSelection(field::degreesOfFreedom, 1); + + EXPECT_TRUE(form.Model().IsGroupVisible(field::thirdLink)); + EXPECT_TRUE(form.Model().IsVisible(field::thirdLength)); + EXPECT_TRUE(form.Model().IsVisible(field::thirdTorque)); + EXPECT_TRUE(form.Model().IsVisible(field::thirdPosition)); +} + +TEST_F(RobotArmFormTest, TheThirdLinkReachesTheConfigurationOnlyWhenSelected) +{ + EXPECT_EQ(form.BuildConfiguration().linkLengths.size(), 2u); + + form.Model().SetSelection(field::degreesOfFreedom, 1); + + const auto config = form.BuildConfiguration(); + ASSERT_EQ(config.linkLengths.size(), 3u); + EXPECT_EQ(config.dof, 3); + EXPECT_NEAR(config.linkLengths[2], 0.3f, 1e-4f); + EXPECT_NEAR(config.linkMasses[2], 0.6f, 1e-4f); +} + +TEST_F(RobotArmFormTest, TheDegreeCountComesFromTheOptionDataNotItsPosition) +{ + EXPECT_EQ(form.BuildConfiguration().dof, 2); + + form.Model().SetSelection(field::degreesOfFreedom, 1); + + EXPECT_EQ(form.BuildConfiguration().dof, 3); +} + +TEST_F(RobotArmFormTest, TheTorqueSlidersCarryTenthsOfANewtonMetre) +{ + form.Model().SetNumber(field::firstTorque, 125.0); + form.Model().SetNumber(field::secondTorque, -40.0); + + const auto torques = form.Torques(); + + ASSERT_EQ(torques.size(), 2u); + EXPECT_NEAR(torques[0], 12.5f, 1e-4f); + EXPECT_NEAR(torques[1], -4.0f, 1e-4f); +} + +TEST_F(RobotArmFormTest, ThePositionSlidersAreDegreesAndReachTheSimulatorAsRadians) +{ + form.Model().SetNumber(field::firstPosition, 90.0); + form.Model().SetNumber(field::secondPosition, -180.0); + + const auto positions = form.InitialPositions(); + + ASSERT_EQ(positions.size(), 2u); + EXPECT_NEAR(positions[0], std::numbers::pi_v / 2.0f, 1e-5f); + EXPECT_NEAR(positions[1], -std::numbers::pi_v, 1e-5f); +} + +TEST_F(RobotArmFormTest, TheTorqueAndPositionVectorsFollowTheSelectedDegreeCount) +{ + EXPECT_EQ(form.Torques().size(), 2u); + EXPECT_EQ(form.InitialPositions().size(), 2u); + + form.Model().SetSelection(field::degreesOfFreedom, 1); + + EXPECT_EQ(form.Torques().size(), 3u); + EXPECT_EQ(form.InitialPositions().size(), 3u); +} + +TEST_F(RobotArmFormTest, TheDraggedControlsAreSlidersCarryingTheirTickIntervals) +{ + for (const auto id : { field::firstTorque, field::secondTorque, field::thirdTorque }) + { + EXPECT_EQ(form.Model().Field(id).kind, ui::model::FieldKind::Slider); + EXPECT_NEAR(form.Model().Field(id).number.tickInterval, 50.0, 1e-9); + } + + for (const auto id : { field::firstPosition, field::secondPosition, field::thirdPosition }) + { + EXPECT_EQ(form.Model().Field(id).kind, ui::model::FieldKind::Slider); + EXPECT_NEAR(form.Model().Field(id).number.tickInterval, 45.0, 1e-9); + } +} + +TEST_F(RobotArmFormTest, TheHiddenThirdLinkKeepsItsValuesAcrossASwitch) +{ + form.Model().SetSelection(field::degreesOfFreedom, 1); + form.Model().SetNumber(field::thirdLength, 1.25); + form.Model().SetSelection(field::degreesOfFreedom, 0); + form.Model().SetSelection(field::degreesOfFreedom, 1); + + EXPECT_NEAR(form.BuildConfiguration().linkLengths[2], 1.25f, 1e-4f); +} diff --git a/simulator/dynamics/RobotArm/view/CMakeLists.txt b/simulator/dynamics/RobotArm/view/CMakeLists.txt index 008975b..8ff6052 100644 --- a/simulator/dynamics/RobotArm/view/CMakeLists.txt +++ b/simulator/dynamics/RobotArm/view/CMakeLists.txt @@ -21,8 +21,6 @@ set_target_properties(robotics.simulator.dynamics.robotarm.view PROPERTIES target_sources(robotics.simulator.dynamics.robotarm.view PRIVATE RobotArmSceneView.cpp RobotArmSceneView.hpp - RobotArmConfigurationPanel.cpp - RobotArmConfigurationPanel.hpp RobotArmMainWindow.cpp RobotArmMainWindow.hpp ) diff --git a/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.cpp b/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.cpp deleted file mode 100644 index 35d3403..0000000 --- a/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.cpp +++ /dev/null @@ -1,232 +0,0 @@ -#include "simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp" -#include -#include -#include -#include - -namespace simulator::dynamics::view -{ - RobotArmConfigurationPanel::RobotArmConfigurationPanel(QWidget* parent) - : QWidget(parent) - { - SetupUi(); - } - - void RobotArmConfigurationPanel::SetupUi() - { - auto* mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(8, 8, 8, 8); - mainLayout->setSpacing(6); - - // DOF selection - auto* dofGroup = new QGroupBox("Robot Configuration", this); - auto* dofLayout = new QHBoxLayout(dofGroup); - dofLayout->addWidget(new QLabel("DOF:", this)); - dofCombo = new QComboBox(this); - dofCombo->addItem("2 DOF (Planar)", 2); - dofCombo->addItem("3 DOF (Spatial)", 3); - dofLayout->addWidget(dofCombo); - mainLayout->addWidget(dofGroup); - - // Link parameters - std::array defaultLengths = { 0.5f, 0.4f, 0.3f }; - std::array defaultMasses = { 1.0f, 0.8f, 0.6f }; - - for (int i = 0; i < maxDof; ++i) - { - auto* group = new QGroupBox(QString("Link %1").arg(i + 1), this); - auto* layout = new QVBoxLayout(group); - - auto* lenLayout = new QHBoxLayout(); - lenLayout->addWidget(new QLabel("Length (m):", this)); - auto* lenSpin = new QDoubleSpinBox(this); - lenSpin->setRange(0.05, 2.0); - lenSpin->setSingleStep(0.05); - lenSpin->setValue(static_cast(defaultLengths[i])); - lenSpin->setDecimals(2); - lenLayout->addWidget(lenSpin); - layout->addLayout(lenLayout); - - auto* massLayout = new QHBoxLayout(); - massLayout->addWidget(new QLabel("Mass (kg):", this)); - auto* massSpin = new QDoubleSpinBox(this); - massSpin->setRange(0.1, 20.0); - massSpin->setSingleStep(0.1); - massSpin->setValue(static_cast(defaultMasses[i])); - massSpin->setDecimals(2); - massLayout->addWidget(massSpin); - layout->addLayout(massLayout); - - linkGroups.push_back(group); - lengthSpins.push_back(lenSpin); - massSpins.push_back(massSpin); - mainLayout->addWidget(group); - } - - // Torque sliders - torqueGroup = new QGroupBox("Joint Torques (N·m)", this); - auto* torqueLayout = new QVBoxLayout(torqueGroup); - - for (int i = 0; i < maxDof; ++i) - { - auto* rowWidget = new QWidget(torqueGroup); - auto* rowLayout = new QHBoxLayout(rowWidget); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->addWidget(new QLabel(QString("τ%1:").arg(i + 1), rowWidget)); - - auto* slider = new QSlider(Qt::Horizontal, rowWidget); - slider->setRange(-200, 200); - slider->setValue(0); - slider->setTickPosition(QSlider::TicksBelow); - slider->setTickInterval(50); - rowLayout->addWidget(slider); - - auto* label = new QLabel("0.0", rowWidget); - label->setFixedWidth(55); - label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - rowLayout->addWidget(label); - - torqueSliders.push_back(slider); - torqueLabels.push_back(label); - torqueRows.push_back(rowWidget); - torqueLayout->addWidget(rowWidget); - - connect(slider, &QSlider::valueChanged, this, &RobotArmConfigurationPanel::UpdateTorqueLabels); - } - mainLayout->addWidget(torqueGroup); - - // Initial position sliders - positionGroup = new QGroupBox("Initial Position (deg)", this); - auto* posLayout = new QVBoxLayout(positionGroup); - - for (int i = 0; i < maxDof; ++i) - { - auto* rowWidget = new QWidget(positionGroup); - auto* rowLayout = new QHBoxLayout(rowWidget); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->addWidget(new QLabel(QString("q%1:").arg(i + 1), rowWidget)); - - auto* slider = new QSlider(Qt::Horizontal, rowWidget); - slider->setRange(-180, 180); - slider->setValue(0); - slider->setTickPosition(QSlider::TicksBelow); - slider->setTickInterval(45); - rowLayout->addWidget(slider); - - auto* label = new QLabel("0°", rowWidget); - label->setFixedWidth(45); - label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - rowLayout->addWidget(label); - - positionSliders.push_back(slider); - positionLabels.push_back(label); - positionRows.push_back(rowWidget); - posLayout->addWidget(rowWidget); - - connect(slider, &QSlider::valueChanged, this, &RobotArmConfigurationPanel::UpdatePositionLabels); - } - mainLayout->addWidget(positionGroup); - - // Damping - auto* dampGroup = new QGroupBox("Simulation", this); - auto* dampLayout = new QHBoxLayout(dampGroup); - dampLayout->addWidget(new QLabel("Damping:", this)); - dampingSpin = new QDoubleSpinBox(this); - dampingSpin->setRange(0.0, 5.0); - dampingSpin->setSingleStep(0.01); - dampingSpin->setValue(0.05); - dampingSpin->setDecimals(3); - dampLayout->addWidget(dampingSpin); - mainLayout->addWidget(dampGroup); - - // Control buttons - auto* controlLayout = new QHBoxLayout(); - startButton = new QPushButton("▶ Start", this); - stopButton = new QPushButton("⬜ Stop", this); - resetButton = new QPushButton("↺ Reset", this); - - startButton->setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 6px; }"); - stopButton->setStyleSheet("QPushButton { background-color: #f44336; color: white; padding: 6px; }"); - resetButton->setStyleSheet("QPushButton { background-color: #2196F3; color: white; padding: 6px; }"); - - controlLayout->addWidget(startButton); - controlLayout->addWidget(stopButton); - controlLayout->addWidget(resetButton); - mainLayout->addLayout(controlLayout); - - mainLayout->addStretch(); - - // Connections - connect(dofCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &RobotArmConfigurationPanel::OnDofChanged); - connect(startButton, &QPushButton::clicked, this, &RobotArmConfigurationPanel::StartRequested); - connect(stopButton, &QPushButton::clicked, this, &RobotArmConfigurationPanel::StopRequested); - connect(resetButton, &QPushButton::clicked, this, &RobotArmConfigurationPanel::ResetRequested); - - OnDofChanged(0); - } - - void RobotArmConfigurationPanel::OnDofChanged(int index) - { - int dof = dofCombo->itemData(index).toInt(); - for (int i = 0; i < maxDof; ++i) - { - bool visible = (i < dof); - linkGroups[i]->setVisible(visible); - torqueRows[i]->setVisible(visible); - positionRows[i]->setVisible(visible); - } - - emit ConfigurationChanged(); - } - - void RobotArmConfigurationPanel::UpdateTorqueLabels() - { - for (int i = 0; i < maxDof; ++i) - { - float value = torqueSliders[i]->value() / 10.0f; - torqueLabels[i]->setText(QString::number(static_cast(value), 'f', 1)); - } - } - - void RobotArmConfigurationPanel::UpdatePositionLabels() - { - for (int i = 0; i < maxDof; ++i) - positionLabels[i]->setText(QString("%1°").arg(positionSliders[i]->value())); - } - - RobotArmConfig RobotArmConfigurationPanel::GetConfiguration() const - { - RobotArmConfig cfg; - cfg.dof = dofCombo->currentData().toInt(); - cfg.damping = static_cast(dampingSpin->value()); - - cfg.linkLengths.resize(cfg.dof); - cfg.linkMasses.resize(cfg.dof); - - for (int i = 0; i < cfg.dof; ++i) - { - cfg.linkLengths[i] = static_cast(lengthSpins[i]->value()); - cfg.linkMasses[i] = static_cast(massSpins[i]->value()); - } - - return cfg; - } - - std::vector RobotArmConfigurationPanel::GetTorques() const - { - int dof = dofCombo->currentData().toInt(); - std::vector t(dof); - for (int i = 0; i < dof; ++i) - t[i] = torqueSliders[i]->value() / 10.0f; - return t; - } - - std::vector RobotArmConfigurationPanel::GetInitialPositions() const - { - int dof = dofCombo->currentData().toInt(); - std::vector pos(dof); - for (int i = 0; i < dof; ++i) - pos[i] = positionSliders[i]->value() * 3.14159265f / 180.0f; - return pos; - } -} diff --git a/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp b/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp deleted file mode 100644 index 89c6998..0000000 --- a/simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include "simulator/dynamics/RobotArm/application/RobotArmTypes.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - -namespace simulator::dynamics::view -{ - class RobotArmConfigurationPanel - : public QWidget - { - Q_OBJECT - - public: - explicit RobotArmConfigurationPanel(QWidget* parent = nullptr); - - RobotArmConfig GetConfiguration() const; - std::vector GetTorques() const; - std::vector GetInitialPositions() const; - - signals: - void ConfigurationChanged(); - void StartRequested(); - void StopRequested(); - void ResetRequested(); - - private: - void SetupUi(); - void OnDofChanged(int index); - void UpdateTorqueLabels(); - void UpdatePositionLabels(); - - QComboBox* dofCombo; - - static constexpr int maxDof = 3; - - std::vector linkGroups; - std::vector lengthSpins; - std::vector massSpins; - - std::vector torqueSliders; - std::vector torqueLabels; - std::vector torqueRows; - QGroupBox* torqueGroup; - - std::vector positionSliders; - std::vector positionLabels; - std::vector positionRows; - QGroupBox* positionGroup; - - QDoubleSpinBox* dampingSpin; - - QPushButton* startButton; - QPushButton* stopButton; - QPushButton* resetButton; - }; -} diff --git a/simulator/dynamics/RobotArm/view/RobotArmMainWindow.cpp b/simulator/dynamics/RobotArm/view/RobotArmMainWindow.cpp index f85155a..4f472b8 100644 --- a/simulator/dynamics/RobotArm/view/RobotArmMainWindow.cpp +++ b/simulator/dynamics/RobotArm/view/RobotArmMainWindow.cpp @@ -1,42 +1,42 @@ #include "simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp" -#include #include namespace simulator::dynamics::view { + namespace + { + const ui::shell::ShellSpec shellSpec{ + "Robot Arm Dynamics Simulator", + ui::Size{ 1400.0f, 900.0f }, + 350.0f, + {}, + "Configure robot parameters and press Start" + }; + } + RobotArmMainWindow::RobotArmMainWindow(QWidget* parent) : QMainWindow(parent) + , formView(new ui::backend::qt::QtFormView{ this }) + , shell(*this, shellSpec) + , view3D(new ui::backend::qt::QtPaintedWidget{ sceneView, this }) + , simulationTimer(new QTimer{ this }) { - setWindowTitle("Robot Arm Dynamics Simulator"); - resize(1400, 900); + formView->Build(form.Model()); + formView->setMinimumWidth(280); + shell.SetPanel(formView); - auto* splitter = new QSplitter(Qt::Horizontal, this); - - configPanel = new RobotArmConfigurationPanel(splitter); - configPanel->setMaximumWidth(350); - configPanel->setMinimumWidth(280); - - view3D = new ui::backend::qt::QtPaintedWidget(sceneView, splitter); view3D->SetBackgroundRole(ui::theme::ColorRole::SceneBackground); view3D->SetPanCursorEnabled(true); + shell.SetContent(view3D); - splitter->addWidget(configPanel); - splitter->addWidget(view3D); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - - setCentralWidget(splitter); - - simulationTimer = new QTimer(this); connect(simulationTimer, &QTimer::timeout, this, &RobotArmMainWindow::OnSimulationStep); - connect(configPanel, &RobotArmConfigurationPanel::StartRequested, this, &RobotArmMainWindow::OnStartRequested); - connect(configPanel, &RobotArmConfigurationPanel::StopRequested, this, &RobotArmMainWindow::OnStopRequested); - connect(configPanel, &RobotArmConfigurationPanel::ResetRequested, this, &RobotArmMainWindow::OnResetRequested); - connect(configPanel, &RobotArmConfigurationPanel::ConfigurationChanged, this, &RobotArmMainWindow::ApplyConfiguration); + form.Model().onActionTriggered = [this](ui::model::ActionId action) + { + OnActionTriggered(action); + }; ApplyConfiguration(); - statusBar()->showMessage("Configure robot parameters and press Start"); } RobotArmMainWindow::~RobotArmMainWindow() @@ -44,11 +44,21 @@ namespace simulator::dynamics::view delete view3D; } + void RobotArmMainWindow::OnActionTriggered(ui::model::ActionId action) + { + if (action == field::start) + OnStartRequested(); + else if (action == field::stop) + OnStopRequested(); + else if (action == field::reset) + OnResetRequested(); + } + void RobotArmMainWindow::ApplyConfiguration() { - auto config = configPanel->GetConfiguration(); + auto config = form.BuildConfiguration(); simulator.Configure(config); - simulator.SetInitialPositions(configPanel->GetInitialPositions()); + simulator.SetInitialPositions(form.InitialPositions()); simulationTimer->setInterval(static_cast(std::round(1000.0f * config.dt))); sceneView.SetState(simulator.GetState(), config.dof); } @@ -58,10 +68,10 @@ namespace simulator::dynamics::view if (!running) { ApplyConfiguration(); - simulator.SetInitialPositions(configPanel->GetInitialPositions()); + simulator.SetInitialPositions(form.InitialPositions()); running = true; simulationTimer->start(); - statusBar()->showMessage("Simulation running..."); + shell.SetStatus("Simulation running..."); } } @@ -69,7 +79,7 @@ namespace simulator::dynamics::view { running = false; simulationTimer->stop(); - statusBar()->showMessage("Simulation stopped"); + shell.SetStatus("Simulation stopped"); } void RobotArmMainWindow::OnResetRequested() @@ -77,20 +87,21 @@ namespace simulator::dynamics::view running = false; simulationTimer->stop(); ApplyConfiguration(); - statusBar()->showMessage("Simulation reset"); + shell.SetStatus("Simulation reset"); } void RobotArmMainWindow::OnSimulationStep() { - simulator.SetTorques(configPanel->GetTorques()); + simulator.SetTorques(form.Torques()); simulator.Step(); auto config = simulator.GetConfig(); sceneView.SetState(simulator.GetState(), config.dof); auto& state = simulator.GetState(); - statusBar()->showMessage( - QString("t=%1s | Running") - .arg(static_cast(state.time), 0, 'f', 2)); + + shell.SetStatus(QString("t=%1s | Running") + .arg(static_cast(state.time), 0, 'f', 2) + .toStdString()); } } diff --git a/simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp b/simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp index 8511c29..02e2d63 100644 --- a/simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp +++ b/simulator/dynamics/RobotArm/view/RobotArmMainWindow.hpp @@ -1,11 +1,12 @@ #pragma once +#include "simulator/dynamics/RobotArm/application/RobotArmForm.hpp" #include "simulator/dynamics/RobotArm/application/RobotArmSimulator.hpp" -#include "simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp" #include "simulator/dynamics/RobotArm/view/RobotArmSceneView.hpp" +#include "ui/backend/qt/QtAppShell.hpp" +#include "ui/backend/qt/QtFormView.hpp" #include "ui/backend/qt/QtPaintedWidget.hpp" #include -#include #include namespace simulator::dynamics::view @@ -20,6 +21,7 @@ namespace simulator::dynamics::view ~RobotArmMainWindow() override; private: + void OnActionTriggered(ui::model::ActionId action); void OnStartRequested(); void OnStopRequested(); void OnResetRequested(); @@ -27,8 +29,10 @@ namespace simulator::dynamics::view void ApplyConfiguration(); RobotArmSimulator simulator; + RobotArmForm form; RobotArmSceneView sceneView; - RobotArmConfigurationPanel* configPanel; + ui::backend::qt::QtFormView* formView; + ui::backend::qt::QtAppShell shell; ui::backend::qt::QtPaintedWidget* view3D; QTimer* simulationTimer; bool running = false; diff --git a/simulator/shell/AppRunner.hpp b/simulator/shell/AppRunner.hpp new file mode 100644 index 0000000..0ad5ba0 --- /dev/null +++ b/simulator/shell/AppRunner.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "ui/backend/qt/QtTheme.hpp" +#include + +namespace simulator::shell +{ + template + int Run(int argc, char* argv[], const ui::theme::Theme& theme) + { + QApplication application{ argc, argv }; + + ui::backend::qt::ApplyTheme(theme); + + Window window; + window.show(); + + return application.exec(); + } +} diff --git a/simulator/shell/CMakeLists.txt b/simulator/shell/CMakeLists.txt new file mode 100644 index 0000000..8be3160 --- /dev/null +++ b/simulator/shell/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(robotics.simulator.shell INTERFACE) + +target_include_directories(robotics.simulator.shell INTERFACE + "$" + "$" +) + +target_sources(robotics.simulator.shell INTERFACE + AppRunner.hpp +) + +target_link_libraries(robotics.simulator.shell INTERFACE + ui.backend.qt +)