diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 584c58f..1a70a44 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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: 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 seed "` container + (found by name prefix) with a `TownComponent`, one shared + ProceduralTexture/Material pair, and per building an exterior-only + CellGrid shell (front door with `doorsEnabled = false` + placeholder + `town_interior.json` scene-switch path for Phase 8) with a flat + full-footprint `RoofComponent` child (`posY = floors` — + `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 diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index b46eb39..3b8fbc1 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 ) # --------------------------------------------------------------------------- diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index 87bd64f..5cd2f71 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -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//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 seed "` 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). diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp index d230599..2270668 100644 --- a/src/features/editScene/components/RoadGraph.hpp +++ b/src/features/editScene/components/RoadGraph.hpp @@ -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 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 > 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 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 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 &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); diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 3133171..b1f7ff4 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -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 "` 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. diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json index 9afe52c..db798e1 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json @@ -849,15 +849,15 @@ }, "transform": { "position": { - "x": 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": { diff --git a/src/features/editScene/demos/demo-taxi/screenshots/markings.png b/src/features/editScene/demos/demo-taxi/screenshots/markings.png new file mode 100644 index 0000000..afb7868 Binary files /dev/null and b/src/features/editScene/demos/demo-taxi/screenshots/markings.png differ diff --git a/src/features/editScene/main.cpp b/src/features/editScene/main.cpp index da1e698..68cd2fa 100644 --- a/src/features/editScene/main.cpp +++ b/src/features/editScene/main.cpp @@ -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=[,] 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 diff --git a/src/features/editScene/roadlib/RoadGeometryLib.cpp b/src/features/editScene/roadlib/RoadGeometryLib.cpp index 0d14813..64a1722 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.cpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.cpp @@ -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 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> &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> 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 * ---------------------------------------------------------------- */ diff --git a/src/features/editScene/roadlib/RoadGeometryLib.hpp b/src/features/editScene/roadlib/RoadGeometryLib.hpp index 919270c..2f14321 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.hpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.hpp @@ -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) * ---------------------------------------------------------------- */ diff --git a/src/features/editScene/roadlib/RoadNetworkGen.cpp b/src/features/editScene/roadlib/RoadNetworkGen.cpp new file mode 100644 index 0000000..e3ec07b --- /dev/null +++ b/src/features/editScene/roadlib/RoadNetworkGen.cpp @@ -0,0 +1,1042 @@ +#include "RoadNetworkGen.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace RoadNetworkGen { + +/* ---------------------------------------------------------------- + * Small XZ-plane geometry helpers (positions are Ogre::Vector3, the + * "y" of the 2D math is the world Z coordinate). + * ---------------------------------------------------------------- */ + +static float xzDist(const Ogre::Vector3 &a, const Ogre::Vector3 &b) +{ + float dx = a.x - b.x, dz = a.z - b.z; + return std::sqrt(dx * dx + dz * dz); +} + +/** + * Closest points between two XZ segments. Returns the parameters + * along each segment (clamped to [0, 1]); degenerate (zero-length) + * segments collapse to their point. + */ +static void segSegClosestXZ(const Ogre::Vector3 &a0, const Ogre::Vector3 &a1, + const Ogre::Vector3 &b0, const Ogre::Vector3 &b1, + float &outTA, float &outTB) +{ + float d1x = a1.x - a0.x, d1z = a1.z - a0.z; + float d2x = b1.x - b0.x, d2z = b1.z - b0.z; + float rx = a0.x - b0.x, rz = a0.z - b0.z; + + float a = d1x * d1x + d1z * d1z; + float e = d2x * d2x + d2z * d2z; + float f = d2x * rx + d2z * rz; + const float eps = 1e-12f; + + if (a <= eps && e <= eps) { + outTA = outTB = 0.0f; + return; + } + if (a <= eps) { + outTA = 0.0f; + outTB = std::max(0.0f, std::min(1.0f, f / e)); + return; + } + float c = d1x * rx + d1z * rz; + if (e <= eps) { + outTB = 0.0f; + outTA = std::max(0.0f, std::min(1.0f, -c / a)); + return; + } + float b = d1x * d2x + d1z * d2z; + float denom = a * e - b * b; + float tA = denom > eps ? + std::max(0.0f, + std::min(1.0f, (b * f - c * e) / denom)) : + 0.0f; + float tB = (b * tA + f) / e; + if (tB < 0.0f) { + tB = 0.0f; + tA = std::max(0.0f, std::min(1.0f, -c / a)); + } else if (tB > 1.0f) { + tB = 1.0f; + tA = std::max(0.0f, std::min(1.0f, (b - c) / a)); + } + outTA = tA; + outTB = tB; +} + +/** Signed doubled polygon area in XZ (positive = CCW with Z as "up"). */ +static double polySignedAreaXZ(const std::vector &poly) +{ + double s = 0.0; + for (size_t i = 0; i < poly.size(); ++i) { + const Ogre::Vector3 &p = poly[i]; + const Ogre::Vector3 &q = poly[(i + 1) % poly.size()]; + s += (double)p.x * (double)q.z - (double)q.x * (double)p.z; + } + return s * 0.5; +} + +/** Strict point-in-polygon test in XZ (ray cast, boundary excluded). */ +static bool pointInPolyXZ(const Ogre::Vector3 &p, + const std::vector &poly) +{ + bool inside = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) { + const Ogre::Vector3 &a = poly[i]; + const Ogre::Vector3 &b = poly[j]; + if ((a.z > p.z) == (b.z > p.z)) + continue; + float xint = a.x + (p.z - a.z) * (b.x - a.x) / (b.z - a.z); + if (p.x < xint) + inside = !inside; + } + return inside; +} + +static float crossXZ(const Ogre::Vector3 &o, const Ogre::Vector3 &a, + const Ogre::Vector3 &b) +{ + return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); +} + +/* Proper segment crossing: the segments intersect at a point strictly + * inside both (endpoint touching and collinear runs do not count, so a + * road running along a lot boundary is not a violation). */ +static bool segsCrossXZ(const Ogre::Vector3 &a0, const Ogre::Vector3 &a1, + const Ogre::Vector3 &b0, const Ogre::Vector3 &b1) +{ + const float eps = 1e-4f; + float d1 = crossXZ(b0, b1, a0); + float d2 = crossXZ(b0, b1, a1); + float d3 = crossXZ(a0, a1, b0); + float d4 = crossXZ(a0, a1, b1); + if (std::fabs(d1) < eps || std::fabs(d2) < eps || + std::fabs(d3) < eps || std::fabs(d4) < eps) + return false; /* touch, not cross */ + return ((d1 > 0.0f) != (d2 > 0.0f)) && + ((d3 > 0.0f) != (d4 > 0.0f)); +} + +/* Vertex-mean polygon centroid in XZ (strictly inside for convex + * polygons, which all fallback squares are; faces may be concave, + * where this is only a heuristic next to the edge-crossing test). */ +static Ogre::Vector3 polyCentroidXZ(const std::vector &p) +{ + Ogre::Vector3 c = Ogre::Vector3::ZERO; + for (const Ogre::Vector3 &v : p) { + c.x += v.x; + c.z += v.z; + } + float inv = p.empty() ? 1.0f : 1.0f / (float)p.size(); + c.x *= inv; + c.z *= inv; + return c; +} + +/* Polygon interior overlap in XZ: a proper edge crossing or one + * polygon containing the other's centroid. Vertices are NOT used for + * the containment test — a shared boundary vertex ray-casts as + * "inside" and would falsely reject lots that only touch (fallback + * squares built directly off a face's edge). */ +static bool polysOverlapXZ(const std::vector &a, + const std::vector &b) +{ + for (size_t i = 0; i < a.size(); ++i) + for (size_t j = 0; j < b.size(); ++j) + if (segsCrossXZ(a[i], a[(i + 1) % a.size()], + b[j], b[(j + 1) % b.size()])) + return true; + if (a.size() >= 3 && b.size() >= 3) { + if (pointInPolyXZ(polyCentroidXZ(a), b)) + return true; + if (pointInPolyXZ(polyCentroidXZ(b), a)) + return true; + } + return false; +} + +/* ---------------------------------------------------------------- + * Edge measures + * ---------------------------------------------------------------- */ + +float edgeHalfWidth(const RoadGraph &graph, const RoadEdge &edge) +{ + int lo = 0, li = 0; + graph.resolveLaneCounts(edge, edge.nodeA, lo, li); + float w = (lo + li) * graph.config.laneWidth * 0.5f; + if (graph.edgeSidewalkEnabled(edge, true)) + w += graph.config.sidewalkWidth; + if (graph.edgeSidewalkEnabled(edge, false)) + w += graph.config.sidewalkWidth; + return w; +} + +float edgeSurfaceYAt(const RoadGraph &graph, const RoadEdge &edge, float t) +{ + const RoadNode *na = graph.findNodeById(edge.nodeA); + const RoadNode *nb = graph.findNodeById(edge.nodeB); + if (!na || !nb) + return 0.0f; + float yA = na->position.y + edge.roadLevelA; + float yB = nb->position.y + edge.roadLevelB; + return yA + (yB - yA) * t; +} + +/* ---------------------------------------------------------------- + * Crossing detection + * ---------------------------------------------------------------- */ + +std::vector findCrossings(const RoadGraph &graph) +{ + std::vector out; + for (size_t i = 0; i < graph.edges.size(); ++i) { + const RoadEdge &ea = graph.edges[i]; + const RoadNode *a1 = graph.findNodeById(ea.nodeA); + const RoadNode *a2 = graph.findNodeById(ea.nodeB); + if (!a1 || !a2) + continue; + float hwA = edgeHalfWidth(graph, ea); + for (size_t j = i + 1; j < graph.edges.size(); ++j) { + const RoadEdge &eb = graph.edges[j]; + /* Edges sharing a node meet at a junction + * already. */ + if (ea.nodeA == eb.nodeA || ea.nodeA == eb.nodeB || + ea.nodeB == eb.nodeA || ea.nodeB == eb.nodeB) + continue; + const RoadNode *b1 = graph.findNodeById(eb.nodeA); + const RoadNode *b2 = graph.findNodeById(eb.nodeB); + if (!b1 || !b2) + continue; + + float tA, tB; + segSegClosestXZ(a1->position, a2->position, + b1->position, b2->position, tA, tB); + Ogre::Vector3 pa = a1->position + + (a2->position - a1->position) * tA; + Ogre::Vector3 pb = b1->position + + (b2->position - b1->position) * tB; + float dist = xzDist(pa, pb); + if (dist > hwA + edgeHalfWidth(graph, eb)) + continue; + + pa.y = edgeSurfaceYAt(graph, ea, tA); + pb.y = edgeSurfaceYAt(graph, eb, tB); + float dy = std::fabs(pa.y - pb.y); + if (dy > graph.config.crossingHeightThreshold) + continue; /* overpass/underpass */ + + RoadCrossing c; + c.nodeA1 = ea.nodeA; + c.nodeA2 = ea.nodeB; + c.nodeB1 = eb.nodeA; + c.nodeB2 = eb.nodeB; + c.tA = tA; + c.tB = tB; + c.pointA = pa; + c.pointB = pb; + c.xzDistance = dist; + c.yDifference = dy; + out.push_back(c); + } + } + return out; +} + +/* ---------------------------------------------------------------- + * Crossing resolution + * ---------------------------------------------------------------- */ + +/** + * Junction node for one side of a crossing: reuse a near end node, + * collapse a too-short edge, or split at the crossing parameter. + * Returns the junction node id, or -1 when the edge is gone. + */ +static int junctionNodeFor(RoadGraph &graph, int n1, int n2, float t, + float snap) +{ + int ei = graph.findEdgeIndex(n1, n2); + if (ei < 0) + return -1; + const RoadEdge &e = graph.edges[(size_t)ei]; + const RoadNode *pa = graph.findNodeById(e.nodeA); + const RoadNode *pb = graph.findNodeById(e.nodeB); + if (!pa || !pb) + return -1; + + /* The detection parameter is relative to the STORED edge + * direction; flip when the caller passed the endpoints in the + * other order. */ + float tA = (e.nodeA == n1) ? t : 1.0f - t; + float len = xzDist(pa->position, pb->position); + float dA = tA * len; + float dB = (1.0f - tA) * len; + if (dA <= snap) + return e.nodeA; + if (dB <= snap) + return e.nodeB; + if (len <= 2.0f * ROAD_MIN_EDGE_LENGTH) { + /* Too short to split: collapse the edge into the end + * nearer to the crossing point. */ + int keep = dA <= dB ? e.nodeA : e.nodeB; + int drop = dA <= dB ? e.nodeB : e.nodeA; + graph.mergeNodes(keep, drop); + return keep; + } + return graph.splitEdge((size_t)ei, tA); +} + +bool resolveCrossing(RoadGraph &graph, const RoadCrossing &crossing, + std::string *error) +{ + float snap = graph.config.crossingSnapDistance; + int jA = junctionNodeFor(graph, crossing.nodeA1, crossing.nodeA2, + crossing.tA, snap); + if (jA < 0) { + if (error) + *error = "crossing edge A no longer exists"; + return false; + } + int jB = junctionNodeFor(graph, crossing.nodeB1, crossing.nodeB2, + crossing.tB, snap); + if (jB < 0) { + if (error) + *error = "crossing edge B no longer exists"; + return false; + } + if (jA != jB) { + RoadNode *keep = graph.findNodeById(jA); + if (!keep) { + if (error) + *error = "junction node lost"; + return false; + } + keep->position = (crossing.pointA + crossing.pointB) * 0.5f; + graph.mergeNodes(jA, jB); + } + graph.bumpVersion(); + return true; +} + +int resolveAllCrossings(RoadGraph &graph, std::string *error, int maxPasses) +{ + int resolved = 0; + for (int pass = 0; pass < maxPasses; ++pass) { + std::vector crossings = findCrossings(graph); + if (crossings.empty()) + return resolved; + if (!resolveCrossing(graph, crossings.front(), error)) + return -1; + ++resolved; + } + if (error) + *error = "crossing resolution did not converge"; + return -1; +} + +/* ---------------------------------------------------------------- + * Sanity + * ---------------------------------------------------------------- */ + +bool checkSane(const RoadGraph &graph, std::string *error) +{ + std::set > seen; + for (const RoadEdge &e : graph.edges) { + if (!graph.findNodeById(e.nodeA) || + !graph.findNodeById(e.nodeB)) { + if (error) + *error = "edge references a missing node"; + return false; + } + if (e.nodeA == e.nodeB) { + if (error) + *error = "self-edge at node " + + std::to_string(e.nodeA); + return false; + } + std::pair key(std::min(e.nodeA, e.nodeB), + std::max(e.nodeA, e.nodeB)); + if (!seen.insert(key).second) { + if (error) + *error = "duplicate edge between nodes " + + std::to_string(key.first) + " and " + + std::to_string(key.second); + return false; + } + const RoadNode *na = graph.findNodeById(e.nodeA); + const RoadNode *nb = graph.findNodeById(e.nodeB); + if (xzDist(na->position, nb->position) < + ROAD_MIN_EDGE_LENGTH - 1e-4f) { + if (error) + *error = "sliver edge between nodes " + + std::to_string(key.first) + " and " + + std::to_string(key.second); + return false; + } + } + for (const RoadNode &n : graph.nodes) { + bool used = false; + for (const RoadEdge &e : graph.edges) { + if (e.nodeA == n.id || e.nodeB == n.id) { + used = true; + break; + } + } + if (!used) { + if (error) + *error = "isolated node " + + std::to_string(n.id); + return false; + } + } + return true; +} + +/* ---------------------------------------------------------------- + * Lots (enclosed faces) + * ---------------------------------------------------------------- */ + +std::vector findLots(const RoadGraph &graph, + const RoadLotFilter &filter) +{ + std::vector lots; + if (graph.edges.empty()) + return lots; + + /* Angle-sorted adjacency. */ + std::map > > adj; + for (const RoadEdge &e : graph.edges) { + const RoadNode *na = graph.findNodeById(e.nodeA); + const RoadNode *nb = graph.findNodeById(e.nodeB); + if (!na || !nb || na->position == nb->position) + continue; + float ab = std::atan2(nb->position.z - na->position.z, + nb->position.x - na->position.x); + float ba = std::atan2(na->position.z - nb->position.z, + na->position.x - nb->position.x); + adj[e.nodeA].push_back(std::make_pair(e.nodeB, ab)); + adj[e.nodeB].push_back(std::make_pair(e.nodeA, ba)); + } + for (auto &kv : adj) + std::sort(kv.second.begin(), kv.second.end(), + [](const std::pair &a, + const std::pair &b) { + return a.second < b.second; + }); + + /* + * Planar face walk: at every node take the sharpest right turn + * (the outgoing edge with the largest positive angle from the + * incoming heading in the atan2(z, x) sense - clockwise when + * viewed from +Y), which keeps the bounded face on the left and + * yields interior faces as CCW cycles (positive XZ signed area). + * The edge back to the previous node (U-turn) 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 + * (dangling edges), where it makes the walk retrace the edge and + * get the face rejected below. + */ + std::set > visited; + size_t maxSteps = graph.edges.size() * 4 + 16; + for (const RoadEdge &e : graph.edges) { + for (int dir = 0; dir < 2; ++dir) { + int u0 = dir == 0 ? e.nodeA : e.nodeB; + int v0 = dir == 0 ? e.nodeB : e.nodeA; + if (!adj.count(u0) || + visited.count(std::make_pair(u0, v0))) + continue; + + std::vector path; + std::set > walked; + bool dupEdge = false, closed = false; + int u = u0, v = v0; + size_t steps = 0; + while (steps++ < maxSteps) { + if (u == u0 && v == v0 && !path.empty()) { + closed = true; + break; + } + if (!walked.insert(std::make_pair(u, v)) + .second) { + dupEdge = true; + break; + } + path.push_back(u); + /* Sharpest right turn at v: largest + * normalized angle from the incoming + * heading, ignoring the edge back to u + * unless it is the only way out. */ + const RoadNode *pu = graph.findNodeById(u); + const RoadNode *pv = graph.findNodeById(v); + if (!pu || !pv) + break; + float heading = std::atan2( + pv->position.z - pu->position.z, + pv->position.x - pu->position.x); + int bestW = -1; + float bestTurn = -(float)(2.0 * M_PI); + for (const auto &nb : adj[v]) { + if (nb.first == u && adj[v].size() > 1) + continue; + float t = nb.second - heading; + while (t >= (float)M_PI) + t -= (float)(2.0 * M_PI); + while (t < -(float)M_PI) + t += (float)(2.0 * M_PI); + if (t > bestTurn) { + bestTurn = t; + bestW = nb.first; + } + } + if (bestW < 0) + bestW = u; /* dead end: retrace */ + if (bestW < 0) + break; + u = v; + v = bestW; + } + for (const auto &w : walked) + visited.insert(w); + if (dupEdge || !closed || steps >= maxSteps || + path.size() < 3) + continue; + + /* Node repeats (other than the closure) mean a + * spur or figure-eight — not a clean lot. */ + bool dupNode = false; + { + std::set seen; + for (int id : path) + if (!seen.insert(id).second) { + dupNode = true; + break; + } + } + if (dupNode) + continue; + + std::vector poly; + for (int id : path) + poly.push_back( + graph.findNodeById(id)->position); + double area = polySignedAreaXZ(poly); + if (area <= 1e-6) + continue; /* exterior face (CW) */ + + /* Filter: too many boundary edges (not a simple + * building lot) or too small to fit the minimum + * square (bounding box in both XZ extents, and + * the area). */ + if ((int)path.size() > filter.maxBoundaryEdges) + continue; + if (area < (double)filter.minLotSize * + (double)filter.minLotSize) + continue; + { + float bbMinX = FLT_MAX, bbMinZ = FLT_MAX; + float bbMaxX = -FLT_MAX, bbMaxZ = -FLT_MAX; + for (const Ogre::Vector3 &p : poly) { + bbMinX = std::min(bbMinX, p.x); + bbMaxX = std::max(bbMaxX, p.x); + bbMinZ = std::min(bbMinZ, p.z); + bbMaxZ = std::max(bbMaxZ, p.z); + } + if (bbMaxX - bbMinX < filter.minLotSize || + bbMaxZ - bbMinZ < filter.minLotSize) + continue; + } + + /* Reject areas with another road node strictly + * inside (a detached edge crosses the lot). */ + bool innerNode = false; + for (const RoadNode &n : graph.nodes) { + bool onBoundary = false; + for (int id : path) + if (n.id == id) { + onBoundary = true; + break; + } + if (onBoundary) + continue; + if (pointInPolyXZ(n.position, poly)) { + innerNode = true; + break; + } + } + if (innerNode) + continue; + + RoadLot lot; + lot.nodeIds = path; + lot.polygon = poly; + lot.area = area; + /* Area-weighted centroid (XZ); Y averaged. */ + double cx = 0.0, cz = 0.0, ys = 0.0; + for (size_t i = 0; i < poly.size(); ++i) { + const Ogre::Vector3 &p = poly[i]; + const Ogre::Vector3 &q = + poly[(i + 1) % poly.size()]; + double cr = (double)p.x * (double)q.z - + (double)q.x * (double)p.z; + cx += ((double)p.x + (double)q.x) * cr; + cz += ((double)p.z + (double)q.z) * cr; + ys += (double)p.y; + } + lot.centroid.x = (float)(cx / (6.0 * area)); + lot.centroid.z = (float)(cz / (6.0 * area)); + lot.centroid.y = + (float)(ys / (double)poly.size()); + lots.push_back(lot); + } + } + + /* Fallback: square lots grown off edge sides that border no + * accepted enclosed lot. The square spans the full edge and + * reaches one edge length outward, so edges shorter than the + * minimum lot size spawn none. */ + if (filter.edgeFallback) { + /* Directed edges whose "left" side (the side a CCW face + * interior lies on) is covered by an accepted lot. */ + std::set > coveredLeft; + for (const RoadLot &lot : lots) + for (size_t i = 0; i < lot.nodeIds.size(); ++i) + coveredLeft.insert(std::make_pair( + lot.nodeIds[i], + lot.nodeIds[(i + 1) % + lot.nodeIds.size()])); + + for (const RoadEdge &e : graph.edges) { + const RoadNode *na = graph.findNodeById(e.nodeA); + const RoadNode *nb = graph.findNodeById(e.nodeB); + if (!na || !nb) + continue; + Ogre::Vector3 d = nb->position - na->position; + d.y = 0.0f; + float len = d.length(); + if (len < filter.minLotSize || len < 1e-4f) + continue; + d /= len; + for (int side = 0; side < 2; ++side) { + bool posSide = (side == 0); + if (coveredLeft.count( + posSide ? + std::make_pair(e.nodeA, + e.nodeB) : + std::make_pair(e.nodeB, + e.nodeA))) + continue; + /* "Left" of A->B in the atan2(z, x) + * sense (the side a CCW face interior + * lies on). */ + Ogre::Vector3 n = + posSide ? Ogre::Vector3(-d.z, 0.0f, + d.x) : + Ogre::Vector3(d.z, 0.0f, -d.x); + std::vector sq; + sq.push_back(na->position); + sq.push_back(nb->position); + sq.push_back(nb->position + n * len); + sq.push_back(na->position + n * len); + if (polySignedAreaXZ(sq) < 0.0) + std::reverse(sq.begin(), sq.end()); + + /* The square must stay clear of the + * graph: no node strictly inside (the + * edge's own endpoints excluded), no + * edge endpoint strictly inside and no + * edge properly crossing a side. */ + bool dirty = false; + for (const RoadNode &gn : graph.nodes) { + if (gn.id == e.nodeA || + gn.id == e.nodeB) + continue; + if (pointInPolyXZ(gn.position, sq)) { + dirty = true; + break; + } + } + for (const RoadEdge &ge : graph.edges) { + if (dirty) + break; + const RoadNode *ga = + graph.findNodeById(ge.nodeA); + const RoadNode *gb = + graph.findNodeById(ge.nodeB); + if (!ga || !gb) + continue; + bool endInside = false; + if (ge.nodeA != e.nodeA && + ge.nodeA != e.nodeB && + pointInPolyXZ(ga->position, sq)) + endInside = true; + if (ge.nodeB != e.nodeA && + ge.nodeB != e.nodeB && + pointInPolyXZ(gb->position, sq)) + endInside = true; + bool crossing = false; + if (!endInside) + for (size_t si = 0; + si < sq.size() && !crossing; + ++si) + crossing = segsCrossXZ( + sq[si], + sq[(si + 1) % + sq.size()], + ga->position, + gb->position); + if (endInside || crossing) + dirty = true; + } + if (dirty) + continue; + + /* And clear of every already-accepted + * lot (faces and earlier squares). */ + for (const RoadLot &lot : lots) + if (polysOverlapXZ(sq, lot.polygon)) { + dirty = true; + break; + } + if (dirty) + continue; + + RoadLot lot; + lot.polygon = sq; + lot.edgeFallback = true; + lot.sourceEdgeA = e.nodeA; + lot.sourceEdgeB = e.nodeB; + lot.area = polySignedAreaXZ(sq); + Ogre::Vector3 c = Ogre::Vector3::ZERO; + for (const Ogre::Vector3 &p : sq) + c += p; + lot.centroid = c / (float)sq.size(); + lots.push_back(lot); + } + } + } + return lots; +} + +/* ---------------------------------------------------------------- + * Streets + * ---------------------------------------------------------------- */ + +static std::pair normEdgeKey(int a, int b) +{ + return std::make_pair(std::min(a, b), std::max(a, b)); +} + +int nameStreet(RoadGraph &graph, const std::string &name, + const std::vector > &edges) +{ + std::set > wanted; + for (const auto &e : edges) { + std::pair key = normEdgeKey(e.first, e.second); + if (graph.findEdgeIndex(key.first, key.second) < 0) + return -1; + wanted.insert(key); + } + if (wanted.empty()) + return -1; + + /* Drop the edges from any street already holding them, and erase + * streets that went empty (also an old street with this name — it + * is redefined below). */ + for (auto it = graph.streets.begin(); it != graph.streets.end();) { + RoadStreet &s = *it; + if (s.name == name) { + it = graph.streets.erase(it); + continue; + } + for (auto eit = s.edges.begin(); eit != s.edges.end();) { + if (wanted.count(normEdgeKey(eit->first, + eit->second))) + eit = s.edges.erase(eit); + else + ++eit; + } + if (s.edges.empty()) + it = graph.streets.erase(it); + else + ++it; + } + + RoadStreet s; + s.name = name; + s.edges.assign(wanted.begin(), wanted.end()); + graph.streets.push_back(s); + graph.bumpVersion(); + return (int)graph.streets.size() - 1; +} + +const RoadStreet *findStreetForEdge(const RoadGraph &graph, int nodeA, + int nodeB) +{ + std::pair key = normEdgeKey(nodeA, nodeB); + for (const RoadStreet &s : graph.streets) + for (const auto &e : s.edges) + if (normEdgeKey(e.first, e.second) == key) + return &s; + return nullptr; +} + +/** Distance from a point to an XZ segment, plus the segment param. */ +static float pointSegDistXZ(const Ogre::Vector3 &p, const Ogre::Vector3 &a, + const Ogre::Vector3 &b, float &outT) +{ + float dx = b.x - a.x, dz = b.z - a.z; + float l2 = dx * dx + dz * dz; + float t = l2 > 1e-12f ? + ((p.x - a.x) * dx + (p.z - a.z) * dz) / l2 : + 0.0f; + t = std::max(0.0f, std::min(1.0f, t)); + Ogre::Vector3 q = a + (b - a) * t; + outT = t; + return xzDist(p, q); +} + +std::vector +assignLotAddresses(const RoadGraph &graph, const std::vector &lots) +{ + std::vector out(lots.size()); + + /* Nearest street per lot (by centroid-to-edge distance). */ + std::map > > perStreet; + for (size_t li = 0; li < lots.size(); ++li) { + int bestStreet = -1; + float bestDist = FLT_MAX; + float bestAlong = 0.0f; + for (size_t si = 0; si < graph.streets.size(); ++si) { + const RoadStreet &s = graph.streets[si]; + float along = 0.0f; + for (const auto &e : s.edges) { + const RoadNode *na = + graph.findNodeById(e.first); + const RoadNode *nb = + graph.findNodeById(e.second); + if (!na || !nb) + continue; + float t; + float d = pointSegDistXZ(lots[li].centroid, + na->position, + nb->position, t); + float segLen = + xzDist(na->position, nb->position); + if (d < bestDist) { + bestDist = d; + bestStreet = (int)si; + bestAlong = along + t * segLen; + } + along += segLen; + } + } + if (bestStreet >= 0) + perStreet[bestStreet].push_back( + std::make_pair(bestAlong, li)); + } + + /* Number lots sequentially along their street. */ + for (auto &kv : perStreet) { + std::sort(kv.second.begin(), kv.second.end()); + const std::string &sn = graph.streets[(size_t)kv.first].name; + int num = 1; + for (const auto &pr : kv.second) + out[pr.second] = sn + " " + std::to_string(num++); + } + for (size_t li = 0; li < lots.size(); ++li) + if (out[li].empty()) + out[li] = "Lot " + std::to_string(li + 1); + return out; +} + +/* ---------------------------------------------------------------- + * Grid generator + * ---------------------------------------------------------------- */ + +uint32_t nextGroupId(const RoadGraph &graph) +{ + uint32_t maxId = 0; + for (const RoadNode &n : graph.nodes) + if (n.generatedGroupId > maxId) + maxId = n.generatedGroupId; + return maxId + 1; +} + +uint32_t generateGrid(RoadGraph &graph, const GridParams ¶ms, + const HeightSampler &sampleHeight, std::string *error) +{ + if (params.blocksX < 1 || params.blocksZ < 1 || + params.blockSizeX < 2.0f * ROAD_MIN_EDGE_LENGTH || + params.blockSizeZ < 2.0f * ROAD_MIN_EDGE_LENGTH) { + if (error) + *error = "grid dimensions too small"; + return 0; + } + + uint32_t groupId = params.groupId ? params.groupId : + nextGroupId(graph); + std::mt19937 rng(params.seed); + std::uniform_real_distribution jr(-params.jitter, + params.jitter); + + std::vector > ids( + (size_t)params.blocksX + 1, + std::vector((size_t)params.blocksZ + 1, -1)); + for (int i = 0; i <= params.blocksX; ++i) { + for (int j = 0; j <= params.blocksZ; ++j) { + float x = params.origin.x + i * params.blockSizeX; + float z = params.origin.z + j * params.blockSizeZ; + bool interior = i > 0 && i < params.blocksX && j > 0 && + j < params.blocksZ; + if (interior && params.jitter > 0.0f) { + x += jr(rng); + z += jr(rng); + } + float y = params.origin.y; + if (sampleHeight) + y = sampleHeight(x, z); + if (params.avoidWater && y < params.waterLevel) + continue; /* rejected: over water */ + Ogre::Vector3 pos(x, y, z); + int id = graph.addNode(pos, 0.0f); + graph.findNodeById(id)->generatedGroupId = groupId; + ids[(size_t)i][(size_t)j] = id; + } + } + + auto tryEdge = [&](int a, int b) { + if (a < 0 || b < 0) + return; + if (params.maxSlope > 0.0f) { + const RoadNode *na = graph.findNodeById(a); + const RoadNode *nb = graph.findNodeById(b); + float run = xzDist(na->position, nb->position); + float rise = std::fabs(nb->position.y - + na->position.y); + if (run < 1e-4f || rise / run > params.maxSlope) + return; /* too steep */ + } + graph.addEdge(a, b); + }; + for (int i = 0; i <= params.blocksX; ++i) + for (int j = 0; j <= params.blocksZ; ++j) { + if (i < params.blocksX) + tryEdge(ids[(size_t)i][(size_t)j], + ids[(size_t)i + 1][(size_t)j]); + if (j < params.blocksZ) + tryEdge(ids[(size_t)i][(size_t)j], + ids[(size_t)i][(size_t)j + 1]); + } + + if (params.runCrossingResolution) { + std::string cerr; + if (resolveAllCrossings(graph, &cerr) < 0 && error) + *error = cerr; + } + graph.bumpVersion(); + return groupId; +} + +int connectGroupToNode(RoadGraph &graph, uint32_t groupId, int nodeId, + float worldSize, std::string *error) +{ + const RoadNode *target = graph.findNodeById(nodeId); + if (!target) { + if (error) + *error = "target node does not exist"; + return -1; + } + int bestId = -1; + float bestDist = FLT_MAX; + for (const RoadNode &n : graph.nodes) { + if (n.generatedGroupId != groupId) + continue; + float d = xzDist(n.position, target->position); + if (d < bestDist) { + bestDist = d; + bestId = n.id; + } + } + if (bestId < 0) { + if (error) + *error = "no nodes in the generated group"; + return -1; + } + int ei = graph.connectNodes(bestId, nodeId, worldSize, error); + if (ei < 0) + return -1; + std::string cerr; + if (resolveAllCrossings(graph, &cerr) < 0) { + if (error) + *error = cerr; + return -1; + } + return ei; +} + +bool removeGroup(RoadGraph &graph, uint32_t groupId) +{ + std::vector doomed; + for (const RoadNode &n : graph.nodes) + if (n.generatedGroupId == groupId) + doomed.push_back(n.id); + for (int id : doomed) + graph.removeNode(id); + if (!doomed.empty()) + graph.bumpVersion(); + return !doomed.empty(); +} + +/* ---------------------------------------------------------------- + * Streets serialization + * ---------------------------------------------------------------- */ + +void streetsToJson(const RoadGraph &graph, nlohmann::json &out) +{ + nlohmann::json arr = nlohmann::json::array(); + for (const RoadStreet &s : graph.streets) { + nlohmann::json js; + js["name"] = s.name; + nlohmann::json je = nlohmann::json::array(); + for (const auto &e : s.edges) + je.push_back({ e.first, e.second }); + js["edges"] = je; + arr.push_back(js); + } + out["streets"] = arr; +} + +void streetsFromJson(RoadGraph &graph, const nlohmann::json &j, + bool dropMissing) +{ + graph.streets.clear(); + for (const auto &js : j.value("streets", nlohmann::json::array())) { + RoadStreet s; + s.name = js.value("name", std::string()); + if (s.name.empty()) + continue; + for (const auto &je : + js.value("edges", nlohmann::json::array())) { + if (!je.is_array() || je.size() != 2) + continue; + int a = je[0].get(); + int b = je[1].get(); + if (dropMissing && graph.findEdgeIndex(a, b) < 0) + continue; + s.edges.push_back(std::make_pair(std::min(a, b), + std::max(a, b))); + } + if (!s.edges.empty()) + graph.streets.push_back(s); + } +} + +} /* namespace RoadNetworkGen */ diff --git a/src/features/editScene/roadlib/RoadNetworkGen.hpp b/src/features/editScene/roadlib/RoadNetworkGen.hpp new file mode 100644 index 0000000..9db04e6 --- /dev/null +++ b/src/features/editScene/roadlib/RoadNetworkGen.hpp @@ -0,0 +1,254 @@ +#ifndef EDITSCENE_ROADNETWORKGEN_HPP +#define EDITSCENE_ROADNETWORKGEN_HPP +#pragma once + +#include "components/RoadGraph.hpp" + +#include +#include +#include +#include +#include + +/** + * 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 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 nodeIds; + /** Boundary positions (same order as nodeIds when non-empty). */ + std::vector 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 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 > &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 ". + * Returns one address string per input lot. + */ +std::vector +assignLotAddresses(const RoadGraph &graph, const std::vector &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 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 ¶ms, + 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 */ diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp new file mode 100644 index 0000000..c073791 --- /dev/null +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -0,0 +1,382 @@ +#include "TownGen.hpp" + +#include +#include +#include +#include + +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 &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 &poly, + const Ogre::Vector3 ¢roid, + std::vector &out) +{ + size_t n = poly.size(); + out.clear(); + if (n < 3) + return false; + + std::vector inset(n, 0.0f); + std::vector 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 ¶ms, + const RoadNetworkGen::HeightSampler &sampleHeight) +{ + TownResult result; + const DistrictParams &dp = districtParams(params.district); + std::vector lots = + RoadNetworkGen::findLots(graph); + + std::mt19937 rng(params.seed); + std::uniform_real_distribution unit(0.0f, 1.0f); + float density = + std::min(1.0f, dp.density * std::max(0.0f, params.densityScale)); + std::map streetNumbers; + + for (size_t li = 0; li < lots.size(); ++li) { + 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 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 */ diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp new file mode 100644 index 0000000..8e08562 --- /dev/null +++ b/src/features/editScene/roadlib/TownGen.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include + +#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; + /** " " or "Lot ". */ + std::string address; +}; + +/** Named curb point in front of a building's door (pickup/dropoff, + * pedestrian origin/destination). */ +struct TownPoi { + std::string name; /* "
pickup" */ + Ogre::Vector3 position = Ogre::Vector3::ZERO; + int building = -1; /* index into buildings */ +}; + +struct TownResult { + std::vector buildings; + std::vector 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 ¶ms, + const RoadNetworkGen::HeightSampler &sampleHeight); + +} /* namespace TownGen */ diff --git a/src/features/editScene/systems/AnimationTreeSystem.cpp b/src/features/editScene/systems/AnimationTreeSystem.cpp index 5447c01..c48c5eb 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.cpp +++ b/src/features/editScene/systems/AnimationTreeSystem.cpp @@ -38,15 +38,20 @@ AnimationTreeSystem::AnimationTreeSystem(flecs::world &world, : m_world(world) , m_sceneMgr(sceneMgr) { - m_world.observer("AnimationTreeCleanup") - .event(flecs::OnRemove) - .each([this](flecs::entity e, AnimationTreeComponent &) { - teardownEntity(e); - }); + m_cleanupObserver = + m_world.observer( + "AnimationTreeCleanup") + .event(flecs::OnRemove) + .each([this](flecs::entity e, + AnimationTreeComponent &) { + teardownEntity(e); + }); } AnimationTreeSystem::~AnimationTreeSystem() { + if (m_cleanupObserver.is_alive()) + m_cleanupObserver.destruct(); std::vector toRemove; for (auto &pair : m_states) toRemove.push_back(pair.first); diff --git a/src/features/editScene/systems/AnimationTreeSystem.hpp b/src/features/editScene/systems/AnimationTreeSystem.hpp index 7dbe254..ec6bd16 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.hpp +++ b/src/features/editScene/systems/AnimationTreeSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index 9c83781..8dbc636 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -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; diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index d50e2a4..b5c08e0 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -33,20 +33,30 @@ CharacterSlotSystem::CharacterSlotSystem(flecs::world &world, , m_sceneMgr(sceneMgr) { ms_singleton = this; - m_world.observer("CharacterSlotsBuild") - .event(flecs::OnAdd) - .each([this](flecs::entity e, CharacterIdentityComponent &) { - markEntityDirty(e.id()); - }); - m_world.observer("CharacterSlotsCleanup") - .event(flecs::OnRemove) - .each([this](flecs::entity e, CharacterIdentityComponent &) { - destroyCharacterParts(e); - }); + m_buildObserver = + m_world.observer( + "CharacterSlotsBuild") + .event(flecs::OnAdd) + .each([this](flecs::entity e, + CharacterIdentityComponent &) { + markEntityDirty(e.id()); + }); + m_cleanupObserver = + m_world.observer( + "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 toRemove; for (auto &pair : m_entities) toRemove.push_back(pair.first); diff --git a/src/features/editScene/systems/CharacterSlotSystem.hpp b/src/features/editScene/systems/CharacterSlotSystem.hpp index 58dfdb0..35a36c8 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.hpp +++ b/src/features/editScene/systems/CharacterSlotSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.cpp b/src/features/editScene/systems/CharacterSpawnerSystem.cpp index 4ecfb7e..3593275 100644 --- a/src/features/editScene/systems/CharacterSpawnerSystem.cpp +++ b/src/features/editScene/systems/CharacterSpawnerSystem.cpp @@ -21,15 +21,20 @@ CharacterSpawnerSystem::CharacterSpawnerSystem(flecs::world &world, , m_uiSystem(uiSystem) , m_query(world.query()) { - m_world.observer("CharacterSpawnerCleanup") - .event(flecs::OnRemove) - .each([this](flecs::entity e, CharacterSpawnerComponent &) { - despawn(e); - }); + m_cleanupObserver = + m_world.observer( + "CharacterSpawnerCleanup") + .event(flecs::OnRemove) + .each([this](flecs::entity e, + CharacterSpawnerComponent &) { + despawn(e); + }); } CharacterSpawnerSystem::~CharacterSpawnerSystem() { + if (m_cleanupObserver.is_alive()) + m_cleanupObserver.destruct(); std::vector ids; ids.reserve(m_spawnedEntities.size()); for (const auto &pair : m_spawnedEntities) diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.hpp b/src/features/editScene/systems/CharacterSpawnerSystem.hpp index cdbe284..b8f2f9e 100644 --- a/src/features/editScene/systems/CharacterSpawnerSystem.hpp +++ b/src/features/editScene/systems/CharacterSpawnerSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/CharacterSystem.cpp b/src/features/editScene/systems/CharacterSystem.cpp index 4adeda5..e92c141 100644 --- a/src/features/editScene/systems/CharacterSystem.cpp +++ b/src/features/editScene/systems/CharacterSystem.cpp @@ -52,15 +52,19 @@ CharacterSystem::CharacterSystem(flecs::world &world, : m_world(world) , m_sceneMgr(sceneMgr) { - m_world.observer("CharacterCleanup") - .event(flecs::OnRemove) - .each([this](flecs::entity e, CharacterComponent &) { - teardownEntity(e); - }); + m_cleanupObserver = m_world.observer( + "CharacterCleanup") + .event(flecs::OnRemove) + .each([this](flecs::entity e, + CharacterComponent &) { + teardownEntity(e); + }); } CharacterSystem::~CharacterSystem() { + if (m_cleanupObserver.is_alive()) + m_cleanupObserver.destruct(); std::vector toRemove; for (auto &pair : m_states) toRemove.push_back(pair.first); diff --git a/src/features/editScene/systems/CharacterSystem.hpp b/src/features/editScene/systems/CharacterSystem.hpp index bde358e..8bcf6f0 100644 --- a/src/features/editScene/systems/CharacterSystem.hpp +++ b/src/features/editScene/systems/CharacterSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 9915bcc..b04e962 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -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 diff --git a/src/features/editScene/systems/EditorWaterPlaneSystem.cpp b/src/features/editScene/systems/EditorWaterPlaneSystem.cpp index 16dd814..6f4ac36 100644 --- a/src/features/editScene/systems/EditorWaterPlaneSystem.cpp +++ b/src/features/editScene/systems/EditorWaterPlaneSystem.cpp @@ -2,6 +2,7 @@ #include "../components/WaterPlane.hpp" #include "../components/Transform.hpp" #include "../components/WaterPhysics.hpp" +#include #include #include #include @@ -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; diff --git a/src/features/editScene/systems/EditorWaterPlaneSystem.hpp b/src/features/editScene/systems/EditorWaterPlaneSystem.hpp index 037c3db..325b6ec 100644 --- a/src/features/editScene/systems/EditorWaterPlaneSystem.hpp +++ b/src/features/editScene/systems/EditorWaterPlaneSystem.hpp @@ -45,6 +45,10 @@ private: Ogre::SceneManager *m_sceneMgr; flecs::query 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 m_createdTargets; float m_underwaterTime = 0.0f; bool m_compositorAdded = false; Ogre::Viewport *m_compositorViewport = nullptr; diff --git a/src/features/editScene/systems/RoadRegionStore.cpp b/src/features/editScene/systems/RoadRegionStore.cpp index ba027d4..e579cfb 100644 --- a/src/features/editScene/systems/RoadRegionStore.cpp +++ b/src/features/editScene/systems/RoadRegionStore.cpp @@ -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; + } +} diff --git a/src/features/editScene/systems/RoadRegionStore.hpp b/src/features/editScene/systems/RoadRegionStore.hpp index 1dd5249..c730446 100644 --- a/src/features/editScene/systems/RoadRegionStore.hpp +++ b/src/features/editScene/systems/RoadRegionStore.hpp @@ -5,6 +5,8 @@ #include #include +#include + /* * 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 decals; std::vector 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: "/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; diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 510409a..0840263 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -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 #include +#include #include #include #include @@ -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()) { - allCreated = false; - break; - } + auto chunkCreated = [](flecs::entity e) { + if (!e.is_alive() || !e.has()) + return false; const auto &tb = e.get(); - 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(); + 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()) + e.get_mut().entity = + tb.ogreEntity; + if (e.has()) + e.get_mut() + .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 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 > markingChunks; + std::vector > decalChunks; + if (!chunks.empty()) { + std::shared_ptr mcur = + std::make_shared(); + std::shared_ptr dcur = + std::make_shared(); + auto flushOverlay = [](std::shared_ptr &c, + std::vector > &dst) { + if (!c->getIndices().empty()) + dst.push_back(c); + c = std::make_shared(); + }; + auto appendOverlay = [&](std::shared_ptr &c, + std::vector > &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= 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 seedIds; + for (const RoadWedge &w : pg.wedges) + seedIds.insert(w.nodeId); + for (const RoadStraightSegment &s : pg.segments) + seedIds.insert(s.nodeId); + std::set 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(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 > &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(xf); + + RenderableComponent rend; + rend.meshName = meshName; + e.set(rend); + + pg.overlayEntities.push_back(e); + + TriangleBufferComponent tb; + tb.buffer = ochunks[i]; + tb.proceduralContent = true; + tb.meshName = meshName; + tb.dirty = true; + e.set(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()) { + auto &tb = e.get_mut(); + 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()) { + auto &xf = e.get_mut(); + 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 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 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 ¢er, 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()) + return; + + const RoadGraph &rg = m_world.entity(m_terrainEntityId).get().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 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 ¶ms, std::string *error) +{ + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) { + if (error) + *error = "no terrain entity"; + return 0; + } + RoadGraph &graph = + terrain.get_mut().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 ¶ms) +{ + 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()) + temp.config = terrain.get().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()) { + if (error) + *error = "no terrain entity"; + return -1; + } + RoadGraph &graph = + terrain.get_mut().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()) + return false; + RoadGraph &graph = + terrain.get_mut().roadGraph; + if (!RoadNetworkGen::removeGroup(graph, groupId)) + return false; + m_impossibleCornersVersion = ~0ull; + rebuildVisualAids(); + return true; +} + const Procedural::TriangleBuffer & RoadSystem::getRoadTemplate(const RoadConfig &cfg) { diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index 5546e5d..d682e27 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -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 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 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 ¶ms, + 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 ¶ms); + 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 diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 2ba080d..6e31c86 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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(tc); diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp index af2ada6..2e3d7bf 100644 --- a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp @@ -34,16 +34,20 @@ TerrainPrefabSpawnerSystem::TerrainPrefabSpawnerSystem( { s_instance = this; - m_world.observer( - "TerrainPrefabSpawnerCleanup") - .event(flecs::OnRemove) - .each([this](flecs::entity e, TerrainPrefabSpawnerComponent &) { - despawn(e); - }); + m_cleanupObserver = + m_world.observer( + "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); diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp index ca75d24..32552c7 100644 --- a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 660aef0..4294748 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -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(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(); diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index 8f82790..140a7b0 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -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 m_roadSystem; + std::unique_ptr m_townGenSystem; void activate(struct TerrainComponent &tc, struct TransformComponent &xform, flecs::entity_t terrainEntityId); diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp new file mode 100644 index 0000000..20943eb --- /dev/null +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -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 +#include + +/* Building cell metrics: 2 m cells, 3 m floor height. */ +static const float TOWN_CELL_SIZE = 2.0f; +static const float TOWN_CELL_HEIGHT = 3.0f; +/* 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().each( + [&count](flecs::entity, EntityNameComponent &name, + TownComponent &) { + if (name.name.rfind("TownGen ", 0) == 0) + ++count; + }); + return count; +} + +int TownGenSystem::clear() +{ + std::vector towns; + m_world.query().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 ¶ms, + 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()) { + if (error) + *error = "no active terrain"; + return 0; + } + const RoadGraph &graph = + terrain.get().roadGraph; + + TownGen::TownParams p = params; + float waterY = -FLT_MAX; + m_world.each([&waterY](WaterPhysics &wp) { + waterY = wp.waterSurfaceY; + }); + p.waterLevel = waterY; + + RoadNetworkGen::HeightSampler sampler; + if (ts) + sampler = [ts](float x, float z) { + return ts->getHeightAt(Ogre::Vector3(x, 0.0f, z)); + }; + + 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(containerName)); + container.add(); + 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(containerXform); + + flecs::entity texture = + createSettlementResources(container, dp, p.seed); + + flecs::entity material = m_world.entity(); + material.child_of(container); + material.set( + EntityNameComponent(containerName + " material")); + material.add(); + ProceduralMaterialComponent mat; + mat.materialName = containerName + " material"; + mat.diffuseTextureId = + texture.get().textureId; + mat.diffuseTextureEntity = texture; + material.set(mat); + + TownComponent town; + town.townName = containerName; + town.proceduralMaterialEntity = material; + town.proceduralMaterialEntityId = std::to_string(material.id()); + container.set(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( + "towngen_tex_" + std::to_string(seed))); + texture.add(); + + 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(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(b.address)); + building.add(); + + 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(xform); + + building.set(grid); + + /* Flat roof over the whole footprint — buildRoofs() only + * processes RoofComponent children of the grid entity. posY at + * one cell above the top floor puts the slab on top of the + * walls; its trim covers the top-floor ceiling seam. */ + flecs::entity roof = m_world.entity(); + roof.child_of(building); + roof.set(EntityNameComponent("roof")); + roof.add(); + RoofComponent rc; + rc.type = RoofComponent::Flat; + rc.posX = 0; + rc.posY = b.floors; + rc.posZ = 0; + rc.sizeX = grid.width; + rc.sizeZ = grid.depth; + roof.set(rc); + + createFoundation(building, b, grid, material, texture); + if (b.stairs) + createStairs(building, b, grid, material, texture); + + /* Curb pickup POI (name-convention marker entity), parented to + * the building with a local transform. */ + flecs::entity poiEnt = m_world.entity(); + poiEnt.child_of(building); + poiEnt.set(EntityNameComponent(poi.name)); + poiEnt.add(); + TransformComponent poiXform; + poiXform.node = xform.node->createChildSceneNode(); + poiXform.position = + xform.rotation.Inverse() * (poi.position - renderPos); + poiXform.rotation = Ogre::Quaternion::IDENTITY; + poiXform.scale = Ogre::Vector3::UNIT_SCALE; + poiXform.applyToNode(); + poiEnt.set(poiXform); +} + +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("foundation")); + foundation.add(); + + /* 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().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(xform); + + TriangleBufferComponent tb; + tb.textureEntity = texture; + tb.textureRectName = "foundation"; + tb.materialEntity = material; + tb.meshName = b.address + "_foundation"; + foundation.set(tb); + + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + foundation.set(rb); + + flecs::entity box = m_world.entity(); + box.child_of(foundation); + box.set(EntityNameComponent("block")); + TransformComponent boxXform; + boxXform.node = xform.node->createChildSceneNode(); + boxXform.position = + shellCenter + Ogre::Vector3(0.0f, + -foundationH * 0.5f + 0.05f, 0.0f); + boxXform.rotation = Ogre::Quaternion::IDENTITY; + boxXform.scale = Ogre::Vector3::UNIT_SCALE; + boxXform.applyToNode(); + box.set(boxXform); + + PrimitiveComponent prim; + prim.type = PrimitiveComponent::Type::Box; + prim.boxSizeX = shellW + 0.15f; + prim.boxSizeY = foundationH; + prim.boxSizeZ = shellD + 0.15f; + box.set(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(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("stairs")); + stairs.add(); + + TransformComponent xform; + Ogre::SceneNode *parentNode = + building.get().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(xform); + + TriangleBufferComponent tb; + tb.textureEntity = texture; + tb.textureRectName = "foundation"; + tb.materialEntity = material; + tb.meshName = b.address + "_stairs"; + stairs.set(tb); + + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + stairs.set(rb); + + /* Solid staircase descending from the door (local +Z, front + * face at grid.depth * cellSize) to the ground at -stairsDrop; + * the ground itself is the last step. */ + 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("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(stepXform); + + PrimitiveComponent prim; + prim.type = PrimitiveComponent::Type::Box; + prim.boxSizeX = STAIR_WIDTH; + prim.boxSizeY = height; + prim.boxSizeZ = STAIR_TREAD; + step.set(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(collider); + } +} diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp new file mode 100644 index 0000000..6e7f24a --- /dev/null +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#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 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 ¶ms, 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; +}; diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index c3592c9..f92c567 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -21,8 +21,10 @@ * Runs the built-in regression configurations (flat, raised/ * lowered corner node, raised endpoints) plus junction probes * that combine all wedges of 3-way/4-way nodes into one buffer - * to catch adjacent-wedge overlap. Exit code 0 when no piece - * self-intersects, 1 otherwise. + * to catch adjacent-wedge overlap, plus the phase 2.5 probes + * (marking strip placement/gating, stop-line clear zones, + * decals, road-atlas UV bands and V tiling). Exit code 0 when + * no piece self-intersects, 1 otherwise. * road_geometry_overlap_test Ax Ay Az Bx By Bz Cx Cy Cz * Analyse a single custom configuration (same checks). */ @@ -32,6 +34,8 @@ #include "components/RoadGraph.hpp" #include "roadlib/RoadGeometryLib.hpp" +#include "roadlib/RoadNetworkGen.hpp" +#include "roadlib/TownGen.hpp" #include #include @@ -1080,6 +1084,133 @@ static bool runRegionProbe(const char *path, float laneWidth = 0.0f, return ok; } +/* Markings extent scan: build the marking overlays of every wedge and + * straight segment and measure how far the paint lands from its node. + * Exploded curb offsets at nasty corners (impossible corners, pin-zone + * failures) show up here as extents far beyond the wedge footprint — + * in game they render as "markings to the sky" spikes. */ +static bool runRegionMarkingsProbe(const char *path, float laneWidth = 0.0f) +{ + RoadGraph graph; + double ox = 0.0, oy = 0.0, oz = 0.0; + if (!loadRegionGraph(path, graph, ox, oy, oz, nullptr, laneWidth, + 0.0f, 0.0f)) + return false; + if (graph.nodes.empty()) + return true; + graph.config.markingsEnabled = true; + + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + + bool ok = true; + long badWedges = 0, badSegs = 0, impossible = 0; + std::vector > extents; /* overshoot */ + std::vector > spans; /* (extent, span) */ + float maxDy = 0.0f; + auto scanBuf = [&](const Procedural::TriangleBuffer &buf, + const Vector3 &origin, float limit, float &maxD) { + for (const auto &v : buf.getVertices()) { + const Vector3 &p = v.mPosition; + if (std::isnan(p.x) || std::isnan(p.y) || + std::isnan(p.z) || std::isinf(p.x) || + std::isinf(p.y) || std::isinf(p.z)) { + maxD = FLT_MAX; + return true; + } + float dx = p.x - origin.x, dy = p.y - origin.y, + dz = p.z - origin.z; + maxD = std::max(maxD, + std::sqrt(dx * dx + dy * dy + + dz * dz)); + maxDy = std::max(maxDy, std::fabs(dy)); + } + return maxD > limit; + }; + for (const auto &w : wedges) { + if (w.degenerate) + continue; + const RoadNode *node = graph.findNodeById(w.nodeId); + if (!node) + continue; + float ps = 0.0f, pe = 0.0f; + bool imp = false; + RoadGeometryLib::filletPinZone(w, graph, ps, pe, nullptr, + &imp); + if (imp) + ++impossible; + float halfW = graph.config.laneWidth * + std::max(1, graph.config.lanesPerDirection); + float span = w.first.halfLength + w.second.halfLength; + float limit = span + 4.0f * halfW + 5.0f; + RoadGeometryLib::RoadMarkingBuffers mb; + RoadGeometryLib::buildWedgeMarkings(w, graph, mb); + float maxD = 0.0f; + bool bad = scanBuf(mb.centerline, node->position, limit, + maxD) || + scanBuf(mb.laneLines, node->position, limit, + maxD) || + scanBuf(mb.edgeLines, node->position, limit, + maxD) || + scanBuf(mb.stopLines, node->position, limit, + maxD); + extents.push_back(std::make_pair(maxD - span, + (uint64_t)w.nodeId)); + spans.push_back(std::make_pair(maxD, span)); + if (bad) { + ++badWedges; + printf(" wedge node %llu: paint extent %.1f m " + "(span %.1f, limit %.1f)%s\n", + (unsigned long long)w.nodeId, maxD, span, + limit, imp ? " [IMPOSSIBLE CORNER]" : ""); + ok = false; + } + } + for (const auto &s : segs) { + const RoadNode *node = graph.findNodeById(s.nodeId); + if (!node) + continue; + float halfW = graph.config.laneWidth * + std::max(1, graph.config.lanesPerDirection); + float limit = s.halfEdge.halfLength + 4.0f * halfW + 5.0f; + RoadGeometryLib::RoadMarkingBuffers mb; + RoadGeometryLib::buildSegmentMarkings(s, graph, mb); + float maxD = 0.0f; + bool bad = scanBuf(mb.centerline, node->position, limit, + maxD) || + scanBuf(mb.laneLines, node->position, limit, + maxD) || + scanBuf(mb.edgeLines, node->position, limit, + maxD) || + scanBuf(mb.stopLines, node->position, limit, + maxD); + if (bad) { + ++badSegs; + printf(" segment node %llu: paint extent %.1f m " + "(limit %.1f)\n", (unsigned long long)s.nodeId, + maxD, limit); + ok = false; + } + } + printf(" markings scan: %ld bad wedges, %ld bad segments, " + "%ld impossible corners, max |dy| from node %.2f m\n", + badWedges, badSegs, impossible, maxDy); + std::sort(extents.begin(), extents.end(), + [](const std::pair &a, + const std::pair &b) { + return a.first > b.first; + }); + for (size_t i = 0; i < extents.size() && i < 8; ++i) + printf(" extent node %llu: %.1f m (span %.1f, " + "overshoot %+.1f)\n", + (unsigned long long)extents[i].second, + spans[i].first, spans[i].second, extents[i].first); + if (!ok) + printf(" *** region markings probe FAILED ***\n"); + return ok; +} + /* Single-node probe: rebuild every wedge of one junction node into ONE * combined buffer (the way a page assembles them) and run the full * coplanar/crossing analysis — catches adjacent-wedge z-fighting at the @@ -2782,6 +2913,889 @@ static bool runPinZonePrecision() return ok; } +/* Pin-zone memoization regression (demo-taxi exterior, nodes 3/16): + * filletMiterPinZoneRaw caches the scan per corner; the cached entry + * round-tripped only the pin point's X/Z, leaving Y uninitialized on + * cache hits. The pinning curb offset (K - center with + * K = node position + pinPoint) could then point straight down with a + * garbage length, and marking rows landing exactly on a pin-zone + * boundary spiked to y ~ -1.5e6 at runtime (the geometry builder + * warmed the cache before the marking builder ran). Asserts: two + * consecutive filletPinZone calls (miss then hit) return identical + * zones and pin points, even when the hit call's output pinPoint is + * pre-filled with a sentinel. Corner geometry replicates demo node 3 + * (60 degrees, 17.8/15.8 m half-arms, 2+2 lanes of 5 m). */ +static bool runPinZoneCacheProbe() +{ + printf("pin-zone cache round-trip probe (tight corner, 2+2 " + "lanes)\n"); + + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = 2; + graph.config.roadThickness = 0.3f; + graph.config.cornerFilletLength = 12.0f; + graph.config.sidewalkEnabled = false; + graph.config.apronEnabled = false; + + Ogre::Vector3 d1(1.0f, 0.0f, 0.0f); + Ogre::Vector3 d2(0.5f, 0.0f, 0.8660254f); /* 60 degrees */ + int idA = graph.addNode(d1 * 35.6f, 0.0f); + int idB = graph.addNode(Vector3(0, 0, 0), 0.0f); + int idC = graph.addNode(d2 * 31.6f, 0.0f); + graph.addEdge(idB, idA); + graph.addEdge(idC, idB); + + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + + for (const auto &w : wedges) { + if (w.nodeId != idB || w.degenerate) + continue; + float ps1 = 0.0f, pe1 = 0.0f, ps2 = 0.0f, pe2 = 0.0f; + Ogre::Vector3 p1(0.0f, 0.0f, 0.0f); + if (!RoadGeometryLib::filletPinZone(w, graph, ps1, pe1, &p1, + nullptr)) + continue; /* not a pinning wedge */ + Ogre::Vector3 p2(-999.0f, -12345.0f, -999.0f); + bool has2 = RoadGeometryLib::filletPinZone(w, graph, ps2, + pe2, &p2, nullptr); + if (!has2 || ps1 != ps2 || pe1 != pe2 || p1.x != p2.x || + p1.y != p2.y || p1.z != p2.z) { + printf(" *** cache hit mismatch: [%.3f, %.3f] " + "(%.3f, %.3f, %.3f) vs [%.3f, %.3f] " + "(%.3f, %.3f, %.3f) ***\n", + ps1, pe1, p1.x, p1.y, p1.z, ps2, pe2, p2.x, + p2.y, p2.z); + return false; + } + printf(" zone [%.2f, %.2f] pin (%.3f, %.3f, %.3f) " + "round-trips\n", + ps1, pe1, p1.x, p1.y, p1.z); + return true; + } + printf(" *** no pinning wedge found ***\n"); + return false; +} + +/* ---------------------------------------------------------------- + * Phase 3 probes: road-network generator graph services + * (RoadNetworkGen): crossing resolution, lots, streets, grid + * generation. All pure graph math, no geometry building. + * ---------------------------------------------------------------- */ + +/* Crossing resolution: X crossing -> 4-way junction, end snap reuses + * the existing node, too-short edges collapse, overpasses (> 10 m Y at + * the closest points) are NOT crossings, the graph stays sane after + * every case. */ +static bool runCrossingResolutionProbe() +{ + printf("crossing resolution probe (junction/snap/collapse/" + "overpass)\n"); + using namespace RoadNetworkGen; + bool ok = true; + + /* Plain X crossing: both edges split, one shared junction node. */ + { + RoadGraph graph; + graph.config.laneWidth = 3.0f; + int a1 = graph.addNode(Vector3(0, 0, 0), 0.0f); + int a2 = graph.addNode(Vector3(40, 0, 0), 0.0f); + int b1 = graph.addNode(Vector3(20, 0, -20), 0.0f); + int b2 = graph.addNode(Vector3(20, 0, 20), 0.0f); + graph.addEdge(a1, a2); + graph.addEdge(b1, b2); + + std::vector cs = findCrossings(graph); + if (cs.size() != 1) { + printf(" *** X: %zu crossings, want 1 ***\n", + cs.size()); + ok = false; + } else { + std::string err; + int n = resolveAllCrossings(graph, &err); + const RoadNode *j = graph.findNodeById(cs[0].nodeA1); + (void)j; + int centerDegree = 0; + for (const RoadNode &node : graph.nodes) { + float d = std::sqrt( + node.position.x * node.position.x + + node.position.z * node.position.z); + if (std::fabs(node.position.x - 20.0f) < 1.0f && + std::fabs(node.position.z) < 1.0f) + centerDegree = + (int)graph.findEdgeIndicesForNode( + node.id) + .size(); + (void)d; + } + std::string serr; + if (n != 1 || graph.nodes.size() != 5 || + graph.edges.size() != 4 || centerDegree != 4 || + !checkSane(graph, &serr) || + !findCrossings(graph).empty()) { + printf(" *** X: n=%d nodes=%zu edges=%zu " + "deg=%d sane=%s (%s) ***\n", + n, graph.nodes.size(), + graph.edges.size(), centerDegree, + serr.empty() ? "yes" : "NO", + serr.c_str()); + ok = false; + } + } + } + + /* End snap: the crossing lies within crossingSnapDistance of an + * edge end — the existing end node becomes the junction (no split + * of that edge). */ + { + RoadGraph graph; + graph.config.laneWidth = 3.0f; + graph.config.crossingSnapDistance = 5.0f; + int a1 = graph.addNode(Vector3(0, 0, 0), 0.0f); + int a2 = graph.addNode(Vector3(40, 0, 0), 0.0f); + int b1 = graph.addNode(Vector3(38, 0, -20), 0.0f); + int b2 = graph.addNode(Vector3(38, 0, 20), 0.0f); + graph.addEdge(a1, a2); + graph.addEdge(b1, b2); + + std::string err; + int n = resolveAllCrossings(graph, &err); + /* Edge A kept whole (end node reused, moved onto the + * crossing), edge B split: 4 nodes, 3 edges, degree-3 + * junction. */ + std::string serr; + int maxDeg = 0; + for (const RoadNode &node : graph.nodes) + maxDeg = std::max( + maxDeg, + (int)graph.findEdgeIndicesForNode(node.id) + .size()); + if (n != 1 || graph.nodes.size() != 4 || + graph.edges.size() != 3 || maxDeg != 3 || + !checkSane(graph, &serr)) { + printf(" *** snap: n=%d nodes=%zu edges=%zu " + "maxDeg=%d sane=%s ***\n", + n, graph.nodes.size(), graph.edges.size(), + maxDeg, serr.empty() ? "yes" : "NO"); + ok = false; + } + } + + /* Collapse: an edge too short to split collapses into the end + * nearer to the crossing. */ + { + RoadGraph graph; + graph.config.laneWidth = 3.0f; + graph.config.crossingSnapDistance = 0.2f; + int a1 = graph.addNode(Vector3(0, 0, 0), 0.0f); + int a2 = graph.addNode(Vector3(1.5f, 0, 0), 0.0f); + int b1 = graph.addNode(Vector3(0.75f, 0, -10), 0.0f); + int b2 = graph.addNode(Vector3(0.75f, 0, 10), 0.0f); + graph.addEdge(a1, a2); + graph.addEdge(b1, b2); + + std::string err; + int n = resolveAllCrossings(graph, &err); + std::string serr; + if (n != 1 || graph.nodes.size() != 3 || + graph.edges.size() != 2 || !checkSane(graph, &serr) || + !findCrossings(graph).empty()) { + printf(" *** collapse: n=%d nodes=%zu edges=%zu " + "sane=%s ***\n", + n, graph.nodes.size(), graph.edges.size(), + serr.empty() ? "yes" : "NO"); + ok = false; + } + } + + /* Overpass: same XZ crossing with 15 m of Y separation is not a + * crossing. */ + { + RoadGraph graph; + graph.config.laneWidth = 3.0f; + int a1 = graph.addNode(Vector3(0, 0, 0), 0.0f); + int a2 = graph.addNode(Vector3(40, 0, 0), 0.0f); + int b1 = graph.addNode(Vector3(20, 15, -20), 0.0f); + int b2 = graph.addNode(Vector3(20, 15, 20), 0.0f); + graph.addEdge(a1, a2); + graph.addEdge(b1, b2); + if (!findCrossings(graph).empty()) { + printf(" *** overpass flagged as crossing ***\n"); + ok = false; + } + } + + printf(" %s\n", ok ? "all cases passed" : "FAILURES above"); + return ok; +} + +/* Lot detection: a 2-block grid yields exactly two 50x50 enclosed lots; + * a spur poking into one block rejects it; fallback squares grow off + * free edge sides; minimum-size and boundary-edge-count filters. */ +static bool runLotProbe() +{ + printf("lot detection probe (2-block grid, spur, fallback " + "squares, filters)\n"); + using namespace RoadNetworkGen; + bool ok = true; + RoadLotFilter noFb; + noFb.edgeFallback = false; + + auto buildGrid = [&](RoadGraph &graph, bool spur, float cell) { + int ids[3][2]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) + ids[i][j] = graph.addNode( + Vector3(i * cell, 0, j * cell), 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], + ids[i + 1][j]); + if (j < 1) + graph.addEdge(ids[i][j], + ids[i][j + 1]); + } + if (spur) { + int s = graph.addNode( + Vector3(cell * 0.5f, 0, cell * 0.5f), 0.0f); + graph.addEdge(ids[1][0], s); + } + }; + + RoadGraph graph; + buildGrid(graph, false, 50.0f); + std::vector lots = findLots(graph, noFb); + if (lots.size() != 2) { + printf(" *** grid: %zu lots, want 2 ***\n", lots.size()); + ok = false; + } else { + for (const RoadLot &lot : lots) + if (std::fabs(lot.area - 2500.0) > 1.0) { + printf(" *** lot area %.1f, want " + "2500 ***\n", + lot.area); + ok = false; + } + } + + /* Fallback squares (default filter): one 50x50 square off every + * free edge side — the middle vertical edge is covered on both + * sides, the other six each grow one outward square. */ + { + std::vector lotsFb = findLots(graph); + int fb = 0; + for (const RoadLot &l : lotsFb) + if (l.edgeFallback) + ++fb; + if (lotsFb.size() != 8 || fb != 6) { + printf(" *** grid fallback: %zu lots (%d " + "fallback), want 8 (6) ***\n", + lotsFb.size(), fb); + ok = false; + } + } + + RoadGraph graph2; + buildGrid(graph2, true, 50.0f); + std::vector lots2 = findLots(graph2, noFb); + if (lots2.size() != 1) { + printf(" *** spur: %zu lots, want 1 ***\n", lots2.size()); + ok = false; + } else if (std::fabs(lots2[0].centroid.x - 75.0f) > 1.0f) { + printf(" *** spur: wrong lot survived (cx=%.1f, want " + "75) ***\n", + lots2[0].centroid.x); + ok = false; + } + /* With fallback: the pierced block spawns no inward squares + * (the spur node sits strictly inside them) and the spur edge + * (~35 m) is too short — only the six outward squares survive. */ + { + std::vector lotsFb2 = findLots(graph2); + if (lotsFb2.size() != 7) { + printf(" *** spur fallback: %zu lots, want 7 " + "***\n", + lotsFb2.size()); + ok = false; + } + } + + /* Minimum size: a 30 m grid produces no lots at all (faces + * cannot fit 45x45, and 30 m edges spawn no fallback squares). */ + { + RoadGraph graphSmall; + buildGrid(graphSmall, false, 30.0f); + if (!findLots(graphSmall).empty()) { + printf(" *** small grid: %zu lots, want 0 ***\n", + findLots(graphSmall).size()); + ok = false; + } + } + + /* Boundary-edge cap: a 10-sided ring (edge ~49 m) is a single + * 10-edge face — rejected by maxBoundaryEdges. */ + { + RoadGraph ring; + int prev = -1, first = -1; + for (int i = 0; i < 10; ++i) { + float ang = (float)i * 0.628319f; + int id = ring.addNode( + Vector3(80.0f * std::cos(ang), 0, + 80.0f * std::sin(ang)), + 0.0f); + if (prev >= 0) + ring.addEdge(prev, id); + else + first = id; + prev = id; + } + ring.addEdge(prev, first); + if (!findLots(ring, noFb).empty()) { + printf(" *** ring10: %zu lots, want 0 ***\n", + findLots(ring, noFb).size()); + ok = false; + } + } + + /* An isolated 50 m edge spawns a fallback square on each side. */ + { + RoadGraph lone; + int a = lone.addNode(Vector3(0, 0, 0), 0.0f); + int b = lone.addNode(Vector3(50, 0, 0), 0.0f); + lone.addEdge(a, b); + std::vector loneLots = findLots(lone); + if (loneLots.size() != 2 || !loneLots[0].edgeFallback || + !loneLots[1].edgeFallback) { + printf(" *** lone edge: %zu lots, want 2 " + "fallback ***\n", + loneLots.size()); + ok = false; + } + } + + printf(" %s\n", ok ? "passed" : "FAILURES above"); + return ok; +} + +/* Streets: naming an edge sequence, reassignment, JSON round trip and + * unique sequential addresses (unnamed fallback). */ +static bool runStreetProbe() +{ + printf("street naming/addressing probe\n"); + using namespace RoadNetworkGen; + bool ok = true; + + RoadGraph graph; + int ids[3][2]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) + ids[i][j] = graph.addNode( + Vector3(i * 50.0f, 0, j * 50.0f), 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], ids[i + 1][j]); + if (j < 1) + graph.addEdge(ids[i][j], ids[i][j + 1]); + } + + std::vector > mainSt = { + std::make_pair(ids[0][1], ids[1][1]), + std::make_pair(ids[1][1], ids[2][1]) + }; + if (nameStreet(graph, "Main St", mainSt) < 0) { + printf(" *** nameStreet failed ***\n"); + ok = false; + } + if (!findStreetForEdge(graph, ids[0][1], ids[1][1]) || + findStreetForEdge(graph, ids[0][0], ids[1][0])) { + printf(" *** findStreetForEdge wrong ***\n"); + ok = false; + } + + /* Reassigning an edge to another street drops it from the + * first. */ + nameStreet(graph, "Side St", + { std::make_pair(ids[0][1], ids[1][1]) }); + const RoadStreet *ms = findStreetForEdge(graph, ids[1][1], + ids[2][1]); + const RoadStreet *ss = findStreetForEdge(graph, ids[0][1], + ids[1][1]); + if (!ms || ms->name != "Main St" || !ss || ss->name != "Side St") { + printf(" *** reassignment wrong ***\n"); + ok = false; + } + + /* JSON round trip into an identical graph. */ + RoadGraph graph2; + int ids2[3][2]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) + ids2[i][j] = graph2.addNode( + Vector3(i * 50.0f, 0, j * 50.0f), 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 2; ++j) { + if (i < 2) + graph2.addEdge(ids2[i][j], ids2[i + 1][j]); + if (j < 1) + graph2.addEdge(ids2[i][j], ids2[i][j + 1]); + } + nlohmann::json j; + streetsToJson(graph, j); + streetsFromJson(graph2, j); + if (graph2.streets.size() != graph.streets.size()) { + printf(" *** round trip: %zu streets, want %zu ***\n", + graph2.streets.size(), graph.streets.size()); + ok = false; + } + + /* Addresses: unique, sequential along the street, unnamed + * fallback. (Enclosed faces only — fallback squares would add + * lots with no street.) */ + RoadLotFilter noFb; + noFb.edgeFallback = false; + std::vector lots = findLots(graph, noFb); + std::vector addr = assignLotAddresses(graph, lots); + if (addr.size() != 2 || addr[0] == addr[1]) { + printf(" *** addresses not unique ***\n"); + ok = false; + } + RoadGraph graph3; + int i0 = graph3.addNode(Vector3(0, 0, 0), 0.0f); + int i1 = graph3.addNode(Vector3(50, 0, 0), 0.0f); + int i2 = graph3.addNode(Vector3(50, 0, 50), 0.0f); + int i3 = graph3.addNode(Vector3(0, 0, 50), 0.0f); + graph3.addEdge(i0, i1); + graph3.addEdge(i1, i2); + graph3.addEdge(i2, i3); + graph3.addEdge(i3, i0); + std::vector lots3 = findLots(graph3, noFb); + std::vector addr3 = assignLotAddresses(graph3, lots3); + if (addr3.size() != 1 || addr3[0] != "Lot 1") { + printf(" *** unnamed fallback wrong (%s) ***\n", + addr3.empty() ? "" : addr3[0].c_str()); + ok = false; + } + + printf(" %s\n", ok ? "passed" : "FAILURES above"); + return ok; +} + +/* Town generation (demo-taxi phase 4): placement on lots, district + * presets, foundation heights, stairs, frontage-street addresses, + * determinism, water rejection. */ +static bool runTownGenProbe() +{ + printf("town generation probe\n"); + bool ok = true; + + /* 2x2 blocks of 100 m: 4 enclosed lots + 8 edge-fallback + * squares (one per perimeter edge) = 12 lots. */ + auto buildTown = [&](RoadGraph &graph) { + int ids[3][3]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + ids[i][j] = graph.addNode( + Vector3(i * 100.0f, 0, j * 100.0f), + 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], + ids[i + 1][j]); + if (j < 2) + graph.addEdge(ids[i][j], + ids[i][j + 1]); + } + }; + + RoadNetworkGen::HeightSampler flat = [](float, float) { + return 0.0f; + }; + + /* CityCenter density 1.0: every lot gets a building. */ + TownGen::TownParams cc; + cc.district = TownGen::District::CityCenter; + cc.seed = 7; + { + RoadGraph graph; + buildTown(graph); + TownGen::TownResult res = + TownGen::generateTown(graph, cc, flat); + if (res.buildings.size() != 12 || res.pois.size() != 12 || + res.skippedTooSmall != 0 || res.skippedWater != 0) { + printf(" *** city center: %zu buildings %zu pois " + "%d tooSmall %d water, want 12/12/0/0 ***\n", + res.buildings.size(), res.pois.size(), + res.skippedTooSmall, res.skippedWater); + ok = false; + } + for (const TownGen::TownBuilding &b : res.buildings) { + if (b.floors < 4 || b.floors > 8) { + printf(" *** floors %d out of city-center " + "range [4,8] ***\n", b.floors); + ok = false; + } + if (b.floorY < b.minGroundY - 0.001f) { + printf(" *** floorY %.2f below minGroundY " + "%.2f ***\n", b.floorY, b.minGroundY); + ok = false; + } + if (b.address.rfind("Lot ", 0) != 0) { + printf(" *** unnamed-street address '%s', " + "want 'Lot N' ***\n", b.address.c_str()); + ok = false; + } + if (b.stairs) { + printf(" *** stairs on flat ground ***\n"); + ok = false; + } + } + + /* Determinism: identical params -> identical output. */ + TownGen::TownResult res2 = + TownGen::generateTown(graph, cc, flat); + if (res2.buildings.size() != res.buildings.size()) { + printf(" *** determinism: count mismatch ***\n"); + ok = false; + } else { + for (size_t i = 0; i < res.buildings.size(); ++i) { + const TownGen::TownBuilding &a = + res.buildings[i]; + const TownGen::TownBuilding &b2 = + res2.buildings[i]; + if (a.center != b2.center || a.yaw != b2.yaw || + a.floors != b2.floors || + a.width != b2.width || + a.address != b2.address) { + printf(" *** determinism: building " + "%zu differs ***\n", i); + ok = false; + break; + } + } + } + + /* Buildings keep clear of the road body: every building + * footprint corner must stay at least the road + * half-width off every edge centerline segment. */ + auto segDistXZ = [](const Vector3 &p, const Vector3 &a, + const Vector3 &b) { + float abx = b.x - a.x, abz = b.z - a.z; + float apx = p.x - a.x, apz = p.z - a.z; + float len2 = abx * abx + abz * abz; + float t = len2 > 0.0f ? + (apx * abx + apz * abz) / len2 : + 0.0f; + t = std::max(0.0f, std::min(1.0f, t)); + float dx = p.x - (a.x + abx * t); + float dz = p.z - (a.z + abz * t); + return std::sqrt(dx * dx + dz * dz); + }; + for (const TownGen::TownBuilding &b : res.buildings) { + float hw = b.width * 0.5f; + float hd = b.depth * 0.5f; + Ogre::Quaternion rot(Ogre::Radian(b.yaw), + Vector3::UNIT_Y); + for (int ci = 0; ci < 4; ++ci) { + Vector3 local(ci < 2 ? -hw : hw, 0.0f, + ci % 2 == 0 ? -hd : hd); + Vector3 corner = b.center + rot * local; + for (const RoadEdge &e : graph.edges) { + const RoadNode *na = + graph.findNodeById(e.nodeA); + const RoadNode *nb = + graph.findNodeById(e.nodeB); + int la, lb; + graph.resolveLaneCounts(e, e.nodeA, + la, lb); + float half = (la + lb) * + graph.config.laneWidth * + 0.5f; + float d = segDistXZ(corner, + na->position, + nb->position); + if (d < half - 0.1f) { + printf(" *** clearance: " + "'%s' corner %.2f m off " + "edge %d-%d, want >= " + "%.2f ***\n", + b.address.c_str(), d, + e.nodeA, e.nodeB, half); + ok = false; + } + } + } + } + } + + /* Water rejection: everything below the water level. */ + { + RoadGraph graph; + buildTown(graph); + TownGen::TownParams wet = cc; + wet.waterLevel = 5.0f; + TownGen::TownResult res = + TownGen::generateTown(graph, wet, flat); + if (!res.buildings.empty() || res.skippedWater != 12) { + printf(" *** water: %zu buildings %d skipped, want " + "0/12 ***\n", res.buildings.size(), + res.skippedWater); + ok = false; + } + } + + /* Sloped ground rising with +z: door-front lots facing downhill + * get stairs, floorY stays the highest corner. */ + { + RoadGraph graph; + buildTown(graph); + RoadNetworkGen::HeightSampler slope = [](float, float z) { + return z * 0.2f; + }; + TownGen::TownResult res = + TownGen::generateTown(graph, cc, slope); + if (res.buildings.size() != 12) { + printf(" *** slope: %zu buildings, want 12 ***\n", + res.buildings.size()); + ok = false; + } + int withStairs = 0; + for (const TownGen::TownBuilding &b : res.buildings) { + if (b.stairs) { + ++withStairs; + if (b.stairsDrop <= 0.35f) { + printf(" *** stairs with drop %.2f " + "***\n", b.stairsDrop); + ok = false; + } + } + if (b.floorY < b.minGroundY - 0.001f) { + printf(" *** slope: floorY %.2f below " + "minGroundY %.2f ***\n", + b.floorY, b.minGroundY); + ok = false; + } + } + if (withStairs == 0 || withStairs == (int)res.buildings.size()) { + printf(" *** slope: %d/%zu buildings with stairs, " + "want some but not all ***\n", + withStairs, res.buildings.size()); + ok = false; + } + } + + /* Street addresses: buildings on a named street get sequential + * " N" addresses; the rest keep "Lot N". */ + { + RoadGraph graph; + buildTown(graph); + const RoadEdge &e0 = graph.edges[0]; + RoadNetworkGen::nameStreet( + graph, "Main Street", + { std::make_pair(e0.nodeA, e0.nodeB) }); + TownGen::TownResult res = + TownGen::generateTown(graph, cc, flat); + std::map seen; + int onMain = 0; + for (const TownGen::TownBuilding &b : res.buildings) { + if (b.address.rfind("Main Street ", 0) == 0) { + ++onMain; + if (++seen[b.address] > 1) { + printf(" *** duplicate address '%s' " + "***\n", b.address.c_str()); + ok = false; + } + } + } + if (onMain != 2) { + printf(" *** %d buildings on Main Street, want 2 " + "***\n", onMain); + ok = false; + } + } + + /* Village preset: single-storey, density leaves some lots empty + * but stays deterministic. */ + { + RoadGraph graph; + buildTown(graph); + TownGen::TownParams vill; + vill.district = TownGen::District::Village; + vill.seed = 3; + TownGen::TownResult res = + TownGen::generateTown(graph, vill, flat); + for (const TownGen::TownBuilding &b : res.buildings) + if (b.floors != 1) { + printf(" *** village floors %d, want 1 ***\n", + b.floors); + ok = false; + } + if (res.buildings.empty()) { + printf(" *** village placed no buildings ***\n"); + ok = false; + } + TownGen::TownResult res2 = + TownGen::generateTown(graph, vill, flat); + if (res2.buildings.size() != res.buildings.size()) { + printf(" *** village determinism: %zu vs %zu ***\n", + res2.buildings.size(), res.buildings.size()); + ok = false; + } + } + + printf(" %s\n", ok ? "passed" : "FAILURES above"); + return ok; +} + +/* Grid generator: determinism, counts, water/slope rejection, group + * connect/remove, two settlements in one graph. */ +static bool runGeneratorProbe() +{ + printf("grid generator probe\n"); + using namespace RoadNetworkGen; + bool ok = true; + + GridParams p; + p.origin = Vector3(0, 0, 0); + p.blocksX = 3; + p.blocksZ = 3; + p.blockSizeX = 50.0f; + p.blockSizeZ = 50.0f; + p.jitter = 3.0f; + p.seed = 42; + p.avoidWater = false; + + /* Determinism: same seed, identical positions. */ + RoadGraph g1, g2; + generateGrid(g1, p, HeightSampler(), nullptr); + generateGrid(g2, p, HeightSampler(), nullptr); + if (g1.nodes.size() != 16 || g2.nodes.size() != 16) { + printf(" *** node counts %zu/%zu, want 16 ***\n", + g1.nodes.size(), g2.nodes.size()); + ok = false; + } else { + for (size_t i = 0; i < g1.nodes.size(); ++i) + if (g1.nodes[i].position != g2.nodes[i].position) { + printf(" *** not deterministic at " + "node %zu ***\n", + i); + ok = false; + break; + } + } + std::string serr; + RoadLotFilter noFb; + noFb.edgeFallback = false; + if (g1.edges.size() != 24 || !checkSane(g1, &serr) || + !findCrossings(g1).empty() || findLots(g1, noFb).size() != 9) { + printf(" *** grid graph: edges=%zu sane=%s lots=%zu " + "crossings=%zu ***\n", + g1.edges.size(), serr.empty() ? "yes" : "NO", + findLots(g1, noFb).size(), findCrossings(g1).size()); + ok = false; + } + /* With fallback squares the twelve perimeter edges each grow one + * outward square (the unjittered boundary is exactly 50 m). */ + if (findLots(g1).size() != 21) { + printf(" *** grid fallback: %zu lots, want 21 ***\n", + findLots(g1).size()); + ok = false; + } + + /* Water rejection: x > 75 is water -> only the x = 0/50 columns + * survive (8 nodes; 6 vertical edges plus the 4 edges between the + * two surviving columns). */ + GridParams pw = p; + pw.jitter = 0.0f; + pw.avoidWater = true; + RoadGraph gw; + HeightSampler sampler = [](float x, float z) { + (void)z; + return x > 75.0f ? -5.0f : 0.0f; + }; + generateGrid(gw, pw, sampler, nullptr); + if (gw.nodes.size() != 8 || gw.edges.size() != 10) { + printf(" *** water: nodes=%zu edges=%zu, want 8/10 ***\n", + gw.nodes.size(), gw.edges.size()); + ok = false; + } + + /* Slope rejection: y = x rises 50 per 50 m block (slope 1), so + * all X-direction edges are skipped; Z-direction edges stay. */ + RoadGraph gs; + HeightSampler slope = [](float x, float z) { + (void)z; + return x; + }; + generateGrid(gs, pw, slope, nullptr); + if (gs.nodes.size() != 16 || gs.edges.size() != 12) { + printf(" *** slope: nodes=%zu edges=%zu, want 16/12 " + "***\n", + gs.nodes.size(), gs.edges.size()); + ok = false; + } + + /* Two settlements in one graph: independent groups; removing one + * leaves the other sane. */ + RoadGraph gt; + GridParams pa = p; + pa.jitter = 0.0f; + GridParams pb = p; + pb.jitter = 0.0f; + pb.origin = Vector3(1000, 0, 1000); + uint32_t ga = generateGrid(gt, pa, HeightSampler(), nullptr); + uint32_t gb = generateGrid(gt, pb, HeightSampler(), nullptr); + if (ga == 0 || gb == 0 || ga == gb || gt.nodes.size() != 32) { + printf(" *** settlements: groups %u/%u nodes=%zu ***\n", + ga, gb, gt.nodes.size()); + ok = false; + } else { + removeGroup(gt, gb); + std::string serr2; + if (gt.nodes.size() != 16 || !checkSane(gt, &serr2)) { + printf(" *** removeGroup: nodes=%zu sane=%s " + "***\n", + gt.nodes.size(), + serr2.empty() ? "yes" : "NO"); + ok = false; + } + } + + /* Connect a manual node to a group "as if it were a road node": + * the closest group node is picked automatically. */ + RoadGraph gc; + uint32_t grp = generateGrid(gc, pa, HeightSampler(), nullptr); + int manual = gc.addNode(Vector3(-30, 0, 10), 0.0f); + std::string cerr; + int ei = connectGroupToNode(gc, grp, manual, 0.0f, &cerr); + std::string serr3; + if (ei < 0 || !findCrossings(gc).empty() || + !checkSane(gc, &serr3)) { + printf(" *** connect: ei=%d crossings=%zu sane=%s (%s) " + "***\n", + ei, findCrossings(gc).size(), + serr3.empty() ? "yes" : "NO", cerr.c_str()); + ok = false; + } else { + /* The connection must land on the grid corner (0, 0). */ + const RoadEdge &e = gc.edges[(size_t)ei]; + int other = e.nodeA == manual ? e.nodeB : e.nodeA; + const RoadNode *n = gc.findNodeById(other); + if (!n || std::fabs(n->position.x) > 1.0f || + std::fabs(n->position.z) > 1.0f) { + printf(" *** connect: wrong group node ***\n"); + ok = false; + } + } + + printf(" %s\n", ok ? "passed" : "FAILURES above"); + return ok; +} + /* Lane-transition taper regression (demo-taxi exterior, node 31): * an edge with 2 lanes per direction (10 m half-width) meets a 1-lane * fallback edge (5 m) at a near-straight node (turn ~0.95 deg, so the @@ -3033,6 +4047,810 @@ static bool runApronSkirt() return ok; } +/* ---------------------------------------------------------------- + * Phase 2.5 probes: road markings, atlas UVs, decals + * ---------------------------------------------------------------- */ + +/* XZ coverage of the paint overlay strips: marking/decal quads are + * horizontal but wind downward (the overlay materials render + * double-sided), so accept both facings. */ +static bool paintCovers(const Procedural::TriangleBuffer &buf, float x, + float z) +{ + const auto &verts = buf.getVertices(); + const auto &indices = buf.getIndices(); + for (size_t i = 0; i + 2 < indices.size(); i += 3) { + const Vector3 &a = verts[(size_t)indices[i]].mPosition; + const Vector3 &b = verts[(size_t)indices[i + 1]].mPosition; + const Vector3 &c = verts[(size_t)indices[i + 2]].mPosition; + Vector3 n = (b - a).crossProduct(c - a); + float l = n.length(); + if (l < 1e-8f || std::fabs(n.y) / l < 0.5f) + continue; + float s0 = (b.x - a.x) * (z - a.z) - (b.z - a.z) * (x - a.x); + float s1 = (c.x - b.x) * (z - b.z) - (c.z - b.z) * (x - b.x); + float s2 = (a.x - c.x) * (z - c.z) - (a.z - c.z) * (x - c.x); + if ((s0 >= -1e-3f && s1 >= -1e-3f && s2 >= -1e-3f) || + (s0 <= 1e-3f && s1 <= 1e-3f && s2 <= 1e-3f)) + return true; + } + return false; +} + +static size_t paintTris(const Procedural::TriangleBuffer &buf) +{ + return buf.getIndices().size() / 3; +} + +/* Marking content on dead-end straight segments: dashed centerline + * for 1+1, double solid for wider, N-1 dashed lane lines per side, + * solid edge lines at both curbs, no stop lines. The per-edge + * tri-state gating is honored both ways. */ +static bool runMarkingSegmentProbe() +{ + printf("marking segment probe (dead-end content + gating)\n"); + bool ok = true; + + auto makeGraph = [](int lanes, RoadMarkingMode mode, bool global, + int &idA, int &idB) { + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = 1; + graph.config.roadThickness = 0.3f; + graph.config.sidewalkEnabled = false; + graph.config.markingsEnabled = global; + idA = graph.addNode(Vector3(0, 0, 0), 0.0f); + idB = graph.addNode(Vector3(40, 0, 0), 0.0f); + int e = graph.addEdge(idA, idB); + graph.edges[(size_t)e].lanesAtoB = lanes; + graph.edges[(size_t)e].lanesBtoA = lanes; + graph.edges[(size_t)e].markingsMode = mode; + return graph; + }; + auto buildAt = [](RoadGraph &graph, int nodeId, + RoadGeometryLib::RoadMarkingBuffers &mb) { + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + bool any = false; + for (const auto &s : segs) { + if (s.nodeId != nodeId) + continue; + any |= RoadGeometryLib::buildSegmentMarkings(s, graph, + mb); + } + return any; + }; + auto checkPaintHeight = [](const Procedural::TriangleBuffer &buf, + float want) { + for (const auto &v : buf.getVertices()) + if (std::fabs(v.mPosition.y - want) > 1e-3f) + return false; + return true; + }; + + int idA, idB; + { + RoadGraph g = makeGraph(1, RoadMarkingMode::Inherit, true, + idA, idB); + RoadGeometryLib::RoadMarkingBuffers mb; + if (!buildAt(g, idA, mb)) { + printf(" *** 1+1: segment markings missing ***\n"); + ok = false; + } + /* 1+1: dashed single centerline, no lane lines, edge + * lines at both curbs, no stop lines. */ + if (paintTris(mb.stopLines) != 0) { + printf(" *** 1+1: stop lines on a dead end ***\n"); + ok = false; + } + if (paintTris(mb.laneLines) != 0) { + printf(" *** 1+1: unexpected lane lines ***\n"); + ok = false; + } + if (paintTris(mb.centerline) == 0 || + !paintCovers(mb.centerline, 1.0f, 0.0f) || + paintCovers(mb.centerline, 4.5f, 0.0f)) { + printf(" *** 1+1: dashed centerline wrong " + "(dash/gap) ***\n"); + ok = false; + } + if (paintTris(mb.edgeLines) == 0 || + !paintCovers(mb.edgeLines, 1.0f, 4.675f) || + !paintCovers(mb.edgeLines, 1.0f, -4.675f)) { + printf(" *** 1+1: edge lines missing at a " + "curb ***\n"); + ok = false; + } + if (!checkPaintHeight(mb.centerline, 0.17f) || + !checkPaintHeight(mb.edgeLines, 0.17f)) { + printf(" *** 1+1: paint height != road top + " + "lift ***\n"); + ok = false; + } + RoadGeometryLib::RoadMarkingBuffers mbB; + if (!buildAt(g, idB, mbB) || + paintTris(mbB.centerline) == 0) { + printf(" *** 1+1: far segment markings " + "missing ***\n"); + ok = false; + } + } + { + RoadGraph g = makeGraph(2, RoadMarkingMode::Inherit, true, + idA, idB); + RoadGeometryLib::RoadMarkingBuffers mb; + if (!buildAt(g, idA, mb)) { + printf(" *** 2+2: segment markings missing ***\n"); + ok = false; + } + /* 2+2: double solid centerline (bare gap at lat 0), one + * dashed lane line per side at lat +/-5, edge lines at + * +/-9.675. */ + if (!paintCovers(mb.centerline, 1.0f, 0.15f) || + !paintCovers(mb.centerline, 4.5f, 0.15f) || + paintCovers(mb.centerline, 1.0f, 0.0f)) { + printf(" *** 2+2: double solid centerline " + "wrong ***\n"); + ok = false; + } + if (!paintCovers(mb.laneLines, 1.0f, 5.0f) || + paintCovers(mb.laneLines, 4.5f, 5.0f) || + !paintCovers(mb.laneLines, 1.0f, -5.0f)) { + printf(" *** 2+2: dashed lane lines wrong ***\n"); + ok = false; + } + if (!paintCovers(mb.edgeLines, 1.0f, 9.675f) || + !paintCovers(mb.edgeLines, 1.0f, -9.675f)) { + printf(" *** 2+2: edge lines missing ***\n"); + ok = false; + } + } + { + RoadGraph g = makeGraph(4, RoadMarkingMode::Inherit, true, + idA, idB); + RoadGeometryLib::RoadMarkingBuffers mb; + if (!buildAt(g, idA, mb)) { + printf(" *** 4+4: segment markings missing ***\n"); + ok = false; + } + /* 4+4: three dashed lane lines per side. */ + const float lats[6] = { 5.0f, 10.0f, 15.0f, + -5.0f, -10.0f, -15.0f }; + for (float lat : lats) + if (!paintCovers(mb.laneLines, 1.0f, lat)) { + printf(" *** 4+4: lane line at lat %.1f " + "missing ***\n", lat); + ok = false; + } + if (paintCovers(mb.laneLines, 1.0f, 18.0f)) { + printf(" *** 4+4: unexpected 4th lane line ***\n"); + ok = false; + } + if (!paintCovers(mb.edgeLines, 1.0f, 19.675f)) { + printf(" *** 4+4: edge line missing ***\n"); + ok = false; + } + } + { + RoadGraph g = makeGraph(1, RoadMarkingMode::Off, true, + idA, idB); + RoadGeometryLib::RoadMarkingBuffers mb; + if (buildAt(g, idA, mb) || paintTris(mb.centerline) || + paintTris(mb.edgeLines)) { + printf(" *** per-edge Off ignored ***\n"); + ok = false; + } + } + { + RoadGraph g = makeGraph(1, RoadMarkingMode::On, false, + idA, idB); + RoadGeometryLib::RoadMarkingBuffers mb; + if (!buildAt(g, idA, mb) || + !paintCovers(mb.centerline, 1.0f, 0.0f)) { + printf(" *** per-edge On with global off " + "ignored ***\n"); + ok = false; + } + } + if (!ok) + printf(" *** marking segment probe FAILED ***\n"); + return ok; +} + +/* Junction markings: a stop bar on the incoming half of every arm, no + * paint inside the junction clear zone, longitudinal lines present + * past the clear zone, and no self-overlap in the combined overlay + * buffer (the stop bar must not z-fight the lines it crosses). */ +static bool runJunctionMarkingCase(const char *name, + const std::vector &neighbors, + int lanesPerDirection) +{ + printf("junction markings %s (%zu arms, %d+%d lanes)\n", name, + neighbors.size(), lanesPerDirection, lanesPerDirection); + + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = lanesPerDirection; + graph.config.roadThickness = 0.3f; + graph.config.sidewalkEnabled = false; + int idO = graph.addNode(Vector3(0, 0, 0), 0.0f); + std::vector dirs; + for (const Vector3 &p : neighbors) { + int idN = graph.addNode(p, 0.0f); + graph.addEdge(idO, idN); + Vector3 d = p; + d.y = 0.0f; + d.normalise(); + dirs.push_back(d); + } + + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + + bool ok = true; + float lw = graph.config.laneWidth; + float halfW = (float)lanesPerDirection * lw; /* per-side width */ + float setback = graph.config.markingStopSetback; + float sw = graph.config.markingStopWidth; + + RoadGeometryLib::RoadMarkingBuffers all; + std::vector armClear(neighbors.size(), 0.0f); + float minClear = FLT_MAX, maxClear = 0.0f; + int wedgeCount = 0; + for (const auto &w : wedges) { + if (w.nodeId != idO || w.degenerate) + continue; + ++wedgeCount; + RoadGeometryLib::WedgeFillet f = + RoadGeometryLib::wedgeFillet(w, graph); + float c0 = (f.active ? f.f1 : halfW) + setback; + float c1 = (f.active ? f.f2 : halfW) + setback; + minClear = std::min(minClear, std::min(c0, c1)); + maxClear = std::max(maxClear, std::max(c0, c1)); + for (size_t i = 0; i < dirs.size(); ++i) + if (dirs[i].dotProduct(w.second.direction) > 0.999f) + armClear[i] = c1; + RoadGeometryLib::RoadMarkingBuffers mb; + RoadGeometryLib::buildWedgeMarkings(w, graph, mb); + all.centerline.append(mb.centerline); + all.laneLines.append(mb.laneLines); + all.edgeLines.append(mb.edgeLines); + all.stopLines.append(mb.stopLines); + } + if (wedgeCount != (int)neighbors.size()) { + printf(" *** expected %zu wedges, got %d ***\n", + neighbors.size(), wedgeCount); + ok = false; + } + + /* One stop bar per arm (one quad = 2 tris each). */ + size_t stopTris = paintTris(all.stopLines); + if (stopTris != 2 * neighbors.size()) { + printf(" *** expected %zu stop-line tris, got %zu ***\n", + 2 * neighbors.size(), stopTris); + ok = false; + } + + if (minClear < 4.5f) { + printf(" *** clear zone %.2f too small for the probe " + "***\n", minClear); + ok = false; + } + + float halfArm = neighbors[0].length() * 0.5f; + for (size_t i = 0; i < dirs.size(); ++i) { + Vector3 perp = RoadGeometryLib::roadRightVec(dirs[i]); + + /* (a) No paint inside the clear zone. */ + const float dists[2] = { 2.0f, 4.0f }; + const float lats[3] = { -halfW + 0.5f, 0.0f, halfW - 0.5f }; + for (float dist : dists) + for (float lat : lats) { + Vector3 p = dirs[i] * dist + perp * lat; + if (paintCovers(all.centerline, p.x, p.z) || + paintCovers(all.laneLines, p.x, p.z) || + paintCovers(all.edgeLines, p.x, p.z) || + paintCovers(all.stopLines, p.x, p.z)) { + printf(" *** arm %zu: paint inside " + "the clear zone at (%.2f, " + "%.2f) ***\n", i, p.x, p.z); + ok = false; + } + } + + /* (b) Lines resume past the clear zone. */ + float s = (maxClear + halfArm) * 0.5f; + Vector3 p = dirs[i] * s; + float edgeLat = halfW - 0.325f; + Vector3 pe1 = p + perp * edgeLat; + Vector3 pe2 = p - perp * edgeLat; + if (!paintCovers(all.edgeLines, pe1.x, pe1.z) || + !paintCovers(all.edgeLines, pe2.x, pe2.z)) { + printf(" *** arm %zu: edge lines missing past " + "the clear zone ***\n", i); + ok = false; + } + auto scanCovers = [&](const Procedural::TriangleBuffer &buf, + float lat) { + for (float q = maxClear + 0.3f; + q < halfArm - 1e-3f; q += 0.2f) { + Vector3 pq = dirs[i] * q + perp * lat; + if (paintCovers(buf, pq.x, pq.z)) + return true; + } + return false; + }; + /* Centerline past the clear zone. A dashed 1+1 + * centerline sits at lat 0; the double solid strip of a + * multi-lane road leaves lat 0 as the gap between the + * two solid lines, so scan inside one strip. */ + float scanLat = lanesPerDirection >= 2 ? + graph.config.markingLineWidth : + 0.0f; + if (!scanCovers(all.centerline, scanLat)) { + printf(" *** arm %zu: centerline missing past " + "the clear zone ***\n", i); + ok = false; + } + if (lanesPerDirection >= 2) { + /* Double solid centerline: continuous at s. */ + Vector3 pc = p + perp * 0.15f; + if (!paintCovers(all.centerline, pc.x, pc.z)) { + printf(" *** arm %zu: double centerline " + "not solid ***\n", i); + ok = false; + } + for (int k = 1; k < lanesPerDirection; ++k) { + if (!scanCovers(all.laneLines, k * lw) || + !scanCovers(all.laneLines, -k * lw)) { + printf(" *** arm %zu: lane line " + "%d missing ***\n", i, k); + ok = false; + } + } + } + + /* (c) A stop bar crosses the incoming half of this + * arm. */ + bool stop = false; + float c1 = armClear[i]; + for (float q = c1; q <= c1 + sw + 1e-4f && !stop; + q += 0.1f) + for (float lat = -halfW; lat <= halfW + 1e-4f; + lat += 0.2f) { + Vector3 pq = dirs[i] * q + perp * lat; + if (paintCovers(all.stopLines, pq.x, + pq.z)) { + stop = true; + break; + } + } + if (!stop) { + printf(" *** arm %zu: stop line missing ***\n", + i); + ok = false; + } + } + + /* Combined overlay: no self-overlap (catches the stop bar + * z-fighting the lines it crosses). */ + Procedural::TriangleBuffer comb; + comb.append(all.centerline); + comb.append(all.laneLines); + comb.append(all.edgeLines); + comb.append(all.stopLines); + ok &= checkPiece(paintTris(comb) > 0, "junction markings combined", + comb, nullptr); + + /* Road slab + overlay: paint must not z-fight the road top. */ + Procedural::TriangleBuffer combRoad = comb; + for (const auto &w : wedges) { + if (w.nodeId != idO || w.degenerate) + continue; + RoadGeometryLib::buildWedgeGeometry(w, graph, combRoad); + } + ok &= checkPiece(true, "junction road+markings", combRoad, nullptr); + if (!ok) + printf(" *** junction markings %s FAILED ***\n", name); + return ok; +} + +/* 2-way continuation: markings follow the corner through a degree-2 + * node (no clear zone) and no stop lines are emitted. A nearly + * straight node keeps the fillet inactive so the paint passes right + * through the node point. */ +static bool runMarkingTwoWayProbe() +{ + printf("marking 2-way continuation probe\n"); + + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = 1; + graph.config.roadThickness = 0.3f; + graph.config.sidewalkEnabled = false; + int idO = graph.addNode(Vector3(0, 0, 0), 0.0f); + int idA = graph.addNode(Vector3(-60, 0, 0.5f), 0.0f); + int idB = graph.addNode(Vector3(60, 0, 0.5f), 0.0f); + graph.addEdge(idO, idA); + graph.addEdge(idO, idB); + + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + + bool ok = true; + RoadGeometryLib::RoadMarkingBuffers all; + int wedgeCount = 0; + for (const auto &w : wedges) { + if (w.nodeId != idO || w.degenerate) + continue; + ++wedgeCount; + RoadGeometryLib::RoadMarkingBuffers mb; + RoadGeometryLib::buildWedgeMarkings(w, graph, mb); + all.centerline.append(mb.centerline); + all.laneLines.append(mb.laneLines); + all.edgeLines.append(mb.edgeLines); + all.stopLines.append(mb.stopLines); + } + if (wedgeCount != 2) { + printf(" *** expected 2 wedges, got %d ***\n", wedgeCount); + ok = false; + } + if (paintTris(all.stopLines) != 0) { + printf(" *** stop lines at a 2-way node ***\n"); + ok = false; + } + + /* The centerline runs through the node with no junction clear + * zone. Dashes phase from the edge midpoint (wedge path d = 0), + * so up to one dash gap next to the node may legitimately be + * unpainted — but a junction-style clear cut (half-width + + * setback) would leave much more. Find the first painted point + * along each arm and bound that gap. */ + Vector3 dirA(-60, 0, 0.5f); + dirA.normalise(); + Vector3 dirB(60, 0, 0.5f); + dirB.normalise(); + auto firstCover = [&](const Vector3 &dir) { + for (float dd = 0.0f; dd <= 8.0f; dd += 0.2f) { + Vector3 p = dir * dd; + if (paintCovers(all.centerline, p.x, p.z)) + return dd; + } + return FLT_MAX; + }; + float maxGap = graph.config.markingDashGap + 0.6f; + float gapA = firstCover(dirA); + float gapB = firstCover(dirB); + if (gapA > maxGap || gapB > maxGap) { + printf(" *** centerline cut back %.2f/%.2f m at the 2-way " + "node (clear zone?) ***\n", gapA, gapB); + ok = false; + } + /* Edge lines on both curbs, both sides of the node. */ + Vector3 pA = dirA * 0.5f; + Vector3 pB = dirB * 0.5f; + Vector3 perpA = RoadGeometryLib::roadRightVec(dirA); + const float sides[2] = { -4.675f, 4.675f }; + for (float side : sides) { + Vector3 qA = pA + perpA * side; + Vector3 qB = pB + perpA * side; + if (!paintCovers(all.edgeLines, qA.x, qA.z) || + !paintCovers(all.edgeLines, qB.x, qB.z)) { + printf(" *** edge line broken at the 2-way node " + "(side %.2f) ***\n", side); + ok = false; + } + } + + Procedural::TriangleBuffer comb; + comb.append(all.centerline); + comb.append(all.laneLines); + comb.append(all.edgeLines); + comb.append(all.stopLines); + ok &= checkPiece(paintTris(comb) > 0, "2-way markings combined", + comb, nullptr); + if (!ok) + printf(" *** marking 2-way probe FAILED ***\n"); + return ok; +} + +/* Decal quads: placement from edgeT + lateral offset, the piecewise + * surface height with the midpoint kink, atlas rect UVs and the + * per-node ownership split (a cross-page edge paints each half's + * decals on one page only). */ +static bool runDecalProbe() +{ + printf("decal probe (placement, UVs, ownership)\n"); + + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = 1; + graph.config.roadThickness = 0.3f; + graph.config.sidewalkEnabled = false; + int idA = graph.addNode(Vector3(0, 0, 0), 0.0f); + int idB = graph.addNode(Vector3(40, 4, 0), 0.0f); + int e = graph.addEdge(idA, idB); + + RoadEdgeDecal d1; + d1.edgeT = 0.25f; + d1.lateralOffset = 1.0f; + d1.size = 2.0f; + d1.uvX = 0.1f; + d1.uvY = 0.2f; + d1.uvW = 0.3f; + d1.uvH = 0.4f; + RoadEdgeDecal d2; + d2.edgeT = 0.75f; + d2.lateralOffset = -1.5f; + d2.size = 1.0f; + d2.uvX = 0.5f; + d2.uvY = 0.5f; + d2.uvW = 0.25f; + d2.uvH = 0.25f; + graph.edges[(size_t)e].decals.push_back(d1); + graph.edges[(size_t)e].decals.push_back(d2); + + bool ok = true; + Procedural::TriangleBuffer buf; + RoadGeometryLib::buildEdgeDecals(graph, (size_t)e, buf, -1); + if (paintTris(buf) != 4) { + printf(" *** expected 4 decal tris, got %zu ***\n", + paintTris(buf)); + ok = false; + } + + /* Expected corners: position (sloped edge, midpoint kink at + * yMid=2, decal lift 0.025 over the road top) paired with its + * atlas UV. */ + const Vector3 wantP[8] = { + Vector3(9.0f, 1.175f, 0.0f), Vector3(9.0f, 1.175f, 2.0f), + Vector3(11.0f, 1.175f, 0.0f), Vector3(11.0f, 1.175f, 2.0f), + Vector3(29.5f, 3.175f, -2.0f), Vector3(29.5f, 3.175f, -1.0f), + Vector3(30.5f, 3.175f, -2.0f), Vector3(30.5f, 3.175f, -1.0f) + }; + const Vector2 wantUV[8] = { + Vector2(0.1f, 0.2f), Vector2(0.1f, 0.6f), + Vector2(0.4f, 0.2f), Vector2(0.4f, 0.6f), + Vector2(0.5f, 0.5f), Vector2(0.5f, 0.75f), + Vector2(0.75f, 0.5f), Vector2(0.75f, 0.75f) + }; + for (int k = 0; k < 8; ++k) { + bool found = false; + for (const auto &v : buf.getVertices()) { + if (v.mPosition.distance(wantP[k]) < 1e-3f && + v.mUV.distance(wantUV[k]) < 1e-4f) { + found = true; + break; + } + } + if (!found) { + printf(" *** decal corner %d at " + "(%.2f,%.3f,%.2f) uv (%.2f,%.2f) missing " + "***\n", k, wantP[k].x, wantP[k].y, + wantP[k].z, wantUV[k].x, wantUV[k].y); + ok = false; + } + } + + /* Ownership split: decal 1 (edgeT 0.25) belongs to nodeA, + * decal 2 (0.75) to nodeB. */ + Procedural::TriangleBuffer bufA, bufB; + RoadGeometryLib::buildEdgeDecals(graph, (size_t)e, bufA, idA); + RoadGeometryLib::buildEdgeDecals(graph, (size_t)e, bufB, idB); + if (paintTris(bufA) != 2 || paintTris(bufB) != 2) { + printf(" *** ownership split: %zu/%zu tris (want 2/2) " + "***\n", paintTris(bufA), paintTris(bufB)); + ok = false; + } + ok &= checkPiece(paintTris(buf) > 0, "decals", buf, nullptr); + if (!ok) + printf(" *** decal probe FAILED ***\n"); + return ok; +} + +/* Atlas UVs: U selects the band (road top 0-0.5, sidewalk 0.5-0.75, + * apron 0.75-1.0) and V tiles once per textureTileLength meters, + * phase-continuous across the mid-edge seam. textureTileLength 0 + * keeps the legacy meter UVs. */ +static bool runUVAtlasProbe() +{ + printf("UV atlas probe (bands, V tiling, legacy)\n"); + bool ok = true; + + auto makeGraph = [](float tile, int &idA, int &idB) { + RoadGraph graph; + graph.config.laneWidth = 5.0f; + graph.config.lanesPerDirection = 1; + graph.config.roadThickness = 0.3f; + graph.config.sidewalkEnabled = true; + graph.config.sidewalkWidth = 1.5f; + graph.config.sidewalkHeight = 0.15f; + graph.config.sidewalkThickness = 0.3f; + graph.config.apronEnabled = true; + graph.config.apronWidth = 2.0f; + graph.config.apronDrop = 1.0f; + graph.config.textureTileLength = tile; + idA = graph.addNode(Vector3(0, 0, 0), 0.0f); + idB = graph.addNode(Vector3(40, 0, 0), 0.0f); + graph.addEdge(idA, idB); + return graph; + }; + auto uRange = [](const Procedural::TriangleBuffer &buf, float &mn, + float &mx) { + mn = FLT_MAX; + mx = -FLT_MAX; + for (const auto &v : buf.getVertices()) { + mn = std::min(mn, v.mUV.x); + mx = std::max(mx, v.mUV.x); + } + }; + /* V range over the top face only (skirt bottoms shift V). */ + auto vRangeTop = [](const Procedural::TriangleBuffer &buf, float &mn, + float &mx) { + mn = FLT_MAX; + mx = -FLT_MAX; + for (const auto &v : buf.getVertices()) { + if (v.mNormal.y < 0.9f) + continue; + mn = std::min(mn, v.mUV.y); + mx = std::max(mx, v.mUV.y); + } + }; + + int idA, idB; + RoadGraph g = makeGraph(4.0f, idA, idB); + std::vector wedges; + std::vector segs; + enumerateWedges(g, wedges, segs); + + float vA0 = 0.0f, vA1 = 0.0f, vB0 = 0.0f, vB1 = 0.0f; + bool gotA = false, gotB = false; + for (const auto &s : segs) { + Procedural::TriangleBuffer road, sw, ap; + RoadGeometryLib::buildSegmentGeometry(s, g, road); + RoadGeometryLib::buildSegmentSidewalkGeometry(s, g, sw); + RoadGeometryLib::buildSegmentApronGeometry(s, g, ap); + float u0, u1; + uRange(road, u0, u1); + if (u0 < -1e-3f || u1 > 0.5f + 1e-3f) { + printf(" *** road U band [%.3f, %.3f] outside " + "[0, 0.5] ***\n", u0, u1); + ok = false; + } + uRange(sw, u0, u1); + if (u0 < 0.5f - 1e-3f || u1 > 0.75f + 1e-3f) { + printf(" *** sidewalk U band [%.3f, %.3f] " + "outside [0.5, 0.75] ***\n", u0, u1); + ok = false; + } + uRange(ap, u0, u1); + if (u0 < 0.75f - 1e-3f || u1 > 1.0f + 1e-3f) { + printf(" *** apron U band [%.3f, %.3f] outside " + "[0.75, 1] ***\n", u0, u1); + ok = false; + } + float v0, v1; + vRangeTop(road, v0, v1); + if (s.nodeId == idA) { + vA0 = v0; + vA1 = v1; + gotA = true; + } else { + vB0 = v0; + vB1 = v1; + gotB = true; + } + } + if (!gotA || !gotB) { + printf(" *** segments missing ***\n"); + ok = false; + } + /* 20 m half-edge + 0.05 seam at 4 m/tile: span 5.0125; the two + * halves meet at V=5 at the edge midpoint. */ + if (std::fabs(vA1 - vA0 - 5.0125f) > 0.02f) { + printf(" *** segment A V span %.4f (want 5.0125) ***\n", + vA1 - vA0); + ok = false; + } + if (std::fabs(vB1 - vB0 - 5.0125f) > 0.02f || + std::fabs(vB0 - vA1) > 0.02f) { + printf(" *** segment B V [%.4f, %.4f] breaks phase " + "continuity with A [%.4f, %.4f] ***\n", vB0, vB1, + vA0, vA1); + ok = false; + } + + /* Wedge pieces of a 2-way straight node: the road stays in the + * asphalt band (each wedge maps its centerline-to-curb half to + * [0.25, 0.5] with symmetric 1+1 lanes), sidewalk/apron in + * their bands, V tiles along the arms. */ + RoadGraph gw; + gw.config.laneWidth = 5.0f; + gw.config.lanesPerDirection = 1; + gw.config.roadThickness = 0.3f; + gw.config.sidewalkEnabled = true; + gw.config.sidewalkWidth = 1.5f; + gw.config.sidewalkHeight = 0.15f; + gw.config.sidewalkThickness = 0.3f; + gw.config.apronEnabled = true; + gw.config.apronWidth = 2.0f; + gw.config.apronDrop = 1.0f; + gw.config.textureTileLength = 4.0f; + int idO = gw.addNode(Vector3(0, 0, 0), 0.0f); + int idW1 = gw.addNode(Vector3(-40, 0, 0), 0.0f); + int idW2 = gw.addNode(Vector3(40, 0, 0), 0.0f); + gw.addEdge(idO, idW1); + gw.addEdge(idO, idW2); + std::vector ww; + std::vector ws; + enumerateWedges(gw, ww, ws); + Procedural::TriangleBuffer wroad, wsw, wap; + for (const auto &w : ww) { + if (w.nodeId != idO || w.degenerate) + continue; + RoadGeometryLib::buildWedgeGeometry(w, gw, wroad); + RoadGeometryLib::buildSidewalkGeometry(w, gw, wsw); + RoadGeometryLib::buildWedgeApronGeometry(w, gw, wap); + } + float wu0, wu1; + uRange(wroad, wu0, wu1); + if (wu0 < 0.25f - 1e-3f || wu1 > 0.5f + 1e-3f) { + printf(" *** wedge road U [%.3f, %.3f] outside " + "[0.25, 0.5] ***\n", wu0, wu1); + ok = false; + } + uRange(wsw, wu0, wu1); + if (wu0 < 0.5f - 1e-3f || wu1 > 0.75f + 1e-3f) { + printf(" *** wedge sidewalk U [%.3f, %.3f] outside " + "band ***\n", wu0, wu1); + ok = false; + } + uRange(wap, wu0, wu1); + if (wu0 < 0.75f - 1e-3f || wu1 > 1.0f + 1e-3f) { + printf(" *** wedge apron U [%.3f, %.3f] outside band " + "***\n", wu0, wu1); + ok = false; + } + float wv0, wv1; + vRangeTop(wroad, wv0, wv1); + if (wv1 - wv0 < 4.9f) { + printf(" *** wedge road V span %.3f does not tile " + "***\n", wv1 - wv0); + ok = false; + } + + /* Legacy meter UVs with textureTileLength == 0. */ + RoadGraph gl = makeGraph(0.0f, idA, idB); + std::vector lwg; + std::vector lsg; + enumerateWedges(gl, lwg, lsg); + for (const auto &s : lsg) { + if (s.nodeId != idA) + continue; + Procedural::TriangleBuffer road; + RoadGeometryLib::buildSegmentGeometry(s, gl, road); + float u0, u1, v0, v1; + uRange(road, u0, u1); + vRangeTop(road, v0, v1); + if (u1 < 19.9f) { + printf(" *** legacy road U max %.3f not meter " + "based ***\n", u1); + ok = false; + } + if (v1 < 9.9f) { + printf(" *** legacy road V max %.3f not meter " + "based ***\n", v1); + ok = false; + } + } + if (!ok) + printf(" *** UV atlas probe FAILED ***\n"); + return ok; +} + int main(int argc, char **argv) { bool ok = true; @@ -3040,6 +4858,14 @@ int main(int argc, char **argv) if (argc == 3 && strcmp(argv[1], "--region") == 0) return runRegionProbe(argv[2]) ? 0 : 1; + if (argc == 3 && strcmp(argv[1], "--region-markings") == 0) + return runRegionMarkingsProbe(argv[2]) ? 0 : 1; + + if (argc == 4 && strcmp(argv[1], "--region-markings") == 0) + return runRegionMarkingsProbe(argv[2], atof(argv[3])) ? + 0 : + 1; + if (argc == 6 && strcmp(argv[1], "--region") == 0) return runRegionProbe(argv[2], atof(argv[3]), atof(argv[4]), atof(argv[5])) ? @@ -3427,8 +5253,34 @@ int main(int argc, char **argv) Vector3(12, 4, 0)); ok &= runPinZonePrecision(); + ok &= runPinZoneCacheProbe(); + ok &= runCrossingResolutionProbe(); + ok &= runLotProbe(); + ok &= runStreetProbe(); + ok &= runGeneratorProbe(); + ok &= runTownGenProbe(); ok &= runLaneTransitionTaper(); ok &= runApronSkirt(); + + ok &= runMarkingSegmentProbe(); + ok &= runJunctionMarkingCase( + "4-way 1+1", + { Vector3(60, 0, 0), Vector3(0, 0, 60), + Vector3(-60, 0, 0), Vector3(0, 0, -60) }, + 1); + ok &= runJunctionMarkingCase( + "4-way 2+2", + { Vector3(60, 0, 0), Vector3(0, 0, 60), + Vector3(-60, 0, 0), Vector3(0, 0, -60) }, + 2); + ok &= runJunctionMarkingCase( + "3-way T 1+1", + { Vector3(60, 0, 0), Vector3(-60, 0, 0), + Vector3(0, 0, 60) }, + 1); + ok &= runMarkingTwoWayProbe(); + ok &= runDecalProbe(); + ok &= runUVAtlasProbe(); } else { fprintf(stderr, "usage: %s [Ax Ay Az Bx By Bz Cx Cy Cz]\n" diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 0417d35..73ef266 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -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 #include #include +#include #include #include #include @@ -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 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 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. */