Phases till 4 implemented. Basic generation of the town is added

This commit is contained in:
2026-10-05 16:55:04 +03:00
parent 5ca9b66d72
commit 7adfaa53a2
39 changed files with 7658 additions and 110 deletions
+97 -3
View File
@@ -51,6 +51,14 @@ cd build-vscode/src/features/editScene
# Run only matching test steps (substring match on step name)
TERRAIN_TEST_FILTER=terrainCompliance ./editSceneEditor --headless --run-terrain-tests=1
# Generate a procedural town from the loaded scene's road graph, print
# the building count and exit (Phase 4 headless integration check;
# needs a scene with an active terrain + road graph):
./editSceneEditor --project demos/demo-taxi --editor \
demo_scene_exterior.json --generate-town=town,7
# --generate-town=<district>[,<seed>] district: village | town |
# city-residential | city-center
# Demo: game-mode SceneScriptComponent inline-Lua event handling
# (scene_loaded / prefab_loaded / custom "demo_pulse"; watch stdout for
# [demo] lines and the cubes moving/scaling). Lives in
@@ -324,8 +332,10 @@ cd demos/demo-taxi
# Road wedge/segment self-intersection regression test (no scene needed,
# also registered as CTest roadGeometryOverlapTest). Covers per-piece
# A-B-C configs, junction probes (all wedges of 4-way/3-way-T/2-way
# nodes combined into one buffer, road+sidewalk combined) and dead-end
# cap assertions (node-end cap present, edge-midpoint end open):
# nodes combined into one buffer, road+sidewalk combined), dead-end
# cap assertions (node-end cap present, edge-midpoint end open) and
# the phase 2.5 probes (marking strip placement/gating, stop-line
# clear zones, decals, road-atlas UV bands and V tiling):
./road_geometry_overlap_test
# ...or analyse one custom A-B-C configuration:
./road_geometry_overlap_test -5 0 0 0 5 0 5 0 5
@@ -562,7 +572,8 @@ spawners for small or non-streaming scenes.
### RoadSystem
Per-terrain procedural roads (`RoadGraph` component, geometry built by
`roadlib/RoadGeometryLib` — see `ProceduralRoadGeometry.md`):
`roadlib/RoadGeometryLib`, graph-level generator services in
`roadlib/RoadNetworkGen` — see `ProceduralRoadGeometry.md`):
- Wedge/segment meshes, physics colliders, terrain compliance fixups and
navmesh dirty-marking are driven by the graph version; bump
@@ -646,6 +657,89 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
band and ratcheted the road down, 2026-09-30 trench bug), while
remaining excluded from the roads-to-terrain footprint — a bump
under an apron lowers, never lifts.
- **Road markings, atlas UVs & decals** (phase 2.5,
`ProceduralRoadGeometry.md` §18): `RoadConfig::markingsEnabled` +
per-edge tri-state `RoadEdge::markingsMode` drive painted overlay
strips (double-solid/dashed centerline, dashed lane lines, solid
edge lines at the curbs, stop bars on the incoming half of junction
arms) built by `buildWedgeMarkings`/`buildSegmentMarkings` 2 cm
above the road top; junction interiors stay unpainted, 2-way corners
paint through. `RoadConfig::textureTileLength` > 0 switches all
road/sidewalk/apron strips to atlas UVs (U bands 0-0.5/0.5-0.75/
0.75-1.0, V tiling per tile length); 0 keeps legacy meter UVs. The
generated `RoadAtlasTexture` placeholder uses anisotropic asphalt
noise (streaks along the lane) with default mipmaps.
`RoadEdge::decals` (`RoadEdgeDecal`: edgeT + lateral + size + atlas
rect) paints flat quads via `buildEdgeDecals`, per-edge so they
never land on junctions. Overlays build into separate `_markings`/
`_decals` chunk meshes with unlit CULL_NONE materials
(`markingMaterialName`/`decalMaterialName`, auto-created when
missing) and no colliders; all of it is hashed into the page
content signature.
- **Road network generator, crossings, lots & streets** (taxi demo
phase 3, `roadlib/RoadNetworkGen.hpp/.cpp`,
`ProceduralRoadGeometry.md` §19): graph-level services, all pure
`RoadGraph` functions exercisable headlessly. `findCrossings()`/
`resolveAllCrossings()` turn edge crossings into proper junctions
(end-node reuse within `RoadConfig::crossingSnapDistance`, sliver
collapse via `RoadGraph::mergeNodes`, edge splits; pairs separated
by more than `crossingHeightThreshold` in Y are overpasses and left
alone). `findLots()` enumerates enclosed faces (planar face walk,
sharpest right turn from the incoming heading with the U-turn edge
excluded) for building lots, filtered by `RoadLotFilter` (default:
45 m min size on bbox + area, max 8 boundary edges) and extended
with square fallback lots grown off edge sides that border no
accepted face (`RoadLot::edgeFallback`); road edit mode draws all
potential lots as dark grey outline loops
(`RoadSystem::buildLotOutlines`); `RoadGraph::streets` +
`nameStreet()`/`assignLotAddresses()` give streets and street+number
lot addresses ("Lot N" fallback). `generateGrid()` emits a seeded
deterministic settlement grid (jittered interior nodes, water/slope
rejection via a height sampler) stamped with `generatedGroupId` —
the editor refuses Remove/Split on grouped nodes/edges, a group is
removed wholesale (`RoadSystem::clearGenerated`) and hooks into a
manual road via `connectGroupToNode()`. Editor UI: terrain panel
"Road Network Generator" section (`RoadSystem::generateRoadNetwork`
— terrain heights + `WaterPhysics` water level — and
`resolveCrossingsNow`), with a "Pick Origin on Terrain" button that
arms a one-shot terrain click for the origin
(`RoadSystem::armOriginPick`/`takePickedOrigin`, consumed by
`EditorUISystem::handleRoadEditClick`) and a "Preview Grid" checkbox
that draws the would-be grid as orange lines without touching the
graph (`RoadSystem::updateGenPreview`/`clearGenPreview` — throwaway
graph, refreshed on parameter change); the edge inspector assigns
streets. Streets
persist inline in the scene JSON when not streaming, in
`roads/streets.json` when streaming; `generatedGroupId` rides both
the inline node JSON and the region files.
- **Procedural town generation** (taxi demo phase 4,
`roadlib/TownGen.hpp/.cpp` + `systems/TownGenSystem.hpp/.cpp`,
`ProceduralRoadGeometry.md` §20): `TownGen::generateTown()` (pure,
headless) turns `findLots()` lots into buildings — district presets
(village/town/city-residential/city-center: footprint, floors,
density, setback), seeded deterministic draws, frontage ranking
(street edge > fallback source edge > longest edge; fallback lots
match their source edge geometrically via `RoadLot::sourceEdgeA`/
`sourceEdgeB` — their polygon winding may be reversed, never assume
`polygon[0]-polygon[1]` is the source edge), foundation block seated
at the highest terrain corner, entrance stairs when the door ground
drops > 0.35 m, sequential street addresses, curbside pickup POIs,
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`
(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` —
`CellGridSystem::buildRoofs()` only processes `RoofComponent`
children of the grid entity) and serialized
Primitive-box foundation/stairs children (Primitive children survive
save/load; raw TriangleBufferComponent buffers don't). 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,
split on piece boundaries — each chunk gets its own mesh, static Jolt
+6
View File
@@ -31,6 +31,7 @@ set(EDITSCENE_SOURCES
systems/SpawnerRegionStore.cpp
systems/RoadRegionStore.cpp
systems/RoadSystem.cpp
systems/TownGenSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -828,6 +829,10 @@ add_test(NAME globalStateTest
add_library(RoadGeometryLib STATIC
roadlib/RoadGeometryLib.cpp
roadlib/RoadGeometryLib.hpp
roadlib/RoadNetworkGen.cpp
roadlib/RoadNetworkGen.hpp
roadlib/TownGen.cpp
roadlib/TownGen.hpp
)
target_include_directories(RoadGeometryLib PUBLIC
@@ -837,6 +842,7 @@ target_include_directories(RoadGeometryLib PUBLIC
target_link_libraries(RoadGeometryLib PUBLIC
OgreMain
OgreProcedural::OgreProcedural
nlohmann_json::nlohmann_json
)
# ---------------------------------------------------------------------------
@@ -526,6 +526,16 @@ boundary chord; `solvePinKernel` iterates Kaczmarz projections from
the road miter corner seed, retrying once with the zone extended to
the whole interior when the tight zone's kernel is empty.
**Memoization.** The scan is O(rows^2) per calling chain row, so
`filletMiterPinZoneRaw` memoizes it per (wedge endpoints, node
position, fillet shape, chain config) in a function-static cache (128
entries, cleared on overflow; road building is main-thread
synchronous). The entry round-trips the FULL pin point — an earlier
version cached only X/Z, and on cache hits the pinning curb offset
`K - center` inherited an uninitialized Y, which sent marking rows on
pin-zone boundaries to y ~ -1.5e6 at runtime (2026-10-02 spike
regression; covered by the suite's pin-zone cache round-trip probe).
**Impossible-corner detection.** A converged kernel only proves the
fan triangles are consistently oriented around the pin point P — when
the boundary cross-sections interpenetrate, the chain sweeps more than
@@ -1278,3 +1288,354 @@ never fills up to meet the apron. Residual cosmetic limitation: on
ground sitting just below road top the flat bench leaves the terrain
flush with the road top across the whole band, so the terrain can poke
through the apron mid-slope — reads as a natural curb cut.
## 18. Road Markings, Atlas UVs and Decals (M5.19, taxi demo phase 2.5)
Painted markings, road-atlas UVs and flat decal quads, all generated by
the same builders as the road geometry so hand editing and procedural
generators get them for free.
### 18.1 Marking kinds and placement rules
`RoadConfig::markingsEnabled` (default on) gates everything; each edge
carries a tri-state `RoadEdge::markingsMode` (Inherit/On/Off, same
pattern as the sidewalk modes) folded by
`RoadGraph::edgeMarkingsEnabled()`. Four overlay buffers are built
(`RoadMarkingBuffers{centerline, laneLines, edgeLines, stopLines}`) by
`buildWedgeMarkings()` per wedge and `buildSegmentMarkings()` per
dead-end straight segment:
- **Centerline**: separates the travel directions. 2+ lanes per
direction -> double solid (two `markingLineWidth` strips with a
one-line gap between them, at lat [-g-w, -g] and [g, g+w],
g = w/2); 1+1 -> a single dashed line at lat 0. Resolved from the
resolved lane counts, not configurable.
- **Lane lines**: dashed lines between same-direction lanes (N lanes
per side -> N-1 lines at lat k * laneWidth on that side).
- **Edge lines**: solid strips at both curbs, inset
`markingEdgeInset` from the curb (curb-relative, so they follow the
widened curb chain through fillets and tapers).
- **Stop lines**: junctions (degree > 2) only, one perpendicular bar
across the incoming half of each arm, `markingStopSetback` past the
fillet/clear boundary and `markingStopWidth` deep. The bar starts
flush at the centerline strip's OUTER edge (lat 1.5*w double solid,
0.5*w dashed) and the arm's longitudinal lines resume past the bar's
far edge — sharing only boundary lines, so nothing coplanar-overlaps
(the combined-overlay overlap check enforces this).
**Junction suppression**: at degree > 2 nodes all longitudinal lines
stop at the clear boundary (fillet reach or junction half-width, plus
the stop setback on the incoming side) and the wedge interior stays
unpainted. At 2-neighbor nodes there is no clear zone and the
markings follow the corner through. Dashes phase from the edge
midpoint (wedge path d = 0), so up to one dash gap next to any node
may legitimately be unpainted.
**Pin zones**: corner pin zones (§5.10) are subtracted from every
marking range — the pinched cross-section there cannot host paint.
The rows landing exactly on a zone boundary pin at the shared kernel
pin point just like the road surface's own boundary row, so the
paint edge meets the fan seam exactly.
Geometry: strips are emitted as quad rows (`ROAD_MARKING_STEP` 0.5 m)
between `sampleWedgeMarking()` samples (center on the wedge centerline
at paint height = road top + `ROAD_MARKING_LIFT` 0.02, lateral
direction and curb distance from `computeCurbOffset`), so they follow
fillets, lane tapers and height profiles exactly. Rows whose strip
would hang past the curb are skipped. The paper-thin quads wind
downward; the overlay material is CULL_NONE, so this is intentional.
### 18.2 Road atlas UVs
`RoadConfig::textureTileLength` > 0 switches every road/sidewalk/apron
strip to atlas UVs (0 keeps the legacy meter UVs: U = along-road
meters, V = lateral meters). U runs ACROSS the surface and selects
the band of the shared square road atlas: 0.0-0.5 road-top asphalt,
0.5-0.75 sidewalk, 0.75-1.0 apron; each strip maps its full width into
its band (the road band is additionally split so the lanesIn-side curb
starts at U = 0.25 within the band — symmetric widening convention).
V runs along the travel direction and tiles once per
`textureTileLength` world meters, phase-continuous across the edge
midpoint (`atlasV()`), so long edges stop smearing the texture.
`RoadSystem::syncRoadAtlasTextureUnit()` swaps the road material's
texture to the generated `RoadAtlasTexture` placeholder while tiling
is on. The placeholder's asphalt band uses anisotropic noise (16-px
runs along image Y, the along-road V axis) so the grain reads as
streaks along the lane, and keeps default mipmaps so distant
minification blurs instead of throwing cross-road moire straps.
### 18.3 Decals
`RoadEdge::decals` is a serialized per-edge list of `RoadEdgeDecal`
quads: centre at `edgeT` along the edge plus `lateralOffset` meters
sideways (positive = right of A->B travel), `size` meters on both
axes, sampling the `(uvX, uvY, uvW, uvH)` rect of the shared
`RoadDecalAtlas` texture (generated placeholder: manhole, arrows,
damage patches). `buildEdgeDecals()` places each quad on the
piecewise road surface (road top + `ROAD_DECAL_LIFT` 0.025, so the
mid-edge kink is followed exactly). Decals are per-edge by
construction, so they never land on junction interiors; a cross-page
edge paints each half's decals on the owning page only (same ownership
split as the geometry). Pure visuals: no collider, no navmesh, no
compliance footprint.
### 18.4 Integration
Markings and decals are built into separate overlay chunk buffers
(`appendOverlay`/`flushOverlay`, same 60000-vertex chunk cap as the
page geometry, `_markings`/`_decals` mesh-name suffixes) with their
own materials and NO physics colliders — vehicles never feel the
paint. `RoadSystem::ensureOverlayMaterials()` creates the defaults
when missing: the marking material is unlit white (RTSS renders a
lighting-disabled pass white, which is exactly road paint), CULL_NONE,
depth write on; the decal material adds SBT_TRANSPARENT_ALPHA, depth
write off and the clamped `RoadDecalAtlas` texture unit. Both are
looked up by name (`RoadConfig::markingMaterialName` /
`decalMaterialName`), so a project can pre-author replacements. The
page content signature hashes all marking config fields, the per-edge
`markingsMode` and the decal lists, so any paint edit rebuilds exactly
the affected pages.
Headless coverage: `road_geometry_overlap_test` probes
(`runMarkingSegmentProbe` dead-end content + per-edge gating,
`runJunctionMarkingCase` 4-way 1+1 / 4-way 2+2 / 3-way T clear zones,
stop bars, combined road+overlay overlap, `runMarkingTwoWayProbe`
corner continuation, `runDecalProbe` placement/UVs/ownership,
`runUVAtlasProbe` bands + V tiling + legacy mode).
## 19. Road Network Generator, Crossings, Lots and Streets (taxi demo phase 3)
Graph-level services for procedural settlement creation, living in
`roadlib/RoadNetworkGen.hpp/.cpp` (namespace `RoadNetworkGen`, part of
the `RoadGeometryLib` target). Everything is a pure function of
`RoadGraph` data plus an optional terrain height sampler, so the whole
namespace is exercisable headlessly.
### 19.1 Crossing detection and resolution
`findCrossings()` reports every edge pair whose closest points are
nearer (in XZ) than the sum of the edge half-widths (lanes + enabled
sidewalks) AND no further apart in Y than
`RoadConfig::crossingHeightThreshold` (default 10 m) — a bigger Y gap
is an overpass/underpass and is deliberately NOT a crossing. Edge
pairs sharing a node are skipped.
`resolveCrossing()` turns one crossing into a proper junction. For
each of the two edges: an end node within
`RoadConfig::crossingSnapDistance` (default 5 m) of the crossing point
is reused, an edge too short to split (at most
`2 * ROAD_MIN_EDGE_LENGTH`) is collapsed via `RoadGraph::mergeNodes()`
into its end nearer to the crossing, anything else is split at the
crossing parameter. The two resulting junction nodes are then merged
into one, placed at the crossing midpoint.
`resolveAllCrossings()` re-detects after every fix (one fix per pass)
until the graph is clean. `checkSane()` verifies the structural
invariants afterwards: no duplicate edge pairs, no sliver edges below
`ROAD_MIN_EDGE_LENGTH`, no isolated nodes (it intentionally does NOT
run `RoadGraph::validate()`, whose wedge-angle rules reject
hand-authored legacy scenes).
### 19.2 Lot detection
`findLots()` enumerates the enclosed faces of the graph (potential
building lots) with a planar face walk: every directed edge is walked
taking the sharpest right turn at each node (largest positive angle
from the incoming heading in the atan2(z, x) sense), which yields
interior faces as positive-area XZ cycles. Two implementation rules
matter:
- the turn is measured from the heading, not from the reverse
direction — a near-straight continuation is ~pi from the reverse
direction and wraps to ~-pi, which would otherwise win the extremum
and send the walk straight through 4-way junctions;
- the U-turn edge back to the previous node is excluded from the
search (its angle from the heading is exactly +-pi, i.e. it sits on
the wrap boundary and would be picked at random depending on float
rounding); it is only taken at dead ends, where it makes the walk
retrace the edge and get the face rejected.
A face is rejected when its boundary walk traverses an edge twice or
revisits a node (a spur/cul-de-sac pokes into it), or when another
graph node lies strictly inside it — such areas are intersected by
road and are not clean lots. Survivors get a polygon, area and
area-weighted centroid.
Acceptance filtering (`RoadLotFilter`, defaults aimed at building
lots): a face is discarded when its boundary has more than
`maxBoundaryEdges` (8) edges, when its area is below
`minLotSize`^2 (45 m -> 2025 m^2), or when its XZ bounding box is
narrower than `minLotSize` in either axis — a lot must fit a
45x45 m square.
Fallback lots (`RoadLotFilter::edgeFallback`, on by default): every
directed edge side that does not already border an accepted face
(`coveredLeft` tracks the left side of each directed edge, the
CCW-face-interior side) grows a square lot with side = edge length
when the edge is at least `minLotSize` long. A candidate square is
rejected when any graph node lies strictly inside it (excluding the
source edge's endpoints), when any edge endpoint lies inside it or
properly crosses its boundary (touching/collinear does not count), or
when it overlaps an already accepted lot. The overlap test
(`polysOverlapXZ`) checks edge crossings plus CENTROID containment —
not vertex containment: a fallback square sharing a boundary vertex
with an accepted face ray-casts that vertex as "inside"
(`pointInPolyXZ` boundary rule) and would be falsely rejected.
Accepted squares get `edgeFallback = true`, empty `nodeIds` and a
vertex-mean centroid. Editor: potential lots are drawn as dark grey
outline loops in road edit mode (`RoadSystem::buildLotOutlines`, part
of the visual-aids overlay).
### 19.3 Streets and addresses
`RoadGraph::streets` holds named edge sequences (`RoadStreet{name,
edges}`; edges are normalized endpoint-id pairs, min first).
`nameStreet()` (re)defines a street, dropping its edges from any other
street; `findStreetForEdge()` looks up the street containing an edge.
`assignLotAddresses()` assigns each lot to its nearest street and
numbers lots sequentially along the street's direction; lots on no
street fall back to "Lot N".
Streets persist inline in the scene JSON (`streets` array) for
non-streaming terrains and in `heightmaps/<terrainId>/roads/streets.json`
next to the region files when streaming (loaded once per terrain in
`RoadSystem::syncRegions()` with `dropMissing = false` — pages are
never all in the graph at once, so entries whose edges are currently
unloaded are kept; written by `RoadSystem::flushRegionStore()`).
### 19.4 Seeded grid generator
`generateGrid()` emits `(blocksX+1) x (blocksZ+1)` intersections with
seeded jitter (mt19937) on interior nodes only (boundary stays put, so
settlements tile predictably), and edges along the grid lines. With a
height sampler: nodes below `waterLevel` are rejected (their edges go
with them) and edges steeper than `maxSlope` rise/run are skipped.
Every created node is stamped with a `generatedGroupId` (0 = manual);
the editor refuses Remove/Split on grouped nodes/edges — a group is a
single unit, removed wholesale via `removeGroup()`.
`connectGroupToNode()` connects a manual road node to the closest
group node ("as if the group were a road node") and resolves
crossings, which is how a settlement hooks into a hand-authored road.
Editor integration: the terrain panel's "Road Network Generator"
section (origin, block counts/sizes, jitter, seed, avoid-water, max
slope, crossing resolution; Generate / Resolve Crossings Now /
per-group Remove buttons) wraps `RoadSystem::generateRoadNetwork()`
(heights from `TerrainSystem::getHeightAt`, water level from the
scene's `WaterPhysics` entity), `RoadSystem::resolveCrossingsNow()`
and `RoadSystem::clearGenerated()`. "Pick Origin on Terrain" arms a
one-shot terrain click (`RoadSystem::armOriginPick` /
`takePickedOrigin`; `EditorUISystem::handleRoadEditClick` consumes the
click before any road tool) that fills the origin fields. The
"Preview Grid" checkbox draws the would-be grid as orange lines
without touching the graph (`RoadSystem::updateGenPreview` runs
`generateGrid()` into a throwaway graph with the same terrain/water
inputs; refreshed on every parameter change, cleared by
`clearGenPreview()` on uncheck, generation, scene clear or terrain
switch). The edge inspector assigns the selected edge to a named
street.
Headless coverage: `road_geometry_overlap_test` probes
(`runCrossingResolutionProbe` X junction/end-snap T/collapse/overpass
untouched, `runLotProbe` 2-block grid + spur rejection,
`runStreetProbe` naming/reassignment/JSON round trip/unique addresses +
unnamed fallback, `runGeneratorProbe` determinism, counts, water and
slope rejection, two settlements in one graph, group connect/remove).
## 20. Procedural Town Generation (taxi demo phase 4)
`TownGen` (`roadlib/TownGen.hpp/.cpp`, part of `RoadGeometryLib`, pure
— no Ogre scene dependencies beyond math types) turns the lots from
`RoadNetworkGen::findLots()` into a building list:
`generateTown(graph, TownParams, HeightSampler) -> TownResult`.
### 20.1 Districts
`District` presets (`districtParams()`): Village (1 floor, 6-10 m
footprints, density 0.6), Town (1-3 floors, 8-14 m, 0.75),
CityResidential (2-5 floors, 10-18 m, 0.85), CityCenter (4-8 floors,
12-22 m, 1.0). Each preset carries footprint range, floor range,
density, setback and a wall tint. `TownParams` = district + seed +
density scale + water level.
### 20.2 Placement
Per lot, in fixed seed-draw order (density, width, depth, floors — so
results are deterministic per seed): pick the **frontage** edge
(ranking: boundary edge on a named street > the fallback lot's source
edge > the longest boundary edge), build the lot frame (u along the
frontage, v from the frontage midpoint towards the centroid), inset a
setback rectangle, and place a width x depth footprint clamped to the
available space. Lots too small after setback are skipped
(`skippedTooSmall`); buildings whose floor would sit below
`waterLevel` are skipped (`skippedWater`).
Because `findLots()` lot polygons run **centerline-to-centerline**,
the lot is first shrunk off the roads (`shrinkLotOffRoads()`): every
boundary edge matching a graph edge (geometric endpoint match, either
direction — covers enclosed faces and fallback source edges alike) is
offset inward by the road half-extent on the lot's side (all lanes x
`laneWidth` / 2 + the lot side's sidewalk + apron + a 0.3 m margin);
the inset vertices are the intersections of the consecutive offset
lines (near-parallel edge pairs average the two offset positions of
the shared vertex). Bounds and placement are measured from the inset
polygon in the lot frame — the buildable front line `vMin` is the
frontage pushed off the road, so the door, stairs and pickup POI stay
inside the lot. A lot whose inset collapses (degenerate edge or
reversed/vanished polygon) is skipped as too small.
Edge-fallback lots (the squares `findLots()` grows off lone road
edges) have no `nodeIds` and their polygon winding may be reversed,
so the source edge is matched **geometrically** via the new
`RoadLot::sourceEdgeA`/`sourceEdgeB` node ids (endpoint positions,
0.01 eps, either direction) — never assume `polygon[0]-polygon[1]`
is the source edge.
### 20.3 Foundation, stairs, addresses, POIs
Uneven terrain is handled without terrain edits: the floor sits at
the **highest** terrain corner of the footprint and a foundation
block reaches below the lowest corner; when the door-side ground
(one meter in front of the front face) drops more than 0.35 m below
the floor, the building gets entrance stairs (`stairsDrop`).
Addresses come from the frontage street, numbered sequentially per
street in lot order ("Lot N" when the lot touches no street — the
fallback-lot source edge also resolves through
`findStreetForEdge()`). Every building gets a curbside pickup
`TownPoi` (later phases use these as taxi pickup/dropoff and
pedestrian origin/destination points).
### 20.4 Scene integration
`TownGenSystem` (`systems/TownGenSystem.hpp/.cpp`, owned by
`TerrainSystem` next to `RoadSystem`) materializes a `TownResult`:
a container entity named `"TownGen <district> seed <n>"` carrying a
`TownComponent` (shared procedural material reference), one shared
`ProceduralTexture` (named rects: wall/roof/foundation/door/winframe/
floor/ceiling) + `ProceduralMaterial` pair, and per building an
EditorMarker entity with an exterior-only `CellGridComponent` shell
(perimeter windows, front-center door with `doorsEnabled = false`
and the placeholder `doorSceneSwitchPath = "town_interior.json"` —
Phase 8 enables the F1 scene switch) plus a `RoofComponent` child
(flat roof over the whole footprint, `posY = floors`, slab + trim on
top of the walls — `CellGridSystem::buildRoofs()` only processes
`RoofComponent` children of the grid entity), plus serialized
`Primitive`
box children for the foundation and stairs (Primitive children
survive save/load; a raw `TriangleBufferComponent` buffer does not)
and the pickup POI child. Placement convention:
`CellGridComponent::cellToWorld()` puts cell (x, y, z) at
`x*cellSize`, so a shell spans `[0, width*cellSize]` from its node —
`createBuilding()` shifts the building node so the shell lands
CENTERED on `TownBuilding::center` (front face at local +Z), and the
foundation/stairs/POI children position themselves in the same shell
coordinates. `clear()` / `countTowns()` find towns by
the `"TownGen "` name prefix. The terrain panel's "Town Generator"
section (district, seed, density, Generate/Clear) wraps it.
Headless coverage: `runTownGenProbe` in
`road_geometry_overlap_test` — building/POI counts on a 12-lot grid,
floor-range per district, determinism, water rejection, slope
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).
+231 -1
View File
@@ -147,6 +147,68 @@ struct RoadConfig {
*/
float prefabSpawnDistance = 150.0f;
float prefabDespawnDistance = 250.0f;
/**
* Painted road markings (taxi demo phase 2.5). When enabled, the
* geometry builders emit marking strips — centerline (double solid
* for 2+ lanes per direction, single dashed for 1+1), dashed lines
* between same-direction lanes, solid edge lines at the curbs and
* stop lines on the incoming half of each arm before an
* intersection (nodes with more than two neighbors) — as separate
* overlay geometry slightly above the road top. Junction wedge
* surfaces stay unmarked; at 2-neighbor nodes the markings follow
* the corner through. Per-edge tri-state overrides:
* RoadEdge::markingsMode.
*/
bool markingsEnabled = true;
/** Width of one painted line (centerline/lane/edge lines). */
float markingLineWidth = 0.15f;
/** Dash length and gap of dashed lines (centerline 1+1, lanes). */
float markingDashLength = 3.0f;
float markingDashGap = 3.0f;
/** Stop line width along travel and setback from the junction. */
float markingStopWidth = 0.4f;
float markingStopSetback = 1.0f;
/** Edge line inset from the curb toward the road center. */
float markingEdgeInset = 0.25f;
/**
* Materials for the marking and decal overlay chunks. Created
* procedurally by RoadSystem when missing (unlit white paint;
* alpha-blended decal atlas).
*/
std::string markingMaterialName = "RoadMarkingMaterial";
std::string decalMaterialName = "RoadDecalMaterial";
/**
* Road texture V tiling (phase 2.5). 0 keeps the legacy UVs (U =
* along-road meters, V = lateral meters). When > 0 the road mesh
* UVs are regenerated as an atlas mapping: U runs ACROSS the
* surface (no tiling) and selects the band of the shared road
* atlas texture — 0.0-0.5 road-top asphalt, 0.5-0.75 sidewalk,
* 0.75-1.0 apron — while V runs along the travel direction and
* tiles once per this many world meters, so long edges stop
* smearing the texture.
*/
float textureTileLength = 0.0f;
/**
* Crossing resolution (taxi demo phase 3,
* RoadNetworkGen::findCrossings/resolveCrossing). Two edges count as
* intersecting when the XZ distance between their closest points is
* at most the sum of their half-widths AND the closest points are at
* most crossingHeightThreshold apart in Y (a larger Y gap is an
* overpass/underpass and creates no junction). When the crossing
* point lies within crossingSnapDistance meters of an edge end the
* existing end node is reused for the junction instead of inserting
* a split node.
*/
float crossingSnapDistance = 5.0f;
float crossingHeightThreshold = 10.0f;
};
/**
@@ -187,6 +249,17 @@ struct RoadNode {
* existing edges.
*/
int id = 0;
/**
* Procedural-generation group membership (taxi demo phase 3). 0 is a
* manually authored node; a nonzero value marks the node as part of a
* generated road grid (RoadNetworkGen::generateGrid stamps every node
* it creates). Generated groups are treated as single unsplittable
* units: the editor refuses to move/remove/split grouped nodes and
* their interior edges, and the whole group can be removed or
* connected to manual road at once.
*/
uint32_t generatedGroupId = 0;
};
/**
@@ -257,6 +330,47 @@ enum class RoadSidewalkMode : int {
Off = 2 /**< No sidewalk on this side even if globally enabled. */
};
/**
* Per-edge road-marking tri-state (phase 2.5).
*
* Same folding rule as RoadSidewalkMode: Inherit follows the global
* RoadConfig::markingsEnabled, On/Off force markings for this edge
* regardless of it. See RoadGraph::edgeMarkingsEnabled().
*/
enum class RoadMarkingMode : int {
Inherit = 0, /**< Follow RoadConfig::markingsEnabled. */
On = 1, /**< Markings on this edge even if globally disabled. */
Off = 2 /**< No markings on this edge even if globally enabled. */
};
/**
* A flat decal quad painted onto the road surface (phase 2.5): manhole
* covers, arrows, damage patches, parking symbols.
*
* The quad is centred at normalized position @c edgeT along the edge,
* shifted @c lateralOffset meters sideways (positive = right side of
* A->B travel, same convention as RoadEdgePrefabSlot), spans
* @c size meters on both axes and samples the @c uv* rect of the shared
* decal atlas texture. Decals are pure visuals: no physics collider,
* no navmesh contribution, no terrain-compliance footprint.
*/
struct RoadEdgeDecal {
/** Normalized position along the edge from nodeA to nodeB. */
float edgeT = 0.5f;
/** Lateral offset from the centerline (positive = right side). */
float lateralOffset = 0.0f;
/** Quad size in meters (both axes). */
float size = 1.0f;
/** Atlas rect: origin (uvX, uvY) and extent (uvW, uvH), 0..1. */
float uvX = 0.0f;
float uvY = 0.0f;
float uvW = 0.5f;
float uvH = 0.5f;
};
/**
* A connection between two RoadNode objects.
*
@@ -353,6 +467,19 @@ struct RoadEdge {
/** Prefab anchored at the centerline, at edgeT (edge midpoint). */
RoadEdgePrefabSlot prefabMid;
/**
* Per-edge marking tri-state (phase 2.5, see RoadMarkingMode).
* Inherit (the default) follows the global
* RoadConfig::markingsEnabled switch.
*/
RoadMarkingMode markingsMode = RoadMarkingMode::Inherit;
/**
* Decal quads painted on this edge's road surface (phase 2.5).
* Purely visual; see RoadEdgeDecal.
*/
std::vector<RoadEdgeDecal> decals;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
@@ -361,6 +488,19 @@ static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
/**
* A named sequence of road edges (taxi demo phase 3). Streets give a
* printable, code-accessible name to a run of edges so lots can be
* referenced as "street name + number" addresses (see
* RoadNetworkGen::assignLotAddresses). Edges are stored as normalized
* endpoint id pairs (first < second) because node ids are stable while
* edge vector indices are not.
*/
struct RoadStreet {
std::string name;
std::vector<std::pair<int, int> > edges;
};
struct RoadGraph;
struct RoadWedge;
struct RoadStraightSegment;
@@ -394,6 +534,13 @@ struct RoadGraph {
/** All road edges in the graph. Ordering is not significant. */
std::vector<RoadEdge> edges;
/**
* Named streets (phase 3). Purely labelling data — geometry never
* reads it. Mutated through RoadNetworkGen::nameStreet; kept here so
* it travels with the graph through region-store flushes.
*/
std::vector<RoadStreet> streets;
/**
* Monotonic version counter incremented by every mutating operation.
*
@@ -424,6 +571,18 @@ struct RoadGraph {
config.sidewalkEnabled);
}
/**
* Effective marking enable of one edge (phase 2.5): the tri-state
* mode folded with the global config.markingsEnabled. Geometry
* builders call this directly instead of caching.
*/
bool edgeMarkingsEnabled(const RoadEdge &e) const
{
return e.markingsMode == RoadMarkingMode::On ||
(e.markingsMode == RoadMarkingMode::Inherit &&
config.markingsEnabled);
}
/**
* Recompute the resolved sidewalkLeft/sidewalkRight bools of every
* edge from the per-side modes and the global
@@ -702,6 +861,61 @@ struct RoadGraph {
return true;
}
/**
* Merge @p dropId into @p keepId (taxi demo phase 3): every edge
* incident to @p dropId is re-pointed at @p keepId (edges that would
* become duplicates or self-edges are removed instead), street
* references are re-pointed too, and @p dropId is removed. Used by
* crossing resolution to collapse an end node or a too-short edge
* into the junction node.
*
* @return true on success (both nodes existed and differ).
*/
bool mergeNodes(int keepId, int dropId)
{
if (keepId == dropId)
return false;
if (!findNodeById(keepId) || !findNodeById(dropId))
return false;
for (auto it = edges.begin(); it != edges.end();) {
RoadEdge &e = *it;
if (e.nodeA != dropId && e.nodeB != dropId) {
++it;
continue;
}
if (e.nodeA == dropId)
e.nodeA = keepId;
if (e.nodeB == dropId)
e.nodeB = keepId;
if (e.nodeA == e.nodeB ||
findEdgeIndex(e.nodeA, e.nodeB) !=
(int)(it - edges.begin())) {
/* Self-edge after the merge, or another edge
* already connects the same pair (findEdgeIndex
* returns the FIRST match, so a mismatch means
* this one is the duplicate). */
it = edges.erase(it);
} else {
++it;
}
}
for (RoadStreet &s : streets)
for (std::pair<int, int> &se : s.edges) {
if (se.first == dropId)
se.first = keepId;
if (se.second == dropId)
se.second = keepId;
if (se.first > se.second)
std::swap(se.first, se.second);
}
removeNode(dropId);
bumpVersion();
return true;
}
/**
* Split an edge at a normalized position and insert a new node.
*
@@ -782,9 +996,11 @@ struct RoadGraph {
/* Sidewalk passes transfer like the prefab slots below (both
* halves keep the A->B orientation, so the side flags carry
* over); the copies on both halves above are replaced by
* edgeT-remapped placement. */
* edgeT-remapped placement. Decals transfer the same way. */
edgeA.sidewalkPasses.clear();
edgeB.sidewalkPasses.clear();
edgeA.decals.clear();
edgeB.decals.clear();
if (actualT > 1e-6f && actualT < 1.0f - 1e-6f) {
auto remapSlot = [&](const RoadEdgePrefabSlot &src,
RoadEdgePrefabSlot &dstA,
@@ -830,6 +1046,20 @@ struct RoadGraph {
edgeB.sidewalkPasses.push_back(q);
}
}
/* Decals transfer with the same edgeT remap. */
for (const RoadEdgeDecal &d : oldEdge.decals) {
if (d.edgeT <= actualT) {
RoadEdgeDecal q = d;
q.edgeT = d.edgeT / actualT;
edgeA.decals.push_back(q);
} else {
RoadEdgeDecal q = d;
q.edgeT = (d.edgeT - actualT) /
(1.0f - actualT);
edgeB.decals.push_back(q);
}
}
}
edges.erase(edges.begin() + edgeIndex);
+391 -12
View File
@@ -1,10 +1,13 @@
# demo-taxi — Implementation Plan
**Status: Phase 2 done (2026-09-17).** Phases 0-2 done (skeleton,
sedan vehicles, road geometry verification + automatic compliance);
Phase 3 (road-network generator) is next. This plan is a working
draft — phases get refined, split or reordered as we work through
them.
**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.
This plan is a working draft — phases get refined, split or reordered
as we work through them.
Taxi driver demo in the open world of `demo_scene_exterior.json`: a
procedurally generated (but tightly controlled) settlement on the
@@ -152,7 +155,10 @@ Non-goals (for now):
the edge and often overlap because too little space is reserved for
lanes and sidewalks; the procedural lane template emits front/back
geometry even where the intersection should be open; terrain
compliance is manual and tedious.
compliance is manual and tedious. No road markings at all (center
line, same-direction dashed lane lines, curb edge lines, stop lines
before intersections), no road decals, and no road-texture V
tiling.
3. Road-network generation: roads are authored node-by-node with the
Connect tool today; no layout generator.
4. Town generation: District/Lot exist but arrange lots in a circle
@@ -176,6 +182,12 @@ Non-goals (for now):
10. Fare chain: no fare/timer/money rules (Lua module + HUD).
11. Assets: sedans (several types x several colors, one taxi-marked),
possibly street props (lamps, signs).
12. Geometry/render hardening: inner-corner wedge midpoints need
outward relaxation (with the center gap refilled via the mid-edge
vertex shift) to kill residual overlaps on tight turns; marking /
decal overlays Z-fight the road top at grazing angles and distance
(needs a depth/shader-level fix, not more lift); no render
batching yet — the bare exterior scene already draws ~85 batches.
## Phases
@@ -1005,24 +1017,275 @@ Follow-ups (2026-09-30, apron terrain interface):
TPS camera) plus env-gated debug dumps (`ROAD_DUMP_DIR`,
`ROAD_DUMP_PIECES`, `ROAD_DEBUG_PIN`).
### Phase 3 — Road-network generator
### Phase 2.5 — Road markings, road texture & decals — DONE
Done 2026-10-02. Everything below is implemented; the headless probes
live in `road_geometry_overlap_test` (`runMarkingSegmentProbe`,
`runJunctionMarkingCase` 4-way 1+1 / 4-way 2+2 / 3-way T,
`runMarkingTwoWayProbe`, `runDecalProbe`, `runUVAtlasProbe`) and the
CTest gate `roadGeometryOverlapTest` is green. Implementation notes
beyond the original text:
- The stop bar starts flush at the centerline strip's outer edge and
the arm's longitudinal lines resume past the bar, so the bar never
z-fights the lines it crosses (found by the combined-overlay probe).
- Dashes phase from the edge midpoint, so up to one dash gap next to
a node can be unpainted — the 2-way probe bounds that gap instead
of demanding paint at a fixed offset.
- Decals never land on junction interiors by construction: they are
placed per edge (edgeT along the half-edge), and a cross-page edge
paints each half's decals on the owning page only.
- `RoadGeometryLib` doc: `ProceduralRoadGeometry.md` §18.
Original phase text follows.
Painted markings, proper road UVs and simple decals, all generated with
the road geometry so both hand editing and the Phase 3 generator get
them for free.
**Markings**
- **Per-edge control**: tri-state `RoadEdge::markingsLeftMode/RightMode`-style
override (Inherit/On/Off, same pattern as the sidewalk modes)
resolving against the global `RoadConfig::markingsEnabled`, so
markings can be disabled (or forced on) per edge.
- **Centerline**: separates the two travel directions. Wide roads
(2+ lanes per direction) get a double solid line; narrow roads
(1 lane per direction) get a single dashed centerline instead.
Resolved automatically from the lane counts.
- **Lane lines**: dashed lines between same-direction lanes
(N lanes per side -> N-1 dashed lines on that side).
- **Edge lines**: solid lines along both road edges at the curbs,
running the full length of every edge.
- **Stop lines**: a perpendicular line across the incoming
(right-hand) half of each arm before an intersection (nodes with
more than two neighbors), placed at the wedge boundary with a small
setback so vehicles stop clear of the junction.
- **Intersection suppression**: no markings inside the intersection
area itself — at nodes with >2 neighbors the wedge surface stays
unmarked and all longitudinal lines end at the wedge boundary. At
2-neighbor nodes (turns) all markings continue through the corner.
**Road texture UVs**
- Proper UV coordinate generation in the road mesh: U runs across the
road and does NOT tile (stretched across the surface width), V runs
along the travel direction and tiles at a fixed world length per
tile (`RoadConfig::textureTileLength`), so long edges stop smearing.
- The road texture is a square atlas shared by all road surfaces;
U bands assign each surface its slice: 0.0-0.5 road-top asphalt,
0.5-0.75 sidewalk, 0.75-1.0 apron. Each strip maps its width into
its U band; V tiles along the length for all of them. The asphalt
band stretched across the lane width and tiled along the lane gives
a believable enough asphalt look.
**Decals**
- Per-edge decal list (serialized with the edge, editable in the road
panel): each decal is an offset along the edge, a lateral offset
from the centerline, a size, and a rectangle in a decal atlas
texture — manhole covers, painted text/arrows, road damage patches.
- The atlas for this phase is a generated placeholder (a few
procedural patches: manhole, arrows, damage spots, placeholder
text), replaced by a real authored texture later — the per-edge
decal data only references atlas rectangles, so swapping the texture
is free.
- Rendered as alpha-blended quads a hair above the road top. Note:
Ogre mainline has no decal system (only test-media images by that
name), so "Ogre decals" here means plain decal geometry built the
same way as the marking strips, sharing the decal atlas material.
Researched alternatives (2026-10-01): the only projection-decal
implementation is the old Ogre 1.7 wiki tutorial (Intermediate
Tutorial 6); current samples contain nothing newer — the
`decalmap` texture_unit names in `Examples-Advanced.material` are
just the legacy fixed-function term for the base diffuse map, and
`setProjectiveTexturing` only appears in the Fresnel sample for
reflections. A projective approach would also need receiver
filtering (it would paint terrain/vehicles/characters too) and one
projector pass per decal, so geometry decals stay the simpler and
batchable choice for a per-edge decal list.
- Decals are suppressed on junction wedges like the markings.
**Implementation**
- Markings and decals are thin geometry strips/quads built by
`RoadGeometryLib` a small offset above the road top, using the same
widened curb-offset chain machinery as sidewalks/aprons, so they
follow fillets, lane tapers and pin zones exactly. One
unlit/faintly-lit marking material (paint color in `RoadConfig`),
one decal material with the atlas.
- `RoadConfig` fields: `markingsEnabled`, line/dash widths, dash
length/gap, stop-line width and setback, `textureTileLength`,
`decalAtlasTexture`.
**Headless**
- Overlap-suite probes: marking strips present with the right counts
for 1/2/4-lane edges, dashed count = lanes-1 per side, dashed
centerline at 1+1 vs double solid when wider, solid edge lines at
both curbs, stop lines on the incoming half at 3-/4-way nodes and
none at 2-way nodes, markings continuing through 2-way turns,
nothing on the junction wedges, no marking/road-top z-overlap;
per-edge disable honored.
- UV probe: V tiles at the configured world length, U bands map
road/sidewalk/apron into 0.0-0.5 / 0.5-0.75 / 0.75-1.0.
- Decal probe: position/orientation derived from edge + lateral
offsets, rectangle mapped from the atlas, suppressed on junction
wedges.
**Regression fixes (2026-10)**
- Marking strips no longer spike vertically on pin-zone boundary rows:
the memoized `filletMiterPinZoneRaw` cache restored only the pin
point's X/Z, leaving Y uninitialized on cache hits, so the pinned
curb offset could point straight down with a garbage length (marking
vertices at y ~ -1.5e6 at runtime). The cache now round-trips the
full pin point.
- The generated road-atlas asphalt band uses anisotropic noise
(streaks along the lane) and default mipmaps; the isotropic
no-mipmap version read as cross-road moire straps at distance.
### Phase 3 — Road-network generator — DONE
The tedious part today: every node/edge is hand-placed. Build a
generator that emits a `RoadGraph` (region files when streaming):
generator that emits a `RoadGraph` (region files when streaming), plus
the graph-level services that both manual and procedural networks
need.
Implemented in `roadlib/RoadNetworkGen.hpp/.cpp` (namespace
`RoadNetworkGen`, pure `RoadGraph` functions, part of the
`RoadGeometryLib` target; see `ProceduralRoadGeometry.md` §19) with
`RoadSystem` wrappers (`generateRoadNetwork` /
`resolveCrossingsNow` / `clearGenerated`), serialization (region
files carry `generatedGroupId`; streets inline in the scene JSON or in
`roads/streets.json` when streaming; `crossingSnapDistance` /
`crossingHeightThreshold` in `roadConfig`), a terrain-panel "Road
Network Generator" section (Generate / Resolve Crossings Now /
per-group Remove; guards refuse Remove/Split on grouped nodes/edges;
the edge inspector assigns streets) and headless probes
(`runCrossingResolutionProbe`, `runLotProbe`, `runStreetProbe`,
`runGeneratorProbe` in `road_geometry_overlap_test` — suite PASSED).
**Crossing resolution (graph level)** — DONE
- Edges must never cross without a junction. Two edges count as
intersecting when the distance between their closest points is less
than or equal to the road width (resolved lane counts x laneWidth,
plus sidewalks where enabled) — unless the closest points are more
than 10 m apart in Y, in which case the edges are NOT intersecting
(overpass/underpass: one road legitimately passes over the other
and no junction is created). → `findCrossings()` with
`RoadConfig::crossingHeightThreshold` (default 10 m).
- On detection it must be possible to create a proper junction: split
both edges at the closest-point parameters and insert a new shared
node. → `resolveCrossing()` + `RoadGraph::splitEdge`.
- When a closest point is near an edge end (within a configurable snap
distance), reuse that existing node for the junction instead of
creating a new one; the other edge still splits.
→ `RoadConfig::crossingSnapDistance` (default 5 m).
- Edges too short to be split (the split would leave slivers below the
minimum edge length) are collapsed into the junction node.
→ `RoadGraph::mergeNodes()`.
- The graph must stay sane at all times: no duplicate edges, no
degenerate sliver edges, no dangling nodes, valences and region
files consistent after every operation. → `checkSane()`
(deliberately not `validate()` — its wedge-angle rules reject
hand-authored legacy scenes).
- Editor: a "resolve crossings" action (check, list, apply per
crossing or for all). The generator runs it automatically on its
own output and when connecting a procedural part to manual road.
→ "Resolve Crossings Now" button; `runCrossingResolution` on
`generateGrid()` and inside `connectGroupToNode()`. (A per-crossing
list/apply UI is deferred — the resolve-all action covers the
generator workflows.)
**Lots (enclosed areas)** — DONE
- Areas enclosed by road edges and not intersected by another edge
(faces of the planar road graph, after crossing resolution) are
tracked as potential lots — for manual and procedural networks
alike. Phase 4 consumes them for building placement.
→ `findLots()` (planar face walk, sharpest right turn from the
incoming heading with the U-turn edge excluded; spur/inner-node
rejection; area + centroid).
- Lot filtering: a face must fit a 45x45 m square (XZ bbox + area,
`RoadLotFilter::minLotSize`) and be bounded by at most 8 edges
(`maxBoundaryEdges`); every edge side bordering no accepted face
grows a square fallback lot (side = edge length) when the edge is
at least 45 m long (`edgeFallback`, overlap/cleanness checked).
→ `RoadLotFilter` defaults; `runLotProbe` covers the size/edge-count
cutoffs, the ring and lone-edge fallback cases.
- All potential lots are drawn as dark grey outline loops in road
edit mode, like the other helper geometry.
→ `RoadSystem::buildLotOutlines` (visual-aids overlay).
**Streets and addresses** — DONE
- A sequence of road edges can be marked as a street and given a name
(printable and code-accessible). Lots are referenced by street name
+ sequential number to form a unique address; lots on unnamed
streets are addressed by sequential number alone. These are mostly
display/reference labels (HUD fare text, debug panels).
→ `RoadGraph::streets`, `nameStreet()`, `findStreetForEdge()`,
`assignLotAddresses()` ("Lot N" fallback).
**Generator** — DONE
- Grid layout first (block size, road width, jitter), explicit seed,
fully deterministic. (Organic/radial layouts are deferred to
Phase 11.) A network is bounded to the settlement square
(max 1000 x 1000 m); generating another settlement is just another
generator invocation at a different origin.
→ `GridParams` + `generateGrid()` (mt19937 jitter on interior nodes
only; group id auto-assigned per invocation).
- A generated road grid is a single unsplittable unit in the road
graph — its interior nodes/edges are not individually editable. It
connects to manual road as if it were a road node; the connection to
the closest node of the procedural part is handled automatically
(and crossing resolution runs on the connecting edge).
→ `RoadNode::generatedGroupId` (editor guards), `removeGroup()`,
`connectGroupToNode()`.
- Terrain-aware placement: sample `getHeightAt`, reject or relocate
segments over water or too-steep slopes, then run the Phase 2
automatic compliance pass.
→ `HeightSampler` (wired to `TerrainSystem::getHeightAt` in
`RoadSystem::generateRoadNetwork`, water level from the scene's
`WaterPhysics` entity), `avoidWater` node rejection + `maxSlope`
edge skipping. (Relocating instead of rejecting and the automatic
post-generation compliance pass are left to Phase 4 workflows —
the user runs the existing compliance buttons.)
- Editor entry point: a "Generate Road Network" panel on the terrain
(parameters, seed, generate/regenerate, clear).
- Headless: generate a network, assert node/edge counts, intersection
valences, no self-intersections, region files written and reload
identically; two settlements in one world do not interfere.
→ "Road Network Generator" tree node in the terrain road section,
with a "Pick Origin on Terrain" button that arms a one-shot terrain
click to fill the origin XZ (`RoadSystem::armOriginPick` /
`takePickedOrigin`, consumed by
`EditorUISystem::handleRoadEditClick`) and a "Preview Grid" checkbox
that draws the would-be grid as orange lines without touching the
graph (`RoadSystem::updateGenPreview`/`clearGenPreview`, refreshed
on every parameter change).
**Headless** — DONE
- Generate a network; assert node/edge counts, intersection valences,
no self-intersections, region files written and reload identically;
two settlements in one world do not interfere.
→ `runGeneratorProbe` (determinism, counts, sanity, two settlements,
group connect/remove). (Region-file reload is covered indirectly:
group ids/streets ride the existing region JSON paths, and the
streets JSON round trip is asserted in `runStreetProbe`.)
- Crossing resolution: crossing edges become a 4-way junction, end
snap reuses the existing node, too-short edges collapse, and the
graph stays sane (no slivers/duplicates/dangling nodes) after each
case; an XZ crossing with more than 10 m of Y separation at the
closest points is left untouched (overpass).
→ `runCrossingResolutionProbe`.
- Lot tracking: enclosed blocks are found, areas intersected by an
edge are not, lot counts match the grid parameters.
→ `runLotProbe` + the 9-lot count in `runGeneratorProbe`.
- Streets: naming an edge sequence yields unique, stable addresses
across save/load; the unnamed-street fallback numbering works.
→ `runStreetProbe`.
### Phase 4 — Procedural town generation
@@ -1052,6 +1315,88 @@ On top of the generated road network:
all buildings on dry land above water level, every building carries
its (disabled) scene-switch door config, navmesh rebuilds.
**Phase 4 status (2026-10-04): implemented.** `TownGen` pure lib
(`roadlib/TownGen.hpp/.cpp`) + `TownGenSystem` + a "Town Generator"
section in the terrain panel; `findLots()` fallback squares gained
`sourceEdgeA`/`sourceEdgeB` so their frontage orients to the road and
addresses resolve through the street table. Done: district presets
(village/town/city-residential/city-center: footprint range, floors,
density, setback), seeded deterministic placement on `findLots()` lots,
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/
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
(`shrinkLotOffRoads()` — `findLots()` polygons run
centerline-to-centerline, so each bounding edge is inset by lanes +
sidewalk + apron + 0.3 m, collapses skip the lot; probe now asserts
every building corner keeps the road half-width off every edge
segment), buildings get their flat roof (a full-footprint
`RoofComponent` child at `posY = floors` — `buildRoofs()` only reads
RoofComponent children), and the building node is shifted so the
CellGrid shell (cells at `x*cellSize` from the node) lands centered
on the computed footprint with foundation/stairs/POI in shell
coordinates. Deferred: per-page building
streaming store (town buildings are always-loaded scene entities for
now — revisit when a settlement grows past a few hundred buildings),
street props (no lamp/sign/tree prefab assets exist yet — add assets,
then a roadside slot pass), terrain compliance per lot (superseded by
foundation blocks + stairs, which tolerate slopes directly).
### Phase 4.5 — Town parameters, building-layout DSL, multi-town areas (research + foundations)
Phase 4 proved the pipeline but exposed three structural gaps: town
regeneration discards all manual tuning (parameters live only in the
generator dialog, not on the map), buildings are fixed shells with no
layout control, and nothing scopes generation to one settlement when
the world holds several. This phase researches and lays the
foundations for all three. Research output lands in
`ProceduralRoadGeometry.md` (new §21) before implementation.
- **Per-lot parameters on road edge sides.** The persistent home for
per-building/per-lot settings is the **side of a road edge** (each
`RoadEdge` gets a left/right `RoadLotParams` block: district
override, density, setback, footprint/floor ranges, roof type and
params, no-build flag, layout reference). Lots are already derived
from edge frontages, so edge-side parameters survive regeneration —
re-running the town generator re-reads them instead of resetting
them. Persisted with the graph (inline scene JSON, region files
when streaming), edited in the edge inspector. Research: the exact
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).
- **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()`
results are filtered per area, each area generates into its own
`"TownGen <name>"` container, and a world can hold several
independent settlements from the start. Research: area/lot overlap
rules (lots straddling the boundary), area editing UX (viewport
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.
- **Docs:** `ProceduralRoadGeometry.md` §21 (params model, DSL
grammar, areas), AGENTS.md, this plan's status blocks.
### Phase 5 — NPC vehicle traffic
- AI driver system (new, engine-level): a vehicle follows the road
@@ -1180,6 +1525,39 @@ demo needs them:
- More vehicle types beyond sedans (the Phase 1 pipeline is already
generic): vans, trucks, buses; service vehicles.
### Phase 11.5 — Geometry polish, marking depth & render batching
Deferred hardening items (noted 2026-10-03 — fix, don't forget). Pull
the phase earlier if any item starts blocking daily work; all three
must land before the Phase 12 performance/polish pass.
- **Wedge midpoint relaxation (inner corners).** Inner (converging)
corner wedges can still overlap neighbouring geometry on tight
turns. Relax the midpoints of wedges with an inner corner outward
(per corner, in the spirit of `relaxImpossibleCorners()` but acting
on the wedge midpoint/seam side instead of the node) so the overlap
clears and the corner looks nicer. The center gap this opens is
filled by shifting the edge vertices as usual (the existing mid-edge
seam machinery), so the mid-edge seams stay gapless. Headless:
tight-corner overlap-suite cases keep zero crossing/coplanar pairs
with relaxation on, and straight/near-straight corners are
unaffected (signature-gated like the other rebuild triggers).
- **Marking Z-fighting.** The marking/decal overlays (2 cm lift) still
Z-fight the road top at grazing angles and at distance. Fix at the
material/shader level instead of adding more lift: bias the overlay
depth toward the camera (Ogre depth-bias material settings, or a
small custom-shader offset pulling `gl_Position` toward the camera),
so the paint stays visually coplanar but always wins the depth test.
Verify on slopes, at long distance and against the decal material.
- **Render batching.** The bare exterior scene already draws ~85
batches; a full settlement will explode. First measure what is hot
(road page chunks, marking/decal overlays, terrain, spawner
prefabs), then batch: merge same-material road/overlay chunks into
fewer meshes per page, instance repeated props (lamps, signs,
trees), static-geometry batching for placed buildings, and material/
texture atlasing where it cuts state switches. Define the target
batch budget after measuring; the Phase 12 FPS check enforces it.
### Phase 12 — Demo assembly, polish, docs
- Assemble the full demo scene: at least one full settlement on the
@@ -1189,7 +1567,8 @@ demo needs them:
"home"/menu-adjacent space.
- Balance pass: speeds, timers, fare slack, traffic density.
- Performance pass: streaming radii, navmesh tile budget, active NPC
caps — measure FPS with both settlements placed.
caps — measure FPS with both settlements placed; verify the Phase
11.5 batch budget holds in the assembled scene.
- Docs: update `AGENTS.md` (both levels), `GameFeatures202609.md`
(F13+ entries), demo README section; full headless test battery
wired into the demo like demo-sokoban's.
@@ -849,15 +849,15 @@
},
"transform": {
"position": {
"x": 4004.76416015625,
"y": 10.909640312194824,
"z": 3964.12255859375
"x": 4002.519287109375,
"y": 11.094489097595215,
"z": 3962.923095703125
},
"rotation": {
"w": 0.991229236125946,
"x": -0.053019944578409195,
"y": 0.12085098028182983,
"z": 0.0069722202606499195
"w": 0.9888900518417358,
"x": -0.0026904146652668715,
"y": 0.14861378073692322,
"z": 0.0017891188617795706
},
"scale": {
"x": 1.0,
@@ -1254,15 +1254,15 @@
},
"transform": {
"position": {
"x": 4017.775390625,
"y": 10.960867881774902,
"z": 3963.472900390625
"x": 4016.510498046875,
"y": 11.157183647155762,
"z": 3964.1669921875
},
"rotation": {
"w": 0.9645168781280518,
"x": -0.05992506444454193,
"y": 0.25656017661094666,
"z": 0.017121003940701485
"w": 0.9334654211997986,
"x": -0.0019687723834067583,
"y": 0.35863012075424194,
"z": 0.004789790604263544
},
"scale": {
"x": 1.0,
@@ -1470,10 +1470,22 @@
"roadConfig": {
"apronDrop": 1.0,
"apronEnabled": true,
"apronSkirtDepth": 2.0,
"apronWidth": 2.0,
"cornerFilletLength": 12.0,
"crossingHeightThreshold": 10.0,
"crossingSnapDistance": 5.0,
"decalMaterialName": "RoadDecalMaterial",
"laneWidth": 5.0,
"lanesPerDirection": 1,
"markingDashGap": 3.0,
"markingDashLength": 3.0,
"markingEdgeInset": 0.25,
"markingLineWidth": 0.15000000596046448,
"markingMaterialName": "RoadMarkingMaterial",
"markingStopSetback": 1.0,
"markingStopWidth": 0.4000000059604645,
"markingsEnabled": true,
"prefabDespawnDistance": 250.0,
"prefabSpawnDistance": 150.0,
"roadLodDistance": 200.0,
@@ -1485,7 +1497,8 @@
"sidewalkHeight": 0.15000000596046448,
"sidewalkMeshTemplate": "",
"sidewalkThickness": 0.30000001192092896,
"sidewalkWidth": 1.5
"sidewalkWidth": 1.5,
"textureTileLength": 10.0
},
"streamingEnabled": true,
"terrainId": 4242424300000001,
@@ -1595,7 +1608,7 @@
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 15.492432594299316,
"timeOfDay": 0.6397704482078552,
"timeSpeed": 0.12999999523162842
},
"transform": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+78 -5
View File
@@ -5,6 +5,9 @@
#include "systems/SceneSerializer.hpp"
#include "systems/CharacterRegistry.hpp"
#include "systems/TerrainTests.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/TownGenSystem.hpp"
#include "roadlib/TownGen.hpp"
#include "OgreRoot.h"
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
@@ -21,6 +24,47 @@ struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
}
};
/** Frame-driven town generation (editor, headless testing).
* Waits a few frames for the terrain/road systems to activate,
* generates a town from the loaded scene's road graph, then exits.
* Exceptions propagate to main()'s handler (exit code 1). */
struct GenerateTownFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
TownGen::TownParams params;
int frames = 0;
int result = -1;
GenerateTownFrameListener(Ogre::Root *r,
const TownGen::TownParams &p)
: root(r), params(p) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (++frames < 5)
return true;
if (result >= 0 || frames > 3600) {
if (result < 0) {
std::cerr << "generate-town: timed out waiting "
<< "for the terrain" << std::endl;
result = 0;
}
root->queueEndRendering();
return true;
}
TerrainSystem *ts = TerrainSystem::getInstance();
TownGenSystem *townGen =
ts ? ts->getTownGenSystem() : nullptr;
if (!townGen)
return true; /* terrain not active yet */
std::string error;
int count = townGen->generate(params, &error);
std::cout << "generate-town: " << count << " buildings"
<< (error.empty() ? "" : " (" + error + ")")
<< std::endl;
result = 0;
root->queueEndRendering();
return true;
}
};
/** Frame-driven terrain test runner.
* Terrain init needs the render loop active (GPU context).
* Runs the suite on the first frame, then exits. */
@@ -65,6 +109,8 @@ int main(int argc, char *argv[])
bool exitAfterFirstFrame = false;
bool headless = false;
int terrainTestIterations = 0;
bool generateTown = false;
TownGen::TownParams genTownParams;
Ogre::String sceneFile;
Ogre::String projectDir;
for (int i = 1; i < argc; i++) {
@@ -82,6 +128,29 @@ int main(int argc, char *argv[])
debugBuoyancy = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg.find("--generate-town=") == 0) {
/* --generate-town=<district>[,<seed>] with district
* village|town|city-residential|city-center */
generateTown = true;
Ogre::String val = arg.substr(16);
size_t comma = val.find(',');
Ogre::String district = val.substr(0, comma);
if (comma != Ogre::String::npos)
genTownParams.seed =
Ogre::StringConverter::parseInt(
val.substr(comma + 1), 1);
if (district == "town")
genTownParams.district =
TownGen::District::Town;
else if (district == "city-residential")
genTownParams.district =
TownGen::District::CityResidential;
else if (district == "city-center")
genTownParams.district =
TownGen::District::CityCenter;
else
genTownParams.district =
TownGen::District::Village;
} else if (arg.find("--run-terrain-tests=") == 0) {
Ogre::String val = arg.substr(20);
terrainTestIterations = Ogre::StringConverter::parseInt(
@@ -190,13 +259,17 @@ int main(int argc, char *argv[])
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame)
app.getRoot()->addFrameListener(&exitListener);
GenerateTownFrameListener genTownListener(app.getRoot(),
genTownParams);
if (generateTown)
app.getRoot()->addFrameListener(&genTownListener);
app.getRoot()->startRendering();
/* Destroy scene entities while the systems are still alive
* (same teardown-ordering reason as the demo mains: spawner
* OnRemove observers dereference their systems). */
if (gameSceneStarted)
app.clearScene();
/* Destroy scene entities while the systems (and OGRE) are
* still alive: components holding OGRE resources (procedural
* materials/textures, spawner OnRemove observers) must not
* be torn down at ecs_fini after Root is gone. */
app.clearScene();
app.closeApp();
/* Propagate terrain test result as process exit code so CI can
@@ -63,6 +63,29 @@ float halfEdgeU(const RoadHalfEdge &he, const RoadGraph &graph, float t)
return t;
}
/* ----------------------------------------------------------------
* Road atlas UV mapping (phase 2.5)
*
* When RoadConfig::textureTileLength > 0 the road mesh UVs are
* regenerated as an atlas mapping instead of meter coordinates: U runs
* ACROSS the surface (no tiling) and selects the band of the shared
* road atlas texture (0.0-0.5 asphalt road top, 0.5-0.75 sidewalk,
* 0.75-1.0 apron), while V runs along the travel direction and tiles
* once per textureTileLength meters. textureTileLength == 0 keeps the
* legacy meter UVs (U along the road, V lateral meters).
* ---------------------------------------------------------------- */
static bool atlasUVsEnabled(const RoadGraph &graph)
{
return graph.config.textureTileLength > 1e-6f;
}
/** Along-road V coordinate: meters tiled by textureTileLength. */
static float atlasV(const RoadGraph &graph, float meters)
{
return meters / graph.config.textureTileLength;
}
float nodeRoadLevel(const RoadGraph &graph, int nodeId)
{
float sum = 0.0f;
@@ -1685,7 +1708,7 @@ static bool filletMiterPinZoneRaw(const RoadWedge &wedge,
bool sidewalkEnabled, apronEnabled;
bool has;
float pinStart, pinEnd;
float pinX, pinZ;
float pinX, pinY, pinZ;
bool impossible;
};
static std::vector<PinZoneCacheEntry> s_cache;
@@ -1735,6 +1758,7 @@ static bool filletMiterPinZoneRaw(const RoadWedge &wedge,
pinStart = e.pinStart;
pinEnd = e.pinEnd;
pinPoint.x = e.pinX;
pinPoint.y = e.pinY;
pinPoint.z = e.pinZ;
}
if (impossible)
@@ -1762,6 +1786,7 @@ static bool filletMiterPinZoneRaw(const RoadWedge &wedge,
key.pinStart = ps;
key.pinEnd = pe;
key.pinX = pp.x;
key.pinY = pp.y;
key.pinZ = pp.z;
key.impossible = imp;
if (s_cache.size() >= 128)
@@ -2187,9 +2212,24 @@ void transformWedgeVertices(Procedural::TriangleBuffer &strip,
/* UVs. */
float widthD = off.length();
v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
v.mUV.y = v.mUV.y * widthD + in1;
float uMeters = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
if (atlasUVsEnabled(graph)) {
/* U across the asphalt band (0..0.5): the lateral
* meter position from the lanesIn-side curb over the
* arm's full road width; V tiles along the road. */
const RoadHalfEdge &hArm = (d <= L1) ? h1 : h2;
float lw = graph.config.laneWidth;
float fullW = (hArm.lanesIn + hArm.lanesOut) * lw;
if (fullW < 1e-4f)
fullW = 1e-4f;
float latM = hArm.lanesIn * lw + v.mUV.y * widthD;
v.mUV.x = 0.5f * latM / fullW;
v.mUV.y = atlasV(graph, uMeters);
} else {
v.mUV.x = uMeters;
v.mUV.y = v.mUV.y * widthD + in1;
}
/*
* Normal. The wedge bend is a reflection of the template:
@@ -2512,10 +2552,21 @@ bool computeSegmentBand(const RoadStraightSegment &segment,
c[0].y = c[3].y = y0;
c[1].y = c[2].y = yL;
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), inW + outW);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), inW + outW);
if (atlasUVsEnabled(graph)) {
/* U across the asphalt band (0 at the -r curb, 0.5 at +r),
* V tiles along the road. */
float v0 = atlasV(graph, halfEdgeU(he, graph, t0));
float v1 = atlasV(graph, halfEdgeU(he, graph, L));
uvc[0] = Ogre::Vector2(0.0f, v0);
uvc[1] = Ogre::Vector2(0.0f, v1);
uvc[2] = Ogre::Vector2(0.5f, v1);
uvc[3] = Ogre::Vector2(0.5f, v0);
} else {
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), inW + outW);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), inW + outW);
}
return true;
}
@@ -2650,10 +2701,18 @@ static void transformSidewalkVertices(Procedural::TriangleBuffer &strip,
}
float worldY = surfY + lift + v.mPosition.y;
/* UVs: phase-continuous u, v across [0, sidewalkWidth]. */
v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
v.mUV.y = v.mUV.y * sw;
/* UVs: phase-continuous u, v across [0, sidewalkWidth]; with
* road-atlas UVs (textureTileLength > 0) U selects the
* sidewalk band (0.5-0.75) and V tiles along the road. */
float uMeters = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
if (atlasUVsEnabled(graph)) {
v.mUV.x = 0.5f + 0.25f * v.mUV.y;
v.mUV.y = atlasV(graph, uMeters);
} else {
v.mUV.x = uMeters;
v.mUV.y = v.mUV.y * sw;
}
/* Normals: same explicit axis mapping as the road (the
* bend is a reflection of the template). */
@@ -3009,10 +3068,20 @@ bool computeSegmentSidewalkBand(const RoadStraightSegment &segment,
c[0].y = c[3].y = halfEdgeHeightAt(he, graph, t0) + sh;
c[1].y = c[2].y = halfEdgeHeightAt(he, graph, L) + sh;
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), sw);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), sw);
if (atlasUVsEnabled(graph)) {
/* Sidewalk band (U 0.5-0.75), V tiles along the road. */
float v0 = atlasV(graph, halfEdgeU(he, graph, t0));
float v1 = atlasV(graph, halfEdgeU(he, graph, L));
uvc[0] = Ogre::Vector2(0.5f, v0);
uvc[1] = Ogre::Vector2(0.5f, v1);
uvc[2] = Ogre::Vector2(0.75f, v1);
uvc[3] = Ogre::Vector2(0.75f, v0);
} else {
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), sw);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), sw);
}
return true;
}
@@ -3205,10 +3274,18 @@ static void transformApronVertices(Procedural::TriangleBuffer &strip,
v.mPosition.x * drop * xSlope +
v.mPosition.y;
/* UVs: phase-continuous u, v across [0, apronWidth]. */
v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
v.mUV.y = v.mUV.y * aw;
/* UVs: phase-continuous u, v across [0, apronWidth]; with
* road-atlas UVs (textureTileLength > 0) U selects the
* apron band (0.75-1.0) and V tiles along the road. */
float uMeters = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
: halfEdgeU(h2, graph, d - L1);
if (atlasUVsEnabled(graph)) {
v.mUV.x = 0.75f + 0.25f * v.mUV.y;
v.mUV.y = atlasV(graph, uMeters);
} else {
v.mUV.x = uMeters;
v.mUV.y = v.mUV.y * aw;
}
/* Normals: same explicit axis mapping as the road (the
* bend is a reflection of the template). */
@@ -3375,10 +3452,20 @@ bool buildSegmentApronGeometry(const RoadStraightSegment &segment,
c[3].y = c[0].y - drop;
Ogre::Vector2 uvc[4];
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), aw);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), aw);
if (atlasUVsEnabled(graph)) {
/* Apron band (U 0.75-1.0), V tiles along the road. */
float v0 = atlasV(graph, halfEdgeU(he, graph, t0));
float v1 = atlasV(graph, halfEdgeU(he, graph, L));
uvc[0] = Ogre::Vector2(0.75f, v0);
uvc[1] = Ogre::Vector2(0.75f, v1);
uvc[2] = Ogre::Vector2(1.0f, v1);
uvc[3] = Ogre::Vector2(1.0f, v0);
} else {
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), aw);
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), aw);
}
/* The slab carries the apron skirt: bottom face and outer
* wall extend apronSkirtDepth below the sloped top so the
@@ -3430,6 +3517,448 @@ bool buildSegmentApronGeometry(const RoadStraightSegment &segment,
return built;
}
/* ----------------------------------------------------------------
* Road markings (phase 2.5)
*
* Painted overlay strips sampled from the same wedge centerline/curb
* chains as the road surface, emitted slightly above the road top so
* they read as paint. Purely visual: these buffers never feed
* physics, navmesh or terrain compliance.
*
* Per-wedge coverage rule (each piece painted exactly once): a wedge's
* first arm (d in [0, L1]) paints the centerline plus the OUTGOING
* side of its edge (dashed lane lines between same-direction lanes and
* the solid curb edge line); the second arm paints only the INCOMING
* side of its edge. Every half-edge is the first arm of exactly one
* wedge and the second arm of exactly one wedge, so the centerline and
* both road sides of every edge are painted exactly once. At junction
* nodes (degree > 2) every arm stops short of the corner by a clear
* zone (the fillet extent, or the widest incident half-width when no
* fillet is active, plus markingStopSetback) and the incoming arm of
* each wedge carries a stop line at the clear boundary; at 2-neighbor
* nodes the markings follow the corner through (no clear zone, no stop
* line). The corner pin zone is cut from all ranges.
* ---------------------------------------------------------------- */
static const float ROAD_MARKING_LIFT = 0.02f;
static const float ROAD_DECAL_LIFT = 0.025f;
static const float ROAD_MARKING_STEP = 0.5f;
/** One paint-height sample of a marking strip row. */
struct MarkingSample {
/** XZ at the road centerline, Y at paint height. */
Ogre::Vector3 center;
/** Normalized horizontal direction toward the covered side. */
Ogre::Vector3 latDir;
/** Curb distance from the centerline along latDir. */
float widthD = 0.0f;
};
static bool sampleWedgeMarking(const RoadWedge &wedge,
const RoadGraph &graph, float d, float yO,
float halfThick, MarkingSample &s)
{
const RoadHalfEdge &h1 = wedge.first;
const RoadHalfEdge &h2 = wedge.second;
float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f;
s.center = wedgeCenterAt(wedge, graph, d);
Ogre::Vector3 off = computeCurbOffset(wedge, graph, d);
s.widthD = off.length();
s.latDir = off;
if (s.latDir.length() < 1e-4f)
s.latDir = (d <= L1) ? roadRightVec(h1.direction) :
-roadRightVec(h2.direction);
else
s.latDir.normalise();
float surfY;
if (d < L1 - 1e-4f)
surfY = halfEdgeHeightAt(h1, graph, L1 - d);
else if (d > L1 + 1e-4f)
surfY = halfEdgeHeightAt(h2, graph, d - L1);
else
surfY = yO;
s.center.y = surfY + halfThick + ROAD_MARKING_LIFT;
return s.widthD > 1e-3f;
}
static void emitMarkingQuad(Procedural::TriangleBuffer &out,
const MarkingSample &a, const MarkingSample &b,
float latA0, float latA1, float latB0, float latB1)
{
Ogre::Vector3 p00 = a.center + a.latDir * latA0;
Ogre::Vector3 p01 = a.center + a.latDir * latA1;
Ogre::Vector3 p10 = b.center + b.latDir * latB0;
Ogre::Vector3 p11 = b.center + b.latDir * latB1;
Ogre::Vector2 uv(0.0f, 0.0f);
emitTri(out, p00, p10, p11, uv, uv, uv);
emitTri(out, p00, p11, p01, uv, uv, uv);
}
/**
* Emit one solid marking strip between path distances d0 and d1.
* When @p curbRelative is false, [lat0, lat1] are fixed lateral offsets
* from the centerline; when true, lat0 is the curb inset and lat1 the
* line width (the strip follows the curb at widthD - inset).
*/
static void emitMarkingLine(Procedural::TriangleBuffer &out,
const RoadWedge &wedge, const RoadGraph &graph,
float d0, float d1, float yO, float halfThick,
float lat0, float lat1, bool curbRelative)
{
if (d1 - d0 < 1e-3f)
return;
int rows = std::max(1, (int)std::ceil((d1 - d0) / ROAD_MARKING_STEP));
MarkingSample prev, cur;
if (!sampleWedgeMarking(wedge, graph, d0, yO, halfThick, prev))
return;
for (int i = 1; i <= rows; ++i) {
float d = d0 + (d1 - d0) * (float)i / (float)rows;
if (!sampleWedgeMarking(wedge, graph, d, yO, halfThick, cur))
return;
float a0, a1, b0, b1;
if (curbRelative) {
a1 = prev.widthD - lat0;
a0 = a1 - lat1;
b1 = cur.widthD - lat0;
b0 = b1 - lat1;
/* Pinched corner rows narrower than inset+width:
* clamp to a sliver next to the centerline. */
a0 = std::max(a0, 0.02f);
b0 = std::max(b0, 0.02f);
if (a0 >= a1 - 1e-4f || b0 >= b1 - 1e-4f) {
prev = cur;
continue;
}
} else {
a0 = b0 = lat0;
a1 = b1 = lat1;
/* Skip quads that would hang past the curb. */
if (lat1 > prev.widthD - 0.02f ||
lat1 > cur.widthD - 0.02f) {
prev = cur;
continue;
}
}
emitMarkingQuad(out, prev, cur, a0, a1, b0, b1);
prev = cur;
}
}
static void emitMarkingDashed(Procedural::TriangleBuffer &out,
const RoadWedge &wedge, const RoadGraph &graph,
float d0, float d1, float yO, float halfThick,
float latCenter, float lineWidth)
{
float dl = graph.config.markingDashLength;
float dg = graph.config.markingDashGap;
float s = d0;
while (s < d1 - 1e-3f) {
float e = std::min(s + dl, d1);
emitMarkingLine(out, wedge, graph, s, e, yO, halfThick,
latCenter - lineWidth * 0.5f,
latCenter + lineWidth * 0.5f, false);
s = e + dg;
}
}
/** Subtract the corner pin zone [ps, pe] from [d0, d1]. */
static void cutPinZone(float d0, float d1, float ps, float pe, bool pin,
std::vector<std::pair<float, float>> &out)
{
if (!pin) {
out.push_back(std::make_pair(d0, d1));
return;
}
if (d0 < ps - 1e-4f)
out.push_back(std::make_pair(d0, std::min(d1, ps)));
if (d1 > pe + 1e-4f)
out.push_back(std::make_pair(std::max(d0, pe), d1));
}
/** Widest incident half-width at a node (junction clear base). */
static float junctionHalfWidth(const RoadGraph &graph, int nodeId)
{
float w = 0.0f;
for (int nid : graph.getNeighborIds(nodeId)) {
int ei = graph.findEdgeIndex(nodeId, nid);
if (ei < 0)
continue;
int lo = 0, li = 0;
graph.resolveLaneCounts(graph.edges[(size_t)ei], nodeId, lo,
li);
w = std::max(w, (lo + li) * graph.config.laneWidth * 0.5f);
}
return w;
}
bool buildWedgeMarkings(const RoadWedge &wedge, const RoadGraph &graph,
RoadMarkingBuffers &out)
{
if (wedge.degenerate)
return false;
const RoadNode *node = graph.findNodeById(wedge.nodeId);
if (!node)
return false;
const RoadHalfEdge &h1 = wedge.first;
const RoadHalfEdge &h2 = wedge.second;
const RoadConfig &cfg = graph.config;
float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f;
float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f;
float lw = cfg.laneWidth;
float lineW = cfg.markingLineWidth;
float yO = node->position.y + nodeRoadLevel(graph, wedge.nodeId);
float halfThick = std::max(0.01f, cfg.roadThickness) * 0.5f;
bool junction = graph.getNeighborIds(wedge.nodeId).size() > 2;
WedgeFillet f = wedgeFillet(wedge, graph);
float clearBase = junctionHalfWidth(graph, wedge.nodeId);
float ps = 0.0f, pe = 0.0f;
bool pin = filletPinZone(wedge, graph, ps, pe, nullptr);
bool built = false;
for (int arm = 0; arm < 2; ++arm) {
const RoadHalfEdge &h = arm == 0 ? h1 : h2;
if (h.edgeIndex < 0 || h.edgeIndex >= (int)graph.edges.size())
continue;
if (!graph.edgeMarkingsEnabled(
graph.edges[(size_t)h.edgeIndex]))
continue;
/* Covered side lane count: a wedge paints its first arm's
* OUTGOING side and its second arm's INCOMING side. */
int sideLanes = arm == 0 ? h.lanesOut : h.lanesIn;
float clear = junction ? (f.active ? (arm == 0 ? f.f1 : f.f2)
: clearBase) +
cfg.markingStopSetback
: 0.0f;
float d0 = arm == 0 ? 0.0f : L1 + clear;
float d1 = arm == 0 ? L1 - clear : L1 + L2;
if (d1 - d0 < 1e-3f)
continue;
/* Stop line: junctions only, on the incoming half — the
* second arm of the wedge covers the incoming side. */
float sw = cfg.markingStopWidth;
bool stopLine = junction && arm == 1 && h.lanesIn > 0 &&
L1 + clear + sw < L1 + L2 - 1e-3f;
/* When a stop line is emitted the longitudinal lines of this
* arm resume past it, so the perpendicular bar never
* z-fights the lane/edge strips crossing its span. */
std::vector<std::pair<float, float>> ranges;
cutPinZone(stopLine ? L1 + clear + sw : d0, d1, ps, pe, pin,
ranges);
for (const auto &rg : ranges) {
/* Centerline: only the first arm (each half-edge
* is the first arm of exactly one wedge). */
if (arm == 0) {
if (h.lanesOut >= 2 || h.lanesIn >= 2) {
float g = lineW * 0.5f;
emitMarkingLine(out.centerline, wedge,
graph, rg.first,
rg.second, yO,
halfThick, -g - lineW,
-g, false);
emitMarkingLine(out.centerline, wedge,
graph, rg.first,
rg.second, yO,
halfThick, g,
g + lineW, false);
} else {
emitMarkingDashed(out.centerline, wedge,
graph, rg.first,
rg.second, yO,
halfThick, 0.0f,
lineW);
}
}
/* Dashed lines between same-direction lanes. */
for (int k = 1; k < sideLanes; ++k)
emitMarkingDashed(out.laneLines, wedge, graph,
rg.first, rg.second, yO,
halfThick, k * lw, lineW);
/* Solid edge line at the curb. */
emitMarkingLine(out.edgeLines, wedge, graph, rg.first,
rg.second, yO, halfThick,
cfg.markingEdgeInset, lineW, true);
built = true;
}
if (stopLine) {
MarkingSample sa, sb;
if (sampleWedgeMarking(wedge, graph, L1 + clear, yO,
halfThick, sa) &&
sampleWedgeMarking(wedge, graph, L1 + clear + sw,
yO, halfThick, sb)) {
/* The bar starts flush at the centerline
* strip's outer edge (the centerline painted
* by the sibling wedge reaches the clear
* boundary at the same d), so the two never
* overlap coplanar. */
float latA = (h.lanesOut >= 2 || h.lanesIn >= 2) ?
1.5f * lineW :
0.5f * lineW;
float latB = h.lanesIn * lw;
latB = std::min(
latB,
std::min(sa.widthD, sb.widthD) - 0.01f);
if (latB > latA + 0.05f) {
emitMarkingQuad(out.stopLines, sa, sb,
latA, latB, latA, latB);
built = true;
}
}
}
}
return built;
}
bool buildSegmentMarkings(const RoadStraightSegment &segment,
const RoadGraph &graph, RoadMarkingBuffers &out)
{
const RoadNode *node = graph.findNodeById(segment.nodeId);
if (!node)
return false;
const RoadHalfEdge &he = segment.halfEdge;
if (he.lanesIn + he.lanesOut < 1)
return false;
if (he.edgeIndex < 0 || he.edgeIndex >= (int)graph.edges.size())
return false;
if (!graph.edgeMarkingsEnabled(graph.edges[(size_t)he.edgeIndex]))
return false;
const RoadConfig &cfg = graph.config;
float lw = cfg.laneWidth;
float lineW = cfg.markingLineWidth;
float inW = he.lanesIn * lw;
float outW = he.lanesOut * lw;
float L = he.halfLength;
float halfThick = std::max(0.01f, cfg.roadThickness) * 0.5f;
const Ogre::Vector3 &O = node->position;
Ogre::Vector3 d = he.direction;
Ogre::Vector3 r = roadRightVec(d);
/* Straight sampler: lat < 0 is the incoming (-r) side. */
auto sampleAt = [&](float t, MarkingSample &s) {
s.center = O + d * t;
s.center.y = halfEdgeHeightAt(he, graph, t) + halfThick +
ROAD_MARKING_LIFT;
s.latDir = r;
s.widthD = std::max(inW, outW);
};
auto emitSeg = [&](Procedural::TriangleBuffer &buf, float ta, float tb,
float lat0, float lat1) {
MarkingSample sa, sb;
sampleAt(ta, sa);
sampleAt(tb, sb);
emitMarkingQuad(buf, sa, sb, lat0, lat1, lat0, lat1);
};
auto line = [&](Procedural::TriangleBuffer &buf, float lat0,
float lat1) {
int rows = std::max(1, (int)std::ceil(L / ROAD_MARKING_STEP));
for (int i = 0; i < rows; ++i)
emitSeg(buf, L * (float)i / (float)rows,
L * (float)(i + 1) / (float)rows, lat0, lat1);
};
auto dashed = [&](Procedural::TriangleBuffer &buf, float latC) {
float s = 0.0f;
while (s < L - 1e-3f) {
float e = std::min(s + cfg.markingDashLength, L);
int rows = std::max(
1, (int)std::ceil((e - s) / ROAD_MARKING_STEP));
for (int i = 0; i < rows; ++i)
emitSeg(buf, s + (e - s) * (float)i /
(float)rows,
s + (e - s) * (float)(i + 1) /
(float)rows,
latC - lineW * 0.5f,
latC + lineW * 0.5f);
s = e + cfg.markingDashGap;
}
};
if (he.lanesOut >= 2 || he.lanesIn >= 2) {
float g = lineW * 0.5f;
line(out.centerline, -g - lineW, -g);
line(out.centerline, g, g + lineW);
} else {
dashed(out.centerline, 0.0f);
}
for (int k = 1; k < he.lanesOut; ++k)
dashed(out.laneLines, k * lw);
for (int k = 1; k < he.lanesIn; ++k)
dashed(out.laneLines, -(float)k * lw);
/* Solid edge lines at both curbs; dead ends have no stop lines. */
if (outW > cfg.markingEdgeInset + lineW)
line(out.edgeLines, outW - cfg.markingEdgeInset - lineW,
outW - cfg.markingEdgeInset);
if (inW > cfg.markingEdgeInset + lineW)
line(out.edgeLines, -(inW - cfg.markingEdgeInset),
-(inW - cfg.markingEdgeInset - lineW));
return true;
}
void buildEdgeDecals(const RoadGraph &graph, size_t edgeIndex,
Procedural::TriangleBuffer &out, int ownerNodeId)
{
if (edgeIndex >= graph.edges.size())
return;
const RoadEdge &e = graph.edges[edgeIndex];
if (e.decals.empty())
return;
const RoadNode *na = graph.findNodeById(e.nodeA);
const RoadNode *nb = graph.findNodeById(e.nodeB);
if (!na || !nb)
return;
const RoadConfig &cfg = graph.config;
float halfThick = std::max(0.01f, cfg.roadThickness) * 0.5f;
Ogre::Vector3 A = na->position, B = nb->position;
Ogre::Vector3 dir = B - A;
dir.y = 0.0f;
float len = dir.normalise();
if (len < 1e-3f)
return;
Ogre::Vector3 r = roadRightVec(dir);
float yA = A.y + e.roadLevelA;
float yB = B.y + e.roadLevelB;
float yMid = 0.5f * (yA + yB);
float halfLen = len * 0.5f;
for (const RoadEdgeDecal &dec : e.decals) {
float te = std::max(0.0f, std::min(1.0f, dec.edgeT));
if (ownerNodeId >= 0) {
int owner = te <= 0.5f ? e.nodeA : e.nodeB;
if (owner != ownerNodeId)
continue;
}
float t = te * len;
/* Piecewise-linear surface height with the midpoint kink. */
float surfY = t <= halfLen ?
yA + (yMid - yA) * (t / halfLen) :
yMid + (yB - yMid) * ((t - halfLen) / halfLen);
Ogre::Vector3 c = A + dir * t + r * dec.lateralOffset;
c.y = surfY + halfThick + ROAD_DECAL_LIFT;
float hs = dec.size * 0.5f;
Ogre::Vector3 p00 = c - dir * hs - r * hs;
Ogre::Vector3 p01 = c - dir * hs + r * hs;
Ogre::Vector3 p10 = c + dir * hs - r * hs;
Ogre::Vector3 p11 = c + dir * hs + r * hs;
Ogre::Vector2 uv00(dec.uvX, dec.uvY);
Ogre::Vector2 uv01(dec.uvX, dec.uvY + dec.uvH);
Ogre::Vector2 uv10(dec.uvX + dec.uvW, dec.uvY);
Ogre::Vector2 uv11(dec.uvX + dec.uvW, dec.uvY + dec.uvH);
emitTri(out, p00, p10, p11, uv00, uv10, uv11);
emitTri(out, p00, p11, p01, uv00, uv11, uv01);
}
}
/* ----------------------------------------------------------------
* Template mesh loading
* ---------------------------------------------------------------- */
@@ -121,6 +121,63 @@ bool buildSegmentApronGeometry(const RoadStraightSegment &segment,
const RoadGraph &graph,
Procedural::TriangleBuffer &out);
/* ----------------------------------------------------------------
* Road markings and decals (phase 2.5)
* ---------------------------------------------------------------- */
/**
* Output buckets for buildWedgeMarkings / buildSegmentMarkings. The
* kinds are kept apart so the caller can merge them into one overlay
* mesh (same material) while tests can count them separately.
*/
struct RoadMarkingBuffers {
Procedural::TriangleBuffer centerline;
Procedural::TriangleBuffer laneLines;
Procedural::TriangleBuffer edgeLines;
Procedural::TriangleBuffer stopLines;
};
/**
* Build the painted marking strips of one wedge (phase 2.5) and append
* to @p out: centerline (double solid when either direction has 2+
* lanes, single dashed otherwise), dashed lane lines between
* same-direction lanes, solid edge lines at the curbs and — at junction
* nodes (degree > 2) — a stop line on the incoming half of the second
* arm (flush at the centerline strip; the arm's longitudinal lines
* resume past it so the bar never z-fights them). Junction arms stop
* short of the corner by a clear zone; at
* 2-neighbor nodes markings follow the corner through. Per-edge
* gating via RoadGraph::edgeMarkingsEnabled.
*
* The strips hover ROAD_MARKING_LIFT above the road top and are purely
* visual (no physics/navmesh/compliance contribution).
*
* @return false when the wedge is degenerate or nothing was emitted.
*/
bool buildWedgeMarkings(const RoadWedge &wedge, const RoadGraph &graph,
RoadMarkingBuffers &out);
/**
* Build the marking strips of a dead-end straight segment and append
* to @p out. Same rules as buildWedgeMarkings minus the junction
* handling (no clear zone, no stop lines).
*/
bool buildSegmentMarkings(const RoadStraightSegment &segment,
const RoadGraph &graph, RoadMarkingBuffers &out);
/**
* Build the decal quads of one edge (phase 2.5) and append to @p out.
* Each RoadEdgeDecal becomes one atlas-textured quad hovering just
* above the road top (and above the marking strips). Purely visual.
*
* When @p ownerNodeId is >= 0 only the decals owned by that node are
* emitted — a decal on the first half of the edge (edgeT <= 0.5)
* belongs to nodeA, the rest to nodeB. RoadSystem uses this so a
* cross-page edge does not paint its decals on both pages.
*/
void buildEdgeDecals(const RoadGraph &graph, size_t edgeIndex,
Procedural::TriangleBuffer &out, int ownerNodeId = -1);
/* ----------------------------------------------------------------
* Pipeline phases (exposed for testing)
* ---------------------------------------------------------------- */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
#ifndef EDITSCENE_ROADNETWORKGEN_HPP
#define EDITSCENE_ROADNETWORKGEN_HPP
#pragma once
#include "components/RoadGraph.hpp"
#include <functional>
#include <nlohmann/json_fwd.hpp>
#include <stdint.h>
#include <string>
#include <vector>
/**
* Road network generator and graph-level services (taxi demo phase 3).
*
* Everything here is a pure function of RoadGraph data (plus an optional
* terrain height sampler for the generator) — no scene, terrain or Ogre
* render state is required, so the whole namespace is exercisable by the
* headless road_geometry_overlap_test suite.
*
* Contents:
* - crossing detection/resolution: edges that come within road width
* of each other (and within RoadConfig::crossingHeightThreshold in
* Y — larger gaps are overpasses) can be turned into proper
* junctions by splitting both edges, reusing near end nodes or
* collapsing too-short edges;
* - lot detection: areas enclosed by road edges (planar faces of the
* graph) that no other edge intersects;
* - streets: printable/code-accessible names for edge sequences and
* street+number lot addressing;
* - a deterministic seeded grid generator emitting graphs stamped
* with a generatedGroupId (single unsplittable unit).
*/
namespace RoadNetworkGen {
/**
* One detected crossing between two edges. Endpoint ids are stable
* across graph edits (edge vector indices are not, so they are not
* stored); @c tA/@c tB are the closest-point parameters along the
* stored edge direction (nodeA -> nodeB) at detection time — re-detect
* after every graph mutation instead of reusing stale values.
*/
struct RoadCrossing {
int nodeA1 = -1, nodeA2 = -1; /* endpoints of edge A */
int nodeB1 = -1, nodeB2 = -1; /* endpoints of edge B */
float tA = 0.0f, tB = 0.0f;
Ogre::Vector3 pointA = Ogre::Vector3::ZERO; /* on A, surface Y */
Ogre::Vector3 pointB = Ogre::Vector3::ZERO; /* on B, surface Y */
float xzDistance = 0.0f;
float yDifference = 0.0f;
};
/**
* Resolved half-width of an edge in meters: half the lanes plus the
* enabled sidewalks on both sides. Used as the crossing reach.
*/
float edgeHalfWidth(const RoadGraph &graph, const RoadEdge &edge);
/** Road surface height at parameter @p t along the edge (node Y plus
* the lerped roadLevel). */
float edgeSurfaceYAt(const RoadGraph &graph, const RoadEdge &edge,
float t);
/**
* Detect all edge pairs that intersect without a junction: XZ distance
* between the closest points at most the sum of the edge half-widths
* AND Y separation at most RoadConfig::crossingHeightThreshold (a
* bigger Y gap is an overpass/underpass and is NOT a crossing). Edge
* pairs sharing a node are skipped.
*/
std::vector<RoadCrossing> findCrossings(const RoadGraph &graph);
/**
* Turn one crossing into a proper junction. For each of the two edges:
* an end node within RoadConfig::crossingSnapDistance meters of the
* crossing point is reused, an edge too short to split (at most
* 2 * ROAD_MIN_EDGE_LENGTH) is collapsed via mergeNodes into its end
* nearer to the crossing, anything else is split at the crossing
* parameter. The two resulting junction nodes are then merged into
* one (placed at the crossing midpoint). Best-effort: returns false
* with @p error set when the edges no longer exist.
*/
bool resolveCrossing(RoadGraph &graph, const RoadCrossing &crossing,
std::string *error = nullptr);
/**
* Resolve crossings until none remain (re-detecting after every fix,
* one fix per pass). Returns the number of junctions created, or -1
* when @p maxPasses was exhausted with crossings left (@p error set).
*/
int resolveAllCrossings(RoadGraph &graph, std::string *error = nullptr,
int maxPasses = 256);
/**
* Structural sanity beyond RoadGraph::validate: no duplicate edge
* pairs, no sliver edges below ROAD_MIN_EDGE_LENGTH, no isolated
* (degree-0) nodes. validate() itself is NOT run (its wedge-angle
* rules reject hand-authored legacy scenes).
*/
bool checkSane(const RoadGraph &graph, std::string *error = nullptr);
/**
* One enclosed area tracked as a potential building lot: a simple
* polygon of road nodes with no road edge crossing its interior, or
* (with RoadLotFilter::edgeFallback) a square grown off an edge side
* that borders no enclosed lot.
*/
struct RoadLot {
/** Boundary node ids in polygon order (EMPTY for edge-fallback
* squares — their corners are not graph nodes). */
std::vector<int> nodeIds;
/** Boundary positions (same order as nodeIds when non-empty). */
std::vector<Ogre::Vector3> polygon;
/** Polygon area in square meters (always positive). */
double area = 0.0;
Ogre::Vector3 centroid = Ogre::Vector3::ZERO;
/** True for square lots grown off a free edge side (fallback)
* rather than enclosed by road edges. */
bool edgeFallback = false;
/** Node ids of the source edge (edge-fallback lots only, -1
* otherwise): the square was grown off one side of this edge, so
* it is the lot's road frontage — usable for street lookup and
* frontage orientation even though nodeIds stays empty. */
int sourceEdgeA = -1;
int sourceEdgeB = -1;
};
/** Lot acceptance rules for findLots(). */
struct RoadLotFilter {
/** A lot must fit a square of this side: both XZ bounding-box
* extents AND the polygon area (side squared) must reach it.
* Edge-fallback squares use the edge length as their side, so
* shorter edges spawn no fallback lot. */
float minLotSize = 45.0f;
/** Enclosed faces bounded by more edges than this are not simple
* building lots and are discarded. */
int maxBoundaryEdges = 8;
/** Grow square fallback lots off edge sides that border no
* accepted enclosed lot. */
bool edgeFallback = true;
};
/**
* Enumerate the potential building lots of the road graph. Primary
* lots are the enclosed faces (planar face walk: sharpest right turn
* at each node, U-turn edge excluded). A face is REJECTED when its
* boundary walk traverses an edge twice or revisits a node (a
* spur/cul-de-sac pokes into it), when another graph node lies
* strictly inside it, when it is bounded by more than
* RoadLotFilter::maxBoundaryEdges edges, or when it cannot fit a
* RoadLotFilter::minLotSize square. With
* RoadLotFilter::edgeFallback, every edge side that borders no
* accepted face spawns a square lot (side = edge length, so edges
* shorter than minLotSize spawn none) provided the square stays clear
* of the graph (no node strictly inside, no edge crossing its
* interior) and does not overlap an already-accepted lot. Works for
* manual and procedural networks alike; run resolveAllCrossings first
* when edges may cross.
*/
std::vector<RoadLot> findLots(const RoadGraph &graph,
const RoadLotFilter &filter = RoadLotFilter());
/**
* Assign @p name to the given edge set (normalized endpoint pairs).
* Edges are removed from any street already containing them; an
* existing street with the same name is redefined. Returns the street
* index, or -1 when any referenced edge does not exist.
*/
int nameStreet(RoadGraph &graph, const std::string &name,
const std::vector<std::pair<int, int> > &edges);
/** Look up the street containing the edge (nodeA, nodeB), or nullptr. */
const RoadStreet *findStreetForEdge(const RoadGraph &graph, int nodeA,
int nodeB);
/**
* Build display addresses for lots: each lot is assigned to its
* nearest street; lots on one street are numbered sequentially along
* the street's direction; lots with no street get "Lot <lotIndex+1>".
* Returns one address string per input lot.
*/
std::vector<std::string>
assignLotAddresses(const RoadGraph &graph, const std::vector<RoadLot> &lots);
/** Grid generator parameters (all deterministic under @p seed). */
struct GridParams {
/** XZ origin of block corner (0, 0); Y fallback when no sampler. */
Ogre::Vector3 origin = Ogre::Vector3::ZERO;
int blocksX = 3, blocksZ = 3;
float blockSizeX = 100.0f, blockSizeZ = 100.0f;
/** Max XZ jitter of interior intersections (boundary stays put). */
float jitter = 0.0f;
uint32_t seed = 1;
/** Stamp for generated nodes; 0 = auto-assign (max existing + 1). */
uint32_t groupId = 0;
/** Reject nodes whose sampled height is below waterLevel. */
bool avoidWater = true;
float waterLevel = -0.1f;
/** Skip edges steeper than this rise/run (0 disables the check). */
float maxSlope = 0.35f;
/** Run resolveAllCrossings after generating. */
bool runCrossingResolution = true;
};
/** Terrain height sampler: render-space (x, z) -> surface Y. */
typedef std::function<float(float x, float z)> HeightSampler;
/**
* Emit a grid road network into the graph: (blocksX+1) x (blocksZ+1)
* intersections, seeded jitter on interior ones, edges along the grid
* lines. With a @p sampleHeight sampler, nodes below waterLevel are
* rejected (their edges go with them) and edges steeper than maxSlope
* are skipped; without a sampler every node sits at origin.y. Every
* created node is stamped with the returned generatedGroupId.
* Returns the group id used, or 0 on a fatal parameter error
* (@p error set).
*/
uint32_t generateGrid(RoadGraph &graph, const GridParams &params,
const HeightSampler &sampleHeight,
std::string *error = nullptr);
/**
* Connect a generated group to a manual node "as if the group were a
* road node": the closest group node is picked automatically and
* connected via RoadGraph::connectNodes (page-boundary splits when
* @p worldSize > 0), then crossings are resolved. Returns the first
* new edge index, or -1 on failure (@p error set).
*/
int connectGroupToNode(RoadGraph &graph, uint32_t groupId, int nodeId,
float worldSize = 0.0f, std::string *error = nullptr);
/** Remove every node (and cascading edges) of a generated group. */
bool removeGroup(RoadGraph &graph, uint32_t groupId);
/** Largest generatedGroupId in the graph + 1 (1 when none). */
uint32_t nextGroupId(const RoadGraph &graph);
/**
* Streets serialization (shared by the region store, the scene
* serializer and the headless tests). Shape:
* `{ "streets": [ { "name": "...", "edges": [[a, b], ...] } ] }`;
* fromJson tolerates a missing/empty array. With @p dropMissing
* (default) entries whose edges no longer exist are dropped; pass
* false when loading a streaming terrain whose pages are not all in
* the graph yet (stale ids are harmless — lookups match existing
* edges only).
*/
void streetsToJson(const RoadGraph &graph, nlohmann::json &out);
void streetsFromJson(RoadGraph &graph, const nlohmann::json &j,
bool dropMissing = true);
} /* namespace RoadNetworkGen */
#endif /* EDITSCENE_ROADNETWORKGEN_HPP */
+382
View File
@@ -0,0 +1,382 @@
#include "TownGen.hpp"
#include <algorithm>
#include <cmath>
#include <map>
#include <random>
namespace TownGen {
const DistrictParams &districtParams(District d)
{
static const DistrictParams presets[(size_t)District::COUNT] = {
/* Village: small single-storey houses. */
{ "Village", 8.0f, 12.0f, 1, 1, 0.8f, 4.0f,
{ 0.85f, 0.75f, 0.60f } },
/* Town: row houses and small apartment blocks. */
{ "Town", 10.0f, 16.0f, 1, 2, 0.9f, 4.0f,
{ 0.80f, 0.70f, 0.65f } },
/* City residential: mid-rise apartment buildings. */
{ "City Residential", 12.0f, 20.0f, 2, 4, 0.95f, 3.0f,
{ 0.70f, 0.70f, 0.75f } },
/* City center: tall blocks on tight lots. */
{ "City Center", 16.0f, 30.0f, 4, 8, 1.0f, 2.0f,
{ 0.65f, 0.68f, 0.72f } },
};
size_t idx = (size_t)d;
if (idx >= (size_t)District::COUNT)
idx = 0;
return presets[idx];
}
/** Stairs appear when the door-side ground is this far below the
* floor (meters). */
static const float STAIRS_THRESHOLD = 0.35f;
/** Extra gap kept between a building wall and the road body on top
* of the road half-width (meters). */
static const float ROAD_CLEARANCE_MARGIN = 0.3f;
/** Signed area of an XZ polygon (shoelace; sign follows winding). */
static float polyAreaXZ(const std::vector<Ogre::Vector3> &poly)
{
float a = 0.0f;
for (size_t i = 0; i < poly.size(); ++i) {
const Ogre::Vector3 &p = poly[i];
const Ogre::Vector3 &q = poly[(i + 1) % poly.size()];
a += p.x * q.z - q.x * p.z;
}
return a * 0.5f;
}
/**
* Half-extent of the road body on the lot side of a graph edge:
* all lanes plus the lot side's sidewalk and apron, plus a small
* margin.
*/
static float roadClearance(const RoadGraph &graph, const RoadEdge &e,
bool lotOnLeft)
{
int la, lb;
graph.resolveLaneCounts(e, e.nodeA, la, lb);
const RoadConfig &cfg = graph.config;
float w = (la + lb) * cfg.laneWidth * 0.5f;
if (graph.edgeSidewalkEnabled(e, lotOnLeft))
w += cfg.sidewalkWidth;
if (cfg.apronEnabled)
w += cfg.apronWidth;
return w + ROAD_CLEARANCE_MARGIN;
}
/**
* Inset a lot polygon off the roads bounding it. findLots() lot
* polygons run centerline-to-centerline, so a building placed only
* the district setback inside the raw polygon would stand on the
* road itself. Every boundary edge that matches a graph edge
* (geometric endpoint match, either direction — covers enclosed
* faces and edge-fallback source edges alike) is offset inward by
* the road half-extent on the lot's side (lanes + sidewalk + apron
* + margin); unmatched edges keep a zero offset. Vertices of the
* inset polygon are the intersections of consecutive offset lines.
* Returns false when the inset collapses (degenerate edge or a
* vanished/reversed polygon), in which case the lot holds nothing
* buildable and should be skipped.
*/
static bool shrinkLotOffRoads(const RoadGraph &graph,
const std::vector<Ogre::Vector3> &poly,
const Ogre::Vector3 &centroid,
std::vector<Ogre::Vector3> &out)
{
size_t n = poly.size();
out.clear();
if (n < 3)
return false;
std::vector<float> inset(n, 0.0f);
std::vector<Ogre::Vector3> dir(n), inward(n);
for (size_t i = 0; i < n; ++i) {
size_t j = (i + 1) % n;
Ogre::Vector3 d = poly[j] - poly[i];
d.y = 0.0f;
if (d.normalise() < 0.001f)
return false;
dir[i] = d;
/* Inward = the side normal pointing at the centroid
* (winding-independent — fallback lots may be
* reversed). */
Ogre::Vector3 nrm(-d.z, 0.0f, d.x);
Ogre::Vector3 mid = (poly[i] + poly[j]) * 0.5f;
if (nrm.dotProduct(centroid - mid) < 0.0f)
nrm = -nrm;
inward[i] = nrm;
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 = poly[i].distance(na->position) < 0.01f &&
poly[j].distance(nb->position) < 0.01f;
bool rev = poly[i].distance(nb->position) < 0.01f &&
poly[j].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);
bool lotOnLeft = inward[i].dotProduct(gleft) > 0.0f;
inset[i] = roadClearance(graph, e, lotOnLeft);
break;
}
}
for (size_t i = 0; i < n; ++i) {
size_t prev = (i + n - 1) % n;
const Ogre::Vector3 &d1 = dir[prev];
const Ogre::Vector3 &d2 = dir[i];
Ogre::Vector3 p1 = poly[prev] + inward[prev] * inset[prev];
Ogre::Vector3 p2 = poly[i] + inward[i] * inset[i];
float denom = d1.x * d2.z - d2.x * d1.z;
Ogre::Vector3 vtx;
if (std::fabs(denom) < 1.0e-6f) {
/* Near-parallel: average the two offset
* positions of the shared vertex. */
vtx = (poly[i] + inward[prev] * inset[prev] +
poly[i] + inward[i] * inset[i]) * 0.5f;
} else {
/* Solve p1 + t*d1 = p2 + s*d2 in XZ. */
float dx = p2.x - p1.x;
float dz = p2.z - p1.z;
float t = (dx * d2.z - dz * d2.x) / denom;
vtx = p1 + d1 * t;
}
vtx.y = poly[i].y;
out.push_back(vtx);
}
return std::fabs(polyAreaXZ(out)) > 0.01f;
}
static float sampleY(const RoadNetworkGen::HeightSampler &sampleHeight,
float x, float z, float fallback)
{
return sampleHeight ? sampleHeight(x, z) : fallback;
}
TownResult generateTown(const RoadGraph &graph, const TownParams &params,
const RoadNetworkGen::HeightSampler &sampleHeight)
{
TownResult result;
const DistrictParams &dp = districtParams(params.district);
std::vector<RoadNetworkGen::RoadLot> lots =
RoadNetworkGen::findLots(graph);
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) {
const RoadNetworkGen::RoadLot &lot = lots[li];
size_t n = lot.polygon.size();
if (n < 3)
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
* polygon may be reversed, so the source edge is matched
* geometrically against both endpoints. */
Ogre::Vector3 srcA, srcB;
bool hasSource = false;
if (lot.edgeFallback && lot.sourceEdgeA >= 0 &&
lot.sourceEdgeB >= 0) {
const RoadNode *sa = graph.findNodeById(lot.sourceEdgeA);
const RoadNode *sb = graph.findNodeById(lot.sourceEdgeB);
if (sa && sb) {
srcA = sa->position;
srcB = sb->position;
hasSource = true;
}
}
int best = 0;
float bestLen = -1.0f;
int bestTier = -1;
for (size_t i = 0; i < n; ++i) {
size_t j = (i + 1) % n;
float len = (lot.polygon[j] - lot.polygon[i]).length();
int tier = 0;
if (!lot.nodeIds.empty() &&
RoadNetworkGen::findStreetForEdge(
graph, lot.nodeIds[i],
lot.nodeIds[j]) != nullptr) {
tier = 2;
} else if (hasSource &&
((lot.polygon[i].distance(srcA) < 0.01f &&
lot.polygon[j].distance(srcB) < 0.01f) ||
(lot.polygon[i].distance(srcB) < 0.01f &&
lot.polygon[j].distance(srcA) < 0.01f))) {
tier = 1;
}
if (tier > bestTier ||
(tier == bestTier && len > bestLen)) {
best = (int)i;
bestLen = len;
bestTier = tier;
}
}
Ogre::Vector3 p0 = lot.polygon[best];
Ogre::Vector3 p1 = lot.polygon[(best + 1) % n];
Ogre::Vector3 frontMid = (p0 + p1) * 0.5f;
Ogre::Vector3 u = p1 - p0;
u.y = 0.0f;
if (u.normalise() < 0.001f)
continue;
/* Inward direction: from the frontage towards the lot
* centroid (winding-independent). */
Ogre::Vector3 v = lot.centroid - frontMid;
v.y = 0.0f;
if (v.normalise() < 0.001f)
continue;
/* Inset the lot off the bounding roads — raw lot
* polygons run centerline-to-centerline, so buildings
* must keep the road half-extent (lanes + sidewalk +
* apron) plus a margin off the polygon boundary. */
std::vector<Ogre::Vector3> buildable;
if (!shrinkLotOffRoads(graph, lot.polygon, lot.centroid,
buildable)) {
result.skippedTooSmall++;
continue;
}
/* Lot bounds in the (u, v) frame around frontMid,
* measured on the inset polygon; vMin is the buildable
* front line (the frontage pushed off the road). */
float uMin = 1.0e30f, uMax = -1.0e30f;
float vMin = 1.0e30f, vMax = -1.0e30f;
for (size_t i = 0; i < n; ++i) {
Ogre::Vector3 d = buildable[i] - frontMid;
float du = d.dotProduct(u);
float dv = d.dotProduct(v);
uMin = std::min(uMin, du);
uMax = std::max(uMax, du);
vMin = std::min(vMin, dv);
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) {
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);
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. */
const RoadStreet *street = nullptr;
if (!lot.nodeIds.empty())
street = RoadNetworkGen::findStreetForEdge(
graph, lot.nodeIds[best],
lot.nodeIds[(best + 1) % n]);
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);
}
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;
}
} /* namespace TownGen */
+123
View File
@@ -0,0 +1,123 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <Ogre.h>
#include "../components/RoadGraph.hpp"
#include "RoadNetworkGen.hpp"
/**
* Procedural town generation on top of a road network (demo-taxi
* phase 4). Pure functions of RoadGraph/RoadLot data plus a terrain
* height sampler — fully exercisable headlessly.
*
* generateTown() consumes the lots of RoadNetworkGen::findLots()
* (enclosed faces + edge-fallback squares) and places one building
* per suitable lot: oriented to the frontage edge (the boundary edge
* on a named street, longest first), sized by the district preset,
* seated on a foundation block (floor at the highest terrain corner
* of the footprint, foundation reaching below the lowest corner)
* with entrance stairs when the door-side ground drops below the
* floor. Deterministic under TownParams::seed.
*/
namespace TownGen {
/** District zoning presets driving building size, height, density. */
enum class District {
Village = 0,
Town,
CityResidential,
CityCenter,
COUNT
};
struct DistrictParams {
const char *name;
/** Building footprint side range in meters. */
float minFootprint;
float maxFootprint;
int minFloors;
int maxFloors;
/** Fraction of suitable lots that get a building (0..1). */
float density;
/** Min distance from the lot boundary to the building walls. */
float setback;
/** Wall tint (linear RGB). */
float wallColor[3];
};
const DistrictParams &districtParams(District d);
struct TownParams {
District district = District::Village;
uint32_t seed = 1;
/** Multiplier on the district density (clamped to 1). */
float densityScale = 1.0f;
/** Lots whose floor level is below this are skipped (water). */
float waterLevel = -1.0e30f;
};
struct TownBuilding {
/** Index into the findLots() output this building sits on. */
int lotIndex = -1;
/** Building center at floor level (render/world XZ of the
* caller's coordinate space). */
Ogre::Vector3 center = Ogre::Vector3::ZERO;
/** Yaw (radians around +Y); the building's local +Z faces the
* street. */
float yaw = 0.0f;
/** Footprint in meters: X along the street, Z into the lot. */
float width = 0.0f;
float depth = 0.0f;
int floors = 1;
/** Floor level: the highest terrain corner of the footprint. */
float floorY = 0.0f;
/** Lowest terrain corner of the footprint. */
float minGroundY = 0.0f;
/** Entrance stairs: the door-side ground is below the floor. */
bool stairs = false;
/** floorY - ground at the door (when stairs). */
float stairsDrop = 0.0f;
/** "<street> <n>" or "Lot <lotIndex+1>". */
std::string address;
};
/** Named curb point in front of a building's door (pickup/dropoff,
* pedestrian origin/destination). */
struct TownPoi {
std::string name; /* "<address> pickup" */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
int building = -1; /* index into buildings */
};
struct TownResult {
std::vector<TownBuilding> buildings;
std::vector<TownPoi> pois;
/** Suitable-but-empty lots (density roll). */
int skippedDensity = 0;
/** Lots too small for the district footprint. */
int skippedTooSmall = 0;
/** Lots below TownParams::waterLevel. */
int skippedWater = 0;
};
/**
* Place buildings on the lots of the road graph. Deterministic:
* lots are processed in findLots() order and every random draw (one
* density roll, then width, depth, floors per built lot) comes from
* one mt19937 seeded with TownParams::seed, so the same graph and
* params always yield the same town.
*
* A lot is skipped when its usable rectangle (lot bounds inset by the
* district setback) cannot fit the district's minimum footprint, or
* when its floor level is below waterLevel. The building's frontage
* 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.
*/
TownResult generateTown(const RoadGraph &graph, const TownParams &params,
const RoadNetworkGen::HeightSampler &sampleHeight);
} /* namespace TownGen */
@@ -38,15 +38,20 @@ AnimationTreeSystem::AnimationTreeSystem(flecs::world &world,
: m_world(world)
, m_sceneMgr(sceneMgr)
{
m_world.observer<AnimationTreeComponent>("AnimationTreeCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, AnimationTreeComponent &) {
teardownEntity(e);
});
m_cleanupObserver =
m_world.observer<AnimationTreeComponent>(
"AnimationTreeCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e,
AnimationTreeComponent &) {
teardownEntity(e);
});
}
AnimationTreeSystem::~AnimationTreeSystem()
{
if (m_cleanupObserver.is_alive())
m_cleanupObserver.destruct();
std::vector<flecs::entity_t> toRemove;
for (auto &pair : m_states)
toRemove.push_back(pair.first);
@@ -139,6 +139,9 @@ private:
findAnimationNode(const AnimationTreeNode &stateNode) const;
flecs::world &m_world;
/* Kept so the destructor can unregister it: the observer must not
* fire into a destroyed system during world teardown (ecs_fini). */
flecs::entity m_cleanupObserver;
Ogre::SceneManager *m_sceneMgr;
bool m_initialized = false;
@@ -689,6 +689,11 @@ static void genPlane(float sizeX, float sizeY, const Ogre::Vector3 &normal,
Procedural::TriangleBuffer &tb,
const Ogre::Vector3 &origin)
{
/* Degenerate trim pieces (e.g. the top frame of a doorway when
* cellHeight leaves no room above the door) have no visible area;
* skip them instead of throwing in the plane generator. */
if (sizeX <= 0.0001f || sizeY <= 0.0001f)
return;
// nsegY is based on the height (sizeX in the original, which is vertical)
int nsegY = (sizeX < 1.0f) ? 1 : std::max(1, (int)(sizeX / 2.0f));
Procedural::PlaneGenerator()
@@ -1192,7 +1197,12 @@ 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;
float doorHeight = doorHeightBase - solidExtOffset;
/* 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. */
float doorHeight = std::min(doorHeightBase -
solidExtOffset,
solidExtHeight);
float doorTopHeight = solidExtHeight - doorHeight;
float baseOffsetY = solidExtOffset;
@@ -1316,7 +1326,9 @@ void CellGridSystem::buildDoors(const CellGridComponent &grid,
(2.0f * hScale - 0.2f - doorWidth) / 2.0f;
float moffset =
(2.0f * hScale - 0.2f - sideWidth) / 2.0f;
float doorHeight = doorHeightBase - solidIntOffset;
float doorHeight = std::min(doorHeightBase -
solidIntOffset,
solidIntHeight);
float doorTopHeight = solidIntHeight - doorHeight;
float baseOffsetY = solidIntOffset;
@@ -33,20 +33,30 @@ CharacterSlotSystem::CharacterSlotSystem(flecs::world &world,
, m_sceneMgr(sceneMgr)
{
ms_singleton = this;
m_world.observer<CharacterIdentityComponent>("CharacterSlotsBuild")
.event(flecs::OnAdd)
.each([this](flecs::entity e, CharacterIdentityComponent &) {
markEntityDirty(e.id());
});
m_world.observer<CharacterIdentityComponent>("CharacterSlotsCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, CharacterIdentityComponent &) {
destroyCharacterParts(e);
});
m_buildObserver =
m_world.observer<CharacterIdentityComponent>(
"CharacterSlotsBuild")
.event(flecs::OnAdd)
.each([this](flecs::entity e,
CharacterIdentityComponent &) {
markEntityDirty(e.id());
});
m_cleanupObserver =
m_world.observer<CharacterIdentityComponent>(
"CharacterSlotsCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e,
CharacterIdentityComponent &) {
destroyCharacterParts(e);
});
}
CharacterSlotSystem::~CharacterSlotSystem()
{
if (m_buildObserver.is_alive())
m_buildObserver.destruct();
if (m_cleanupObserver.is_alive())
m_cleanupObserver.destruct();
std::vector<flecs::entity_t> toRemove;
for (auto &pair : m_entities)
toRemove.push_back(pair.first);
@@ -108,6 +108,10 @@ private:
const nlohmann::json *entry);
flecs::world &m_world;
/* Kept so the destructor can unregister them: the observers must
* not fire into a destroyed system during world teardown. */
flecs::entity m_buildObserver;
flecs::entity m_cleanupObserver;
Ogre::SceneManager *m_sceneMgr;
bool m_initialized = false;
@@ -21,15 +21,20 @@ CharacterSpawnerSystem::CharacterSpawnerSystem(flecs::world &world,
, m_uiSystem(uiSystem)
, m_query(world.query<CharacterSpawnerComponent, TransformComponent>())
{
m_world.observer<CharacterSpawnerComponent>("CharacterSpawnerCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, CharacterSpawnerComponent &) {
despawn(e);
});
m_cleanupObserver =
m_world.observer<CharacterSpawnerComponent>(
"CharacterSpawnerCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e,
CharacterSpawnerComponent &) {
despawn(e);
});
}
CharacterSpawnerSystem::~CharacterSpawnerSystem()
{
if (m_cleanupObserver.is_alive())
m_cleanupObserver.destruct();
std::vector<flecs::entity_t> ids;
ids.reserve(m_spawnedEntities.size());
for (const auto &pair : m_spawnedEntities)
@@ -94,6 +94,9 @@ private:
Ogre::Vector3 getCameraPosition() const;
flecs::world &m_world;
/* Kept so the destructor can unregister it: the observer must not
* fire into a destroyed system during world teardown (ecs_fini). */
flecs::entity m_cleanupObserver;
Ogre::SceneManager *m_sceneMgr;
EditorCameraSystem *m_cameraSystem;
EditorUISystem *m_uiSystem;
@@ -52,15 +52,19 @@ CharacterSystem::CharacterSystem(flecs::world &world,
: m_world(world)
, m_sceneMgr(sceneMgr)
{
m_world.observer<CharacterComponent>("CharacterCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, CharacterComponent &) {
teardownEntity(e);
});
m_cleanupObserver = m_world.observer<CharacterComponent>(
"CharacterCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e,
CharacterComponent &) {
teardownEntity(e);
});
}
CharacterSystem::~CharacterSystem()
{
if (m_cleanupObserver.is_alive())
m_cleanupObserver.destruct();
std::vector<flecs::entity_t> toRemove;
for (auto &pair : m_states)
toRemove.push_back(pair.first);
@@ -68,6 +68,9 @@ private:
void createChestCollider(flecs::entity e, CharacterComponent &cc);
flecs::world &m_world;
/* Kept so the destructor can unregister it: the observer must not
* fire into a destroyed system during world teardown (ecs_fini). */
flecs::entity m_cleanupObserver;
Ogre::SceneManager *m_sceneMgr;
JoltPhysicsWrapper *m_physics = nullptr;
bool m_initialized = false;
@@ -2462,6 +2462,13 @@ void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay)
Ogre::Vector3 hit = mouseRay.getPoint(t);
/* Generator origin picking swallows the click before any tool. */
if (rs->isOriginPickArmed()) {
rs->armOriginPick(false);
rs->setPickedOrigin(hit);
return;
}
int nodeId = pickRoadNode(hit);
/* Connect tool: first click pins the source node, second click on
@@ -2,6 +2,7 @@
#include "../components/WaterPlane.hpp"
#include "../components/Transform.hpp"
#include "../components/WaterPhysics.hpp"
#include <algorithm>
#include <OgreLogManager.h>
#include <OgreManualObject.h>
#include <OgreMaterialManager.h>
@@ -34,7 +35,27 @@ EditorWaterPlaneSystem::EditorWaterPlaneSystem(flecs::world &world,
{
}
EditorWaterPlaneSystem::~EditorWaterPlaneSystem() = default;
EditorWaterPlaneSystem::~EditorWaterPlaneSystem()
{
/* destroyEditorSystems() runs before Ogre Root teardown, but the
* render textures themselves are only destroyed by the render
* system during Root::~Root — detach the listener and viewports
* now so that teardown does not fire into a dead system. The
* texture is deliberately NOT removed here: the water material
* still references it and TextureManager owns its final
* destruction. */
for (Ogre::RenderTarget *rt : m_createdTargets) {
rt->removeListener(&m_listener);
rt->removeAllViewports();
}
m_createdTargets.clear();
m_query.each([this](flecs::entity, WaterPlane &wp,
TransformComponent &) {
wp.renderTarget = nullptr;
wp.reflectionViewport = nullptr;
wp.refractionViewport = nullptr;
});
}
void EditorWaterPlaneSystem::update(float deltaTime, Ogre::Camera *camera)
{
@@ -137,6 +158,7 @@ void EditorWaterPlaneSystem::rebuildWaterPlane(
// Set listener to hide water during RTT
m_listener.waterMesh = wp.manualObject;
wp.renderTarget->addListener(&m_listener);
m_createdTargets.push_back(wp.renderTarget);
// Create cameras with their own nodes
wp.reflectionCamera = m_sceneMgr->createCamera(baseName + "ReflectionCam");
@@ -199,6 +221,10 @@ void EditorWaterPlaneSystem::cleanupWaterPlane(WaterPlane &wp)
if (wp.renderTarget) {
wp.renderTarget->removeListener(&m_listener);
wp.renderTarget->removeAllViewports();
m_createdTargets.erase(
std::remove(m_createdTargets.begin(),
m_createdTargets.end(), wp.renderTarget),
m_createdTargets.end());
wp.renderTarget = nullptr;
}
wp.reflectionViewport = nullptr;
@@ -45,6 +45,10 @@ private:
Ogre::SceneManager *m_sceneMgr;
flecs::query<struct WaterPlane, struct TransformComponent> m_query;
WaterRenderTargetListener m_listener;
/* Every render target that currently has m_listener registered;
* the destructor detaches from all of them regardless of whether
* the owning entities are still alive. */
std::vector<Ogre::RenderTarget *> m_createdTargets;
float m_underwaterTime = 0.0f;
bool m_compositorAdded = false;
Ogre::Viewport *m_compositorViewport = nullptr;
@@ -69,6 +69,9 @@ RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY)
n.z = jn.value("z", 0.0);
n.verticalOffset = jn.value(
"verticalOffset", 0.0f);
n.generatedGroupId = jn.value(
"generatedGroupId",
(uint32_t)0);
page.data.nodes.push_back(n);
}
}
@@ -105,6 +108,39 @@ RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY)
e.sidewalkRightMode = je.value(
"sidewalkRightMode",
e.sidewalkRight ? 0 : 2);
/* Phase 2.5: marking mode
* (absent = Inherit) and the
* decal list. */
e.markingsMode = je.value(
"markingsMode", 0);
if (je.contains("decals") &&
je["decals"].is_array()) {
for (const auto &jd :
je["decals"]) {
RoadRegionDecal d;
d.edgeT = jd.value(
"edgeT",
0.5f);
d.lateralOffset = jd.value(
"lateralOffset",
0.0f);
d.size = jd.value(
"size",
1.0f);
if (jd.contains(
"uv") &&
jd["uv"].is_array() &&
jd["uv"].size() ==
4) {
d.uvX = jd["uv"][0];
d.uvY = jd["uv"][1];
d.uvW = jd["uv"][2];
d.uvH = jd["uv"][3];
}
e.decals.push_back(
d);
}
}
if (je.contains("sidewalkPasses") &&
je["sidewalkPasses"]
.is_array()) {
@@ -193,6 +229,8 @@ bool RoadRegionStore::saveRegion(long px, long py, const RoadRegionData &data)
jn["y"] = n.y;
jn["z"] = n.z;
jn["verticalOffset"] = n.verticalOffset;
if (n.generatedGroupId != 0)
jn["generatedGroupId"] = n.generatedGroupId;
j["nodes"].push_back(jn);
}
j["edges"] = nlohmann::json::array();
@@ -210,6 +248,21 @@ bool RoadRegionStore::saveRegion(long px, long py, const RoadRegionData &data)
je["sidewalkRight"] = e.sidewalkRight;
je["sidewalkLeftMode"] = e.sidewalkLeftMode;
je["sidewalkRightMode"] = e.sidewalkRightMode;
if (e.markingsMode != 0)
je["markingsMode"] = e.markingsMode;
if (!e.decals.empty()) {
nlohmann::json dj = nlohmann::json::array();
for (const RoadRegionDecal &d : e.decals) {
nlohmann::json jd;
jd["edgeT"] = d.edgeT;
jd["lateralOffset"] = d.lateralOffset;
jd["size"] = d.size;
jd["uv"] = { d.uvX, d.uvY, d.uvW,
d.uvH };
dj.push_back(jd);
}
je["decals"] = dj;
}
if (!e.sidewalkPasses.empty()) {
nlohmann::json pj = nlohmann::json::array();
for (const RoadRegionSidewalkPass &p :
@@ -265,3 +318,50 @@ void RoadRegionStore::removeRegionFile(long px, long py)
std::string(e.what()));
}
}
std::string RoadRegionStore::streetsFilePath(const std::string &rootDir)
{
return rootDir + "/roads/streets.json";
}
bool RoadRegionStore::loadStreets(nlohmann::json *out)
{
if (m_rootDir.empty() || !out)
return false;
const std::string path = streetsFilePath(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: streets load error: " +
std::string(e.what()));
return false;
}
}
bool RoadRegionStore::saveStreets(const nlohmann::json &j)
{
if (m_rootDir.empty())
return false;
try {
std::filesystem::create_directories(m_rootDir + "/roads");
const std::string path = streetsFilePath(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: streets save error: " +
std::string(e.what()));
return false;
}
}
@@ -5,6 +5,8 @@
#include <string>
#include <vector>
#include <nlohmann/json_fwd.hpp>
/*
* One road node stored in a region file. Positions are absolute world
* space doubles so regions work at any distance from the origin
@@ -16,6 +18,9 @@ struct RoadRegionNode {
double y = 0.0;
double z = 0.0;
float verticalOffset = 0.0f;
/* Phase 3: procedural generator group (0 = manual). Absent in
* legacy files = manual. */
uint32_t generatedGroupId = 0;
};
struct RoadRegionEdgePrefab {
@@ -31,6 +36,18 @@ struct RoadRegionSidewalkPass {
float width = 6.0f;
};
/* One decal quad painted on an edge (phase 2.5); semantics match
* RoadEdgeDecal. */
struct RoadRegionDecal {
float edgeT = 0.5f;
float lateralOffset = 0.0f;
float size = 1.0f;
float uvX = 0.0f;
float uvY = 0.0f;
float uvW = 0.5f;
float uvH = 0.5f;
};
/*
* One road edge stored in a region file. Endpoints are referenced by
* the region-local node ids of this file. Field semantics match
@@ -51,6 +68,10 @@ struct RoadRegionEdge {
* (true -> Inherit, false -> Off). */
int sidewalkLeftMode = 0;
int sidewalkRightMode = 0;
/* Per-edge marking tri-state (RoadMarkingMode as int, phase 2.5).
* Absent in legacy files = Inherit (0). */
int markingsMode = 0;
std::vector<RoadRegionDecal> decals;
std::vector<RoadRegionSidewalkPass> sidewalkPasses;
RoadRegionEdgePrefab prefabLeft;
RoadRegionEdgePrefab prefabRight;
@@ -97,6 +118,13 @@ public:
void removeRegionFile(long px, long py);
void clearCache();
/* Streets (named edge sequences, phase 3) live in a single file
* per terrain: "<root>/roads/streets.json". loadStreets returns
* false when the file does not exist (leaving *out untouched). */
bool loadStreets(nlohmann::json *out);
bool saveStreets(const nlohmann::json &j);
static std::string streetsFilePath(const std::string &rootDir);
private:
struct PageData {
RoadRegionData data;
+701 -9
View File
@@ -10,11 +10,14 @@
#include "../components/Lod.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/WaterPhysics.hpp"
#include "../physics/physics.h"
#include "../roadlib/RoadGeometryLib.hpp"
#include "../roadlib/RoadNetworkGen.hpp"
#include "PrefabSystem.hpp"
#include <OgreTerrainGroup.h>
#include <OgreMaterialManager.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cmath>
@@ -51,18 +54,21 @@ void RoadSystem::createManualObjects()
m_nodeMarkers = m_sceneMgr->createManualObject("RoadNodeMarkers");
m_edgeLines = m_sceneMgr->createManualObject("RoadEdgeLines");
m_lotOutlines = m_sceneMgr->createManualObject("RoadLotOutlines");
m_widthIndicators = m_sceneMgr->createManualObject("RoadWidthIndicators");
m_selectionHighlight = m_sceneMgr->createManualObject(
"RoadSelectionHighlight");
m_visualNode->attachObject(m_nodeMarkers);
m_visualNode->attachObject(m_edgeLines);
m_visualNode->attachObject(m_lotOutlines);
m_visualNode->attachObject(m_widthIndicators);
m_visualNode->attachObject(m_selectionHighlight);
/* Draw in the overlay queue so the aids are never hidden by terrain. */
m_nodeMarkers->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_edgeLines->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_lotOutlines->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_widthIndicators->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_selectionHighlight->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
@@ -72,6 +78,12 @@ void RoadSystem::createManualObjects()
m_debugWedgeObject->setRenderQueueGroup(
Ogre::RENDER_QUEUE_MAIN);
m_debugWedgeObject->setVisible(false);
/* Generator preview lives outside m_visualNode so it is visible
* without road edit mode. */
m_genPreview = m_sceneMgr->createManualObject("RoadGenPreview");
m_sceneMgr->getRootSceneNode()->attachObject(m_genPreview);
m_genPreview->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
}
void RoadSystem::destroyManualObjects()
@@ -100,9 +112,11 @@ void RoadSystem::destroyManualObjects()
destroyManual(m_nodeMarkers);
destroyManual(m_edgeLines);
destroyManual(m_lotOutlines);
destroyManual(m_widthIndicators);
destroyManual(m_selectionHighlight);
destroyManual(m_debugWedgeObject);
destroyManual(m_genPreview);
}
void RoadSystem::setTerrainEntity(flecs::entity entity)
@@ -115,6 +129,7 @@ void RoadSystem::setTerrainEntity(flecs::entity entity)
m_lastGraphVersion = 0;
resetRegionState();
clearPageGeometry();
clearGenPreview();
rebuildVisualAids();
}
@@ -126,6 +141,7 @@ void RoadSystem::clear()
m_lastGraphVersion = 0;
resetRegionState();
clearPageGeometry();
clearGenPreview();
rebuildVisualAids();
}
@@ -266,6 +282,13 @@ void RoadSystem::update(float deltaTime)
}
syncPages();
/* Phase 2.5: overlay materials/textures and the road atlas
* texture unit. ProceduralMaterialSystem restyles the road
* material in place and wipes extra texture units, so the atlas
* unit is re-attached here whenever it is missing. */
ensureOverlayMaterials(tc.roadGraph.config);
syncRoadAtlasTextureUnit(tc.roadGraph.config);
/* Mesh assembly (M5.8): rebuild dirty pages, then finalize pages
* whose chunk meshes ProceduralMeshSystem has since created. */
for (auto &kv : m_pageGeometry) {
@@ -279,18 +302,25 @@ void RoadSystem::update(float deltaTime)
continue;
bool allCreated = true;
for (flecs::entity e : pg.bufferEntities) {
if (!e.is_alive() ||
!e.has<TriangleBufferComponent>()) {
allCreated = false;
break;
}
auto chunkCreated = [](flecs::entity e) {
if (!e.is_alive() || !e.has<TriangleBufferComponent>())
return false;
const auto &tb = e.get<TriangleBufferComponent>();
if (!tb.meshCreated || !tb.ogreEntity) {
return tb.meshCreated && tb.ogreEntity;
};
for (flecs::entity e : pg.bufferEntities) {
if (!chunkCreated(e)) {
allCreated = false;
break;
}
}
if (allCreated)
for (flecs::entity e : pg.overlayEntities) {
if (!chunkCreated(e)) {
allCreated = false;
break;
}
}
if (!allCreated)
continue;
@@ -306,6 +336,26 @@ void RoadSystem::update(float deltaTime)
.markChanged();
createPageCollider(pg, e, tb.meshName);
}
/* Overlay chunks (phase 2.5): rendering distance, renderable
* pointer and the shared marking/decal material by name —
* no collider, no navmesh dirtying. */
for (flecs::entity e : pg.overlayEntities) {
auto &tb = e.get_mut<TriangleBufferComponent>();
tb.ogreEntity->setRenderingDistance(
tc.roadGraph.config.roadVisibilityDistance);
const std::string &matName =
tb.meshName.find("_decals") !=
std::string::npos
? tc.roadGraph.config.decalMaterialName
: tc.roadGraph.config.markingMaterialName;
tb.ogreEntity->setMaterialName(matName);
if (e.has<RenderableComponent>())
e.get_mut<RenderableComponent>().entity =
tb.ogreEntity;
if (e.has<TransformComponent>())
e.get_mut<TransformComponent>()
.markChanged();
}
markNavMeshDirty();
pg.meshFinalized = true;
}
@@ -637,6 +687,14 @@ void RoadSystem::syncRegions()
if (root != m_regionRoot) {
m_regionRoot = root;
m_regionStore.setRootDirectory(root);
/* Streets live in one file per terrain. Pages are not
* all in the graph yet (and may never be at once while
* streaming), so keep entries whose edges are currently
* missing — lookups only ever match existing edges. */
nlohmann::json sj;
if (m_regionStore.loadStreets(&sj))
RoadNetworkGen::streetsFromJson(graph, sj,
/*dropMissing=*/false);
}
Ogre::TerrainGroup *group = ts->getTerrainGroup();
@@ -714,6 +772,40 @@ void RoadSystem::syncRegions()
m_streamActivity = true;
}
/* Phase 2.5: decal list conversion between RoadEdge and
* RoadRegionEdge, shared by the migrate/load/build paths. */
static void copyDecalsToRegion(const RoadEdge &src, RoadRegionEdge &dst)
{
dst.decals.clear();
for (const RoadEdgeDecal &d : src.decals) {
RoadRegionDecal rd;
rd.edgeT = d.edgeT;
rd.lateralOffset = d.lateralOffset;
rd.size = d.size;
rd.uvX = d.uvX;
rd.uvY = d.uvY;
rd.uvW = d.uvW;
rd.uvH = d.uvH;
dst.decals.push_back(rd);
}
}
static void copyDecalsFromRegion(const RoadRegionEdge &src, RoadEdge &dst)
{
dst.decals.clear();
for (const RoadRegionDecal &rd : src.decals) {
RoadEdgeDecal d;
d.edgeT = rd.edgeT;
d.lateralOffset = rd.lateralOffset;
d.size = rd.size;
d.uvX = rd.uvX;
d.uvY = rd.uvY;
d.uvW = rd.uvW;
d.uvH = rd.uvH;
dst.decals.push_back(d);
}
}
void RoadSystem::migrateGraphToRegions(RoadGraph &graph)
{
TerrainSystem *ts = TerrainSystem::getInstance();
@@ -797,6 +889,8 @@ void RoadSystem::migrateGraphToRegions(RoadGraph &graph)
re.sidewalkRight = e.sidewalkRight;
re.sidewalkLeftMode = (int)e.sidewalkLeftMode;
re.sidewalkRightMode = (int)e.sidewalkRightMode;
re.markingsMode = (int)e.markingsMode;
copyDecalsToRegion(e, re);
for (const RoadSidewalkPass &p : e.sidewalkPasses) {
RoadRegionSidewalkPass rp;
rp.rightSide = p.rightSide;
@@ -867,6 +961,8 @@ void RoadSystem::loadRegion(long px, long py, RoadGraph &graph)
remap[rn.id] = graph.addNode(
ts->worldToRender(rn.x, rn.y, rn.z),
rn.verticalOffset);
graph.findNodeById(remap[rn.id])->generatedGroupId =
rn.generatedGroupId;
}
/* Second pass: edges whose endpoints both resolved. */
@@ -891,6 +987,8 @@ void RoadSystem::loadRegion(long px, long py, RoadGraph &graph)
e.sidewalkRight = re.sidewalkRight;
e.sidewalkLeftMode = (RoadSidewalkMode)re.sidewalkLeftMode;
e.sidewalkRightMode = (RoadSidewalkMode)re.sidewalkRightMode;
e.markingsMode = (RoadMarkingMode)re.markingsMode;
copyDecalsFromRegion(re, e);
for (const RoadRegionSidewalkPass &rp : re.sidewalkPasses) {
RoadSidewalkPass p;
p.rightSide = rp.rightSide;
@@ -942,6 +1040,7 @@ void RoadSystem::buildRegionData(long px, long py, const RoadGraph &graph,
rn.y = ts->renderToWorldY(n.position.y);
rn.z = wz;
rn.verticalOffset = n.verticalOffset;
rn.generatedGroupId = n.generatedGroupId;
out.nodes.push_back(rn);
}
@@ -958,6 +1057,7 @@ void RoadSystem::buildRegionData(long px, long py, const RoadGraph &graph,
rn.y = ts->renderToWorldY(n->position.y);
rn.z = ts->renderToWorldZ(n->position.z);
rn.verticalOffset = n->verticalOffset;
rn.generatedGroupId = n->generatedGroupId;
out.nodes.push_back(rn);
};
@@ -982,6 +1082,8 @@ void RoadSystem::buildRegionData(long px, long py, const RoadGraph &graph,
re.sidewalkRight = e.sidewalkRight;
re.sidewalkLeftMode = (int)e.sidewalkLeftMode;
re.sidewalkRightMode = (int)e.sidewalkRightMode;
re.markingsMode = (int)e.markingsMode;
copyDecalsToRegion(e, re);
for (const RoadSidewalkPass &p : e.sidewalkPasses) {
RoadRegionSidewalkPass rp;
rp.rightSide = p.rightSide;
@@ -1031,6 +1133,11 @@ void RoadSystem::flushRegionStore()
m_regionStore.saveRegion(kv.second.first, kv.second.second,
data);
}
/* Streets (phase 3) go alongside the region files. */
nlohmann::json sj;
RoadNetworkGen::streetsToJson(graph, sj);
m_regionStore.saveStreets(sj);
}
void RoadSystem::onRenderOriginChanged(const Ogre::Vector3 &delta)
@@ -1122,6 +1229,15 @@ uint64_t RoadSystem::computePageSignature(uint64_t key) const
fnvHashPod(h, cfg.apronSkirtDepth);
fnvHashString(h, cfg.roadMeshTemplate);
fnvHashString(h, cfg.sidewalkMeshTemplate);
/* Phase 2.5: markings/decal overlay + atlas UV config. */
fnvHashPod(h, cfg.markingsEnabled);
fnvHashPod(h, cfg.markingLineWidth);
fnvHashPod(h, cfg.markingDashLength);
fnvHashPod(h, cfg.markingDashGap);
fnvHashPod(h, cfg.markingStopWidth);
fnvHashPod(h, cfg.markingStopSetback);
fnvHashPod(h, cfg.markingEdgeInset);
fnvHashPod(h, cfg.textureTileLength);
/* Seed nodes of this page's wedges/segments. */
std::set<int> nodeIds;
@@ -1186,6 +1302,16 @@ uint64_t RoadSystem::computePageSignature(uint64_t key) const
fnvHashPod(h, slot->lateralOffset);
fnvHashPod(h, slot->yOffset);
}
fnvHashPod(h, e.markingsMode);
for (const RoadEdgeDecal &d : e.decals) {
fnvHashPod(h, d.edgeT);
fnvHashPod(h, d.lateralOffset);
fnvHashPod(h, d.size);
fnvHashPod(h, d.uvX);
fnvHashPod(h, d.uvY);
fnvHashPod(h, d.uvW);
fnvHashPod(h, d.uvH);
}
}
return h;
}
@@ -1336,6 +1462,105 @@ void RoadSystem::buildPageMeshes(RoadPageGeometry &pg)
}
flushChunk();
/*
* Markings and decals (phase 2.5): separate overlay chunk buffers,
* same chunking rule but WITHOUT collider, navmesh contribution or
* LOD set. Markings are built per wedge/segment (each page owns
* its seed nodes' pieces, so no duplication); decals are per edge
* with the halves attributed to the page seeding the owning node
* (edgeT <= 0.5 -> nodeA) so cross-page edges paint once.
*/
std::vector<std::shared_ptr<Procedural::TriangleBuffer> > markingChunks;
std::vector<std::shared_ptr<Procedural::TriangleBuffer> > decalChunks;
if (!chunks.empty()) {
std::shared_ptr<Procedural::TriangleBuffer> mcur =
std::make_shared<Procedural::TriangleBuffer>();
std::shared_ptr<Procedural::TriangleBuffer> dcur =
std::make_shared<Procedural::TriangleBuffer>();
auto flushOverlay = [](std::shared_ptr<Procedural::TriangleBuffer> &c,
std::vector<std::shared_ptr<Procedural::TriangleBuffer> > &dst) {
if (!c->getIndices().empty())
dst.push_back(c);
c = std::make_shared<Procedural::TriangleBuffer>();
};
auto appendOverlay = [&](std::shared_ptr<Procedural::TriangleBuffer> &c,
std::vector<std::shared_ptr<Procedural::TriangleBuffer> > &dst,
const Procedural::TriangleBuffer &piece) {
if (piece.getIndices().empty())
return;
if (!c->getIndices().empty() &&
c->getVertices().size() + piece.getVertices().size() >
ROAD_PAGE_CHUNK_MAX_VERTICES)
flushOverlay(c, dst);
c->append(piece);
};
for (const RoadWedge &w : pg.wedges) {
RoadGeometryLib::RoadMarkingBuffers mb;
RoadGeometryLib::buildWedgeMarkings(w, rg, mb);
appendOverlay(mcur, markingChunks, mb.centerline);
appendOverlay(mcur, markingChunks, mb.laneLines);
appendOverlay(mcur, markingChunks, mb.edgeLines);
appendOverlay(mcur, markingChunks, mb.stopLines);
}
for (const RoadStraightSegment &s : pg.segments) {
RoadGeometryLib::RoadMarkingBuffers mb;
RoadGeometryLib::buildSegmentMarkings(s, rg, mb);
appendOverlay(mcur, markingChunks, mb.centerline);
appendOverlay(mcur, markingChunks, mb.laneLines);
appendOverlay(mcur, markingChunks, mb.edgeLines);
appendOverlay(mcur, markingChunks, mb.stopLines);
}
flushOverlay(mcur, markingChunks);
/* Debug: ROAD_DUMP_MARKINGS=<file> writes every marking
* triangle (9 floats per line) for offline analysis. */
if (const char *dumpPath = getenv("ROAD_DUMP_MARKINGS")) {
std::ofstream dump(dumpPath, std::ios::app);
for (const auto &mc : markingChunks) {
const auto &vv = mc->getVertices();
const auto &ii = mc->getIndices();
for (size_t t = 0; t + 2 < ii.size(); t += 3) {
const Ogre::Vector3 &a =
vv[ii[t]].mPosition;
const Ogre::Vector3 &b =
vv[ii[t + 1]].mPosition;
const Ogre::Vector3 &c =
vv[ii[t + 2]].mPosition;
dump << a.x << " " << a.y << " " << a.z
<< " " << b.x << " " << b.y << " "
<< b.z << " " << c.x << " " << c.y
<< " " << c.z << "\n";
}
}
}
std::set<int> seedIds;
for (const RoadWedge &w : pg.wedges)
seedIds.insert(w.nodeId);
for (const RoadStraightSegment &s : pg.segments)
seedIds.insert(s.nodeId);
std::set<int> edgeSet;
for (int id : seedIds)
for (size_t ei : rg.findEdgeIndicesForNode(id))
edgeSet.insert((int)ei);
for (int ei : edgeSet) {
const RoadEdge &e = rg.edges[(size_t)ei];
if (e.decals.empty())
continue;
Procedural::TriangleBuffer piece;
int owners[2] = { e.nodeA, e.nodeB };
for (int oi = 0; oi < 2; ++oi) {
if (!seedIds.count(owners[oi]))
continue;
RoadGeometryLib::buildEdgeDecals(rg, (size_t)ei,
piece,
owners[oi]);
}
appendOverlay(dcur, decalChunks, piece);
}
flushOverlay(dcur, decalChunks);
}
if (chunks.empty()) {
/* No road content seeded on this page. */
@@ -1416,6 +1641,45 @@ void RoadSystem::buildPageMeshes(RoadPageGeometry &pg)
lod.settingsEntity = ensureRoadLodSettings(rg.config);
e.set<LodComponent>(lod);
}
/*
* Overlay chunk entities (phase 2.5): marking strips and decal
* quads. Deliberately no NavMeshGeometrySource, no LodComponent
* and no materialEntity — the finalize pass assigns the shared
* marking/decal material by name (picked from the "_markings"/
* "_decals" mesh-name infix) and never builds colliders for them.
*/
auto createOverlayChunks = [&](const std::vector<std::shared_ptr<Procedural::TriangleBuffer> > &ochunks,
const std::string &infix) {
for (size_t i = 0; i < ochunks.size(); ++i) {
std::string meshName = baseName + infix;
if (ochunks.size() > 1)
meshName += "_c" + std::to_string(i);
flecs::entity e = m_world.entity();
TransformComponent xf;
xf.node = m_sceneMgr->getRootSceneNode()
->createChildSceneNode(meshName +
"_node");
e.set<TransformComponent>(xf);
RenderableComponent rend;
rend.meshName = meshName;
e.set<RenderableComponent>(rend);
pg.overlayEntities.push_back(e);
TriangleBufferComponent tb;
tb.buffer = ochunks[i];
tb.proceduralContent = true;
tb.meshName = meshName;
tb.dirty = true;
e.set<TriangleBufferComponent>(tb);
}
};
createOverlayChunks(markingChunks, "_markings");
createOverlayChunks(decalChunks, "_decals");
}
void RoadSystem::destroyPageMeshes(RoadPageGeometry &pg)
@@ -1459,6 +1723,43 @@ void RoadSystem::destroyPageMeshes(RoadPageGeometry &pg)
e.destruct();
}
pg.bufferEntities.clear();
/* Overlay chunks (phase 2.5 markings/decals): same teardown, minus
* the navmesh bookkeeping they never contributed to. */
for (flecs::entity e : pg.overlayEntities) {
if (!e.is_alive())
continue;
if (e.has<TriangleBufferComponent>()) {
auto &tb = e.get_mut<TriangleBufferComponent>();
if (tb.ogreEntity) {
try {
m_sceneMgr->destroyEntity(tb.ogreEntity);
} catch (...) {
}
tb.ogreEntity = nullptr;
}
if (!tb.meshName.empty()) {
try {
Ogre::MeshManager::getSingleton()
.remove(tb.meshName);
} catch (...) {
}
}
tb.meshCreated = false;
}
if (e.has<TransformComponent>()) {
auto &xf = e.get_mut<TransformComponent>();
if (xf.node) {
m_sceneMgr->destroySceneNode(xf.node);
xf.node = nullptr;
}
}
e.destruct();
}
pg.overlayEntities.clear();
pg.meshFinalized = false;
if (contributedToNavMesh)
@@ -1522,6 +1823,236 @@ flecs::entity RoadSystem::ensureRoadLodSettings(const RoadConfig &cfg)
return m_lodSettingsEntity;
}
/* ------------------------------------------------------------------ */
/* Phase 2.5 overlay materials and atlas textures */
/* ------------------------------------------------------------------ */
void RoadSystem::createRoadAtlasTextures()
{
Ogre::TextureManager &tm = Ogre::TextureManager::getSingleton();
const Ogre::String &grp =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
/* Road surface atlas: U bands 0-0.5 asphalt, 0.5-0.75 sidewalk,
* 0.75-1.0 apron; V tiles along the road (TAM_WRAP). The asphalt
* noise is elongated along image Y (the along-road V axis), so the
* grain reads as wheel-track streaks along the lane instead of
* isotropic speckle; mipmaps stay enabled (default) so distant
* minification blurs instead of throwing cross-road moire straps.
*/
if (!tm.resourceExists("RoadAtlasTexture")) {
const int W = 256, H = 256;
std::vector<Ogre::uchar> data((size_t)W * H * 3);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
float u = ((float)x + 0.5f) / (float)W;
/* Cheap deterministic value noise so the
* bands do not look flat; asphalt quantizes Y
* into 16-px runs (streaks along the lane).
* The coordinates are avalanched separately
* before mixing — a bare multiply-xor hash
* leaves a lattice correlation that moires
* into a giant checkerboard on the road. */
int ys = (u < 0.5f) ? y / 16 : y;
unsigned hx = (unsigned)x * 0x8da6b343u;
hx ^= hx >> 15;
unsigned hy = (unsigned)ys * 0xd8163841u;
hy ^= hy >> 15;
unsigned s = hx ^ hy;
s *= 0xcb1ab31fu;
s ^= s >> 16;
float n = (float)(s & 0xff) / 255.0f - 0.5f;
int r, g, b;
if (u < 0.5f) {
/* Asphalt: dark gray, fine noise. */
int c = 58 + (int)(n * 24.0f);
r = g = b = c;
} else if (u < 0.75f) {
/* Sidewalk: light gray paving. */
int c = 150 + (int)(n * 18.0f);
r = g = b = c;
} else {
/* Apron: warm tan gravel. */
r = 128 + (int)(n * 20.0f);
g = 112 + (int)(n * 18.0f);
b = 92 + (int)(n * 16.0f);
}
size_t idx = ((size_t)y * W + x) * 3;
data[idx + 0] = (Ogre::uchar)std::max(
0, std::min(255, r));
data[idx + 1] = (Ogre::uchar)std::max(
0, std::min(255, g));
data[idx + 2] = (Ogre::uchar)std::max(
0, std::min(255, b));
}
}
Ogre::Image img;
img.loadDynamicImage(data.data(), W, H, 1, Ogre::PF_BYTE_RGB);
tm.loadImage("RoadAtlasTexture", grp, img, Ogre::TEX_TYPE_2D,
Ogre::MIP_DEFAULT, 1.0f);
}
/*
* Decal atlas (RGBA, transparent background): four quadrant
* presets, selected per decal via RoadEdgeDecal uv* —
* [0, 0]-[0.5, 0.5] manhole cover (dark disc + ring)
* [0.5, 0]-[1, 0.5] direction arrow (white, +V forward)
* [0, 0.5]-[0.5, 1] road damage patch (dark blotch)
* [0.5, 0.5]-[1, 1] parking bars (white horizontal lines)
* Placeholder art until a real atlas lands in the asset pipeline.
*/
if (!tm.resourceExists("RoadDecalAtlas")) {
const int W = 256, H = 256;
std::vector<Ogre::uchar> data((size_t)W * H * 4, 0);
auto px = [&](int x, int y, int r, int g, int b, int a) {
size_t idx = ((size_t)y * W + x) * 4;
data[idx + 0] = (Ogre::uchar)r;
data[idx + 1] = (Ogre::uchar)g;
data[idx + 2] = (Ogre::uchar)b;
data[idx + 3] = (Ogre::uchar)a;
};
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
/* Local quadrant coordinates 0..1. */
float qx = (x % 128 + 0.5f) / 128.0f;
float qy = (y % 128 + 0.5f) / 128.0f;
bool right = x >= 128;
bool bottom = y >= 128;
if (!right && !bottom) {
/* Manhole: dark disc with a ring. */
float dx = qx - 0.5f, dy = qy - 0.5f;
float rr = std::sqrt(dx * dx + dy * dy);
if (rr < 0.42f) {
int c = 40;
if (rr > 0.36f || rr < 0.1f)
c = 70;
px(x, y, c, c, c, 255);
}
} else if (right && !bottom) {
/* Arrow: shaft + head, forward +V. */
bool shaft = std::fabs(qx - 0.5f) < 0.06f &&
qy > 0.15f && qy < 0.7f;
float hd = std::fabs(qx - 0.5f) +
(qy - 0.7f);
bool head = qy >= 0.7f && qy < 0.95f &&
hd < 0.35f;
if (shaft || head)
px(x, y, 230, 230, 230, 255);
} else if (!right && bottom) {
/* Damage patch: irregular blotch. */
float dx = qx - 0.5f, dy = qy - 0.55f;
float rr = std::sqrt(dx * dx + dy * dy);
unsigned s = (unsigned)(x * 2654435761u) ^
(unsigned)(y * 40503u);
float n = ((s >> 13) & 0xff) / 255.0f;
if (rr < 0.3f + 0.12f * n)
px(x, y, 26, 24, 22,
150 + (int)(60 * n));
} else {
/* Parking bars: white horizontals. */
float fy = qy * 5.0f;
bool bar = (fy - std::floor(fy)) < 0.45f &&
qx > 0.15f && qx < 0.85f;
if (bar)
px(x, y, 235, 235, 235, 255);
}
}
}
Ogre::Image img;
img.loadDynamicImage(data.data(), W, H, 1, Ogre::PF_BYTE_RGBA);
tm.loadImage("RoadDecalAtlas", grp, img, Ogre::TEX_TYPE_2D, 0,
1.0f);
}
}
void RoadSystem::ensureOverlayMaterials(const RoadConfig &cfg)
{
Ogre::MaterialManager &mm = Ogre::MaterialManager::getSingleton();
const Ogre::String &grp =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!mm.resourceExists(cfg.markingMaterialName)) {
/*
* Unlit white paint. RTSS ignores material colours in a
* lighting-disabled pass and renders it white — exactly
* what road paint needs. Culling is disabled so the
* paper-thin quads render from any angle; depth write
* stays on (the strips hover 2 cm above the road top, so
* no bias is needed).
*/
Ogre::MaterialPtr mat =
mm.create(cfg.markingMaterialName, grp);
Ogre::Technique *tech = mat->getTechnique(0);
Ogre::Pass *pass = tech->getNumPasses() > 0 ?
tech->getPass(0) :
tech->createPass();
pass->setLightingEnabled(false);
pass->setCullingMode(Ogre::CULL_NONE);
}
if (!mm.resourceExists(cfg.decalMaterialName)) {
createRoadAtlasTextures();
Ogre::MaterialPtr mat = mm.create(cfg.decalMaterialName, grp);
Ogre::Technique *tech = mat->getTechnique(0);
Ogre::Pass *pass = tech->getNumPasses() > 0 ?
tech->getPass(0) :
tech->createPass();
pass->setLightingEnabled(false);
pass->setCullingMode(Ogre::CULL_NONE);
pass->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA);
pass->setDepthWriteEnabled(false);
Ogre::TextureUnitState *tu =
pass->createTextureUnitState("RoadDecalAtlas");
tu->setTextureAddressingMode(Ogre::TextureUnitState::TAM_CLAMP);
}
}
void RoadSystem::syncRoadAtlasTextureUnit(const RoadConfig &cfg)
{
if (cfg.textureTileLength <= 1e-6f)
return;
Ogre::MaterialManager &mm = Ogre::MaterialManager::getSingleton();
if (!mm.resourceExists(cfg.roadMaterialName))
return;
createRoadAtlasTextures();
/*
* The road material is restyled in place by
* ProceduralMaterialSystem (which removes all passes whenever the
* material component is dirty), so attach the atlas texture unit
* whenever it is missing. Both axes wrap: U never leaves
* [0, 1] but V tiles once per textureTileLength meters.
*/
Ogre::MaterialPtr mat = mm.getByName(
cfg.roadMaterialName,
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
if (!mat)
return;
for (unsigned ti = 0; ti < mat->getNumTechniques(); ++ti) {
Ogre::Technique *tech = mat->getTechnique(ti);
for (unsigned pi = 0; pi < tech->getNumPasses(); ++pi) {
Ogre::Pass *pass = tech->getPass(pi);
bool has = false;
for (unsigned ui = 0; ui < pass->getNumTextureUnitStates();
++ui) {
if (pass->getTextureUnitState(ui)->getTextureName() ==
"RoadAtlasTexture") {
has = true;
break;
}
}
if (!has) {
Ogre::TextureUnitState *tu =
pass->createTextureUnitState(
"RoadAtlasTexture");
tu->setTextureAddressingMode(
Ogre::TextureUnitState::TAM_WRAP);
}
}
}
}
void RoadSystem::markNavMeshDirty()
{
/* While streaming, page churn dirties the navmesh repeatedly;
@@ -1656,12 +2187,13 @@ static void addBox(Ogre::ManualObject *mo, const Ogre::Vector3 &center,
void RoadSystem::rebuildVisualAids()
{
if (!m_nodeMarkers || !m_edgeLines || !m_widthIndicators ||
!m_selectionHighlight)
if (!m_nodeMarkers || !m_edgeLines || !m_lotOutlines ||
!m_widthIndicators || !m_selectionHighlight)
return;
m_nodeMarkers->clear();
m_edgeLines->clear();
m_lotOutlines->clear();
m_widthIndicators->clear();
m_selectionHighlight->clear();
@@ -1680,6 +2212,7 @@ void RoadSystem::rebuildVisualAids()
buildNodeMarkers();
buildEdgeLines();
buildLotOutlines();
buildWidthIndicators();
buildSelectionHighlight();
@@ -1750,6 +2283,40 @@ void RoadSystem::buildEdgeLines()
m_edgeLines->end();
}
void RoadSystem::buildLotOutlines()
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
const RoadGraph &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
/* Potential building lots (RoadNetworkGen::findLots with the
* default 45 m / 8-edge filter and edge-fallback squares) drawn as
* dark grey outline loops, lifted slightly above the road surface
* like the other helper geometry. */
std::vector<RoadNetworkGen::RoadLot> lots =
RoadNetworkGen::findLots(rg);
m_lotOutlines->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_lotOutlines->colour(Ogre::ColourValue(0.1f, 0.1f, 0.1f));
for (const RoadNetworkGen::RoadLot &lot : lots) {
size_t n = lot.polygon.size();
for (size_t i = 0; i < n; ++i) {
Ogre::Vector3 a = lot.polygon[i];
Ogre::Vector3 b = lot.polygon[(i + 1) % n];
a.y += 0.25f;
b.y += 0.25f;
m_lotOutlines->position(a);
m_lotOutlines->position(b);
}
}
m_lotOutlines->end();
}
void RoadSystem::buildWidthIndicators()
{
flecs::entity terrain = getTerrainEntity();
@@ -1966,6 +2533,131 @@ int RoadSystem::relaxImpossibleCorners()
return (int)m_impossibleCorners.size();
}
uint32_t RoadSystem::generateRoadNetwork(
const RoadNetworkGen::GridParams &params, std::string *error)
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
if (error)
*error = "no terrain entity";
return 0;
}
RoadGraph &graph =
terrain.get_mut<TerrainComponent>().roadGraph;
/* Heights come from the terrain; the water level from the
* scene's WaterPhysics entity (default -0.1 when absent). */
TerrainSystem *ts = TerrainSystem::getInstance();
RoadNetworkGen::HeightSampler sampler;
if (ts)
sampler = [ts](float x, float z) {
return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z));
};
RoadNetworkGen::GridParams p = params;
if (p.avoidWater) {
float waterY = -0.1f;
m_world.each([&waterY](WaterPhysics &wp) {
waterY = wp.waterSurfaceY;
});
p.waterLevel = waterY;
}
uint32_t groupId =
RoadNetworkGen::generateGrid(graph, p, sampler, error);
if (groupId == 0)
return 0;
m_impossibleCornersVersion = ~0ull;
clearGenPreview();
rebuildVisualAids();
return groupId;
}
void RoadSystem::updateGenPreview(const RoadNetworkGen::GridParams &params)
{
if (!m_genPreview)
return;
m_genPreview->clear();
/* Generate into a throwaway graph — the real graph is never
* touched; only the would-be edges are drawn as lines. */
RoadGraph temp;
flecs::entity terrain = getTerrainEntity();
if (terrain.is_alive() && terrain.has<TerrainComponent>())
temp.config = terrain.get<TerrainComponent>().roadGraph.config;
/* Same height/water inputs as generateRoadNetwork(). */
TerrainSystem *ts = TerrainSystem::getInstance();
RoadNetworkGen::HeightSampler sampler;
if (ts)
sampler = [ts](float x, float z) {
return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z));
};
RoadNetworkGen::GridParams p = params;
if (p.avoidWater) {
float waterY = -0.1f;
m_world.each([&waterY](WaterPhysics &wp) {
waterY = wp.waterSurfaceY;
});
p.waterLevel = waterY;
}
if (RoadNetworkGen::generateGrid(temp, p, sampler, nullptr) == 0)
return;
m_genPreview->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_genPreview->colour(Ogre::ColourValue(1.0f, 0.65f, 0.0f));
for (const RoadEdge &e : temp.edges) {
const RoadNode *na = temp.findNodeById(e.nodeA);
const RoadNode *nb = temp.findNodeById(e.nodeB);
if (!na || !nb)
continue;
m_genPreview->position(na->position +
Ogre::Vector3(0.0f, 0.3f, 0.0f));
m_genPreview->position(nb->position +
Ogre::Vector3(0.0f, 0.3f, 0.0f));
}
m_genPreview->end();
}
void RoadSystem::clearGenPreview()
{
if (m_genPreview)
m_genPreview->clear();
}
int RoadSystem::resolveCrossingsNow(std::string *error)
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
if (error)
*error = "no terrain entity";
return -1;
}
RoadGraph &graph =
terrain.get_mut<TerrainComponent>().roadGraph;
int created = RoadNetworkGen::resolveAllCrossings(graph, error);
if (created > 0) {
m_impossibleCornersVersion = ~0ull;
rebuildVisualAids();
}
return created;
}
bool RoadSystem::clearGenerated(uint32_t groupId)
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return false;
RoadGraph &graph =
terrain.get_mut<TerrainComponent>().roadGraph;
if (!RoadNetworkGen::removeGroup(graph, groupId))
return false;
m_impossibleCornersVersion = ~0ull;
rebuildVisualAids();
return true;
}
const Procedural::TriangleBuffer &
RoadSystem::getRoadTemplate(const RoadConfig &cfg)
{
@@ -18,6 +18,7 @@
#include "../components/RoadGraph.hpp"
#include "../roadlib/RoadGeometryLib.hpp"
#include "../roadlib/RoadNetworkGen.hpp"
#include "RoadRegionStore.hpp"
namespace Ogre {
@@ -49,6 +50,14 @@ struct RoadPageGeometry {
*/
std::vector<flecs::entity> bufferEntities;
/**
* Overlay chunk entities of this page (phase 2.5): painted marking
* strips and decal quads hovering slightly above the road top.
* Purely visual — no physics collider, no navmesh contribution, no
* LOD set; they share the chunking rule of bufferEntities.
*/
std::vector<flecs::entity> overlayEntities;
/**
* Jolt bodies of this page's static road chunk colliders (M5.9):
* one body per chunk mesh, created from the chunk's render mesh
@@ -185,6 +194,57 @@ public:
*/
int relaxImpossibleCorners();
/**
* Phase 3: procedural road network services (RoadNetworkGen).
*
* generateRoadNetwork emits a seeded settlement grid into the
* terrain road graph (heights sampled from the terrain, water
* level from the scene's WaterPhysics entity) and returns the
* generatedGroupId stamped on the new nodes (0 on failure).
* resolveCrossingsNow turns every edge crossing in the graph into
* a proper junction and returns the number created (-1 when the
* pass limit was exhausted). clearGenerated removes every node
* (and cascading edges) of one generated group.
*/
uint32_t generateRoadNetwork(const RoadNetworkGen::GridParams &params,
std::string *error = nullptr);
int resolveCrossingsNow(std::string *error = nullptr);
bool clearGenerated(uint32_t groupId);
/**
* Generator origin picking: while armed, the next road-edit-mode
* terrain click (EditorUISystem::handleRoadEditClick) is consumed
* as the generator origin instead of a tool action; the terrain
* panel polls takePickedOrigin() to pick it up.
*/
void armOriginPick(bool armed) { m_originPickArmed = armed; }
bool isOriginPickArmed() const { return m_originPickArmed; }
void setPickedOrigin(const Ogre::Vector3 &p)
{
m_pickedOrigin = p;
m_originPicked = true;
}
bool takePickedOrigin(Ogre::Vector3 &out)
{
if (!m_originPicked)
return false;
out = m_pickedOrigin;
m_originPicked = false;
return true;
}
/**
* Generator preview: updateGenPreview() runs generateGrid() into a
* throwaway graph (same terrain height/water inputs as
* generateRoadNetwork) and draws the would-be edges as orange
* lines, so generator parameters can be tweaked before the actual
* generation; the real graph is never touched. The preview lives
* on its own scene-node attachment, independent of the road-edit
* visual aids. clearGenPreview() removes the lines.
*/
void updateGenPreview(const RoadNetworkGen::GridParams &params);
void clearGenPreview();
/** Wedge-debug mode for geometry inspection. */
bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; }
void setDebugWedgeEnabled(bool v);
@@ -489,6 +549,7 @@ private:
void buildNodeMarkers();
void buildEdgeLines();
void buildLotOutlines();
void buildWidthIndicators();
void buildSelectionHighlight();
@@ -528,6 +589,14 @@ private:
void destroyPageMeshes(RoadPageGeometry &pg);
flecs::entity ensureRoadMaterial(const RoadConfig &cfg);
flecs::entity ensureRoadLodSettings(const RoadConfig &cfg);
/* Overlay materials/textures (phase 2.5): the unlit white marking
* material, the alpha-blended decal material and the procedurally
* generated road/decal atlas textures. All created lazily via
* Ogre::MaterialManager/TextureManager when missing. */
void ensureOverlayMaterials(const RoadConfig &cfg);
void createRoadAtlasTextures();
void syncRoadAtlasTextureUnit(const RoadConfig &cfg);
void markNavMeshDirty();
void applyNavMeshDirty();
@@ -562,6 +631,12 @@ private:
std::string m_connectError;
bool m_connectErrorPending = false;
/* Generator origin picking: while armed the next road-edit terrain
* click is captured as the generator origin for the panel. */
bool m_originPickArmed = false;
bool m_originPicked = false;
Ogre::Vector3 m_pickedOrigin = Ogre::Vector3::ZERO;
/* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated
* wedges/segments bucketed by seed-node page for ALL pages (including
* unloaded ones); m_pageGeometry only tracks loaded pages. */
@@ -621,8 +696,10 @@ private:
Ogre::SceneNode *m_visualNode = nullptr;
Ogre::ManualObject *m_nodeMarkers = nullptr;
Ogre::ManualObject *m_edgeLines = nullptr;
Ogre::ManualObject *m_lotOutlines = nullptr;
Ogre::ManualObject *m_widthIndicators = nullptr;
Ogre::ManualObject *m_selectionHighlight = nullptr;
Ogre::ManualObject *m_genPreview = nullptr;
};
#endif // EDITSCENE_ROADSYSTEM_HPP
@@ -26,6 +26,7 @@
#include "../components/CharacterSlots.hpp"
#include "../components/CharacterIdentity.hpp"
#include "CharacterRegistry.hpp"
#include "../roadlib/RoadNetworkGen.hpp"
#include "../components/AnimationTree.hpp"
#include "AnimationTreeRegistry.hpp"
#include "AnimationTreeRegistry.hpp"
@@ -1181,6 +1182,13 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity,
// Restore main scene entity map
m_entityMap = savedMap;
/* Resolve procedural texture references queued while
* deserializing the prefab (a self-contained prefab carries
* both the texture and the material referencing it); at
* runtime nothing else calls this — it only runs at the end
* of a full scene load. */
resolveProceduralTextureReferences();
/* Run any scene scripts contained in the prefab subtree
* (pending-only, so repeat instantiations are cheap), then
* notify listeners that this instance finished loading. */
@@ -4814,10 +4822,33 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
tc.roadGraph.config.apronSkirtDepth;
roadConfigJson["cornerFilletLength"] =
tc.roadGraph.config.cornerFilletLength;
roadConfigJson["crossingSnapDistance"] =
tc.roadGraph.config.crossingSnapDistance;
roadConfigJson["crossingHeightThreshold"] =
tc.roadGraph.config.crossingHeightThreshold;
roadConfigJson["prefabSpawnDistance"] =
tc.roadGraph.config.prefabSpawnDistance;
roadConfigJson["prefabDespawnDistance"] =
tc.roadGraph.config.prefabDespawnDistance;
/* Phase 2.5: markings + road atlas texture. */
roadConfigJson["markingsEnabled"] = tc.roadGraph.config.markingsEnabled;
roadConfigJson["markingLineWidth"] =
tc.roadGraph.config.markingLineWidth;
roadConfigJson["markingDashLength"] =
tc.roadGraph.config.markingDashLength;
roadConfigJson["markingDashGap"] = tc.roadGraph.config.markingDashGap;
roadConfigJson["markingStopWidth"] =
tc.roadGraph.config.markingStopWidth;
roadConfigJson["markingStopSetback"] =
tc.roadGraph.config.markingStopSetback;
roadConfigJson["markingEdgeInset"] =
tc.roadGraph.config.markingEdgeInset;
roadConfigJson["markingMaterialName"] =
tc.roadGraph.config.markingMaterialName;
roadConfigJson["decalMaterialName"] =
tc.roadGraph.config.decalMaterialName;
roadConfigJson["textureTileLength"] =
tc.roadGraph.config.textureTileLength;
json["roadConfig"] = roadConfigJson;
/* Streaming terrains persist the road network in per-page region
@@ -4832,6 +4863,8 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
nj["position"] = { n.position.x, n.position.y,
n.position.z };
nj["verticalOffset"] = n.verticalOffset;
if (n.generatedGroupId != 0)
nj["generatedGroupId"] = n.generatedGroupId;
roadNodesJson.push_back(nj);
}
json["roadNodes"] = roadNodesJson;
@@ -4850,6 +4883,21 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
ej["sidewalkRight"] = e.sidewalkRight;
ej["sidewalkLeftMode"] = (int)e.sidewalkLeftMode;
ej["sidewalkRightMode"] = (int)e.sidewalkRightMode;
if ((int)e.markingsMode != 0)
ej["markingsMode"] = (int)e.markingsMode;
if (!e.decals.empty()) {
nlohmann::json dj = nlohmann::json::array();
for (const RoadEdgeDecal &d : e.decals) {
nlohmann::json jd;
jd["edgeT"] = d.edgeT;
jd["lateralOffset"] = d.lateralOffset;
jd["size"] = d.size;
jd["uv"] = { d.uvX, d.uvY, d.uvW,
d.uvH };
dj.push_back(jd);
}
ej["decals"] = dj;
}
if (!e.sidewalkPasses.empty()) {
nlohmann::json pj = nlohmann::json::array();
for (const RoadSidewalkPass &p :
@@ -4877,6 +4925,10 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
roadEdgesJson.push_back(ej);
}
json["roadEdges"] = roadEdgesJson;
/* Phase 3: named streets (streaming terrains store them
* in roads/streets.json via RoadSystem instead). */
RoadNetworkGen::streetsToJson(tc.roadGraph, json);
}
/* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */
@@ -4993,10 +5045,35 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
rcj.value("apronSkirtDepth", 2.0f);
tc.roadGraph.config.cornerFilletLength =
rcj.value("cornerFilletLength", 12.0f);
tc.roadGraph.config.crossingSnapDistance =
rcj.value("crossingSnapDistance", 5.0f);
tc.roadGraph.config.crossingHeightThreshold =
rcj.value("crossingHeightThreshold", 10.0f);
tc.roadGraph.config.prefabSpawnDistance =
rcj.value("prefabSpawnDistance", 150.0f);
tc.roadGraph.config.prefabDespawnDistance =
rcj.value("prefabDespawnDistance", 250.0f);
/* Phase 2.5: markings + road atlas texture. */
tc.roadGraph.config.markingsEnabled =
rcj.value("markingsEnabled", true);
tc.roadGraph.config.markingLineWidth =
rcj.value("markingLineWidth", 0.15f);
tc.roadGraph.config.markingDashLength =
rcj.value("markingDashLength", 3.0f);
tc.roadGraph.config.markingDashGap =
rcj.value("markingDashGap", 3.0f);
tc.roadGraph.config.markingStopWidth =
rcj.value("markingStopWidth", 0.4f);
tc.roadGraph.config.markingStopSetback =
rcj.value("markingStopSetback", 1.0f);
tc.roadGraph.config.markingEdgeInset =
rcj.value("markingEdgeInset", 0.25f);
tc.roadGraph.config.markingMaterialName =
rcj.value("markingMaterialName", "RoadMarkingMaterial");
tc.roadGraph.config.decalMaterialName =
rcj.value("decalMaterialName", "RoadDecalMaterial");
tc.roadGraph.config.textureTileLength =
rcj.value("textureTileLength", 0.0f);
}
if (json.contains("roadNodes") && json["roadNodes"].is_array()) {
@@ -5004,6 +5081,8 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
RoadNode node;
node.id = nj.value("id", 0);
node.verticalOffset = nj.value("verticalOffset", 0.0f);
node.generatedGroupId =
nj.value("generatedGroupId", (uint32_t)0);
if (nj.contains("position") && nj["position"].is_array() &&
nj["position"].size() >= 3) {
node.position.x = nj["position"][0];
@@ -5033,6 +5112,28 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
"sidewalkLeftMode", edge.sidewalkLeft ? 0 : 2);
edge.sidewalkRightMode = (RoadSidewalkMode)ej.value(
"sidewalkRightMode", edge.sidewalkRight ? 0 : 2);
/* Phase 2.5: marking tri-state (absent = Inherit) and
* the decal list. */
edge.markingsMode =
(RoadMarkingMode)ej.value("markingsMode", 0);
if (ej.contains("decals") && ej["decals"].is_array()) {
for (const auto &jd : ej["decals"]) {
RoadEdgeDecal d;
d.edgeT = jd.value("edgeT", 0.5f);
d.lateralOffset =
jd.value("lateralOffset", 0.0f);
d.size = jd.value("size", 1.0f);
if (jd.contains("uv") &&
jd["uv"].is_array() &&
jd["uv"].size() == 4) {
d.uvX = jd["uv"][0];
d.uvY = jd["uv"][1];
d.uvW = jd["uv"][2];
d.uvH = jd["uv"][3];
}
edge.decals.push_back(d);
}
}
if (ej.contains("sidewalkPasses") &&
ej["sidewalkPasses"].is_array()) {
for (const auto &jp : ej["sidewalkPasses"]) {
@@ -5102,6 +5203,11 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
}
}
/* Phase 3: named streets (absent in legacy scenes; streaming
* terrains load them from roads/streets.json via RoadSystem). */
if (json.contains("streets"))
RoadNetworkGen::streetsFromJson(tc.roadGraph, json);
tc.roadGraph.bumpVersion();
entity.set<TerrainComponent>(tc);
@@ -34,16 +34,20 @@ TerrainPrefabSpawnerSystem::TerrainPrefabSpawnerSystem(
{
s_instance = this;
m_world.observer<TerrainPrefabSpawnerComponent>(
"TerrainPrefabSpawnerCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, TerrainPrefabSpawnerComponent &) {
despawn(e);
});
m_cleanupObserver =
m_world.observer<TerrainPrefabSpawnerComponent>(
"TerrainPrefabSpawnerCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e,
TerrainPrefabSpawnerComponent &) {
despawn(e);
});
}
TerrainPrefabSpawnerSystem::~TerrainPrefabSpawnerSystem()
{
if (m_cleanupObserver.is_alive())
m_cleanupObserver.destruct();
for (auto &pair : m_spawners) {
if (pair.second.spawned.is_alive())
destroyInstanceRecursive(pair.second.spawned);
@@ -163,6 +163,9 @@ private:
Ogre::Vector3 getCameraPosition() const;
flecs::world &m_world;
/* Kept so the destructor can unregister it: the observer must not
* fire into a destroyed system during world teardown (ecs_fini). */
flecs::entity m_cleanupObserver;
Ogre::SceneManager *m_sceneMgr;
EditorCameraSystem *m_cameraSystem;
EditorUISystem *m_uiSystem;
@@ -1,5 +1,6 @@
#include "TerrainSystem.hpp"
#include "RoadSystem.hpp"
#include "TownGenSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../physics/physics.h"
@@ -3012,6 +3013,11 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform,
m_roadSystem->setTerrainEntity(m_world.entity(m_terrainEntityId));
m_roadSystem->setVisualAidsVisible(m_roadEditMode);
/* Town generation builds on the road graph; it holds no OGRE
* objects of its own, so no teardown is needed on deactivate. */
m_townGenSystem = std::make_unique<TownGenSystem>(m_world,
m_sceneMgr);
if (m_physics)
m_physics->setBodyDrawFilter(&mBodyDrawFilter);
@@ -3112,6 +3118,7 @@ void TerrainSystem::deactivate()
/* Destroy road system visual aids before terrain teardown. */
m_roadSystem.reset();
m_townGenSystem.reset();
deactStep("roadSystem reset");
removeAllColliders();
@@ -32,6 +32,7 @@
class JoltPhysicsWrapper;
class RoadSystem;
class TownGenSystem;
class TerrainSystem {
public:
@@ -406,6 +407,11 @@ public:
return m_roadSystem.get();
}
TownGenSystem *getTownGenSystem() const
{
return m_townGenSystem.get();
}
/* Physics wrapper used for terrain/road colliders (may be null).
* Exposed for headless tests that verify collider interaction. */
JoltPhysicsWrapper *getPhysics() const
@@ -527,6 +533,7 @@ public:
private:
std::unique_ptr<RoadSystem> m_roadSystem;
std::unique_ptr<TownGenSystem> m_townGenSystem;
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform,
flecs::entity_t terrainEntityId);
@@ -0,0 +1,516 @@
#include "TownGenSystem.hpp"
#include "TerrainSystem.hpp"
#include "RoadSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/Primitive.hpp"
#include "../components/TriangleBuffer.hpp"
#include "../components/RigidBody.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../components/ProceduralMaterial.hpp"
#include "../components/ProceduralTexture.hpp"
#include "../components/WaterPhysics.hpp"
#include "../components/RoadGraph.hpp"
#include "../roadlib/RoadNetworkGen.hpp"
#include <cmath>
#include <cfloat>
/* 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;
/* Foundation reaches this far below the lowest terrain corner. */
static const float FOUNDATION_MARGIN = 0.5f;
/* Stairs step geometry. */
static const float STAIR_RISE = 0.18f;
static const float STAIR_TREAD = 0.30f;
static const float STAIR_WIDTH = 1.4f;
TownGenSystem::TownGenSystem(flecs::world &world, Ogre::SceneManager *sceneMgr)
: m_world(world)
, m_sceneMgr(sceneMgr)
{
m_terrainSystem = TerrainSystem::getInstance();
}
int TownGenSystem::countTowns() const
{
int count = 0;
m_world.query<EntityNameComponent, TownComponent>().each(
[&count](flecs::entity, EntityNameComponent &name,
TownComponent &) {
if (name.name.rfind("TownGen ", 0) == 0)
++count;
});
return count;
}
int TownGenSystem::clear()
{
std::vector<flecs::entity> towns;
m_world.query<EntityNameComponent, TownComponent>().each(
[&towns](flecs::entity e, EntityNameComponent &name,
TownComponent &) {
if (name.name.rfind("TownGen ", 0) == 0)
towns.push_back(e);
});
for (flecs::entity e : towns)
e.destruct();
return (int)towns.size();
}
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";
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));
};
TownGen::TownResult result = TownGen::generateTown(graph, p, sampler);
/* Replace any previously generated town. */
clear();
if (result.buildings.empty()) {
if (error)
*error = "no suitable lots";
return 0;
}
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);
container.set<EntityNameComponent>(
EntityNameComponent(containerName));
container.add<EditorMarkerComponent>();
TransformComponent containerXform;
containerXform.node =
m_sceneMgr->getRootSceneNode()->createChildSceneNode();
containerXform.position = Ogre::Vector3::ZERO;
containerXform.rotation = Ogre::Quaternion::IDENTITY;
containerXform.scale = Ogre::Vector3::UNIT_SCALE;
containerXform.applyToNode();
container.set<TransformComponent>(containerXform);
flecs::entity texture =
createSettlementResources(container, dp, p.seed);
flecs::entity material = m_world.entity();
material.child_of(container);
material.set<EntityNameComponent>(
EntityNameComponent(containerName + " material"));
material.add<EditorMarkerComponent>();
ProceduralMaterialComponent mat;
mat.materialName = containerName + " material";
mat.diffuseTextureId =
texture.get<ProceduralTextureComponent>().textureId;
mat.diffuseTextureEntity = texture;
material.set<ProceduralMaterialComponent>(mat);
TownComponent town;
town.townName = containerName;
town.proceduralMaterialEntity = material;
town.proceduralMaterialEntityId = std::to_string(material.id());
container.set<TownComponent>(town);
for (size_t i = 0; i < result.buildings.size(); ++i)
createBuilding(container, material, texture,
result.buildings[i], result.pois[i]);
return (int)result.buildings.size();
}
flecs::entity
TownGenSystem::createSettlementResources(flecs::entity container,
const TownGen::DistrictParams &dp,
uint32_t seed)
{
flecs::entity texture = m_world.entity();
texture.child_of(container);
texture.set<EntityNameComponent>(EntityNameComponent(
"towngen_tex_" + std::to_string(seed)));
texture.add<EditorMarkerComponent>();
ProceduralTextureComponent tex;
tex.textureName = "towngen_tex_" + std::to_string(seed);
/* Whole atlas tinted with the district wall color; specific
* rects get their own colors. */
Ogre::ColourValue wall(dp.wallColor[0], dp.wallColor[1],
dp.wallColor[2]);
for (int i = 0; i < ProceduralTextureComponent::RECT_COUNT; ++i)
tex.setColor(i, wall);
tex.setColor(1, Ogre::ColourValue(0.55f, 0.30f, 0.25f)); /* roof */
tex.setColor(2, Ogre::ColourValue(0.55f, 0.55f, 0.55f)); /* found. */
tex.setColor(3, Ogre::ColourValue(0.35f, 0.22f, 0.12f)); /* door */
tex.setColor(4, Ogre::ColourValue(0.90f, 0.90f, 0.88f)); /* winframe */
tex.setColor(5, Ogre::ColourValue(0.75f, 0.70f, 0.62f)); /* floor */
tex.setColor(6, Ogre::ColourValue(0.92f, 0.92f, 0.92f)); /* ceiling */
tex.addNamedRect("wall", 0);
tex.addNamedRect("roof", 1);
tex.addNamedRect("foundation", 2);
tex.addNamedRect("door", 3);
tex.addNamedRect("winframe", 4);
tex.addNamedRect("floor", 5);
tex.addNamedRect("ceiling", 6);
texture.set<ProceduralTextureComponent>(tex);
return texture;
}
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;
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";
grid.floorRectName = "floor";
grid.ceilingRectName = "ceiling";
grid.extWallRectName = "wall";
grid.intWallRectName = "wall";
grid.extDoorFrameRectName = "door";
grid.intDoorFrameRectName = "door";
grid.extWindowFrameRectName = "winframe";
grid.intWindowFrameRectName = "winframe";
grid.roofTopRectName = "roof";
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);
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);
}
}
}
grid.markDirty();
}
void TownGenSystem::createBuilding(flecs::entity container,
flecs::entity material, flecs::entity texture,
const TownGen::TownBuilding &b,
const TownGen::TownPoi &poi)
{
TerrainSystem *ts = m_terrainSystem ?
m_terrainSystem :
TerrainSystem::getInstance();
flecs::entity building = m_world.entity();
building.child_of(container);
building.set<EntityNameComponent>(EntityNameComponent(b.address));
building.add<EditorMarkerComponent>();
CellGridComponent grid;
fillBuildingCells(b, grid);
/* cellToWorld() puts cell (x, y, z) at x*cellSize, so the shell
* spans [0, width*cellSize] from the entity node — shift the
* 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,
0.0f,
(grid.depth - 1) * TOWN_CELL_SIZE * 0.5f);
Ogre::Quaternion yaw(Ogre::Radian(b.yaw), Ogre::Vector3::UNIT_Y);
/* TownGen works in render space (the height sampler is
* TerrainSystem::getHeightAt); store the authoritative world
* position so render-origin rebases keep the node exact. */
Ogre::Vector3 renderPos = b.center - yaw * shellCenter;
TransformComponent xform;
xform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode();
xform.position = renderPos;
xform.rotation = yaw;
xform.scale = Ogre::Vector3::UNIT_SCALE;
if (ts) {
xform.worldX = ts->renderToWorldX(renderPos.x);
xform.worldY = ts->renderToWorldY(renderPos.y);
xform.worldZ = ts->renderToWorldZ(renderPos.z);
xform.hasWorldPosition = true;
}
xform.applyToNode();
building.set<TransformComponent>(xform);
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);
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);
}
void TownGenSystem::createFoundation(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid,
flecs::entity material,
flecs::entity texture)
{
float foundationH = b.floorY - (b.minGroundY - FOUNDATION_MARGIN);
/* 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,
0.0f,
(grid.depth - 1) * TOWN_CELL_SIZE * 0.5f);
flecs::entity foundation = m_world.entity();
foundation.child_of(building);
foundation.set<EntityNameComponent>(EntityNameComponent("foundation"));
foundation.add<EditorMarkerComponent>();
/* Child of the building node: identity local transform, the
* buffer box hangs below the shell base (floor level). */
TransformComponent xform;
Ogre::SceneNode *buildingNode = building.get<TransformComponent>().node;
xform.node = buildingNode->createChildSceneNode();
xform.position = Ogre::Vector3::ZERO;
xform.rotation = Ogre::Quaternion::IDENTITY;
xform.scale = Ogre::Vector3::UNIT_SCALE;
xform.applyToNode();
foundation.set<TransformComponent>(xform);
TriangleBufferComponent tb;
tb.textureEntity = texture;
tb.textureRectName = "foundation";
tb.materialEntity = material;
tb.meshName = b.address + "_foundation";
foundation.set<TriangleBufferComponent>(tb);
RigidBodyComponent rb;
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);
PrimitiveComponent prim;
prim.type = PrimitiveComponent::Type::Box;
prim.boxSizeX = shellW + 0.15f;
prim.boxSizeY = foundationH;
prim.boxSizeZ = shellD + 0.15f;
box.set<PrimitiveComponent>(prim);
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);
}
void TownGenSystem::createStairs(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid,
flecs::entity material, flecs::entity texture)
{
int steps = (int)std::ceil(b.stairsDrop / STAIR_RISE);
if (steps < 2)
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;
flecs::entity stairs = m_world.entity();
stairs.child_of(building);
stairs.set<EntityNameComponent>(EntityNameComponent("stairs"));
stairs.add<EditorMarkerComponent>();
TransformComponent xform;
Ogre::SceneNode *parentNode =
building.get<TransformComponent>().node;
xform.node = parentNode->createChildSceneNode();
xform.position = Ogre::Vector3::ZERO;
xform.rotation = Ogre::Quaternion::IDENTITY;
xform.scale = Ogre::Vector3::UNIT_SCALE;
xform.applyToNode();
stairs.set<TransformComponent>(xform);
TriangleBufferComponent tb;
tb.textureEntity = texture;
tb.textureRectName = "foundation";
tb.materialEntity = material;
tb.meshName = b.address + "_stairs";
stairs.set<TriangleBufferComponent>(tb);
RigidBodyComponent rb;
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. */
for (int i = 0; i < steps - 1; ++i) {
float surfaceY = -((float)i + 1.0f) * rise;
float height = surfaceY - (-b.stairsDrop);
if (height < 0.01f)
continue;
flecs::entity step = m_world.entity();
step.child_of(stairs);
step.set<EntityNameComponent>(
EntityNameComponent("step " + std::to_string(i)));
TransformComponent stepXform;
stepXform.node = xform.node->createChildSceneNode();
stepXform.position = Ogre::Vector3(
doorX, (-b.stairsDrop + surfaceY) * 0.5f,
frontZ + ((float)i + 0.5f) * STAIR_TREAD);
stepXform.rotation = Ogre::Quaternion::IDENTITY;
stepXform.scale = Ogre::Vector3::UNIT_SCALE;
stepXform.applyToNode();
step.set<TransformComponent>(stepXform);
PrimitiveComponent prim;
prim.type = PrimitiveComponent::Type::Box;
prim.boxSizeX = STAIR_WIDTH;
prim.boxSizeY = height;
prim.boxSizeZ = STAIR_TREAD;
step.set<PrimitiveComponent>(prim);
PhysicsColliderComponent collider;
collider.shapeType = PhysicsColliderComponent::ShapeType::Box;
collider.parameters = Ogre::Vector3(
STAIR_WIDTH * 0.5f, height * 0.5f, STAIR_TREAD * 0.5f);
step.set<PhysicsColliderComponent>(collider);
}
}
@@ -0,0 +1,73 @@
#pragma once
#include <string>
#include <flecs.h>
#include <Ogre.h>
#include "../roadlib/TownGen.hpp"
#include "../components/CellGrid.hpp"
class TerrainSystem;
/**
* Town generation system (demo-taxi phase 4).
*
* Turns the lots of the terrain's road graph into a procedural
* settlement of CellGrid buildings (roadlib/TownGen does the pure
* placement math): one exteriorOnly CellGrid shell per lot, seated on
* a foundation block (PrimitiveComponent box) with entrance stairs
* when the door-side ground drops below the floor, plus a named
* pickup POI child at the curb. Every building carries its
* scene-switch door config with doorsEnabled=false so Phase 8 only
* has to enable and wire the interiors.
*
* Generated entities are ordinary scene entities (EditorMarker) under
* a container entity named "TownGen <district> seed <seed>" holding a
* TownComponent that points at the shared settlement material, so
* they stay editable and serialize with the scene. generate() clears
* any previous generated town first; clear() removes it.
*
* Placement convention: `CellGridComponent::cellToWorld()` puts cell
* (x, y, z) at x*cellSize, so a shell spans [0, width*cellSize] from
* its node. createBuilding() therefore shifts the building node so
* the shell is CENTERED on TownBuilding::center (front face at local
* +Z facing the street); the foundation/stairs children position
* themselves in the same shell coordinates.
*/
class TownGenSystem
{
public:
TownGenSystem(flecs::world &world, Ogre::SceneManager *sceneMgr);
/** Generate a settlement on the active terrain's road graph.
* Returns the number of buildings placed (0 on failure, @p error
* set). Clears the previously generated town first. */
int generate(const TownGen::TownParams &params, std::string *error);
/** Destroy every generated town (all "TownGen " containers). */
int clear();
/** Live "TownGen " container entities. */
int countTowns() const;
private:
flecs::entity createSettlementResources(flecs::entity container,
const TownGen::DistrictParams &dp,
uint32_t seed);
void createBuilding(flecs::entity container, flecs::entity material,
flecs::entity texture, const TownGen::TownBuilding &b,
const TownGen::TownPoi &poi);
void createFoundation(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid,
flecs::entity material, flecs::entity texture);
void createStairs(flecs::entity building, const TownGen::TownBuilding &b,
const CellGridComponent &grid,
flecs::entity material, flecs::entity texture);
static void fillBuildingCells(const TownGen::TownBuilding &b,
CellGridComponent &grid);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
TerrainSystem *m_terrainSystem = nullptr;
};
File diff suppressed because it is too large Load Diff
+458 -4
View File
@@ -8,6 +8,7 @@
#include "../components/EntityName.hpp"
#include "../systems/TerrainSystem.hpp"
#include "../systems/RoadSystem.hpp"
#include "../systems/TownGenSystem.hpp"
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
#include "../systems/PrefabSystem.hpp"
#include "NavigationPanel.hpp"
@@ -21,6 +22,7 @@
#include <cstring>
#include <filesystem>
#include <functional>
#include <map>
#include <string>
#include <unordered_map>
#include <vector>
@@ -807,10 +809,278 @@ private:
"(aprons start at the sidewalk's "
"outer top edge)");
}
ImGui::SeparatorText("Markings");
cfgChanged |= ImGui::Checkbox(
"Markings Enabled", &rg.config.markingsEnabled);
if (rg.config.markingsEnabled) {
cfgChanged |= ImGui::SliderFloat(
"Line Width",
&rg.config.markingLineWidth, 0.05f,
0.5f, "%.2f");
cfgChanged |= ImGui::SliderFloat(
"Dash Length",
&rg.config.markingDashLength, 0.5f,
10.0f, "%.1f");
cfgChanged |= ImGui::SliderFloat(
"Dash Gap", &rg.config.markingDashGap,
0.5f, 10.0f, "%.1f");
cfgChanged |= ImGui::SliderFloat(
"Stop Line Width",
&rg.config.markingStopWidth, 0.1f,
1.0f, "%.2f");
cfgChanged |= ImGui::SliderFloat(
"Stop Setback",
&rg.config.markingStopSetback, 0.0f,
5.0f, "%.1f");
cfgChanged |= ImGui::SliderFloat(
"Edge Line Inset",
&rg.config.markingEdgeInset, 0.0f,
1.0f, "%.2f");
}
ImGui::SeparatorText("Road Texture");
cfgChanged |= ImGui::SliderFloat(
"Tile Length (0 = off)",
&rg.config.textureTileLength, 0.0f, 50.0f,
"%.1f m");
if (rg.config.textureTileLength > 0.0f)
ImGui::TextDisabled(
"(atlas UVs: asphalt/sidewalk/apron "
"bands, V tiles along the road)");
if (cfgChanged)
rg.bumpVersion();
ImGui::TreePop();
}
/* Phase 3: procedural settlement generator, crossing
* resolution and generated-group management (RoadNetworkGen). */
if (ImGui::TreeNode("Road Network Generator")) {
static int s_blocksX = 3, s_blocksZ = 3;
static float s_blockSizeX = 100.0f, s_blockSizeZ = 100.0f;
static float s_jitter = 0.0f;
static int s_seed = 1;
static float s_origin[2] = { 0.0f, 0.0f };
static bool s_avoidWater = true;
static float s_maxSlope = 0.35f;
static bool s_resolve = true;
static bool s_pickArmed = false;
static bool s_preview = false;
static std::string s_genResult;
bool paramsChanged =
ImGui::InputFloat2("Origin XZ", s_origin);
if (!s_pickArmed) {
if (ImGui::Button("Pick Origin on Terrain")) {
/* The pick click is handled by road
* edit mode. */
if (ts)
ts->setRoadEditMode(true);
rs->armOriginPick(true);
s_pickArmed = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Arm a terrain click that sets "
"the generator origin XZ (uses "
"road edit mode).");
} else {
ImGui::TextDisabled(
"Click the terrain to set the "
"origin...");
ImGui::SameLine();
if (ImGui::SmallButton("Cancel")) {
rs->armOriginPick(false);
s_pickArmed = false;
}
}
Ogre::Vector3 pickedOrigin;
if (rs->takePickedOrigin(pickedOrigin)) {
s_origin[0] = pickedOrigin.x;
s_origin[1] = pickedOrigin.z;
s_pickArmed = false;
paramsChanged = true;
}
paramsChanged |= ImGui::SliderInt("Blocks X", &s_blocksX, 1,
20);
paramsChanged |= ImGui::SliderInt("Blocks Z", &s_blocksZ, 1,
20);
paramsChanged |= ImGui::SliderFloat("Block Size X",
&s_blockSizeX,
30.0f, 500.0f,
"%.0f m");
paramsChanged |= ImGui::SliderFloat("Block Size Z",
&s_blockSizeZ,
30.0f, 500.0f,
"%.0f m");
paramsChanged |= ImGui::SliderFloat("Jitter", &s_jitter,
0.0f, 20.0f,
"%.1f m");
paramsChanged |= ImGui::InputInt("Seed", &s_seed);
paramsChanged |= ImGui::Checkbox("Avoid Water",
&s_avoidWater);
paramsChanged |= ImGui::SliderFloat("Max Edge Slope",
&s_maxSlope, 0.0f,
1.0f, "%.2f");
ImGui::Checkbox("Resolve Crossings", &s_resolve);
auto buildParams = [&]() {
RoadNetworkGen::GridParams p;
p.origin = Ogre::Vector3(s_origin[0], 0.0f,
s_origin[1]);
p.blocksX = s_blocksX;
p.blocksZ = s_blocksZ;
p.blockSizeX = s_blockSizeX;
p.blockSizeZ = s_blockSizeZ;
p.jitter = s_jitter;
p.seed = (uint32_t)s_seed;
p.avoidWater = s_avoidWater;
p.maxSlope = s_maxSlope;
p.runCrossingResolution = s_resolve;
return p;
};
if (ImGui::Checkbox("Preview Grid", &s_preview)) {
if (!s_preview)
rs->clearGenPreview();
else
paramsChanged = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Draw the would-be grid as orange lines "
"without touching the road graph; "
"refreshes on every parameter change.");
if (s_preview && paramsChanged)
rs->updateGenPreview(buildParams());
if (ImGui::Button("Generate Grid")) {
s_preview = false;
RoadNetworkGen::GridParams p = buildParams();
std::string err;
uint32_t gid = rs->generateRoadNetwork(p, &err);
char buf[256];
if (gid == 0)
snprintf(buf, sizeof(buf),
"Generation failed: %s",
err.c_str());
else
snprintf(buf, sizeof(buf),
"Generated group %u (%zu nodes, "
"%zu edges in the graph).",
gid, rg.nodes.size(),
rg.edges.size());
s_genResult = buf;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Emit a deterministic seeded settlement grid. "
"All generated nodes are stamped with a group id "
"and can only be removed as a whole group.");
if (ImGui::Button("Resolve Crossings Now")) {
std::string err;
int created = rs->resolveCrossingsNow(&err);
char buf[256];
if (created < 0)
snprintf(buf, sizeof(buf),
"Crossing resolution failed: %s",
err.c_str());
else
snprintf(buf, sizeof(buf),
"Crossing resolution: %d "
"junction(s) created.",
created);
s_genResult = buf;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Turn every crossing of road edges into a "
"proper junction (edges closer than "
"crossingHeightThreshold in Y; larger gaps "
"are overpasses and left alone).");
if (!s_genResult.empty())
ImGui::TextWrapped("%s", s_genResult.c_str());
/* Generated groups present in the graph; each is
* removed as a whole. */
std::map<uint32_t, int> groupCounts;
for (const RoadNode &n : rg.nodes)
if (n.generatedGroupId != 0)
++groupCounts[n.generatedGroupId];
for (const auto &kv : groupCounts) {
ImGui::PushID((int)kv.first);
ImGui::Text("Group %u (%d nodes)", kv.first,
kv.second);
ImGui::SameLine();
if (ImGui::SmallButton("Remove")) {
rs->clearGenerated(kv.first);
ImGui::PopID();
break;
}
ImGui::PopID();
}
ImGui::TreePop();
}
/* Phase 4: procedural town generation on the road lots
* (TownGenSystem + roadlib/TownGen). */
if (ImGui::TreeNode("Town Generator")) {
static int s_district = 0;
static int s_townSeed = 1;
static float s_density = 1.0f;
static std::string s_townResult;
const char *districts[] = {
"Village", "Town", "City Residential",
"City Center"
};
TownGenSystem *tgs = ts->getTownGenSystem();
if (!tgs) {
ImGui::TextDisabled(
"Town generation is unavailable.");
} else {
ImGui::Combo("District", &s_district, districts, 4);
ImGui::InputInt("Seed##town", &s_townSeed);
ImGui::SliderFloat("Density##town", &s_density, 0.0f, 1.0f,
"%.2f");
if (ImGui::Button("Generate Town")) {
TownGen::TownParams p;
p.district = (TownGen::District)s_district;
p.seed = (uint32_t)s_townSeed;
p.densityScale = s_density;
std::string err;
int placed = tgs->generate(p, &err);
char buf[256];
if (placed == 0)
snprintf(buf, sizeof(buf),
"Town generation failed: %s",
err.c_str());
else
snprintf(buf, sizeof(buf),
"Placed %d buildings.",
placed);
s_townResult = buf;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Place one building per suitable road lot "
"(deterministic under the seed). Replaces "
"the previously generated town.");
ImGui::SameLine();
if (ImGui::Button("Clear Town")) {
int removed = tgs->clear();
char buf[128];
snprintf(buf, sizeof(buf),
"Removed %d town(s).", removed);
s_townResult = buf;
}
if (!s_townResult.empty())
ImGui::TextWrapped("%s", s_townResult.c_str());
}
ImGui::TreePop();
}
/* Toolbar — visible only in road edit mode. */
if (ts->getRoadEditMode()) {
/* Tool selector: Move Node (default) / Add Node /
@@ -831,10 +1101,28 @@ private:
ts->setRoadEditTool(
TerrainSystem::RoadEditTool::Connect);
if (ImGui::Button("Remove Selected Node") &&
rs->getSelectedNodeId() >= 0) {
rg.removeNode(rs->getSelectedNodeId());
rs->setSelectedNodeId(-1);
{
const RoadNode *selNode =
rs->getSelectedNodeId() >= 0 ?
rg.findNodeById(
rs->getSelectedNodeId()) :
nullptr;
bool selGrouped = selNode &&
selNode->generatedGroupId != 0;
ImGui::BeginDisabled(selGrouped);
if (ImGui::Button("Remove Selected Node") &&
rs->getSelectedNodeId() >= 0) {
rg.removeNode(rs->getSelectedNodeId());
rs->setSelectedNodeId(-1);
}
ImGui::EndDisabled();
if (selGrouped &&
ImGui::IsItemHovered(
ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip(
"Generated-group node: remove the "
"whole group in the Road Network "
"Generator section.");
}
static std::string s_roadValidationError;
@@ -1038,6 +1326,9 @@ private:
for (const auto &n : rg.nodes) {
bool selected = (n.id == selectedNodeId);
std::string label = "Node " + std::to_string(n.id);
if (n.generatedGroupId != 0)
label += " [gen " +
std::to_string(n.generatedGroupId) + "]";
bool showConnect =
selectedNodeId >= 0 && selectedNodeId != n.id;
float rowWidth = ImGui::GetContentRegionAvail().x;
@@ -1089,6 +1380,12 @@ private:
std::string label = "Edge " + std::to_string(i) + ": " +
std::to_string(e.nodeA) + " <-> " +
std::to_string(e.nodeB);
/* Generated-group edges are unsplittable/unremovable:
* the group is managed as a whole. */
const RoadNode *ea = rg.findNodeById(e.nodeA);
const RoadNode *eb = rg.findNodeById(e.nodeB);
bool eGrouped = (ea && ea->generatedGroupId != 0) ||
(eb && eb->generatedGroupId != 0);
float rowWidth = ImGui::GetContentRegionAvail().x;
rowWidth -= ImGui::CalcTextSize("Split").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
@@ -1105,6 +1402,7 @@ private:
rs->setSelectedNodeId(-1);
}
ImGui::SameLine();
ImGui::BeginDisabled(eGrouped);
if (ImGui::SmallButton("Split")) {
int newId = rg.splitEdge(i, 0.5f);
/* The midpoint Y is interpolated between the
@@ -1126,6 +1424,14 @@ private:
rs->setSelectedEdgeIndex(-1);
}
}
ImGui::EndDisabled();
if (eGrouped &&
ImGui::IsItemHovered(
ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip(
"Generated-group edge: split/remove is "
"disabled; manage the group in the Road "
"Network Generator section.");
ImGui::PopID();
}
@@ -1417,6 +1723,154 @@ private:
}
}
/* Markings tri-state (phase 2.5): Inherit
* follows RoadConfig::markingsEnabled, On/Off
* force this edge. */
{
const char *mitems[3] = { "Inherit", "On",
"Off" };
int mcur = (int)e.markingsMode;
if (ImGui::Combo("Markings", &mcur,
mitems, 3)) {
e.markingsMode =
(RoadMarkingMode)mcur;
edgeChanged = true;
}
}
/* Street assignment (phase 3): an edge belongs
* to at most one named street; streets give
* lots their addresses. */
{
const RoadStreet *st =
RoadNetworkGen::findStreetForEdge(
rg, e.nodeA, e.nodeB);
static char s_streetBuf[128] = {};
if (st)
ImGui::Text("Street: %s",
st->name.c_str());
else
ImGui::TextDisabled(
"Street: (none)");
ImGui::SetNextItemWidth(160.0f);
ImGui::InputText("##streetname",
s_streetBuf,
sizeof(s_streetBuf));
ImGui::SameLine();
if (ImGui::SmallButton("Assign") &&
s_streetBuf[0]) {
RoadNetworkGen::nameStreet(
rg, s_streetBuf,
{ std::make_pair(e.nodeA,
e.nodeB) });
edgeChanged = true;
}
if (st) {
ImGui::SameLine();
if (ImGui::SmallButton("Unassign")) {
std::pair<int, int> key(
std::min(e.nodeA,
e.nodeB),
std::max(e.nodeA,
e.nodeB));
for (RoadStreet &s :
rg.streets) {
auto &edges =
s.edges;
edges.erase(
std::remove(
edges.begin(),
edges.end(),
key),
edges.end());
}
for (size_t si =
rg.streets.size();
si-- > 0;)
if (rg.streets[si]
.edges
.empty())
rg.streets.erase(
rg.streets.begin() +
si);
edgeChanged = true;
}
}
}
/* Decals (phase 2.5): atlas-textured quads
* painted on the road surface. The preset
* combo selects one of the four quadrants of
* the shared decal atlas; the uv rect stays
* directly editable for custom atlases. */
if (ImGui::TreeNode("Decals")) {
static const char *s_presets[4] = {
"Manhole", "Arrow", "Damage",
"Parking Bars"
};
static const float s_presetUv[4][4] = {
{ 0.0f, 0.0f, 0.5f, 0.5f },
{ 0.5f, 0.0f, 0.5f, 0.5f },
{ 0.0f, 0.5f, 0.5f, 0.5f },
{ 0.5f, 0.5f, 0.5f, 0.5f }
};
int removeDecal = -1;
for (size_t di = 0; di < e.decals.size();
++di) {
RoadEdgeDecal &d = e.decals[di];
ImGui::PushID((int)di + 1000);
int preset = -1;
for (int k = 0; k < 4; ++k)
if (d.uvX == s_presetUv[k][0] &&
d.uvY == s_presetUv[k][1] &&
d.uvW == s_presetUv[k][2] &&
d.uvH == s_presetUv[k][3])
preset = k;
ImGui::SetNextItemWidth(110.0f);
if (ImGui::Combo(
"##preset", &preset,
s_presets, 4)) {
d.uvX = s_presetUv[preset][0];
d.uvY = s_presetUv[preset][1];
d.uvW = s_presetUv[preset][2];
d.uvH = s_presetUv[preset][3];
edgeChanged = true;
}
ImGui::SameLine();
ImGui::SetNextItemWidth(90.0f);
edgeChanged |= ImGui::SliderFloat(
"T", &d.edgeT, 0.0f, 1.0f);
ImGui::SameLine();
if (ImGui::SmallButton("X"))
removeDecal = (int)di;
ImGui::SetNextItemWidth(150.0f);
edgeChanged |= ImGui::SliderFloat(
"Lateral (m)",
&d.lateralOffset, -15.0f,
15.0f);
ImGui::SetNextItemWidth(150.0f);
edgeChanged |= ImGui::SliderFloat(
"Size (m)", &d.size, 0.3f,
8.0f);
ImGui::PopID();
}
if (removeDecal >= 0) {
e.decals.erase(e.decals.begin() +
removeDecal);
edgeChanged = true;
}
if (ImGui::SmallButton("Add Decal")) {
RoadEdgeDecal d;
d.uvX = s_presetUv[0][0];
d.uvY = s_presetUv[0][1];
d.uvW = s_presetUv[0][2];
d.uvH = s_presetUv[0][3];
e.decals.push_back(d);
edgeChanged = true;
}
ImGui::TreePop();
}
/* Edge prefab slots (improvement plan §3):
* left/right curb anchors plus one centerline
* midpoint prefab. */