From 8d4bd060947a92668619fe83d55e4b80b961a874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:03:33 +0000 Subject: [PATCH 1/2] feat(simulator): render the robot arm panel through ui-cpp Completes this repository's migration. The 3D view already drew through Canvas; the panel, the window and the entry point were the remainder. The degree-of-freedom switch is a condition rather than a handler. The panel built all three link groups against a fixed maximum and toggled their visibility from OnDofChanged, so a selection mask over the choice expresses it exactly, and the third link's group and its three controls now carry that mask instead. The six dragged controls use the slider field kind added in ui-cpp#7, so they keep their tick marks rather than becoming spin boxes. Their scaling stays in the application layer where the panel had it: torques are tenths of a newton metre, positions are degrees converted to radians on the way to the simulator. The three buttons take the Start, Stop and Reset roles, which removes the three inline setStyleSheet colours they carried. Configuration is still applied on Start and Reset only, not on change. ApplyConfiguration recomputes the timer interval and calls SetInitialPositions, so applying per keystroke would reset a running simulation; onFieldChanged is left unwired for that reason. Adds the first application-layer test for this simulator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YQEnuDHtCQb5UyddnMup3Q --- simulator/CMakeLists.txt | 1 + simulator/dynamics/RobotArm/CMakeLists.txt | 1 + simulator/dynamics/RobotArm/Main.cpp | 12 +- .../RobotArm/application/CMakeLists.txt | 7 + .../RobotArm/application/RobotArmForm.cpp | 122 +++++++++ .../RobotArm/application/RobotArmForm.hpp | 62 +++++ .../RobotArm/application/test/CMakeLists.txt | 12 + .../application/test/TestRobotArmForm.cpp | 127 ++++++++++ .../dynamics/RobotArm/view/CMakeLists.txt | 2 - .../view/RobotArmConfigurationPanel.cpp | 232 ------------------ .../view/RobotArmConfigurationPanel.hpp | 63 ----- .../RobotArm/view/RobotArmMainWindow.cpp | 77 +++--- .../RobotArm/view/RobotArmMainWindow.hpp | 10 +- simulator/shell/AppRunner.hpp | 20 ++ simulator/shell/CMakeLists.txt | 14 ++ 15 files changed, 419 insertions(+), 343 deletions(-) create mode 100644 simulator/dynamics/RobotArm/application/RobotArmForm.cpp create mode 100644 simulator/dynamics/RobotArm/application/RobotArmForm.hpp create mode 100644 simulator/dynamics/RobotArm/application/test/CMakeLists.txt create mode 100644 simulator/dynamics/RobotArm/application/test/TestRobotArmForm.cpp delete mode 100644 simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.cpp delete mode 100644 simulator/dynamics/RobotArm/view/RobotArmConfigurationPanel.hpp create mode 100644 simulator/shell/AppRunner.hpp create mode 100644 simulator/shell/CMakeLists.txt 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 +) From 6669b6d52d50f70f901141a3fedf33908ec45dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:08:22 +0000 Subject: [PATCH 2/2] style: drop the FetchContent rationale comments Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YQEnuDHtCQb5UyddnMup3Q --- CMakeLists.txt | 5 ----- 1 file changed, 5 deletions(-) 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()