LCOV - code coverage report
Current view: top level - src/simlab - FullDemoScenario.cpp (source / functions) Coverage Total Hit
Test: coverage.info Lines: 94.2 % 171 161
Test Date: 2026-04-10 19:03:25 Functions: 90.9 % 11 10

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2025 aeml
       3              :  *
       4              :  * This program is free software: you can redistribute it and/or modify
       5              :  * it under the terms of the GNU General Public License as published by
       6              :  * the Free Software Foundation, either version 3 of the License, or
       7              :  * (at your option) any later version.
       8              :  *
       9              :  * This program is distributed in the hope that it will be useful,
      10              :  * but WITHOUT ANY WARRANTY; without even the implied warranty of
      11              :  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
      12              :  * GNU General Public License for more details.
      13              :  *
      14              :  * You should have received a copy of the GNU General Public License
      15              :  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
      16              :  */
      17              : 
      18              : // FullDemoScenario — exercises every AtlasCore subsystem in a single scene.
      19              : //
      20              : // Systems / features shown:
      21              : //   ECS        — CreateEntity, AddComponent, GetComponent, GetStorage,
      22              : //                ForEach<T>, View<T1,T2> (multi-component intersection)
      23              : //   Physics    — EnvironmentForces (gravity + drag), PhysicsSettings
      24              : //                (substeps, constraintIterations), ConfigureCircleInertia,
      25              : //                ConfigureBoxInertia, restitution, friction, angularDrag
      26              : //   Bodies     — static AABB floor/walls, static AABB platform,
      27              : //                dynamic AABB box tower, dynamic circle particles,
      28              : //                dynamic circle chain links + heavy pendulum ball
      29              : //   Joints     — DistanceJointComponent rigid chain (compliance = 0)
      30              : //   Custom sys — WindGustSystem: custom ISystem, alternating horizontal
      31              : //                impulse every kWindPeriod seconds
      32              : //   JobSystem  — m_jobs owned by scenario, passed to PhysicsSystem
      33              : //   ASCII      — TextRenderer with all 8 Color values,
      34              : //                DrawRect / DrawLine / DrawCircle / DrawEllipse /
      35              : //                FillEllipse / Put
      36              : //   Logger     — core::Logger for setup-time diagnostic messages
      37              : 
      38              : #include "simlab/Scenario.hpp"
      39              : #include "ecs/World.hpp"
      40              : #include "physics/Components.hpp"
      41              : #include "physics/Systems.hpp"
      42              : #include "jobs/JobSystem.hpp"
      43              : #include "ascii/TextRenderer.hpp"
      44              : #include "core/Logger.hpp"
      45              : 
      46              : #include <cmath>
      47              : #include <cstring>
      48              : #include <iostream>
      49              : #include <random>
      50              : #include <string>
      51              : #include <vector>
      52              : 
      53              : namespace simlab
      54              : {
      55              :     // =========================================================================
      56              :     //  WindGustSystem — custom ISystem
      57              :     //
      58              :     //  Applies a brief, alternating horizontal impulse to every dynamic body
      59              :     //  once every kWindPeriod seconds.  Demonstrates:
      60              :     //    - Subclassing ecs::ISystem
      61              :     //    - Time accumulation inside a system Update hook
      62              :     //    - world.ForEach<T> on RigidBodyComponent
      63              :     // =========================================================================
      64              :     namespace
      65              :     {
      66              :         class WindGustSystem : public ecs::ISystem
      67              :         {
      68              :         public:
      69          540 :             void Update(ecs::World& world, float dt) override
      70              :             {
      71          540 :                 m_elapsed += dt;
      72          540 :                 if (m_elapsed < kWindPeriod)
      73          540 :                     return;
      74              : 
      75            0 :                 m_elapsed  = 0.0f;
      76            0 :                 m_gustDir  = -m_gustDir;                           // alternate direction
      77            0 :                 const float impulse = m_gustDir * kWindImpulse;
      78              : 
      79            0 :                 world.ForEach<physics::RigidBodyComponent>(
      80            0 :                     [&](ecs::EntityId, physics::RigidBodyComponent& rb)
      81              :                     {
      82            0 :                         if (rb.invMass > 0.0f)
      83            0 :                             rb.vx += impulse;
      84            0 :                     });
      85              :             }
      86              : 
      87              :         private:
      88              :             float m_elapsed  {0.0f};
      89              :             float m_gustDir  {1.0f};
      90              : 
      91              :             static constexpr float kWindPeriod {6.0f};   // seconds between gusts
      92              :             static constexpr float kWindImpulse{3.0f};   // m/s applied per gust
      93              :         };
      94              :     } // anonymous namespace
      95              : 
      96              : 
      97              :     // =========================================================================
      98              :     //  FullDemoScenario
      99              :     // =========================================================================
     100              :     class FullDemoScenario : public IScenario
     101              :     {
     102              :     public:
     103              : 
     104              :         // ---------------------------------------------------------------------
     105              :         //  Setup
     106              :         // ---------------------------------------------------------------------
     107            3 :         void Setup(ecs::World& world) override
     108              :         {
     109            3 :             m_renderer = std::make_unique<ascii::TextRenderer>(kW, kH);
     110              : 
     111              :             // ---- Logger (instance-based) ------------------------------------
     112            3 :             core::Logger log;
     113            3 :             log.Info("FullDemoScenario: initialising");
     114              : 
     115              :             // ---- Physics system + JobSystem + PhysicsSettings ---------------
     116            3 :             physics::EnvironmentForces env;
     117            3 :             env.gravityY = -9.81f;
     118            3 :             env.drag     =  0.02f;    // air resistance — damps pendulum to rest
     119              : 
     120            3 :             auto phys = std::make_unique<physics::PhysicsSystem>();
     121            3 :             physics::PhysicsSettings cfg;
     122            3 :             cfg.substeps             = 16;  // high stability
     123            3 :             cfg.constraintIterations = 16;  // stable rigid chain
     124            3 :             cfg.positionIterations   = 20;
     125            3 :             phys->SetSettings(cfg);
     126            3 :             phys->SetEnvironment(env);
     127            3 :             phys->SetJobSystem(&m_jobs);    // pass owned JobSystem
     128            3 :             world.AddSystem(std::move(phys));
     129              : 
     130              :             // ---- Custom system (added after physics so it runs each step) ---
     131            3 :             world.AddSystem(std::make_unique<WindGustSystem>());
     132              : 
     133              :             // ---- Static arena walls (thick to prevent tunnelling) -----------
     134              :             //  Inner bounds: X ∈ [kLeftX, kRightX], Y ∈ [kFloorY, kArenaTop]
     135            3 :             MakeWall(world,               0.0f, kFloorY   - 50.0f,  80.0f, 100.0f); // floor
     136            3 :             MakeWall(world, kLeftX  - 50.0f,  0.0f,  100.0f,  40.0f); // left
     137            3 :             MakeWall(world, kRightX + 50.0f,  0.0f,  100.0f,  40.0f); // right
     138              : 
     139              :             // ---- Tower platform (right of centre, surface Y = 0) -----------
     140              :             //  Raised shelf for the box tower so the pendulum can reach it.
     141            3 :             MakeWall(world, 2.0f, -1.0f, 8.0f, 2.0f);
     142              : 
     143              :             // ---- Pendulum chain + heavy wrecking ball -----------------------
     144              :             //  Anchor at (kAnchorX, kAnchorY) — static, attached to ceiling.
     145              :             //  kChainCount links placed at 45° SW so the chain falls clockwise
     146              :             //  (rightward) into the box tower on the right side.
     147              :             //  Each inter-link step is (−1.41, −1.41): dist = √(1.41²+1.41²) ≈ 2.0
     148              :             //  which matches kLinkDist, so the chain starts nearly taut.
     149            3 :             auto anchor = world.CreateEntity();
     150            3 :             world.AddComponent<physics::TransformComponent>(anchor, kAnchorX, kAnchorY, 0.0f);
     151              :             {
     152            3 :                 auto& ab = world.AddComponent<physics::RigidBodyComponent>(anchor);
     153            3 :                 ab.mass = 0.0f; ab.invMass = 0.0f;
     154              :             }
     155            3 :             m_anchorId = anchor;
     156              : 
     157            3 :             ecs::EntityId prev = anchor;
     158           15 :             for (int i = 0; i < kChainCount; ++i)
     159              :             {
     160           12 :                 const float lx = kAnchorX - static_cast<float>(i + 1) * 1.41f;
     161           12 :                 const float ly = kAnchorY - static_cast<float>(i + 1) * 1.41f;
     162              : 
     163           12 :                 auto link = world.CreateEntity();
     164           12 :                 world.AddComponent<physics::TransformComponent>(link, lx, ly, 0.0f);
     165           12 :                 auto& lb = world.AddComponent<physics::RigidBodyComponent>(link);
     166              : 
     167           12 :                 const bool isBall = (i == kChainCount - 1);
     168           12 :                 if (isBall)
     169              :                 {
     170              :                     // Heavy wrecking ball — large mass, circle collider
     171            3 :                     lb.mass        = 25.0f;
     172            3 :                     lb.invMass     = 1.0f / 25.0f;
     173            3 :                     lb.restitution = 0.4f;
     174            3 :                     lb.friction    = 0.3f;
     175            3 :                     lb.angularDrag = 0.15f;
     176            3 :                     world.AddComponent<physics::CircleColliderComponent>(link, kBallR);
     177            3 :                     physics::ConfigureCircleInertia(lb, kBallR);
     178            3 :                     m_ballId = link;
     179              :                 }
     180              :                 else
     181              :                 {
     182              :                     // Small chain link
     183            9 :                     lb.mass        = 0.6f;
     184            9 :                     lb.invMass     = 1.0f / 0.6f;
     185            9 :                     lb.restitution = 0.1f;
     186            9 :                     world.AddComponent<physics::CircleColliderComponent>(link, 0.22f);
     187            9 :                     physics::ConfigureCircleInertia(lb, 0.22f);
     188              :                 }
     189              : 
     190              :                 // Distance joint connecting this link to the previous one
     191           12 :                 auto& jc         = world.AddComponent<physics::DistanceJointComponent>(link);
     192           12 :                 jc.entityA       = prev;
     193           12 :                 jc.entityB       = link;
     194           12 :                 jc.targetDistance = kLinkDist;
     195           12 :                 jc.compliance    = 0.0f;  // fully rigid
     196              : 
     197           12 :                 m_chainIds.push_back(link);
     198           12 :                 prev = link;
     199              :             }
     200              : 
     201              :             // ---- Tower of dynamic AABB boxes (on raised platform) -----------
     202              :             //  kTowerCols columns × kTowerRows rows, sitting on the tower platform.
     203            3 :             log.Info("FullDemoScenario: building box tower");
     204           12 :             for (int row = 0; row < kTowerRows; ++row)
     205              :             {
     206           45 :                 for (int col = 0; col < kTowerCols; ++col)
     207              :                 {
     208           36 :                     const float bx = kTowerLeft  + (static_cast<float>(col) + 0.5f) * kBoxSize;
     209           36 :                     const float by = kTowerBaseY + (static_cast<float>(row) + 0.5f) * kBoxSize;
     210              : 
     211           36 :                     auto box = world.CreateEntity();
     212           36 :                     world.AddComponent<physics::TransformComponent>(box, bx, by, 0.0f);
     213           36 :                     auto& bb = world.AddComponent<physics::RigidBodyComponent>(box);
     214           36 :                     bb.mass        = 1.5f;
     215           36 :                     bb.invMass     = 1.0f / 1.5f;
     216           36 :                     bb.friction    = 0.7f;
     217           36 :                     bb.restitution = 0.1f;
     218           36 :                     bb.angularDrag = 0.15f;
     219           36 :                     world.AddComponent<physics::AABBComponent>(
     220              :                         box,
     221            0 :                         bx - kBoxSize * 0.5f, by - kBoxSize * 0.5f,
     222           36 :                         bx + kBoxSize * 0.5f, by + kBoxSize * 0.5f);
     223           36 :                     physics::ConfigureBoxInertia(bb, kBoxSize, kBoxSize);
     224           36 :                     m_boxIds.push_back(box);
     225              :                 }
     226              :             }
     227              : 
     228              :             // ---- Bouncing circle particles (left half of arena) ------------
     229              :             //  Randomised starting positions and upward velocities.
     230              :             //  Demonstrates high-entity-count circle collisions.
     231            3 :             log.Info("FullDemoScenario: spawning particles");
     232            3 :             std::mt19937 rng{2025u};
     233            3 :             std::uniform_real_distribution<float> rndX{kLeftX + 1.0f, -2.0f};
     234            3 :             std::uniform_real_distribution<float> rndY{kFloorY + 0.5f,  3.0f};
     235            3 :             std::uniform_real_distribution<float> rndVx{-4.0f,  4.0f};
     236            3 :             std::uniform_real_distribution<float> rndVy{ 2.0f, 10.0f};
     237              : 
     238           93 :             for (int i = 0; i < kParticles; ++i)
     239              :             {
     240           90 :                 const float px = rndX(rng), py = rndY(rng);
     241           90 :                 auto p = world.CreateEntity();
     242           90 :                 world.AddComponent<physics::TransformComponent>(p, px, py, 0.0f);
     243           90 :                 auto& pb = world.AddComponent<physics::RigidBodyComponent>(p);
     244           90 :                 pb.mass        = 0.12f;
     245           90 :                 pb.invMass     = 1.0f / 0.12f;
     246           90 :                 pb.restitution = 0.80f;
     247           90 :                 pb.friction    = 0.05f;
     248           90 :                 pb.vx          = rndVx(rng);
     249           90 :                 pb.vy          = rndVy(rng);
     250           90 :                 world.AddComponent<physics::CircleColliderComponent>(p, 0.30f);
     251           90 :                 physics::ConfigureCircleInertia(pb, 0.30f);
     252           90 :                 m_partIds.push_back(p);
     253              :             }
     254              : 
     255              :             // ---- GetStorage demo — log total RigidBody count after setup ----
     256            3 :             if (auto* rbs = world.GetStorage<physics::RigidBodyComponent>())
     257              :             {
     258            3 :                 log.Info("FullDemoScenario: total RigidBody entities = "
     259            6 :                          + std::to_string(rbs->Size()));
     260              :             }
     261            3 :         }
     262              : 
     263              :         // ---------------------------------------------------------------------
     264              :         //  Update — scenario logic hook only.
     265              :         //  The engine owns world.Update(); calling it here would violate the
     266              :         //  IScenario contract tested by scenario_update_contract_tests.
     267              :         // ---------------------------------------------------------------------
     268          540 :         void Update(ecs::World& /*world*/, float /*dt*/) override {}
     269              : 
     270              :         // ---------------------------------------------------------------------
     271              :         //  Render — full ASCII showcase using every drawing primitive and all
     272              :         //  eight Color values.
     273              :         // ---------------------------------------------------------------------
     274          180 :         void Render(ecs::World& world, std::ostream& out) override
     275              :         {
     276          180 :             m_renderer->Clear();
     277              : 
     278              :             // ---- Arena border: DrawRect (White '+') -------------------------
     279              :             //  Maps inner bounds [kLeftX..kRightX] × [kFloorY..kArenaTop]
     280              :             //  to screen rectangle.
     281          180 :             const int bx0 = toSX(kLeftX),    by0 = toSY(kArenaTop);
     282          180 :             const int bx1 = toSX(kRightX),   by1 = toSY(kFloorY);
     283          180 :             m_renderer->DrawRect(bx0, by0, bx1 - bx0, by1 - by0,
     284              :                                  '+', ascii::Color::White);
     285              : 
     286              :             // ---- Tower platform: DrawLine (Yellow '=') ---------------------
     287          180 :             m_renderer->DrawLine(toSX(-2.0f), toSY(0.0f),
     288              :                                  toSX( 6.0f), toSY(0.0f),
     289              :                                  '=', ascii::Color::Yellow);
     290              : 
     291              :             // ---- Tower boxes: View<TransformComponent, AABBComponent> ------
     292              :             //  Uses multi-component View to select only entities that have
     293              :             //  both a transform and an AABB.  Static bodies (invMass==0) are
     294              :             //  skipped so only the dynamic tower blocks are drawn.
     295          180 :             world.View<physics::TransformComponent, physics::AABBComponent>(
     296          180 :                 [&](ecs::EntityId id,
     297              :                     physics::TransformComponent& t,
     298              :                     physics::AABBComponent& /*aabb*/)
     299              :                 {
     300         2880 :                     auto* rb = world.GetComponent<physics::RigidBodyComponent>(id);
     301         2880 :                     if (!rb || rb->invMass == 0.0f) return;
     302         2160 :                     const int sx = toSX(t.x), sy = toSY(t.y);
     303         2160 :                     if (inBounds(sx, sy))
     304         2057 :                         m_renderer->Put(sx, sy, '#', ascii::Color::Cyan);
     305              :                 });
     306              : 
     307              :             // ---- Particles: iterate tracked IDs (Green '.') ----------------
     308         5580 :             for (ecs::EntityId pid : m_partIds)
     309              :             {
     310         5400 :                 if (auto* t = world.GetComponent<physics::TransformComponent>(pid))
     311              :                 {
     312         5400 :                     const int sx = toSX(t->x), sy = toSY(t->y);
     313         5400 :                     if (inBounds(sx, sy))
     314         4789 :                         m_renderer->Put(sx, sy, '.', ascii::Color::Green);
     315              :                 }
     316              :             }
     317              : 
     318              :             // ---- Chain segments: DrawLine (Yellow '-') ---------------------
     319              :             //  Draws a line between each consecutive pair of link transforms
     320              :             //  starting from the anchor.
     321              :             {
     322          180 :                 int px = toSX(kAnchorX), py = toSY(kAnchorY);
     323          900 :                 for (ecs::EntityId cid : m_chainIds)
     324              :                 {
     325          720 :                     if (auto* t = world.GetComponent<physics::TransformComponent>(cid))
     326              :                     {
     327          720 :                         const int nx = toSX(t->x), ny = toSY(t->y);
     328          720 :                         m_renderer->DrawLine(px, py, nx, ny, '-', ascii::Color::Yellow);
     329          720 :                         px = nx; py = ny;
     330              :                     }
     331              :                 }
     332              :             }
     333              : 
     334              :             // ---- Wrecking ball: DrawEllipse outer ring + FillEllipse body --
     335              :             //  DrawEllipse (Red ':') draws the impact halo one cell larger than
     336              :             //  the filled body, giving the ball a distinct outline.
     337          180 :             if (auto* bt = world.GetComponent<physics::TransformComponent>(m_ballId))
     338              :             {
     339          180 :                 const int bsx = toSX(bt->x), bsy = toSY(bt->y);
     340          180 :                 const int rx  = static_cast<int>(kBallR * kSX);  // = 3
     341          180 :                 const int ry  = static_cast<int>(kBallR * kSY);  // = 3
     342          180 :                 m_renderer->DrawEllipse(bsx, bsy, rx + 1, ry + 1, ':', ascii::Color::Red);
     343          180 :                 m_renderer->FillEllipse (bsx, bsy, rx,     ry,     'O', ascii::Color::Red);
     344              :             }
     345              : 
     346              :             // ---- Anchor: DrawCircle pivot ring (Blue) + Put 'X' (Magenta) --
     347              :             //  DrawCircle draws a small pivot indicator around the anchor point.
     348          180 :             const int asx = toSX(kAnchorX), asy = toSY(kAnchorY);
     349          180 :             m_renderer->DrawCircle(asx, asy, 2, 'o', ascii::Color::Blue);
     350          180 :             m_renderer->Put(asx, asy, 'X', ascii::Color::Magenta);
     351              : 
     352              :             // ---- Title bar (Magenta, row 0) ---------------------------------
     353              :             static constexpr const char* kTitle = " ATLASCORE FULL DEMO ";
     354          180 :             const int tx = (kW - static_cast<int>(std::strlen(kTitle))) / 2;
     355         3960 :             for (int i = 0; kTitle[i] != '\0'; ++i)
     356         3780 :                 m_renderer->Put(tx + i, 0, kTitle[i], ascii::Color::Magenta);
     357              : 
     358              :             // ---- Feature legend (White, bottom two rows) -------------------
     359              :             static constexpr const char* kLegend[2] =
     360              :             {
     361              :                 "ECS|JOBS|PHYSICS|JOINTS",
     362              :                 "ASCII|CUSTOM-SYS|ALL-8-CLR",
     363              :             };
     364          540 :             for (int r = 0; r < 2; ++r)
     365              :             {
     366          360 :                 const int lx = kW - static_cast<int>(std::strlen(kLegend[r])) - 1;
     367          360 :                 const int ly = kH - 2 + r;
     368         9180 :                 for (int i = 0; kLegend[r][i] != '\0'; ++i)
     369         8820 :                     m_renderer->Put(lx + i, ly, kLegend[r][i], ascii::Color::White);
     370              :             }
     371              : 
     372          180 :             m_renderer->SetHeadless(simlab::IsHeadlessRendering());
     373          180 :             m_renderer->PresentDiff(out);
     374          180 :         }
     375              : 
     376              :     private:
     377              : 
     378              :         // ---- World / screen constants ---------------------------------------
     379              :         static constexpr float kFloorY   = -9.5f;
     380              :         static constexpr float kLeftX    = -19.0f;
     381              :         static constexpr float kRightX   =  19.0f;
     382              :         static constexpr float kArenaTop =   9.5f;
     383              : 
     384              :         static constexpr int   kW  = 80;
     385              :         static constexpr int   kH  = 40;
     386              :         static constexpr float kSX = 2.0f;   // screen units per world unit (X)
     387              :         static constexpr float kSY = 2.0f;   // screen units per world unit (Y)
     388              : 
     389              :         // ---- Chain / ball ---------------------------------------------------
     390              :         static constexpr float kAnchorX    = -5.0f;
     391              :         static constexpr float kAnchorY    =  9.0f;
     392              :         static constexpr float kLinkDist   =  2.0f;
     393              :         static constexpr float kBallR      =  1.5f;
     394              :         static constexpr int   kChainCount =  4;    // 3 small links + 1 ball
     395              : 
     396              :         // ---- Tower ----------------------------------------------------------
     397              :         static constexpr float kBoxSize     =  1.4f;
     398              :         static constexpr float kTowerLeft   = -1.0f;
     399              :         static constexpr float kTowerBaseY  =  0.0f;   // top of tower platform
     400              :         static constexpr int   kTowerCols   =  4;
     401              :         static constexpr int   kTowerRows   =  3;
     402              : 
     403              :         // ---- Particles ------------------------------------------------------
     404              :         static constexpr int   kParticles  = 30;
     405              : 
     406              :         // ---- Coordinate mapping helpers ------------------------------------
     407              :         //  World X ∈ [−20, 20] → screen X ∈ [0, 80]
     408              :         //  World Y ∈ [−10, 10] → screen Y ∈ [40, 0]  (Y-axis flipped)
     409         9540 :         int toSX(float wx) const
     410              :         {
     411         9540 :             return static_cast<int>((wx + 20.0f) * kSX);
     412              :         }
     413         9540 :         int toSY(float wy) const
     414              :         {
     415         9540 :             return static_cast<int>((10.0f - wy) * kSY);
     416              :         }
     417         7560 :         bool inBounds(int sx, int sy) const
     418              :         {
     419         7560 :             return sx >= 0 && sx < kW && sy >= 0 && sy < kH;
     420              :         }
     421              : 
     422              :         // ---- Entity factory: static thick-walled AABB ----------------------
     423           12 :         static void MakeWall(ecs::World& world,
     424              :                               float cx, float cy, float w, float h)
     425              :         {
     426           12 :             auto e = world.CreateEntity();
     427           12 :             world.AddComponent<physics::TransformComponent>(e, cx, cy, 0.0f);
     428           12 :             auto& b = world.AddComponent<physics::RigidBodyComponent>(e);
     429           12 :             b.mass = 0.0f; b.invMass = 0.0f;
     430           12 :             world.AddComponent<physics::AABBComponent>(
     431              :                 e,
     432            0 :                 cx - w * 0.5f, cy - h * 0.5f,
     433           12 :                 cx + w * 0.5f, cy + h * 0.5f);
     434           12 :         }
     435              : 
     436              :         // ---- Owned resources -----------------------------------------------
     437              :         std::unique_ptr<ascii::TextRenderer> m_renderer;
     438              :         jobs::JobSystem                       m_jobs;
     439              : 
     440              :         // ---- Entity handles ------------------------------------------------
     441              :         ecs::EntityId              m_anchorId{0};
     442              :         ecs::EntityId              m_ballId  {0};
     443              :         std::vector<ecs::EntityId> m_chainIds;
     444              :         std::vector<ecs::EntityId> m_boxIds;
     445              :         std::vector<ecs::EntityId> m_partIds;
     446              :     };
     447              : 
     448              :     // -------------------------------------------------------------------------
     449              :     //  Factory function — registered in ScenarioRegistry
     450              :     // -------------------------------------------------------------------------
     451            4 :     std::unique_ptr<IScenario> CreateFullDemoScenario()
     452              :     {
     453            4 :         return std::make_unique<FullDemoScenario>();
     454              :     }
     455              : 
     456              : } // namespace simlab
        

Generated by: LCOV version 2.0-1