From a3279f984be645edbac5ca1827358075fd9e78ea Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Thu, 8 Oct 2026 05:16:04 +0300 Subject: [PATCH] Phase 4.5, town and building layouts --- src/features/editScene/AGENTS.md | 102 +- src/features/editScene/CMakeLists.txt | 19 + src/features/editScene/EditorApp.cpp | 13 + .../editScene/ProceduralRoadGeometry.md | 401 ++++++ .../editScene/components/CellGrid.hpp | 25 + .../editScene/components/RoadGraph.hpp | 190 +++ .../editScene/demos/demo-taxi/PLAN.md | 279 +++- .../demos/demo-taxi/demo_scene_exterior.json | 30 +- .../editScene/layoutlib/BuildingPlan.hpp | 128 ++ .../editScene/layoutlib/LayoutEval.cpp | 1272 +++++++++++++++++ .../editScene/layoutlib/LayoutEval.hpp | 232 +++ .../editScene/layoutlib/LayoutLang.cpp | 171 +++ .../editScene/layoutlib/LayoutLang.hpp | 48 + .../editScene/roadlib/RoadNetworkGen.cpp | 50 + .../editScene/roadlib/RoadNetworkGen.hpp | 11 + src/features/editScene/roadlib/TownGen.cpp | 496 +++++-- src/features/editScene/roadlib/TownGen.hpp | 101 +- .../editScene/systems/CellGridSystem.cpp | 17 +- .../editScene/systems/RoadRegionStore.cpp | 104 ++ .../editScene/systems/RoadRegionStore.hpp | 27 + src/features/editScene/systems/RoadSystem.cpp | 86 ++ .../editScene/systems/SceneSerializer.cpp | 65 +- .../editScene/systems/TerrainSystem.cpp | 5 + .../editScene/systems/TownGenSystem.cpp | 805 ++++++++--- .../editScene/systems/TownGenSystem.hpp | 53 +- .../tests/road_geometry_overlap_test.cpp | 843 ++++++++++- src/features/editScene/ui/TerrainEditor.hpp | 413 +++++- 27 files changed, 5671 insertions(+), 315 deletions(-) create mode 100644 src/features/editScene/layoutlib/BuildingPlan.hpp create mode 100644 src/features/editScene/layoutlib/LayoutEval.cpp create mode 100644 src/features/editScene/layoutlib/LayoutEval.hpp create mode 100644 src/features/editScene/layoutlib/LayoutLang.cpp create mode 100644 src/features/editScene/layoutlib/LayoutLang.hpp 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 +#include +#include +#include + +#include "BuildingPlan.hpp" +#include "LayoutLang.hpp" + +/* + * Evaluator for the building layout DSL (layoutlib, demo-taxi phase + * 4.5 item 2). Pure C++ (no Ogre/ECS/Lua); the only external input + * besides the source is a LayoutBindings struct and an optional RNG + * stream (the town seed stream — rand/rand-range/rand-int draw from + * it so layouts stay deterministic per town seed). + * + * Source form: a sequence of top-level verb/special forms. The + * source string may also be the NAME of a layout registered in a + * LayoutLibrary (LayoutEval::evaluate resolves names when the string + * does not start with '('); libraries additionally load + * "(layout name body...)" definitions from files + * (town_layouts.txt in the project directory). + * + * Predefined bindings (set from LayoutBindings): + * lot-w lot-d buildable lot size (post road-clearance + * shrink, pre-setback), meters + * avail-w avail-d buildable size after the setback, meters — + * the shell is clamped to these + * min-fp max-fp district footprint range, meters + * min-floors max-floors district floor range + * roof-type edge-side roof override (-1 = none) + * + * Special forms: let, if, cond, dotimes, for-range. + * Builtins: + - * / min max clamp abs sqrt, < <= > >= = !=, + * and or not, rand rand-range rand-int. + * Verbs: shell floor cut door windows room idoor roof parking place. + * + * Example: + * (shell (rand-range min-fp max-fp) (rand-range min-fp max-fp) + * :floors (rand-int min-floors max-floors)) + * (floor 0 (door front center)) + * (windows all :spacing 4) + * (roof cone :height 2) + */ + +namespace LayoutLib { + +struct LayoutBindings { + /** Buildable lot size after the road-clearance shrink (meters). */ + float lotW = 0.0f; + float lotD = 0.0f; + /** Buildable size after the setback; the shell is clamped to + * these. */ + float availW = 0.0f; + float availD = 0.0f; + /** District footprint range (meters). */ + float minFp = 8.0f; + float maxFp = 12.0f; + /** District floor range. */ + int minFloors = 1; + int maxFloors = 1; + /** Edge-side roof override (-1 = none, builder default). */ + int roofType = -1; +}; + +class LayoutLibrary +{ +public: + /** Register (or replace) a named layout body. */ + void registerLayout(const std::string &name, + std::vector body); + /** Load "(layout name body...)" forms from source text. */ + bool loadSource(const std::string &source, std::string *error); + /** Load a layout file (same form as loadSource). Missing file + * is an error only when mustExist is set. */ + bool loadFile(const std::string &path, std::string *error); + const std::vector *find(const std::string &name) const; + + /** The built-in district default layouts (village-house, + * town-rowhouse, city-block, city-tower — all currently sharing + * the parametric default body driven by the min/max + * bindings). */ + static LayoutLibrary withBuiltins(); + + /** Names of the built-in district default layouts. */ + static std::vector builtinNames(); + /** Source of the shared parametric body behind the built-ins — + * the canonical starting template for a custom layout (used by + * the editor's Insert Template button). */ + static const char *defaultTemplateSource(); + + /** One worked DSL example for the editor help window. */ + struct LayoutExample { + const char *title; + const char *source; + }; + /** Worked examples shown in the editor DSL help window; every + * one evaluates cleanly (probe-verified in + * road_geometry_overlap_test). */ + static const std::vector &examples(); + +private: + std::map > m_layouts; +}; + +class LayoutEval +{ +public: + /** + * @param bindings lot/district inputs (bindings above) + * @param rng town seed stream for rand* (may be null; + * rand* then evaluates to an error) + * @param library layout library for name resolution (may be + * null; the built-ins are always available) + */ + LayoutEval(const LayoutBindings &bindings, std::mt19937 *rng, + const LayoutLibrary *library); + + /** + * Evaluate a layout into a fresh plan. @a sourceOrName is + * inline source when it starts with '(' (after whitespace), + * otherwise a library/built-in layout name. Returns false and + * fills *error on failure; *out is then left invalid. + */ + bool evaluate(const std::string &sourceOrName, BuildingPlan &out, + std::string *error); + +private: + struct Value { + enum Type { Number, String, Bool, Void } type; + double num; + std::string str; + bool boolean; + + Value() + : type(Void) + , num(0.0) + , boolean(false) + { + } + static Value number(double v) + { + Value x; + x.type = Number; + x.num = v; + return x; + } + static Value string(const std::string &v) + { + Value x; + x.type = String; + x.str = v; + return x; + } + static Value booleanV(bool v) + { + Value x; + x.type = Bool; + x.boolean = v; + return x; + } + bool truthy() const + { + if (type == Bool) + return boolean; + if (type == Number) + return num != 0.0; + return type != Void; + } + }; + + struct RoomRect { + int x0 = 0; + int z0 = 0; + int x1 = 0; + int z1 = 0; + }; + + bool eval(const SExpr &e, Value &out); + bool evalList(const SExpr &e, Value &out); + bool evalForms(const std::vector &forms, size_t start); + bool lookup(const std::string &name, Value &out) const; + bool fail(const SExpr &at, const std::string &msg); + double num(const Value &v, bool &ok) const; + bool argNumber(const SExpr &e, double &out); + bool collectArgs(const SExpr &form, size_t start, + std::vector &positional, + std::map &keywords); + + bool verbShell(const std::vector &pos, + const std::map &kw, + const SExpr &at); + bool verbFloor(const SExpr &form); + bool verbRoomRaw(const SExpr &form); + bool verbCellSize(const std::vector &pos, const SExpr &at); + bool verbCut(const std::vector &pos, const SExpr &at); + bool verbDoor(const std::vector &pos, const SExpr &at); + bool verbWindows(const std::vector &pos, + const std::map &kw, + const SExpr &at); + bool verbIDoor(const std::vector &pos, const SExpr &at); + bool verbRoof(const std::vector &pos, + const std::map &kw, + const SExpr &at); + bool verbParking(const SExpr &form); + bool verbPlace(const SExpr &form); + + int sideIndex(const std::string &side) const; + bool placeOpening(int x, int z, uint64_t clearMask, uint64_t setBits, + const SExpr &at); + void windowsOnSide(int side, float spacing); + + LayoutBindings m_b; + std::mt19937 *m_rng; + const LayoutLibrary *m_lib; + LayoutLibrary m_builtins; + BuildingPlan *m_plan; + int m_floor; /* current (floor n ...) context */ + bool m_inFloor = false; /* inside a (floor n ...) form */ + /* Grid metrics in meters (the cell-size verb; copied into the + * plan by verbShell). */ + float m_cellSize = LAYOUT_CELL_SIZE; + float m_cellHeight = LAYOUT_CELL_HEIGHT; + std::string m_error; + std::vector > m_scopes; + /* rooms per floor: floor -> (name -> rect) */ + std::map > m_rooms; +}; + +} /* namespace LayoutLib */ diff --git a/src/features/editScene/layoutlib/LayoutLang.cpp b/src/features/editScene/layoutlib/LayoutLang.cpp new file mode 100644 index 0000000..9757bcb --- /dev/null +++ b/src/features/editScene/layoutlib/LayoutLang.cpp @@ -0,0 +1,171 @@ +#include "LayoutLang.hpp" + +#include + +namespace LayoutLib { + +namespace { + +struct Parser { + const std::string &src; + size_t pos = 0; + int line = 1; + std::string err; + + explicit Parser(const std::string &s) + : src(s) + { + } + + char peek() const + { + return pos < src.size() ? src[pos] : '\0'; + } + char get() + { + char c = peek(); + if (c == '\n') + ++line; + ++pos; + return c; + } + + void skipTrivia() + { + for (;;) { + while (pos < src.size() && + std::isspace((unsigned char)src[pos])) + get(); + if (peek() == ';') { + while (pos < src.size() && src[pos] != '\n') + ++pos; + continue; + } + break; + } + } + + bool fail(const std::string &msg) + { + err = "line " + std::to_string(line) + ": " + msg; + return false; + } + + static bool isAtomChar(char c) + { + return c != 0 && !std::isspace((unsigned char)c) && + c != '(' && c != ')' && c != '"' && c != ';'; + } + + bool parseAtom(SExpr &out) + { + int atomLine = line; + if (peek() == '"') { + get(); + std::string s; + while (pos < src.size() && peek() != '"') { + char c = get(); + if (c == '\\' && pos < src.size()) { + char e = get(); + s += e == 'n' ? '\n' : + e == 't' ? '\t' : + e; + } else { + s += c; + } + } + if (peek() != '"') + return fail("unterminated string"); + get(); + out.type = SExpr::String; + out.text = s; + out.line = atomLine; + return true; + } + + std::string tok; + while (pos < src.size() && isAtomChar(peek())) + tok += get(); + if (tok.empty()) + return fail("expected atom"); + + /* Number? Optional sign followed by a digit or dot. */ + size_t i = 0; + if (tok[i] == '-' || tok[i] == '+') + ++i; + if (i < tok.size() && + (std::isdigit((unsigned char)tok[i]) || tok[i] == '.')) { + try { + size_t used = 0; + double v = std::stod(tok, &used); + if (used == tok.size()) { + out.type = SExpr::Number; + out.number = v; + out.line = atomLine; + return true; + } + } catch (...) { + /* falls through to symbol */ + } + } + out.type = SExpr::Symbol; + out.text = tok; + out.line = atomLine; + return true; + } + + bool parseForm(SExpr &out) + { + skipTrivia(); + if (pos >= src.size()) + return fail("unexpected end of input"); + if (peek() == '(') { + int listLine = line; + get(); + SExpr list; + list.type = SExpr::List; + list.line = listLine; + for (;;) { + skipTrivia(); + if (pos >= src.size()) + return fail("unterminated list"); + if (peek() == ')') { + get(); + break; + } + SExpr item; + if (!parseForm(item)) + return false; + list.list.push_back(std::move(item)); + } + out = std::move(list); + return true; + } + if (peek() == ')') + return fail("unexpected ')'"); + return parseAtom(out); + } +}; + +} /* namespace */ + +bool parseSExprs(const std::string &source, std::vector &out, + std::string *error) +{ + Parser p(source); + out.clear(); + for (;;) { + p.skipTrivia(); + if (p.pos >= source.size()) + return true; + SExpr form; + if (!p.parseForm(form)) { + if (error) + *error = p.err; + return false; + } + out.push_back(std::move(form)); + } +} + +} /* namespace LayoutLib */ diff --git a/src/features/editScene/layoutlib/LayoutLang.hpp b/src/features/editScene/layoutlib/LayoutLang.hpp new file mode 100644 index 0000000..137cf5e --- /dev/null +++ b/src/features/editScene/layoutlib/LayoutLang.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +/* + * S-expression parser for the building layout DSL (layoutlib, + * demo-taxi phase 4.5 item 2). Pure C++, no Ogre/ECS/Lua. + * + * Grammar: + * source := form* + * form := atom | list + * atom := number | string | symbol + * list := '(' form* ')' + * comment := ';' ... end-of-line + * + * Numbers are doubles ("12", "-3.5", ".5"); strings are + * double-quoted with \\ and \" escapes; everything else is a symbol + * (including :keywords like :spacing). + */ + +namespace LayoutLib { + +struct SExpr { + enum Type { Number, String, Symbol, List } type = Symbol; + double number = 0.0; + std::string text; /* String contents or Symbol name */ + std::vector list; + int line = 0; /* 1-based source line for error messages */ + + bool isNumber() const { return type == Number; } + bool isString() const { return type == String; } + bool isSymbol() const { return type == Symbol; } + bool isList() const { return type == List; } + bool isSymbol(const char *name) const + { + return type == Symbol && text == name; + } +}; + +/** + * Parse a source string into top-level forms. Returns false on a + * syntax error and fills *error (with line numbers) when non-null. + */ +bool parseSExprs(const std::string &source, std::vector &out, + std::string *error); + +} /* namespace LayoutLib */ diff --git a/src/features/editScene/roadlib/RoadNetworkGen.cpp b/src/features/editScene/roadlib/RoadNetworkGen.cpp index e3ec07b..4a68825 100644 --- a/src/features/editScene/roadlib/RoadNetworkGen.cpp +++ b/src/features/editScene/roadlib/RoadNetworkGen.cpp @@ -1039,4 +1039,54 @@ void streetsFromJson(RoadGraph &graph, const nlohmann::json &j, } } +/* ---------------------------------------------------------------- + * Town areas serialization (phase 4.5 item 4) + * ---------------------------------------------------------------- */ + +void townAreasToJson(const RoadGraph &graph, nlohmann::json &out) +{ + nlohmann::json arr = nlohmann::json::array(); + for (const RoadTownArea &a : graph.townAreas) { + nlohmann::json ja; + ja["name"] = a.name; + ja["circular"] = a.circular; + ja["center"] = { a.center.x, a.center.y, a.center.z }; + ja["halfSize"] = a.halfSize; + ja["radius"] = a.radius; + ja["district"] = (int)a.district; + ja["densityScale"] = a.densityScale; + ja["seed"] = a.seed; + arr.push_back(ja); + } + out["townAreas"] = arr; +} + +void townAreasFromJson(RoadGraph &graph, const nlohmann::json &j) +{ + graph.townAreas.clear(); + for (const auto &ja : + j.value("townAreas", nlohmann::json::array())) { + RoadTownArea a; + a.name = ja.value("name", std::string()); + if (a.name.empty()) + continue; + a.circular = ja.value("circular", false); + if (ja.contains("center") && ja["center"].is_array() && + ja["center"].size() == 3) { + a.center.x = ja["center"][0].get(); + a.center.y = ja["center"][1].get(); + a.center.z = ja["center"][2].get(); + } + a.halfSize = ja.value("halfSize", 500.0f); + a.radius = ja.value("radius", 500.0f); + int d = ja.value("district", 0); + if (d < 0 || d > (int)RoadTownDistrict::CityCenter) + d = 0; + a.district = (RoadTownDistrict)d; + a.densityScale = ja.value("densityScale", 1.0f); + a.seed = ja.value("seed", 1u); + graph.townAreas.push_back(a); + } +} + } /* namespace RoadNetworkGen */ diff --git a/src/features/editScene/roadlib/RoadNetworkGen.hpp b/src/features/editScene/roadlib/RoadNetworkGen.hpp index 9db04e6..be181a8 100644 --- a/src/features/editScene/roadlib/RoadNetworkGen.hpp +++ b/src/features/editScene/roadlib/RoadNetworkGen.hpp @@ -249,6 +249,17 @@ void streetsToJson(const RoadGraph &graph, nlohmann::json &out); void streetsFromJson(RoadGraph &graph, const nlohmann::json &j, bool dropMissing = true); +/** + * Town-area serialization (phase 4.5 item 4). Same dual-path pattern + * as streets: inline in the scene JSON when not streaming, + * `roads/towns.json` via RoadRegionStore when streaming. Shape: + * `{ "townAreas": [ { "name", "circular", "center": [x,y,z], + * "halfSize", "radius", "district", "densityScale", "seed" } ] }`; + * fromJson tolerates a missing/empty array. + */ +void townAreasToJson(const RoadGraph &graph, nlohmann::json &out); +void townAreasFromJson(RoadGraph &graph, const nlohmann::json &j); + } /* namespace RoadNetworkGen */ #endif /* EDITSCENE_ROADNETWORKGEN_HPP */ diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp index c073791..7bb4378 100644 --- a/src/features/editScene/roadlib/TownGen.cpp +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -1,5 +1,7 @@ #include "TownGen.hpp" +#include "../layoutlib/LayoutEval.hpp" + #include #include #include @@ -165,8 +167,30 @@ static float sampleY(const RoadNetworkGen::HeightSampler &sampleHeight, return sampleHeight ? sampleHeight(x, z) : fallback; } +/** District -> default layout name (the built-in library in + * LayoutLib::LayoutLibrary::withBuiltins()). */ +static const char *defaultLayoutForDistrict(District d) +{ + switch (d) { + case District::Village: + return "village-house"; + case District::Town: + return "town-rowhouse"; + case District::CityResidential: + return "city-block"; + case District::CityCenter: + return "city-tower"; + default: + return "village-house"; + } +} + TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, - const RoadNetworkGen::HeightSampler &sampleHeight) + const RoadNetworkGen::HeightSampler &sampleHeight, + const LayoutLib::LayoutLibrary *layouts, + const std::vector *areas, + int areaIndex, + const PageScope *page) { TownResult result; const DistrictParams &dp = districtParams(params.district); @@ -175,16 +199,102 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, std::mt19937 rng(params.seed); std::uniform_real_distribution unit(0.0f, 1.0f); - float density = - std::min(1.0f, dp.density * std::max(0.0f, params.densityScale)); std::map streetNumbers; - for (size_t li = 0; li < lots.size(); ++li) { + /* Phase 4.5 item 5: page scoping for streamed building + * generation. With a mapper the lots are processed grouped by + * their terrain page (sorted page order; within a page sorted by + * centroid so the order is independent of the graph storage + * order), each page drawing from its own RNG stream seeded + * seed ^ hash(page) — a single page's building set is then + * computable without generating the rest of the town, and the + * all-pages run is exactly the union of the per-page runs. + * Street numbering restarts per page. Without a mapper the + * legacy single-stream order is kept bit-identical. */ + std::vector order(lots.size()); + for (size_t i = 0; i < lots.size(); ++i) + order[i] = i; + std::vector lotPageX, lotPageY; + const bool paged = page && page->mapper != nullptr; + if (paged) { + const bool filterPage = page->pageX >= 0 && page->pageY >= 0; + lotPageX.assign(lots.size(), 0); + lotPageY.assign(lots.size(), 0); + std::map, std::vector > byPage; + for (size_t i = 0; i < lots.size(); ++i) { + long px = 0, py = 0; + if (!page->mapper(lots[i].centroid.x, + lots[i].centroid.z, px, py)) + continue; /* unmappable lot: never builds */ + lotPageX[i] = px; + lotPageY[i] = py; + if (filterPage && + (px != page->pageX || py != page->pageY)) + continue; + byPage[std::make_pair(px, py)].push_back(i); + } + order.clear(); + for (auto &kv : byPage) { + std::vector &idxs = kv.second; + std::sort(idxs.begin(), idxs.end(), + [&lots](size_t a, size_t b) { + const Ogre::Vector3 &ca = lots[a].centroid; + const Ogre::Vector3 &cb = lots[b].centroid; + if (ca.x != cb.x) + return ca.x < cb.x; + return ca.z < cb.z; + }); + for (size_t i : idxs) + order.push_back(i); + } + } + long curPageX = 0, curPageY = 0; + bool pageStarted = false; + + for (size_t oi = 0; oi < order.size(); ++oi) { + const size_t li = order[oi]; const RoadNetworkGen::RoadLot &lot = lots[li]; size_t n = lot.polygon.size(); if (n < 3) continue; + /* Page boundary: reseed the stream and restart street + * numbering (see the paging note above). Placed after + * the degenerate-lot skip so skipped lots never disturb + * the stream. */ + if (paged && (!pageStarted || lotPageX[li] != curPageX || + lotPageY[li] != curPageY)) { + curPageX = lotPageX[li]; + curPageY = lotPageY[li]; + pageStarted = true; + uint32_t h = (uint32_t)(curPageX * 73856093L) ^ + (uint32_t)(curPageY * 19349663L); + rng.seed(params.seed ^ h); + streetNumbers.clear(); + } + + /* Phase 4.5 item 4: town-area scoping. The lot belongs + * to the area containing its centroid (first-match-wins + * over overlapping areas); only that area's run builds + * it. No RNG draws happen before this point, so + * filtering does not disturb determinism. */ + if (areas && areaIndex >= 0 && + areaIndex < (int)areas->size()) { + if (!(*areas)[areaIndex].contains(lot.centroid.x, + lot.centroid.z)) + continue; + bool ownedEarlier = false; + for (int k = 0; k < areaIndex; ++k) { + if ((*areas)[k].contains(lot.centroid.x, + lot.centroid.z)) { + ownedEarlier = true; + break; + } + } + if (ownedEarlier) + continue; + } + /* Frontage ranking: an edge on a named street beats an * edge-fallback lot's source edge, which beats the plain * longest edge. Fallback lots have no nodeIds and their @@ -243,6 +353,73 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, if (v.normalise() < 0.001f) continue; + /* Phase 4.5: per-side lot params from the frontage edge. + * Match the frontage geometrically against the graph + * edges (either direction — fallback lots' polygons may + * be reversed); the side the centroid lies on picks + * lotLeft/lotRight. */ + const RoadLotParams *lotParams = nullptr; + for (const RoadEdge &e : graph.edges) { + const RoadNode *na = graph.findNodeById(e.nodeA); + const RoadNode *nb = graph.findNodeById(e.nodeB); + if (!na || !nb) + continue; + bool fwd = p0.distance(na->position) < 0.01f && + p1.distance(nb->position) < 0.01f; + bool rev = p0.distance(nb->position) < 0.01f && + p1.distance(na->position) < 0.01f; + if (!fwd && !rev) + continue; + /* Graph "left" of A->B is UNIT_Y x dir. */ + Ogre::Vector3 gdir = nb->position - na->position; + gdir.y = 0.0f; + gdir.normalise(); + Ogre::Vector3 gleft(gdir.z, 0.0f, -gdir.x); + Ogre::Vector3 edgeMid = + (na->position + nb->position) * 0.5f; + bool lotOnLeft = + gleft.dotProduct(lot.centroid - edgeMid) > 0.0f; + lotParams = lotOnLeft ? &e.lotLeft : &e.lotRight; + break; + } + + /* Merge: edge side > district preset. Buildable==Off + * skips before any RNG draws so the draw sequence per lot + * index stays deterministic. */ + if (lotParams && + lotParams->buildable == RoadLotBuildable::Off) { + result.skippedNotBuildable++; + continue; + } + DistrictParams ldp = dp; + float densityScale = params.densityScale; + int roofType = -1; + std::string layout; + if (lotParams) { + if ((int)lotParams->district >= 0) + ldp = districtParams( + (District)(int)lotParams->district); + if (lotParams->densityScale >= 0.0f) + densityScale = lotParams->densityScale; + if (lotParams->setback >= 0.0f) + ldp.setback = lotParams->setback; + if (lotParams->minFootprint > 0.0f) + ldp.minFootprint = lotParams->minFootprint; + if (lotParams->maxFootprint > 0.0f) + ldp.maxFootprint = lotParams->maxFootprint; + if (lotParams->minFloors > 0) + ldp.minFloors = lotParams->minFloors; + if (lotParams->maxFloors > 0) + ldp.maxFloors = lotParams->maxFloors; + ldp.maxFootprint = + std::max(ldp.maxFootprint, ldp.minFootprint); + ldp.maxFloors = std::max(ldp.maxFloors, ldp.minFloors); + roofType = (int)lotParams->roofType; + layout = lotParams->layout; + } + float density = std::min( + 1.0f, ldp.density * std::max(0.0f, densityScale)); + /* Inset the lot off the bounding roads — raw lot * polygons run centerline-to-centerline, so buildings * must keep the road half-extent (lanes + sidewalk + @@ -269,86 +446,31 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, vMax = std::max(vMax, dv); } - float availW = (uMax - uMin) - 2.0f * dp.setback; - float availD = (vMax - vMin) - dp.setback; - if (availW < dp.minFootprint || availD < dp.minFootprint) { + float lotW = uMax - uMin; + float lotD = vMax - vMin; + float availD = lotD - ldp.setback; + if (lotW - 2.0f * ldp.setback < ldp.minFootprint || + availD < ldp.minFootprint) { result.skippedTooSmall++; continue; } - /* Seeded draws: density first, then width, depth, - * floors — fixed order keeps the sequence deterministic - * per lot index. */ - if (unit(rng) > density) { - result.skippedDensity++; - continue; - } - float width = dp.minFootprint + - unit(rng) * (dp.maxFootprint - dp.minFootprint); - float depth = dp.minFootprint + - unit(rng) * (dp.maxFootprint - dp.minFootprint); - width = std::min(width, availW); - depth = std::min(depth, availD); - int floors = dp.minFloors; - if (dp.maxFloors > dp.minFloors) - floors += (int)(unit(rng) * - (float)(dp.maxFloors - dp.minFloors + 1)); - floors = std::min(floors, dp.maxFloors); + /* Phase 4.5 item 3: split wide frontages along u into + * independent strips (edge-side maxFrontage > 0). Each + * strip is an independent draw sharing the lot's params; + * sibling addresses share the street number with a letter + * suffix in frontage order. */ + int strips = 1; + float maxFrontage = + lotParams ? lotParams->maxFrontage : 0.0f; + if (maxFrontage > 0.0f) + strips = (int)std::ceil(lotW / maxFrontage); + while (strips > 1 && lotW / strips < ldp.minFootprint) + --strips; + float stripW = lotW / (float)strips; - float cu = (uMin + uMax) * 0.5f; - float halfW = width * 0.5f; - cu = std::max(uMin + dp.setback + halfW, - std::min(cu, uMax - dp.setback - halfW)); - float cv = vMin + dp.setback + depth * 0.5f; - - Ogre::Vector3 center2d = frontMid + u * cu + v * cv; - - /* Foundation block: the floor sits on the highest - * terrain corner, the block reaches below the lowest. */ - float corners[4][2] = { - { -halfW, -depth * 0.5f }, - { halfW, -depth * 0.5f }, - { halfW, depth * 0.5f }, - { -halfW, depth * 0.5f }, - }; - float floorY = -1.0e30f; - float minGroundY = 1.0e30f; - for (auto &c : corners) { - Ogre::Vector3 w = - center2d + u * c[0] + v * c[1]; - float y = sampleY(sampleHeight, w.x, w.z, - lot.centroid.y); - floorY = std::max(floorY, y); - minGroundY = std::min(minGroundY, y); - } - - if (floorY < params.waterLevel) { - result.skippedWater++; - continue; - } - - TownBuilding b; - b.lotIndex = (int)li; - b.center = center2d; - b.center.y = floorY; - b.yaw = std::atan2(-v.x, -v.z); - b.width = width; - b.depth = depth; - b.floors = floors; - b.floorY = floorY; - b.minGroundY = minGroundY; - - /* Door ground one meter in front of the front face. */ - Ogre::Vector3 doorPos = - center2d - v * (depth * 0.5f + 1.0f); - float doorGround = - sampleY(sampleHeight, doorPos.x, doorPos.z, - lot.centroid.y); - b.stairsDrop = floorY - doorGround; - b.stairs = b.stairsDrop > STAIRS_THRESHOLD; - - /* Address from the frontage street, numbered - * sequentially per street in lot order. */ + /* Street for addressing, resolved once per lot (split + * siblings share one number). */ const RoadStreet *street = nullptr; if (!lot.nodeIds.empty()) street = RoadNetworkGen::findStreetForEdge( @@ -357,23 +479,207 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, else if (lot.sourceEdgeA >= 0 && lot.sourceEdgeB >= 0) street = RoadNetworkGen::findStreetForEdge( graph, lot.sourceEdgeA, lot.sourceEdgeB); - if (street) { - int num = ++streetNumbers[street->name]; - b.address = street->name + " " + std::to_string(num); - } else { - b.address = "Lot " + std::to_string(li + 1); + int streetNumber = -1; /* drawn on the first built strip */ + + for (int si = 0; si < strips; ++si) { + float su0 = uMin + stripW * (float)si; + float su1 = su0 + stripW; + float sAvailW = stripW - 2.0f * ldp.setback; + if (sAvailW < ldp.minFootprint) { + result.skippedTooSmall++; + continue; + } + + /* Seeded draws: density first (here), then the layout + * evaluation draws width/depth/floors through + * rand-range/ rand-int — the default layouts keep + * the legacy width, depth, floors draw order, so an + * all-default unsplit town is bit-identical to the + * pre-DSL generator. */ + if (unit(rng) > density) { + result.skippedDensity++; + continue; + } + + /* Phase 4.5 item 2: evaluate the layout DSL into a + * BuildingPlan (edge-side layout string — inline + * source or library name — or the district + * default). lot-w is the STRIP width. */ + LayoutLib::LayoutBindings lb; + lb.lotW = stripW; + lb.lotD = lotD; + lb.availW = sAvailW; + lb.availD = availD; + lb.minFp = ldp.minFootprint; + lb.maxFp = ldp.maxFootprint; + lb.minFloors = ldp.minFloors; + lb.maxFloors = ldp.maxFloors; + lb.roofType = roofType; + const char *defLayout = + defaultLayoutForDistrict(params.district); + const std::string layoutSrc = + layout.empty() ? std::string(defLayout) : layout; + LayoutLib::BuildingPlan plan; + std::string layoutErr; + { + LayoutLib::LayoutEval eval(lb, &rng, layouts); + if (!eval.evaluate(layoutSrc, plan, + &layoutErr)) { + result.warnings.push_back( + "lot " + std::to_string(li) + + " strip " + std::to_string(si) + + " layout '" + layoutSrc + + "': " + layoutErr + + " (district default used)"); + LayoutLib::LayoutEval fallback( + lb, &rng, layouts); + if (!fallback.evaluate(defLayout, plan, + &layoutErr)) { + result.warnings.push_back( + "lot " + + std::to_string(li) + + ": default layout " + "failed: " + + layoutErr); + result.skippedLayoutError++; + continue; + } + } + } + float width = plan.cellsX * plan.cellSize; + float depth = plan.cellsZ * plan.cellSize; + width = std::min(width, sAvailW); + depth = std::min(depth, availD); + int floors = plan.floors; + + float cu = (su0 + su1) * 0.5f; + float halfW = width * 0.5f; + cu = std::max(su0 + ldp.setback + halfW, + std::min(cu, su1 - ldp.setback - halfW)); + float cv = vMin + ldp.setback + depth * 0.5f; + + Ogre::Vector3 center2d = frontMid + u * cu + v * cv; + + /* Foundation block: the floor sits on the highest + * terrain corner, the block reaches below the + * lowest. */ + float corners[4][2] = { + { -halfW, -depth * 0.5f }, + { halfW, -depth * 0.5f }, + { halfW, depth * 0.5f }, + { -halfW, depth * 0.5f }, + }; + float floorY = -1.0e30f; + float minGroundY = 1.0e30f; + for (auto &c : corners) { + Ogre::Vector3 w = + center2d + u * c[0] + v * c[1]; + float y = sampleY(sampleHeight, w.x, w.z, + lot.centroid.y); + floorY = std::max(floorY, y); + minGroundY = std::min(minGroundY, y); + } + + if (floorY < params.waterLevel) { + result.skippedWater++; + continue; + } + + TownBuilding b; + b.lotIndex = (int)li; + b.center = center2d; + b.center.y = floorY; + b.yaw = std::atan2(-v.x, -v.z); + b.width = width; + b.depth = depth; + b.floors = floors; + b.roofType = roofType; + b.layout = layout; + b.floorY = floorY; + b.minGroundY = minGroundY; + b.plan = std::move(plan); + + /* Door ground one meter in front of the front + * face, at the actual door cell when the layout + * declares one — shaped (cut) buildings may move + * the door off the front-face center. Building + * local +X in world is (-v.z, 0, v.x) (from + * b.yaw). */ + int doorCellX = -1; + int frontZ = b.plan.cellsZ - 1; + for (int dx = 0; dx < b.plan.cellsX; ++dx) { + if (b.plan.at(dx, 0, frontZ) & + LayoutLib::PlanFlags::DoorZPos) { + doorCellX = dx; + break; + } + } + b.doorCellX = doorCellX; + float doorOffX = + doorCellX >= 0 ? + (doorCellX + 0.5f) * + b.plan.cellSize - + width * 0.5f : + 0.0f; + Ogre::Vector3 doorPos = + center2d - v * (depth * 0.5f + 1.0f) + + Ogre::Vector3(-v.z, 0.0f, v.x) * doorOffX; + float doorGround = + sampleY(sampleHeight, doorPos.x, doorPos.z, + lot.centroid.y); + b.stairsDrop = floorY - doorGround; + b.stairs = b.stairsDrop > STAIRS_THRESHOLD; + + /* Address from the frontage street; split siblings + * share the number with a letter suffix in + * frontage order ("Main Street 12A", "12B"). */ + if (street) { + if (streetNumber < 0) + streetNumber = + ++streetNumbers[street->name]; + b.address = street->name + " " + + std::to_string(streetNumber); + } else { + b.address = "Lot " + std::to_string(li + 1); + } + if (strips > 1) + b.address += (char)('A' + si); + + TownPoi poi; + poi.name = b.address + " pickup"; + poi.position = center2d - v * (depth * 0.5f + 3.0f); + poi.position.y = + sampleY(sampleHeight, poi.position.x, + poi.position.z, lot.centroid.y); + poi.building = (int)result.buildings.size(); + + result.buildings.push_back(b); + result.pois.push_back(poi); + + /* Parking pads (layout DSL lot features) become + * extra POIs: strip-frame rect center -> world, + * "
parking [i]". */ + const TownBuilding &built = result.buildings.back(); + for (size_t pi = 0; pi < built.plan.parkings.size(); + ++pi) { + const LayoutLib::PlanParking &pp = + built.plan.parkings[pi]; + TownPoi ppPoi; + ppPoi.name = built.address + " parking"; + if (built.plan.parkings.size() > 1) + ppPoi.name += " " + std::to_string(pi + 1); + ppPoi.position = frontMid + + u * (su0 + pp.x + pp.w * 0.5f) + + v * (vMin + pp.z + pp.d * 0.5f); + ppPoi.position.y = + sampleY(sampleHeight, ppPoi.position.x, + ppPoi.position.z, + lot.centroid.y); + ppPoi.building = + (int)result.buildings.size() - 1; + result.pois.push_back(ppPoi); + } } - - TownPoi poi; - poi.name = b.address + " pickup"; - poi.position = center2d - v * (depth * 0.5f + 3.0f); - poi.position.y = - sampleY(sampleHeight, poi.position.x, poi.position.z, - lot.centroid.y); - poi.building = (int)result.buildings.size(); - - result.buildings.push_back(b); - result.pois.push_back(poi); } return result; diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp index 8e08562..37f88d6 100644 --- a/src/features/editScene/roadlib/TownGen.hpp +++ b/src/features/editScene/roadlib/TownGen.hpp @@ -6,8 +6,13 @@ #include #include "../components/RoadGraph.hpp" +#include "../layoutlib/BuildingPlan.hpp" #include "RoadNetworkGen.hpp" +namespace LayoutLib { +class LayoutLibrary; +} + /** * Procedural town generation on top of a road network (demo-taxi * phase 4). Pure functions of RoadGraph/RoadLot data plus a terrain @@ -72,6 +77,22 @@ struct TownBuilding { float width = 0.0f; float depth = 0.0f; int floors = 1; + /** + * Roof type override from the frontage edge's lot params + * (phase 4.5); values mirror RoofComponent::Type, -1 = use the + * builder's default. + */ + int roofType = -1; + /** Layout DSL string from the lot params (phase 4.5 item 2); + * empty = default procedural layout. */ + std::string layout; + /** + * Evaluated building plan (phase 4.5 item 2): grid metrics, + * per-cell flags, roofs, lot features, placements. Always + * populated — the district default layout goes through the same + * evaluator, so TownGenSystem materializes plans only. + */ + LayoutLib::BuildingPlan plan; /** Floor level: the highest terrain corner of the footprint. */ float floorY = 0.0f; /** Lowest terrain corner of the footprint. */ @@ -80,6 +101,11 @@ struct TownBuilding { bool stairs = false; /** floorY - ground at the door (when stairs). */ float stairsDrop = 0.0f; + /** Front door cell (x index on the local +Z face of ground + * floor), -1 when the layout declares none; the door ground + * sample and the entrance stairs follow it, so shaped (cut) + * buildings keep the stairs at the real door. */ + int doorCellX = -1; /** " " or "Lot ". */ std::string address; }; @@ -101,6 +127,16 @@ struct TownResult { int skippedTooSmall = 0; /** Lots below TownParams::waterLevel. */ int skippedWater = 0; + /** Lots whose frontage edge side is marked non-buildable + * (phase 4.5 RoadLotParams::buildable == Off). */ + int skippedNotBuildable = 0; + /** Lots skipped because both their layout and the district + * default failed to evaluate (layout errors are otherwise + * reported in warnings and fall back to the default). */ + int skippedLayoutError = 0; + /** Non-fatal per-lot notes (layout evaluation errors that fell + * back to the district default, etc.). */ + std::vector warnings; }; /** @@ -116,8 +152,71 @@ struct TownResult { * edge is the longest boundary edge on a named street (longest * boundary edge when the lot touches no street); the address numbers * buildings sequentially per street in lot order. + * + * Phase 4.5: the frontage edge's per-side RoadLotParams (the side the + * lot centroid lies on, matched geometrically against the graph edge) + * override the district preset per lot — buildable tri-state, district + * override, densityScale, setback, footprint/floor ranges, roof type + * and layout DSL string. All inherit fields leave the district + * preset untouched; buildable == Off skips the lot before any RNG + * draws so determinism is preserved. + * + * Phase 4.5 item 2: each building's shape comes from evaluating a + * layout DSL script (layoutlib) — the lot's `layout` string (inline + * source or a library name) or the district default. The default + * layouts draw width/depth/floors from the seed stream in the legacy + * order, so an all-default town is bit-identical to the pre-DSL + * generator. A failing layout falls back to the district default + * (noted in TownResult::warnings). The evaluated BuildingPlan rides + * on TownBuilding::plan; parking rects become extra "
+ * parking" POIs. + * + * Phase 4.5 item 3: an edge-side `maxFrontage` > 0 splits a wide lot + * into independent strips along the frontage (each strip its own + * density/layout draws; strip count clamped so no strip is narrower + * than the district minimum footprint). Split siblings share the + * street number with a letter suffix ("Main Street 12A"/"12B"). An + * unsplit town is bit-identical to the pre-splitting generator. + * + * Phase 4.5 item 4: `areas` + `areaIndex` scope generation to one + * town area: a lot qualifies iff its centroid XZ is inside + * (*areas)[areaIndex] and NOT inside any (*areas)[k], k < areaIndex + * (first-match-wins), so overlapping areas partition the lots + * deterministically. Filtering happens before any RNG draws so the + * per-area town is deterministic; the caller maps the area's + * district/seed/densityScale onto TownParams. areaIndex < 0 (the + * default) disables filtering entirely. + * + * Phase 4.5 item 5 (building streaming): a non-null `page` with a + * mapper switches the RNG from one global stream to per-page streams + * seeded `params.seed ^ hash(physical page)`, so a page's building + * set is computable WITHOUT generating the rest of the town and the + * all-pages generation is exactly the union of the per-page + * generations. Lots are assigned to terrain pages through the + * caller's mapper (render-space centroid -> physical page); lots + * within a page are processed sorted by centroid (load-order + * independent), street numbering restarts per page. pageX >= 0 + * generates only that page. A null scope keeps the legacy single + * stream (bit-identical to the pre-streaming generator). */ +typedef std::function + PageMapper; + +struct PageScope { + /** Render-space centroid -> physical terrain page. Empty + * std::function disables paging. */ + PageMapper mapper; + /** Page filter; negative = all pages. */ + long pageX = -1; + long pageY = -1; +}; + TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, - const RoadNetworkGen::HeightSampler &sampleHeight); + const RoadNetworkGen::HeightSampler &sampleHeight, + const LayoutLib::LayoutLibrary *layouts = nullptr, + const std::vector *areas = nullptr, + int areaIndex = -1, + const PageScope *page = nullptr); } /* namespace TownGen */ diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index 8dbc636..dfb90c1 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -1183,9 +1183,14 @@ void CellGridSystem::buildDoors(const CellGridComponent &grid, const float solidExtHeight = grid.cellHeight; const float solidIntHeight = grid.cellHeight - 0.3f - solidIntOffset; - // Door dimensions (original values scaled) + // Door dimensions (original values scaled). The height scales + // with cellHeight like the door frame mesh and the leaf do + // (buildDoorFrameMesh / buildDoorEntities), so the wall opening + // always matches the frame; with a fixed 3.0 base the opening of + // a 3 m town cell ran the full wall height while the frame stayed + // at 2.25 m, leaving a hole above the door frame. const float doorWidth = 1.4f * hScale; - const float doorHeightBase = 3.0f; + const float doorHeightBase = 3.0f * (grid.cellHeight / 4.0f); for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); @@ -1197,9 +1202,11 @@ void CellGridSystem::buildDoors(const CellGridComponent &grid, cell.hasFlag(CellFlags::DoorZPos)) { float sideWidth = (2.0f * hScale - doorWidth) / 2.0f; float moffset = (2.0f * hScale - sideWidth) / 2.0f; - /* Clamp to the wall height: with cellHeight <= - * doorHeightBase (e.g. the 3 m town cells) the door - * fills the wall and no top frame is emitted. */ + /* Clamp to the wall height: with a very short + * cellHeight the door fills the wall and no top + * frame is emitted; normal heights (including the + * 3 m town cells) get a lintel plane above the + * opening. */ float doorHeight = std::min(doorHeightBase - solidExtOffset, solidExtHeight); diff --git a/src/features/editScene/systems/RoadRegionStore.cpp b/src/features/editScene/systems/RoadRegionStore.cpp index e579cfb..6eb8263 100644 --- a/src/features/editScene/systems/RoadRegionStore.cpp +++ b/src/features/editScene/systems/RoadRegionStore.cpp @@ -43,6 +43,47 @@ static void prefabFromJson(const nlohmann::json &js, out.yOffset = js.value("yOffset", 0.0f); } +static bool lotParamsIsDefault(const RoadRegionLotParams &lp) +{ + return lp.buildable == 0 && lp.district == -1 && + lp.densityScale < 0.0f && lp.setback < 0.0f && + lp.minFootprint == 0.0f && lp.maxFootprint == 0.0f && + lp.minFloors == 0 && lp.maxFloors == 0 && lp.roofType == -1 && + lp.layout.empty() && lp.maxFrontage == 0.0f; +} + +static void lotParamsToJson(const RoadRegionLotParams &src, + nlohmann::json &out) +{ + out["buildable"] = src.buildable; + out["district"] = src.district; + out["densityScale"] = src.densityScale; + out["setback"] = src.setback; + out["minFootprint"] = src.minFootprint; + out["maxFootprint"] = src.maxFootprint; + out["minFloors"] = src.minFloors; + out["maxFloors"] = src.maxFloors; + out["roofType"] = src.roofType; + out["layout"] = src.layout; + out["maxFrontage"] = src.maxFrontage; +} + +static void lotParamsFromJson(const nlohmann::json &js, + RoadRegionLotParams &out) +{ + out.buildable = js.value("buildable", 0); + out.district = js.value("district", -1); + out.densityScale = js.value("densityScale", -1.0f); + out.setback = js.value("setback", -1.0f); + out.minFootprint = js.value("minFootprint", 0.0f); + out.maxFootprint = js.value("maxFootprint", 0.0f); + out.minFloors = js.value("minFloors", 0); + out.maxFloors = js.value("maxFloors", 0); + out.roofType = js.value("roofType", -1); + out.layout = js.value("layout", std::string()); + out.maxFrontage = js.value("maxFrontage", 0.0f); +} + RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY) { const uint64_t key = packPageKey(pageX, pageY); @@ -172,6 +213,16 @@ RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY) prefabFromJson( je["prefabMid"], e.prefabMid); + /* Phase 4.5: per-side lot params + * (absent = all-inherit). */ + if (je.contains("lotLeft")) + lotParamsFromJson( + je["lotLeft"], + e.lotLeft); + if (je.contains("lotRight")) + lotParamsFromJson( + je["lotRight"], + e.lotRight); page.data.edges.push_back(e); } } @@ -281,6 +332,12 @@ bool RoadRegionStore::saveRegion(long px, long py, const RoadRegionData &data) prefabToJson(e.prefabRight, je["prefabRight"]); if (!e.prefabMid.prefabPath.empty()) prefabToJson(e.prefabMid, je["prefabMid"]); + /* Phase 4.5: per-side lot params, sparse like the + * prefab slots (all-inherit sides are omitted). */ + if (!lotParamsIsDefault(e.lotLeft)) + lotParamsToJson(e.lotLeft, je["lotLeft"]); + if (!lotParamsIsDefault(e.lotRight)) + lotParamsToJson(e.lotRight, je["lotRight"]); j["edges"].push_back(je); } @@ -365,3 +422,50 @@ bool RoadRegionStore::saveStreets(const nlohmann::json &j) return false; } } + +std::string RoadRegionStore::townsFilePath(const std::string &rootDir) +{ + return rootDir + "/roads/towns.json"; +} + +bool RoadRegionStore::loadTowns(nlohmann::json *out) +{ + if (m_rootDir.empty() || !out) + return false; + const std::string path = townsFilePath(m_rootDir); + try { + std::ifstream file(path); + if (!file.is_open()) + return false; + file >> *out; + return true; + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: towns load error: " + + std::string(e.what())); + return false; + } +} + +bool RoadRegionStore::saveTowns(const nlohmann::json &j) +{ + if (m_rootDir.empty()) + return false; + try { + std::filesystem::create_directories(m_rootDir + "/roads"); + const std::string path = townsFilePath(m_rootDir); + std::ofstream file(path); + if (!file.is_open()) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: failed to write " + path); + return false; + } + file << j.dump(4); + return true; + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: towns save error: " + + std::string(e.what())); + return false; + } +} diff --git a/src/features/editScene/systems/RoadRegionStore.hpp b/src/features/editScene/systems/RoadRegionStore.hpp index c730446..d4a3e85 100644 --- a/src/features/editScene/systems/RoadRegionStore.hpp +++ b/src/features/editScene/systems/RoadRegionStore.hpp @@ -48,6 +48,24 @@ struct RoadRegionDecal { float uvH = 0.5f; }; +/* Per-edge-side building-lot parameters (taxi demo phase 4.5); field + * semantics match RoadLotParams (enums mirrored as ints: buildable is + * RoadLotBuildable, district RoadLotDistrict, roofType RoadLotRoof). + * Absent in legacy files = all-inherit defaults. */ +struct RoadRegionLotParams { + int buildable = 0; + int district = -1; + float densityScale = -1.0f; + float setback = -1.0f; + float minFootprint = 0.0f; + float maxFootprint = 0.0f; + int minFloors = 0; + int maxFloors = 0; + int roofType = -1; + std::string layout; + float maxFrontage = 0.0f; +}; + /* * One road edge stored in a region file. Endpoints are referenced by * the region-local node ids of this file. Field semantics match @@ -76,6 +94,9 @@ struct RoadRegionEdge { RoadRegionEdgePrefab prefabLeft; RoadRegionEdgePrefab prefabRight; RoadRegionEdgePrefab prefabMid; + /* Phase 4.5: per-side lot params (see RoadRegionLotParams). */ + RoadRegionLotParams lotLeft; + RoadRegionLotParams lotRight; }; /* @@ -125,6 +146,12 @@ public: bool saveStreets(const nlohmann::json &j); static std::string streetsFilePath(const std::string &rootDir); + /* Town areas (phase 4.5 item 4) live in a sibling file: + * "/roads/towns.json". Same semantics as loadStreets. */ + bool loadTowns(nlohmann::json *out); + bool saveTowns(const nlohmann::json &j); + static std::string townsFilePath(const std::string &rootDir); + private: struct PageData { RoadRegionData data; diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 0840263..3922660 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -695,6 +695,11 @@ void RoadSystem::syncRegions() if (m_regionStore.loadStreets(&sj)) RoadNetworkGen::streetsFromJson(graph, sj, /*dropMissing=*/false); + /* Town areas (phase 4.5 item 4): one file per terrain, + * same lifecycle as the streets file. */ + nlohmann::json tj; + if (m_regionStore.loadTowns(&tj)) + RoadNetworkGen::townAreasFromJson(graph, tj); } Ogre::TerrainGroup *group = ts->getTerrainGroup(); @@ -806,6 +811,40 @@ static void copyDecalsFromRegion(const RoadRegionEdge &src, RoadEdge &dst) } } +/* Phase 4.5: lot-param conversion between RoadEdge and + * RoadRegionEdge (enums cross as ints, same as the modes). */ +static void copyLotParamsToRegion(const RoadLotParams &src, + RoadRegionLotParams &dst) +{ + dst.buildable = (int)src.buildable; + dst.district = (int)src.district; + dst.densityScale = src.densityScale; + dst.setback = src.setback; + dst.minFootprint = src.minFootprint; + dst.maxFootprint = src.maxFootprint; + dst.minFloors = src.minFloors; + dst.maxFloors = src.maxFloors; + dst.roofType = (int)src.roofType; + dst.layout = src.layout; + dst.maxFrontage = src.maxFrontage; +} + +static void copyLotParamsFromRegion(const RoadRegionLotParams &src, + RoadLotParams &dst) +{ + dst.buildable = (RoadLotBuildable)src.buildable; + dst.district = (RoadLotDistrict)src.district; + dst.densityScale = src.densityScale; + dst.setback = src.setback; + dst.minFootprint = src.minFootprint; + dst.maxFootprint = src.maxFootprint; + dst.minFloors = src.minFloors; + dst.maxFloors = src.maxFloors; + dst.roofType = (RoadLotRoof)src.roofType; + dst.layout = src.layout; + dst.maxFrontage = src.maxFrontage; +} + void RoadSystem::migrateGraphToRegions(RoadGraph &graph) { TerrainSystem *ts = TerrainSystem::getInstance(); @@ -891,6 +930,8 @@ void RoadSystem::migrateGraphToRegions(RoadGraph &graph) re.sidewalkRightMode = (int)e.sidewalkRightMode; re.markingsMode = (int)e.markingsMode; copyDecalsToRegion(e, re); + copyLotParamsToRegion(e.lotLeft, re.lotLeft); + copyLotParamsToRegion(e.lotRight, re.lotRight); for (const RoadSidewalkPass &p : e.sidewalkPasses) { RoadRegionSidewalkPass rp; rp.rightSide = p.rightSide; @@ -989,6 +1030,8 @@ void RoadSystem::loadRegion(long px, long py, RoadGraph &graph) e.sidewalkRightMode = (RoadSidewalkMode)re.sidewalkRightMode; e.markingsMode = (RoadMarkingMode)re.markingsMode; copyDecalsFromRegion(re, e); + copyLotParamsFromRegion(re.lotLeft, e.lotLeft); + copyLotParamsFromRegion(re.lotRight, e.lotRight); for (const RoadRegionSidewalkPass &rp : re.sidewalkPasses) { RoadSidewalkPass p; p.rightSide = rp.rightSide; @@ -1084,6 +1127,8 @@ void RoadSystem::buildRegionData(long px, long py, const RoadGraph &graph, re.sidewalkRightMode = (int)e.sidewalkRightMode; re.markingsMode = (int)e.markingsMode; copyDecalsToRegion(e, re); + copyLotParamsToRegion(e.lotLeft, re.lotLeft); + copyLotParamsToRegion(e.lotRight, re.lotRight); for (const RoadSidewalkPass &p : e.sidewalkPasses) { RoadRegionSidewalkPass rp; rp.rightSide = p.rightSide; @@ -1138,6 +1183,11 @@ void RoadSystem::flushRegionStore() nlohmann::json sj; RoadNetworkGen::streetsToJson(graph, sj); m_regionStore.saveStreets(sj); + + /* Town areas (phase 4.5 item 4) likewise. */ + nlohmann::json tj; + RoadNetworkGen::townAreasToJson(graph, tj); + m_regionStore.saveTowns(tj); } void RoadSystem::onRenderOriginChanged(const Ogre::Vector3 &delta) @@ -2314,6 +2364,42 @@ void RoadSystem::buildLotOutlines() } } + /* Town generation areas (phase 4.5 item 4): dark blue loops, + * squares as 4 corners, circles as 64-segment polylines. */ + m_lotOutlines->colour(Ogre::ColourValue(0.1f, 0.1f, 0.3f)); + for (const RoadTownArea &area : rg.townAreas) { + if (area.circular) { + const int segs = 64; + for (int i = 0; i < segs; ++i) { + float a0 = (float)i / (float)segs * 6.2831853f; + float a1 = (float)(i + 1) / (float)segs * + 6.2831853f; + m_lotOutlines->position( + area.center.x + area.radius * std::cos(a0), + area.center.y + 0.25f, + area.center.z + area.radius * std::sin(a0)); + m_lotOutlines->position( + area.center.x + area.radius * std::cos(a1), + area.center.y + 0.25f, + area.center.z + area.radius * std::sin(a1)); + } + } else { + const float sx[4] = { -1.0f, 1.0f, 1.0f, -1.0f }; + const float sz[4] = { -1.0f, -1.0f, 1.0f, 1.0f }; + for (int i = 0; i < 4; ++i) { + int j = (i + 1) % 4; + m_lotOutlines->position( + area.center.x + sx[i] * area.halfSize, + area.center.y + 0.25f, + area.center.z + sz[i] * area.halfSize); + m_lotOutlines->position( + area.center.x + sx[j] * area.halfSize, + area.center.y + 0.25f, + area.center.z + sz[j] * area.halfSize); + } + } + } + m_lotOutlines->end(); } diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 6e31c86..c857577 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -116,10 +116,13 @@ bool SceneSerializer::saveToFile(const std::string &filepath) // Collect all entities with EditorMarkerComponent; region-store // spawners (M4) are persisted per terrain page instead and - // must not land in the scene JSON. + // generated town buildings (phase 4.5 item 5) are regenerated + // from the road graph — neither must land in the scene JSON. m_world.query_builder<>() .with() .without() + .without() + .without() .build() .each([&](flecs::entity entity) { // Only save root entities (children will be saved recursively) @@ -4922,6 +4925,28 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) ej["prefabLeft"] = writeSlot(e.prefabLeft); ej["prefabRight"] = writeSlot(e.prefabRight); ej["prefabMid"] = writeSlot(e.prefabMid); + + /* Phase 4.5: per-side lot params, written sparsely + * (all-inherit sides are omitted). */ + auto writeLotParams = [](const RoadLotParams &lp) { + nlohmann::json lj; + lj["buildable"] = (int)lp.buildable; + lj["district"] = (int)lp.district; + lj["densityScale"] = lp.densityScale; + lj["setback"] = lp.setback; + lj["minFootprint"] = lp.minFootprint; + lj["maxFootprint"] = lp.maxFootprint; + lj["minFloors"] = lp.minFloors; + lj["maxFloors"] = lp.maxFloors; + lj["roofType"] = (int)lp.roofType; + lj["layout"] = lp.layout; + lj["maxFrontage"] = lp.maxFrontage; + return lj; + }; + if (!e.lotLeft.isDefault()) + ej["lotLeft"] = writeLotParams(e.lotLeft); + if (!e.lotRight.isDefault()) + ej["lotRight"] = writeLotParams(e.lotRight); roadEdgesJson.push_back(ej); } json["roadEdges"] = roadEdgesJson; @@ -4929,6 +4954,10 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) /* Phase 3: named streets (streaming terrains store them * in roads/streets.json via RoadSystem instead). */ RoadNetworkGen::streetsToJson(tc.roadGraph, json); + + /* Phase 4.5 item 4: town areas (streaming terrains store + * them in roads/towns.json via RoadSystem instead). */ + RoadNetworkGen::townAreasToJson(tc.roadGraph, json); } /* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */ @@ -5160,6 +5189,35 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, readSlot("prefabRight", edge.prefabRight); readSlot("prefabMid", edge.prefabMid); + /* Phase 4.5: per-side lot params (absent = all-inherit + * defaults). */ + auto readLotParams = [&ej](const char *key, + RoadLotParams &lp) { + if (!ej.contains(key)) + return; + const auto &lj = ej[key]; + lp.buildable = (RoadLotBuildable)lj.value( + "buildable", 0); + lp.district = + (RoadLotDistrict)lj.value("district", -1); + lp.densityScale = + lj.value("densityScale", -1.0f); + lp.setback = lj.value("setback", -1.0f); + lp.minFootprint = + lj.value("minFootprint", 0.0f); + lp.maxFootprint = + lj.value("maxFootprint", 0.0f); + lp.minFloors = lj.value("minFloors", 0); + lp.maxFloors = lj.value("maxFloors", 0); + lp.roofType = + (RoadLotRoof)lj.value("roofType", -1); + lp.layout = lj.value("layout", ""); + lp.maxFrontage = + lj.value("maxFrontage", 0.0f); + }; + readLotParams("lotLeft", edge.lotLeft); + readLotParams("lotRight", edge.lotRight); + /* Migration: legacy sidePrefabs array -> fixed slots. * The first left-side entry becomes prefabLeft, the first * right-side entry prefabRight; sideOffset (absolute @@ -5208,6 +5266,11 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, if (json.contains("streets")) RoadNetworkGen::streetsFromJson(tc.roadGraph, json); + /* Phase 4.5 item 4: town areas (same inline/region split as + * streets). */ + if (json.contains("townAreas")) + RoadNetworkGen::townAreasFromJson(tc.roadGraph, json); + tc.roadGraph.bumpVersion(); entity.set(tc); diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 4294748..466106a 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -3400,6 +3400,11 @@ void TerrainSystem::update(float /*deltaTime*/) m_roadSystem->update(0.0f); updStep("roadSystem"); + /* Streamed town buildings (game mode, phase 4.5 item 5). */ + if (m_townGenSystem) + m_townGenSystem->update(); + updStep("townGen"); + mBodyDrawFilter.showTerrain = m_showTerrainColliders; mBodyDrawFilter.showRoad = m_showRoadColliders; if (m_roadSystem) diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 20943eb..18f70c2 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -2,6 +2,8 @@ #include "TerrainSystem.hpp" #include "RoadSystem.hpp" +#include "CellGridSystem.hpp" +#include "../GameMode.hpp" #include "../components/Terrain.hpp" #include "../components/Transform.hpp" #include "../components/EntityName.hpp" @@ -18,16 +20,81 @@ #include #include +#include +#include +#include -/* Building cell metrics: 2 m cells, 3 m floor height. */ -static const float TOWN_CELL_SIZE = 2.0f; -static const float TOWN_CELL_HEIGHT = 3.0f; +/* Building grid metrics come from the layout plan + * (BuildingPlan::cellSize/cellHeight, per-layout via the DSL + * `cell-size` verb; defaults LAYOUT_CELL_SIZE/LAYOUT_CELL_HEIGHT). */ + +/* Phase 4.5 item 2: the layout DSL plan flags must stay bit-identical + * to CellFlags — the plan is copied into the grid untranslated. */ +static_assert(LayoutLib::PlanFlags::Floor == CellFlags::Floor, ""); +static_assert(LayoutLib::PlanFlags::WallXNeg == CellFlags::WallXNeg, ""); +static_assert(LayoutLib::PlanFlags::DoorZPos == CellFlags::DoorZPos, ""); +static_assert(LayoutLib::PlanFlags::WindowXPos == CellFlags::WindowXPos, + ""); +static_assert(LayoutLib::PlanFlags::IntWallZNeg == CellFlags::IntWallZNeg, + ""); +static_assert(LayoutLib::PlanFlags::IntDoorXPos == CellFlags::IntDoorXPos, + ""); +static_assert(LayoutLib::PlanFlags::IntWindowZPos == + CellFlags::IntWindowZPos, + ""); /* Foundation reaches this far below the lowest terrain corner. */ static const float FOUNDATION_MARGIN = 0.5f; +/* The foundation block(s) overhang the shell walls by this much per + * side, so the house reads as seated on its slab. */ +static const float FOUNDATION_OVERHANG = 0.3f; /* Stairs step geometry. */ static const float STAIR_RISE = 0.18f; static const float STAIR_TREAD = 0.30f; static const float STAIR_WIDTH = 1.4f; +/* Floor-level landing depth in front of the door: without it the + * door frame (which sits at the wall plane and whose trim extends + * past it) overhangs the first step and the doorway is hard to step + * through. */ +static const float STAIR_LANDING = 0.9f; + +/* True when every cell of the given plan floor is present (no cut + * cells) — solid floors keep the single full-footprint foundation + * block / fallback roof, so default towns stay bit-identical. */ +static bool planFloorSolid(const LayoutLib::BuildingPlan &plan, int floor) +{ + for (int z = 0; z < plan.cellsZ; ++z) + for (int x = 0; x < plan.cellsX; ++x) + if (!(plan.at(x, floor, z) & + LayoutLib::PlanFlags::Floor)) + return false; + return true; +} + +/* Per-row runs of present (Floor-flagged) cells of one plan floor: + * (z, x0, x1) per run. Used to split the foundation block and the + * fallback roof under/over cut (L/U-shaped) footprints. */ +static std::vector> +planFloorRuns(const LayoutLib::BuildingPlan &plan, int floor) +{ + std::vector > runs; + for (int z = 0; z < plan.cellsZ; ++z) { + int x = 0; + while (x < plan.cellsX) { + while (x < plan.cellsX && + !(plan.at(x, floor, z) & + LayoutLib::PlanFlags::Floor)) + ++x; + int x0 = x; + while (x < plan.cellsX && + (plan.at(x, floor, z) & + LayoutLib::PlanFlags::Floor)) + ++x; + if (x > x0) + runs.push_back({ z, x0, x - 1 }); + } + } + return runs; +} TownGenSystem::TownGenSystem(flecs::world &world, Ogre::SceneManager *sceneMgr) : m_world(world) @@ -62,44 +129,136 @@ int TownGenSystem::clear() return (int)towns.size(); } +LayoutLib::LayoutLibrary TownGenSystem::loadLayoutLibrary() +{ + /* Built-in district defaults plus the optional project file + * (town_layouts.txt in the CWD — the project directory, see + * EditorApp --project). */ + LayoutLib::LayoutLibrary lib = LayoutLib::LayoutLibrary::withBuiltins(); + std::string err; + if (!lib.loadFile("town_layouts.txt", &err)) { + std::ifstream probe("town_layouts.txt"); + if (probe.good()) + Ogre::LogManager::getSingleton().logMessage( + "TownGenSystem: town_layouts.txt: " + err); + } + return lib; +} + +/* Phase 4.5 item 4: RoadTownDistrict mirrors TownGen::District — the + * area's district is cast directly onto TownParams. */ +static_assert((int)RoadTownDistrict::Village == + (int)TownGen::District::Village, + ""); +static_assert((int)RoadTownDistrict::CityCenter == + (int)TownGen::District::CityCenter, + ""); + +/* Phase 4.5 item 5: map a render-space XZ position to the physical + * terrain page owning it (same mapping as + * TerrainPrefabSpawnerSystem::pageForWorld). */ +static TownGen::PageMapper makePageMapper(TerrainSystem *ts) +{ + return [ts](float rx, float rz, long &px, long &py) -> bool { + Ogre::TerrainGroup *group = ts->getTerrainGroup(); + if (!ts->getStreamingActive() || !group) + return false; + const double ws = group->getTerrainWorldSize(); + const long pages = ts->getStreamingPageCount(); + long x = (long)std::floor( + ts->visualToPhysicalX(ts->renderToWorldX(rx)) / ws); + long y = (long)std::floor( + ts->visualToPhysicalZ(ts->renderToWorldZ(rz)) / ws); + if (x < 0) + x = 0; + if (y < 0) + y = 0; + if (x > pages - 1) + x = pages - 1; + if (y > pages - 1) + y = pages - 1; + px = x; + py = y; + return true; + }; +} + +/* Same (px, py) packing the region stores use (physical pages are + * non-negative, so no sign handling needed). */ +static uint64_t packTownPageKey(long px, long py) +{ + return ((uint64_t)(uint32_t)px << 32) | (uint32_t)py; +} + +/* Shared setup of generate()/generateAreas(): resolve the active + * terrain's road graph, the water level and the height sampler. */ +static bool setupTownRun(TerrainSystem *ts, const RoadGraph **graphOut, + float *waterYOut, + RoadNetworkGen::HeightSampler *samplerOut, + flecs::world &world, std::string *error) +{ + if (!ts || !ts->getRoadSystem()) { + if (error) + *error = "no terrain system"; + return false; + } + flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) { + if (error) + *error = "no active terrain"; + return false; + } + *graphOut = &terrain.get().roadGraph; + + float waterY = -FLT_MAX; + world.each([&waterY](WaterPhysics &wp) { + waterY = wp.waterSurfaceY; + }); + *waterYOut = waterY; + + *samplerOut = [ts](float x, float z) { + return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z)); + }; + return true; +} + int TownGenSystem::generate(const TownGen::TownParams ¶ms, std::string *error) { TerrainSystem *ts = m_terrainSystem ? m_terrainSystem : TerrainSystem::getInstance(); - if (!ts || !ts->getRoadSystem()) { - if (error) - *error = "no terrain system"; + const RoadGraph *graph = nullptr; + float waterY = -FLT_MAX; + RoadNetworkGen::HeightSampler sampler; + if (!setupTownRun(ts, &graph, &waterY, &sampler, m_world, error)) return 0; - } - flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity(); - if (!terrain.is_alive() || !terrain.has()) { - if (error) - *error = "no active terrain"; - return 0; - } - const RoadGraph &graph = - terrain.get().roadGraph; TownGen::TownParams p = params; - float waterY = -FLT_MAX; - m_world.each([&waterY](WaterPhysics &wp) { - waterY = wp.waterSurfaceY; - }); p.waterLevel = waterY; - RoadNetworkGen::HeightSampler sampler; - if (ts) - sampler = [ts](float x, float z) { - return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z)); - }; + LayoutLib::LayoutLibrary layouts = loadLayoutLibrary(); - TownGen::TownResult result = TownGen::generateTown(graph, p, sampler); + /* Phase 4.5 item 5: on a streaming terrain the all-pages paged + * generation keeps the editor-built town identical to the + * game-mode streamed union. */ + TownGen::PageScope scope; + const TownGen::PageScope *scopePtr = nullptr; + if (ts->getStreamingActive() && ts->getTerrainGroup()) { + scope.mapper = makePageMapper(ts); + scopePtr = &scope; + } + TownGen::TownResult result = + TownGen::generateTown(*graph, p, sampler, &layouts, nullptr, + -1, scopePtr); /* Replace any previously generated town. */ clear(); + for (const std::string &w : result.warnings) + Ogre::LogManager::getSingleton().logMessage( + "TownGenSystem: " + w); + if (result.buildings.empty()) { if (error) *error = "no suitable lots"; @@ -108,17 +267,97 @@ int TownGenSystem::generate(const TownGen::TownParams ¶ms, const TownGen::DistrictParams &dp = TownGen::districtParams(p.district); - - /* Container entity: holds the shared material reference - * (TownComponent) so buildings pick it up via the CellGrid - * parent-chain material lookup. */ - flecs::entity container = m_world.entity(); std::string containerName = std::string("TownGen ") + dp.name + " seed " + std::to_string(p.seed); + createTownContainer(containerName, result, dp, p.seed); + + return (int)result.buildings.size(); +} + +int TownGenSystem::generateAreas(std::string *error) +{ + TerrainSystem *ts = m_terrainSystem ? + m_terrainSystem : + TerrainSystem::getInstance(); + const RoadGraph *graph = nullptr; + float waterY = -FLT_MAX; + RoadNetworkGen::HeightSampler sampler; + if (!setupTownRun(ts, &graph, &waterY, &sampler, m_world, error)) + return 0; + + if (graph->townAreas.empty()) { + if (error) + *error = "no town areas defined"; + return 0; + } + + LayoutLib::LayoutLibrary layouts = loadLayoutLibrary(); + + /* See generate(): paged generation on streaming terrains. */ + TownGen::PageScope scope; + const TownGen::PageScope *scopePtr = nullptr; + if (ts->getStreamingActive() && ts->getTerrainGroup()) { + scope.mapper = makePageMapper(ts); + scopePtr = &scope; + } + + /* Replace any previously generated town(s). */ + clear(); + + int total = 0; + for (size_t ai = 0; ai < graph->townAreas.size(); ++ai) { + const RoadTownArea &area = graph->townAreas[ai]; + + TownGen::TownParams p; + p.district = (TownGen::District)(int)area.district; + p.seed = area.seed; + p.densityScale = area.densityScale; + p.waterLevel = waterY; + + TownGen::TownResult result = + TownGen::generateTown(*graph, p, sampler, &layouts, + &graph->townAreas, (int)ai, + scopePtr); + for (const std::string &w : result.warnings) + Ogre::LogManager::getSingleton().logMessage( + "TownGenSystem[" + area.name + "]: " + w); + + if (result.buildings.empty()) + continue; + + const TownGen::DistrictParams &dp = + TownGen::districtParams(p.district); + createTownContainer("TownGen " + area.name, result, dp, + p.seed); + total += (int)result.buildings.size(); + } + + if (total == 0) { + if (error) + *error = "no suitable lots in any town area"; + return 0; + } + return total; +} + +flecs::entity +TownGenSystem::createTownContainer(const std::string &containerName, + const TownGen::TownResult &result, + const TownGen::DistrictParams &dp, + uint32_t seed) +{ + /* Container entity: holds the shared material reference + * (TownComponent) so buildings pick it up via the CellGrid + * parent-chain material lookup. GeneratedTownTag keeps every + * generated town out of the scene JSON — towns are a pure + * function of the road graph and regenerate in the editor + * (Town Generator panel) or stream per page in game mode. */ + flecs::entity container = m_world.entity(); container.set( EntityNameComponent(containerName)); container.add(); + container.add(); TransformComponent containerXform; containerXform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); @@ -129,7 +368,7 @@ int TownGenSystem::generate(const TownGen::TownParams ¶ms, container.set(containerXform); flecs::entity texture = - createSettlementResources(container, dp, p.seed); + createSettlementResources(container, dp, seed); flecs::entity material = m_world.entity(); material.child_of(container); @@ -149,11 +388,153 @@ int TownGenSystem::generate(const TownGen::TownParams ¶ms, town.proceduralMaterialEntityId = std::to_string(material.id()); container.set(town); - for (size_t i = 0; i < result.buildings.size(); ++i) + for (size_t i = 0; i < result.buildings.size(); ++i) { + /* POIs are not 1:1 with buildings (parking POIs share + * their building's slot), gather them by index. */ + std::vector pois; + for (const TownGen::TownPoi &poi : result.pois) + if (poi.building == (int)i) + pois.push_back(&poi); createBuilding(container, material, texture, - result.buildings[i], result.pois[i]); + result.buildings[i], pois); + } - return (int)result.buildings.size(); + return container; +} + +void TownGenSystem::update() +{ + TerrainSystem *ts = m_terrainSystem ? + m_terrainSystem : + TerrainSystem::getInstance(); + const bool active = ts && ts->getStreamingActive() && + ts->getTerrainGroup() && editScene::isGameMode(); + RoadSystem *rs = active ? ts->getRoadSystem() : nullptr; + flecs::entity terrain = + rs ? rs->getTerrainEntity() : flecs::entity(); + const RoadGraph *graph = nullptr; + if (terrain.is_alive() && terrain.has()) + graph = &terrain.get().roadGraph; + + if (!graph || graph->townAreas.empty()) { + clearStreamedPages(); + m_streamTerrainId = 0; + return; + } + + /* Scene switches rebuild the terrain entity; the containers of + * the old scene died with it, so restart the bookkeeping. */ + if (m_streamTerrainId != terrain.id()) { + clearStreamedPages(); + m_streamTerrainId = terrain.id(); + } + + /* Loaded physical pages (slot (x, y) is physical page (x, -y)). */ + std::set loaded; + for (const auto &kv : ts->getTerrainGroup()->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance || !slot->instance->isLoaded()) + continue; + loaded.insert(packTownPageKey(slot->x, -slot->y)); + } + + /* Drop pages that streamed out. */ + for (auto it = m_streamedPages.begin(); + it != m_streamedPages.end();) { + if (loaded.find(it->first) == loaded.end()) { + for (flecs::entity c : it->second.containers) + if (c.is_alive()) + c.destruct(); + it = m_streamedPages.erase(it); + } else { + ++it; + } + } + + /* Register newly loaded pages (recorded even when they produce + * no buildings, so they are not regenerated every frame). */ + for (uint64_t key : loaded) + if (m_streamedPages.find(key) == m_streamedPages.end()) + m_streamedPages[key] = StreamedTownPage(); + + /* Build budget: one (page, area) generation per frame. */ + for (auto &kv : m_streamedPages) { + StreamedTownPage &sp = kv.second; + if (sp.nextArea >= graph->townAreas.size()) + continue; + buildStreamedPageArea(*graph, kv.first, sp); + ++sp.nextArea; + break; + } +} + +void TownGenSystem::buildStreamedPageArea(const RoadGraph &graph, + uint64_t pageKey, + StreamedTownPage &sp) +{ + TerrainSystem *ts = m_terrainSystem ? + m_terrainSystem : + TerrainSystem::getInstance(); + const long px = (long)(uint32_t)(pageKey >> 32); + const long py = (long)(uint32_t)(pageKey & 0xffffffffu); + const RoadTownArea &area = graph.townAreas[sp.nextArea]; + + float waterY = -FLT_MAX; + m_world.each([&waterY](WaterPhysics &wp) { + waterY = wp.waterSurfaceY; + }); + RoadNetworkGen::HeightSampler sampler = [ts](float x, float z) { + return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z)); + }; + if (!m_streamLayouts) + m_streamLayouts.reset( + new LayoutLib::LayoutLibrary(loadLayoutLibrary())); + + TownGen::TownParams p; + p.district = (TownGen::District)(int)area.district; + p.seed = area.seed; + p.densityScale = area.densityScale; + p.waterLevel = waterY; + + TownGen::PageScope scope; + scope.mapper = makePageMapper(ts); + scope.pageX = px; + scope.pageY = py; + + TownGen::TownResult result = + TownGen::generateTown(graph, p, sampler, + m_streamLayouts.get(), + &graph.townAreas, (int)sp.nextArea, + &scope); + for (const std::string &w : result.warnings) + Ogre::LogManager::getSingleton().logMessage( + "TownGenSystem[" + area.name + " p" + + std::to_string(px) + "_" + std::to_string(py) + + "]: " + w); + + if (result.buildings.empty()) + return; + + const TownGen::DistrictParams &dp = + TownGen::districtParams(p.district); + flecs::entity container = createTownContainer( + "TownGen " + area.name + " p" + std::to_string(px) + "_" + + std::to_string(py), + result, dp, p.seed); + StreamedTownTag tag; + tag.pageX = px; + tag.pageY = py; + container.set(tag); + sp.containers.push_back(container); +} + +void TownGenSystem::clearStreamedPages() +{ + for (auto &kv : m_streamedPages) + for (flecs::entity c : kv.second.containers) + if (c.is_alive()) + c.destruct(); + m_streamedPages.clear(); } flecs::entity @@ -197,22 +578,28 @@ TownGenSystem::createSettlementResources(flecs::entity container, void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid) { - int cellsX = std::max(2, (int)std::lround(b.width / TOWN_CELL_SIZE)); - int cellsZ = std::max(2, (int)std::lround(b.depth / TOWN_CELL_SIZE)); - - grid.width = cellsX; - grid.height = b.floors; - grid.depth = cellsZ; - grid.cellSize = TOWN_CELL_SIZE; - grid.cellHeight = TOWN_CELL_HEIGHT; + /* Phase 4.5 item 2: the building shape comes from the + * evaluated layout plan (TownBuilding::plan); the plan flags + * are bit-identical to CellFlags (static_asserted above), so + * the copy is direct. interiorOnly flags are recorded but not + * generated in exteriorOnly mode. */ + const LayoutLib::BuildingPlan &plan = b.plan; + grid.width = plan.cellsX; + grid.height = plan.floors; + grid.depth = plan.cellsZ; + grid.cellSize = plan.cellSize; + grid.cellHeight = plan.cellHeight; grid.generationMode = "exteriorOnly"; - /* Exterior shell only; the entrance door frame is generated but - * the leaf is not (doorsEnabled=false). The scene-switch door - * config is preset for Phase 8 — flipping doorsEnabled wires the - * interior scene switch. */ - grid.doorsEnabled = false; - grid.doorSceneSwitchPath = "town_interior.json"; + /* Exterior shell only. The entrance gets a real door leaf so the + * doorway reads as a door, but every external door is locked by + * default (prompt "E Locked", never opens) until Phase 8 wires + * the interior scene switch — Phase 8 replaces the lock with a + * per-door sceneSwitchPath/target. The grid uid is derived from + * the address so streamed rebuilds keep stable door IDs in the + * global state store instead of churning random UUIDs. */ + grid.doorsEnabled = true; + grid.gridUid = "town-" + b.address; grid.floorRectName = "floor"; grid.ceilingRectName = "ceiling"; @@ -226,49 +613,28 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, grid.roofSideRectName = "roof"; grid.doorRectName = "door"; - /* Front (street-facing) side is local +Z; the door sits in the - * center cell of the ground floor front. */ - int doorX = cellsX / 2; - - for (int y = 0; y < b.floors; ++y) { - bool top = (y == b.floors - 1); - for (int x = 0; x < cellsX; ++x) { - for (int z = 0; z < cellsZ; ++z) { - Cell &c = grid.getOrCreateCell(x, y, z); - c.setFlag(CellFlags::Floor); - if (top) - c.setFlag(CellFlags::Ceiling); - - bool xMin = (x == 0), xMax = (x == cellsX - 1); - bool zMin = (z == 0), zMax = (z == cellsZ - 1); - bool corner = (xMin || xMax) && (zMin || zMax); - - if (y == 0 && zMax && x == doorX) { - c.setFlag(CellFlags::DoorZPos); - c.setFlag(CellFlags::IntDoorZPos); - if (xMin) - c.setFlag(CellFlags::WallXNeg); - if (xMax) - c.setFlag(CellFlags::WallXPos); + for (int y = 0; y < plan.floors; ++y) + for (int z = 0; z < plan.cellsZ; ++z) + for (int x = 0; x < plan.cellsX; ++x) { + uint64_t flags = plan.at(x, y, z); + if (!flags) continue; - } - - /* Non-corner perimeter cells get - * windows; corners stay plain walls. */ - if (xMin) - c.setFlag(corner ? CellFlags::WallXNeg : - CellFlags::WindowXNeg); - if (xMax) - c.setFlag(corner ? CellFlags::WallXPos : - CellFlags::WindowXPos); - if (zMin) - c.setFlag(corner ? CellFlags::WallZNeg : - CellFlags::WindowZNeg); - if (zMax) - c.setFlag(corner ? CellFlags::WallZPos : - CellFlags::WindowZPos); + Cell &c = grid.getOrCreateCell(x, y, z); + c.flags = flags; } - } + + /* Lock every external doorway: the leaf is there (and blocks + * the doorway physically and on the navmesh) but cannot be + * opened before the interior exists. */ + for (const CellGridSystem::DoorwayInfo &dw : + CellGridSystem::collectDoorways(grid)) { + if (dw.internal) + continue; + CellGridDoorConfig cfg; + cfg.label = "Front door"; + cfg.lockable = true; + cfg.lockedByDefault = true; + grid.doorConfigs[dw.edgeKey] = cfg; } grid.markDirty(); @@ -277,7 +643,7 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, void TownGenSystem::createBuilding(flecs::entity container, flecs::entity material, flecs::entity texture, const TownGen::TownBuilding &b, - const TownGen::TownPoi &poi) + const std::vector &pois) { TerrainSystem *ts = m_terrainSystem ? m_terrainSystem : @@ -296,11 +662,11 @@ void TownGenSystem::createBuilding(flecs::entity container, * node so the shell is CENTERED on TownBuilding::center and the * front face (local +Z) sits at shellD/2 facing the street. * Foundation/stairs/POI children use the same convention. */ - float shellW = grid.width * TOWN_CELL_SIZE; - float shellD = grid.depth * TOWN_CELL_SIZE; - Ogre::Vector3 shellCenter((grid.width - 1) * TOWN_CELL_SIZE * 0.5f, + float shellW = grid.width * grid.cellSize; + float shellD = grid.depth * grid.cellSize; + Ogre::Vector3 shellCenter((grid.width - 1) * grid.cellSize * 0.5f, 0.0f, - (grid.depth - 1) * TOWN_CELL_SIZE * 0.5f); + (grid.depth - 1) * grid.cellSize * 0.5f); Ogre::Quaternion yaw(Ogre::Radian(b.yaw), Ogre::Vector3::UNIT_Y); /* TownGen works in render space (the height sampler is @@ -324,41 +690,91 @@ void TownGenSystem::createBuilding(flecs::entity container, building.set(grid); - /* Flat roof over the whole footprint — buildRoofs() only - * processes RoofComponent children of the grid entity. posY at - * one cell above the top floor puts the slab on top of the - * walls; its trim covers the top-floor ceiling seam. */ - flecs::entity roof = m_world.entity(); - roof.child_of(building); - roof.set(EntityNameComponent("roof")); - roof.add(); - RoofComponent rc; - rc.type = RoofComponent::Flat; - rc.posX = 0; - rc.posY = b.floors; - rc.posZ = 0; - rc.sizeX = grid.width; - rc.sizeZ = grid.depth; - roof.set(rc); + /* Roofs from the layout plan; when the plan defines none (the + * district defaults don't), fall back to the edge-side roofType + * override or a flat slab over the whole footprint. + * buildRoofs() only processes RoofComponent children of the + * grid entity; posY at one cell above the top floor puts the + * slab on top of the walls. */ + if (!b.plan.roofs.empty()) { + int ri = 0; + for (const LayoutLib::PlanRoof &pr : b.plan.roofs) { + flecs::entity roof = m_world.entity(); + roof.child_of(building); + roof.set(EntityNameComponent( + "roof " + std::to_string(ri++))); + roof.add(); + RoofComponent rc; + rc.type = (RoofComponent::Type)pr.type; + rc.posX = pr.posX; + rc.posY = pr.posY; + rc.posZ = pr.posZ; + rc.sizeX = pr.sizeX; + rc.sizeZ = pr.sizeZ; + rc.baseHeight = pr.baseHeight; + rc.maxHeight = pr.maxHeight; + roof.set(rc); + } + } else if (planFloorSolid(b.plan, b.plan.floors - 1)) { + flecs::entity roof = m_world.entity(); + roof.child_of(building); + roof.set(EntityNameComponent("roof")); + roof.add(); + RoofComponent rc; + /* Phase 4.5: the lot's roofType override (-1 = the flat + * default). */ + rc.type = b.roofType >= 0 ? (RoofComponent::Type)b.roofType : + RoofComponent::Flat; + rc.posX = 0; + rc.posY = b.floors; + rc.posZ = 0; + rc.sizeX = grid.width; + rc.sizeZ = grid.depth; + roof.set(rc); + } else { + /* Cut (L/U-shaped) footprint: one roof strip per row + * run of the top floor, so nothing overhangs the cut. */ + int ri = 0; + for (const std::array &run : + planFloorRuns(b.plan, b.plan.floors - 1)) { + flecs::entity roof = m_world.entity(); + roof.child_of(building); + roof.set(EntityNameComponent( + "roof " + std::to_string(ri++))); + roof.add(); + RoofComponent rc; + rc.type = b.roofType >= 0 ? + (RoofComponent::Type)b.roofType : + RoofComponent::Flat; + rc.posX = run[1]; + rc.posY = b.floors; + rc.posZ = run[0]; + rc.sizeX = run[2] - run[1] + 1; + rc.sizeZ = 1; + roof.set(rc); + } + } createFoundation(building, b, grid, material, texture); if (b.stairs) createStairs(building, b, grid, material, texture); - /* Curb pickup POI (name-convention marker entity), parented to - * the building with a local transform. */ - flecs::entity poiEnt = m_world.entity(); - poiEnt.child_of(building); - poiEnt.set(EntityNameComponent(poi.name)); - poiEnt.add(); - TransformComponent poiXform; - poiXform.node = xform.node->createChildSceneNode(); - poiXform.position = - xform.rotation.Inverse() * (poi.position - renderPos); - poiXform.rotation = Ogre::Quaternion::IDENTITY; - poiXform.scale = Ogre::Vector3::UNIT_SCALE; - poiXform.applyToNode(); - poiEnt.set(poiXform); + /* POI marker entities (pickup, parking pads), parented to the + * building with local transforms. */ + for (const TownGen::TownPoi *poi : pois) { + flecs::entity poiEnt = m_world.entity(); + poiEnt.child_of(building); + poiEnt.set(EntityNameComponent(poi->name)); + poiEnt.add(); + TransformComponent poiXform; + poiXform.node = xform.node->createChildSceneNode(); + poiXform.position = + xform.rotation.Inverse() * (poi->position - renderPos); + poiXform.rotation = Ogre::Quaternion::IDENTITY; + poiXform.scale = Ogre::Vector3::UNIT_SCALE; + poiXform.applyToNode(); + poiEnt.set(poiXform); + } } void TownGenSystem::createFoundation(flecs::entity building, @@ -372,11 +788,11 @@ void TownGenSystem::createFoundation(flecs::entity building, /* The building node sits half a shell off the intended * footprint center (cells start at the node, see * createBuilding) — the block centers on the shell. */ - float shellW = grid.width * TOWN_CELL_SIZE; - float shellD = grid.depth * TOWN_CELL_SIZE; - Ogre::Vector3 shellCenter((grid.width - 1) * TOWN_CELL_SIZE * 0.5f, + float shellW = grid.width * grid.cellSize; + float shellD = grid.depth * grid.cellSize; + Ogre::Vector3 shellCenter((grid.width - 1) * grid.cellSize * 0.5f, 0.0f, - (grid.depth - 1) * TOWN_CELL_SIZE * 0.5f); + (grid.depth - 1) * grid.cellSize * 0.5f); flecs::entity foundation = m_world.entity(); foundation.child_of(building); @@ -405,32 +821,61 @@ void TownGenSystem::createFoundation(flecs::entity building, rb.bodyType = RigidBodyComponent::BodyType::Static; foundation.set(rb); - flecs::entity box = m_world.entity(); - box.child_of(foundation); - box.set(EntityNameComponent("block")); - TransformComponent boxXform; - boxXform.node = xform.node->createChildSceneNode(); - boxXform.position = - shellCenter + Ogre::Vector3(0.0f, - -foundationH * 0.5f + 0.05f, 0.0f); - boxXform.rotation = Ogre::Quaternion::IDENTITY; - boxXform.scale = Ogre::Vector3::UNIT_SCALE; - boxXform.applyToNode(); - box.set(boxXform); + /* The block(s) follow the ground-floor footprint: a single box + * for a solid floor (bit-identical default towns), one box per + * row run for cut (L/U-shaped) footprints so the foundation + * does not stick out under the cut. */ + bool solid = planFloorSolid(b.plan, 0); + std::vector > runs; /* z, x0, x1; z<0 = bbox */ + if (solid) + runs.push_back({ -1, 0, grid.width - 1 }); + else + runs = planFloorRuns(b.plan, 0); - PrimitiveComponent prim; - prim.type = PrimitiveComponent::Type::Box; - prim.boxSizeX = shellW + 0.15f; - prim.boxSizeY = foundationH; - prim.boxSizeZ = shellD + 0.15f; - box.set(prim); + int bi = 0; + for (const std::array &run : runs) { + float cx, cz, sx, sz; + if (run[0] < 0) { + cx = shellCenter.x; + cz = shellCenter.z; + sx = shellW + 2.0f * FOUNDATION_OVERHANG; + sz = shellD + 2.0f * FOUNDATION_OVERHANG; + } else { + cx = (run[1] + run[2]) * 0.5f * grid.cellSize; + cz = run[0] * grid.cellSize; + sx = (run[2] - run[1] + 1) * grid.cellSize + + 2.0f * FOUNDATION_OVERHANG; + sz = grid.cellSize + 2.0f * FOUNDATION_OVERHANG; + } - PhysicsColliderComponent collider; - collider.shapeType = PhysicsColliderComponent::ShapeType::Box; - collider.parameters = Ogre::Vector3((shellW + 0.15f) * 0.5f, - foundationH * 0.5f, - (shellD + 0.15f) * 0.5f); - box.set(collider); + flecs::entity box = m_world.entity(); + box.child_of(foundation); + box.set(EntityNameComponent( + solid ? "block" : + "block " + std::to_string(bi++))); + TransformComponent boxXform; + boxXform.node = xform.node->createChildSceneNode(); + boxXform.position = + Ogre::Vector3(cx, -foundationH * 0.5f + 0.05f, cz); + boxXform.rotation = Ogre::Quaternion::IDENTITY; + boxXform.scale = Ogre::Vector3::UNIT_SCALE; + boxXform.applyToNode(); + box.set(boxXform); + + PrimitiveComponent prim; + prim.type = PrimitiveComponent::Type::Box; + prim.boxSizeX = sx; + prim.boxSizeY = foundationH; + prim.boxSizeZ = sz; + box.set(prim); + + PhysicsColliderComponent collider; + collider.shapeType = PhysicsColliderComponent::ShapeType::Box; + collider.parameters = + Ogre::Vector3(sx * 0.5f, foundationH * 0.5f, + sz * 0.5f); + box.set(collider); + } } void TownGenSystem::createStairs(flecs::entity building, @@ -443,12 +888,16 @@ void TownGenSystem::createStairs(flecs::entity building, return; float rise = b.stairsDrop / (float)steps; - /* Shell convention (see createBuilding): cells start at the - * node, walls extend cellSize/2 past the outer cell centers. - * The door sits in the center cell of the front (Z+) face; - * the steps descend from there. */ - float doorX = (grid.width / 2) * TOWN_CELL_SIZE; - float frontZ = (grid.depth - 0.5f) * TOWN_CELL_SIZE; + /* Shell convention (see createBuilding): cell CENTERS sit at + * x*cellSize, walls extend cellSize/2 past the outer cell + * centers. The door frame is centered in the plan's front door + * cell (TownGen scans it, so shaped buildings keep the stairs at + * the real door), so the stairs center on the same cell center; + * the shell center is the fallback for doorless layouts. */ + float doorX = b.doorCellX >= 0 ? + b.doorCellX * grid.cellSize : + (grid.width - 1) * grid.cellSize * 0.5f; + float frontZ = (grid.depth - 0.5f) * grid.cellSize; flecs::entity stairs = m_world.entity(); stairs.child_of(building); @@ -476,9 +925,41 @@ void TownGenSystem::createStairs(flecs::entity building, rb.bodyType = RigidBodyComponent::BodyType::Static; stairs.set(rb); - /* Solid staircase descending from the door (local +Z, front - * face at grid.depth * cellSize) to the ground at -stairsDrop; - * the ground itself is the last step. */ + /* Landing at floor level in front of the door (solid down to the + * ground like the steps), then the staircase descending from its + * far edge; the ground itself is the last step. */ + { + flecs::entity landing = m_world.entity(); + landing.child_of(stairs); + landing.set( + EntityNameComponent("landing")); + + TransformComponent landingXform; + landingXform.node = xform.node->createChildSceneNode(); + landingXform.position = + Ogre::Vector3(doorX, -b.stairsDrop * 0.5f, + frontZ + STAIR_LANDING * 0.5f); + landingXform.rotation = Ogre::Quaternion::IDENTITY; + landingXform.scale = Ogre::Vector3::UNIT_SCALE; + landingXform.applyToNode(); + landing.set(landingXform); + + PrimitiveComponent prim; + prim.type = PrimitiveComponent::Type::Box; + prim.boxSizeX = STAIR_WIDTH; + prim.boxSizeY = b.stairsDrop; + prim.boxSizeZ = STAIR_LANDING; + landing.set(prim); + + PhysicsColliderComponent collider; + collider.shapeType = PhysicsColliderComponent::ShapeType::Box; + collider.parameters = + Ogre::Vector3(STAIR_WIDTH * 0.5f, + b.stairsDrop * 0.5f, + STAIR_LANDING * 0.5f); + landing.set(collider); + } + for (int i = 0; i < steps - 1; ++i) { float surfaceY = -((float)i + 1.0f) * rise; float height = surfaceY - (-b.stairsDrop); @@ -494,7 +975,7 @@ void TownGenSystem::createStairs(flecs::entity building, stepXform.node = xform.node->createChildSceneNode(); stepXform.position = Ogre::Vector3( doorX, (-b.stairsDrop + surfaceY) * 0.5f, - frontZ + ((float)i + 0.5f) * STAIR_TREAD); + frontZ + STAIR_LANDING + ((float)i + 0.5f) * STAIR_TREAD); stepXform.rotation = Ogre::Quaternion::IDENTITY; stepXform.scale = Ogre::Vector3::UNIT_SCALE; stepXform.applyToNode(); diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index 6e7f24a..b4699bb 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -1,10 +1,15 @@ #pragma once +#include +#include +#include #include +#include #include #include #include "../roadlib/TownGen.hpp" +#include "../layoutlib/LayoutEval.hpp" #include "../components/CellGrid.hpp" class TerrainSystem; @@ -44,6 +49,25 @@ public: * set). Clears the previously generated town first. */ int generate(const TownGen::TownParams ¶ms, std::string *error); + /** Generate every town area of the graph (phase 4.5 item 4): + * one "TownGen " container per RoadTownArea, each with + * the area's district/seed/densityScale and only the lots whose + * centroid it owns (first-match-wins over overlapping areas). + * Returns the total number of buildings (0 on failure, @p error + * set — also when the graph defines no town areas). */ + int generateAreas(std::string *error); + + /** Per-frame driver (phase 4.5 item 5, called from + * TerrainSystem::update). Game mode + streaming terrain + town + * areas in the graph: regenerates each loaded page's buildings + * deterministically (one (page, area) generation per frame) into + * "TownGen p_" containers tagged StreamedTownTag + * (excluded from scene serialization — the content is a pure + * function of the persisted road graph), destroying them when + * the page streams out. Inactive otherwise (editor builds stay + * always-loaded via generate/generateAreas). */ + void update(); + /** Destroy every generated town (all "TownGen " containers). */ int clear(); @@ -51,12 +75,31 @@ public: int countTowns() const; private: + /** Streamed-page bookkeeping for update(): nextArea walks the + * graph's town areas one per frame; containers holds the created + * "TownGen p_" entities. */ + struct StreamedTownPage { + size_t nextArea = 0; + std::vector containers; + }; + flecs::entity createSettlementResources(flecs::entity container, const TownGen::DistrictParams &dp, uint32_t seed); + /** Shared tail of generate()/generateAreas(): container entity + + * settlement resources + one building entity per TownBuilding. + * Returns the container. */ + flecs::entity createTownContainer(const std::string &containerName, + const TownGen::TownResult &result, + const TownGen::DistrictParams &dp, + uint32_t seed); + /** One (page, area) step of the streaming driver. */ + void buildStreamedPageArea(const RoadGraph &graph, uint64_t pageKey, + StreamedTownPage &sp); + void clearStreamedPages(); void createBuilding(flecs::entity container, flecs::entity material, flecs::entity texture, const TownGen::TownBuilding &b, - const TownGen::TownPoi &poi); + const std::vector &pois); void createFoundation(flecs::entity building, const TownGen::TownBuilding &b, const CellGridComponent &grid, @@ -66,8 +109,16 @@ private: flecs::entity material, flecs::entity texture); static void fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid); + /** Layout library loader: built-ins + town_layouts.txt from the + * current working directory when present (phase 4.5 item 2). */ + static LayoutLib::LayoutLibrary loadLayoutLibrary(); flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; TerrainSystem *m_terrainSystem = nullptr; + + /* Streaming driver state (item 5). */ + std::map m_streamedPages; + flecs::entity_t m_streamTerrainId = 0; + std::unique_ptr m_streamLayouts; }; diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index f92c567..23270d6 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -33,6 +33,7 @@ #include #include "components/RoadGraph.hpp" +#include "layoutlib/LayoutEval.hpp" #include "roadlib/RoadGeometryLib.hpp" #include "roadlib/RoadNetworkGen.hpp" #include "roadlib/TownGen.hpp" @@ -45,6 +46,7 @@ #include #include #include +#include #include #include @@ -3647,12 +3649,847 @@ static bool runTownGenProbe() } } + /* Phase 4.5: per-edge-side lot params. A single 100 m edge grows + * one fallback square lot per side; the edge runs +X, so graph + * "left" (UNIT_Y x dir) is -Z. */ + auto buildSingleEdge = [&](RoadGraph &graph) -> RoadEdge & { + int a = graph.addNode(Vector3(0, 0, 0), 0.0f); + int b = graph.addNode(Vector3(100, 0, 0), 0.0f); + int ei = graph.addEdge(a, b); + return graph.edges[(size_t)ei]; + }; + auto sideLotOk = [&](const TownGen::TownResult &res) { + return res.buildings.size() == 2 && + res.buildings[0].center.z * res.buildings[1].center.z < + 0.0f; + }; + { + RoadGraph graph; + buildSingleEdge(graph); + TownGen::TownResult base = + TownGen::generateTown(graph, cc, flat); + if (!sideLotOk(base)) { + printf(" *** lotparams base: %zu buildings, want 2 " + "on opposite sides ***\n", base.buildings.size()); + ok = false; + } + + /* Buildable=Off on the left (-Z) side skips that lot + * before any RNG draws. */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotLeft.buildable = RoadLotBuildable::Off; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + if (res.buildings.size() != 1 || + res.skippedNotBuildable != 1 || + res.buildings[0].center.z < 0.0f) { + printf(" *** lotparams buildable-off: %zu " + "buildings %d skipped z=%.1f, want 1/1/+Z " + "***\n", + res.buildings.size(), + res.skippedNotBuildable, + res.buildings.empty() ? + 0.0f : + res.buildings[0].center.z); + ok = false; + } + } + + if (sideLotOk(base)) { + /* Floor range pinned on the right (+Z) side. */ + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.minFloors = 7; + e.lotRight.maxFloors = 7; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + const TownGen::TownBuilding *right = nullptr; + for (const TownGen::TownBuilding &b : + res.buildings) + if (b.center.z > 0.0f) + right = &b; + if (!right || right->floors != 7) { + printf(" *** lotparams floors: right floors %d, " + "want 7 ***\n", + right ? right->floors : -1); + ok = false; + } + + /* District override on the right side: Village + * forces single-storey. */ + RoadGraph g2; + RoadEdge &e2 = buildSingleEdge(g2); + e2.lotRight.district = RoadLotDistrict::Village; + TownGen::TownResult res2 = + TownGen::generateTown(g2, cc, flat); + const TownGen::TownBuilding *right2 = nullptr; + for (const TownGen::TownBuilding &b : + res2.buildings) + if (b.center.z > 0.0f) + right2 = &b; + if (!right2 || right2->floors != 1) { + printf(" *** lotparams district: right floors " + "%d, want 1 (village) ***\n", + right2 ? right2->floors : -1); + ok = false; + } + + /* Roof type override lands on the building. */ + RoadGraph g3; + RoadEdge &e3 = buildSingleEdge(g3); + e3.lotLeft.roofType = RoadLotRoof::Cone; + TownGen::TownResult res3 = + TownGen::generateTown(g3, cc, flat); + const TownGen::TownBuilding *left3 = nullptr; + for (const TownGen::TownBuilding &b : + res3.buildings) + if (b.center.z < 0.0f) + left3 = &b; + if (!left3 || left3->roofType != (int)RoadLotRoof::Cone) { + printf(" *** lotparams roof: left roofType %d, " + "want %d (cone) ***\n", + left3 ? left3->roofType : -99, + (int)RoadLotRoof::Cone); + ok = false; + } + + /* Setback override: the right building's front + * moves out by exactly the setback delta + * (city-center default 2 -> 6 = +4 m on z). */ + RoadGraph g4; + RoadEdge &e4 = buildSingleEdge(g4); + e4.lotRight.setback = 6.0f; + TownGen::TownResult res4 = + TownGen::generateTown(g4, cc, flat); + const TownGen::TownBuilding *rightBase = nullptr; + for (const TownGen::TownBuilding &b : + base.buildings) + if (b.center.z > 0.0f) + rightBase = &b; + const TownGen::TownBuilding *right4 = nullptr; + for (const TownGen::TownBuilding &b : + res4.buildings) + if (b.center.z > 0.0f) + right4 = &b; + if (!rightBase || !right4 || + std::fabs(right4->center.z - rightBase->center.z - + 4.0f) > 0.01f) { + printf(" *** lotparams setback: dz %.2f, want " + "4.0 ***\n", + (rightBase && right4) ? + right4->center.z - + rightBase->center.z : + -1.0f); + ok = false; + } + } + } + + /* Phase 4.5 item 2: layout DSL (layoutlib). Direct evaluator + * probes plus integration through generateTown. */ + { + using namespace LayoutLib; + std::mt19937 rngDsl(1); + std::string derr; + + LayoutBindings lb; + lb.lotW = 60.0f; + lb.lotD = 60.0f; + lb.availW = 55.0f; + lb.availD = 55.0f; + lb.minFp = 8.0f; + lb.maxFp = 12.0f; + lb.minFloors = 1; + lb.maxFloors = 3; + + /* Lot-size conditional: the branch follows lot-w. */ + const char *condSrc = + "(if (>= lot-w 50) (shell 20 20 :floors 2)" + " (shell 8 8 :floors 1))"; + BuildingPlan pWide; + LayoutEval evWide(lb, &rngDsl, nullptr); + if (!evWide.evaluate(condSrc, pWide, &derr) || + pWide.floors != 2 || pWide.cellsX != 10) { + printf(" *** dsl conditional wide: floors %d cellsX %d " + "err '%s' ***\n", + pWide.floors, pWide.cellsX, derr.c_str()); + ok = false; + } + LayoutBindings lbNarrow = lb; + lbNarrow.lotW = 30.0f; + lbNarrow.lotD = 30.0f; + lbNarrow.availW = 25.0f; + lbNarrow.availD = 25.0f; + BuildingPlan pNarrow; + LayoutEval evNarrow(lbNarrow, &rngDsl, nullptr); + if (!evNarrow.evaluate(condSrc, pNarrow, &derr) || + pNarrow.floors != 1 || pNarrow.cellsX != 4) { + printf(" *** dsl conditional narrow: floors %d cellsX " + "%d err '%s' ***\n", + pNarrow.floors, pNarrow.cellsX, derr.c_str()); + ok = false; + } + + /* The shell clamps to the setback-adjusted rect. */ + BuildingPlan pClamp; + LayoutEval evClamp(lbNarrow, &rngDsl, nullptr); + if (!evClamp.evaluate("(shell 80 80)", pClamp, &derr) || + pClamp.cellsX != (int)(25.0f / LAYOUT_CELL_SIZE)) { + printf(" *** dsl clamp: cellsX %d, want %d ***\n", + pClamp.cellsX, (int)(25.0f / LAYOUT_CELL_SIZE)); + ok = false; + } + + /* Parking rect (lot frame). */ + BuildingPlan pPark; + LayoutEval evPark(lb, &rngDsl, nullptr); + if (!evPark.evaluate("(shell 10 10)" + "(parking (rect-front-right 6 3))", + pPark, &derr) || + pPark.parkings.size() != 1 || + std::fabs(pPark.parkings[0].x - 54.0f) > 0.001f || + std::fabs(pPark.parkings[0].z) > 0.001f || + std::fabs(pPark.parkings[0].w - 6.0f) > 0.001f) { + printf(" *** dsl parking: %zu pads err '%s' ***\n", + pPark.parkings.size(), derr.c_str()); + ok = false; + } + + /* Rooms + interior door: 12x8 m shell -> 6x4 cells; room a + * x[0,2], room b x[3,5], shared wall at x 2/3. */ + BuildingPlan pRooms; + LayoutEval evRooms(lb, &rngDsl, nullptr); + if (!evRooms.evaluate("(shell 12 8)" + "(floor 0 (room a (rect 0 0 0.5 1))" + " (room b (rect 0.5 0 1 1))" + " (idoor a b))", + pRooms, &derr)) { + printf(" *** dsl rooms: eval failed '%s' ***\n", + derr.c_str()); + ok = false; + } else { + uint64_t doorCell = pRooms.at(2, 0, 1); + uint64_t wallCell = pRooms.at(2, 0, 0); + if (!(doorCell & PlanFlags::IntDoorXPos) || + (doorCell & PlanFlags::IntWallXPos) || + !(wallCell & PlanFlags::IntWallXPos)) { + printf(" *** dsl rooms: door cell flags " + "%llx wall cell %llx ***\n", + (unsigned long long)doorCell, + (unsigned long long)wallCell); + ok = false; + } + } + + /* cut: an L-shape drops the corner cells and the cells + * facing the cut gain exterior walls; roof rects map to + * cells. */ + BuildingPlan pCut; + LayoutEval evCut(lb, &rngDsl, nullptr); + if (!evCut.evaluate("(shell 12 12)" + "(cut 6 6 12 12)" + "(roof flat 0 0 12 6)" + "(roof flat 0 6 6 12)", + pCut, &derr)) { + printf(" *** dsl cut: eval failed '%s' ***\n", + derr.c_str()); + ok = false; + } else { + uint64_t gone = pCut.at(4, 0, 4); + uint64_t faceX = pCut.at(2, 0, 4); + uint64_t faceZ = pCut.at(4, 0, 2); + if (gone != 0 || + !(faceX & PlanFlags::WallXPos) || + !(faceZ & PlanFlags::WallZPos) || + pCut.roofs.size() != 2 || + pCut.roofs[0].sizeX != 6 || + pCut.roofs[0].sizeZ != 3 || + pCut.roofs[1].posZ != 3 || + pCut.roofs[1].sizeX != 3 || + pCut.roofs[1].sizeZ != 3) { + printf(" *** dsl cut: gone %llx faceX %llx " + "faceZ %llx roofs %zu ***\n", + (unsigned long long)gone, + (unsigned long long)faceX, + (unsigned long long)faceZ, + pCut.roofs.size()); + ok = false; + } + } + + /* cut safety: a door on a cut cell is an error (no + * floating doorways). */ + BuildingPlan pCutDoor; + LayoutEval evCutDoor(lb, &rngDsl, nullptr); + if (evCutDoor.evaluate("(shell 12 12)(cut 6 6 12 12)" + "(floor 0 (door front center))", + pCutDoor, &derr)) { + printf(" *** dsl cut door: accepted a door on a " + "cut cell ***\n"); + ok = false; + } + + /* Fallback reuse: TownGen retries a failed layout into + * the SAME BuildingPlan with the district default — the + * partial state of the failed attempt (the shell was + * defined before the door failed) must not leak into + * the retry ("shell already defined" regression). The + * sources mirror the demo scene's Edge 0 lotLeft case: + * 16 m L-cut with the door at front center (in the + * notch), then the door moved to the surviving half. */ + BuildingPlan pRetry; + LayoutEval evRetry(lb, &rngDsl, nullptr); + const char *badDoorL = + "(let ((w 16) (d 16))\n" + "(shell w d :floors 1)\n" + "(cut (/ w 2) (/ d 2) w d)\n" + "(floor 0 (door front center))\n" + "(windows all)\n" + "(roof normal))\n"; + if (evRetry.evaluate(badDoorL, pRetry, &derr) || + !pRetry.valid()) { + printf(" *** dsl retry setup: accepted or no shell " + "('%s') ***\n", derr.c_str()); + ok = false; + } + LayoutEval evFallback(lb, &rngDsl, nullptr); + BuildingPlan pFallback; + if (!evFallback.evaluate( + LayoutLibrary::defaultTemplateSource(), + pRetry /* reused on purpose */, &derr) || + pRetry.cellsX <= 0) { + printf(" *** dsl retry: fallback on reused plan " + "failed '%s' ***\n", derr.c_str()); + ok = false; + } + (void)pFallback; + LayoutEval evGoodL(lb, &rngDsl, nullptr); + BuildingPlan pGoodL; + if (!evGoodL.evaluate( + "(let ((w 16) (d 16))\n" + "(shell w d :floors 1)\n" + "(cut (/ w 2) (/ d 2) w d)\n" + "(floor 0 (door front -4)\n" + " (windows all :spacing 4))\n" + "(roof normal 0 0 w (/ d 2))\n" + "(roof normal 0 (/ d 2) (/ w 2) d))\n", + pGoodL, &derr) || + pGoodL.roofs.size() != 2 || + !(pGoodL.at(2, 0, pGoodL.cellsZ - 1) & + PlanFlags::DoorZPos)) { + printf(" *** dsl L 16m: eval failed '%s' roofs %zu " + "***\n", derr.c_str(), pGoodL.roofs.size()); + ok = false; + } + + /* cell-size: the default template carries the default + * grid metrics (2 m cells / 4 m floors); the verb + * overrides them before the shell and rescales the + * meter->cell mapping; after the shell it is an + * error. */ + BuildingPlan pDefMetrics; + LayoutEval evDefMetrics(lb, &rngDsl, nullptr); + if (!evDefMetrics.evaluate( + LayoutLibrary::defaultTemplateSource(), + pDefMetrics, &derr) || + pDefMetrics.cellSize != LAYOUT_CELL_SIZE || + pDefMetrics.cellHeight != LAYOUT_CELL_HEIGHT || + LAYOUT_CELL_SIZE != 2.0f || + LAYOUT_CELL_HEIGHT != 4.0f) { + printf(" *** dsl default metrics: %.2f/%.2f err " + "'%s' ***\n", pDefMetrics.cellSize, + pDefMetrics.cellHeight, derr.c_str()); + ok = false; + } + BuildingPlan pCs; + LayoutEval evCs(lb, &rngDsl, nullptr); + if (!evCs.evaluate("(cell-size 2.5 3)(shell 10 10)" + "(cut 5 5 10 10)", + pCs, &derr) || + pCs.cellSize != 2.5f || pCs.cellHeight != 3.0f || + pCs.cellsX != 4 || pCs.cellsZ != 4 || + pCs.at(3, 0, 3) != 0 || + !(pCs.at(1, 0, 1) & PlanFlags::Floor)) { + printf(" *** dsl cell-size: %.2f/%.2f %dx%d err " + "'%s' ***\n", pCs.cellSize, pCs.cellHeight, + pCs.cellsX, pCs.cellsZ, derr.c_str()); + ok = false; + } + BuildingPlan pCsLate; + LayoutEval evCsLate(lb, &rngDsl, nullptr); + if (evCsLate.evaluate("(shell 10 10)(cell-size 4 4)", + pCsLate, &derr)) { + printf(" *** dsl cell-size: accepted after shell " + "***\n"); + ok = false; + } + + /* Name resolution: library first, then the built-ins. */ + LayoutLibrary lib; + if (!lib.loadSource("(layout my-house (shell 10 8 :floors 3))", + &derr)) { + printf(" *** dsl library load: '%s' ***\n", + derr.c_str()); + ok = false; + } + BuildingPlan pNamed; + LayoutEval evNamed(lb, &rngDsl, &lib); + if (!evNamed.evaluate("my-house", pNamed, &derr) || + pNamed.cellsX != 5 || pNamed.cellsZ != 4 || + pNamed.floors != 3) { + printf(" *** dsl named: %dx%dx%d err '%s' ***\n", + pNamed.cellsX, pNamed.cellsZ, pNamed.floors, + derr.c_str()); + ok = false; + } + BuildingPlan pBuiltin; + LayoutEval evBuiltin(lb, &rngDsl, nullptr); + if (!evBuiltin.evaluate("village-house", pBuiltin, &derr) || + !pBuiltin.valid()) { + printf(" *** dsl builtin: err '%s' ***\n", + derr.c_str()); + ok = false; + } + + /* Editor starting aids: the insert-template source and + * every help-window example must evaluate cleanly. */ + { + BuildingPlan pTpl; + std::mt19937 rngTpl(3); + LayoutEval evTpl(lb, &rngTpl, nullptr); + if (!evTpl.evaluate( + LayoutLibrary::defaultTemplateSource(), + pTpl, &derr) || + !pTpl.valid()) { + printf(" *** dsl template: err '%s' ***\n", + derr.c_str()); + ok = false; + } + for (const LayoutLibrary::LayoutExample &ex : + LayoutLibrary::examples()) { + BuildingPlan pEx; + std::mt19937 rngEx(5); + LayoutEval evEx(lb, &rngEx, nullptr); + if (!evEx.evaluate(ex.source, pEx, &derr) || + !pEx.valid()) { + printf(" *** dsl example '%s': err " + "'%s' ***\n", + ex.title, derr.c_str()); + ok = false; + } + } + } + + /* Errors: unbalanced source, unknown name, double shell. */ + BuildingPlan pBad; + LayoutEval evBad(lb, &rngDsl, nullptr); + if (evBad.evaluate("(shell 10", pBad, &derr) || + evBad.evaluate("no-such-layout", pBad, &derr) || + evBad.evaluate("(shell 10 8)(shell 4 4)", pBad, &derr)) { + printf(" *** dsl errors: bad sources accepted ***\n"); + ok = false; + } + + /* Integration: an inline edge-side layout drives the + * building (10x8 m shell, 3 floors, cone roof). */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.layout = + "(shell 10 8 :floors 3)" + "(floor 0 (door front center))" + "(roof cone :height 2)"; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + const TownGen::TownBuilding *right = nullptr; + for (const TownGen::TownBuilding &b : res.buildings) + if (b.center.z > 0.0f) + right = &b; + if (!right || + std::fabs(right->width - 10.0f) > 0.001f || + std::fabs(right->depth - 8.0f) > 0.001f || + right->floors != 3 || + right->plan.roofs.size() != 1 || + right->plan.roofs[0].type != 3 || + std::fabs(right->plan.roofs[0].maxHeight - 2.0f) > + 0.001f) { + printf(" *** dsl integration: building %.1fx%.1f " + "%d floors %zu roofs err ***\n", + right ? right->width : -1.0f, + right ? right->depth : -1.0f, + right ? right->floors : -1, + right ? right->plan.roofs.size() : 0); + ok = false; + } else if (!(right->plan.at(2, 0, 3) & + PlanFlags::DoorZPos)) { + printf(" *** dsl integration: no front door at " + "(2,0,3) ***\n"); + ok = false; + } + } + + /* Fallback: a broken layout warns and falls back to the + * district default (no lot lost). */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.layout = "(shell 10"; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + const TownGen::TownBuilding *right = nullptr; + for (const TownGen::TownBuilding &b : res.buildings) + if (b.center.z > 0.0f) + right = &b; + if (!right || res.warnings.empty() || + res.skippedLayoutError != 0 || + right->floors < 4 || right->floors > 8) { + printf(" *** dsl fallback: building %s, %zu " + "warnings, %d skipped, floors %d ***\n", + right ? "ok" : "MISSING", + res.warnings.size(), + res.skippedLayoutError, + right ? right->floors : -1); + ok = false; + } + } + + /* Phase 4.5 item 3: lot splitting along the frontage + * (edge-side maxFrontage). The 100 m fallback squares + * split into 25 m strips. */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.maxFrontage = 25.0f; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + std::vector rightSide; + int leftCount = 0; + for (const TownGen::TownBuilding &b : res.buildings) { + if (b.center.z > 0.0f) + rightSide.push_back(&b); + else + ++leftCount; + } + if (rightSide.size() != 4 || leftCount != 1) { + printf(" *** split: %zu right %d left, want " + "4/1 ***\n", + rightSide.size(), leftCount); + ok = false; + } else { + /* Letter suffixes in frontage order, one + * shared base address, strips capped at + * 25 - 2*setback wide. */ + for (size_t i = 0; i < rightSide.size(); ++i) { + const TownGen::TownBuilding *b = + rightSide[i]; + std::string want = + "Lot 1" + std::string( + 1, (char)('A' + (int)i)); + if (b->address != want || + b->width > 21.001f) { + printf(" *** split: strip %zu " + "address '%s' width %.2f, " + "want '%s' <= 21 ***\n", + i, b->address.c_str(), + b->width, want.c_str()); + ok = false; + } + } + } + /* POIs follow the strips (one pickup each). */ + int pickups = 0; + for (const TownGen::TownPoi &poi : res.pois) + if (poi.name.find(" pickup") != + std::string::npos) + ++pickups; + if (pickups != (int)res.buildings.size()) { + printf(" *** split: %d pickups for %zu " + "buildings ***\n", + pickups, res.buildings.size()); + ok = false; + } + /* Deterministic under the seed. */ + TownGen::TownResult res2 = + TownGen::generateTown(g, cc, flat); + if (res2.buildings.size() != res.buildings.size()) { + printf(" *** split determinism: %zu vs %zu " + "***\n", + res2.buildings.size(), + res.buildings.size()); + ok = false; + } else { + for (size_t i = 0; i < res.buildings.size(); + ++i) + if (res2.buildings[i].address != + res.buildings[i].address || + res2.buildings[i].center != + res.buildings[i].center) { + printf(" *** split determinism: " + "building %zu differs ***\n", + i); + ok = false; + break; + } + } + } + + /* Strip count clamps so no strip is narrower than the + * district minimum footprint: 100 m / 15 m -> 7, clamped + * to 6 (city-center minFp 16); those 12.67 m strips + * (post-setback) then all reject as too small. */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.maxFrontage = 15.0f; + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + if (res.skippedTooSmall != 6 || + res.buildings.size() != 1) { + printf(" *** split clamp: %d tooSmall %zu " + "buildings, want 6/1 ***\n", + res.skippedTooSmall, + res.buildings.size()); + ok = false; + } + } + + /* Split siblings on a named street share the street + * number ("Main Street 1A", "1B", ...). */ + { + RoadGraph g; + RoadEdge &e = buildSingleEdge(g); + e.lotRight.maxFrontage = 25.0f; + RoadNetworkGen::nameStreet( + g, "Split Street", + { std::make_pair(e.nodeA, e.nodeB) }); + TownGen::TownResult res = + TownGen::generateTown(g, cc, flat); + std::map suffixNums; + int plain = 0; + for (const TownGen::TownBuilding &b : res.buildings) { + if (b.address.rfind("Split Street ", 0) != 0) { + printf(" *** split street: address '%s' " + "***\n", b.address.c_str()); + ok = false; + continue; + } + std::string rest = + b.address.substr(strlen("Split Street ")); + char last = rest[rest.size() - 1]; + int num = atoi(rest.c_str()); + if (last >= 'A' && last <= 'Z') + suffixNums[last] = num; + else + ++plain; + } + if (suffixNums.size() != 4 || plain != 1 || + suffixNums['A'] != suffixNums['D']) { + printf(" *** split street: %zu suffixed %d " + "plain, nums %d..%d ***\n", + suffixNums.size(), plain, + suffixNums['A'], suffixNums['D']); + ok = false; + } + } + } + + /* Phase 4.5 item 4: town areas scope generation by lot centroid, + * first-match-wins over overlapping areas. */ + { + RoadGraph graph; + buildTown(graph); + /* 12 lots: 4 enclosed (centroids (50,50),(150,50), + * (50,150),(150,150)) + 8 fallback squares centered 50 m + * off each perimeter edge midpoint. */ + RoadTownArea areaA; + areaA.name = "west"; + areaA.center = Vector3(0.0f, 0.0f, 100.0f); + areaA.halfSize = 60.0f; /* x∈[-60,60], z∈[40,160] */ + RoadTownArea areaB; + areaB.name = "east"; + areaB.circular = true; + areaB.center = Vector3(150.0f, 0.0f, 100.0f); + areaB.radius = 120.0f; + graph.townAreas = { areaA, areaB }; + + TownGen::TownResult resA = + TownGen::generateTown(graph, cc, flat, nullptr, + &graph.townAreas, 0); + TownGen::TownResult resB = + TownGen::generateTown(graph, cc, flat, nullptr, + &graph.townAreas, 1); + TownGen::TownResult resA2 = + TownGen::generateTown(graph, cc, flat, nullptr, + &graph.townAreas, 0); + + /* A: left enclosed pair + left fallback pair = 4 lots. + * B's circle overlaps A on the (50,*) enclosed lots but + * loses them first-match-wins: right enclosed pair + + * right fallback pair = 4 lots. The top/bottom fallback + * squares lie outside both areas and build nothing. */ + if (resA.buildings.size() != 4 || resB.buildings.size() != 4) { + printf(" *** town areas: A=%zu B=%zu, want 4/4 ***\n", + resA.buildings.size(), resB.buildings.size()); + ok = false; + } + + /* Deterministic per area. */ + if (resA2.buildings.size() != resA.buildings.size()) { + printf(" *** town areas: determinism count ***\n"); + ok = false; + } else { + for (size_t i = 0; i < resA.buildings.size(); ++i) + if (resA2.buildings[i].lotIndex != + resA.buildings[i].lotIndex) { + printf(" *** town areas: determinism " + "lot %zu ***\n", i); + ok = false; + break; + } + } + + /* First-match-wins: the two area runs own disjoint lot + * sets (the overlap lots belong to A). */ + std::set lotsA, lotsB; + for (const TownGen::TownBuilding &b : resA.buildings) + lotsA.insert(b.lotIndex); + for (const TownGen::TownBuilding &b : resB.buildings) + lotsB.insert(b.lotIndex); + int overlap = 0; + for (int li : lotsB) + if (lotsA.count(li)) + ++overlap; + if (overlap != 0 || lotsA.size() + lotsB.size() != 8) { + printf(" *** town areas: overlap=%d |A|=%zu |B|=%zu, " + "want 0/4/4 ***\n", + overlap, lotsA.size(), lotsB.size()); + ok = false; + } + + /* townAreas json round trip. */ + nlohmann::json tj; + RoadNetworkGen::townAreasToJson(graph, tj); + RoadGraph graph2; + RoadNetworkGen::townAreasFromJson(graph2, tj); + if (graph2.townAreas.size() != 2) { + printf(" *** town areas json: %zu areas, want 2 ***\n", + graph2.townAreas.size()); + ok = false; + } else { + const RoadTownArea &ra = graph2.townAreas[0]; + const RoadTownArea &rb = graph2.townAreas[1]; + if (ra.name != "west" || ra.circular || + ra.center != areaA.center || + ra.halfSize != 60.0f || + rb.name != "east" || !rb.circular || + rb.center != areaB.center || + rb.radius != 120.0f) { + printf(" *** town areas json: fields " + "mismatch ***\n"); + ok = false; + } + } + } + + /* Phase 4.5 item 5: paged generation — per-page RNG streams make + * the all-pages run the exact union of the per-page runs. The + * +500 offset keeps every test page non-negative (negative page + * coordinates are the "all pages" sentinel; physical terrain + * pages are always non-negative). With 100 m pages the 12 lots + * of the 2x2-block town land on 12 distinct pages (centroids at + * -50/50/150/250 -> pages 4/5/6/7). */ + { + RoadGraph graph; + buildTown(graph); + TownGen::PageMapper mapper = + [](float x, float z, long &px, long &py) { + px = (long)std::floor((x + 500.0f) / 100.0f); + py = (long)std::floor((z + 500.0f) / 100.0f); + return true; + }; + TownGen::PageScope all; + all.mapper = mapper; + TownGen::TownResult resAll = + TownGen::generateTown(graph, cc, flat, nullptr, + nullptr, -1, &all); + if (resAll.buildings.size() != 12) { + printf(" *** town pages: all-pages %zu, want 12 " + "***\n", resAll.buildings.size()); + ok = false; + } + std::map perPage; + for (long py = 4; py <= 7; ++py) + for (long px = 4; px <= 7; ++px) { + TownGen::PageScope one; + one.mapper = mapper; + one.pageX = px; + one.pageY = py; + TownGen::TownResult res1 = + TownGen::generateTown(graph, cc, flat, + nullptr, nullptr, + -1, &one); + for (const TownGen::TownBuilding &b : + res1.buildings) { + if (perPage.count(b.lotIndex)) { + printf(" *** town pages: " + "lot %d built twice " + "***\n", b.lotIndex); + ok = false; + } + perPage[b.lotIndex] = b; + } + } + if (perPage.size() != 12) { + printf(" *** town pages: union %zu, want 12 ***\n", + perPage.size()); + ok = false; + } + for (const TownGen::TownBuilding &b : resAll.buildings) { + auto it = perPage.find(b.lotIndex); + if (it == perPage.end()) { + printf(" *** town pages: lot %d missing " + "from the union ***\n", b.lotIndex); + ok = false; + continue; + } + const TownGen::TownBuilding &q = it->second; + if (q.center != b.center || q.yaw != b.yaw || + q.width != b.width || q.depth != b.depth || + q.floors != b.floors || q.address != b.address) { + printf(" *** town pages: lot %d differs " + "between all-pages and per-page ***\n", + b.lotIndex); + ok = false; + } + } + + /* Per-page determinism (page (6,5) holds the (150,50) + * enclosed lot only). */ + TownGen::PageScope one; + one.mapper = mapper; + one.pageX = 6; + one.pageY = 5; + TownGen::TownResult d1 = + TownGen::generateTown(graph, cc, flat, nullptr, + nullptr, -1, &one); + TownGen::TownResult d2 = + TownGen::generateTown(graph, cc, flat, nullptr, + nullptr, -1, &one); + if (d1.buildings.size() != 1 || + d2.buildings.size() != d1.buildings.size() || + d2.buildings[0].address != d1.buildings[0].address || + d2.buildings[0].center != d1.buildings[0].center) { + printf(" *** town pages: determinism page (1,0) " + "***\n"); + ok = false; + } + } + printf(" %s\n", ok ? "passed" : "FAILURES above"); return ok; } - -/* Grid generator: determinism, counts, water/slope rejection, group - * connect/remove, two settlements in one graph. */ static bool runGeneratorProbe() { printf("grid generator probe\n"); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 73ef266..43cabcf 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -9,6 +9,7 @@ #include "../systems/TerrainSystem.hpp" #include "../systems/RoadSystem.hpp" #include "../systems/TownGenSystem.hpp" +#include "../layoutlib/LayoutEval.hpp" #include "../systems/TerrainPrefabSpawnerSystem.hpp" #include "../systems/PrefabSystem.hpp" #include "NavigationPanel.hpp" @@ -662,6 +663,94 @@ private: ImGui::PopID(); } + static bool &getLayoutDslHelpOpen() + { + static bool open = false; + return open; + } + + /* Reference + worked examples for the building layout DSL + * (phase 4.5 item 2). The examples come from + * LayoutLibrary::examples() so the headless probe in + * road_geometry_overlap_test keeps them valid. */ + static void renderLayoutDslHelp() + { + bool &open = getLayoutDslHelpOpen(); + if (!open) + return; + ImGui::SetNextWindowSize(ImVec2(560.0f, 540.0f), + ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Layout DSL Help", &open)) { + ImGui::End(); + return; + } + ImGui::TextWrapped( + "Building layout DSL (S-expressions). Inline source " + "starts with '('. A plain word is a library layout " + "name: a built-in (village-house, town-rowhouse, " + "city-block, city-tower) or a name from " + "town_layouts.txt in the project directory " + "((layout name body...) forms)."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextUnformatted("Bindings:"); + ImGui::TextWrapped( + " lot-w lot-d buildable lot size after road " + "clearance (m)\n" + " avail-w avail-d buildable size after setback; " + "the shell clamps to these\n" + " min-fp max-fp district footprint range (m)\n" + " min-floors max-floors district floor range\n" + " roof-type edge-side roof override " + "(-1 = none)"); + ImGui::Spacing(); + ImGui::TextUnformatted("Verbs:"); + ImGui::TextWrapped( + " (cell-size xz-m [height-m]) grid metrics, " + "before shell (default 2 / 4)\n" + " (shell w d [:floors n]) exterior walls, " + "meters\n" + " (cut x0 z0 x1 z1) remove cells " + "(L/U shapes), shell-frame meters; all floors, or " + "one inside (floor n ...)\n" + " (floor n body...) per-floor context\n" + " (door front|back|left|right center|)\n" + " (windows all|front|back|left|right [:spacing m])\n" + " (room name (rect x0 z0 x1 z1)) fractions of the " + "shell\n" + " (idoor roomA roomB) door in the shared " + "wall\n" + " (roof flat|normal|normal2|gable|cone|cylinder " + "[x0 z0 x1 z1] [:height m])\n" + " (parking x z w d) or (parking (rect-front-left " + "w d)) or (rect-front-right w d)\n" + " (place \"\" (at x z) [:yaw deg]) " + "recorded only"); + ImGui::Spacing(); + ImGui::TextUnformatted("Forms & builtins:"); + ImGui::TextWrapped( + " let, if (1 or 2 branches), cond, dotimes, " + "for-range\n" + " + - * / min max clamp abs sqrt, comparisons, " + "and/or/not\n" + " rand rand-range rand-int (deterministic per town " + "seed)"); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextUnformatted("Examples:"); + for (const LayoutLib::LayoutLibrary::LayoutExample &ex : + LayoutLib::LayoutLibrary::examples()) { + ImGui::Spacing(); + ImGui::TextUnformatted(ex.title); + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.65f, 0.65f, 0.65f, + 1.0f)); + ImGui::TextUnformatted(ex.source); + ImGui::PopStyleColor(); + } + ImGui::End(); + } + static void renderRoadSection(TerrainComponent &tc, TerrainSystem *ts) { RoadSystem *rs = ts ? ts->getRoadSystem() : nullptr; @@ -895,7 +984,7 @@ private: } } Ogre::Vector3 pickedOrigin; - if (rs->takePickedOrigin(pickedOrigin)) { + if (s_pickArmed && rs->takePickedOrigin(pickedOrigin)) { s_origin[0] = pickedOrigin.x; s_origin[1] = pickedOrigin.z; s_pickArmed = false; @@ -1078,6 +1167,163 @@ private: } if (!s_townResult.empty()) ImGui::TextWrapped("%s", s_townResult.c_str()); + + ImGui::Separator(); + /* Phase 4.5 item 4: town areas — named square/circular + * generation scopes carried by the road graph. A lot is + * built by the area containing its centroid + * (first-match-wins over overlapping areas). + * "Generate All Areas" builds one "TownGen " + * container per area. */ + ImGui::TextUnformatted("Town Areas"); + static int s_areaPick = -1; + const char *shapes[] = { "Square", "Circle" }; + int removeArea = -1; + bool areasChanged = false; + for (size_t ai = 0; ai < rg.townAreas.size(); ++ai) { + RoadTownArea &area = rg.townAreas[ai]; + ImGui::PushID((int)ai); + if (ImGui::TreeNode("area", "%zu: %s", ai, + area.name.c_str())) { + char nameBuf[64]; + snprintf(nameBuf, sizeof(nameBuf), "%s", + area.name.c_str()); + if (ImGui::InputText("Name", nameBuf, + sizeof(nameBuf))) { + area.name = nameBuf; + areasChanged = true; + } + int shape = area.circular ? 1 : 0; + if (ImGui::Combo("Shape", &shape, + shapes, 2)) { + area.circular = shape == 1; + areasChanged = true; + } + float cxz[2] = { area.center.x, + area.center.z }; + if (ImGui::DragFloat2("Center XZ", cxz, + 1.0f)) { + area.center.x = cxz[0]; + area.center.z = cxz[1]; + areasChanged = true; + } + if (s_areaPick == (int)ai) { + ImGui::TextDisabled( + "Click the terrain to set " + "the area center..."); + ImGui::SameLine(); + if (ImGui::SmallButton( + "Cancel##areapick")) { + rs->armOriginPick(false); + s_areaPick = -1; + } + } else if (ImGui::SmallButton( + "Pick Center")) { + if (ts) + ts->setRoadEditMode(true); + rs->armOriginPick(true); + s_areaPick = (int)ai; + } + if (area.circular) { + if (ImGui::DragFloat( + "Radius", + &area.radius, 1.0f, + 10.0f, 100000.0f, + "%.0f m")) + areasChanged = true; + } else { + if (ImGui::DragFloat( + "Half Size", + &area.halfSize, 1.0f, + 10.0f, 100000.0f, + "%.0f m")) + areasChanged = true; + } + int aDistrict = (int)area.district; + if (ImGui::Combo("District##area", + &aDistrict, districts, + 4)) { + area.district = + (RoadTownDistrict)aDistrict; + areasChanged = true; + } + int aSeed = (int)area.seed; + if (ImGui::InputInt("Seed##area", + &aSeed)) { + area.seed = (uint32_t)aSeed; + areasChanged = true; + } + if (ImGui::SliderFloat("Density##area", + &area.densityScale, + 0.0f, 1.0f, "%.2f")) + areasChanged = true; + if (ImGui::Button("Remove Area")) + removeArea = (int)ai; + ImGui::TreePop(); + } + ImGui::PopID(); + } + Ogre::Vector3 pickedAreaCenter; + if (s_areaPick >= 0 && + s_areaPick < (int)rg.townAreas.size() && + rs->takePickedOrigin(pickedAreaCenter)) { + rg.townAreas[s_areaPick].center = + pickedAreaCenter; + s_areaPick = -1; + areasChanged = true; + } + if (removeArea >= 0 && + removeArea < (int)rg.townAreas.size()) { + if (s_areaPick == removeArea) { + rs->armOriginPick(false); + s_areaPick = -1; + } else if (s_areaPick > removeArea) { + --s_areaPick; + } + rg.townAreas.erase(rg.townAreas.begin() + + removeArea); + areasChanged = true; + } + if (ImGui::Button("Add Area (Square)")) { + RoadTownArea a; + a.name = "Area " + std::to_string( + rg.townAreas.size() + 1); + a.circular = false; + rg.townAreas.push_back(a); + areasChanged = true; + } + ImGui::SameLine(); + if (ImGui::Button("Add Area (Circle)")) { + RoadTownArea a; + a.name = "Area " + std::to_string( + rg.townAreas.size() + 1); + a.circular = true; + rg.townAreas.push_back(a); + areasChanged = true; + } + if (areasChanged) + rg.bumpVersion(); + if (ImGui::Button("Generate All Areas")) { + std::string err; + int placed = tgs->generateAreas(&err); + char buf[256]; + if (placed == 0) + snprintf(buf, sizeof(buf), + "Area generation failed: %s", + err.c_str()); + else + snprintf(buf, sizeof(buf), + "Placed %d buildings across " + "%zu area(s).", + placed, rg.townAreas.size()); + s_townResult = buf; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Build one town per area (each area's " + "district/seed/density; only lots whose " + "centroid it owns). Replaces all " + "generated towns."); } ImGui::TreePop(); } @@ -1798,6 +2044,171 @@ private: } } + /* Lot params (phase 4.5): per-side building + * settings consumed by TownGen; a lot takes the + * params of its frontage edge's side. All + * fields carry an inherit sentinel (combos at + * Inherit, density/setback < 0, sizes 0) so an + * untouched side follows the district preset. + * The Layout field takes a layout DSL name or + * inline source (phase 4.5 item 2). */ + auto lotParamsEditor = [&](const char *label, + RoadLotParams &lp, + int side) { + ImGui::PushID(label); + if (ImGui::TreeNode(label)) { + const char *bitems[3] = { + "Inherit", "On", "Off" + }; + int bcur = (int)lp.buildable; + if (ImGui::Combo("Buildable", &bcur, + bitems, 3)) { + lp.buildable = + (RoadLotBuildable)bcur; + edgeChanged = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Off keeps lots fronting this " + "side clear of buildings"); + const char *ditems[5] = { + "Inherit", "Village", "Town", + "City Residential", + "City Center" + }; + int dcur = (int)lp.district + 1; + if (ImGui::Combo("District", &dcur, + ditems, 5)) { + lp.district = + (RoadLotDistrict)(dcur - 1); + edgeChanged = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "District preset override for " + "lots fronting this side"); + const char *ritems[6] = { + "Inherit", "Flat", "Normal", + "Normal2", "Cone", "Cylinder" + }; + int rcur = (int)lp.roofType + 1; + if (ImGui::Combo("Roof", &rcur, + ritems, 6)) { + lp.roofType = + (RoadLotRoof)(rcur - 1); + edgeChanged = true; + } + edgeChanged |= ImGui::InputFloat( + "Density Scale", + &lp.densityScale); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Multiplier on the district " + "density; < 0 = inherit"); + edgeChanged |= ImGui::InputFloat( + "Setback (m)", &lp.setback); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Lot boundary to wall " + "distance; < 0 = inherit"); + edgeChanged |= ImGui::InputFloat( + "Min Footprint (m)", + &lp.minFootprint); + edgeChanged |= ImGui::InputFloat( + "Max Footprint (m)", + &lp.maxFootprint); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "0 = inherit"); + edgeChanged |= ImGui::InputInt( + "Min Floors", &lp.minFloors); + edgeChanged |= ImGui::InputInt( + "Max Floors", &lp.maxFloors); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "0 = inherit"); + edgeChanged |= ImGui::InputFloat( + "Max Frontage (m)", + &lp.maxFrontage); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Split lots wider than this " + "along their depth; 0 = no " + "splitting (phase 4.5 item 3)"); + /* Layout DSL (phase 4.5 item 2): + * a library layout name + * (village-house, town-rowhouse, + * city-block, city-tower or a + * town_layouts.txt entry) or inline + * source starting with '('. */ + static char s_layoutBuf[2][2048]; + static int s_layoutEdge[2] = { -1, + -1 }; + if (s_layoutEdge[side] != idx) { + snprintf(s_layoutBuf[side], + sizeof(s_layoutBuf + [side]), + "%s", + lp.layout.c_str()); + s_layoutEdge[side] = idx; + } + if (ImGui::InputTextMultiline( + "Layout", + s_layoutBuf[side], + sizeof(s_layoutBuf + [side]), + ImVec2(-1.0f, 60.0f))) { + lp.layout = + s_layoutBuf[side]; + edgeChanged = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Layout DSL name or inline " + "source; empty = district " + "default"); + /* Starting aid: fill an empty field with + * the default template; the help window + * carries the reference and worked + * examples. */ + bool layoutEmpty = + s_layoutBuf[side][0] == '\0'; + if (!layoutEmpty) + ImGui::BeginDisabled(); + if (ImGui::SmallButton( + "Insert Template")) { + snprintf( + s_layoutBuf[side], + sizeof(s_layoutBuf + [side]), + "%s", + LayoutLib::LayoutLibrary::defaultTemplateSource()); + lp.layout = + s_layoutBuf[side]; + edgeChanged = true; + } + if (!layoutEmpty) + ImGui::EndDisabled(); + if (ImGui::IsItemHovered( + ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip( + "Fill the empty field " + "with the default " + "layout template"); + ImGui::SameLine(); + if (ImGui::SmallButton("DSL Help")) + getLayoutDslHelpOpen() = + !getLayoutDslHelpOpen(); + ImGui::TreePop(); + } + ImGui::PopID(); + }; + lotParamsEditor("Lot Params Left (A->B)", e.lotLeft, + 0); + lotParamsEditor("Lot Params Right (A->B)", + e.lotRight, 1); + renderLayoutDslHelp(); + /* Decals (phase 2.5): atlas-textured quads * painted on the road surface. The preset * combo selects one of the four quadrants of