From 214b05b40fa212f58aead66e844c8149de80f8e0 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Fri, 30 Oct 2026 19:58:52 +0300 Subject: [PATCH] Phase 7 checkpoint --- src/features/editScene/AGENTS.md | 67 ++- .../editScene/components/CellGrid.cpp | 2 + .../editScene/components/CellGrid.hpp | 7 + .../editScene/demos/demo-taxi/PLAN.md | 152 ++++++- .../editScene/demos/demo-taxi/demo_main.cpp | 169 ++++++- .../editScene/layoutlib/BuildingPlan.hpp | 2 + .../editScene/layoutlib/LayoutEval.cpp | 65 ++- .../editScene/layoutlib/LayoutEval.hpp | 4 +- .../editScene/systems/CellGridSystem.cpp | 420 +++++++++++++----- .../editScene/systems/FurniturePlacement.cpp | 13 + .../editScene/systems/TerrainTests.cpp | 185 ++++++++ .../editScene/systems/TownGenSystem.cpp | 137 ++++++ .../editScene/systems/TownGenSystem.hpp | 9 + .../tests/road_geometry_overlap_test.cpp | 26 ++ src/features/editScene/ui/TerrainEditor.hpp | 15 +- 15 files changed, 1110 insertions(+), 163 deletions(-) diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index fcf57d7..1e05849 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -3121,6 +3121,59 @@ hosts a staircase from its own level up to the next. The generator: "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. +- Automatic stairwells (phase 7.3a): `TownGenSystem:: + fillBuildingCells` calls `autoPlaceStairwell()` for plans with more + than one floor. An explicit plan stair cell wins; otherwise a + deterministic straight run is carved along the longer usable axis + (4 cells first, 3 as a fallback) on the first row/column that is + free of door cells and has a boarding cell on the ground floor and + a step-off cell on the top floor. Interior walls + internal + windows on the run cells are opened (the run may cross room + boundaries), as are the facing walls at the boarding and step-off + cells. When no run fits, one `TownGen: warning - no stairwell fits + building ...` line is logged and the building stays as before. + This covers the built-in parametric layouts, `town_layouts.txt` + layouts and inline sources without editing layout text; exterior- + only shells are unchanged (stairs are interior geometry). The DSL + help window documents `:type stairwell` + the automatic stairwell, + and `LayoutLibrary::examples()` ships "Two storeys, automatic + stairwell" and an explicit-stairwell-room recipe. +- Climbability envelope: the Jolt character only mounts a 0.174 m + riser when the approach is fast enough and the tread is not too + short. Measured: 1.4 m/s (NPC walk) and 1.5 m/s climb 0.30 m and + 0.248 m treads; 1.2 m/s does not. Straight runs therefore need at + least 3 cells at 2 m cells (tread >= ~0.25) - 4-cell runs + (tread 0.30) are the verified default - and a one-cell 2 m dogleg + (tread ~0.09) is never climbable (the dogleg layout is for larger + cells or hand-authored grids). `FurniturePlacement::canPlace` + rejects stair cells and the cells directly above them ("blocks a + stairwell" / "above a stairwell opening"). +- Exterior stairs (phase 7.4): `CellFlags::ExteriorStair` (bit 27) on + an upper-floor boundary cell with an exterior door builds a motel- + style outside stair: a 1.5 m walkway landing at the upper floor + flush with the door, a 0.9 m outer rail (wall buffer + collider), + and a straight solid flight descending along the facade to the grid + base level (0.18 cap, tread 0.30, 1.4 m deep against the wall), + using the stair mesh + collider. The facade side comes from the + cell's door/window/wall bits (exactly one door side required); the + flight direction is the along-facade side with room for the run + (+ preferred) and may extend past the building corner over the + ground. DSL: `(floor n (door side offset :gallery))` (upper floors + only); the DSL help documents it; the editor flag list shows + "Exterior Stair". Limits: no rail along the flight; the flight + bottoms out at the grid base (the terrain foundation must meet it); + the gallery door is a normal door (closed leaf blocks the capsule, + the navmesh treats doors as passable per F7). +- Grand / lobby stairs (phase 7.5): the layout follows a stairwell + block's aspect - compact blocks build the two-flight dogleg, + elongated blocks (aspect >= 1.5, run >= 3 cells) build ONE wide + straight flight spanning the block's short side (width = + shortCells * cellSize - 0.30); single-row runs keep the 1.8 m + comfort cap, so existing layouts are unchanged. Wide flights reuse + the same step contract. Curved/branching flights and railings + inside stairwell rooms are explicitly out of scope (the shaft walls + enclose the flights; an open grand stair ending mid-room would need + a railing system). Coverage: `tests/cellgrid_door_test.cpp` test 4 (flag + rect round trip, old-scene defaults), the terrain step @@ -3130,14 +3183,22 @@ 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); +must build stair geometry AND a two-storey layout with no stair room +that must get an automatic stairwell (cells + mesh), plus an +exterior-stair fixture (upper door + `ExteriorStair`: mesh outside the +facade, reaches the upper floor, Recast path ground->upper through the +gallery door), and a 3 x 2-cell grand-stair block (one continuous +3.7 m wide flight, the whole block opened above); +`road_geometry_overlap_test` DSL probes +(rooms-first + filled-shell flag placement and `door :gallery`); `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 +spawn has no physics capsule), a 2 m-cell three-cell run climbs at NPC +walk speed, and an exterior-stair fixture is climbed from the ground +to the gallery; 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. diff --git a/src/features/editScene/components/CellGrid.cpp b/src/features/editScene/components/CellGrid.cpp index 630a618..d69d460 100644 --- a/src/features/editScene/components/CellGrid.cpp +++ b/src/features/editScene/components/CellGrid.cpp @@ -164,6 +164,7 @@ const char* CellGridComponent::getFlagName(uint64_t flag) case CellFlags::IntWindowZPos: return "Int Window Z+"; case CellFlags::IntWindowZNeg: return "Int Window Z-"; case CellFlags::StairWell: return "Stairwell"; + case CellFlags::ExteriorStair: return "Exterior Stair"; default: return "Unknown"; } } @@ -209,5 +210,6 @@ std::vector> CellGridComponent::getAllFlags() { CellFlags::IntWindowZPos, "Int Window Z+" }, { CellFlags::IntWindowZNeg, "Int Window Z-" }, { CellFlags::StairWell, "Stairwell" }, + { CellFlags::ExteriorStair, "Exterior Stair" }, }; } diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp index df19f51..55934a0 100644 --- a/src/features/editScene/components/CellGrid.hpp +++ b/src/features/editScene/components/CellGrid.hpp @@ -65,6 +65,13 @@ namespace CellFlags { // (x, z) on consecutive levels. constexpr uint64_t StairWell = 1ULL << 26; + // Phase 7.4: an exterior stair. On an upper-floor boundary cell + // carrying an exterior door on one side, the generator builds an + // outside walkway landing in front of the door and a straight + // flight descending along that facade to the ground level + // (motel-style external stairs). Does not cut any floors. + constexpr uint64_t ExteriorStair = 1ULL << 27; + // Masks constexpr uint64_t AllExternalWalls = WallXNeg | WallXPos | WallZPos | WallZNeg; constexpr uint64_t AllInternalWalls = IntWallXNeg | IntWallXPos | IntWallZPos | IntWallZNeg; diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index df4b662..4938dac 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -5795,7 +5795,8 @@ 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:** 7.1, 7.2 and 7.3 DONE (2026-10-30). 7.4-7.5 planned. +**Status:** Phase 7 COMPLETE — 7.1-7.5 DONE, including 7.3a +(automatic stairwells for multi-storey layouts) (2026-10-30). **How to read this phase.** 7.1 is the vertical slice: one hand- authored grid, one stairwell, real geometry. 7.2 proves traversal. @@ -5856,6 +5857,14 @@ verification, and every slice keeps the global invariant: 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. +- **Climbability envelope (phase 7.3a findings):** the Jolt character + mounts a 0.174 m riser only when the horizontal approach is fast + enough and the tread is not too short. Measured: 1.4 m/s (the NPC + walk speed) and 1.5 m/s climb 0.30 m and 0.248 m treads; 1.2 m/s + does not. Straight runs therefore need >= 3 cells at 2 m cells + (tread >= ~0.25) and the generator prefers 4-cell runs + (tread 0.30). A one-cell 2 m dogleg (tread ~0.09) is never + climbable; the dogleg is for larger cells / hand-authored grids. - 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 @@ -5958,29 +5967,131 @@ Verified: built stair geometry. - `component_lua_test` 67/67, `cellgrid_door_test` 15/15. -#### Phase 7.4 — Exterior stairs (motel-style) +#### Phase 7.3a — automatic stairwells for generated layouts — DONE -- Stairs outside the shell walls: exterior corridors/galleries on - upper floors reached by an external flight (as in many motels). - Reuses the 7.1 step geometry and materials; placement is driven by - authored cells/DSL, not by a town-wide rule. +The town generator now guarantees that every multi-storey building it +places has a stairwell: -Verify: -- Headless: character climbs from terrain level to the upper - gallery and enters through its exterior door; navmesh connects - terrain to the gallery. +- `TownGenSystem::fillBuildingCells` calls + `autoPlaceStairwell()`: an explicit plan stair cell (a + `:type stairwell` room) wins; otherwise a deterministic straight run + is carved along the longer usable axis - 4 cells first (tread + 0.30 at the DSL's 2 m cells, the verified climbable shape), 3 cells + as a fallback - on the first wall/door-free row or column that has a + boarding cell on the ground floor and a step-off cell on the top + floor. Interior walls and internal windows on the run cells are + opened (a carved stairwell may cross room boundaries); the facing + walls at the boarding and step-off cells are opened too. When + nothing fits (very small lots) the building logs one + `TownGen: warning - no stairwell fits building ...` line and stays + as before. +- This covers the built-in parametric layouts (`village-house`, + `town-rowhouse`, `city-block`, `city-tower` - any plan drawn with + `rand-int min-floors max-floors` > 1), `town_layouts.txt` layouts + (`two-floor-house` etc.) and inline sources, without editing any + layout text. Exterior-only shells are unchanged (stairs are + interior geometry): generated town fronts stay bit-identical. +- Furniture placement now refuses stair cells and the cells directly + above them (`FurniturePlacement::canPlace`: "blocks a stairwell" / + "above a stairwell opening"), so room furniture cannot block a + stair or float over the opening. +- DSL help window: the `room` verb line documents + `:type stairwell` and the automatic stairwell; two new examples in + `LayoutLibrary::examples()`: "Two storeys, automatic stairwell" + and "Two-storey house with a stairwell room (rooms-first)" (the + explicit recipe: a 3-cell stair room with `connect` doorways on the + boarding and step-off sides). -#### Phase 7.5 — Grand / lobby stairs (research, then implement) +Verified: `cellGridStairs` additionally previews a two-storey layout +with NO stair room and asserts the automatic stairwell cells + +geometry; `road_geometry_overlap_test`, `component_lua_test`, +`cellgrid_door_test`, CTest 9/9, and the demo batteries +(`--test-stairs`: 4 m straight + 2 m 3-cell run climbs at NPC walk +speed; `--test-furniture`, `--test-population-switch`, +`--test-switch`) all PASS. -Open design question carried over from the original phase notes: -typing a room `stairwell` is the easiest DSL/component authoring -path, but lobbies of hotels, theatres and administrative buildings -want a **large stair inside an ordinary room** — wide flights, -possibly curved or branching, not a utilitarian shaft. Research -first: how a wide multi-cell flight is authored (room option vs. -dedicated cells), whether the 7.1 straight/dogleg layouts suffice -when scaled up, and what railing/balustrade geometry is needed. -Only then implement. +#### Phase 7.4 — Exterior stairs (motel-style) — DONE + +As built (2026-10-30): + +- `CellFlags::ExteriorStair` (bit 27, mirrored in `PlanFlags` with a + static_assert) on an UPPER-floor boundary cell carrying an exterior + door on one side. The generator builds, outside that facade: + - a walkway landing in front of the door (1.5 m deep, flush with the + upper floor surface) with a 0.9 m outer rail band (wall buffer + + collider, so nobody walks off and Recast sees the edge), and + - a straight flight descending along the facade from the landing to + the grid base level: 0.18 rise cap (0.175 at 4 m floors, 24 + steps), tread 0.30 shrunk only when the facade is short (warn + below 0.12), solid boxes, depth 1.4 against the wall. + The flight runs in the direction (along the facade) that has room + for the run, +axis preferred, and extends past the building corner + over the ground when needed. Steps go into the stair mesh with its + collider; the rail goes into the internal wall buffer. Warnings + (once per cell) for: ambiguous facade doors, no facade opening at + all, no room for the flight, and the flag on the ground floor. +- Authoring: the DSL door verb accepts `:gallery` on upper floors - + `(floor 1 (door front -4 :gallery))` marks that door cell as an + exterior stair; the DSL help window documents it and the room verb's + stairwell note. The Cell Grid editor's per-cell flag list shows + "Exterior Stair" automatically. Serialization rides the raw cell + flags (no schema change); Lua unaffected (no new component field). +- Limits (documented, not blockers): no rail along the flight itself + (only the landing rail); the flight bottoms out at the grid base + level, so the terrain/foundation must meet the building base (the + town foundations already do); the gallery door is a normal door - + a closed leaf blocks the capsule until opened, while the navmesh + treats doors as passable (F7). + +Verified: +- `TERRAIN_TEST_FILTER=cellGridStairs` builds an exterior fixture + (5 x 2 x 3 grid at 2 m cells, upper door + exterior flag): the stair + mesh reaches the upper floor and has geometry outside the facade + (z > 5), and a Recast path runs from the ground to the upper floor + through the gallery door. +- `demoTaxi --test-stairs` gained an exterior-walk phase: a capsule + climbs the outside flight from the ground to the gallery at NPC + walk speed (PASS at Y > 3.9). `road_geometry_overlap_test` asserts + the `:gallery` DSL flag placement. CTest 9/9 and the demo + batteries stay green. + +#### Phase 7.5 — Grand / lobby stairs — DONE + +Research conclusions (2026-10-30): + +- **Authoring unit: the existing `:type stairwell` room**, no new + verb. The room's aspect decides the layout: compact blocks keep the + two-flight dogleg; **elongated blocks (aspect >= 1.5, run of >= 3 + cells) now build ONE wide straight flight** spanning the block's + short side - the grand lobby stair. Wide flights are a generator + change on the cell flags, so hand-authored multi-row `StairWell` + blocks and DSL stairwell rooms get them alike. +- **Curved/branching flights: out of scope** (documented). A curved + stair would need a path-based mesh generator and is not justified by + the current building stock; branching is expressible as two + stairwell rooms. +- **Railings**: not generated inside stairwell rooms - the shaft walls + enclose the flights, and a wide flight spans the room up to the + 0.15 m ring margin per side. An open grand stair ending mid-room + would need a railing system; deferred (the shipped layouts do not + need it). +- Wide flights reuse the 7.1 step contract: 0.18 rise cap, 0.30 tread + (shrunk on short runs with a warning), solid boxes, stair mesh + + collider, `stairRectName`/floor rect material. + +As built: the block branch of `CellGridSystem::buildStairs` computes +the block aspect; aspect >= 1.5 with a run of >= 3 cells emits one +straight flight across the full short side (width = shortCells * +cellSize - 0.30); otherwise the dogleg is kept. Single-row runs keep +the 1.8 m comfort cap, so existing layouts are unchanged. + +Verified: the `cellGridStairs` terrain step adds a 3 x 2-cell stairwell +block in a 6 x 2 x 6 grid and asserts the stair mesh's first tread +spans one continuous 3.7 m wide flight (z extent at the tread-top +level) and the level above is opened over the whole block; +`road_geometry_overlap_test` and the demo batteries stay green. The +DSL help window documents the wide-stairwell room and ships a "Grand +lobby stair (wide stairwell room)" example. **Explicitly out of scope for Phase 7:** interior scene generation and door wiring (Phase 8), spiral stairs, elevators, and town-wide @@ -5989,6 +6100,7 @@ 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. +- That is only place where we can start properly verifying verify some of previous things, so be ready to fix older problems here. - 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. diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index faf5e1e..6275614 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -4605,16 +4605,30 @@ struct StairsTestListener : public Ogre::FrameListener { Ogre::Vector3 stairOrigin = Ogre::Vector3(0, 0, 20); Ogre::Vector3 plainOrigin = Ogre::Vector3(40, 0, 20); + Ogre::Vector3 dogOrigin = Ogre::Vector3(0, 0, 40); + Ogre::Vector3 extOrigin = Ogre::Vector3(0, 0, 60); Ogre::SceneNode *stairNode = nullptr; Ogre::SceneNode *plainNode = nullptr; + Ogre::SceneNode *dogNode = nullptr; + Ogre::SceneNode *extNode = nullptr; Ogre::SceneNode *walkerNode = nullptr; + Ogre::SceneNode *dogWalkerNode = nullptr; + Ogre::SceneNode *extWalkerNode = nullptr; flecs::entity stairGrid; flecs::entity plainGrid; + flecs::entity dogGrid; + flecs::entity extGrid; flecs::entity walker; + flecs::entity dogWalker; + flecs::entity extWalker; flecs::entity navE; bool navBuilt = false; int buildFrame = -1; int walkFrame = -1; + int dogBuildFrame = -1; + int dogWalkFrame = -1; + int extBuildFrame = -1; + int extWalkFrame = -1; StairsTestListener(EditorApp *a) : app(a) @@ -4631,17 +4645,19 @@ struct StairsTestListener : public Ogre::FrameListener { app->getRoot()->queueEndRendering(); } - flecs::entity buildGrid(const char *name, const Ogre::Vector3 &origin, - bool withStairs, Ogre::SceneNode *&nodeOut) + flecs::entity buildGridGeneric(const char *name, + const Ogre::Vector3 &origin, float cs, + int w, int h, int d, int stairMode, + Ogre::SceneNode *&nodeOut) { - flecs::world *w = app->getWorld(); + flecs::world *wf = app->getWorld(); Ogre::SceneNode *node = app->getSceneManager() ->getRootSceneNode() ->createChildSceneNode(name); node->setPosition(origin); nodeOut = node; - flecs::entity e = w->entity(); + flecs::entity e = wf->entity(); TransformComponent t; t.node = node; t.position = origin; @@ -4651,10 +4667,10 @@ struct StairsTestListener : public Ogre::FrameListener { CellGridComponent grid; grid.gridUid = name; - grid.width = GRID_W; - grid.height = GRID_H; - grid.depth = GRID_D; - grid.cellSize = CELL_SIZE; + grid.width = w; + grid.height = h; + grid.depth = d; + grid.cellSize = cs; grid.cellHeight = CELL_HEIGHT; for (int y = 0; y < grid.height; ++y) for (int z = 0; z < grid.depth; ++z) @@ -4663,26 +4679,41 @@ struct StairsTestListener : public Ogre::FrameListener { c.flags = CellFlags::Floor | CellFlags::Ceiling; } - if (withStairs) + if (stairMode == 1) /* straight run x 2..4, z 1 */ for (int x = 2; x <= 4; ++x) grid.getOrCreateCell(x, 0, 1) .setFlag(CellFlags::StairWell); + else if (stairMode == 2) /* 2 m cells: 3-cell straight run x1..3 */ + for (int x = 1; x <= 3; ++x) + grid.getOrCreateCell(x, 0, 1) + .setFlag(CellFlags::StairWell); + else if (stairMode == 3) /* exterior stair door cell */ + grid.getOrCreateCell(0, 1, 2) + .setFlag(CellFlags::DoorZPos | + CellFlags::ExteriorStair); e.set(grid); return e; } + flecs::entity buildGrid(const char *name, const Ogre::Vector3 &origin, + bool withStairs, Ogre::SceneNode *&nodeOut) + { + return buildGridGeneric(name, origin, CELL_SIZE, GRID_W, GRID_H, + GRID_D, withStairs ? 1 : 0, nodeOut); + } + /* 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::entity buildWalker(const char *name, const Ogre::Vector3 &pos, + Ogre::SceneNode *&outNode) { flecs::world *w = app->getWorld(); Ogre::SceneNode *node = app->getSceneManager() ->getRootSceneNode() - ->createChildSceneNode( - "StairsTestWalker"); + ->createChildSceneNode(name); node->setPosition(pos); - walkerNode = node; + outNode = node; flecs::entity e = w->entity(); TransformComponent t; t.node = node; @@ -4699,11 +4730,11 @@ struct StairsTestListener : public Ogre::FrameListener { bool frameRenderingQueued(const Ogre::FrameEvent &) override { frame++; - if (failed || phase == 3) { + if (failed || phase == 7) { app->getRoot()->queueEndRendering(); return true; } - if (frame > 4000) { + if (frame > 8000) { fail("timeout in phase " + std::to_string(phase)); return true; } @@ -4722,12 +4753,13 @@ struct StairsTestListener : public Ogre::FrameListener { /* 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. */ + /* Spawn the walker just before the flight (short + * run-up; the narrow fixtures start close too). */ const Ogre::Vector3 start = - stairOrigin + Ogre::Vector3(0.0f, 0.35f, + stairOrigin + Ogre::Vector3(9.8f, 0.35f, CELL_SIZE); - walker = buildWalker(start); + walker = buildWalker("StairsTestWalker", start, + walkerNode); walkFrame = frame; phase = 1; std::cout << "[test] stairs: walking up from " @@ -4763,6 +4795,101 @@ struct StairsTestListener : public Ogre::FrameListener { return true; } case 2: { + /* 2 m-cell 3-cell straight run. */ + if (dogBuildFrame < 0) { + dogGrid = buildGridGeneric( + "StairsTestNarrow", dogOrigin, 2.0f, + 5, 2, 4, 2, dogNode); + dogBuildFrame = frame; + return true; + } + if (frame - dogBuildFrame < 40) + return true; + const Ogre::Vector3 start = + dogOrigin + Ogre::Vector3(0.0f, 0.35f, 2.0f); + dogWalker = buildWalker("StairsTestNarrowWalker", + start, dogWalkerNode); + dogWalkFrame = frame; + phase = 3; + std::cout << "[test] stairs: narrow-cell walk from " + << start << std::endl; + return true; + } + case 3: { + /* Walk +X up the 3-cell flight. */ + if (!dogWalker.is_alive() || !dogWalkerNode) + return true; + CharacterComponent &cc = + dogWalker.get_mut(); + const Ogre::Vector3 p = dogWalkerNode->getPosition(); + const float lx = p.x - dogOrigin.x; + cc.linearVelocity = Ogre::Vector3(1.4f, 0.0f, 0.0f); + if (p.y > 3.9f) { + cc.linearVelocity = Ogre::Vector3::ZERO; + std::cout << "[test] stairs: narrow-cell run " + "reached upper floor at " + << p << " (" + << (frame - dogWalkFrame) + << " frames, y " + << p.y << ")" << std::endl; + phase = 4; + return true; + } + if (lx > 10.0f) { + cc.linearVelocity = Ogre::Vector3::ZERO; + fail("narrow-cell walker left the stairwell " + "without climbing (Y " + + Ogre::StringConverter::toString(p.y) + + ", lx " + + Ogre::StringConverter::toString(lx) + + ")"); + } + return true; + } + case 4: { + /* Phase 7.4: exterior stair - walk up the outside + * flight from the ground to the gallery landing. */ + if (extBuildFrame < 0) { + extGrid = buildGridGeneric( + "StairsTestExterior", extOrigin, + 2.0f, 5, 2, 3, 3, extNode); + extBuildFrame = frame; + return true; + } + if (frame - extBuildFrame < 40) + return true; + const Ogre::Vector3 start = + extOrigin + Ogre::Vector3(8.2f, 0.35f, 5.75f); + extWalker = buildWalker("StairsTestExteriorWalker", + start, extWalkerNode); + extWalkFrame = frame; + phase = 5; + std::cout << "[test] stairs: exterior walk from " + << start << std::endl; + return true; + } + case 5: { + /* Walk -X up the exterior flight. */ + if (!extWalker.is_alive() || !extWalkerNode) + return true; + CharacterComponent &cc = + extWalker.get_mut(); + cc.linearVelocity = Ogre::Vector3(-1.5f, 0.0f, 0.0f); + const Ogre::Vector3 p = extWalkerNode->getPosition(); + if (p.y > 3.9f) { + cc.linearVelocity = Ogre::Vector3::ZERO; + std::cout << "[test] stairs: exterior stair " + "reached the gallery at " + << p << " (" + << (frame - extWalkFrame) + << " frames, y " + << p.y << ")" << std::endl; + phase = 6; + return true; + } + return true; + } + case 6: { /* Navmesh: ground->top path in the stair building, * none in the stairless twin. */ if (buildFrame >= 0 && plainGrid.id() == 0) { @@ -4841,7 +4968,7 @@ struct StairsTestListener : public Ogre::FrameListener { std::cout << "[test] PASS: stairs are walkable and " "navmesh-connected; stairless floors are " "not" << std::endl; - phase = 3; + phase = 7; app->getRoot()->queueEndRendering(); return true; } @@ -11998,7 +12125,7 @@ int main(int argc, char *argv[]) app.closeApp(); return 1; } - if (stairsListener.phase != 3) { + if (stairsListener.phase != 7) { std::cerr << "[test] FAIL: incomplete (stairs " "phase " << stairsListener.phase << ")" diff --git a/src/features/editScene/layoutlib/BuildingPlan.hpp b/src/features/editScene/layoutlib/BuildingPlan.hpp index 1b34bf5..96a24b8 100644 --- a/src/features/editScene/layoutlib/BuildingPlan.hpp +++ b/src/features/editScene/layoutlib/BuildingPlan.hpp @@ -58,6 +58,8 @@ 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; +/* Phase 7.4: exterior stair cell (see CellFlags::ExteriorStair). */ +constexpr uint64_t ExteriorStair = 1ULL << 27; } /** 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 780e531..ca47451 100644 --- a/src/features/editScene/layoutlib/LayoutEval.cpp +++ b/src/features/editScene/layoutlib/LayoutEval.cpp @@ -373,6 +373,49 @@ const std::vector &LayoutLibrary::examples() " (windows all :spacing 4))\n" "(roof flat 6 0 12 8 :level 1)\n" "(roof gable 0 0 6 8 :height 2)\n" }, + { "Two storeys, automatic stairwell (phase 7.3)", + "(shell 12 8 :floors 2)\n" + "(floor 0 (door front center)\n" + " (windows all))\n" + "; Any layout with :floors 2 or more and no stairwell\n" + "; room gets a straight-run stairwell carved by the\n" + "; generator (boarding and step-off cells included).\n" }, + { "Two-storey house with a stairwell room (rooms-first)", + "(shell 12 8 :floors 2 :empty)\n" + "(floor 0 (room hall (rect 0 0 0.166 1))\n" + " (room stair (rect 0.166 0.25 0.666 0.5)\n" + " :type stairwell)\n" + " (room back (rect 0.166 0 1 0.25))\n" + " (room front (rect 0.166 0.5 1 1))\n" + " (room east (rect 0.666 0.25 1 0.5))\n" + " (connect hall stair)\n" + " (connect stair east)\n" + " (connect hall back)\n" + " (connect hall front)\n" + " (door front center)\n" + " (windows all))\n" + "(floor 1 (room upstairs (rect 0 0 1 1))\n" + " (windows all))\n" + "(roof gable :height 2)\n" }, + { "Grand lobby stair (wide stairwell room, phase 7.5)", + "(shell 12 12 :floors 2 :empty)\n" + "(floor 0 (room lobby (rect 0 0 0.333 1))\n" + " (room stair (rect 0.333 0.166 0.833 0.5)\n" + " :type stairwell)\n" + " (room east (rect 0.833 0.166 1 0.5))\n" + " (room back (rect 0.333 0 1 0.166))\n" + " (room south (rect 0.333 0.5 1 1))\n" + " (connect lobby stair)\n" + " (connect stair east)\n" + " (connect lobby back)\n" + " (connect lobby south)\n" + " (door front -4)\n" + " (windows all))\n" + "(floor 1 (room hall (rect 0 0 1 1))\n" + " (windows all))\n" + "(roof gable :height 2)\n" + "; An elongated stairwell room (here 3 x 2 cells) builds\n" + "; ONE wide straight flight instead of a dogleg.\n" }, { "Windowless bathroom (per-room :windows none)", "(shell 12 8 :empty)\n" "(floor 0 (room living (rect 0 0 0.5 1))\n" @@ -856,7 +899,7 @@ bool LayoutEval::evalList(const SExpr &e, Value &out) else if (op == "cut") ok = verbCut(pos, e); else if (op == "door") - ok = verbDoor(pos, e); + ok = verbDoor(pos, kw, e); else if (op == "windows") ok = verbWindows(pos, kw, e); else if (op == "idoor") @@ -1177,9 +1220,11 @@ bool LayoutEval::verbCut(const std::vector &pos, const SExpr &at) return true; } -bool LayoutEval::verbDoor(const std::vector &pos, const SExpr &at) +bool LayoutEval::verbDoor(const std::vector &pos, + const std::map &kw, + const SExpr &at) { - /* (door front|back|left|right center|) */ + /* (door front|back|left|right center| [:gallery]) */ if (pos.size() < 1) return fail(at, "door needs (door side [center|offset])"); std::string side; @@ -1274,8 +1319,18 @@ bool LayoutEval::verbDoor(const std::vector &pos, const SExpr &at) z = horizontal ? idx : (si == 0 ? m_plan->cellsZ - 1 : 0); } - return placeOpening(x, z, wallBit[si] | winBit[si], - doorBit[si] | intDoorBit[si], at); + if (!placeOpening(x, z, wallBit[si] | winBit[si], + doorBit[si] | intDoorBit[si], at)) + return false; + /* Phase 7.4: :gallery marks the door cell as an exterior stair + * (the generator adds an outside walkway landing and a flight + * descending along this facade). */ + if (kw.find(":gallery") != kw.end()) { + if (m_floor < 1) + return fail(at, "door :gallery needs an upper floor"); + m_plan->at(x, m_floor, z) |= PlanFlags::ExteriorStair; + } + return true; } void LayoutEval::windowsOnSide(int side, float spacing) diff --git a/src/features/editScene/layoutlib/LayoutEval.hpp b/src/features/editScene/layoutlib/LayoutEval.hpp index d606cb3..1bf4b14 100644 --- a/src/features/editScene/layoutlib/LayoutEval.hpp +++ b/src/features/editScene/layoutlib/LayoutEval.hpp @@ -267,7 +267,9 @@ private: bool verbRoomRaw(const SExpr &form); bool verbCellSize(const std::vector &pos, const SExpr &at); bool verbCut(const std::vector &pos, const SExpr &at); - bool verbDoor(const std::vector &pos, const SExpr &at); + bool verbDoor(const std::vector &pos, + const std::map &kw, + const SExpr &at); bool verbWindows(const std::vector &pos, const std::map &kw, const SExpr &at); diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index eb6b892..d144c09 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -876,8 +876,18 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, " stair cell(s) have no floor above (grid height " + std::to_string(grid.height) + "); ignored"); } - if (stairCells.empty()) - return; + if (stairCells.empty()) { + /* Interior stairwells may be absent while exterior stairs + * (phase 7.4) are requested; keep going for those. */ + bool hasExterior = false; + for (const auto &cell : grid.cells) + if (cell.flags & CellFlags::ExteriorStair) { + hasExterior = true; + break; + } + if (!hasExterior) + return; + } /* ---- Opening rims: one slab-edge ring per hole level. ---- */ for (int level = 1; level < grid.height; ++level) { @@ -978,6 +988,138 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, } }; + /* Dogleg over a rectangular block (one cell for a lone stair + * cell, 2x2+ cells where the footprint is wider than deep - the + * shape generated 2 m-cell layouts use, since a one-cell 2 m + * dogleg would be too steep to climb). 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, so columns stack boarding and step-off on the same side. */ + auto emitDogleg = [&](int dx0, int dx1, int dz0, int dz1, int y) { + const float baseY = (float)y * ch; + const float botY = baseY + floorY; + const float minX = (float)dx0 * cs - half + STAIR_RING; + const float maxX = (float)dx1 * cs + half - STAIR_RING; + const float minZ = (float)dz0 * cs - half + STAIR_RING; + const float maxZ = (float)dz1 * 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(dx0) + "," + std::to_string(y) + + "," + std::to_string(dz0) + ")"); + return; + } + int n; + float rise, tread; + planSteps(spanZ, n, rise, tread); + const int nA = (n + 1) / 2; + const int nB = n - nA; + /* Spend the span on flights first, keeping a landing of + * 0.6..1.2 m at the +Z end. */ + const float landing = + std::min(1.2f, std::max(0.6f, spanZ * 0.3f)); + float treadA = (spanZ - landing) / (float)nA; + if (treadA < 0.12f) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - dogleg tread too short at " + "(" + std::to_string(dx0) + "," + + std::to_string(y) + "," + + std::to_string(dz0) + ")"); + treadA = 0.12f; + } + 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 }); + } + }; + + /* Straight flight along +X over [bx0..bx1] x [bz0..bz1]: a + * single-row block keeps the comfortable 1.8 m width cap, a + * multi-row block (phase 7.5 grand stair) spans the whole block + * width as ONE wide flight. */ + auto emitStraightX = [&](int bx0, int bx1, int bz0, int bz1, int y) { + const float baseY = (float)y * ch; + const float botY = baseY + floorY; + const float runLen = (float)(bx1 - bx0 + 1) * cs; + int n; + float rise, tread; + planSteps(runLen, n, rise, tread); + const float width = + (bz0 == bz1) ? + std::min(STAIR_MAX_WIDTH, + cs - 2.0f * STAIR_RING) : + (float)(bz1 - bz0 + 1) * cs - + 2.0f * STAIR_RING; + const float xHigh = (float)bx1 * cs + half - STAIR_RING; + const float xStart = xHigh - (float)n * tread; + const float zc = ((float)bz0 + (float)bz1) * 0.5f * cs; + 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, zc }, + { tread, top - baseY, width }); + } + }; + + /* Straight flight along +Z over [bx0..bx1] x [bz0..bz1]. */ + auto emitStraightZ = [&](int bx0, int bx1, int bz0, int bz1, int y) { + const float baseY = (float)y * ch; + const float botY = baseY + floorY; + const float runLen = (float)(bz1 - bz0 + 1) * cs; + int n; + float rise, tread; + planSteps(runLen, n, rise, tread); + const float width = + (bx0 == bx1) ? + std::min(STAIR_MAX_WIDTH, + cs - 2.0f * STAIR_RING) : + (float)(bx1 - bx0 + 1) * cs - + 2.0f * STAIR_RING; + const float zHigh = (float)bz1 * cs + half - STAIR_RING; + const float zStart = zHigh - (float)n * tread; + const float xc = ((float)bx0 + (float)bx1) * 0.5f * cs; + for (int i = 0; i < n; ++i) { + const float top = botY + (float)(i + 1) * rise; + const float sz = zStart + (float)i * tread; + genStairBox(stairTb, + { xc, (baseY + top) * 0.5f, + sz + tread * 0.5f }, + { width, top - baseY, tread }); + } + }; + std::set consumed; for (const auto &cell : grid.cells) { if (!(cell.flags & CellFlags::StairWell)) @@ -987,9 +1129,6 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, 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))) @@ -1004,124 +1143,187 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, 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 }); + if (x1 > x0 && z1 > z0) { + const int spanX = x1 - x0 + 1; + const int spanZ = z1 - z0 + 1; + const bool xLonger = spanX >= spanZ; + const int longSpan = xLonger ? spanX : spanZ; + const int shortSpan = xLonger ? spanZ : spanX; + /* Phase 7.5: an elongated block (aspect >= 1.5 and + * a run of at least 3 cells) becomes ONE wide + * straight flight - the grand lobby stair. Compact + * blocks keep the two-flight dogleg. */ + if (longSpan >= 3 && longSpan * 2 >= shortSpan * 3) { + if (xLonger) + emitStraightX(x0, x1, z0, z1, + cell.y); + else + emitStraightZ(x0, x1, z0, z1, + cell.y); + } else { + emitDogleg(x0, x1, z0, z1, cell.y); } + for (int z = z0; z <= z1; ++z) + for (int x = x0; x <= x1; ++x) + if (stairCells.count( + cellKey(x, cell.y, z))) + consumed.insert( + cellKey(x, cell.y, + z)); + } else if (x1 > x0) { + emitStraightX(x0, x1, cell.z, cell.z, cell.y); 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 }); - } + emitStraightZ(cell.x, cell.x, z0, z1, cell.y); 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) { + /* Lone stair cell: one-cell dogleg. */ + emitDogleg(cell.x, cell.x, cell.z, cell.z, cell.y); + consumed.insert(cell.getKey()); + } + } + + /* ---- Phase 7.4: exterior stairs (motel-style). ---- */ + const float extDepth = 1.5f; + const float extRailH = 0.9f; + const float extRailT = 0.08f; + for (const auto &cell : grid.cells) { + if (!(cell.flags & CellFlags::ExteriorStair)) + continue; + if (cell.y < 1) + continue; /* no upper floor to reach */ + /* Facade choice: the side with an exterior door; a single + * door side is required when several sides carry one. */ + static const uint64_t sideDoor[4] = { + CellFlags::DoorZPos, CellFlags::DoorZNeg, + CellFlags::DoorXNeg, CellFlags::DoorXPos + }; + static const uint64_t sideWin[4] = { + CellFlags::WindowZPos, CellFlags::WindowZNeg, + CellFlags::WindowXNeg, CellFlags::WindowXPos + }; + static const uint64_t sideWall[4] = { + CellFlags::WallZPos, CellFlags::WallZNeg, + CellFlags::WallXNeg, CellFlags::WallXPos + }; + int doorSide = -1; + int doorCount = 0; + int anySide = -1; + for (int s = 0; s < 4; ++s) { + if (cell.flags & sideDoor[s]) { + ++doorCount; + doorSide = s; + } + if (anySide < 0 && + (cell.flags & (sideDoor[s] | sideWin[s] | + sideWall[s]))) + anySide = s; + } + const int side = doorSide >= 0 ? doorSide : anySide; + if (doorCount > 1 || side < 0) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - exterior stair at (" + + std::to_string(cell.x) + "," + + std::to_string(cell.y) + "," + + std::to_string(cell.z) + + ") needs exactly one facade door"); + continue; + } + const bool alongX = side < 2; /* Z faces run along X */ + const float uCenter = + (float)(alongX ? cell.x : cell.z) * cs; + const float uMin = uCenter - half; + const float uMax = uCenter + half; + const float landY = (float)cell.y * ch + floorY; + const float groundY = 0.0f; + const int alongCount = alongX ? grid.width : grid.depth; + const int alongCell = alongX ? cell.x : cell.z; + int n = std::max( + 2, (int)std::ceil((landY - groundY) / + STAIR_RISE_MAX)); + const float stepRise = (landY - groundY) / (float)n; + float tread = STAIR_TREAD; + float run = (float)(n - 1) * tread; + const int availPos = alongCount - 1 - alongCell; + const int availNeg = alongCell; + int dir = 0; + if ((float)availPos * cs >= run + 0.2f) + dir = 1; + else if ((float)availNeg * cs >= run + 0.2f) + dir = -1; + if (dir == 0) { + const int avail = + std::max(availPos, availNeg); + dir = availPos >= availNeg ? 1 : -1; + const float fit = (float)avail * cs; + if (fit < 2.0f) { Ogre::LogManager::getSingleton().logMessage( - "CellGrid: warning - cell too small " - "for a dogleg stair at (" + + "CellGrid: warning - exterior stair " + "at (" + + std::to_string(cell.x) + "," + + std::to_string(cell.y) + "," + + std::to_string(cell.z) + + ") has no facade room for a flight"); + continue; + } + tread = std::min(tread, + fit / (float)(n - 1)); + run = (float)(n - 1) * tread; + if (tread < 0.12f) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: warning - exterior stair " + "tread too short 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 }); + } + /* Facade plane and outward direction. */ + const float face = (side == 0) ? (float)cell.z * cs + half : + (side == 1) ? (float)cell.z * cs - half : + (side == 2) ? (float)cell.x * cs - half : + (float)cell.x * cs + half; + const float outSign = (side == 0 || side == 3) ? 1.0f : -1.0f; + /* Box placement in facade space: u along the face, v + * outward from the wall plane. */ + auto placeFacadeBox = [&](float uMid, float uSize, + float vMid, float vSize, + float yMin, float yMax, + Procedural::TriangleBuffer &tb) { + const float yc = (yMin + yMax) * 0.5f; + Ogre::Vector3 center, size; + if (alongX) { + center = Ogre::Vector3( + uMid, yc, face + outSign * vMid); + size = Ogre::Vector3(uSize, yMax - yMin, + vSize); + } else { + center = Ogre::Vector3( + face + outSign * vMid, yc, uMid); + size = Ogre::Vector3(vSize, yMax - yMin, + uSize); } - /* 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()); + genStairBox(tb, center, size); + }; + /* Walkway landing in front of the door. */ + placeFacadeBox(uCenter, cs, extDepth * 0.5f, extDepth, + landY - 0.3f, landY, stairTb); + /* Outer rail on the landing. */ + placeFacadeBox(uCenter, cs, extDepth - extRailT * 0.5f, + extRailT, landY, landY + extRailH, wallTb); + /* Straight flight descending along the facade. */ + const float uEdge = dir > 0 ? uMax : uMin; + for (int j = 0; j < n - 1; ++j) { + const float top = + groundY + (float)(n - 1 - j) * stepRise; + const float uStart = dir > 0 ? + uEdge + (float)j * tread : + uEdge - (float)(j + 1) * tread; + placeFacadeBox(uStart + tread * 0.5f, tread, 0.7f, + 1.4f, groundY, top, stairTb); } } } diff --git a/src/features/editScene/systems/FurniturePlacement.cpp b/src/features/editScene/systems/FurniturePlacement.cpp index e2cf2f1..83d7c37 100644 --- a/src/features/editScene/systems/FurniturePlacement.cpp +++ b/src/features/editScene/systems/FurniturePlacement.cpp @@ -112,6 +112,19 @@ bool FurniturePlacement::canPlace(const CellGridComponent &grid, *error = "requires a floor"; return false; } + /* Phase 7.3a: stairs and their upper-floor openings are not + * furniture space. */ + if (cell->flags & CellFlags::StairWell) { + if (error) + *error = "blocks a stairwell"; + return false; + } + const Cell *below = grid.findCell(cx, request.y - 1, cz); + if (below && (below->flags & CellFlags::StairWell)) { + if (error) + *error = "above a stairwell opening"; + return false; + } } if (def->requiresWall || diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index f91de2d..4185a42 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -7525,6 +7525,42 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) } } tgs.clearLayoutPreview(); + + /* Phase 7.3a: a multi-storey layout WITHOUT a stair room + * gets an automatic straight-run stairwell. */ + if (!tgs.previewLayout( + "(shell 12 8 :floors 2)" + "(floor 0 (door front center))", + nullptr, 0, 42, Ogre::Vector3(0, 0, 0), "full", + &dslError)) { + fail("auto-stairwell preview failed: " + dslError); + } else { + flecs::entity autoBuilding; + w->each([&](flecs::entity e, CellGridComponent &) { + if (!autoBuilding.is_alive()) + autoBuilding = e; + }); + int stairCells = 0; + if (autoBuilding.is_alive()) + for (const Cell &c : + autoBuilding.get() + .cells) + if (c.flags & CellFlags::StairWell) + ++stairCells; + if (stairCells < 4) + fail("multi-storey layout got no automatic " + "stairwell"); + cellSys->update(); + if (autoBuilding.is_alive() && + autoBuilding.has() && + stairTestAttachedMesh( + autoBuilding.get().node, + "_stairs") + .isNull()) + fail("automatic stairwell built no stair " + "geometry"); + } + tgs.clearLayoutPreview(); } /* One 8 x 3 x 4 grid (32 x 4 m footprint, 4 m floors), every cell @@ -7701,15 +7737,164 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) if (!stairTestAttachedMesh(plainNode, "_intWall").isNull()) fail("grid without stair flags built opening rings"); + /* Phase 7.4: exterior stair - an upper-floor door cell with + * CellFlags::ExteriorStair gets a walkway landing outside the + * facade and a straight flight down to the ground; a navmesh path + * connects the ground to the upper floor through the door. */ + flecs::entity extE; + Ogre::SceneNode *extNode = nullptr; + { + extE = w->entity(); + extNode = sm->getRootSceneNode()->createChildSceneNode( + "StairsTestExterior"); + extE.set( + { extNode, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE }); + CellGridComponent grid; + grid.gridUid = "stairs-test-exterior"; + grid.width = 5; + grid.height = 2; + grid.depth = 3; + grid.cellSize = 2.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; + } + /* Upper-floor door on the +Z facade, at the -X end so the + * flight has the rest of the facade to descend along. */ + grid.getOrCreateCell(0, 1, 2) + .setFlag(CellFlags::DoorZPos); + grid.getOrCreateCell(0, 1, 2) + .setFlag(CellFlags::ExteriorStair); + extE.set(grid); + } + cellSys->update(); + Ogre::MeshPtr extStairs = + stairTestAttachedMesh(extNode, "_stairs"); + float extMaxY = -1.0e9f; + int extOutside = 0; + stairTestScanMesh(extStairs, [&](const Ogre::Vector3 &p) { + extMaxY = std::max(extMaxY, p.y); + if (p.z > 5.05f) + ++extOutside; + }); + if (extMaxY < 4.1f) + fail("exterior stair does not reach the upper floor"); + if (extOutside == 0) + fail("exterior stair has no geometry outside the facade"); + + NavMeshSystem *navSys = app.getNavMeshSystem(); + if (!navSys) { + fail("no NavMeshSystem"); + } else { + flecs::entity navE = w->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); + std::vector path; + const Ogre::Vector3 ground(8.2f, 0.5f, 5.75f); + const Ogre::Vector3 upper(0.0f, 4.7f, 3.5f); + bool found = + navSys->findPath(navE, ground, upper, path); + if (!found || path.empty() || + (path.back() - upper).length() > 1.5f) + fail("no navmesh path from the ground to the " + "upper floor through the gallery door"); + 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 the exterior stair"); + navE.destruct(); + } + + /* Phase 7.5: grand/lobby stair - an elongated stairwell block + * (3 x 2 cells, aspect 1.5) becomes ONE wide straight flight + * instead of a dogleg, and the level above is opened over the + * whole block. */ + flecs::entity grandE; + Ogre::SceneNode *grandNode = nullptr; + { + grandE = w->entity(); + grandNode = sm->getRootSceneNode()->createChildSceneNode( + "StairsTestGrand"); + grandE.set( + { grandNode, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE }); + CellGridComponent grid; + grid.gridUid = "stairs-test-grand"; + grid.width = 6; + grid.height = 2; + grid.depth = 6; + grid.cellSize = 2.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; + } + for (int x = 1; x <= 3; ++x) + for (int z = 1; z <= 2; ++z) + grid.getOrCreateCell(x, 0, z) + .setFlag(CellFlags::StairWell); + grandE.set(grid); + } + cellSys->update(); + { + Ogre::MeshPtr grand = + stairTestAttachedMesh(grandNode, "_stairs"); + const float firstTop = 0.2f + 4.0f / 23.0f; + float zMin = 1.0e9f, zMax = -1.0e9f; + stairTestScanMesh(grand, [&](const Ogre::Vector3 &p) { + if (std::fabs(p.y - firstTop) < 0.01f) { + zMin = std::min(zMin, p.z); + zMax = std::max(zMax, p.z); + } + }); + if (grand.isNull() || zMax - zMin < 3.4f) + fail("grand stair is not one wide flight"); + Ogre::MeshPtr grandFloor = + stairTestAttachedMesh(grandNode, "_floor"); + int inHole = 0, outsideHole = 0; + countPlanes(grandFloor, 4.2f, Rect{ 1.0f, 7.0f, 1.0f, 5.0f }, + inHole, outsideHole); + if (inHole != 0 || outsideHole == 0) + fail("grand stair block opening missing"); + } + /* Teardown: destruct the entities and let the CellGridSystem sweep * own the Ogre resources (it tracks the meshes per grid). */ gridE.destruct(); plainE.destruct(); + if (extE.is_alive()) + extE.destruct(); + if (grandE.is_alive()) + grandE.destruct(); cellSys->update(); if (gridNode->numAttachedObjects() == 0) sm->destroySceneNode(gridNode); if (plainNode->numAttachedObjects() == 0) sm->destroySceneNode(plainNode); + if (extNode && extNode->numAttachedObjects() == 0) + sm->destroySceneNode(extNode); + if (grandNode && grandNode->numAttachedObjects() == 0) + sm->destroySceneNode(grandNode); if (!ok) return false; diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index ac02d44..99c554a 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -55,6 +55,9 @@ static_assert(LayoutLib::PlanFlags::IntWindowZPos == * untranslated like every other cell flag. */ static_assert(LayoutLib::PlanFlags::StairWell == CellFlags::StairWell, ""); +static_assert(LayoutLib::PlanFlags::ExteriorStair == + CellFlags::ExteriorStair, + ""); /* 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 @@ -1154,6 +1157,11 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, c.flags = flags; } + /* Phase 7.3a: every multi-storey plan gets a stairwell so the + * upper floors are reachable; an explicit stairwell room wins. */ + if (plan.floors > 1) + autoPlaceStairwell(grid, b.address); + /* Lock every external doorway: the leaf is there (and blocks * the doorway physically and on the navmesh) but cannot be * opened before the interior exists. */ @@ -1171,6 +1179,135 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, grid.markDirty(); } +bool TownGenSystem::autoPlaceStairwell(CellGridComponent &grid, + const std::string &address) +{ + const int floors = grid.height; + if (floors < 2) + return false; + for (const Cell &c : grid.cells) { + if (c.flags & CellFlags::StairWell) + return true; /* explicit stairwell room wins */ + } + + const int W = grid.width; + const int D = grid.depth; + /* Internal walls + internal windows are opened on the run cells: + * a carved stairwell may cross room boundaries. */ + const uint64_t intOpenMask = + CellFlags::AllInternalWalls | (1ULL << 22) | (1ULL << 23) | + (1ULL << 24) | (1ULL << 25); + + auto cellAt = [&](int x, int y, int z) { return grid.findCell(x, y, z); }; + auto hasFloor = [&](int x, int y, int z) { + const Cell *c = cellAt(x, y, z); + return c && (c->flags & CellFlags::Floor); + }; + + struct Run { + bool alongX = true; + int start = -1; + int cross = 0; + int len = 0; + }; + Run best; + + /* Deterministic scan: 4-cell runs first (tread 0.30 at 2 m cells, + * the verified climbable shape), 3-cell fallback; rows/columns in + * order. A run needs the boarding cell at the low end (ground + * floor) and the step-off cell at the high end (top floor). */ + auto tryAxis = [&](bool alongX) { + const int axisLen = alongX ? W : D; + const int crossLen = alongX ? D : W; + for (int wants = 4; wants >= 3 && best.start < 0; --wants) { + if (wants + 2 > axisLen) + continue; + for (int cross = 0; cross < crossLen && best.start < 0; + ++cross) { + for (int start = 1; start + wants < axisLen; + ++start) { + bool ok = true; + for (int y = 0; y + 1 < floors && ok; + ++y) + for (int i = 0; i < wants && ok; + ++i) { + const int x = alongX ? + start + i : + cross; + const int z = alongX ? + cross : + start + i; + const Cell *c = + cellAt(x, y, z); + if (!c || + (c->flags & + CellFlags::AllDoors)) + ok = false; + } + const int bx = alongX ? start - 1 : cross; + const int bz = alongX ? cross : start - 1; + const int ex = + alongX ? start + wants : cross; + const int ez = + alongX ? cross : + start + wants; + if (!ok || !hasFloor(bx, 0, bz) || + !hasFloor(ex, floors - 1, ez)) + continue; + best.alongX = alongX; + best.start = start; + best.cross = cross; + best.len = wants; + break; + } + } + } + }; + + /* Prefer the longer usable axis; try the other as a fallback. */ + if (W >= D) { + tryAxis(true); + if (best.start < 0) + tryAxis(false); + } else { + tryAxis(false); + if (best.start < 0) + tryAxis(true); + } + + if (best.start < 0) { + Ogre::LogManager::getSingleton().logMessage( + "TownGen: warning - no stairwell fits building " + + address + " (" + std::to_string(W) + "x" + + std::to_string(D) + " cells, " + + std::to_string(floors) + + " floors); upper floors are unreachable"); + return false; + } + + for (int y = 0; y + 1 < floors; ++y) + for (int i = 0; i < best.len; ++i) { + const int x = best.alongX ? best.start + i : best.cross; + const int z = best.alongX ? best.cross : best.start + i; + Cell *c = cellAt(x, y, z); + c->flags |= CellFlags::StairWell; + c->flags &= ~intOpenMask; + } + + /* Open the facing walls at the boarding and step-off cells. */ + const int bx = best.alongX ? best.start - 1 : best.cross; + const int bz = best.alongX ? best.cross : best.start - 1; + const int ex = best.alongX ? best.start + best.len : best.cross; + const int ez = best.alongX ? best.cross : best.start + best.len; + if (Cell *bc = cellAt(bx, 0, bz)) + bc->flags &= ~(best.alongX ? CellFlags::IntWallXPos : + CellFlags::IntWallZPos); + if (Cell *ec = cellAt(ex, floors - 1, ez)) + ec->flags &= ~(best.alongX ? CellFlags::IntWallXNeg : + CellFlags::IntWallZNeg); + return true; +} + void TownGenSystem::createPlanRooms(flecs::entity building, const TownGen::TownBuilding &b, const CellGridComponent &grid) diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index 887cc32..583c462 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -220,6 +220,15 @@ private: static void fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid, const std::string &genMode); + /** Phase 7.3a: carve an automatic stairwell into a multi-storey + * plan that declares none (a `:type stairwell` room wins). Picks + * a deterministic straight run along the longer usable axis (4 + * cells, 3 as a fallback) with a boarding cell on the ground + * floor and a step-off cell on the top floor; interior walls on + * the run and the two facing entry/exit walls are opened. Logs a + * warning and returns false when nothing fits. */ + static bool autoPlaceStairwell(CellGridComponent &grid, + const std::string &address); /** Phase 6.5.F: materialize the plan's room metadata (roomIds + * roomNames/roomTypes/roomFurnitureTags) as RoomComponent children * for full/interiorOnly grids, so DSL-authored rooms are furnished diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 21dd8ff..d596c39 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -4519,6 +4519,32 @@ static bool runTownGenProbe() } } } + /* Phase 7.4: a door with :gallery on an upper floor marks its + * cell as an exterior stair. */ + { + BuildingPlan pExt; + LayoutEval evExt(lb, &rngDsl, nullptr); + if (!evExt.evaluate( + "(shell 12 8 :floors 2)" + "(floor 1 (door front -4 :gallery))", + pExt, &derr)) { + printf(" *** dsl door :gallery: eval failed " + "'%s' ***\n", derr.c_str()); + ok = false; + } else { + const uint64_t doorCell = pExt.at(1, 1, 3); + const uint64_t other = pExt.at(2, 1, 3); + if (!(doorCell & PlanFlags::ExteriorStair) || + !(doorCell & PlanFlags::DoorZPos) || + (other & PlanFlags::ExteriorStair)) { + printf(" *** dsl door :gallery: flags " + "%llx/%llx ***\n", + (unsigned long long)doorCell, + (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/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 1a2f85c..82d9d4c 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -752,16 +752,23 @@ private: " (floor n body...) per-floor context\n" " (door front|back|left|right center|)" " center on a non-rect footprint moves to the " - "nearest floor cell (warning)\n" + "nearest floor cell (warning); upper floors accept " + ":gallery (outside walkway + flight down the facade)\n" " (windows all|front|back|left|right [:spacing m])" " 'all' covers the whole footprint boundary of a " "rooms-first shell (incl. courtyard walls)\n" " (room name (rect x0 z0 x1 z1) [:type type] " "[:windows none|all] [:furniture (tag ...)] " "[:compose (rule ...)]) " - "fractions of the shell; :windows none suppresses " - "exterior windows on the room's boundary cells " - "(bathroom/storage); declare rooms before the " + "fractions of the shell; :type stairwell marks the " + "room as a staircase (its ceiling and the floor above " + "are opened; connect it to the rooms on both ends of " + "the run) - multi-storey layouts without one get an " + "automatic straight-run stairwell; an elongated " + "stairwell room (aspect >= 1.5, run >= 3 cells) builds " + "one wide grand flight; :windows none " + "suppresses exterior windows on the room's boundary " + "cells (bathroom/storage); declare rooms before the " "windows verb\n" " (connect roomA roomB) door in the " "shared wall (alias: idoor)\n"