From 141bfd31c10d601e26b357cf822119800687a86f Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Fri, 30 Oct 2026 18:24:30 +0300 Subject: [PATCH] Phase 7 checkpoint --- src/features/editScene/AGENTS.md | 78 ++++ .../editScene/components/CellGrid.cpp | 2 + .../editScene/components/CellGrid.hpp | 17 +- .../editScene/demos/demo-taxi/PLAN.md | 192 +++++---- .../editScene/demos/demo-taxi/demo_main.cpp | 311 ++++++++++++++ .../editScene/layoutlib/BuildingPlan.hpp | 2 + .../editScene/layoutlib/LayoutEval.cpp | 11 + .../editScene/lua/LuaComponentApi.cpp | 5 + .../editScene/systems/CellGridSystem.cpp | 386 +++++++++++++++++- .../editScene/systems/CellGridSystem.hpp | 11 + .../editScene/systems/RoomLayoutSystem.cpp | 12 + .../editScene/systems/SceneSerializer.cpp | 5 + .../editScene/systems/TerrainTests.cpp | 306 ++++++++++++++ .../editScene/systems/TerrainTests.hpp | 1 + .../editScene/systems/TownGenSystem.cpp | 4 + .../editScene/tests/cellgrid_door_test.cpp | 21 + .../editScene/tests/component_lua_test.cpp | 7 +- .../tests/road_geometry_overlap_test.cpp | 64 +++ src/features/editScene/ui/CellGridEditor.cpp | 3 + 19 files changed, 1361 insertions(+), 77 deletions(-) diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index f5c8bc3..fcf57d7 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -302,6 +302,12 @@ cd demos/demo-taxi # work_counter/repair_vehicle hooks latch through # ActuatorSystem::executeAction (the headless E-press entry point): ./demoTaxi --headless --test-furniture +# ...or headless phase 7.2 CellGrid stairs acceptance: on the flat +# vehicletest floor, builds a two-storey building with a straight +# stair run, walks a real Jolt character capsule up it through +# CharacterSystem, then checks Recast finds a ground->top path only +# in the stair building: +./demoTaxi --headless --test-stairs demo_scene_vehicletest.json # ...or headless check of the NPC motion core (phase 4.7.5): sweeps the # game clock over a full day at a slowed walk speed, asserting NPCs enter # IN_TRANSIT, that positions move across the day and that re-visiting an @@ -3064,6 +3070,78 @@ editor) selects which parts `buildCellGrid()` generates: - Skipped parts have empty buffers, so they get no meshes and no physics colliders (colliders follow the mesh lists). +#### Stairs (phase 7.1) + +`CellFlags::StairWell` (bit 26, mirrored as `PlanFlags::StairWell` with a +static_assert in `TownGenSystem.cpp`; the Cell Grid editor's per-cell flag +list shows it as "Stairwell" through `getAllFlags()`) marks a cell that +hosts a staircase from its own level up to the next. The generator: + +- Cuts the openings: a stair cell's own `Ceiling` plane is skipped and the + `Floor` plane of the cell directly above is skipped (implementation rule: + a cell's floor is cut when the cell below is a stair cell). Flags only + take effect when `y + 1 < grid.height`; stair flags on the top floor are + ignored with one `CellGrid:` warning per build. +- Builds the flights in `buildStairs` (called from `buildCellGrid` after + the internal corners, before UV mapping): a straight run of flagged + cells along one axis becomes one flight rising toward +X / +Z with the + bottom at the run's low-coordinate end; a lone cell becomes a dogleg + (flight A on the -X band rising +Z, mid landing across the +Z end, + flight B on the +X band rising -Z and topping out flush with the -Z + rim, so a column stacks boarding/step-off on the same side). Every + step is a solid box from its flight's slab bottom to the tread top + (the `TownGenSystem::createStairs` construction); `cellHeight / + ceil(cellHeight / 0.18)` rise (0.174 m at 4 m floors, 23 steps; + the 0.18 cap is EMPIRICAL - 0.25 m risers block the Jolt character + capsule at the first tread), `STAIR_TREAD` 0.30 m (shrinks with a + warning on too-short runs), flight + width min(1.8 m, cell width - 2 x ring), 0.4 m gap between dogleg + flights. The flights go into `MeshData::stairMesh` with the regular + per-part mesh collider; UVs sample `stairRectName` (empty = + `floorRectName`). +- Closes the opening rims with a **slab-edge ring**: 0.15 m wide box + strips spanning exactly the hole level's ceiling-to-floor band + (`level*cellHeight - 0.3` .. `level*cellHeight + floorSurfaceY`), + appended to the internal wall buffer (wall rect + wall collider). The + ring hides the backface see-through of the cut plane edges and its + flush top face is the step-off ledge. Ring cells are skipped where + neither the stair cell's ceiling nor the destination cell's floor + exists (no slab edge to close). The shaft top is capped by the top + floor's ceiling/roof - no extra cap geometry. +- Serializes like everything else in the grid: the flag rides the raw + `uint64` per-cell `flags` (no schema change), `stairRectName` is a + normal optional rect name (old scenes default to empty). Grids without + stair flags generate exactly the old geometry. +- Authoring (phase 7.3): a `RoomComponent` with + `roomType == "stairwell"` flags its cells (`RoomLayoutSystem:: + processRoom`, idempotent for plan-derived rooms); the layout DSL's + `(room ... :type stairwell)` sets `PlanFlags::StairWell` in BOTH + rooms-first and filled-shell modes; the Cell Grid editor has a + "Stairs" texture-rect combo and the per-cell flag list shows + "Stairwell"; Lua exposes `stairRectName` on the `CellGrid` + component. A stairwell room on the top floor has no floor above - + the generator warns once and ignores those flags. + +Coverage: `tests/cellgrid_door_test.cpp` test 4 (flag + rect round trip, +old-scene defaults), the terrain step +`TERRAIN_TEST_FILTER=cellGridStairs` (`TerrainTests.cpp`): an 8 x 3 x 4 +grid with a two-cell straight run at level 0 and a single-cell dogleg at +level 1 - hole planes absent inside the footprints / present outside, +stair mesh spanning exactly ground to top, both ring bands present, a +stairs mesh collider, the no-flag twin grid unchanged, plus a +rooms-first DSL stairwell layout previewed through `TownGenSystem` that +must build stair geometry; `road_geometry_overlap_test` DSL probes +(rooms-first + filled-shell flag placement); +`component_lua_test` test 33 (`stairRectName` round trip). Traversal +acceptance is the demo probe `demoTaxi --headless --test-stairs +demo_scene_vehicletest.json` (phase 7.2): a bare `CharacterComponent` +capsule walks up a three-cell straight flight on the flat vehicletest +floor through the normal `CharacterSystem` path (the headless player +spawn has no physics capsule), and a Recast rebuild finds a ground->top +path only in the stair building. A 0.25 m riser variant was tried and +the capsule never mounted the first tread - keep generated risers at or +below 0.18 m. + #### Door identity & per-door configuration (F0) A doorway's identity is its **canonical edge key** (`"X:x:y:z"` / diff --git a/src/features/editScene/components/CellGrid.cpp b/src/features/editScene/components/CellGrid.cpp index 9b39958..630a618 100644 --- a/src/features/editScene/components/CellGrid.cpp +++ b/src/features/editScene/components/CellGrid.cpp @@ -163,6 +163,7 @@ const char* CellGridComponent::getFlagName(uint64_t flag) case CellFlags::IntWindowXPos: return "Int Window X+"; case CellFlags::IntWindowZPos: return "Int Window Z+"; case CellFlags::IntWindowZNeg: return "Int Window Z-"; + case CellFlags::StairWell: return "Stairwell"; default: return "Unknown"; } } @@ -207,5 +208,6 @@ std::vector> CellGridComponent::getAllFlags() { CellFlags::IntWindowXPos, "Int Window X+" }, { CellFlags::IntWindowZPos, "Int Window Z+" }, { CellFlags::IntWindowZNeg, "Int Window Z-" }, + { CellFlags::StairWell, "Stairwell" }, }; } diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp index 8d7dc55..df19f51 100644 --- a/src/features/editScene/components/CellGrid.hpp +++ b/src/features/editScene/components/CellGrid.hpp @@ -54,7 +54,17 @@ namespace CellFlags { constexpr uint64_t IntWindowXPos = 1ULL << 23; constexpr uint64_t IntWindowZPos = 1ULL << 24; constexpr uint64_t IntWindowZNeg = 1ULL << 25; - + + // Phase 7.1: a stair cell. A cell carrying this flag hosts a + // straight-flight or dogleg staircase rising from its own floor + // (level y) to the floor above (level y+1): the generator cuts + // the cell's own ceiling and the floor of the cell directly + // above, encloses the opening with a slab-edge ring and fills it + // with stepped boxes sized within the character's climb limits. + // A stairwell column is consecutive flagged cells at the same + // (x, z) on consecutive levels. + constexpr uint64_t StairWell = 1ULL << 26; + // Masks constexpr uint64_t AllExternalWalls = WallXNeg | WallXPos | WallZPos | WallZNeg; constexpr uint64_t AllInternalWalls = IntWallXNeg | IntWallXPos | IntWallZPos | IntWallZNeg; @@ -225,7 +235,10 @@ struct CellGridComponent { // Roof texture rectangles std::string roofTopRectName; std::string roofSideRectName; - + + // Phase 7.1: named rect for stair treads (empty = floorRectName) + std::string stairRectName; + // Door settings - one door entity is spawned per unique doorway // (deduplicated across the two cells sharing a door edge) on the same // condition as door frames (any of the 8 door cell flags). diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index b9ac844..df4b662 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -5795,7 +5795,7 @@ generator — **engine-level, not taxi-specific** — so that both the player (Jolt character) and NPCs (Recast navmesh) can travel between every floor. Phase 8's interior generator consumes the result. -**Status:** planned (decomposed 2026-10-30); nothing implemented yet. +**Status:** 7.1, 7.2 and 7.3 DONE (2026-10-30). 7.4-7.5 planned. **How to read this phase.** 7.1 is the vertical slice: one hand- authored grid, one stairwell, real geometry. 7.2 proves traversal. @@ -5838,86 +5838,125 @@ verification, and every slice keeps the global invariant: sits at `floorSurfaceY()` = 0.2 m above the cell base, so a floor-to-floor connection rises exactly `cellHeight`. -**Shared geometry contract (all slices):** +**Shared geometry contract (all slices, as built in 7.1):** -- A **stair connection** between level y and y+1 occupies one or more - cells at the same (x, z) on both levels: the generator clears - `Ceiling` at level y and `Floor` at level y+1 on those cells and - fills the opening with a stepped stair mesh plus walkable - colliders. A **stairwell column** chains connections upward through - consecutive floors at the same (x, z). -- Steps: tread 0.30 m, rise 0.18-0.25 m (exact value settled in 7.2), - flight width >= 1.2 m (twice the Recast agent radius plus margin). -- One 4 x 4 m cell fits a **dogleg** (two flights + mid-landing); - a **straight flight** needs two or more cells in a row. The - generator picks the layout from the stair cell run's shape. -- The space between floors around the opening is closed by geometry - (shaft sides and top cap): no visible void, no gaps at slab edges. - How the top cap interacts with the per-row-run roofs of cut - footprints is settled in 7.1. -- Stairs use the grid's procedural material like walls/floors: - treads default to the floor rect, shaft sides to the wall rect - (square UV mapping per surface), with a serialized `stairRectName` - override. +- A **stair connection** between level y and y+1 is hosted by a + `CellFlags::StairWell` cell at (x, y, z): the cell's own `Ceiling` + plane and the `Floor` plane of the cell directly above are removed, + and the opening is filled with a stepped stair mesh plus walkable + colliders. (Implementation rule: a cell's floor is cut when the + cell BELOW it is a stair cell.) A **stairwell column** chains + connections upward by flagging consecutive levels at the same + (x, z); the deepest valid flag is `grid.height - 2` (flags on the + top floor are ignored with a log warning). Opening footprints are + the flagged cells' XZ footprint one level up. +- Steps: tread 0.30 m, rise `cellHeight / ceil(cellHeight / 0.18)` + (0.174 m at the default 4 m floors = 23 steps), flight width + min(1.8 m, cell width - 0.30 m). The tread shrinks (with a warning) + when a run is too short. The 0.18 m rise cap is EMPIRICAL: 0.25 m + risers block the Jolt character capsule dead at the first tread + (phase 7.2); Recast's 1.0 m climb was never the limiting factor. +- A run of flagged cells along one axis becomes a **straight flight** + rising toward +X / +Z (bottom at the run's low-coordinate end, top + tread flush with the destination rim). A lone cell becomes a + **dogleg**: flight A on the -X band rising +Z, a mid landing across + the +Z end, flight B on the +X band rising -Z and topping out flush + with the -Z rim — so a column stacks boarding and step-off on the + same side at every level. +- The space between floors around each opening is closed by a + **slab-edge ring**: four box strips (0.15 m wide) spanning exactly + the ceiling-to-floor band of the hole level, emitted into the + internal wall buffer (wall rect + wall collider). The ring kills + the backface see-through of the cut planes and its flush top face + is the step-off ledge. The shaft top stays capped by the top + floor's ceiling/roof — no special cap geometry. +- Stairs use the grid's procedural material like walls/floors: the + stair mesh samples `stairRectName` (empty = `floorRectName`), + rings sample `intWallRectName` (square UV mapping per surface). -#### Phase 7.1 — Core stairwell cells (vertical slice) +#### Phase 7.1 — Core stairwell cells (vertical slice) — DONE Hand-authored grids only; no DSL, no RoomLayoutSystem yet. -- New `CellFlags::StairWell` bit (mirrored `PlanFlags` bit + static - assert). A stair cell marks "a stair passes through here"; the - column/run shape comes from the flagged cells themselves. -- `buildCellGrid` pass: cut the openings per the geometry contract, - emit straight-flight and dogleg stair meshes + shaft closure, add - them to the collider lists, apply the procedural-material UV - mapping. Serialized `stairRectName` field (empty = floor/wall - rect defaults). -- Serialization: flags ride the existing raw-uint64 cell format; - `stairRectName` round-trips like the other rect names. +As built (2026-10-30): -Verify: -- `cellgrid_door_test`-style case: flag + `stairRectName` round trip, - old scenes load with defaults. -- New terrain-tests step (`TERRAIN_TEST_FILTER=cellGridStairs`): - multi-floor grid with a stairwell builds openings, stair mesh and - colliders; a grid without stair flags is unchanged. +- `CellFlags::StairWell` = bit 26 (`components/CellGrid.hpp`), + mirrored as `PlanFlags::StairWell` + static assert; the Cell Grid + editor's per-cell flag list picks it up automatically through + `getAllFlags()` ("Stairwell"). +- `CellGridSystem::buildFloorsAndCeilings` cuts the planes; + `CellGridSystem::buildStairs` emits the flights into + `MeshData::stairMesh` (mesh collider via the regular per-part + collider list) and the rings into `intWallTb`. +- Serialization: the flag rides the raw `uint64` cell format (no + schema change); `stairRectName` round-trips like the other rect + names (old scenes default to empty = floor rect). -#### Phase 7.2 — Traversal acceptance (physics + navmesh) +Verified: +- `cellgrid_door_test` test 4 extended: flag + `stairRectName` round + trip, old scenes default. 15/15 PASS. +- New terrain step `TERRAIN_TEST_FILTER=cellGridStairs`: builds an + 8 x 3 x 4 grid with a two-cell straight run (level 0) and a + single-cell dogleg (level 1); asserts the level-1/level-2 floor and + level-0/level-1 ceiling planes are absent inside the hole + footprints and present outside, the stair mesh spans exactly + ground-to-top (0 .. 8.2), both slab bands have ring geometry, the + stairs have a mesh collider, and a twin grid without stair flags is + byte-for-byte the old geometry (576 floor + 576 ceiling vertices, + no stairs, no rings). PASS (full suite 43/43). -- Character physics: tune rise/tread inside the proven envelope so - the Jolt character climbs without snagging; verify headroom under - landings and at the top opening. -- Navmesh: stairs rasterize walkable (per the Recast limits above); - doorway-area costs and locked-door obstacles keep behaving (the - `navMeshDoors` step stays green). +#### Phase 7.2 — Traversal acceptance (physics + navmesh) — DONE -Verify: -- Headless: a character walks from the ground floor to the top floor - over the stairs (physics), and a navmesh query finds a - ground-to-top path. No-stair grids: no path between floors, as - today. +As built (2026-10-30): -#### Phase 7.3 — Authoring paths (rooms, DSL, editor, Lua) +- Rise settled empirically at 0.18 m (23 steps, 0.174 m rise at 4 m + floors). A 0.25 m riser stopped the Jolt capsule dead at the first + tread; 0.174 climbs smoothly with the supporting volume. +- New demo gate `demoTaxi --headless --test-stairs + demo_scene_vehicletest.json` (flat floor scene): + - builds the 6 x 2 x 3 two-storey grid with a three-cell straight + run, lets the colliders materialize, then walks a bare + `CharacterComponent` capsule at 1.5 m/s toward +X through the + normal `CharacterSystem` path. (The headless player spawn has no + physics capsule - a bare registry is provided in headless mode - + so NPC-tier capsule machinery is the honest walking path.) + - PASS when the capsule reaches the upper floor (node Y > 3.9, + observed 3.90 at ~1566 frames), then Recast-rebuilds the scene + with a stairless twin grid: a ground->top `findPath` succeeds in + the stair building (endpoint at the upper target, path max Y > + 3.0) and does NOT connect the stairless twin's floors. +- Doors unchanged: the `navMeshDoors` / `navMeshFurniture` / + `townPlanRooms` terrain steps stay green. -- **RoomLayoutSystem:** a room of type `stairwell` marks its cells; - single-floor stairwells connect a room to the level directly above - it (floors/ceilings removed for the stair to pass, per the shared - contract). A stairwell room column stacks through all floors it - spans. `processRoom` must not re-stamp `Floor`/`Ceiling` over the - openings it just cut. -- **Layout DSL:** `(room ... :type stairwell)` records the same - cells in `BuildingPlan` (rooms-first claims skip the opening - flags; filled-shell mode stays metadata + flags). `fillBuildingCells` - needs no change — flags flow through verbatim. -- **Editor:** Cell Grid editor per-cell stair toggle (next to the - door/window toggles) plus the `stairRectName` widget. -- **Lua:** stair flag/rect exposed per the component rules. -- Docs (`editScene/AGENTS.md`, this file) updated with the slice. +#### Phase 7.3 — Authoring paths (rooms, DSL, editor, Lua) — DONE -Verify: -- DSL grammar probes in `road_geometry_overlap_test`; Lua round trip - in `component_lua_test`; a DSL-authored two-storey stairwell - building passes the 7.1/7.2 checks. +As built (2026-10-30): + +- **RoomLayoutSystem:** a room with `roomType == "stairwell"` gets + `CellFlags::StairWell` stamped on its cells (idempotent for + plan-derived rooms, which already carry the flag). The generator + cuts the room's own ceiling and the floor of the cell above, so a + node-based stairwell room connects to the room on the next level; + a column of stairwell rooms stacks through all floors. +- **Layout DSL:** `(room ... :type stairwell)` sets + `PlanFlags::StairWell` on the claimed cells in BOTH shell modes + (rooms-first claim and the filled-shell room loop); the flags flow + into the grid verbatim through `fillBuildingCells`. +- **Editor:** the Cell Grid editor's texture-rect panel gained a + "Stairs" rect combo (`stairRectName`); the per-cell flag list shows + "Stairwell" automatically through `getAllFlags()`. +- **Lua:** `stairRectName` get/set on the `CellGrid` component + (`component_lua_test` test 33). + +Verified: +- `road_geometry_overlap_test`: rooms-first and filled-shell DSL + probes assert the flag lands on exactly the stairwell room cells + (and not on the neighbor room / the floor above). +- `TERRAIN_TEST_FILTER=cellGridStairs` now also previews a + two-storey rooms-first DSL layout with a stairwell room through the + real `TownGenSystem::previewLayout` path and asserts the building + built stair geometry. +- `component_lua_test` 67/67, `cellgrid_door_test` 15/15. #### Phase 7.4 — Exterior stairs (motel-style) @@ -5949,7 +5988,20 @@ rules for where stairwells go (layouts decide; default layouts get stairwells when Phase 8 makes upper floors enterable). ### Phase 8 — Enterable buildings (on-the-fly interiors) - +- Interior depends on previous steps, most notably furniture and stairs. +- Additionnally interior depends on beinc capable of creating furniture pieces with actionable items. + - using furniture prefabs, where with the help of editor we create scene, add one or more entities with meshes, add physics colliders, other objects. The instructions should be provided for the one who will do it. With constraints and a way to create multiple cell furniture. + - using Blender. The instructions should be provided for the one who will do it. With constraints and a way to create multiple cell furniture, and how to properly fill custom properties. + - while documenting, separate cases should looked upon: + - normal furniture, like beds, tables + - ceiling furniture (lights, simple fixtures), having no collider + - floor furniture, like carpets/rags, having no collider + - wall-only furniture, like pictures, wall rags, ornaments, having no collider too. +- with automatic furniture placement there are simple constraints: + - there should always be a path from a door to each normal furniture object, so NPCs should never be trapped in the rooms or be unable to get to a piece of furniture. + - placement algorithm should be fast and simple, but produce realistic enough placement, current algorithm is rudimentary. + - it would be best, but not absolutely required, to have straight path theough the room where there are doors across the rooms. + - the iterior should have its state and remember it - Interior scene generator (new, engine-level): given a building's exterior parameters (footprint, floors, template, seed), generate the matching interior scene deterministically — same building, same diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index e6f22b4..faf5e1e 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -22,6 +22,8 @@ #include "systems/EventBus.hpp" #include "systems/GlobalStateStore.hpp" #include "systems/CellGridSystem.hpp" +#include "systems/NavMeshSystem.hpp" +#include "components/NavMesh.hpp" #include "systems/ActuatorSystem.hpp" #include "components/CellGrid.hpp" #include "components/Actuator.hpp" @@ -4570,6 +4572,284 @@ static void setAiRoute(AiDriverComponent &ai, const Ogre::Vector3 *points, } } +/* + * --test-stairs (phase 7.2): traversal acceptance for CellGrid stairs. + * + * Runs on the flat demo_scene_vehicletest.json floor. Builds a + * two-storey CellGrid building with a three-cell straight stair run, + * lets the physics colliders materialize, then walks a real Jolt + * character capsule up the stairs (a bare CharacterComponent entity + * driven by CharacterSystem - the headless player spawn has no + * physics capsule). PASS when the capsule reaches the upper floor + * surface (node Y > 3.9). Then builds a stairless twin grid and + * checks Recast: a ground->top path exists only in the stair + * building (the stairless one keeps its floors unconnected). + * + * Run: ./demoTaxi --headless --test-stairs demo_scene_vehicletest.json + */ +struct StairsTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + + /* Cells: 6 x 2 x 3 at 4 m cells / 4 m floors. Stair run: + * level-0 cells x=2..4, z=1 (straight flight rising +X, bottom at + * local x = 6, top exits at local x = 18 onto cell x=5's floor). */ + static const int GRID_W = 6; + static const int GRID_H = 2; + static const int GRID_D = 3; + static constexpr float CELL_SIZE = 4.0f; + static constexpr float CELL_HEIGHT = 4.0f; + + Ogre::Vector3 stairOrigin = Ogre::Vector3(0, 0, 20); + Ogre::Vector3 plainOrigin = Ogre::Vector3(40, 0, 20); + Ogre::SceneNode *stairNode = nullptr; + Ogre::SceneNode *plainNode = nullptr; + Ogre::SceneNode *walkerNode = nullptr; + flecs::entity stairGrid; + flecs::entity plainGrid; + flecs::entity walker; + flecs::entity navE; + bool navBuilt = false; + int buildFrame = -1; + int walkFrame = -1; + + StairsTestListener(EditorApp *a) + : app(a) + { + } + + void fail(const std::string &why) + { + if (failed) + return; + failed = true; + failReason = why; + std::cerr << "[test] stairs FAIL: " << why << std::endl; + app->getRoot()->queueEndRendering(); + } + + flecs::entity buildGrid(const char *name, const Ogre::Vector3 &origin, + bool withStairs, Ogre::SceneNode *&nodeOut) + { + flecs::world *w = app->getWorld(); + Ogre::SceneNode *node = app->getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(name); + node->setPosition(origin); + nodeOut = node; + + flecs::entity e = w->entity(); + TransformComponent t; + t.node = node; + t.position = origin; + t.rotation = Ogre::Quaternion::IDENTITY; + t.scale = Ogre::Vector3::UNIT_SCALE; + e.set(t); + + CellGridComponent grid; + grid.gridUid = name; + grid.width = GRID_W; + grid.height = GRID_H; + grid.depth = GRID_D; + grid.cellSize = CELL_SIZE; + grid.cellHeight = CELL_HEIGHT; + for (int y = 0; y < grid.height; ++y) + for (int z = 0; z < grid.depth; ++z) + for (int x = 0; x < grid.width; ++x) { + Cell &c = grid.getOrCreateCell(x, y, z); + c.flags = CellFlags::Floor | + CellFlags::Ceiling; + } + if (withStairs) + for (int x = 2; x <= 4; ++x) + grid.getOrCreateCell(x, 0, 1) + .setFlag(CellFlags::StairWell); + e.set(grid); + return e; + } + + /* Bare capsule character walked by CharacterSystem (the same + * machinery the NPC tiers use; the headless player spawn has no + * physics capsule). */ + flecs::entity buildWalker(const Ogre::Vector3 &pos) + { + flecs::world *w = app->getWorld(); + Ogre::SceneNode *node = app->getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode( + "StairsTestWalker"); + node->setPosition(pos); + walkerNode = node; + flecs::entity e = w->entity(); + TransformComponent t; + t.node = node; + t.position = pos; + t.rotation = Ogre::Quaternion::IDENTITY; + t.scale = Ogre::Vector3::UNIT_SCALE; + e.set(t); + CharacterComponent cc; + cc.dirty = true; + e.set(cc); + return e; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 3) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 4000) { + fail("timeout in phase " + std::to_string(phase)); + return true; + } + + switch (phase) { + case 0: { + /* Wait for gameplay, then build the stair building. */ + if (!editScene::isGamePlaying()) + return true; + if (buildFrame < 0) { + stairGrid = buildGrid("StairsTestGrid", + stairOrigin, true, + stairNode); + buildFrame = frame; + } + /* Let the cell-grid physics colliders materialize. */ + if (frame - buildFrame < 40) + return true; + /* Spawn the walker on the ground floor inside the + * run's z-row and walk it toward +X. */ + const Ogre::Vector3 start = + stairOrigin + Ogre::Vector3(0.0f, 0.35f, + CELL_SIZE); + walker = buildWalker(start); + walkFrame = frame; + phase = 1; + std::cout << "[test] stairs: walking up from " + << start << std::endl; + return true; + } + case 1: { + /* Drive the capsule toward +X; PASS the moment it + * stands on the upper floor. */ + if (!walker.is_alive() || !walkerNode) + return true; + CharacterComponent &cc = + walker.get_mut(); + cc.linearVelocity = Ogre::Vector3(1.5f, 0.0f, 0.0f); + const Ogre::Vector3 p = walkerNode->getPosition(); + const float localX = p.x - stairOrigin.x; + if (p.y > 3.9f) { + cc.linearVelocity = Ogre::Vector3::ZERO; + std::cout << "[test] stairs: reached upper floor " + "at " << p << " (" + << (frame - walkFrame) + << " frames)" << std::endl; + phase = 2; + return true; + } + if (localX > 26.0f) { + cc.linearVelocity = Ogre::Vector3::ZERO; + fail("walker passed the building without " + "climbing (Y " + + Ogre::StringConverter::toString(p.y) + + ")"); + } + return true; + } + case 2: { + /* Navmesh: ground->top path in the stair building, + * none in the stairless twin. */ + if (buildFrame >= 0 && plainGrid.id() == 0) { + plainGrid = buildGrid("StairsTestPlainGrid", + plainOrigin, false, + plainNode); + return true; + } + NavMeshSystem *navSys = app->getNavMeshSystem(); + if (!navSys) { + fail("no NavMeshSystem"); + return true; + } + if (!navBuilt) { + navE = app->getWorld()->entity(); + NavMeshComponent nm; + nm.cellSize = 0.3f; + nm.cellHeight = 0.2f; + nm.agentHeight = 2.0f; + nm.agentRadius = 0.4f; + nm.agentMaxClimb = 1.0f; + nm.agentMaxSlope = 20.0f; + nm.tileSize = 48; + navE.set(nm); + navSys->rebuild(navE); + navBuilt = true; + return true; + } + + std::vector path; + const Ogre::Vector3 groundStart = + stairOrigin + Ogre::Vector3(0.0f, 0.7f, + CELL_SIZE); + const Ogre::Vector3 upperTarget = + stairOrigin + + Ogre::Vector3(4.0f * CELL_SIZE + CELL_SIZE * 0.5f, + CELL_HEIGHT + 0.7f, + CELL_SIZE); + bool found = + navSys->findPath(navE, groundStart, + upperTarget, path); + if (!found || path.empty() || + (path.back() - upperTarget).length() > 1.5f) { + fail("no navmesh path from the ground floor " + "to the upper floor"); + return true; + } + float pathMaxY = -1.0e9f; + for (const auto &p : path) + pathMaxY = std::max(pathMaxY, p.y); + if (pathMaxY < 3.0f) { + fail("navmesh path reaches the upper target " + "without climbing"); + return true; + } + + const Ogre::Vector3 plainStart = + plainOrigin + Ogre::Vector3(0.0f, 0.7f, + CELL_SIZE); + const Ogre::Vector3 plainUpper = + plainOrigin + + Ogre::Vector3(4.0f * CELL_SIZE + CELL_SIZE * 0.5f, + CELL_HEIGHT + 0.7f, + CELL_SIZE); + std::vector plainPath; + bool plainFound = navSys->findPath( + navE, plainStart, plainUpper, plainPath); + if (plainFound && !plainPath.empty() && + (plainPath.back() - plainUpper).length() <= + 3.0f) { + fail("stairless grid is navmesh-connected " + "between floors"); + return true; + } + + std::cout << "[test] PASS: stairs are walkable and " + "navmesh-connected; stairless floors are " + "not" << std::endl; + phase = 3; + app->getRoot()->queueEndRendering(); + return true; + } + } + return true; + } +}; + /* * --test-traffic-loop (phase 5.1): two AI-driven sedans on a flat-floor * rectangle loop (vehicletest scene, sedan1 + sedan2). @@ -10808,6 +11088,7 @@ int main(int argc, char *argv[]) bool testTrafficSchedule = false; bool testParking = false; bool testFurniture = false; + bool testStairs = false; bool testAnimTravel = false; bool testPoi = false; bool benchmark = false; @@ -10889,6 +11170,8 @@ int main(int argc, char *argv[]) testParking = true; } else if (arg == "--test-furniture") { testFurniture = true; + } else if (arg == "--test-stairs") { + testStairs = true; } else if (arg == "--test-anim-travel") { testAnimTravel = true; } else if (arg == "--test-poi") { @@ -11038,6 +11321,9 @@ int main(int argc, char *argv[]) /* The furniture test loads the furnished demo scene. */ if (testFurniture && !sceneArgGiven) sceneFile = "demo_scene_furniture.json"; + /* The stairs test needs a flat floor to seat the building. */ + if (testStairs && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; /* The benchmark measures the generated town. */ if (benchmark && !sceneArgGiven) sceneFile = "demo_scene_exterior.json"; @@ -11208,6 +11494,11 @@ int main(int argc, char *argv[]) if (testFurniture) app.getRoot()->addFrameListener(&furnitureTestListener); + /* Phase 7.2 stairs acceptance: flat vehicletest floor. */ + StairsTestListener stairsListener(&app); + if (testStairs) + app.getRoot()->addFrameListener(&stairsListener); + /* Phase 6.7 travel probe: runs on the default interior * scene (no scene override needed). */ AnimTravelTestListener animTravelListener(&app); @@ -11698,6 +11989,26 @@ int main(int argc, char *argv[]) } } + if (testStairs) { + if (stairsListener.failed) { + std::cerr << "[test] FAIL: " + << stairsListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (stairsListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete (stairs " + "phase " + << stairsListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + if (testAnimTravel) { if (animTravelListener.failed) { std::cerr << "[test] FAIL: " diff --git a/src/features/editScene/layoutlib/BuildingPlan.hpp b/src/features/editScene/layoutlib/BuildingPlan.hpp index 3fbd89b..1b34bf5 100644 --- a/src/features/editScene/layoutlib/BuildingPlan.hpp +++ b/src/features/editScene/layoutlib/BuildingPlan.hpp @@ -56,6 +56,8 @@ constexpr uint64_t IntWindowXNeg = 1ULL << 22; constexpr uint64_t IntWindowXPos = 1ULL << 23; constexpr uint64_t IntWindowZPos = 1ULL << 24; constexpr uint64_t IntWindowZNeg = 1ULL << 25; +/* Phase 7.1: stair cell (see CellFlags::StairWell). */ +constexpr uint64_t StairWell = 1ULL << 26; } /** Roof entry; type mirrors RoofComponent::Type (Flat=0 .. Cylinder=4). */ diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp index c04a377..780e531 100644 --- a/src/features/editScene/layoutlib/LayoutEval.cpp +++ b/src/features/editScene/layoutlib/LayoutEval.cpp @@ -1990,6 +1990,12 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) c |= PlanFlags::Floor; if (top) c |= PlanFlags::Ceiling; + /* Phase 7.3: a stairwell room flags its + * cells; the CellGrid generator cuts the + * room's own ceiling and the floor of the + * cell above so the stairs pass through. */ + if (roomType == "stairwell") + c |= PlanFlags::StairWell; m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) * m_plan->cellsX + x] = @@ -2044,6 +2050,11 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) c |= PlanFlags::IntWallZNeg; if (z == rr.z1 && rr.z1 < m_plan->cellsZ - 1) c |= PlanFlags::IntWallZPos; + /* Phase 7.3: a stairwell room also flags its + * cells (geometry effect in the CellGrid + * generator: ceiling/floor openings + stairs). */ + if (roomType == "stairwell") + c |= PlanFlags::StairWell; m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) * m_plan->cellsX + x] = roomId; } diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 2077927..c562e3d 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -1549,6 +1549,8 @@ static void registerAllComponents() lua_setfield(L, -2, "glassMaterialName"); lua_pushnumber(L, c.glassReflectivity); lua_setfield(L, -2, "glassReflectivity"); + lua_pushstring(L, c.stairRectName.c_str()); + lua_setfield(L, -2, "stairRectName"); lua_newtable(L); for (const auto &pair : c.doorConfigs) { const CellGridDoorConfig &dc = pair.second; @@ -1648,6 +1650,9 @@ static void registerAllComponents() lua_isnumber(L, -1)) c.glassReflectivity = (float)lua_tonumber(L, -1); lua_pop(L, 1); + if (lua_getfield(L, idx, "stairRectName"), lua_isstring(L, -1)) + c.stairRectName = lua_tostring(L, -1); + lua_pop(L, 1); if (lua_getfield(L, idx, "doorConfigs"), lua_istable(L, -1)) { c.doorConfigs.clear(); lua_pushnil(L); diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index bba51f1..eb6b892 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -398,6 +398,9 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, // Build geometry parts Procedural::TriangleBuffer floorTb, ceilingTb, extWallTb, intWallTb, extWindowsTb, intWindowsTb, extDoorsTb, intDoorsTb, roofTopTb, roofSideTb; + /* Phase 7.1: stair flights (mesh collider) - rings go into + * intWallTb so they share the wall rect/collider. */ + Procedural::TriangleBuffer stairTb; /* F4: interiorOnly skips the exterior shell (external walls, external * window panels + frames, roofs, external corners); exit doorway wall @@ -423,6 +426,7 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, } if (!exteriorOnly) { buildInternalCorners(grid, intWallTb); + buildStairs(grid, stairTb, intWallTb); } buildDoors(grid, extDoorsTb, intDoorsTb); buildWindows(grid, extWindowsTb, intWindowsTb); @@ -450,6 +454,12 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, materialEntity); applyUVMappingToBuffer(intDoorsTb, grid.intWallRectName, materialEntity); + /* Phase 7.1: stair treads use stairRectName (floor rect default). */ + applyUVMappingToBuffer(stairTb, + grid.stairRectName.empty() ? + grid.floorRectName : + grid.stairRectName, + materialEntity); applyUVMappingToBuffer(roofTopTb, grid.roofTopRectName, materialEntity); applyUVMappingToBuffer(roofSideTb, grid.roofSideRectName, materialEntity); @@ -594,6 +604,18 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, meshData.entities.push_back(entity3d); } + // Phase 7.1: stair flights (and their collider via the regular + // per-part mesh collider list) + if (stairTb.getVertices().size() >= 3) { + meshData.stairMesh = baseName + "_stairs"; + auto mesh = convertToMesh(meshData.stairMesh, stairTb, + materialName); + auto entity3d = + m_sceneMgr->createEntity(meshData.stairMesh); + transform.node->attachObject(entity3d); + meshData.entities.push_back(entity3d); + } + Ogre::LogManager::getSingleton().logMessage( "CellGrid: Built geometry for entity " + std::to_string(entity.id()) + @@ -609,7 +631,8 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, " intDoors=" + std::to_string(intDoorsTb.getVertices().size()) + " roofTop=" + std::to_string(roofTopTb.getVertices().size()) + " roofSide=" + std::to_string(roofSideTb.getVertices().size()) + - " glass=" + std::to_string(glassTb.getVertices().size())); + " glass=" + std::to_string(glassTb.getVertices().size()) + + " stairs=" + std::to_string(stairTb.getVertices().size())); // Build window and door frames (creates StaticGeometry region) try { @@ -715,6 +738,51 @@ static void genPlane(float sizeX, float sizeY, const Ogre::Vector3 &normal, .addToTriangleBuffer(tb); } +/* ---- Phase 7.1: CellGrid stairs ------------------------------------- */ + +/* Comfort/traversal bounds for generated interior stairs. The tread + * (0.30) and the rise cap (0.18) stay inside the proven climbing + * envelope of the existing entrance stairs (rise 0.18 / tread 0.30, + * TownGenSystem::createStairs) and far inside Recast's limits + * (walkableClimb 1.0 m, walkableSlopeAngle 20 deg). 0.25 m risers + * were tried first and the Jolt character cannot mount them (the + * capsule stops dead at the first tread). */ +static const float STAIR_RISE_MAX = 0.18f; +static const float STAIR_TREAD = 0.30f; +/* Slab-edge ring width: the ring both hides the backface see-through + * of the cut floor/ceiling planes and doubles as the flush step-off + * ledge at the opening rim. */ +static const float STAIR_RING = 0.15f; +/* Widest comfortable flight; wider cells get free floor beside it. */ +static const float STAIR_MAX_WIDTH = 1.8f; +/* Gap between the two flights of a dogleg. */ +static const float STAIR_FLIGHT_GAP = 0.4f; + +/* Sparse-cell key, identical to Cell::getKey(). */ +static int64_t cellKey(int x, int y, int z) +{ + return (int64_t)x + 2048 * (int64_t)y + 2048 * 2048 * (int64_t)z; +} + +/* Solid box into a triangle buffer (BoxGenerator position = centre). */ +static void genStairBox(Procedural::TriangleBuffer &tb, + const Ogre::Vector3 ¢er, + const Ogre::Vector3 &size) +{ + if (size.x <= 0.001f || size.y <= 0.001f || size.z <= 0.001f) + return; + Procedural::BoxGenerator() + .setSizeX(size.x) + .setSizeY(size.y) + .setSizeZ(size.z) + .setNumSegX(1) + .setNumSegY(1) + .setNumSegZ(1) + .setPosition(center) + .setEnableNormals(true) + .addToTriangleBuffer(tb); +} + void CellGridSystem::buildFloorsAndCeilings( const CellGridComponent &grid, Procedural::TriangleBuffer &floorTb, Procedural::TriangleBuffer &ceilingTb) @@ -727,19 +795,35 @@ void CellGridSystem::buildFloorsAndCeilings( const float floorY = CellGridSystem::floorSurfaceY(); const float ceilingY = grid.cellHeight - 0.3f; + /* Phase 7.1: a valid stair cell (one with a floor above it in + * the grid) cuts its own ceiling and the floor of the cell + * directly above. */ + std::set stairCells; + for (const auto &cell : grid.cells) { + if ((cell.flags & CellFlags::StairWell) && + cell.y + 1 < grid.height) + stairCells.insert(cell.getKey()); + } + for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); uint64_t flags = cell.flags; + const bool stairCell = stairCells.count(cell.getKey()) != 0; + /* This cell's floor is the opening when the cell BELOW it is + * a stair cell (the stair rises through into this level). */ + const bool holeBelow = + stairCells.count( + cellKey(cell.x, cell.y - 1, cell.z)) != 0; // Floor: original sizeX=2.0f, sizeY=2.0f (half-sizes), scaled by hScale - if (flags & CellFlags::Floor) { + if ((flags & CellFlags::Floor) && !holeBelow) { genPlane(2.0f * hScale, 2.0f * hScale, Ogre::Vector3::UNIT_Y, Ogre::Vector3(0, floorY, 0), floorTb, origin); } // Ceiling: original sizeX=2.0f, sizeY=2.0f (half-sizes), scaled by hScale - if (flags & CellFlags::Ceiling) { + if ((flags & CellFlags::Ceiling) && !stairCell) { genPlane(2.0f * hScale, 2.0f * hScale, Ogre::Vector3::NEGATIVE_UNIT_Y, Ogre::Vector3(0, ceilingY, 0), ceilingTb, @@ -748,6 +832,300 @@ void CellGridSystem::buildFloorsAndCeilings( } } +/* Phase 7.1: stairwell geometry. + * + * A StairWell cell at level y hosts the connection y -> y+1. The + * opening is the XZ footprint of the flagged cells one level up + * (buildFloorsAndCeilings clears the planes); this pass fills it: + * + * - a run of flagged cells along one axis becomes a STRAIGHT flight + * rising toward +X / +Z; a lone flagged cell becomes a DOGLEG + * (two flights side by side plus a mid landing, boarding and + * stepping off on the -Z side, which stacks cleanly in a column); + * - every step is a solid box from its flight's slab bottom to the + * tread top (the TownGenSystem::createStairs construction); + * - the opening rim gets a slab-edge RING (STAIR_RING wide, spanning + * exactly the ceiling-to-floor band of the hole level) appended to + * the internal wall buffer, so no cut plane leaves a see-through + * backface gap and the ring's top face is the flush step-off ledge. + */ +void CellGridSystem::buildStairs(const CellGridComponent &grid, + Procedural::TriangleBuffer &stairTb, + Procedural::TriangleBuffer &wallTb) +{ + const float cs = grid.cellSize; + const float ch = grid.cellHeight; + const float half = cs * 0.5f; + const float floorY = CellGridSystem::floorSurfaceY(); + + /* Valid stair cells: the destination floor must exist. */ + std::set stairCells; + int outOfRange = 0; + for (const auto &cell : grid.cells) { + if (!(cell.flags & CellFlags::StairWell)) + continue; + if (cell.y + 1 >= grid.height) { + ++outOfRange; + continue; + } + stairCells.insert(cell.getKey()); + } + if (outOfRange > 0) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - " + std::to_string(outOfRange) + + " stair cell(s) have no floor above (grid height " + + std::to_string(grid.height) + "); ignored"); + } + if (stairCells.empty()) + return; + + /* ---- Opening rims: one slab-edge ring per hole level. ---- */ + for (int level = 1; level < grid.height; ++level) { + std::set > hole; + for (const auto &cell : grid.cells) { + if (cell.y + 1 != level) + continue; + if (!(cell.flags & CellFlags::StairWell)) + continue; + /* The band only exists where a cut plane actually + * leaves a slab edge: the stair cell's own + * ceiling or the destination cell's floor. */ + const Cell *below = + grid.findCell(cell.x, level - 1, cell.z); + const Cell *above = + grid.findCell(cell.x, level, cell.z); + const bool hasCeiling = + below && (below->flags & CellFlags::Ceiling); + const bool hasFloor = + above && (above->flags & CellFlags::Floor); + if (hasCeiling || hasFloor) + hole.insert({ cell.x, cell.z }); + } + if (hole.empty()) + continue; + + const float bandBottom = level * ch - 0.3f; + const float bandTop = level * ch + floorY; + const float bandH = bandTop - bandBottom; + const float yCenter = (bandBottom + bandTop) * 0.5f; + auto inHole = [&](int x, int z) { + return hole.count({ x, z }) != 0; + }; + for (const auto &xz : hole) { + const int x = xz.first; + const int z = xz.second; + const float minX = x * cs - half; + const float maxX = x * cs + half; + const float minZ = z * cs - half; + const float maxZ = z * cs + half; + /* X-side strips span the full edge; Z-side strips + * are inset where an X strip already covers the + * corner (avoids coincident faces). */ + if (!inHole(x - 1, z)) + genStairBox( + wallTb, + { minX + STAIR_RING * 0.5f, yCenter, + z * cs }, + { STAIR_RING, bandH, cs }); + if (!inHole(x + 1, z)) + genStairBox( + wallTb, + { maxX - STAIR_RING * 0.5f, yCenter, + z * cs }, + { STAIR_RING, bandH, cs }); + const float zSpanMin = + inHole(x - 1, z) ? minX : minX + STAIR_RING; + const float zSpanMax = + inHole(x + 1, z) ? maxX : maxX - STAIR_RING; + if (zSpanMax - zSpanMin > 0.001f) { + if (!inHole(x, z - 1)) + genStairBox( + wallTb, + { (zSpanMin + zSpanMax) * 0.5f, + yCenter, + minZ + STAIR_RING * 0.5f }, + { zSpanMax - zSpanMin, bandH, + STAIR_RING }); + if (!inHole(x, z + 1)) + genStairBox( + wallTb, + { (zSpanMin + zSpanMax) * 0.5f, + yCenter, + maxZ - STAIR_RING * 0.5f }, + { zSpanMax - zSpanMin, bandH, + STAIR_RING }); + } + } + } + + /* ---- Flights. ---- */ + auto planSteps = [&](float runLen, int &nSteps, float &rise, + float &tread) { + nSteps = std::max(2, (int)std::ceil(ch / STAIR_RISE_MAX)); + rise = ch / (float)nSteps; + tread = STAIR_TREAD; + const float avail = runLen - 2.0f * STAIR_RING; + if ((float)nSteps * tread > avail) { + tread = avail / (float)nSteps; + if (tread < 0.12f) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - stair run too " + "short for " + + std::to_string(nSteps) + + " steps; tread clamped"); + tread = 0.12f; + } + } + }; + + std::set consumed; + for (const auto &cell : grid.cells) { + if (!(cell.flags & CellFlags::StairWell)) + continue; + if (cell.y + 1 >= grid.height) + continue; + if (consumed.count(cell.getKey())) + continue; + + const float baseY = (float)cell.y * ch; + const float botY = baseY + floorY; + + /* Straight run along X? */ + int x0 = cell.x; + while (stairCells.count(cellKey(x0 - 1, cell.y, cell.z))) + --x0; + int x1 = cell.x; + while (stairCells.count(cellKey(x1 + 1, cell.y, cell.z))) + ++x1; + int z0 = cell.z; + while (stairCells.count(cellKey(cell.x, cell.y, z0 - 1))) + --z0; + int z1 = cell.z; + while (stairCells.count(cellKey(cell.x, cell.y, z1 + 1))) + ++z1; + + if (x1 > x0) { + /* Straight flight along +X, bottom at x0. */ + const float runLen = (float)(x1 - x0 + 1) * cs; + int n; + float rise, tread; + planSteps(runLen, n, rise, tread); + const float width = std::min( + STAIR_MAX_WIDTH, cs - 2.0f * STAIR_RING); + const float xHigh = + (float)x1 * cs + half - STAIR_RING; + const float xStart = xHigh - (float)n * tread; + for (int i = 0; i < n; ++i) { + const float top = botY + (float)(i + 1) * rise; + const float sx = xStart + (float)i * tread; + genStairBox( + stairTb, + { sx + tread * 0.5f, + (baseY + top) * 0.5f, + (float)cell.z * cs }, + { tread, top - baseY, width }); + } + for (int x = x0; x <= x1; ++x) + consumed.insert(cellKey(x, cell.y, cell.z)); + } else if (z1 > z0) { + /* Straight flight along +Z, bottom at z0. */ + const float runLen = (float)(z1 - z0 + 1) * cs; + int n; + float rise, tread; + planSteps(runLen, n, rise, tread); + const float width = std::min( + STAIR_MAX_WIDTH, cs - 2.0f * STAIR_RING); + const float zHigh = + (float)z1 * cs + half - STAIR_RING; + const float zStart = zHigh - (float)n * tread; + for (int i = 0; i < n; ++i) { + const float top = botY + (float)(i + 1) * rise; + const float sz = zStart + (float)i * tread; + genStairBox( + stairTb, + { (float)cell.x * cs, + (baseY + top) * 0.5f, + sz + tread * 0.5f }, + { width, top - baseY, tread }); + } + for (int z = z0; z <= z1; ++z) + consumed.insert(cellKey(cell.x, cell.y, z)); + } else { + /* Dogleg in a single cell: flight A rises along +Z + * on the -X band, the mid landing spans the +Z end, + * flight B rises along -Z on the +X band and tops + * out flush with the -Z rim. */ + const float minX = (float)cell.x * cs - half + + STAIR_RING; + const float maxX = (float)cell.x * cs + half - + STAIR_RING; + const float minZ = (float)cell.z * cs - half + + STAIR_RING; + const float maxZ = (float)cell.z * cs + half - + STAIR_RING; + const float spanX = maxX - minX; + const float spanZ = maxZ - minZ; + const float fw = + (spanX - STAIR_FLIGHT_GAP) * 0.5f; + if (fw < 0.5f || spanZ < 1.2f) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - cell too small " + "for a dogleg stair at (" + + std::to_string(cell.x) + "," + + std::to_string(cell.y) + "," + + std::to_string(cell.z) + ")"); + consumed.insert(cell.getKey()); + continue; + } + int n; + float rise, tread; + planSteps(spanZ, n, rise, tread); + const int nA = (n + 1) / 2; + const int nB = n - nA; + /* Keep at least 0.6 m of landing at the +Z end. */ + float treadA = tread; + if ((float)nA * treadA > spanZ - 0.6f) + treadA = (spanZ - 0.6f) / (float)nA; + const float landingMinZ = minZ + (float)nA * treadA; + const float treadB = + nB > 0 ? (landingMinZ - minZ) / (float)nB + : treadA; + const float midTop = botY + (float)nA * rise; + /* Flight A (-X band, rising +Z). */ + for (int k = 0; k < nA; ++k) { + const float top = botY + (float)(k + 1) * rise; + const float sz = minZ + (float)k * treadA; + genStairBox( + stairTb, + { minX + fw * 0.5f, + (baseY + top) * 0.5f, + sz + treadA * 0.5f }, + { fw, top - baseY, treadA }); + } + /* Mid landing across the full usable width. */ + genStairBox(stairTb, + { (minX + maxX) * 0.5f, + (baseY + midTop) * 0.5f, + (landingMinZ + maxZ) * 0.5f }, + { spanX, midTop - baseY, + maxZ - landingMinZ }); + /* Flight B (+X band, rising -Z, on the landing). */ + for (int k = 0; k < nB; ++k) { + const float top = midTop + (float)(k + 1) * rise; + const float szHi = + landingMinZ - (float)k * treadB; + genStairBox( + stairTb, + { maxX - fw * 0.5f, + (midTop + top) * 0.5f, + szHi - treadB * 0.5f }, + { fw, top - midTop, treadB }); + } + consumed.insert(cell.getKey()); + } + } +} + void CellGridSystem::buildWalls(const CellGridComponent &grid, Procedural::TriangleBuffer &extWallTb, Procedural::TriangleBuffer &intWallTb, @@ -3370,6 +3748,7 @@ void CellGridSystem::destroyCellGridMeshes(flecs::entity entity) removeMesh(it->second.extWallMesh); removeMesh(it->second.intWallMesh); removeMesh(it->second.intWindowsMesh); + removeMesh(it->second.stairMesh); removeMesh(it->second.roofMesh); removeMesh(it->second.roofSideMesh); removeMesh(it->second.glassMesh); @@ -3574,6 +3953,7 @@ void CellGridSystem::buildPhysicsColliders(flecs::entity entity) addMeshCollider(meshData.extWallMesh); addMeshCollider(meshData.intWallMesh); addMeshCollider(meshData.intWindowsMesh); + addMeshCollider(meshData.stairMesh); addMeshCollider(meshData.roofMesh); addMeshCollider(meshData.roofSideMesh); addMeshCollider(meshData.glassMesh); diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index 912253d..a77f0f3 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -173,6 +173,14 @@ private: void buildFloorsAndCeilings(const struct CellGridComponent &grid, Procedural::TriangleBuffer &floorTb, Procedural::TriangleBuffer &ceilingTb); + /* Phase 7.1: stairwell cells. Curves the openings (own ceiling, + * the floor of the cell above) and emits stepped stair meshes + * into stairTb plus the opening's slab-edge ring into wallTb. + * stairHoles has every hole level's XZ footprint keyed by + * Cell::getKey() of a (x, holeLevel, z) position. */ + void buildStairs(const struct CellGridComponent &grid, + Procedural::TriangleBuffer &stairTb, + Procedural::TriangleBuffer &wallTb); void buildWalls(const struct CellGridComponent &grid, Procedural::TriangleBuffer &extWallTb, Procedural::TriangleBuffer &intWallTb, @@ -324,6 +332,9 @@ private: std::string extWallMesh; std::string intWallMesh; std::string intWindowsMesh; + /* Phase 7.1: stair treads/flights (mesh collider via the + * regular per-part collider list). */ + std::string stairMesh; std::vector doorMeshes; std::vector windowMeshes; std::string roofMesh; diff --git a/src/features/editScene/systems/RoomLayoutSystem.cpp b/src/features/editScene/systems/RoomLayoutSystem.cpp index 11fce88..0ec103a 100644 --- a/src/features/editScene/systems/RoomLayoutSystem.cpp +++ b/src/features/editScene/systems/RoomLayoutSystem.cpp @@ -343,6 +343,18 @@ void RoomLayoutSystem::processRoom(flecs::entity roomEntity, RoomComponent& room } } + /* Phase 7.3: a stairwell room flags its cells; the CellGrid + * generator cuts the room's own ceiling and the floor of the + * cell above, so a staircase can pass between the floors. */ + if (room.roomType == "stairwell") { + for (int z = room.minZ; z < room.maxZ; z++) { + for (int x = room.minX; x < room.maxX; x++) { + grid.getOrCreateCell(x, y, z).setFlag( + CellFlags::StairWell); + } + } + } + // Create interior walls around the room perimeter if (room.createInteriorWalls) { // Z- side (north) diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 1991fed..fe7b5df 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -2853,6 +2853,8 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity) json["intWindowFrameRectName"] = grid.intWindowFrameRectName; json["roofTopRectName"] = grid.roofTopRectName; json["roofSideRectName"] = grid.roofSideRectName; + /* Phase 7.1: stair tread rect (empty = floorRectName). */ + json["stairRectName"] = grid.stairRectName; json["friction"] = grid.friction; // Serialize door settings @@ -3142,6 +3144,9 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity, grid.intWindowFrameRectName = json.value("intWindowFrameRectName", ""); grid.roofTopRectName = json.value("roofTopRectName", ""); grid.roofSideRectName = json.value("roofSideRectName", ""); + /* Phase 7.1: old scenes have no stair rect - empty uses the floor + * rect at build time. */ + grid.stairRectName = json.value("stairRectName", ""); grid.friction = json.value("friction", 0.5f); // Deserialize door settings diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index ccf6221..f91de2d 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -7413,6 +7413,311 @@ bool TerrainTestRunner::testFurnitureLayers(EditorApp &app, return true; } +/* ------------------------------------------------------------------ */ +/* testCellGridStairs (phase 7.1: stairwell cells) */ +/* ------------------------------------------------------------------ */ + +/* Iterate every vertex of a mesh (all submeshes). */ +template +static void stairTestScanMesh(const Ogre::MeshPtr &mesh, Fn fn) +{ + if (mesh.isNull()) + return; + for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh *sub = mesh->getSubMesh(si); + Ogre::VertexData *vd = sub->useSharedVertices ? + mesh->sharedVertexData : + sub->vertexData; + if (!vd || !vd->vertexBufferBinding->isBufferBound(0)) + continue; + Ogre::HardwareVertexBufferSharedPtr vb = + vd->vertexBufferBinding->getBuffer(0); + const size_t verts = vd->vertexCount; + if (verts == 0) + continue; + std::vector data(vb->getVertexSize() * verts); + vb->readData(0, data.size(), data.data()); + const size_t stride = vb->getVertexSize() / sizeof(float); + for (size_t v = 0; v < verts; ++v) + fn(Ogre::Vector3(data[v * stride], + data[v * stride + 1], + data[v * stride + 2])); + } +} + +/* First mesh attached to the node whose name ends with the suffix. */ +static Ogre::MeshPtr stairTestAttachedMesh(Ogre::SceneNode *node, + const std::string &suffix) +{ + if (!node) + return Ogre::MeshPtr(); + for (unsigned i = 0; i < node->numAttachedObjects(); ++i) { + Ogre::Entity *ent = dynamic_cast( + node->getAttachedObject(i)); + if (!ent) + continue; + const std::string &name = ent->getMesh()->getName(); + if (name.size() >= suffix.size() && + name.compare(name.size() - suffix.size(), suffix.size(), + suffix) == 0) + return ent->getMesh(); + } + return Ogre::MeshPtr(); +} + +bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) +{ + (void)ts; + CellGridSystem *cellSys = app.getCellGridSystem(); + if (!cellSys) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no CellGridSystem in app"); + return false; + } + flecs::world *w = app.getWorld(); + Ogre::SceneManager *sm = app.getSceneManager(); + + bool ok = true; + auto fail = [&](const std::string &message) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - " + message); + ok = false; + }; + + /* Phase 7.3: a DSL stairwell room produces stair geometry through + * the real town generator (rooms-first two-storey layout). Runs + * first so the preview building is the only grid in the world. */ + EditorPhysicsSystem *physSys = app.getEditorPhysicsSystem(); + if (!physSys) { + fail("no EditorPhysicsSystem"); + } else { + TownGenSystem tgs(*w, sm, physSys->getPhysicsWrapper()); + std::string dslError; + if (!tgs.previewLayout( + "(shell 8 8 :floors 2 :empty)" + "(floor 0 (room sw (rect 0 0 0.5 1)" + " :type stairwell)" + " (room hall (rect 0.5 0 1 1)))" + "(floor 1 (room landing (rect 0 0 1 1)))", + nullptr, 0, 42, Ogre::Vector3(0, 0, 0), "full", + &dslError)) { + fail("DSL stairwell preview failed: " + dslError); + } else { + flecs::entity dslBuilding; + w->each([&](flecs::entity e, CellGridComponent &) { + if (!dslBuilding.is_alive()) + dslBuilding = e; + }); + if (!dslBuilding.is_alive() || + !dslBuilding.has()) { + fail("DSL stairwell preview created no building"); + } else { + /* Preview meshes build on the next + * CellGridSystem update (dirty grid). */ + cellSys->update(); + Ogre::SceneNode *dslNode = + dslBuilding.get() + .node; + if (stairTestAttachedMesh(dslNode, "_stairs") + .isNull()) + fail("DSL stairwell building has no stair " + "geometry"); + } + } + tgs.clearLayoutPreview(); + } + + /* One 8 x 3 x 4 grid (32 x 4 m footprint, 4 m floors), every cell + * floored and ceiled. With stairs: a two-cell straight run at + * level 0 (cells 4..5, z=1) and a single-cell dogleg at level 1 + * (cell 4, z=2) so both layouts are covered. */ + auto buildGrid = [&](const char *nodeName, const char *uid, + bool withStairs, + Ogre::SceneNode *&outNode) -> flecs::entity { + flecs::entity e = w->entity(); + Ogre::SceneNode *node = + sm->getRootSceneNode()->createChildSceneNode(nodeName); + outNode = node; + e.set( + { node, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE }); + CellGridComponent grid; + grid.gridUid = uid; + grid.width = 8; + grid.height = 3; + grid.depth = 4; + grid.cellSize = 4.0f; + grid.cellHeight = 4.0f; + for (int y = 0; y < grid.height; ++y) + for (int z = 0; z < grid.depth; ++z) + for (int x = 0; x < grid.width; ++x) { + Cell &c = grid.getOrCreateCell(x, y, z); + c.flags = CellFlags::Floor | + CellFlags::Ceiling; + } + if (withStairs) { + grid.getOrCreateCell(4, 0, 1) + .setFlag(CellFlags::StairWell); + grid.getOrCreateCell(5, 0, 1) + .setFlag(CellFlags::StairWell); + grid.getOrCreateCell(4, 1, 2) + .setFlag(CellFlags::StairWell); + } + e.set(grid); + return e; + }; + + Ogre::SceneNode *gridNode = nullptr; + Ogre::SceneNode *plainNode = nullptr; + flecs::entity gridE = + buildGrid("StairsTestGrid", "stairs-test-grid", true, + gridNode); + cellSys->update(); + + Ogre::MeshPtr floor = stairTestAttachedMesh(gridNode, "_floor"); + Ogre::MeshPtr ceiling = + stairTestAttachedMesh(gridNode, "_ceiling"); + Ogre::MeshPtr stairs = stairTestAttachedMesh(gridNode, "_stairs"); + /* The opening rings go into the internal wall buffer. */ + Ogre::MeshPtr rings = stairTestAttachedMesh(gridNode, "_intWall"); + if (floor.isNull()) + fail("no floor mesh"); + if (ceiling.isNull()) + fail("no ceiling mesh"); + if (stairs.isNull()) + fail("no stairs mesh"); + if (rings.isNull()) + fail("no slab-edge ring mesh (intWall)"); + + /* Cell c spans c*cellSize +- cellSize/2 (cell coords are the + * generated geometry's centres). */ + struct Rect { + float minX, maxX, minZ, maxZ; + }; + const Rect runHole{ 14.0f, 22.0f, 2.0f, 6.0f }; + const Rect dogHole{ 14.0f, 18.0f, 6.0f, 10.0f }; + /* genPlane emits each 4 m plane as one consecutive 2 x 3 vertex + * block (numSegX 1, numSegY 2 - the fixture's cell size), so a + * plane is inside a rect when its whole block lies inside. */ + auto countPlanes = [&](const Ogre::MeshPtr &mesh, float y, + const Rect &r, int &insideCount, + int &outsideCount) { + std::vector pts; + stairTestScanMesh(mesh, [&](const Ogre::Vector3 &p) { + if (std::fabs(p.y - y) < 0.01f) + pts.push_back(p); + }); + for (size_t i = 0; i + 6 <= pts.size(); i += 6) { + float mnX = 1.0e9f, mxX = -1.0e9f; + float mnZ = 1.0e9f, mxZ = -1.0e9f; + for (size_t k = 0; k < 6; ++k) { + mnX = std::min(mnX, pts[i + k].x); + mxX = std::max(mxX, pts[i + k].x); + mnZ = std::min(mnZ, pts[i + k].z); + mxZ = std::max(mxZ, pts[i + k].z); + } + const bool in = mnX >= r.minX - 0.01f && + mxX <= r.maxX + 0.01f && + mnZ >= r.minZ - 0.01f && + mxZ <= r.maxZ + 0.01f; + if (in) + ++insideCount; + else + ++outsideCount; + } + }; + + int floorInRunHole = 0, floorOutside = 0, floorInDogHole = 0, + floorDogOutside = 0; + countPlanes(floor, 4.2f, runHole, floorInRunHole, floorOutside); + countPlanes(floor, 8.2f, dogHole, floorInDogHole, floorDogOutside); + if (floorInRunHole != 0) + fail("level-1 floor still covers the straight-run hole"); + if (floorInDogHole != 0) + fail("level-2 floor still covers the dogleg hole"); + if (floorOutside == 0) + fail("level-1 floor missing outside the hole"); + + int ceilInRun = 0, ceilOutside = 0, ceilInDog = 0, ceilDogOutside = 0; + countPlanes(ceiling, 3.7f, runHole, ceilInRun, ceilOutside); + countPlanes(ceiling, 7.7f, dogHole, ceilInDog, ceilDogOutside); + if (ceilInRun != 0) + fail("stair cell ceiling still covers the opening"); + if (ceilInDog != 0) + fail("dogleg cell ceiling still covers the opening"); + if (ceilOutside == 0) + fail("ceilings missing outside the stair cells"); + + float stairMinY = 1.0e9f, stairMaxY = -1.0e9f; + stairTestScanMesh(stairs, [&](const Ogre::Vector3 &p) { + stairMinY = std::min(stairMinY, p.y); + stairMaxY = std::max(stairMaxY, p.y); + }); + if (stairMaxY < 8.1f) + fail("dogleg does not reach the upper floor surface"); + if (stairMinY > 0.3f) + fail("straight flight does not start at the ground floor"); + if (stairMaxY > 8.3f || stairMinY < -0.1f) + fail("stair mesh extends outside the stacked floors"); + + /* Rings must close BOTH slab bands (3.7..4.2 and 7.7..8.2). */ + bool ringLow = false, ringHigh = false; + stairTestScanMesh(rings, [&](const Ogre::Vector3 &p) { + if (p.y > 3.69f && p.y < 4.21f) + ringLow = true; + if (p.y > 7.69f && p.y < 8.21f) + ringHigh = true; + }); + if (!ringLow || !ringHigh) + fail("slab-edge rings missing at an opening rim"); + + /* Mesh collider for the stair geometry. */ + int stairColliders = 0; + w->each([&](flecs::entity, PhysicsColliderComponent &c) { + if (c.shapeType != PhysicsColliderComponent::ShapeType::Mesh) + return; + const std::string &n = c.meshName; + if (n.size() >= 7 && + n.compare(n.size() - 7, 7, "_stairs") == 0) + ++stairColliders; + }); + if (stairColliders < 1) + fail("stairs have no physics collider"); + + /* The same grid without stair flags keeps today's geometry. */ + flecs::entity plainE = + buildGrid("StairsTestPlainGrid", "stairs-test-plain", false, + plainNode); + cellSys->update(); + Ogre::MeshPtr plainFloor = + stairTestAttachedMesh(plainNode, "_floor"); + int plainInRunHole = 0, plainOutside = 0; + countPlanes(plainFloor, 4.2f, runHole, plainInRunHole, + plainOutside); + if (plainFloor.isNull() || plainInRunHole == 0) + fail("grid without stair flags lost its level-1 floor"); + if (!stairTestAttachedMesh(plainNode, "_stairs").isNull()) + fail("grid without stair flags built stair geometry"); + if (!stairTestAttachedMesh(plainNode, "_intWall").isNull()) + fail("grid without stair flags built opening rings"); + + /* Teardown: destruct the entities and let the CellGridSystem sweep + * own the Ogre resources (it tracks the meshes per grid). */ + gridE.destruct(); + plainE.destruct(); + cellSys->update(); + if (gridNode->numAttachedObjects() == 0) + sm->destroySceneNode(gridNode); + if (plainNode->numAttachedObjects() == 0) + sm->destroySceneNode(plainNode); + + if (!ok) + return false; + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: cell grid stairs test passed"); + return true; +} + /* ------------------------------------------------------------------ */ /* Logging */ /* ------------------------------------------------------------------ */ @@ -7529,6 +7834,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "townCompositions", testTownCompositions }, { "furniturePoiQuery", testFurniturePoiQuery }, { "furnitureLayers", testFurnitureLayers }, + { "cellGridStairs", testCellGridStairs }, }; diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 0504fa5..b281111 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -131,6 +131,7 @@ static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts); static bool testTownCompositions(EditorApp &app, TerrainSystem *ts); static bool testFurniturePoiQuery(EditorApp &app, TerrainSystem *ts); static bool testFurnitureLayers(EditorApp &app, TerrainSystem *ts); + static bool testCellGridStairs(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app); static void logResult(const TerrainTestResult &r); diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index ce2ad0a..ac02d44 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -51,6 +51,10 @@ static_assert(LayoutLib::PlanFlags::IntDoorXPos == CellFlags::IntDoorXPos, static_assert(LayoutLib::PlanFlags::IntWindowZPos == CellFlags::IntWindowZPos, ""); +/* Phase 7.1 stairs: the stair flag must cross the plan->grid copy + * untranslated like every other cell flag. */ +static_assert(LayoutLib::PlanFlags::StairWell == CellFlags::StairWell, + ""); /* Foundation reaches this far below the lowest terrain corner. */ static const float FOUNDATION_MARGIN = 0.5f; /* The foundation block(s) overhang the shell walls by this much per diff --git a/src/features/editScene/tests/cellgrid_door_test.cpp b/src/features/editScene/tests/cellgrid_door_test.cpp index ea18eac..74bec27 100644 --- a/src/features/editScene/tests/cellgrid_door_test.cpp +++ b/src/features/editScene/tests/cellgrid_door_test.cpp @@ -192,6 +192,17 @@ static int testSerializationRoundTrip() cell.setFlag(CellFlags::DoorXPos); grid.cells.push_back(cell); + /* Phase 7.1: the stair flag and tread rect ride the same raw + * uint64 cell format / rect-name fields. */ + Cell stairCell; + stairCell.x = 3; + stairCell.y = 1; + stairCell.z = 2; + stairCell.setFlag(CellFlags::Floor); + stairCell.setFlag(CellFlags::StairWell); + grid.cells.push_back(stairCell); + grid.stairRectName = "stairs"; + CellGridDoorConfig cfg; cfg.label = "Kitchen door"; cfg.hasOverride = true; @@ -279,6 +290,16 @@ static int testSerializationRoundTrip() f2.groupId != "bedroom-set" || f2.tags.size() != 2) FAIL("furniture placement metadata mismatch after round trip"); + /* Phase 7.1: stair flag + tread rect survive the round trip. */ + if (g2.cells.size() != 2) + FAIL("cell count mismatch after round trip"); + if (!g2.cells[1].hasFlag(CellFlags::StairWell)) + FAIL("stair flag lost in round trip"); + if (g2.cells[1].x != 3 || g2.cells[1].y != 1 || g2.cells[1].z != 2) + FAIL("stair cell position mismatch after round trip"); + if (g2.stairRectName != "stairs") + FAIL("stairRectName lost in round trip"); + PASS(); return 0; } diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index 20418e3..3668b80 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -1074,7 +1074,8 @@ static int testCellGridComponent(lua_State *L) " glassColor = { r = 0.1, g = 0.2, b = 0.3," " a = 0.4 }," " glassMaterialName = 'MyGlass'," - " glassReflectivity = 0.25" + " glassReflectivity = 0.25," + " stairRectName = 'stairs'" "});" "local c = ecs.get_component(id, 'CellGrid');" "assert(c ~= nil, 'CellGrid should exist');" @@ -1106,7 +1107,9 @@ static int testCellGridComponent(lua_State *L) "assert(c.glassMaterialName == 'MyGlass', " "'wrong glassMaterialName');" "assert(c.glassReflectivity == 0.25, " - "'wrong glassReflectivity')"); + "'wrong glassReflectivity');" + "assert(c.stairRectName == 'stairs', " + "'wrong stairRectName')"); if (!ok) FAIL("CellGrid component assertion failed"); diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index be6450a..21dd8ff 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -4456,6 +4456,70 @@ static bool runTownGenProbe() } } + /* Phase 7.3: stairwell rooms flag their cells in both + * shell modes; the CellGrid generator turns the flags + * into the ceiling/floor openings and stair geometry. */ + { + BuildingPlan pStair; + std::vector warnsStair; + LayoutEval evStair(lb, &rngDsl, nullptr); + if (!evStair.evaluate( + "(shell 8 8 :floors 2 :empty)" + "(floor 0 (room sw (rect 0 0 0.5 1)" + " :type stairwell)" + " (room hall (rect 0.5 0 1 1)))" + "(floor 1 (room landing (rect 0 0 1 1)))", + pStair, &derr, &warnsStair)) { + printf(" *** dsl stairwell rooms-first: eval " + "failed '%s' ***\n", derr.c_str()); + ok = false; + } else { + const uint64_t swA = pStair.at(0, 0, 0); + const uint64_t swB = pStair.at(1, 0, 3); + const uint64_t hall = pStair.at(2, 0, 0); + const uint64_t above = pStair.at(0, 1, 0); + if (!(swA & PlanFlags::StairWell) || + !(swB & PlanFlags::StairWell) || + (hall & PlanFlags::StairWell) || + (above & PlanFlags::StairWell) || + !(swA & PlanFlags::Floor)) { + printf(" *** dsl stairwell rooms-first: " + "flags sw %llx/%llx hall %llx " + "above %llx ***\n", + (unsigned long long)swA, + (unsigned long long)swB, + (unsigned long long)hall, + (unsigned long long)above); + ok = false; + } + } + } + { + BuildingPlan pStairFilled; + LayoutEval evStairFilled(lb, &rngDsl, nullptr); + if (!evStairFilled.evaluate( + "(shell 8 8 :floors 2)" + "(floor 0 (room sw (rect 0 0 0.5 1)" + " :type stairwell))", + pStairFilled, &derr)) { + printf(" *** dsl stairwell filled: eval failed " + "'%s' ***\n", derr.c_str()); + ok = false; + } else { + const uint64_t sw = pStairFilled.at(1, 0, 2); + const uint64_t other = pStairFilled.at(3, 0, 2); + if (!(sw & PlanFlags::StairWell) || + (other & PlanFlags::StairWell) || + !(sw & PlanFlags::Floor)) { + printf(" *** dsl stairwell filled: " + "flags sw %llx other %llx ***\n", + (unsigned long long)sw, + (unsigned long long)other); + ok = false; + } + } + } + /* Phase 6.5.F: furniture-selection metadata is part of the * room plan and stays parallel to room names/types. */ { diff --git a/src/features/editScene/ui/CellGridEditor.cpp b/src/features/editScene/ui/CellGridEditor.cpp index 1862956..9d92f8b 100644 --- a/src/features/editScene/ui/CellGridEditor.cpp +++ b/src/features/editScene/ui/CellGridEditor.cpp @@ -501,6 +501,9 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo if (renderRectCombo("Roof Side", grid.roofSideRectName)) { grid.markDirty(); } + if (renderRectCombo("Stairs", grid.stairRectName)) { + grid.markDirty(); + } if (renderRectCombo("Door Leaf", grid.doorRectName)) { grid.markDirty(); }