diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index 1a70a44..01311b7 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -727,18 +727,101 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
water-level rejection. Lots are shrunk off the road body before
placement (`shrinkLotOffRoads()`: the centerline-bounded lot polygon
is inset per bounding edge by lanes + that side's sidewalk + apron +
- 0.3 m; lots whose inset collapses are skipped). `TownGenSystem`
+ 0.3 m; lots whose inset collapses are skipped). Per-side edge lot
+ params (phase 4.5 item 1, `RoadLotParams lotLeft/lotRight` on
+ `RoadEdge`, same side convention as the prefab slots, all fields
+ with inherit sentinels) override the district preset per lot —
+ buildable tri-state, district override, densityScale, setback,
+ footprint/floor ranges, roof type, layout DSL string, maxFrontage
+ (> 0 splits the lot into independent frontage strips whose addresses
+ share the street number with a letter suffix, phase 4.5 item 3);
+ a lot takes the params of the side of its frontage edge (matched
+ geometrically), the merge runs per lot before the density roll so
+ buildable==Off skips cost no RNG draws and determinism holds.
+ Persisted inline in scene JSON and in region files (sparse:
+ all-inherit sides are omitted); the editor edge inspector edits
+ them via "Lot Params Left/Right" tree nodes (including a multiline
+ Layout field with an Insert Template button — fills an empty field
+ with `LayoutLibrary::defaultTemplateSource()` — and a DSL Help
+ window with the reference and the worked
+ `LayoutLibrary::examples()`, both probe-verified in
+ road_geometry_overlap_test). **Building layout DSL** (phase 4.5 item 2,
+ `layoutlib/` — pure std, CMake `LayoutLib`, linked into
+ RoadGeometryLib): an S-expression language evaluated per lot into a
+ `LayoutLib::BuildingPlan` (grid metrics + per-cell `PlanFlags`
+ bit-identical to `CellFlags` — static_asserted in
+ TownGenSystem.cpp — + roof list + parking rects + recorded `place`
+ placements). The layout string (edge-side `RoadLotParams::layout`,
+ else the district default) is inline source when it starts with
+ `(`, otherwise a `LayoutLibrary`/built-in name; the built-ins
+ `village-house`/`town-rowhouse`/`city-block`/`city-tower` share one
+ parametric body keeping the legacy RNG draw order, so default towns
+ are bit-identical to the pre-DSL generator. The plan carries its
+ grid metrics (`cellSize`/`cellHeight`, defaults 2 m / 4 m) — the
+ `(cell-size xz-m [height-m])` verb before the shell overrides them
+ per layout and every meter->cell mapping honors them. Non-rectangular
+ footprints compose by subtraction: `(cut x0 z0 x1 z1)` (shell-frame
+ meters, origin at the shell corner, +x along the frontage) clears
+ the covered cells (all floors, or the current floor inside a
+ `floor` context) and re-marks the hole-facing surviving neighbors
+ as exterior walls; `roof` takes an optional rect `(roof type x0 z0
+ x1 z1)` for per-wing roofs; openings landing on a cut cell are hard
+ evaluation errors; the worked examples include L- and U-shaped
+ houses. Evaluation failures
+ warn and fall back to the district default (`evaluate()` resets the
+ output plan on entry, so the fallback retry starts clean). Parking rects become
+ extra POIs (`"
parking N"` — buildings are no longer 1:1
+ with POIs, `TownGenSystem` gathers via `TownPoi::building`); an
+ optional `town_layouts.txt` in the project directory extends the
+ library. **Town areas** (phase 4.5 item 4): `RoadGraph::townAreas`
+ (`RoadTownArea`: name, square/circle XZ scope, district, seed,
+ densityScale) scope generation per settlement —
+ `TownGen::generateTown()` filters lots by centroid containment with
+ first-match-wins over overlapping areas before any RNG draws;
+ `TownGenSystem::generateAreas()` builds one `"TownGen "`
+ container per area (the unscoped `generate()` stays for
+ single-settlement scenes and `--generate-town`). Areas persist
+ inline in the scene JSON when not streaming, in `roads/towns.json`
+ when streaming; the Town Generator panel edits the area list
+ (Pick Center reuses the origin-pick arm) and area outlines draw in
+ dark blue next to the lot loops in road edit mode. **Building
+ streaming** (phase 4.5 item 5): `TownGen::generateTown()` takes a
+ `PageScope` (render-XZ -> physical-page `PageMapper` + filter) that
+ switches the RNG to per-page streams (`seed ^ hash(page)`), so a
+ page's buildings regenerate without the rest of the town and the
+ all-pages run is exactly the per-page union; editor generate uses
+ the all-pages scope on streaming terrains. In game mode
+ `TownGenSystem::update()` (from `TerrainSystem::update`) streams
+ buildings per terrain page into `StreamedTownTag`-tagged
+ `"TownGen p_"` containers (excluded from scene JSON,
+ one (page, area) generation per frame, destroyed on page unload);
+ editor-generated town containers carry `GeneratedTownTag` with the
+ same exclusion — a town is a pure function of the persisted graph,
+ so saving it would only duplicate the regenerated buildings
+ (regenerate from the panel / game-mode streaming instead). `TownGenSystem`
(owned by `TerrainSystem`)
materializes the result: a `"TownGen seed "` container
(found by name prefix) with a `TownComponent`, one shared
ProceduralTexture/Material pair, and per building an exterior-only
- CellGrid shell (front door with `doorsEnabled = false` + placeholder
- `town_interior.json` scene-switch path for Phase 8) with a flat
- full-footprint `RoofComponent` child (`posY = floors` —
+ CellGrid shell (external doorways get a real door leaf locked by
+ default via per-door `lockable`/`lockedByDefault` configs — "E
+ Locked", never opens until Phase 8 swaps the lock for the interior
+ scene-switch wiring; the grid uid is `"town-"` so
+ streamed rebuilds keep stable door IDs) with
+ `RoofComponent` child(ren) (`posY = floors`, type from the
+ lot's `roofType` override, Flat by default —
`CellGridSystem::buildRoofs()` only processes `RoofComponent`
- children of the grid entity) and serialized
+ children of the grid entity; a solid top floor gets one
+ full-footprint roof, a cut footprint one roof per top-floor row
+ run) and serialized
Primitive-box foundation/stairs children (Primitive children survive
- save/load; raw TriangleBufferComponent buffers don't). Editor UI:
+ save/load; raw TriangleBufferComponent buffers don't; a solid
+ ground floor gets the single legacy block, a cut footprint one box
+ per row run; the blocks overhang the shell walls by 0.3 m per side
+ so the house reads as seated on its slab, and the door ground
+ sample / entrance stairs center on the plan's front-door cell
+ `TownBuilding::doorCellX` — cell centers sit at x*cellSize in the
+ cell frame, so the stairs match the actual door frame). Editor UI:
terrain panel "Town Generator" section.
- **Chunked page meshes**: page assembly splits the page geometry into
chunk meshes capped at `ROAD_PAGE_CHUNK_MAX_VERTICES` (60000) vertices,
@@ -924,6 +1007,13 @@ to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags
`space`/`spacePressed` (vehicle handbrake while driving) are also set from the
game-mode key handlers.
+Both `EditorApp::keyPressed`/`keyReleased` return early when
+`ImGui::GetIO().WantTextInput` — a focused ImGui text field (lot-params DSL
+editor, rename fields, the script editor) owns the whole keyboard, so editor
+hotkeys (camera fly keys, the F camera focus, ESC mode exits) and game input
+stay silent while typing. ImGui still receives the events itself (it sits in
+the same OgreBites input-listener chain).
+
### AnimationTreeSystem & Root Motion
- Animation trees are stored in the global `AnimationTreeRegistry`, persisted to
diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt
index 3b8fbc1..a9ff15d 100644
--- a/src/features/editScene/CMakeLists.txt
+++ b/src/features/editScene/CMakeLists.txt
@@ -826,6 +826,24 @@ add_test(NAME globalStateTest
# ---------------------------------------------------------------------------
# Extracted from RoadSystem.cpp; depends only on Ogre, OgreProcedural, and
# RoadGraph.hpp. No ECS, physics, or terrain dependency.
+# ---------------------------------------------------------------------------
+# Layout Library — building layout DSL (phase 4.5 item 2)
+# ---------------------------------------------------------------------------
+# Pure std library (no Ogre/ECS): S-expression layout language parser and
+# evaluator producing a BuildingPlan (cells/flags, doors, rooms, roofs,
+# parking) consumed by roadlib/TownGen and TownGenSystem.
+add_library(LayoutLib STATIC
+ layoutlib/BuildingPlan.hpp
+ layoutlib/LayoutLang.cpp
+ layoutlib/LayoutLang.hpp
+ layoutlib/LayoutEval.cpp
+ layoutlib/LayoutEval.hpp
+)
+
+target_include_directories(LayoutLib PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
add_library(RoadGeometryLib STATIC
roadlib/RoadGeometryLib.cpp
roadlib/RoadGeometryLib.hpp
@@ -843,6 +861,7 @@ target_link_libraries(RoadGeometryLib PUBLIC
OgreMain
OgreProcedural::OgreProcedural
nlohmann_json::nlohmann_json
+ LayoutLib
)
# ---------------------------------------------------------------------------
diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp
index 166e302..217800f 100644
--- a/src/features/editScene/EditorApp.cpp
+++ b/src/features/editScene/EditorApp.cpp
@@ -2707,6 +2707,15 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
{
m_currentModifiers = evt.keysym.mod;
+ /* While an ImGui text field has focus (the lot-params DSL
+ * editor, rename fields, the script editor, ...) every key
+ * belongs to it: editor hotkeys (camera fly keys, the F focus,
+ * the ESC mode exits) and game input must not fire. ImGui
+ * itself still receives the event — it sits in the same
+ * OgreBites input-listener chain. */
+ if (ImGui::GetIO().WantTextInput)
+ return true;
+
/* Exit terrain sculpt/paint modes with ESC before other handlers
* consume the key. */
if (m_terrainSystem &&
@@ -2850,6 +2859,10 @@ bool EditorApp::keyReleased(const OgreBites::KeyboardEvent &evt)
{
m_currentModifiers = evt.keysym.mod;
+ /* See keyPressed: a focused ImGui text field owns the keyboard. */
+ if (ImGui::GetIO().WantTextInput)
+ return true;
+
if (m_gameMode == GameMode::Game) {
switch (evt.keysym.sym) {
case 'w':
diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md
index 5cd2f71..02cab9c 100644
--- a/src/features/editScene/ProceduralRoadGeometry.md
+++ b/src/features/editScene/ProceduralRoadGeometry.md
@@ -1639,3 +1639,404 @@ stairs (some but not all on a tilted grid), sequential Main Street
addresses including the fallback square, village single-floor, and
the road-clearance check (every building footprint corner stays at
least the road half-width off every edge centerline segment).
+
+## 21. Town Parameters, Layout DSL, Lot Splitting, Streaming, Town Areas (taxi demo phase 4.5, research)
+
+Design foundations for phase 4.5. Phase 4 generates a whole town from
+global dialog parameters; phase 4.5 makes generation non-destructive
+(settings live on the map, not in the dialog), layout-controlled (a DSL
+drives building shape), subdividable (long frontages host several
+buildings), streamable (several settlements per world) and scoped
+(town areas in the road graph).
+
+### 21.1 Edge-side lot parameters
+
+**Problem.** `findLots()` output has no stable identity — lots are
+re-enumerated from the graph on every run, so per-lot settings stored
+by lot index evaporate on regeneration. Road edges, on the other
+hand, have stable endpoint node ids and already carry per-side data
+(sidewalk tri-state modes, prefab slots).
+
+**Design.** `RoadEdge` gains two `RoadLotParams` blocks, `lotLeft`
+and `lotRight` (side convention of the prefab slots: looking from
+nodeA to nodeB, left is `UNIT_Y x dir`). Every field is an explicit
+inherit-sentinel so an untouched edge costs nothing:
+
+```
+struct RoadLotParams {
+ TriState buildable = Inherit; // Off: side stays green (park, field)
+ District district = Inherit; // district preset override
+ float densityScale = -1; // < 0: inherit
+ float setback = -1; // < 0: inherit
+ float minFootprint = 0, maxFootprint = 0; // 0: inherit
+ int minFloors = 0, maxFloors = 0; // 0: inherit
+ RoofType roofType = Inherit; // Flat/Normal/Normal2/Cone/Cylinder
+ std::string layout; // layout-DSL template, "" = default
+ float maxFrontage = 0; // 0: no splitting (see 21.3)
+};
+```
+
+**Edge-side -> lot mapping.** A lot's parameters come from ONE
+source: the side of its **frontage edge** — the same edge the
+placement pass already ranks (named street > fallback source edge >
+longest boundary edge, §20.2). The side is derived geometrically
+(which side of the edge the centroid lies on); fallback squares record
+their source edge and the grown side the same way. Enclosed faces
+touch many edges, but only the frontage edge's side block applies —
+no multi-edge precedence soup. Merge order: edge-side field >
+town-area field (21.5) > district preset default.
+
+**Persistence.** The blocks serialize inside the edge record, both
+paths: inline scene JSON and `RoadRegionStore` page files (same
+pattern as `sidewalkLeftMode`/`decals`/`prefabLeft` — both serializers
+gain the field; absent = all-inherit). `RoadGraph::splitEdge()`
+copies both blocks onto the two halves, like the prefab slots.
+
+**Status (implemented).** Done as designed: `RoadLotParams` +
+`RoadLotBuildable`/`RoadLotDistrict`/`RoadLotRoof` enums live in
+`components/RoadGraph.hpp` (enums mirrored as ints in
+`RoadRegionStore.hpp` to keep the store decoupled); inline scene JSON
+and region files write the blocks sparsely (`isDefault()` sides are
+omitted); `RoadSystem` converts them at all three region boundary
+sites (migrate-in, merge-out, `buildRegionData`).
+`TownGen::generateTown()` matches each lot's frontage edge
+geometrically (either direction, 0.01 eps — covers fallback source
+edges), derives the side from the centroid and merges edge side >
+district preset per lot; `buildable == Off` skips before any RNG
+draws (`skippedNotBuildable`), keeping the per-lot draw order
+deterministic; `TownBuilding` carries `roofType` (-1 = Flat default,
+applied by `TownGenSystem`) and `layout`. Editor: "Lot Params Left
+(A->B) / Right (A->B)" tree nodes in the edge inspector (the Layout
+multiline text field arrived with item 2; the lot-outline tint for
+overridden sides from the Editor paragraph below is not yet
+implemented). Probes in
+`runTownGenProbe()` cover buildable-off, floor pinning, district
+override, roof override and the exact setback shift on a two-lot
+single-edge graph.
+
+**Editor.** Edge inspector gains a "Lot Parameters (left / right)"
+section; the road-edit lot outline preview (dark grey loops) tints a
+side whose params differ from the inherited defaults.
+
+### 21.2 Building layout DSL (S-expressions)
+
+**Problem.** Phase 4 buildings are fixed shells (perimeter windows,
+one front door, flat roof). Real settlements need layout control —
+rooms, internal doors, roof shapes, parking — and Phase 8 interiors
+must match the exterior, so one definition must drive both.
+
+**Design.** A small S-expression language, parsed and evaluated by a
+new pure `layoutlib` (no Ogre/ECS/Lua dependency — headless-testable
+like `TownGen`; the parser is a few hundred lines, no external
+dependency). Lua embedding is explicitly deferred to later phases for
+specialized layouts; the S-expr core stays the base layer. Example:
+
+```lisp
+(layout village-house
+ (let ((w (clamp lot-w 8 14))
+ (d (clamp lot-d 8 12)))
+ (shell w d) ; exterior walls, w x d meters
+ (floor 0
+ (door front center)
+ (windows all :spacing 4) ; every ~4 m, corners excluded
+ (room living (rect 0 0 0.6 1.0)) ; fractions of w/d
+ (room kitchen (rect 0.6 0 1.0 1.0))
+ (idoor living kitchen))
+ (if (>= lot-w 12)
+ (parking (rect-front-left 4 5))) ; lot feature, see below
+ (roof gable :height 2)))
+```
+
+**Language features.** Numbers/strings/symbols; arithmetic and
+comparison builtins; `let` variables; `if`/`cond` conditionals —
+crucially on `lot-w`/`lot-d` (the buildable lot size after the 20.2
+road-clearance shrink), so one template adapts to the lot it lands on;
+`for-range`/`dotimes` iteration for repetitive structures (window
+bays, fence posts); `rand`/`rand-int` drawing from the town seed
+stream (deterministic per building). **Templating**: a named layout
+library (`(layout name ...)`), templates referenced from edge-side
+`RoadLotParams::layout`, reused across lots of different sizes without
+per-building edits. **Default layouts per building type**
+(village-house, town-rowhouse, city-block, ...) ship with the library;
+a lot with no explicit layout picks the default for its district,
+conditioned on lot size.
+
+**Evaluation boundary.** Evaluation produces a `BuildingPlan` — a
+pure value: grid metrics (cellsX/cellsZ/floors), per-cell flag array
+(the CellFlags vocabulary: Floor/Ceiling, ext/int walls, doors,
+windows), a roof list (RoofComponent tuples), lot features (parking
+rects in lot space) and a generic placement list for extensions.
+`TownGen` owns placement (WHERE: lot, frontage, foundation, seed
+stream) and calls the evaluator; `TownGenSystem` owns materialization
+(WHAT: plan -> CellGridComponent + RoofComponent children +
+foundation/stairs + parking POIs). Phase 8's interior generator
+consumes the SAME `BuildingPlan`, so exterior and interior can never
+disagree.
+
+**Extensibility.** The placement list is the growth point:
+`(place (at x z) :yaw a)` for pre-made meshes with
+generated colliders, `(fence (line x0 z0 x1 z1))`, actuator markers —
+evaluated into plan entries, materialized by TownGenSystem in later
+phases.
+
+**Status (implemented).** Done as designed in `layoutlib/`
+(`BuildingPlan.hpp`, `LayoutLang.hpp/.cpp` S-expr parser,
+`LayoutEval.hpp/.cpp` evaluator + `LayoutLibrary`; the CMake
+`LayoutLib` static lib is pure std, linked into `RoadGeometryLib`).
+`PlanFlags` are bit-identical to `CellFlags` (static_asserts in
+`TownGenSystem.cpp`); the plan frame defaults to 2 m cells / 4 m
+floors, overridable per layout with `(cell-size xz-m [height-m])`
+before the shell (the plan carries its `cellSize`/`cellHeight` and
+every meter->cell mapping honors them), cell index
+`[y*cellsZ*cellsX + z*cellsX + x]`. Language as designed with these
+refinements: unbound symbols in verb argument position are enum-like
+string literals (`door front center`, `windows all`, `roof cone`),
+bound symbols (`lot-w`, `min-fp`, ...) evaluate to their value; there
+is deliberately no math `floor`/`ceil` (name clash with the `floor`
+verb); each `rand`/`rand-range`/`rand-int` draws one uniform from the
+town `std::mt19937` stream. Verbs implemented: `shell` (clamped to
+the setback-adjusted rect), `cell-size` (grid metrics before the
+shell), `floor` context, `door`, `windows`
+(corners excluded, doors win, `:spacing`), `room` (fractions, IntWall
+on the room's own boundary cells), `idoor` (shared-wall IntDoor,
+clears IntWall), `roof` (`gable` aliases `normal`; optional rect
+`(roof type x0 z0 x1 z1)` in shell-frame meters, one RoofComponent
+per rect, omitted = full footprint), `parking`, `place` (recorded
+only — materialization deferred), `cut` (`(cut x0 z0 x1 z1)` —
+shell-frame meters, origin at the shell corner, +x along the
+frontage; clears all flags on the covered cells of every floor, or
+of the current floor inside a `floor` context, and re-marks the
+surviving neighbors facing the hole as exterior walls, so L/U/other
+non-rectangular footprints compose from a rectangular shell plus
+subtraction; place cuts right after `shell`, before `door`/`windows`
+— an opening landing on a cut cell is a hard evaluation error and
+`windows` skips Floor-less cells). The four
+built-in district layouts (`village-house`/`town-rowhouse`/
+`city-block`/`city-tower`) all share one parametric body drawing
+width, depth, floors from the `min-*`/`max-*` bindings in the legacy
+order, so an all-default town is bit-identical to the pre-DSL
+generator (probe-verified). Shaped buildings stay consistent end
+to end: `TownGen` records the plan's front-door cell
+(`TownBuilding::doorCellX`) and samples the door ground / places the
+entrance stairs at the real door instead of the front-face center,
+and `TownGenSystem`'s materialization falls back per shape — a top
+floor with cut cells gets one roof per top-floor row run (type from
+the edge-side override or Flat) instead of the single full-bbox
+roof, and a ground floor with cut cells gets one foundation box per
+row run instead of the single block; solid floors keep the legacy
+single-piece layout. The foundation block(s) overhang the shell
+walls by 0.3 m per side so the house reads as seated on its slab,
+and the entrance stairs center on the door CELL CENTER (cell centers
+sit at x*cellSize in the cell frame — the cell-center convention
+also drives the door ground sample), matching the actual door frame
+instead of sitting half a cell off; a floor-level landing (0.9 m)
+in front of the door keeps the door frame from overhanging the
+first step. Layout resolution: a string starting
+with `(` is inline source, otherwise a library/built-in name;
+evaluation failure logs a `TownResult::warnings` entry and falls back
+to the district default (`evaluate()` resets the output plan on
+entry, so the fallback retry never inherits the failed attempt's
+partial shell), and only when THAT fails is the lot skipped
+(`skippedLayoutError`). Parking rects become extra POIs
+(`" parking N"`) — buildings are no longer 1:1 with POIs,
+`TownGenSystem::generate()` gathers them via `TownPoi::building`.
+`TownGenSystem::fillBuildingCells()` copies the plan flags into the
+grid; roofs come from `plan.roofs` when non-empty, else the edge-side
+`roofType`/Flat fallback; an optional `town_layouts.txt` in the
+project directory extends the library (load errors logged only when
+the file exists). Editor: the Lot Params tree nodes gained a
+multiline Layout text field with an Insert Template button (fills an
+empty field with `LayoutLibrary::defaultTemplateSource()`, the
+shared built-in body) and a DSL Help window showing the reference
+plus the worked `LayoutLibrary::examples()`. Probes in `runTownGenProbe()` cover the
+lot-size conditional, shell clamping, parking rects, room/idoor
+flags, name-vs-inline resolution, error rejection, the
+template/example sources, the inline-layout
+integration (10x8 m, 3 floors, cone roof, front-door flags), the
+broken-layout fallback, the `cut` verb (L-shape cell flags,
+exterior walls re-marked on the cut-facing neighbors, per-rect
+roofs) and the door-on-a-cut-cell rejection. Deferred: `place`/`fence` materialization,
+the Phase 8 interior consumer.
+
+### 21.3 Lot splitting along long frontages
+
+**Problem.** A long edge side currently yields at most one building —
+a 100 m frontage should host a row of houses.
+
+**Design.** Splitting is a `TownGen`-time subdivision, NOT a
+`findLots()` change (lot detection stays untouched): when the lot's
+frontage exceeds the edge-side `maxFrontage` (> 0), the buildable
+rectangle in the (u, v) lot frame is divided into
+`ceil(frontage / maxFrontage)` equal strips along u, each strip
+clamped to >= the district minimum footprint. Each strip is an
+independent building draw (own width/depth/floors rolls from the seed
+stream, in strip order). Strips keep the lot's edge-side params.
+**Addresses**: split siblings share the street number with a letter
+suffix — "Main Street 12A", "12B" — assigned in frontage order.
+
+**Status (implemented).** Done as designed in
+`TownGen::generateTown()`: `maxFrontage` comes from the frontage
+edge's side params (0 = no splitting, the default); the strip count
+is `ceil(lotU / maxFrontage)` decremented while strips would come out
+narrower than the district minimum footprint. Each strip is
+independent — own post-setback availability check (skippedTooSmall),
+density roll, layout evaluation (the `lot-w`/`avail-w` bindings are
+the STRIP width, so conditionals adapt per strip), placement clamped
+to the strip's u-range, terrain corners, water check. The street
+number is drawn once per lot on the first built strip; sibling
+addresses append the letter only when the lot actually split
+("Main Street 12A"/"12B", "Lot 3A"/"3B"), so unsplit addresses are
+unchanged. Pickup and parking POIs are per strip (the parking frame
+origin is the strip's front-left). An unsplit town is bit-identical
+to the pre-splitting generator (existing probes hold).
+`runTownGenProbe()` covers the 100 m edge split into four 25 m strips
+(addresses Lot 1A..1D, strip width cap, per-strip pickups,
+determinism), the min-footprint clamp (100/15 -> 6 strips, all
+rejected post-setback) and shared street numbers on a named street.
+
+### 21.4 Building streaming
+
+**Problem.** Multiple 1000x1000 m settlements of CellGrid buildings
+cannot all stay resident (memory, collider count).
+
+**Options.** (a) Distance-based spawn/despawn of building entities
+(CharacterSpawner/TerrainPrefabSpawner pattern) — simple, but ignores
+page boundaries and pays rebuild cost at awkward radii. (b) Per-page
+region store of generated building definitions
+(`heightmaps//buildings/_.json`, SpawnerRegionStore
+pattern) — page-granular, but duplicates what is already derivable.
+
+**Recommendation: (c) deterministic per-page regeneration.** A town's
+buildings are already a pure function of (road graph, edge-side
+params, district/area params, seed). Make the RNG per-page instead of
+global: lots are assigned to pages by centroid, each page's buildings
+draw from `seed ^ pageIndex` in lot order, so a page's building set is
+computable WITHOUT generating the rest of the town. Buildings of a
+page are created when the page loads and destroyed when it unloads
+(exactly the RoadRegionStore/SpawnerRegionStore lifecycle), with a
+builds-per-frame budget like the terrain page loader. Nothing extra
+is stored; regeneration stays consistent because the inputs (graph
+regions, edge params) are already the persisted truth. Editor builds
+stay always-loaded (editor = authoring context).
+
+**Status (implemented).** Done as designed (option c).
+`TownGen::generateTown()` gained a trailing `PageScope` (a
+caller-supplied `PageMapper` render-XZ -> physical-page callback plus
+a page filter): with a mapper, lots are grouped by page (sorted page
+order, within a page sorted by centroid so the order is independent
+of the graph storage order), each page draws from its own
+`std::mt19937` seeded `params.seed ^ (px*73856093 ^ py*19349663)`,
+and street numbering restarts per page — the all-pages run is exactly
+the union of the per-page runs (probe-verified: 12 lots on 12
+distinct pages, identical center/yaw/width/depth/floors/address,
+disjoint lot sets, per-page determinism). Negative page coordinates
+are the "all pages" sentinel; physical terrain pages are always
+non-negative. Without a mapper the legacy single stream is kept
+bit-identical (all pre-existing probes hold). `TownGenSystem::
+generate()`/`generateAreas()` pass the all-pages scope on streaming
+terrains (the demo-taxi `--generate-town=town,7` count moved 26 -> 28
+from the reseeding, deterministic; the exact count tracks the saved
+graph INCLUDING edge-side lot params — a saved per-edge `layout`
+override changes that lot's RNG draw count and reshuffles the
+per-page stream, e.g. the demo's current saved lot-params yield 25,
+the pristine graph 28), so editor-built towns match the
+streamed union. The game-mode driver `TownGenSystem::update()`
+(called from `TerrainSystem::update`, active only when
+`editScene::isGameMode()` + streaming + non-empty `townAreas`) polls
+`TerrainGroup::getTerrainSlots()` like the spawner system, builds one
+(page, area) generation per frame into `"TownGen p_"`
+containers tagged `StreamedTownTag` (components/CellGrid.hpp,
+excluded from scene serialization next to StreamedSpawnerTag), and
+destructs them when the page streams out; a terrain-entity id change
+(scene switch) resets the bookkeeping. Editor-generated town
+containers carry `GeneratedTownTag` with the same serialization
+exclusion — a town is a pure function of the persisted graph, so
+saving it with the scene would only duplicate (and on streaming
+worlds double up with) the regenerated buildings; the editor
+regenerates from the Town Generator panel instead.
+
+### 21.5 Town areas in the road graph
+
+**Problem.** Generation is currently "everywhere on the graph"; a
+world with several settlements needs scoping, and a settlement's
+district/seed/density belong to the map, not the generator dialog.
+
+**Design.** `RoadGraph` gains `std::vector townAreas`:
+
+```
+struct RoadTownArea {
+ std::string name; // "Milltown" -> "TownGen Milltown"
+ bool circular = false; // false: square
+ Ogre::Vector3 center; // world/render XZ (+ fallback Y)
+ float halfSize = 500; // square half side
+ float radius = 500; // circle
+ District district; // base preset for the area
+ float densityScale = 1.0f;
+ uint32_t seed = 1;
+};
+```
+
+`TownGen` filters lots per area by **centroid containment** (simple,
+deterministic; a lot straddling the boundary belongs to the area
+containing its centroid — overlapping areas resolve first-match-wins
+and are flagged in the editor). Lots inside NO area generate nothing
+(areas are the only generation scope; the old global generate becomes
+a one-click "create area around origin" convenience). Each area
+generates into its own `"TownGen "` container. Persistence
+mirrors streets: inline in the scene JSON when not streaming,
+`roads/towns.json` (RoadRegionStore sibling of `streets.json`) when
+streaming. Editor: Town Generator panel lists areas (add square /
+add circle, pick-center-on-terrain like the network generator origin
+pick); area outlines draw in road edit mode next to the lot outlines.
+
+**Status (implemented).** Done as designed, with two deviations:
+the unscoped `TownGenSystem::generate()` stays as-is (area filtering
+is opt-in via the trailing `areas`/`areaIndex` parameters of
+`TownGen::generateTown()`, so the single-town button, the
+`--generate-town` headless check and all existing probes are
+unaffected), and overlapping areas are NOT flagged in the editor —
+first-match-wins applies silently. `RoadTownArea` lives in
+`components/RoadGraph.hpp` with its own `RoadTownDistrict` enum
+(Village..CityCenter, no Inherit, value-compatible with
+`TownGen::District`, static_asserted in TownGenSystem.cpp);
+`contains(x,z)` is the XZ centroid test. `TownGenSystem::
+generateAreas()` shares the terrain/water/sampler setup with
+`generate()` (extracted `setupTownRun`), requires a non-empty
+`townAreas`, clears once and builds one `"TownGen "`
+container per area through the extracted `createTownContainer()`
+tail. Filtering runs before any RNG draws (centroid inside the
+area, not inside any earlier area), so per-area towns are
+deterministic. Serialization: `RoadNetworkGen::townAreasToJson/
+FromJson`, inline `townAreas` in the scene JSON next to `streets`
+(non-streaming), `roads/towns.json` via
+`RoadRegionStore::loadTowns/saveTowns` when streaming (RoadSystem
+loads it with the streets, saves it from `flushRegionStore()`).
+Editor: the Town Generator panel edits the area list (name, square/
+circle, center XZ + Pick Center reusing the origin-pick arm — the
+grid-origin pick consumption is now guarded by its own armed flag so
+the two picks don't steal each other's click —, halfSize/radius,
+district, seed, densityScale, Remove), with Add Area (Square/Circle)
+and Generate All Areas buttons; `RoadSystem::buildLotOutlines()`
+draws area outlines in dark blue (0.1,0.1,0.3) next to the dark grey
+lot loops. Probes in `runTownGenProbe()`: two overlapping areas on
+the 2x2-block grid (west square 4 lots, east circle 4 lots after
+first-match-wins, 4 lots outside all areas build nothing), per-area
+determinism, disjoint lot sets, and the townAreas json round trip.
+
+### 21.6 Implementation order
+
+1. Edge-side `RoadLotParams` + both serializers + inspector UI +
+ merge-into-TownGen (unlocks non-destructive regeneration).
+2. `layoutlib` S-expr parser + evaluator + `BuildingPlan`; default
+ layouts; TownGenSystem materializes plans (replaces
+ `fillBuildingCells`).
+3. Lot splitting (uses 21.1 `maxFrontage`).
+4. Town areas + `roads/towns.json` + panel (unlocks multi-settlement
+ worlds).
+5. Per-page building regeneration (streaming) — last, it restructures
+ TownGen's global RNG into per-page RNG.
+
+Headless coverage per the plan: params round-trip and regeneration
+stability (1), DSL evaluation incl. lot-size conditionals and parking
+(2), letter-suffix splitting (3), two-area independence (4),
+per-page == global building sets (5).
diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp
index 861bc3a..2bf800d 100644
--- a/src/features/editScene/components/CellGrid.hpp
+++ b/src/features/editScene/components/CellGrid.hpp
@@ -584,6 +584,31 @@ struct TownComponent {
void markMaterialDirty() { materialDirty = true; }
};
+/**
+ * Tag for streamed town containers (taxi demo phase 4.5 item 5).
+ * TownGenSystem::update() creates/destroys "TownGen p_"
+ * container entities as terrain pages stream in/out in game mode;
+ * their content is a pure function of the road graph (already
+ * persisted), so SceneSerializer must NOT write them into the scene
+ * JSON. pageX/pageY identify the owning physical terrain page.
+ */
+struct StreamedTownTag {
+ long pageX = 0;
+ long pageY = 0;
+};
+
+/**
+ * Tag for generated town containers (taxi demo phase 4.5). Towns
+ * are a pure function of the road graph + town areas (already
+ * persisted): they regenerate via the Town Generator panel in the
+ * editor and stream in per page in game mode, so SceneSerializer
+ * must NOT write them into the scene JSON. (Streamed per-page
+ * containers additionally carry StreamedTownTag.)
+ */
+struct GeneratedTownTag {
+ bool generated = true;
+};
+
/**
* @brief Furniture template - defines a furniture item
*/
diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp
index 2270668..9a5e483 100644
--- a/src/features/editScene/components/RoadGraph.hpp
+++ b/src/features/editScene/components/RoadGraph.hpp
@@ -343,6 +343,127 @@ enum class RoadMarkingMode : int {
Off = 2 /**< No markings on this edge even if globally enabled. */
};
+/**
+ * Per-edge-side lot buildability tri-state (taxi demo phase 4.5).
+ *
+ * Inherit (the default) leaves the decision to the town generator (lots
+ * are buildable unless rejected geometrically); On/Off force the side
+ * regardless. Off is the "keep this frontage clear" switch (e.g. a
+ * planned plaza or a driveway).
+ */
+enum class RoadLotBuildable : int {
+ Inherit = 0,
+ On = 1,
+ Off = 2
+};
+
+/**
+ * Per-edge-side district override (taxi demo phase 4.5).
+ *
+ * Values mirror TownGen::District (roadlib/TownGen.hpp) shifted by one
+ * so that -1 means "no override". Mirrored as ints instead of reusing
+ * the enum to keep RoadGraph.hpp free of roadlib dependencies.
+ */
+enum class RoadLotDistrict : int {
+ Inherit = -1,
+ Village = 0,
+ Town = 1,
+ CityResidential = 2,
+ CityCenter = 3
+};
+
+/**
+ * Per-edge-side roof type override (taxi demo phase 4.5).
+ *
+ * Values mirror RoofComponent::Type (components/CellGrid.hpp) shifted by
+ * one so that -1 means "no override" (the district default is used).
+ */
+enum class RoadLotRoof : int {
+ Inherit = -1,
+ Flat = 0,
+ Normal = 1,
+ Normal2 = 2,
+ Cone = 3,
+ Cylinder = 4
+};
+
+/**
+ * Per-edge-side building-lot parameters (taxi demo phase 4.5).
+ *
+ * One instance per edge side (lotLeft/lotRight on RoadEdge, same side
+ * convention as the prefab slots: looking from nodeA toward nodeB, left
+ * is UNIT_Y x dir). A lot takes its parameters from the side of its
+ * frontage edge only. Every field carries an "inherit" sentinel so an
+ * unset side costs nothing: numeric sentinels (densityScale/setback < 0,
+ * min/max sizes 0, layout empty) mean "fall back" to the town-area
+ * settings (phase 4.5 item 4) and then to the district preset. The
+ * merge happens in TownGen::generateTown() per lot, so the settings
+ * survive town regeneration.
+ *
+ * Merge order: edge side > town area > district preset.
+ */
+struct RoadLotParams {
+ /** Buildability tri-state (see RoadLotBuildable). */
+ RoadLotBuildable buildable = RoadLotBuildable::Inherit;
+
+ /** District preset override (see RoadLotDistrict). */
+ RoadLotDistrict district = RoadLotDistrict::Inherit;
+
+ /**
+ * Multiplier on the (possibly overridden) district density.
+ * < 0 means inherit.
+ */
+ float densityScale = -1.0f;
+
+ /**
+ * Distance in meters from the inset lot front line to the building
+ * front face. < 0 means inherit.
+ */
+ float setback = -1.0f;
+
+ /** Minimum building footprint in meters. 0 means inherit. */
+ float minFootprint = 0.0f;
+
+ /** Maximum building footprint in meters. 0 means inherit. */
+ float maxFootprint = 0.0f;
+
+ /** Minimum floor count. 0 means inherit. */
+ int minFloors = 0;
+
+ /** Maximum floor count. 0 means inherit. */
+ int maxFloors = 0;
+
+ /** Roof type override (see RoadLotRoof). */
+ RoadLotRoof roofType = RoadLotRoof::Inherit;
+
+ /**
+ * Building layout DSL string (phase 4.5 item 2). Empty means the
+ * default procedural layout; the DSL shares its vocabulary with the
+ * interior generator so exterior shell and interior floor plan stay
+ * consistent.
+ */
+ std::string layout;
+
+ /**
+ * Maximum lot frontage in meters before the lot is split along its
+ * depth into equal parcels (phase 4.5 item 3). 0 means no
+ * splitting.
+ */
+ float maxFrontage = 0.0f;
+
+ /** true when every field sits at its inherit sentinel. */
+ bool isDefault() const
+ {
+ return buildable == RoadLotBuildable::Inherit &&
+ district == RoadLotDistrict::Inherit &&
+ densityScale < 0.0f && setback < 0.0f &&
+ minFootprint == 0.0f && maxFootprint == 0.0f &&
+ minFloors == 0 && maxFloors == 0 &&
+ roofType == RoadLotRoof::Inherit && layout.empty() &&
+ maxFrontage == 0.0f;
+ }
+};
+
/**
* A flat decal quad painted onto the road surface (phase 2.5): manhole
* covers, arrows, damage patches, parking symbols.
@@ -480,6 +601,16 @@ struct RoadEdge {
* Purely visual; see RoadEdgeDecal.
*/
std::vector decals;
+
+ /**
+ * Per-side building-lot parameters (taxi demo phase 4.5, see
+ * RoadLotParams). Same side convention as the prefab slots:
+ * looking from nodeA toward nodeB, left is UNIT_Y x dir. A lot
+ * takes its parameters from the side of its frontage edge.
+ * splitEdge() copies both structs to both halves automatically.
+ */
+ RoadLotParams lotLeft;
+ RoadLotParams lotRight;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
@@ -501,6 +632,56 @@ struct RoadStreet {
std::vector > edges;
};
+/**
+ * District preset of a town area (taxi demo phase 4.5 item 4).
+ *
+ * Values mirror TownGen::District (roadlib/TownGen.hpp); mirrored as a
+ * separate enum to keep RoadGraph.hpp free of roadlib dependencies.
+ * Unlike RoadLotDistrict there is no Inherit value — an area always
+ * names its base preset.
+ */
+enum class RoadTownDistrict : int {
+ Village = 0,
+ Town = 1,
+ CityResidential = 2,
+ CityCenter = 3
+};
+
+/**
+ * A town generation scope on the road graph (taxi demo phase 4.5 item
+ * 4). Areas are the only generation scope of
+ * TownGenSystem::generateAreas(): a lot belongs to the area containing
+ * its centroid (XZ, square or circle test); overlapping areas resolve
+ * first-match-wins in vector order; lots inside no area generate
+ * nothing. The area carries the district preset, seed and density
+ * scale so a settlement's identity lives on the map, not in the
+ * generator dialog.
+ */
+struct RoadTownArea {
+ std::string name;
+ /** false: axis-aligned square (halfSize); true: circle (radius). */
+ bool circular = false;
+ /** Area center (world/render XZ; y is a display fallback). */
+ Ogre::Vector3 center = Ogre::Vector3::ZERO;
+ /** Square half side in meters. */
+ float halfSize = 500.0f;
+ /** Circle radius in meters. */
+ float radius = 500.0f;
+ RoadTownDistrict district = RoadTownDistrict::Village;
+ float densityScale = 1.0f;
+ uint32_t seed = 1;
+
+ /** XZ centroid containment test. */
+ bool contains(float x, float z) const
+ {
+ float dx = x - center.x;
+ float dz = z - center.z;
+ if (circular)
+ return dx * dx + dz * dz <= radius * radius;
+ return std::fabs(dx) <= halfSize && std::fabs(dz) <= halfSize;
+ }
+};
+
struct RoadGraph;
struct RoadWedge;
struct RoadStraightSegment;
@@ -541,6 +722,15 @@ struct RoadGraph {
*/
std::vector streets;
+ /**
+ * Town generation areas (phase 4.5 item 4). Like streets this is
+ * labelling/scoping data — geometry never reads it; consumed by
+ * TownGenSystem::generateAreas() (see RoadTownArea). Persisted
+ * inline in the scene JSON when not streaming, in
+ * roads/towns.json when streaming.
+ */
+ std::vector townAreas;
+
/**
* Monotonic version counter incremented by every mutating operation.
*
diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md
index b1f7ff4..95c5078 100644
--- a/src/features/editScene/demos/demo-taxi/PLAN.md
+++ b/src/features/editScene/demos/demo-taxi/PLAN.md
@@ -1,11 +1,12 @@
# demo-taxi — Implementation Plan
-**Status: Phase 4 done (2026-10-05).** Phases 0-4 done (skeleton,
-sedan vehicles, road geometry verification + automatic compliance,
-road markings/UVs/decals, road-network generator with crossings/lots/
-streets, procedural town generation with districts/foundations/stairs);
-Phase 4.5 (town parameters on road edge sides, building-layout DSL,
-multi-town areas — research + foundations) is next.
+**Status: Phase 4.5 done (2026-10-06).** Phases 0-4 done
+(skeleton, sedan vehicles, road geometry verification + automatic
+compliance, road markings/UVs/decals, road-network generator with
+crossings/lots/streets, procedural town generation with
+districts/foundations/stairs); Phase 4.5 (edge-side lot params,
+layout DSL, lot splitting, town areas, per-page building streaming —
+`ProceduralRoadGeometry.md` §21) is fully implemented.
This plan is a working draft — phases get refined, split or reordered
as we work through them.
@@ -1326,9 +1327,9 @@ frontage = street edge > fallback source edge > longest edge, floor on
a foundation block seated at the highest terrain corner, entrance
stairs (solid steps) when the door ground drops > 0.35 m, per-building
pickup POI children, sequential street addresses ("Lot N" fallback),
-water-level rejection, disabled scene-switch exterior doors
-(`doorsEnabled = false`, placeholder `town_interior.json` wired in
-Phase 8), headless probes (counts/floors/determinism/water/stairs/
+water-level rejection, locked exterior door leaves (real leaf per
+doorway, `lockable` + `lockedByDefault`, opened by Phase 8's
+interior scene-switch wiring), headless probes (counts/floors/determinism/water/stairs/
addresses — the navmesh-rebuild assert is covered by code review:
CellGrid dirty marks dirty the navmesh). Fix pass (2026-10-05):
lots are shrunk off the road body before placement
@@ -1370,17 +1371,44 @@ foundations for all three. Research output lands in
field set, how edge-side blocks map to lots (enclosed faces touch
several edges — precedence rules), how overrides merge with district
presets, migration of existing scenes (absent = inherit).
-- **Building layout DSL.** A small declarative language describing a
- building layout — floors, rooms, walls, windows, doors, roofs, all
- with parameters — evaluated into CellGrid cells + `RoofComponent`
- sets. One layout definition must drive BOTH the exterior shell
- (Phase 4 path) and the interior scene (Phase 8), so the DSL is the
- single source of truth for a building's shape. Research: grammar
- (Lua table DSL vs custom text format vs JSON), parameterization
- (variables, per-floor overrides, random draws under the town seed),
- a template library (named layouts referenced from edge-side lot
- params), and the evaluation boundary between `TownGen` (placement)
- and `TownGenSystem` (materialization).
+- **Building layout DSL.** A small declarative language —
+ S-expression based — describing a building layout (floors, rooms,
+ walls, windows, doors, roofs, all with parameters), evaluated into
+ CellGrid cells + `RoofComponent` sets. One layout definition must
+ drive BOTH the exterior shell (Phase 4 path) and the interior scene
+ (Phase 8), so the DSL is the single source of truth for a building's
+ shape. Required language features:
+ - **Variables, iteration and conditionals** — in particular
+ conditionals on the lot size, so one layout adapts itself (or
+ picks a fitting variant) to the lot it lands on; random draws stay
+ under the town seed.
+ - **Templating** — named layouts in a library, referenced from
+ edge-side lot params and reused across buildings/lots of different
+ sizes without per-building edits.
+ - **Default layouts per building type**, so a plain generation run
+ still produces varying buildings with no authored layouts.
+ - **Lot features** — a car parking area can be defined for the lot
+ (used later by traffic and NPC driving).
+ - **Extensibility** — the same mechanism should later grow fences,
+ pre-made meshes with generated colliders, prefab placement and
+ actuators.
+ - **Lua embedding is deferred** to later phases, where it will serve
+ specialized/complex layouts; the S-expression core stays the base
+ layer.
+ Research: the concrete grammar, the evaluation boundary between
+ `TownGen` (placement) and `TownGenSystem` (materialization), and how
+ one evaluated layout feeds both the exterior shell and the interior
+ scene generator.
+- **Lot splitting along long edges.** A long edge side can be split
+ into more than one lot (frontage subdivision), each split getting
+ its own building and an address with a letter suffix ("Main Street
+ 12A", "12B"). Research: split rules (minimum frontage per split,
+ relation to the 45 m minimum lot size), per-split parameter
+ overrides, interaction with enclosed-face lots.
+- **Building streaming.** Buildings should stream in/out with the
+ terrain pages to keep memory bounded. Research: the distance-based
+ spawner pattern vs a per-page region store of generated building
+ definitions (revisits the Phase 4 deferral).
- **Multiple towns: areas in the road graph.** `RoadGraph` gains a
`townAreas` list — square or circular areas (center, half-size or
radius, district + params, seed) scoping generation: `findLots()`
@@ -1391,14 +1419,214 @@ foundations for all three. Research output lands in
outline, panel), streaming interaction (areas vs region stores).
- **Headless coverage:** edge-side params round-trip (serialize /
region store / regeneration keeps overrides), layout DSL evaluation
- (a sample layout produces the expected cell flags and roof set),
- two overlapping-scope areas generate independent deterministic
- towns.
+ (a sample layout produces the expected cell flags, roof set and
+ parking area; conditionals pick variants per lot size), lot
+ splitting (a long edge yields multiple letter-suffixed lots), two
+ overlapping-scope areas generate independent deterministic towns.
- **Docs:** `ProceduralRoadGeometry.md` §21 (params model, DSL
grammar, areas), AGENTS.md, this plan's status blocks.
-### Phase 5 — NPC vehicle traffic
+**Phase 4.5 implementation status (2026-10-06): all five items DONE —
+1 (edge-side lot params), 2 (layout DSL), 3 (lot splitting), 4 (town
+areas), 5 (per-page building streaming).** `RoadLotParams lotLeft/lotRight` on
+`RoadEdge` (enums `RoadLotBuildable`/`RoadLotDistrict`/`RoadLotRoof`,
+all fields with inherit sentinels), serialized inline (sparse) and in
+region files (`RoadRegionLotParams` mirror), converted at all three
+`RoadSystem` region boundaries, merged per lot in
+`TownGen::generateTown()` off the geometrically matched frontage edge
+(buildable==Off skips before RNG draws; `skippedNotBuildable`),
+`TownBuilding::roofType`/`layout` carried to `TownGenSystem` (roof
+override), editor "Lot Params Left/Right" tree nodes in the edge
+inspector, probes in `runTownGenProbe()`. Item 2: the pure `layoutlib`
+(`BuildingPlan` + S-expr parser + evaluator/library, CMake `LayoutLib`)
+evaluates edge-side/inline/district-default layouts into a
+`BuildingPlan` (bit-identical `PlanFlags`/`CellFlags`, roof list,
+parking rects -> extra POIs, recorded `place` entries);
+`TownGenSystem::fillBuildingCells()` copies the plan into the grid,
+roofs come from `plan.roofs` with the edge-side/Flat fallback; the four
+built-in district layouts share a parametric body that keeps the legacy
+RNG draw order (all-default towns are bit-identical to pre-DSL,
+probe-verified); eval failures warn and fall back to the district
+default (`skippedLayoutError` only when that also fails). Item 3:
+edge-side `maxFrontage` > 0 splits a wide lot into independent
+frontage strips in `TownGen::generateTown()` (per-strip density/layout
+draws, `lot-w` binds the strip width, strip count clamped to the
+district minimum footprint; siblings share the street number with a
+letter suffix — "Main Street 12A"/"12B"; per-strip pickup/parking
+POIs). Unsplit towns are bit-identical to before (existing probes
+hold); `runTownGenProbe()` covers the 4-strip split, the min-footprint
+clamp and shared street numbers. Item 4: `RoadTownArea` records on
+`RoadGraph::townAreas` (name, square/circle XZ scope, district, seed,
+densityScale; `contains()` centroid test) scope generation per
+settlement — `TownGen::generateTown()` filters lots by centroid
+containment with first-match-wins over overlapping areas (before any
+RNG draws, so per-area towns are deterministic), lots inside no area
+build nothing in area mode; `TownGenSystem::generateAreas()` builds
+one `"TownGen "` container per area (setup shared with
+`generate()` via `setupTownRun`, creation tail extracted into
+`createTownContainer()`); serialized inline next to `streets` when not
+streaming, in `roads/towns.json` (`RoadRegionStore::loadTowns/
+saveTowns`) when streaming; the Town Generator panel edits the area
+list (name/shape/center with Pick Center on terrain/halfSize/radius/
+district/seed/density, Add/Remove, Generate All Areas) and
+`buildLotOutlines()` draws the areas in dark blue next to the dark
+grey lot loops; probes cover the two-area split with overlap
+first-match-wins, per-area determinism and the json round trip.
+Item 5: `TownGen::generateTown()` page scoping (`PageScope`: a
+render-XZ -> physical-page `PageMapper` + page filter) switches the
+RNG to per-page streams (`seed ^ hash(page)`, lots grouped by page
+and centroid-sorted within), so the all-pages run is exactly the
+union of the per-page runs and a page's buildings regenerate without
+the rest of the town; editor `generate()`/`generateAreas()` use the
+all-pages scope on streaming terrains (the `--generate-town=town,7`
+count moved 26 -> 28, deterministic). The game-mode driver
+`TownGenSystem::update()` (from `TerrainSystem::update`) streams
+buildings per terrain page into `StreamedTownTag`-ged `"TownGen
+ p_"` containers (excluded from scene JSON), one
+(page, area) generation per frame, destroyed on page unload; probes
+cover the all-pages == per-page-union equality, disjoint lot sets
+and per-page determinism.
+Follow-up fix (2026-10-06): town houses showed a hole above the door
+frame — `CellGridSystem::buildDoors()` used a fixed 3.0 m opening
+height while the frame mesh and leaf scale as
+`3.0 * cellHeight / 4`, so a 3 m town cell got a full-height opening
+with a 2.25 m frame. The opening height now scales the same way, so
+short cells get a lintel plane above the doorway and the opening
+always matches the frame; 4 m manual grids are unchanged. Follow-up
+(2026-10-06): town houses now also get a real door leaf — every
+external doorway gets a per-door config with `lockable` +
+`lockedByDefault` ("E Locked", never opens, blocks physically and on
+the navmesh) and the grid uid is `"town-"` so streamed
+rebuilds keep stable door IDs; Phase 8 replaces the lock with the
+interior scene-switch wiring. Editor aid (2026-10-06): the Lot
+Params Layout field gained an Insert Template button (empty field
+<- `LayoutLibrary::defaultTemplateSource()`) and a DSL Help window
+with the language reference and worked examples
+(`LayoutLibrary::examples()`, probe-verified).
+
+Follow-up (2026-10-07): non-rectangular buildings. The DSL gained
+`(cut x0 z0 x1 z1)` — shell-frame meters, clears the covered cells
+(all floors, or the current floor inside a `floor` context) and
+re-marks the surviving hole-facing neighbors as exterior walls — and
+`roof` gained an optional rect `(roof type x0 z0 x1 z1)` for
+per-wing roofs; openings on cut cells are hard evaluation errors,
+`windows` skips Floor-less cells. The examples library now includes
+probe-verified L- and U-shaped houses (shown in the DSL Help
+window). `TownGen` records the plan's front-door cell
+(`TownBuilding::doorCellX`) so the door ground sample and entrance
+stairs sit at the real door instead of the front-face center;
+`TownGenSystem` materializes cut footprints shape-aware (one roof
+per top-floor row run / one foundation box per ground-floor row run;
+solid floors keep the legacy single-piece geometry bit-identical).
+Probes cover the cut flags, the per-rect roofs and the
+door-on-a-cut-cell rejection. Follow-up (2026-10-07):
+editor-generated town containers are now tagged `GeneratedTownTag`
+and excluded from scene serialization (same treatment as the
+game-mode `StreamedTownTag`) — a town is a pure function of the
+persisted road graph, so saving it with the scene only duplicated
+the regenerated buildings; the editor regenerates from the Town
+Generator panel. Note: the `--generate-town=town,7` canary count
+tracks the SAVED graph including edge-side lot params — a saved
+per-edge layout override changes that lot's RNG draw count and
+reshuffles the per-page stream (the pristine graph yields 28, the
+demo's current saved lot-params 25; both deterministic).
+Follow-up fix (2026-10-07): a failed layout no longer poisons the
+district-default fallback — `LayoutEval::evaluate()` now resets the
+output plan on entry (the failed attempt's partial shell otherwise
+made the retry trip "shell already defined" and the lot lost its
+building entirely); probe covers the reuse.
+
+Follow-up (2026-10-08): per-layout grid metrics + seating fixes.
+The DSL gained `(cell-size xz-m [height-m])` (before the shell) —
+the plan now carries its `cellSize`/`cellHeight` (defaults changed
+to 2 m cells / 4 m floors, matching manual grids) and the
+evaluator, TownGen and TownGenSystem all honor them instead of the
+former compile-time constants. The foundation block(s) now
+overhang the shell walls by 0.3 m per side (was a flush 0.075 m),
+and the entrance stairs center on the door cell CENTER (cell
+centers sit at x*cellSize in the cell frame — the old
+`(doorCellX + 0.5) * cellSize` sat half a cell off the actual door
+frame; the doorless fallback is the shell center). Probes cover
+the default metrics, the verb's meter->cell remapping and the
+after-shell rejection. Same round: `EditorApp::keyPressed/
+keyReleased()` now return early when `ImGui::GetIO().WantTextInput`
+(typing in the lot-params DSL editor no longer fires editor hotkeys
+like the F camera focus or game input; ImGui still receives the
+events via the input-listener chain), and the entrance stairs gained
+a floor-level landing (`STAIR_LANDING` 0.9 m) in front of the door
+so the door frame no longer overhangs the first step.
+
+**Phase 4.5 research status (2026-10-05): design landed in
+`ProceduralRoadGeometry.md` §21.** Key decisions: edge-side
+`RoadLotParams` on `RoadEdge` (a lot takes params from the side of its
+FRONTAGE edge only — no multi-edge precedence; inherit sentinels on
+every field; both serializers + `splitEdge` copy); an S-expression
+`layoutlib` (pure, no Lua) evaluating to a pure `BuildingPlan` (cell
+flags + roof list + lot features + extension placements) shared by the
+exterior shell and the Phase 8 interior generator; lot splitting as a
+TownGen-time frontage subdivision with letter-suffix addresses ("Main
+Street 12A/12B"), driven by `RoadLotParams::maxFrontage`; building
+streaming via deterministic PER-PAGE regeneration (seed ^ pageIndex,
+lots assigned by centroid — no building store needed); town areas on
+`RoadGraph` (square/circle, centroid containment, `roads/towns.json`
+when streaming). Implementation order: §21.6 (edge params -> DSL ->
+splitting -> areas -> per-page streaming).
+
+### Phase 4.6 — Room-composed buildings + per-building regeneration
+
+- Rooms-first layout DSL (additive, complements shell+cut): the shell
+ declares the overall building rect, rooms fill it by relative
+ offsets/sizes (fractions or meters, e.g. `(room living (rect 0 0 0.5
+ 1) :at 0 0)` style entries), and the floor FOOTPRINT is the union of
+ the room rects — L/U/T/courtyard shapes emerge from room placement
+ with no `cut` needed. Exterior walls = boundary of the union,
+ interior walls = shared edges, openings via room connections
+ (`(connect living kitchen)` / door declared on the connection;
+ validated adjacency). Rooms may not overlap; gaps in the union
+ become exterior (courtyard/notch); the entry door targets a room
+ cell on the union boundary. Output stays the same BuildingPlan, so
+ materialization, streaming and the Phase 8 interior consumer are
+ untouched — pure evaluator work. shell+cut stays supported for
+ quick massing.
+- This becomes the primary authoring path for buildings that get
+ interiors (Phase 8): rooms are the primary data there anyway, so
+ exterior and interior can never disagree.
+- Per-building regeneration in the editor: re-evaluate one lot's
+ layout and rebuild only that building entity (the lot params are the
+ persisted truth), so layout tweaking doesn't pay a whole-town
+ rebuild. Deterministic for the edited lot; downstream lots in the
+ same page stream can diverge from a full regeneration when the
+ layout's RNG draw count changes — a full regen restores consistency
+ (documented in the UI).
+- It should be easy to check for overlapping rooms and connected rooms not sharing a wall. Due to fractionable numbers use, care should be taken to make sure neighboring rooms do not disagree on resulting cell positions, making it safe to assume that the fraction gets consistent integer positions in the grid.
+- Samples in the help should be updated adding room-based layouts example for various building footprints.
+- Headless: probes for room-union footprints (L/U shapes without cut),
+ overlap/adjacency validation errors, connections -> IntDoor flags,
+ entry-door placement on non-rect unions, per-building regen
+ equivalence with a full regen for the edited lot.
+
+### Phase 4.7 NPC motion
+- the function is centered around NPC having a target to get to.
+- should be controlled by dedicated system
+- NPCs are deterministic and are generated by the town using per-lot settings
+- House bed count should be equal or more to NPC count. The bed should be registered to NPC (or bed side for bigger/composite beds) (after we start handling interiors)
+- NPCs are defined as families/groups with members having relationships set
+- NPCs name/sex/stats/needs growths/etc. values are set there too. Whatever not set is generated. Rules should be per-town.
+- NPCs are generated via character registry by creating dynamic entries but using pre-defined registry character can be used.
+- Town influences NPC schedule by setting their goal.
+- NPC motion system tracks NPC motion by symbolic locations like home/work/leisure/interest, etc. which are defined per NPC and generated if not defined.
+- NPC can be at the location running action/smart object or between them.
+- If NPC is in between, they join traffic (phase 5 or 6)
+- The motion simulation can be low detail or high detail. Low detail simulation just changes NPC world position and applys effects, no NPC spawned. High detail simulation spawns NPC and or the vehicle and provides full simulation.
+- Traffic connects locations to each other. The path between locations is generated using AStar, using navmesh or other algorithms.
+- Traffic is used only for exterior. Interior uses completely different systems based on GOAP or HTN and behavior trees. NPC motion system uses goals and delivers NPCs to simulate live town being believable but without complex computations. Basically it simulates NPC presence and motion at location at time so if player gets to that location and NPC is moving to the place they see NPC walking or driving. NPCs should realistically leave buildings, enter vehicles or walk, park and leave vehicles ane enter buildings. Entering building means NPC despawn from exterior scene by activating the front door. Not all targets are inside buildings, exterior locations should be supporterd too. It should be easy to author locations (interior/exterior).
+- Taxi mission should have NPCs near player to have special goal of using the taxi and waiting to be picked up. However out of the mission, NPCs should be able to call the taxi and transit by it.
+### Phase 5 — NPC vehicle traffic
+- the NPC can choose by distance if vehicle is used or going by feet (phase 8).
+- the vehicle is used when NPC has one parked near their current location.
+- NPC position is simulated in varying levels of detail
+ - camera is far away and not visible =
- AI driver system (new, engine-level): a vehicle follows the road
graph (route = path over edges), respects per-edge speed limits,
basic collision avoidance (raycast/whisker braking for the vehicle
@@ -1468,7 +1696,8 @@ taxi-specific):
cached on first visit), not pre-authored per building. Multi-storey
buildings get Phase 7 stairwells so every floor is traversable.
- Enable the F1 scene-switch exterior doors prepared in Phase 4
- (doorConfigs `disabled` -> wired with `sceneSwitchPath`/target);
+ (the per-door `lockable`/`lockedByDefault` config is replaced by
+ `sceneSwitchPath`/target wiring);
entering switches to the generated interior, the interior's exit
door switches back to the player's exterior position.
- Interior content level for v1: walls/floor/ceiling/stairs/windows
diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json
index db798e1..4b8689d 100644
--- a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json
+++ b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json
@@ -849,15 +849,15 @@
},
"transform": {
"position": {
- "x": 4002.519287109375,
- "y": 11.094489097595215,
- "z": 3962.923095703125
+ "x": 3999.490234375,
+ "y": 10.889010429382324,
+ "z": 3961.32958984375
},
"rotation": {
- "w": 0.9888900518417358,
- "x": -0.0026904146652668715,
- "y": 0.14861378073692322,
- "z": 0.0017891188617795706
+ "w": 0.9959467053413391,
+ "x": -0.05614081025123596,
+ "y": -0.07026758790016174,
+ "z": -0.000987574690952897
},
"scale": {
"x": 1.0,
@@ -1254,15 +1254,15 @@
},
"transform": {
"position": {
- "x": 4016.510498046875,
- "y": 11.157183647155762,
- "z": 3964.1669921875
+ "x": 4014.667724609375,
+ "y": 10.975330352783203,
+ "z": 3964.62451171875
},
"rotation": {
- "w": 0.9334654211997986,
- "x": -0.0019687723834067583,
- "y": 0.35863012075424194,
- "z": 0.004789790604263544
+ "w": 0.8245971202850342,
+ "x": -0.0635930672287941,
+ "y": 0.5606324672698975,
+ "z": 0.04107089713215828
},
"scale": {
"x": 1.0,
@@ -1608,7 +1608,7 @@
0.800000011920929
],
"sunSphereSize": 5.0,
- "timeOfDay": 0.6397704482078552,
+ "timeOfDay": 9.319156646728516,
"timeSpeed": 0.12999999523162842
},
"transform": {
diff --git a/src/features/editScene/layoutlib/BuildingPlan.hpp b/src/features/editScene/layoutlib/BuildingPlan.hpp
new file mode 100644
index 0000000..a62ffee
--- /dev/null
+++ b/src/features/editScene/layoutlib/BuildingPlan.hpp
@@ -0,0 +1,128 @@
+#pragma once
+
+#include
+#include
+#include
+
+/*
+ * BuildingPlan — the pure value produced by evaluating a building
+ * layout DSL script (layoutlib, demo-taxi phase 4.5 item 2).
+ *
+ * layoutlib is deliberately free of Ogre/ECS/Lua dependencies so the
+ * parser and evaluator are headless-testable like roadlib. The plan
+ * is consumed by TownGenSystem (exterior shell materialization) and
+ * later by the Phase 8 interior generator, so exterior and interior
+ * always come from the same definition.
+ *
+ * Cell flags mirror components/CellGrid.hpp CellFlags bit-for-bit
+ * (TownGenSystem static_asserts the match) so the plan can be copied
+ * into a CellGridComponent without translation.
+ */
+
+namespace LayoutLib {
+
+/** Default grid metrics; the DSL `cell-size` verb overrides them per
+ * plan (BuildingPlan::cellSize/cellHeight). */
+constexpr float LAYOUT_CELL_SIZE = 2.0f; /* meters per cell */
+constexpr float LAYOUT_CELL_HEIGHT = 4.0f; /* meters per floor */
+
+/** Cell flag bits — MUST stay identical to CellFlags (CellGrid.hpp). */
+namespace PlanFlags {
+constexpr uint64_t Floor = 1ULL << 0;
+constexpr uint64_t Ceiling = 1ULL << 1;
+constexpr uint64_t WallXNeg = 1ULL << 2;
+constexpr uint64_t WallXPos = 1ULL << 3;
+constexpr uint64_t WallZPos = 1ULL << 4;
+constexpr uint64_t WallZNeg = 1ULL << 5;
+constexpr uint64_t DoorXNeg = 1ULL << 6;
+constexpr uint64_t DoorXPos = 1ULL << 7;
+constexpr uint64_t DoorZPos = 1ULL << 8;
+constexpr uint64_t DoorZNeg = 1ULL << 9;
+constexpr uint64_t WindowXNeg = 1ULL << 10;
+constexpr uint64_t WindowXPos = 1ULL << 11;
+constexpr uint64_t WindowZPos = 1ULL << 12;
+constexpr uint64_t WindowZNeg = 1ULL << 13;
+constexpr uint64_t IntWallXNeg = 1ULL << 14;
+constexpr uint64_t IntWallXPos = 1ULL << 15;
+constexpr uint64_t IntWallZPos = 1ULL << 16;
+constexpr uint64_t IntWallZNeg = 1ULL << 17;
+constexpr uint64_t IntDoorXNeg = 1ULL << 18;
+constexpr uint64_t IntDoorXPos = 1ULL << 19;
+constexpr uint64_t IntDoorZPos = 1ULL << 20;
+constexpr uint64_t IntDoorZNeg = 1ULL << 21;
+constexpr uint64_t IntWindowXNeg = 1ULL << 22;
+constexpr uint64_t IntWindowXPos = 1ULL << 23;
+constexpr uint64_t IntWindowZPos = 1ULL << 24;
+constexpr uint64_t IntWindowZNeg = 1ULL << 25;
+}
+
+/** Roof entry; type mirrors RoofComponent::Type (Flat=0 .. Cylinder=4). */
+struct PlanRoof {
+ int type = 0;
+ int posX = 0;
+ int posY = 0; /* cell row above the top floor */
+ int posZ = 0;
+ int sizeX = 1;
+ int sizeZ = 1;
+ float baseHeight = 0.5f;
+ float maxHeight = 0.5f;
+};
+
+/**
+ * Parking pad in LOT frame: origin at the front-left corner of the
+ * buildable (road-clearance-inset) lot rect, +x along the frontage,
+ * +z into the lot, meters.
+ */
+struct PlanParking {
+ float x = 0.0f;
+ float z = 0.0f;
+ float w = 0.0f;
+ float d = 0.0f;
+};
+
+/**
+ * Generic placement (prefab path + lot-frame position, yaw degrees).
+ * Recorded by the (place ...) verb; materialization is deferred to
+ * later phases.
+ */
+struct PlanPlacement {
+ std::string prefab;
+ float x = 0.0f;
+ float z = 0.0f;
+ float yawDeg = 0.0f;
+};
+
+struct BuildingPlan {
+ int cellsX = 0; /* along the frontage */
+ int cellsZ = 0; /* into the lot */
+ int floors = 0;
+ /** Grid metrics in meters (the `cell-size` verb; defaults
+ * LAYOUT_CELL_SIZE/LAYOUT_CELL_HEIGHT). */
+ float cellSize = LAYOUT_CELL_SIZE;
+ float cellHeight = LAYOUT_CELL_HEIGHT;
+ /**
+ * Per-cell flags, indexed [floor * cellsZ * cellsX + z * cellsX
+ * + x]. Only valid when cellsX/cellsZ/floors > 0.
+ */
+ std::vector cells;
+ std::vector roofs;
+ std::vector parkings;
+ std::vector placements;
+
+ bool valid() const
+ {
+ return cellsX > 0 && cellsZ > 0 && floors > 0 &&
+ cells.size() == (size_t)cellsX * cellsZ * floors;
+ }
+
+ uint64_t &at(int x, int y, int z)
+ {
+ return cells[((size_t)y * cellsZ + z) * cellsX + x];
+ }
+ uint64_t at(int x, int y, int z) const
+ {
+ return cells[((size_t)y * cellsZ + z) * cellsX + x];
+ }
+};
+
+} /* namespace LayoutLib */
diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp
new file mode 100644
index 0000000..3f9173d
--- /dev/null
+++ b/src/features/editScene/layoutlib/LayoutEval.cpp
@@ -0,0 +1,1272 @@
+#include "LayoutEval.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace LayoutLib {
+
+/* ------------------------------------------------------------------ */
+/* LayoutLibrary */
+/* ------------------------------------------------------------------ */
+
+void LayoutLibrary::registerLayout(const std::string &name,
+ std::vector body)
+{
+ m_layouts[name] = std::move(body);
+}
+
+bool LayoutLibrary::loadSource(const std::string &source, std::string *error)
+{
+ std::vector forms;
+ if (!parseSExprs(source, forms, error))
+ return false;
+ for (const SExpr &f : forms) {
+ if (!f.isList() || f.list.size() < 2 ||
+ !f.list[0].isSymbol("layout") || !f.list[1].isSymbol()) {
+ if (error)
+ *error = "line " + std::to_string(f.line) +
+ ": expected (layout name body...)";
+ return false;
+ }
+ std::vector body(f.list.begin() + 2, f.list.end());
+ registerLayout(f.list[1].text, std::move(body));
+ }
+ return true;
+}
+
+bool LayoutLibrary::loadFile(const std::string &path, std::string *error)
+{
+ std::ifstream file(path);
+ if (!file.is_open()) {
+ if (error)
+ *error = "cannot open " + path;
+ return false;
+ }
+ std::stringstream ss;
+ ss << file.rdbuf();
+ return loadSource(ss.str(), error);
+}
+
+const std::vector *LayoutLibrary::find(const std::string &name) const
+{
+ auto it = m_layouts.find(name);
+ return it == m_layouts.end() ? nullptr : &it->second;
+}
+
+LayoutLibrary LayoutLibrary::withBuiltins()
+{
+ LayoutLibrary lib;
+ for (const std::string &name : builtinNames()) {
+ std::vector forms;
+ std::string err;
+ if (parseSExprs(defaultTemplateSource(), forms, &err))
+ lib.registerLayout(name, std::move(forms));
+ }
+ return lib;
+}
+
+std::vector LayoutLibrary::builtinNames()
+{
+ return { "village-house", "town-rowhouse", "city-block",
+ "city-tower" };
+}
+
+const char *LayoutLibrary::defaultTemplateSource()
+{
+ /* District defaults share one parametric body for now: the
+ * district preset (and edge-side overrides) flow in through the
+ * min/max bindings, so footprint/floors still come from the
+ * merged district params and the rand draws keep the legacy
+ * TownGen draw order (width, depth, floors). */
+ return "(shell (rand-range min-fp max-fp)\n"
+ " (rand-range min-fp max-fp)\n"
+ " :floors (rand-int min-floors max-floors))\n"
+ "(floor 0 (door front center))\n"
+ "(windows all)\n";
+}
+
+const std::vector &LayoutLibrary::examples()
+{
+ static const std::vector ex = {
+ { "Minimal one-floor house",
+ "(shell 12 8 :floors 1)\n"
+ "(floor 0 (door front center)\n"
+ " (windows all :spacing 4))\n"
+ "(roof gable :height 2)\n" },
+ { "Two rooms with an interior door",
+ "(shell 12 8)\n"
+ "(floor 0 (room living (rect 0 0 0.5 1))\n"
+ " (room kitchen (rect 0.5 0 1 1))\n"
+ " (idoor living kitchen)\n"
+ " (door front center)\n"
+ " (windows all))\n" },
+ { "Lot-adaptive size with parking",
+ "(if (>= lot-w 12)\n"
+ " (shell 14 10 :floors 2)\n"
+ " (shell 8 8 :floors 1))\n"
+ "(floor 0 (door front center))\n"
+ "(windows all)\n"
+ "(if (>= lot-w 12)\n"
+ " (parking (rect-front-left 4 5)))\n" },
+ { "L-shaped house (cut + per-wing roofs)",
+ "(let ((w 12) (d 12))\n"
+ " (shell w d :floors 1)\n"
+ " (cut (/ w 2) (/ d 2) w d)\n"
+ " (floor 0 (door front -3)\n"
+ " (windows all :spacing 4))\n"
+ " (roof flat 0 0 w (/ d 2))\n"
+ " (roof flat 0 (/ d 2) (/ w 2) d))\n" },
+ { "U-shaped house (courtyard to the street)",
+ "(let ((w 12) (d 12))\n"
+ " (shell w d :floors 1)\n"
+ " (cut 4 (/ d 2) 8 d)\n"
+ " (floor 0 (door front -4)\n"
+ " (windows all :spacing 4))\n"
+ " (roof flat 0 0 w (/ d 2))\n"
+ " (roof flat 0 (/ d 2) 4 d)\n"
+ " (roof flat 8 (/ d 2) w d))\n" },
+ };
+ return ex;
+}
+
+/* ------------------------------------------------------------------ */
+/* LayoutEval */
+/* ------------------------------------------------------------------ */
+
+LayoutEval::LayoutEval(const LayoutBindings &bindings, std::mt19937 *rng,
+ const LayoutLibrary *library)
+ : m_b(bindings)
+ , m_rng(rng)
+ , m_lib(library)
+ , m_builtins(LayoutLibrary::withBuiltins())
+ , m_plan(nullptr)
+ , m_floor(0)
+{
+}
+
+bool LayoutEval::fail(const SExpr &at, const std::string &msg)
+{
+ m_error = "line " + std::to_string(at.line) + ": " + msg;
+ return false;
+}
+
+double LayoutEval::num(const Value &v, bool &ok) const
+{
+ if (v.type == Value::Number) {
+ ok = true;
+ return v.num;
+ }
+ if (v.type == Value::Bool) {
+ ok = true;
+ return v.boolean ? 1.0 : 0.0;
+ }
+ ok = false;
+ return 0.0;
+}
+
+bool LayoutEval::lookup(const std::string &name, Value &out) const
+{
+ for (auto it = m_scopes.rbegin(); it != m_scopes.rend(); ++it) {
+ auto f = it->find(name);
+ if (f != it->end()) {
+ out = f->second;
+ return true;
+ }
+ }
+ return false;
+}
+
+bool LayoutEval::eval(const SExpr &e, Value &out)
+{
+ switch (e.type) {
+ case SExpr::Number:
+ out = Value::number(e.number);
+ return true;
+ case SExpr::String:
+ out = Value::string(e.text);
+ return true;
+ case SExpr::Symbol:
+ if (e.text == "true") {
+ out = Value::booleanV(true);
+ return true;
+ }
+ if (e.text == "false") {
+ out = Value::booleanV(false);
+ return true;
+ }
+ if (lookup(e.text, out))
+ return true;
+ return fail(e, "unbound symbol '" + e.text + "'");
+ case SExpr::List:
+ return evalList(e, out);
+ }
+ return fail(e, "bad expression");
+}
+
+bool LayoutEval::argNumber(const SExpr &e, double &out)
+{
+ Value v;
+ if (!eval(e, v))
+ return false;
+ bool ok = false;
+ out = num(v, ok);
+ if (!ok)
+ return fail(e, "expected a number");
+ return true;
+}
+
+bool LayoutEval::collectArgs(const SExpr &form, size_t start,
+ std::vector &positional,
+ std::map &keywords)
+{
+ size_t i = start;
+ while (i < form.list.size()) {
+ const SExpr &a = form.list[i];
+ if (a.isSymbol() && !a.text.empty() && a.text[0] == ':') {
+ if (i + 1 >= form.list.size())
+ return fail(a, "keyword '" + a.text +
+ "' without a value");
+ Value v;
+ if (!eval(form.list[i + 1], v))
+ return false;
+ keywords[a.text] = v;
+ i += 2;
+ continue;
+ }
+ Value v;
+ if (a.isSymbol() && a.text != "true" && a.text != "false" &&
+ !lookup(a.text, v)) {
+ /* Unbound symbols are enum-like literals (door
+ * front center, windows all, roof cone) — pass them
+ * to the verb as strings. Bound symbols (lot-w,
+ * min-fp, ...) evaluate to their value. */
+ v = Value::string(a.text);
+ } else if (v.type == Value::Void && !eval(a, v)) {
+ return false;
+ }
+ positional.push_back(v);
+ ++i;
+ }
+ return true;
+}
+
+bool LayoutEval::evalForms(const std::vector &forms, size_t start)
+{
+ for (size_t i = start; i < forms.size(); ++i) {
+ Value v;
+ if (!eval(forms[i], v))
+ return false;
+ }
+ return true;
+}
+
+bool LayoutEval::evalList(const SExpr &e, Value &out)
+{
+ if (e.list.empty())
+ return fail(e, "empty form");
+ const SExpr &head = e.list[0];
+ if (!head.isSymbol())
+ return fail(head, "form head must be a symbol");
+ const std::string &op = head.text;
+
+ /* --- special forms --- */
+ if (op == "let") {
+ if (e.list.size() < 3 || !e.list[1].isList())
+ return fail(e, "let needs ((name value)...) body...");
+ std::map scope;
+ for (const SExpr &pair : e.list[1].list) {
+ if (!pair.isList() || pair.list.size() != 2 ||
+ !pair.list[0].isSymbol())
+ return fail(pair,
+ "let binding must be (name "
+ "value)");
+ Value v;
+ m_scopes.push_back(scope);
+ bool ok = eval(pair.list[1], v);
+ m_scopes.pop_back();
+ if (!ok)
+ return false;
+ scope[pair.list[0].text] = v;
+ }
+ m_scopes.push_back(std::move(scope));
+ bool ok = evalForms(e.list, 2);
+ m_scopes.pop_back();
+ out = Value();
+ return ok;
+ }
+ if (op == "if") {
+ if (e.list.size() < 3 || e.list.size() > 4)
+ return fail(e, "if needs (if cond then [else])");
+ Value c;
+ if (!eval(e.list[1], c))
+ return false;
+ if (c.truthy())
+ return eval(e.list[2], out);
+ if (e.list.size() == 4)
+ return eval(e.list[3], out);
+ out = Value();
+ return true;
+ }
+ if (op == "cond") {
+ for (size_t i = 1; i < e.list.size(); ++i) {
+ const SExpr &clause = e.list[i];
+ if (!clause.isList() || clause.list.size() != 2)
+ return fail(clause,
+ "cond clause must be (cond "
+ "value)");
+ Value c;
+ if (!eval(clause.list[0], c))
+ return false;
+ if (c.truthy())
+ return eval(clause.list[1], out);
+ }
+ out = Value();
+ return true;
+ }
+ if (op == "dotimes" || op == "for-range") {
+ /* (dotimes (i n) body...) /
+ * (for-range (i start end) body...) */
+ if (e.list.size() < 3 || !e.list[1].isList())
+ return fail(e, "loop needs ((var n) body...)");
+ const SExpr &spec = e.list[1];
+ double from = 0.0, to = 0.0;
+ if (op == "dotimes") {
+ if (spec.list.size() != 2 ||
+ !spec.list[0].isSymbol())
+ return fail(spec, "dotimes spec: (var n)");
+ if (!argNumber(spec.list[1], to))
+ return false;
+ } else {
+ if (spec.list.size() != 3 ||
+ !spec.list[0].isSymbol())
+ return fail(spec,
+ "for-range spec: (var start "
+ "end)");
+ if (!argNumber(spec.list[1], from) ||
+ !argNumber(spec.list[2], to))
+ return false;
+ }
+ const std::string &var = spec.list[0].text;
+ out = Value();
+ for (double i = from; i < to; i += 1.0) {
+ std::map scope;
+ scope[var] = Value::number(i);
+ m_scopes.push_back(std::move(scope));
+ bool ok = evalForms(e.list, 2);
+ m_scopes.pop_back();
+ if (!ok)
+ return false;
+ }
+ return true;
+ }
+
+ /* --- builtins --- */
+ static const char *arith[] = { "+", "-", "*", "/", "min",
+ "max", "clamp", "abs", "sqrt" };
+ bool isArith = false;
+ for (const char *a : arith)
+ if (op == a)
+ isArith = true;
+ if (isArith) {
+ std::vector pos;
+ std::map kw;
+ if (!collectArgs(e, 1, pos, kw))
+ return false;
+ bool ok = true;
+ double r = 0.0;
+ if (op == "+") {
+ for (const Value &v : pos)
+ r += num(v, ok);
+ } else if (op == "-") {
+ if (pos.empty())
+ ok = false;
+ else if (pos.size() == 1)
+ r = -num(pos[0], ok);
+ else {
+ r = num(pos[0], ok);
+ for (size_t i = 1; i < pos.size(); ++i)
+ r -= num(pos[i], ok);
+ }
+ } else if (op == "*") {
+ r = 1.0;
+ for (const Value &v : pos)
+ r *= num(v, ok);
+ } else if (op == "/") {
+ if (pos.size() < 2) {
+ ok = false;
+ } else {
+ r = num(pos[0], ok);
+ for (size_t i = 1; i < pos.size(); ++i) {
+ double d = num(pos[i], ok);
+ if (d == 0.0)
+ return fail(e, "division by "
+ "zero");
+ r /= d;
+ }
+ }
+ } else if (op == "min" || op == "max") {
+ if (pos.empty()) {
+ ok = false;
+ } else {
+ r = num(pos[0], ok);
+ for (size_t i = 1; i < pos.size(); ++i) {
+ double v = num(pos[i], ok);
+ r = op == "min" ? std::min(r, v) :
+ std::max(r, v);
+ }
+ }
+ } else if (op == "clamp") {
+ if (pos.size() != 3) {
+ ok = false;
+ } else {
+ double v = num(pos[0], ok);
+ double lo = num(pos[1], ok);
+ double hi = num(pos[2], ok);
+ r = std::max(lo, std::min(hi, v));
+ }
+ } else if (op == "abs") {
+ if (pos.size() != 1)
+ ok = false;
+ else
+ r = std::fabs(num(pos[0], ok));
+ } else if (op == "sqrt") {
+ if (pos.size() != 1)
+ ok = false;
+ else
+ r = std::sqrt(num(pos[0], ok));
+ }
+ if (!ok)
+ return fail(e, "bad arguments to '" + op + "'");
+ out = Value::number(r);
+ return true;
+ }
+
+ static const char *cmp[] = { "<", "<=", ">", ">=", "=", "!=" };
+ bool isCmp = false;
+ for (const char *c : cmp)
+ if (op == c)
+ isCmp = true;
+ if (isCmp || op == "and" || op == "or" || op == "not") {
+ std::vector pos;
+ std::map kw;
+ if (!collectArgs(e, 1, pos, kw))
+ return false;
+ if (op == "not") {
+ if (pos.size() != 1)
+ return fail(e, "not needs one argument");
+ out = Value::booleanV(!pos[0].truthy());
+ return true;
+ }
+ if (op == "and" || op == "or") {
+ bool r = op == "and";
+ for (const Value &v : pos)
+ r = op == "and" ? (r && v.truthy()) :
+ (r || v.truthy());
+ out = Value::booleanV(r);
+ return true;
+ }
+ if (pos.size() < 2)
+ return fail(e, "comparison needs two arguments");
+ bool ok = true;
+ bool r = true;
+ double prev = num(pos[0], ok);
+ for (size_t i = 1; i < pos.size() && ok; ++i) {
+ double v = num(pos[i], ok);
+ if (op == "<")
+ r = r && prev < v;
+ else if (op == "<=")
+ r = r && prev <= v;
+ else if (op == ">")
+ r = r && prev > v;
+ else if (op == ">=")
+ r = r && prev >= v;
+ else if (op == "=")
+ r = r && prev == v;
+ else
+ r = r && prev != v;
+ prev = v;
+ }
+ if (!ok)
+ return fail(e, "bad arguments to '" + op + "'");
+ out = Value::booleanV(r);
+ return true;
+ }
+
+ if (op == "rand" || op == "rand-range" || op == "rand-int") {
+ if (!m_rng)
+ return fail(e, "'" + op + "' needs a seed stream");
+ std::vector pos;
+ std::map kw;
+ if (!collectArgs(e, 1, pos, kw))
+ return false;
+ std::uniform_real_distribution unit(0.0f, 1.0f);
+ float u = unit(*m_rng);
+ bool ok = true;
+ if (op == "rand") {
+ out = Value::number(u);
+ return true;
+ }
+ if (pos.size() != 2)
+ return fail(e, "'" + op + "' needs lo hi");
+ double lo = num(pos[0], ok);
+ double hi = num(pos[1], ok);
+ if (!ok)
+ return fail(e, "bad range for '" + op + "'");
+ if (op == "rand-range") {
+ out = Value::number(lo + u * (hi - lo));
+ } else {
+ /* Inclusive integer range; matches the legacy
+ * TownGen floors draw. */
+ int ilo = (int)std::lround(lo);
+ int ihi = (int)std::lround(hi);
+ int span = std::max(1, ihi - ilo + 1);
+ int v = ilo + (int)(u * (float)span);
+ out = Value::number((double)std::min(v, ihi));
+ }
+ return true;
+ }
+
+ /* --- verbs (need a plan) --- */
+ static const char *verbs[] = { "shell", "floor", "cut",
+ "door", "windows", "room",
+ "idoor", "roof", "parking",
+ "place", "cell-size" };
+ bool isVerb = false;
+ for (const char *v : verbs)
+ if (op == v)
+ isVerb = true;
+ if (isVerb) {
+ if (!m_plan)
+ return fail(e, "'" + op + "' outside a layout "
+ "evaluation");
+ if (op == "floor")
+ return verbFloor(e);
+ if (op == "room")
+ return verbRoomRaw(e);
+ if (op == "parking")
+ return verbParking(e);
+ if (op == "place")
+ return verbPlace(e);
+ std::vector pos;
+ std::map kw;
+ if (!collectArgs(e, 1, pos, kw))
+ return false;
+ bool ok = false;
+ if (op == "shell")
+ ok = verbShell(pos, kw, e);
+ else if (op == "cell-size")
+ ok = verbCellSize(pos, e);
+ else if (op == "cut")
+ ok = verbCut(pos, e);
+ else if (op == "door")
+ ok = verbDoor(pos, e);
+ else if (op == "windows")
+ ok = verbWindows(pos, kw, e);
+ else if (op == "idoor")
+ ok = verbIDoor(pos, e);
+ else if (op == "roof")
+ ok = verbRoof(pos, kw, e);
+ out = Value();
+ return ok;
+ }
+
+ return fail(head, "unknown form '" + op + "'");
+}
+
+/* ------------------------------------------------------------------ */
+/* Verbs */
+/* ------------------------------------------------------------------ */
+
+bool LayoutEval::verbShell(const std::vector &pos,
+ const std::map &kw,
+ const SExpr &at)
+{
+ if (m_plan->valid())
+ return fail(at, "shell already defined");
+ if (pos.size() < 2)
+ return fail(at, "shell needs (shell w d [:floors f])");
+ bool ok = true;
+ double w = num(pos[0], ok);
+ double d = num(pos[1], ok);
+ if (!ok)
+ return fail(at, "shell dimensions must be numbers");
+
+ int floors = 1;
+ auto f = kw.find(":floors");
+ if (f != kw.end()) {
+ bool okf = true;
+ floors = (int)std::lround(num(f->second, okf));
+ if (!okf || floors < 1)
+ return fail(at, ":floors must be a positive "
+ "integer");
+ }
+
+ /* Clamp to the setback-adjusted buildable rect so the shell
+ * can never violate the lot bounds, whatever the script says. */
+ int cx = (int)std::lround(w / m_cellSize);
+ int cz = (int)std::lround(d / m_cellSize);
+ int maxCX = (int)std::floor(m_b.availW / m_cellSize);
+ int maxCZ = (int)std::floor(m_b.availD / m_cellSize);
+ cx = std::max(2, std::min(cx, std::max(2, maxCX)));
+ cz = std::max(2, std::min(cz, std::max(2, maxCZ)));
+
+ m_plan->cellsX = cx;
+ m_plan->cellsZ = cz;
+ m_plan->floors = floors;
+ m_plan->cellSize = m_cellSize;
+ m_plan->cellHeight = m_cellHeight;
+ m_plan->cells.assign((size_t)cx * cz * floors, 0);
+
+ for (int y = 0; y < floors; ++y) {
+ bool top = y == floors - 1;
+ for (int z = 0; z < cz; ++z) {
+ for (int x = 0; x < cx; ++x) {
+ uint64_t &c = m_plan->at(x, y, z);
+ c |= PlanFlags::Floor;
+ if (top)
+ c |= PlanFlags::Ceiling;
+ if (x == 0)
+ c |= PlanFlags::WallXNeg;
+ if (x == cx - 1)
+ c |= PlanFlags::WallXPos;
+ if (z == 0)
+ c |= PlanFlags::WallZNeg;
+ if (z == cz - 1)
+ c |= PlanFlags::WallZPos;
+ }
+ }
+ }
+ return true;
+}
+
+bool LayoutEval::verbCellSize(const std::vector &pos, const SExpr &at)
+{
+ /* (cell-size xz-m [height-m]) — grid metrics for the shell;
+ * must come before it. Defaults: LAYOUT_CELL_SIZE (2) and
+ * LAYOUT_CELL_HEIGHT (4). */
+ if (m_plan->valid())
+ return fail(at, "cell-size must come before shell");
+ if (pos.empty() || pos.size() > 2)
+ return fail(at, "cell-size needs (cell-size xz-m "
+ "[height-m])");
+ bool ok = true;
+ double xz = num(pos[0], ok);
+ double h = pos.size() >= 2 ? num(pos[1], ok) : m_cellHeight;
+ if (!ok || xz <= 0.0 || h <= 0.0)
+ return fail(at, "cell-size must be positive meters");
+ m_cellSize = (float)xz;
+ m_cellHeight = (float)h;
+ return true;
+}
+
+bool LayoutEval::verbFloor(const SExpr &form)
+{
+ /* (floor n body...) — set the current floor context. */
+ if (form.list.size() < 2)
+ return fail(form, "floor needs (floor n body...)");
+ if (!m_plan->valid())
+ return fail(form, "floor before shell");
+ double n;
+ if (!argNumber(form.list[1], n))
+ return false;
+ int f = (int)std::lround(n);
+ if (f < 0 || f >= m_plan->floors)
+ return fail(form.list[1],
+ "floor index out of range (0.." +
+ std::to_string(m_plan->floors - 1) + ")");
+ int prev = m_floor;
+ bool prevIn = m_inFloor;
+ m_floor = f;
+ m_inFloor = true;
+ bool ok = evalForms(form.list, 2);
+ m_floor = prev;
+ m_inFloor = prevIn;
+ return ok;
+}
+
+int LayoutEval::sideIndex(const std::string &side) const
+{
+ /* front = +Z (street side), back = -Z, left = -X, right = +X. */
+ if (side == "front")
+ return 0;
+ if (side == "back")
+ return 1;
+ if (side == "left")
+ return 2;
+ if (side == "right")
+ return 3;
+ return -1;
+}
+
+bool LayoutEval::placeOpening(int x, int z, uint64_t clearMask,
+ uint64_t setBits, const SExpr &at)
+{
+ if (!m_plan->valid())
+ return fail(at, "opening before shell");
+ uint64_t &c = m_plan->at(x, m_floor, z);
+ if (!(c & PlanFlags::Floor))
+ return fail(at, "opening on a cell removed by cut");
+ c &= ~clearMask;
+ c |= setBits;
+ return true;
+}
+
+bool LayoutEval::verbCut(const std::vector &pos, const SExpr &at)
+{
+ /* (cut x0 z0 x1 z1) — shell-frame meters, origin at the shell
+ * corner, +x along the frontage, +z into the lot. Removes the
+ * cells of the rect from the building (every floor, or only the
+ * current floor inside a (floor n ...) context) and turns the
+ * faces of the surviving neighbour cells into exterior walls.
+ * L/U/T footprints are a shell plus one or two cuts. Place
+ * cuts right after shell, before door/windows. */
+ if (!m_plan->valid())
+ return fail(at, "cut before shell");
+ if (pos.size() != 4)
+ return fail(at, "cut needs (cut x0 z0 x1 z1) in meters");
+ double v[4];
+ for (int i = 0; i < 4; ++i) {
+ bool ok = true;
+ v[i] = num(pos[i], ok);
+ if (!ok)
+ return fail(at, "cut rect must be numbers");
+ }
+ int cx0 = std::max(0, (int)std::floor(v[0] / m_cellSize));
+ int cz0 = std::max(0, (int)std::floor(v[1] / m_cellSize));
+ int cx1 = std::min(m_plan->cellsX - 1,
+ (int)std::ceil(v[2] / m_cellSize) - 1);
+ int cz1 = std::min(m_plan->cellsZ - 1,
+ (int)std::ceil(v[3] / m_cellSize) - 1);
+ if (cx1 < cx0 || cz1 < cz0)
+ return fail(at, "cut rect maps to no cells");
+
+ int y0 = m_inFloor ? m_floor : 0;
+ int y1 = m_inFloor ? m_floor : m_plan->floors - 1;
+ for (int y = y0; y <= y1; ++y) {
+ for (int z = cz0; z <= cz1; ++z)
+ for (int x = cx0; x <= cx1; ++x)
+ m_plan->at(x, y, z) = 0;
+ /* The surviving neighbours facing the cut become
+ * exterior walls. Cleared cells have no Floor flag, so
+ * neighbours inside the cut are skipped automatically. */
+ for (int z = cz0; z <= cz1; ++z) {
+ for (int x = cx0; x <= cx1; ++x) {
+ if (x > 0 &&
+ (m_plan->at(x - 1, y, z) &
+ PlanFlags::Floor))
+ m_plan->at(x - 1, y, z) |=
+ PlanFlags::WallXPos;
+ if (x < m_plan->cellsX - 1 &&
+ (m_plan->at(x + 1, y, z) &
+ PlanFlags::Floor))
+ m_plan->at(x + 1, y, z) |=
+ PlanFlags::WallXNeg;
+ if (z > 0 &&
+ (m_plan->at(x, y, z - 1) &
+ PlanFlags::Floor))
+ m_plan->at(x, y, z - 1) |=
+ PlanFlags::WallZPos;
+ if (z < m_plan->cellsZ - 1 &&
+ (m_plan->at(x, y, z + 1) &
+ PlanFlags::Floor))
+ m_plan->at(x, y, z + 1) |=
+ PlanFlags::WallZNeg;
+ }
+ }
+ }
+ return true;
+}
+
+bool LayoutEval::verbDoor(const std::vector &pos, const SExpr &at)
+{
+ /* (door front|back|left|right center|) */
+ if (pos.size() < 1)
+ return fail(at, "door needs (door side [center|offset])");
+ std::string side;
+ if (pos[0].type == Value::String)
+ side = pos[0].str;
+ else
+ return fail(at, "door side must be a symbol");
+ int si = sideIndex(side);
+ if (si < 0)
+ return fail(at, "door side must be front/back/left/right");
+
+ bool horizontal = si >= 2; /* left/right run along Z */
+ int cells = horizontal ? m_plan->cellsZ : m_plan->cellsX;
+ double offset = 0.0;
+ bool centered = true;
+ if (pos.size() >= 2) {
+ if (pos[1].type == Value::String && pos[1].str == "center") {
+ centered = true;
+ } else {
+ bool ok = true;
+ offset = num(pos[1], ok);
+ if (!ok)
+ return fail(at, "door position must be "
+ "center or meters");
+ centered = false;
+ }
+ }
+ int idx;
+ if (centered) {
+ idx = cells / 2;
+ } else {
+ double halfLen = cells * m_cellSize * 0.5;
+ idx = (int)std::lround((halfLen + offset) /
+ m_cellSize);
+ }
+ idx = std::max(0, std::min(cells - 1, idx));
+
+ static const uint64_t wallBit[4] = { PlanFlags::WallZPos,
+ PlanFlags::WallZNeg,
+ PlanFlags::WallXNeg,
+ PlanFlags::WallXPos };
+ static const uint64_t winBit[4] = { PlanFlags::WindowZPos,
+ PlanFlags::WindowZNeg,
+ PlanFlags::WindowXNeg,
+ PlanFlags::WindowXPos };
+ static const uint64_t doorBit[4] = { PlanFlags::DoorZPos,
+ PlanFlags::DoorZNeg,
+ PlanFlags::DoorXNeg,
+ PlanFlags::DoorXPos };
+ static const uint64_t intDoorBit[4] = { PlanFlags::IntDoorZPos,
+ PlanFlags::IntDoorZNeg,
+ PlanFlags::IntDoorXNeg,
+ PlanFlags::IntDoorXPos };
+ int x = horizontal ? (si == 2 ? 0 : m_plan->cellsX - 1) : idx;
+ int z = horizontal ? idx : (si == 0 ? m_plan->cellsZ - 1 : 0);
+ return placeOpening(x, z, wallBit[si] | winBit[si],
+ doorBit[si] | intDoorBit[si], at);
+}
+
+void LayoutEval::windowsOnSide(int side, float spacing)
+{
+ bool horizontal = side >= 2;
+ int cells = horizontal ? m_plan->cellsZ : m_plan->cellsX;
+ static const uint64_t wallBit[4] = { PlanFlags::WallZPos,
+ PlanFlags::WallZNeg,
+ PlanFlags::WallXNeg,
+ PlanFlags::WallXPos };
+ static const uint64_t winBit[4] = { PlanFlags::WindowZPos,
+ PlanFlags::WindowZNeg,
+ PlanFlags::WindowXNeg,
+ PlanFlags::WindowXPos };
+ static const uint64_t doorBit[4] = { PlanFlags::DoorZPos,
+ PlanFlags::DoorZNeg,
+ PlanFlags::DoorXNeg,
+ PlanFlags::DoorXPos };
+
+ float lastPlaced = -1.0e30f;
+ for (int i = 1; i < cells - 1; ++i) { /* corners excluded */
+ float p = ((float)i + 0.5f) * m_cellSize;
+ if (p - lastPlaced < spacing)
+ continue;
+ int x = horizontal ? (side == 2 ? 0 : m_plan->cellsX - 1) : i;
+ int z = horizontal ? i : (side == 0 ? m_plan->cellsZ - 1 : 0);
+ uint64_t &c = m_plan->at(x, m_floor, z);
+ if (!(c & PlanFlags::Floor)) /* removed by cut */
+ continue;
+ if (c & doorBit[side]) /* doors win over windows */
+ continue;
+ c &= ~wallBit[side];
+ c |= winBit[side];
+ lastPlaced = p;
+ }
+}
+
+bool LayoutEval::verbWindows(const std::vector &pos,
+ const std::map &kw,
+ const SExpr &at)
+{
+ /* (windows all|front|back|left|right [:spacing m]) — no
+ * spacing (or < one cell) puts a window on every non-corner
+ * perimeter cell of the side(s). */
+ if (!m_plan->valid())
+ return fail(at, "windows before shell");
+ if (pos.empty())
+ return fail(at, "windows needs a side (or all)");
+ float spacing = 0.0f;
+ auto s = kw.find(":spacing");
+ if (s != kw.end()) {
+ bool ok = true;
+ spacing = (float)num(s->second, ok);
+ if (!ok)
+ return fail(at, ":spacing must be a number");
+ }
+ for (const Value &v : pos) {
+ if (v.type != Value::String)
+ return fail(at, "window side must be a symbol");
+ if (v.str == "all") {
+ for (int side = 0; side < 4; ++side)
+ windowsOnSide(side, spacing);
+ continue;
+ }
+ int si = sideIndex(v.str);
+ if (si < 0)
+ return fail(at, "window side must be all/front/"
+ "back/left/right");
+ windowsOnSide(si, spacing);
+ }
+ return true;
+}
+
+bool LayoutEval::verbRoomRaw(const SExpr &form)
+{
+ /* (room name (rect x0 z0 x1 z1)) — rect in fractions of the
+ * footprint. Interior walls go on the room's own cells facing
+ * outward (the CellGrid builder offsets each internal wall
+ * plane into its cell, so two rooms sharing a face each keep
+ * their own wall). */
+ if (!m_plan->valid())
+ return fail(form, "room before shell");
+ if (form.list.size() != 3 || !form.list[1].isSymbol())
+ return fail(form, "room needs (room name (rect x0 z0 x1 "
+ "z1))");
+ const SExpr &rect = form.list[2];
+ if (!rect.isList() || rect.list.size() != 5 ||
+ !rect.list[0].isSymbol("rect"))
+ return fail(rect, "room rect must be (rect x0 z0 x1 z1) "
+ "in footprint fractions");
+ double x0, z0, x1, z1;
+ if (!argNumber(rect.list[1], x0) || !argNumber(rect.list[2], z0) ||
+ !argNumber(rect.list[3], x1) || !argNumber(rect.list[4], z1))
+ return false;
+
+ auto clamp01 = [](double v) { return std::max(0.0, std::min(1.0, v)); };
+ x0 = clamp01(x0);
+ z0 = clamp01(z0);
+ x1 = clamp01(x1);
+ z1 = clamp01(z1);
+ if (x1 <= x0 || z1 <= z0)
+ return fail(rect, "room rect is empty");
+
+ RoomRect rr;
+ rr.x0 = std::max(0, (int)std::floor(x0 * m_plan->cellsX));
+ rr.z0 = std::max(0, (int)std::floor(z0 * m_plan->cellsZ));
+ rr.x1 = std::min(m_plan->cellsX - 1,
+ (int)std::ceil(x1 * m_plan->cellsX) - 1);
+ rr.z1 = std::min(m_plan->cellsZ - 1,
+ (int)std::ceil(z1 * m_plan->cellsZ) - 1);
+ if (rr.x1 < rr.x0 || rr.z1 < rr.z0)
+ return fail(rect, "room rect maps to no cells");
+
+ m_rooms[m_floor][form.list[1].text] = rr;
+
+ for (int z = rr.z0; z <= rr.z1; ++z) {
+ for (int x = rr.x0; x <= rr.x1; ++x) {
+ uint64_t &c = m_plan->at(x, m_floor, z);
+ if (x == rr.x0 && rr.x0 > 0)
+ c |= PlanFlags::IntWallXNeg;
+ if (x == rr.x1 && rr.x1 < m_plan->cellsX - 1)
+ c |= PlanFlags::IntWallXPos;
+ if (z == rr.z0 && rr.z0 > 0)
+ c |= PlanFlags::IntWallZNeg;
+ if (z == rr.z1 && rr.z1 < m_plan->cellsZ - 1)
+ c |= PlanFlags::IntWallZPos;
+ }
+ }
+ return true;
+}
+
+bool LayoutEval::verbIDoor(const std::vector &pos, const SExpr &at)
+{
+ /* (idoor roomA roomB) — interior door on the shared wall,
+ * centered on the overlap of the two room ranges. */
+ if (!m_plan->valid())
+ return fail(at, "idoor before shell");
+ if (pos.size() != 2 || pos[0].type != Value::String ||
+ pos[1].type != Value::String)
+ return fail(at, "idoor needs (idoor roomA roomB)");
+ auto &floorRooms = m_rooms[m_floor];
+ auto ra = floorRooms.find(pos[0].str);
+ auto rb = floorRooms.find(pos[1].str);
+ if (ra == floorRooms.end() || rb == floorRooms.end())
+ return fail(at, "idoor: unknown room (declare rooms on "
+ "the same floor first)");
+ const RoomRect &A = ra->second;
+ const RoomRect &B = rb->second;
+
+ /* A left of B (A's +X face shared with B's -X face)? */
+ if (A.x1 + 1 == B.x0 && A.z0 <= B.z1 && B.z0 <= A.z1) {
+ int zm = (std::max(A.z0, B.z0) + std::min(A.z1, B.z1)) / 2;
+ uint64_t &ca = m_plan->at(A.x1, m_floor, zm);
+ ca &= ~PlanFlags::IntWallXPos;
+ ca |= PlanFlags::IntDoorXPos;
+ uint64_t &cb = m_plan->at(B.x0, m_floor, zm);
+ cb &= ~PlanFlags::IntWallXNeg;
+ cb |= PlanFlags::IntDoorXNeg;
+ return true;
+ }
+ if (B.x1 + 1 == A.x0 && A.z0 <= B.z1 && B.z0 <= A.z1) {
+ int zm = (std::max(A.z0, B.z0) + std::min(A.z1, B.z1)) / 2;
+ uint64_t &cb = m_plan->at(B.x1, m_floor, zm);
+ cb &= ~PlanFlags::IntWallXPos;
+ cb |= PlanFlags::IntDoorXPos;
+ uint64_t &ca = m_plan->at(A.x0, m_floor, zm);
+ ca &= ~PlanFlags::IntWallXNeg;
+ ca |= PlanFlags::IntDoorXNeg;
+ return true;
+ }
+ /* A in front of B (A's -Z face shared with B's +Z face)? */
+ if (A.z1 + 1 == B.z0 && A.x0 <= B.x1 && B.x0 <= A.x1) {
+ int xm = (std::max(A.x0, B.x0) + std::min(A.x1, B.x1)) / 2;
+ uint64_t &ca = m_plan->at(xm, m_floor, A.z1);
+ ca &= ~PlanFlags::IntWallZPos;
+ ca |= PlanFlags::IntDoorZPos;
+ uint64_t &cb = m_plan->at(xm, m_floor, B.z0);
+ cb &= ~PlanFlags::IntWallZNeg;
+ cb |= PlanFlags::IntDoorZNeg;
+ return true;
+ }
+ if (B.z1 + 1 == A.z0 && A.x0 <= B.x1 && B.x0 <= A.x1) {
+ int xm = (std::max(A.x0, B.x0) + std::min(A.x1, B.x1)) / 2;
+ uint64_t &cb = m_plan->at(xm, m_floor, B.z1);
+ cb &= ~PlanFlags::IntWallZPos;
+ cb |= PlanFlags::IntDoorZPos;
+ uint64_t &ca = m_plan->at(xm, m_floor, A.z0);
+ ca &= ~PlanFlags::IntWallZNeg;
+ ca |= PlanFlags::IntDoorZNeg;
+ return true;
+ }
+ return fail(at, "idoor: rooms '" + pos[0].str + "' and '" +
+ pos[1].str + "' do not share a wall");
+}
+
+bool LayoutEval::verbRoof(const std::vector &pos,
+ const std::map &kw,
+ const SExpr &at)
+{
+ /* (roof flat|normal|normal2|gable|cone|cylinder [x0 z0 x1 z1]
+ * [:height m]). gable is an alias of normal. The optional
+ * rect (shell-frame meters, like cut) limits the roof to part
+ * of the footprint — needed for cut (L/U-shaped) buildings,
+ * where one roof per wing avoids overhanging the cut. */
+ if (!m_plan->valid())
+ return fail(at, "roof before shell");
+ if (pos.empty())
+ return fail(at, "roof needs a type");
+ if (pos.size() != 1 && pos.size() < 5)
+ return fail(at, "roof rect needs (roof type x0 z0 x1 z1)");
+ int type = -1;
+ if (pos[0].type == Value::Number) {
+ type = (int)pos[0].num;
+ } else if (pos[0].type == Value::String) {
+ const std::string &t = pos[0].str;
+ if (t == "flat")
+ type = 0;
+ else if (t == "normal" || t == "gable")
+ type = 1;
+ else if (t == "normal2")
+ type = 2;
+ else if (t == "cone")
+ type = 3;
+ else if (t == "cylinder")
+ type = 4;
+ }
+ if (type < 0 || type > 4)
+ return fail(at, "unknown roof type");
+ PlanRoof roof;
+ roof.type = type;
+ roof.posY = m_plan->floors;
+ roof.sizeX = m_plan->cellsX;
+ roof.sizeZ = m_plan->cellsZ;
+ if (pos.size() >= 5) {
+ double v[4];
+ for (int i = 0; i < 4; ++i) {
+ bool ok = true;
+ v[i] = num(pos[i + 1], ok);
+ if (!ok)
+ return fail(at, "roof rect must be numbers");
+ }
+ int rx0 = std::max(0, (int)std::floor(v[0] /
+ m_cellSize));
+ int rz0 = std::max(0, (int)std::floor(v[1] /
+ m_cellSize));
+ int rx1 = std::min(m_plan->cellsX,
+ (int)std::ceil(v[2] / m_cellSize));
+ int rz1 = std::min(m_plan->cellsZ,
+ (int)std::ceil(v[3] / m_cellSize));
+ if (rx1 <= rx0 || rz1 <= rz0)
+ return fail(at, "roof rect maps to no cells");
+ roof.posX = rx0;
+ roof.posZ = rz0;
+ roof.sizeX = rx1 - rx0;
+ roof.sizeZ = rz1 - rz0;
+ }
+ auto h = kw.find(":height");
+ if (h != kw.end()) {
+ bool ok = true;
+ roof.maxHeight = (float)num(h->second, ok);
+ if (!ok)
+ return fail(at, ":height must be a number");
+ }
+ m_plan->roofs.push_back(roof);
+ return true;
+}
+
+bool LayoutEval::verbParking(const SExpr &form)
+{
+ /* (parking x z w d) or (parking (rect-front-left w d)) /
+ * (parking (rect-front-right w d)) — LOT frame meters (origin
+ * at the front-left of the inset buildable rect, +x along the
+ * frontage, +z into the lot). */
+ if (form.list.size() < 2)
+ return fail(form, "parking needs a rect");
+ PlanParking p;
+ const SExpr &a1 = form.list[1];
+ if (a1.isList()) {
+ if (a1.list.size() != 3 || !a1.list[0].isSymbol())
+ return fail(a1, "bad parking rect");
+ double w, d;
+ if (!argNumber(a1.list[1], w) || !argNumber(a1.list[2], d))
+ return false;
+ if (a1.list[0].isSymbol("rect-front-left")) {
+ p.x = 0.0f;
+ p.z = 0.0f;
+ } else if (a1.list[0].isSymbol("rect-front-right")) {
+ p.x = m_b.lotW - (float)w;
+ p.z = 0.0f;
+ } else {
+ return fail(a1, "unknown parking rect '" +
+ a1.list[0].text + "'");
+ }
+ p.w = (float)w;
+ p.d = (float)d;
+ } else {
+ if (form.list.size() < 5)
+ return fail(form, "parking needs x z w d");
+ double x, z, w, d;
+ if (!argNumber(form.list[1], x) ||
+ !argNumber(form.list[2], z) ||
+ !argNumber(form.list[3], w) ||
+ !argNumber(form.list[4], d))
+ return false;
+ p.x = (float)x;
+ p.z = (float)z;
+ p.w = (float)w;
+ p.d = (float)d;
+ }
+ m_plan->parkings.push_back(p);
+ return true;
+}
+
+bool LayoutEval::verbPlace(const SExpr &form)
+{
+ /* (place (at x z) [:yaw deg]) — recorded for
+ * later-phase materialization. */
+ if (form.list.size() < 2)
+ return fail(form, "place needs a prefab path");
+ PlanPlacement pl;
+ const SExpr &p = form.list[1];
+ if (p.isString() || p.isSymbol())
+ pl.prefab = p.text;
+ else
+ return fail(p, "place path must be a string or symbol");
+ size_t i = 2;
+ while (i < form.list.size()) {
+ const SExpr &a = form.list[i];
+ if (a.isList() && !a.list.empty() &&
+ a.list[0].isSymbol("at")) {
+ if (a.list.size() != 3)
+ return fail(a, "at needs (at x z)");
+ double x, z;
+ if (!argNumber(a.list[1], x) ||
+ !argNumber(a.list[2], z))
+ return false;
+ pl.x = (float)x;
+ pl.z = (float)z;
+ ++i;
+ continue;
+ }
+ if (a.isSymbol(":yaw")) {
+ if (i + 1 >= form.list.size())
+ return fail(a, ":yaw without a value");
+ double yaw;
+ if (!argNumber(form.list[i + 1], yaw))
+ return false;
+ pl.yawDeg = (float)yaw;
+ i += 2;
+ continue;
+ }
+ return fail(a, "unexpected place argument");
+ }
+ m_plan->placements.push_back(pl);
+ return true;
+}
+
+/* ------------------------------------------------------------------ */
+/* Entry point */
+/* ------------------------------------------------------------------ */
+
+bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
+ std::string *error)
+{
+ /* Fresh plan every call: a failed evaluation may have partially
+ * filled the output (a shell defined before a later form
+ * failed), and callers legitimately reuse the same object for a
+ * fallback evaluation — without the reset the fallback's shell
+ * trips "shell already defined". */
+ out = BuildingPlan();
+ m_plan = &out;
+ m_floor = 0;
+ m_inFloor = false;
+ m_cellSize = LAYOUT_CELL_SIZE;
+ m_cellHeight = LAYOUT_CELL_HEIGHT;
+ m_error.clear();
+ m_rooms.clear();
+
+ /* Predefined bindings. */
+ std::map base;
+ base["lot-w"] = Value::number(m_b.lotW);
+ base["lot-d"] = Value::number(m_b.lotD);
+ base["avail-w"] = Value::number(m_b.availW);
+ base["avail-d"] = Value::number(m_b.availD);
+ base["min-fp"] = Value::number(m_b.minFp);
+ base["max-fp"] = Value::number(m_b.maxFp);
+ base["min-floors"] = Value::number(m_b.minFloors);
+ base["max-floors"] = Value::number(m_b.maxFloors);
+ base["roof-type"] = Value::number(m_b.roofType);
+ m_scopes.clear();
+ m_scopes.push_back(std::move(base));
+
+ const std::vector *body = nullptr;
+ std::vector parsed;
+ size_t first = sourceOrName.find_first_not_of(" \t\r\n");
+ if (first != std::string::npos && sourceOrName[first] == '(') {
+ if (!parseSExprs(sourceOrName, parsed, &m_error)) {
+ if (error)
+ *error = m_error;
+ return false;
+ }
+ body = &parsed;
+ } else {
+ /* Layout name: explicit library first, then built-ins. */
+ const std::vector *found =
+ m_lib ? m_lib->find(sourceOrName) : nullptr;
+ if (!found)
+ found = m_builtins.find(sourceOrName);
+ if (!found) {
+ if (error)
+ *error = "unknown layout '" + sourceOrName +
+ "'";
+ return false;
+ }
+ body = found;
+ }
+
+ if (!evalForms(*body, 0)) {
+ if (error)
+ *error = m_error;
+ return false;
+ }
+ if (!out.valid()) {
+ if (error)
+ *error = "layout produced no shell";
+ return false;
+ }
+ return true;
+}
+
+} /* namespace LayoutLib */
diff --git a/src/features/editScene/layoutlib/LayoutEval.hpp b/src/features/editScene/layoutlib/LayoutEval.hpp
new file mode 100644
index 0000000..8fd7b31
--- /dev/null
+++ b/src/features/editScene/layoutlib/LayoutEval.hpp
@@ -0,0 +1,232 @@
+#pragma once
+
+#include
+#include