Phase 4.5, town and building layouts

This commit is contained in:
2026-10-08 05:16:04 +03:00
parent 7adfaa53a2
commit a3279f984b
27 changed files with 5671 additions and 315 deletions
+96 -6
View File
@@ -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 (`"<address> 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 <area name>"`
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 <area> p<x>_<y>"` 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 <district> seed <n>"` 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-<address>"` 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
+19
View File
@@ -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
)
# ---------------------------------------------------------------------------
+13
View File
@@ -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':
@@ -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 <prefab-path> (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
(`"<address> 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/<id>/buildings/<px>_<py>.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 <area> p<x>_<y>"`
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<RoadTownArea> 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 <name>"` 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 <area name>"`
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).
@@ -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 <area> p<x>_<y>"
* 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
*/
@@ -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<RoadEdgeDecal> 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<std::pair<int, int> > 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<RoadStreet> 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<RoadTownArea> townAreas;
/**
* Monotonic version counter incremented by every mutating operation.
*
+254 -25
View File
@@ -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 <area name>"` 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
<area> p<x>_<y>"` 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-<address>"` 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
@@ -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": {
@@ -0,0 +1,128 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
/*
* 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<uint64_t> cells;
std::vector<PlanRoof> roofs;
std::vector<PlanParking> parkings;
std::vector<PlanPlacement> 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 */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
#pragma once
#include <functional>
#include <map>
#include <random>
#include <string>
#include <vector>
#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<SExpr> 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<SExpr> *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<std::string> 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<LayoutExample> &examples();
private:
std::map<std::string, std::vector<SExpr> > 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<SExpr> &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<Value> &positional,
std::map<std::string, Value> &keywords);
bool verbShell(const std::vector<Value> &pos,
const std::map<std::string, Value> &kw,
const SExpr &at);
bool verbFloor(const SExpr &form);
bool verbRoomRaw(const SExpr &form);
bool verbCellSize(const std::vector<Value> &pos, const SExpr &at);
bool verbCut(const std::vector<Value> &pos, const SExpr &at);
bool verbDoor(const std::vector<Value> &pos, const SExpr &at);
bool verbWindows(const std::vector<Value> &pos,
const std::map<std::string, Value> &kw,
const SExpr &at);
bool verbIDoor(const std::vector<Value> &pos, const SExpr &at);
bool verbRoof(const std::vector<Value> &pos,
const std::map<std::string, Value> &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<std::map<std::string, Value> > m_scopes;
/* rooms per floor: floor -> (name -> rect) */
std::map<int, std::map<std::string, RoomRect> > m_rooms;
};
} /* namespace LayoutLib */
@@ -0,0 +1,171 @@
#include "LayoutLang.hpp"
#include <cctype>
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<SExpr> &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 */
@@ -0,0 +1,48 @@
#pragma once
#include <string>
#include <vector>
/*
* 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<SExpr> 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<SExpr> &out,
std::string *error);
} /* namespace LayoutLib */
@@ -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<float>();
a.center.y = ja["center"][1].get<float>();
a.center.z = ja["center"][2].get<float>();
}
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 */
@@ -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 */
+401 -95
View File
@@ -1,5 +1,7 @@
#include "TownGen.hpp"
#include "../layoutlib/LayoutEval.hpp"
#include <algorithm>
#include <cmath>
#include <map>
@@ -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 &params,
const RoadNetworkGen::HeightSampler &sampleHeight)
const RoadNetworkGen::HeightSampler &sampleHeight,
const LayoutLib::LayoutLibrary *layouts,
const std::vector<RoadTownArea> *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 &params,
std::mt19937 rng(params.seed);
std::uniform_real_distribution<float> unit(0.0f, 1.0f);
float density =
std::min(1.0f, dp.density * std::max(0.0f, params.densityScale));
std::map<std::string, int> 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<size_t> order(lots.size());
for (size_t i = 0; i < lots.size(); ++i)
order[i] = i;
std::vector<long> 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::pair<long, long>, std::vector<size_t> > 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<size_t> &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 &params,
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 &params,
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 &params,
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,
* "<address> 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;
+100 -1
View File
@@ -6,8 +6,13 @@
#include <Ogre.h>
#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;
/** "<street> <n>" or "Lot <lotIndex+1>". */
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<std::string> 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 "<address>
* 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<bool(float renderX, float renderZ,
long &pageX, long &pageY)>
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 &params,
const RoadNetworkGen::HeightSampler &sampleHeight);
const RoadNetworkGen::HeightSampler &sampleHeight,
const LayoutLib::LayoutLibrary *layouts = nullptr,
const std::vector<RoadTownArea> *areas = nullptr,
int areaIndex = -1,
const PageScope *page = nullptr);
} /* namespace TownGen */
@@ -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);
@@ -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;
}
}
@@ -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:
* "<root>/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;
@@ -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();
}
@@ -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<EditorMarkerComponent>()
.without<StreamedSpawnerTag>()
.without<StreamedTownTag>()
.without<GeneratedTownTag>()
.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<TerrainComponent>(tc);
@@ -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)
+643 -162
View File
@@ -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 <cmath>
#include <cfloat>
#include <array>
#include <fstream>
#include <set>
/* 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<std::array<int, 3>>
planFloorRuns(const LayoutLib::BuildingPlan &plan, int floor)
{
std::vector<std::array<int, 3> > 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<TerrainComponent>()) {
if (error)
*error = "no active terrain";
return false;
}
*graphOut = &terrain.get<TerrainComponent>().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 &params,
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<TerrainComponent>()) {
if (error)
*error = "no active terrain";
return 0;
}
const RoadGraph &graph =
terrain.get<TerrainComponent>().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 &params,
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>(
EntityNameComponent(containerName));
container.add<EditorMarkerComponent>();
container.add<GeneratedTownTag>();
TransformComponent containerXform;
containerXform.node =
m_sceneMgr->getRootSceneNode()->createChildSceneNode();
@@ -129,7 +368,7 @@ int TownGenSystem::generate(const TownGen::TownParams &params,
container.set<TransformComponent>(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 &params,
town.proceduralMaterialEntityId = std::to_string(material.id());
container.set<TownComponent>(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<const TownGen::TownPoi *> 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<TerrainComponent>())
graph = &terrain.get<TerrainComponent>().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<uint64_t> 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<StreamedTownTag>(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<const TownGen::TownPoi *> &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<CellGridComponent>(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>(EntityNameComponent("roof"));
roof.add<EditorMarkerComponent>();
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<RoofComponent>(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>(EntityNameComponent(
"roof " + std::to_string(ri++)));
roof.add<EditorMarkerComponent>();
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<RoofComponent>(rc);
}
} else if (planFloorSolid(b.plan, b.plan.floors - 1)) {
flecs::entity roof = m_world.entity();
roof.child_of(building);
roof.set<EntityNameComponent>(EntityNameComponent("roof"));
roof.add<EditorMarkerComponent>();
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<RoofComponent>(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<int, 3> &run :
planFloorRuns(b.plan, b.plan.floors - 1)) {
flecs::entity roof = m_world.entity();
roof.child_of(building);
roof.set<EntityNameComponent>(EntityNameComponent(
"roof " + std::to_string(ri++)));
roof.add<EditorMarkerComponent>();
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<RoofComponent>(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>(EntityNameComponent(poi.name));
poiEnt.add<EditorMarkerComponent>();
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<TransformComponent>(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>(EntityNameComponent(poi->name));
poiEnt.add<EditorMarkerComponent>();
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<TransformComponent>(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<RigidBodyComponent>(rb);
flecs::entity box = m_world.entity();
box.child_of(foundation);
box.set<EntityNameComponent>(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<TransformComponent>(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<std::array<int, 3> > 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<PrimitiveComponent>(prim);
int bi = 0;
for (const std::array<int, 3> &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<PhysicsColliderComponent>(collider);
flecs::entity box = m_world.entity();
box.child_of(foundation);
box.set<EntityNameComponent>(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<TransformComponent>(boxXform);
PrimitiveComponent prim;
prim.type = PrimitiveComponent::Type::Box;
prim.boxSizeX = sx;
prim.boxSizeY = foundationH;
prim.boxSizeZ = sz;
box.set<PrimitiveComponent>(prim);
PhysicsColliderComponent collider;
collider.shapeType = PhysicsColliderComponent::ShapeType::Box;
collider.parameters =
Ogre::Vector3(sx * 0.5f, foundationH * 0.5f,
sz * 0.5f);
box.set<PhysicsColliderComponent>(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<RigidBodyComponent>(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>(
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<TransformComponent>(landingXform);
PrimitiveComponent prim;
prim.type = PrimitiveComponent::Type::Box;
prim.boxSizeX = STAIR_WIDTH;
prim.boxSizeY = b.stairsDrop;
prim.boxSizeZ = STAIR_LANDING;
landing.set<PrimitiveComponent>(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<PhysicsColliderComponent>(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();
@@ -1,10 +1,15 @@
#pragma once
#include <cstdint>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <flecs.h>
#include <Ogre.h>
#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 &params, std::string *error);
/** Generate every town area of the graph (phase 4.5 item 4):
* one "TownGen <area name>" 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 <area> p<x>_<y>" 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 <area> p<x>_<y>" entities. */
struct StreamedTownPage {
size_t nextArea = 0;
std::vector<flecs::entity> 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<const TownGen::TownPoi *> &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<uint64_t, StreamedTownPage> m_streamedPages;
flecs::entity_t m_streamTerrainId = 0;
std::unique_ptr<LayoutLib::LayoutLibrary> m_streamLayouts;
};
@@ -33,6 +33,7 @@
#include <ProceduralTriangleBuffer.h>
#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 <cstring>
#include <fstream>
#include <map>
#include <set>
#include <utility>
#include <vector>
@@ -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<const TownGen::TownBuilding *> 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<char, int> 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<int> 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<int, TownGen::TownBuilding> 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");
+412 -1
View File
@@ -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|<offset-m>)\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 \"<prefab>\" (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 <area>"
* 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