Phase 7 checkpoint

This commit is contained in:
2026-10-30 18:24:30 +03:00
parent 7e0a74d837
commit 141bfd31c1
19 changed files with 1361 additions and 77 deletions
+78
View File
@@ -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"` /
@@ -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<std::pair<uint64_t, const char*>> CellGridComponent::getAllFlags()
{ CellFlags::IntWindowXPos, "Int Window X+" },
{ CellFlags::IntWindowZPos, "Int Window Z+" },
{ CellFlags::IntWindowZNeg, "Int Window Z-" },
{ CellFlags::StairWell, "Stairwell" },
};
}
+15 -2
View File
@@ -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).
+122 -70
View File
@@ -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
@@ -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<TransformComponent>(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<CellGridComponent>(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<TransformComponent>(t);
CharacterComponent cc;
cc.dirty = true;
e.set<CharacterComponent>(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<CharacterComponent>();
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<NavMeshComponent>(nm);
navSys->rebuild(navE);
navBuilt = true;
return true;
}
std::vector<Ogre::Vector3> 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<Ogre::Vector3> 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: "
@@ -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). */
@@ -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;
}
@@ -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);
@@ -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 &center,
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<int64_t> 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<int64_t> 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<std::pair<int, int> > 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<int64_t> 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);
@@ -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<std::string> doorMeshes;
std::vector<std::string> windowMeshes;
std::string roofMesh;
@@ -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)
@@ -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
@@ -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 <typename Fn>
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<float> 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<Ogre::Entity *>(
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<TransformComponent>()) {
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<TransformComponent>()
.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<TransformComponent>(
{ 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<CellGridComponent>(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<Ogre::Vector3> 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 },
};
@@ -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);
@@ -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
@@ -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;
}
@@ -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");
@@ -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<std::string> 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. */
{
@@ -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();
}