From 33f76a1e54144ed09c030b83928c12cc8dec773a Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Tue, 4 Aug 2026 17:37:49 +0300 Subject: [PATCH] Added demo for road geometry --- src/features/editScene/CMakeLists.txt | 40 + .../editScene/ProceduralRoadGeometry.md | 489 ++++++++ .../editScene/TerrainML5Verification.md | 7 +- src/features/editScene/TerrainRequirements.md | 82 +- src/features/editScene/road_demo/main.cpp | 611 ++++++++++ .../editScene/roadlib/RoadGeometryLib.cpp | 643 ++++++++++ .../editScene/roadlib/RoadGeometryLib.hpp | 147 +++ src/features/editScene/systems/RoadSystem.cpp | 1075 ++++++++++------- src/features/editScene/systems/RoadSystem.hpp | 103 +- .../editScene/systems/TerrainTests.cpp | 242 ++-- 10 files changed, 2885 insertions(+), 554 deletions(-) create mode 100644 src/features/editScene/ProceduralRoadGeometry.md create mode 100644 src/features/editScene/road_demo/main.cpp create mode 100644 src/features/editScene/roadlib/RoadGeometryLib.cpp create mode 100644 src/features/editScene/roadlib/RoadGeometryLib.hpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index f6b56b8..781ec80 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -629,6 +629,46 @@ target_include_directories(save_load_lua_test PRIVATE ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src ) +# --------------------------------------------------------------------------- +# Road Geometry Library — standalone wedge/segment generation (M5) +# --------------------------------------------------------------------------- +# Extracted from RoadSystem.cpp; depends only on Ogre, OgreProcedural, and +# RoadGraph.hpp. No ECS, physics, or terrain dependency. +add_library(RoadGeometryLib STATIC + roadlib/RoadGeometryLib.cpp + roadlib/RoadGeometryLib.hpp +) + +target_include_directories(RoadGeometryLib PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(RoadGeometryLib PUBLIC + OgreMain + OgreProcedural::OgreProcedural +) + +# --------------------------------------------------------------------------- +# Road Geometry Demo — standalone OGRE + ImGui app for debugging +# --------------------------------------------------------------------------- +# Takes 3 world-space points ABC and renders the wedge formed at node B +# (midpoint AB → B → midpoint BC). ImGui sliders adjust points in real-time. +add_executable(RoadGeometryDemo + road_demo/main.cpp +) + +target_link_libraries(RoadGeometryDemo + RoadGeometryLib + OgreBites + OgreOverlay + OgreMain + OgreProcedural::OgreProcedural +) + +target_include_directories(RoadGeometryDemo PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + # --------------------------------------------------------------------------- # Package Archive Library # --------------------------------------------------------------------------- diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md new file mode 100644 index 0000000..f49a14a --- /dev/null +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -0,0 +1,489 @@ +# Procedural Road Wedge Geometry — Specification + +## 1. Terms + +| Term | Definition | +|------|------------| +| **Wedge** | A road piece covering the V-shaped region from midpoint of edge AB, through node B, to midpoint of edge BC. Produced by `enumerateWedges()` — consecutive pairs of angle-sorted half-edges at a node. Wedges at a node tile the full 360°. | +| **Half-edge** | One directional segment from a node to the midpoint of one of its incident edges. Fields: `direction`, `halfLength`, `lanesOut`, `lanesIn`, `roadLevelAtNode`, `roadLevelAtNeighbor`. | +| **Centerline** | The 2-segment polyline M_A → O → M_B. O is the seed node; M_A, M_B are edge midpoints. Lengths: L1 = first half-edge, L2 = second, total L = L1 + L2. | +| **Template** | An Ogre mesh (or generated fallback box) returned by `RoadSystem::getRoadTemplate(cfg)`. X∈[0,1], Y∈[-thick/2,+thick/2], Z∈[-1,0], UV∈[0,1]². | +| **Template space** | Coordinate system of the concatenated strip: X = lateral from centerline toward outer curb, Y = vertical from road surface, Z = longitudinal (0 at wedge start, negative toward end). | +| **Outer curb** | The exposed boundary of the wedge — the edge farthest from the node, opposite the centerline. Defined by the continuous curve C(d) = center(d) + offset(d). | + +## 2. Template Mesh Conventions + +The template from `getRoadTemplate(cfg)`: + +- **X**: ∈ [0, 1]. Template +X maps toward the **outer curb** of the wedge. X=0 is the centerline. +- **Y**: ∈ [-roadThickness/2, +roadThickness/2]. Maps directly to world vertical offset from the road surface at that position. +- **Z**: ∈ [-1, 0] (fallback box; loaded files may differ but must span exactly 1 unit of distance along the road). +- **UVs**: span (0,0)–(1,1) over X/Z extents on each face. +- **Normals**: preserved through rigid rotation during transformation. + +If the template file is missing, the fallback is a 6-face unit box (24 verts, 36 indices, X∈[0,1], Y∈[-thick/2,+thick/2], Z∈[-1,0]). + +## 3. Algorithm Overview + +Three phases, each a pure function on `Procedural::TriangleBuffer`: + +``` +Phase 1: buildConcatenatedStrip(template, N) + → Straight strip of N concatenated template copies along -Z. + +Phase 2: transformWedgeVertices(strip, wedge, graph) + → Bend the strip into the wedge shape. The outer-curb offset is + interpolated through a narrow blend zone at the node, so the + cross-section direction varies continuously — no gaps. + +Phase 3: shiftSeamVertices(strip, wedge, graph) + → Shift centerline-side vertices near the node slightly past O + so adjacent wedges overlap and close the center junction. + Essential for nodes with > 2 neighbors. +``` + +These are chained by the public entry points: + +```cpp +static bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); +static bool buildSegmentGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); +``` + +Degenerate wedges (sweptAngleDeg > 270°, flagged by `enumerateWedges()`) return false and emit nothing. + +## 4. Phase 1 — Concatenated Strip + +**Function**: `static void buildConcatenatedStrip(Procedural::TriangleBuffer &out, const Procedural::TriangleBuffer &templ, int N)` + +``` +out.clear() +for i = 0 to N-1: + base = out.vertexCount() + for each vertex v in templ: + v' = copy of v + v'.position.z -= i // shift 1 unit back per copy + out.addVertex(v') + for each index idx in templ: + out.addIndex(base + idx) +``` + +After Phase 1 the strip occupies X∈[0,1], Y∈[-thick/2,+thick/2], Z∈[-N,0]. + +`N = (int)ceil(L1 + L2)`. Vertices with d = |z| > L after Phase 2 are removed. + +## 5. Phase 2 — Vertex Transformation (No Gaps) + +**Function**: `static void transformWedgeVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph)` + +### 5.1 Derived Values + +``` +O = graph.nodes[seedNode].position +M_A = O + H1.direction * L1 +M_B = O + H2.direction * L2 + +L1 = H1.halfLength +L2 = H2.halfLength +L = L1 + L2 + +dir1 = H1.direction // normalized, y=0, points from O toward neighbor +dir2 = H2.direction + +r1 = dir1.crossProduct(UNIT_Y) // right vector, segment 1 +r2 = dir2.crossProduct(UNIT_Y) // right vector, segment 2 + +lw = graph.config.laneWidth +w1 = H1.lanesOut * lw // road half-width on H1 side of centerline +w2 = H2.lanesIn * lw // road half-width on H2 side of centerline +in1 = H1.lanesIn * lw // UV lateral offset for continuity + +yO = O.y + nodeRoadLevel(graph, seedNode) +``` + +### 5.2 Blend Zone + +A narrow symmetric zone around the node where the outer-curb offset +transitions continuously from `w1 * r1` to `-w2 * r2`: + +``` +W = min(ROAD_SEAM_OVERLAP * 4, // ~0.2 units — tight, keeps corners sharp + L1 * 0.5f, L2 * 0.5f) // clamped for very short edges +``` + +If L1 < ROAD_SEAM_OVERLAP or L2 < ROAD_SEAM_OVERLAP, W is set to 0 +(no blending needed — both segment ends are at nearly the same point). + +### 5.3 Centerline Position + +For a vertex at distance `d = -v.position.z` from the wedge start: + +``` +// d is guaranteed to be in [0, L] by Phase 1 construction +if d <= L1: + t = d / L1 + center(d) = lerp(M_A, O, t) +else: + t = (d - L1) / L2 + center(d) = lerp(O, M_B, t) +``` + +`center(d)` has a sharp corner at O — the centerline is a polyline. +This is correct: road intersections have sharp bends. The corner +at O is shared between adjacent wedges. + +### 5.4 Outer-Curb Offset (Continuous Across the Node) + +The outer-curb offset `offset(d)` is the vector from `center(d)` to the +outer curb at distance d. It transitions **continuously** from `w1 * r1` +(H1 side) to `-w2 * r2` (H2 side) through the blend zone: + +``` +if W == 0 or d <= L1 - W: + offset(d) = w1 * r1 +elif d >= L1 + W: + offset(d) = -w2 * r2 +else: + t = (d - (L1 - W)) / (2 * W) // 0 → 1 across blend zone + offset(d) = lerp(w1 * r1, -w2 * r2, t) +``` + +Linear vector interpolation works because both `w1*r1` and `-w2*r2` +point into the wedge interior (they are the directions to the outer +curb on each side). The interpolated vector never passes through +zero for non-degenerate wedges — it always points somewhere within +the wedge. + +### 5.5 Road Width Interpolation + +The scalar road half-width varies linearly across the wedge: + +``` +width(d) = w1 + (w2 - w1) * (d / L) +``` + +### 5.6 Surface Height + +``` +roadSurfaceY(d): + if d <= L1: return halfEdgeHeightAt(H1, graph, d) + else: return halfEdgeHeightAt(H2, graph, d - L1) +``` + +`halfEdgeHeightAt(he, graph, d)` (existing helper, RoadSystem.cpp:1183) +returns the absolute world Y of the road surface at distance d from +the seed node, using linear interpolation of the edge's roadLevel values. + +### 5.7 Per-Vertex Transform + +For each vertex `v` at template position (vx, vy, vz): + +``` +d = -vz // guaranteed to be in [0, L] + +localWidth = width(d) +lateral = vx * localWidth // template X∈[0,1] → world distance +lateralDir = normalize(offset(d)) // unit vector toward outer curb + +worldXZ = center(d) + lateral * lateralDir +worldY = roadSurfaceY(d) + vy + +v.position = Vector3(worldXZ.x, worldY, worldXZ.z) + +// UV — longitudinal U from halfEdgeU (phase-continuous), lateral V scaled +v.uv.x = (d <= L1) ? halfEdgeU(H1, graph, d) + : halfEdgeU(H2, graph, d - L1) +v.uv.y = v.uv.y * localWidth + in1 + +// Normal — rotate template-forward (-Z) to segment direction: +segDir = (d <= L1) ? dir1 : dir2 +Ogre::Quaternion q(segDir.angleBetween(Ogre::Vector3::NEGATIVE_UNIT_Z), + Ogre::Vector3::UNIT_Y); +v.normal = q * v.normal; +``` + +Since Phase 1 guarantees d ∈ [0, L] (we use exactly ceil(L) copies and +the template spans exactly 1 Z-unit), there are no out-of-range vertices. + +### 5.8 Why This Is Continuous (No Gaps) + +At every distance d, the cross-section extends from `center(d)` along +`lateralDir(d) = normalize(offset(d))`. Since `offset(d)` is continuous +(even across the node!), `lateralDir(d)` varies continuously. Vertices +at adjacent distances d and d+ε map to adjacent world positions. **No gap +opens at the outer corner.** + +The travel direction `dir(d)` is piecewise (dir1 → dir2 at the node), but +`dir(d)` only affects the normal rotation and UV computation — it does +not affect vertex positions. The cross-section orientation is driven +entirely by the continuous `offset(d)`. + +The centerline has a sharp corner at O, but the centerline edge is the +**inside** of the bend, shared with adjacent wedges. No fill is needed +there. + +## 6. Phase 3 — Center Seam Shifting + +**Function**: `static void shiftSeamVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph)` + +For nodes with > 2 neighbors, the inner edges of all incident wedges may +not meet at a perfect point, leaving a sub-pixel hole at the exact center. +This is closed by shifting centerline-side vertices near O slightly past +the node: + +``` +ROAD_SEAM_OVERLAP = 0.05f + +// Only needed for nodes with > 2 neighbors +if graph.getNeighborIds(seedNode).size() <= 2: + return // straight-through or endpoint, center is continuous + +for each vertex v in strip: + // Check if vertex is on the centerline side (small lateral offset) + Ogre::Vector3 toNode(v.position.x - O.x, 0, v.position.z - O.z); + float distToNode = toNode.length(); + + if distToNode < ROAD_SEAM_OVERLAP: + Ogre::Vector3 radial = toNode.normalisedCopy(); + if radial.isZeroLength(): + continue // exactly at O, should not happen + v.position += radial * (ROAD_SEAM_OVERLAP - distToNode + ROAD_SEAM_OVERLAP); +``` + +This creates ~0.05 units of overlap at the center junction where >2 +wedges meet. For nodes with exactly 2 neighbors (straight-through roads) +or 1 neighbor (endpoints), the centerline is continuous and no shifting +is needed. + +## 7. Straight Segments (Dead-End Nodes) + +A `RoadStraightSegment` covers a single half-edge from an endpoint node. +No bend, no blend zone — a simple rectangular band: + +``` +d = HE.direction +r = d.crossProduct(UNIT_Y) +inW = HE.lanesIn * laneWidth +outW= HE.lanesOut * laneWidth +L = HE.halfLength + +// Four corners of the center-surface band: +c[0] = O + (-ROAD_SEAM_OVERLAP) * d - inW * r // node end, inbound curb +c[1] = O + L * d - inW * r // far end, inbound curb +c[2] = O + L * d + outW * r // far end, outbound curb +c[3] = O + (-ROAD_SEAM_OVERLAP) * d + outW * r // node end, outbound curb + +// Heights: +c[0].y = c[3].y = halfEdgeHeightAt(HE, graph, -ROAD_SEAM_OVERLAP) +c[1].y = c[2].y = halfEdgeHeightAt(HE, graph, L) + +// UV: +uvc[0] = (halfEdgeU(HE, graph, -ROAD_SEAM_OVERLAP), 0) +uvc[1] = (halfEdgeU(HE, graph, L), 0) +uvc[2] = (halfEdgeU(HE, graph, L), inW + outW) +uvc[3] = (halfEdgeU(HE, graph, -ROAD_SEAM_OVERLAP), inW + outW) +``` + +The `-ROAD_SEAM_OVERLAP` extends the inner end past the node so it overlaps +adjacent wedge pieces. + +Triangulation: two center-surface triangles (c0,c1,c2) and (c0,c2,c3). +Extruded to slab with skirts on c[0]→c[3] (node-end cap), c[0]→c[1] +(inbound curb), c[3]→c[2] (outbound curb). The far end c[1]→c[2] is open +(it meets the neighbor's geometry at the edge midpoint). + +**Function**: `static bool buildSegmentGeometry(const RoadStraightSegment &seg, const RoadGraph &graph, Procedural::TriangleBuffer &out)` + +## 8. Slab Extrusion + +**Function**: `static void extrudeToSlab(Procedural::TriangleBuffer &out, const Procedural::TriangleBuffer ¢erSurf, float roadThickness)` + +### 8.1 Top and Bottom + +For each triangle (p0, p1, p2, uv0, uv1, uv2) in centerSurf: + +``` +h = roadThickness * 0.5f +up = (0, h, 0) + +// Top surface (keep winding) +emitTri(out, p0+up, p1+up, p2+up, uv0, uv1, uv2) + +// Bottom surface (flip winding) +emitTri(out, p0-up, p2-up, p1-up, uv0, uv2, uv1) +``` + +### 8.2 Side Skirts + +Only on **boundary edges** — edges appearing in exactly one triangle. +Detection: scan all triangle edges; an edge (minIdx, maxIdx) seen once +is a boundary edge. + +For each boundary edge (p0, p1, uv0, uv1): + +``` +thick = 2 * h +t0 = p0 + up; t1 = p1 + up +b0 = p0 - up; b1 = p1 - up + +// Orient the skirt so its normal points outward. +Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0); +Ogre::Vector3 mid = (t0 + t1 + b0 + b1) * 0.25f; +bool outward = n.dotProduct(mid - refPoint) >= 0; + +if outward: + emitTri(out, t0, t1, b1, uv0, uv1, UV(uv1.x, uv1.y - thick)); + emitTri(out, t0, b1, b0, uv0, UV(uv1.x, uv1.y - thick), UV(uv0.x, uv0.y - thick)); +else: + emitTri(out, t0, b1, t1, uv0, UV(uv1.x, uv1.y - thick), uv1); + emitTri(out, t0, b0, b1, uv0, UV(uv0.x, uv0.y - thick), UV(uv1.x, uv1.y - thick)); +``` + +Where `refPoint` is the centroid of `centerSurf`. + +### 8.3 Application + +- **Wedge**: After Phase 2+3, the strip contains the center-surface triangles + (from the template index buffer, transformed). Pass to `extrudeToSlab`. +- **Segment**: After building the center-surface band, pass to `extrudeToSlab`. + +In both cases, the boundary edges are: the outer curb chain, the start cap, +and the end cap. Centerline edges (O→M_A, O→M_B) are interior and get no +skirts — they meet adjacent wedge pieces. + +## 9. Seam Suppression Summary + +| Mechanism | What it fixes | Where | +|-----------|--------------|-------| +| Continuous curb offset (§5.4) | Outer-corner gap where H1 and H2 diverge | Phase 2 | +| ROAD_SEAM_OVERLAP on segments (§7) | Center gap for dead-end nodes | Segment band | +| Center seam shifting (§6) | Center hole where >2 wedges meet | Phase 3 | +| Slab extrusion (§8) | Road must be a closed solid | Post-Phase 3 | + +## 10. Internal Functions (Testable) + +```cpp +// Phase 1 +static void buildConcatenatedStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + int N); + +// Phase 2 — transforms vertices in-place, continuous across the node +static void transformWedgeVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + +// Phase 3 — shifts centerline vertices in-place past the node +static void shiftSeamVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + +// Slab: adds top+bottom+skirts to output from center surface +static void extrudeToSlab(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer ¢erSurf, + float roadThickness); + +// The key math — independently testable, no scene required +static Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d); +``` + +## 11. Test Specification + +### 11.1 `computeCurbOffset` Unit Tests + +| Test | Setup | d=0 expected | d=L1 (node) expected | d=L expected | +|------|-------|-------------|---------------------|-------------| +| 90° wedge, w1=w2=3 | dir1=+X, dir2=+Z | (0,0,3) | (1.5,0,1.5) | (3,0,0) | +| 270° wedge, w1=w2=3 | dir1=+Z, dir2=+X | (-3,0,0) | (-1.5,0,-1.5) | (0,0,-3) | +| 180° straight, w1=w2=3 | dir1=+X, dir2=-X | (0,0,3) | (0,0,3) | (0,0,3) | +| Asymmetric w1=6,w2=3 | 90° | (0,0,6) | (1.5,0,4.5) | (3,0,0) | +| Blend zone continuity | Any | offset varies with d | no discontinuity at L1 | — | + +### 11.2 Integration Tests (matching existing `testRoadWedgeGeometry`) + +| Test | Expected | +|------|----------| +| Segment slab (0,0,0)–(20,0,0), 1+1 lanes | X∈[-0.05,10], Z∈[-3,3], Y top≈+0.15, bot≈-0.15 | +| Elevated nodes y=10 | Top Y≈10.15 (not 20.15 — regression test) | +| Asymmetric (2 out, 1 in) | Z∈[-3,6] | +| 90° L-corner | All XZ∈[0,10]², outer corner near (3,y,3), node vertex at (0,y,0) | +| 270° wrap | XZ∈[-3,10]², outer corner near (-3,y,-3) | +| 180° straight-through | Two rectangular halves, Z∈[0,3] and [-3,0], no bowing | +| Degenerate (>270°) | Returns false, empty output | +| Template usage | Vertex count ∝ templateVertCount × ceil(L) | + +### 11.3 `extrudeToSlab` Tests + +| Test | Expected | +|------|----------| +| Single triangle, thick=0.3 | 6 tris (top+bottom+3 skirts), Y∈[-0.15,0.15] | +| Two adjacent triangles | 10 tris (shared edge has no skirt) | + +## 12. Migration from Current Implementation + +### Kept unchanged + +- `roadRightVec(d)` — cross with UNIT_Y +- `halfEdgeHeights(he, graph, yNode, yMid)` +- `halfEdgeHeightAt(he, graph, t)` +- `halfEdgeU(he, graph, t)` — phase-continuous UV +- `nodeRoadLevel(graph, nodeId)` +- `emitTri(out, p0, p1, p2, uv0, uv1, uv2)` — degenerate-skip +- `ROAD_SEAM_OVERLAP = 0.05f` +- Helper structs: `RoadSurfTri`, `RoadSkirtEdge` + +### Replaced + +| Old | New | Reason | +|-----|-----|--------| +| `computeWedgeOutline()` (~80 lines) | `computeCurbOffset()` (~30 lines) | Interpolate offsets, not build polygon | +| `RoadWedgeOutline` struct | Not needed | No explicit polygon | +| `triangulateOutline()` (~90 lines) | Template index buffer | Template already has triangulation | +| `emitSlab()` (~45 lines) | `extrudeToSlab()` (~40 lines) | Same logic, cleaner signature | + +### Removed + +- `RoadWedgeOutline` struct — no polygon built +- `triangulateOutline()` — ear-clipping no longer needed +- Gap-fill triangle fan at outer corner — continuous offset eliminates the gap +- `computeMiterCorner()` — no miter corner needed + +## 13. Rationale + +### Why continuous curb offset instead of segment classification + miter fill? + +The continuous-interpolation approach is what the user's original +implementation did: every vertex's Z coordinate maps uniquely to a +position+orientation along the path. No gaps appear because the +cross-section orientation varies continuously. + +The alternative (classify each vertex as "segment 1" or "segment 2" +and apply a different transform) creates a discontinuity at the node +where the two segments' outer curbs diverge. This requires extra +geometry (miter corner triangles) to fill — an unnecessary complication. + +### Why linear interpolation of offset vectors? + +Both `w1*r1` and `-w2*r2` point into the wedge interior. Linear +vector interpolation stays within the wedge for all sweep angles. +Angular interpolation (slerp) would add complexity with no visible +benefit for the narrow blend zone (W ≈ 0.2 units). + +### Why is the travel direction still piecewise? + +`dir(d)` is piecewise (dir1 for d≤L1, dir2 for d>L1) because the +centerline is a polyline with a sharp corner. This is correct for +road intersections. `dir(d)` only affects normal rotation and UV +lookup — vertex positions are driven by the continuous `offset(d)`. + +### Template mesh is finally used + +The current implementation ignores the template from M5.3. This +specification makes `getRoadTemplate()` meaningful: a custom `.mesh` +with curb profiles or road crowning produces detailed geometry +automatically through the concatenate-and-transform pipeline. diff --git a/src/features/editScene/TerrainML5Verification.md b/src/features/editScene/TerrainML5Verification.md index ee77e37..197a39f 100644 --- a/src/features/editScene/TerrainML5Verification.md +++ b/src/features/editScene/TerrainML5Verification.md @@ -281,7 +281,7 @@ ordered by dependency. | # | Item | Files to modify | Status | |---|------|-----------------|--------| -| W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-07-31) | +| W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) — **superseded 2026-08-02**: the radial curb sweep left node-center holes, diagonal > 180° bands, bowed through-roads and double-height segments; replaced by the mitered polyline sweep (`computeWedgeOutline`/`triangulateOutline` + `emitSlab`) per user direction | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-08-02, reworked) | | W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE | | W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | ✅ DONE | | W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE | @@ -296,7 +296,7 @@ ordered by dependency. | Area | Status | |------|--------| | M5.1–M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) | -| M5.6 sweep-based wedge geometry | ✅ Implemented (W0, 2026-07-31) — replaces fan/strip approach | +| M5.6 wedge geometry | ✅ Mitered polyline sweep (2026-08-02) — replaces the broken radial curb sweep (W0 rework): wedge = one mesh bent along the 2-segment centerline polyline, exact width at corners, no node holes/overlaps | | M5.9 automated coverage | ✅ W4 adds raycast+rebuild verification | | M5.9.6 road collider debug toggle | ❌ Not implemented → W5 | | M5.10 perpendicular falloff | ✅ Implemented → W1+W2 | @@ -307,7 +307,8 @@ ordered by dependency. | Open questions | ✅ All resolved (section 0) | **Exit criteria** — Milestone 5 is fully verified when: -- [x] W0 (sweep-based wedge geometry) implemented and tested. +- [x] W0 (wedge geometry) implemented and tested — mitered polyline sweep + (2026-08-02 rework; radial curb sweep attempt reverted). - [x] W1–W4, W6–W8 implemented. - [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all 22 tests green per iteration (verified 2026-07-31). diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 0e89e56..cedf610 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -2135,7 +2135,7 @@ verification plan, manual test procedures, and open questions. | M5.3 Road mesh template | ✅ complete | `RoadSystem::getRoadTemplate()`, fallback box, `roadTemplate` test | | M5.4 Road geometry generation | ✅ complete | page tracking + wedge bucketing in `RoadSystem`, `roadPageAssignment` test | | M5.5 Wedge enumeration | ✅ complete | `enumerateWedges()` in `RoadGraph.hpp`, `validate()` angle checks, `roadWedgeEnumeration` test | -| M5.6 Wedge geometry | ✅ complete | `buildWedgeGeometry`/`buildSegmentGeometry` + `emitSlab` in `RoadSystem`, `roadWedgeGeometry` test | +| M5.6 Wedge geometry | ✅ complete | mitered polyline sweep: `computeWedgeOutline`/`triangulateOutline` + `emitSlab` in `RoadSystem`, `roadWedgeGeometry` test | | M5.7 Edge length constraint | ✅ complete | `snapToIntegerLength()` + `ROAD_MIN_EDGE_LENGTH`; `splitEdge` snaps, `joinNodes` warns, `validate` rejects short edges; `roadEdgeLength` test green | | M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test | | M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` | @@ -2646,42 +2646,66 @@ region defined by its two half-edges. - No wedge blending is needed. - Apply the same lane-count/asymmetric-lane rules as for a wedge side. -**Status (2026-07-26): ✅ complete**, with a deliberate deviation from the -literal template-copy sweep described above. The naive chord sweep was -rejected: it overshoots non-road corner triangles and ignores per-side lane -widths. What is implemented in `RoadSystem::buildWedgeGeometry()` / -`buildSegmentGeometry()` instead: +**Status (2026-08-02): ✅ complete — mitered polyline sweep.** Each wedge +piece is generated as ONE whole mesh bent along the wedge's 2-segment +centerline polyline `M_A → O → M_B` (edge midpoint → node → edge midpoint) +— the same transform as bending a mesh along a spline, but with a +2-segment polyline. Cross-sections run from the centerline (shared +exactly with the neighbouring wedge) out to the curb; at the node the +section uses the miter frame, so both segments' curb lines meet in one +outer corner `X` and the road keeps its exact width through the turn — +no holes at the node, no overlaps, no width distortion at corners. -- The wedge region is the exact union of two one-sided lane band strips - (H1's outbound side `s ∈ [0, L_out·laneWidth]`, H2's inbound side - `s ∈ [-L_in·laneWidth, 0]`; `roadRightVec(d) = d × UNIT_Y`). -- Swept ≤ 180° with a valid outer corner `X` (curb intersection, Cramer - solve with `|det| ≥ 0.05`, `t` inside both half-lengths): single L-shaped - hexagon emitted as a fan around `X` (4 triangles; degenerate ones skipped). - Only the two outer curbs get skirts. -- Otherwise (swept > 180°, near-parallel, or corner outside the half-edges): - two independent strip quads with outer curb + node-end cap skirts and a - 0.05 seam overlap at the node (replaces the "center-gap filling" above). -- Straight segments emit the full band `s ∈ [-L_in·w, +L_out·w]` with cap + - both curb skirts; the far end has no skirt (meets the neighbor's geometry). +An earlier radial-sweep attempt (2026-07-31, reverted 2026-08-02) swept a +constant-width band along the outer-curb polyline: it left holes at every +node center, collapsed > 180° wedges to a diagonal band across the node, +bowed straight-through roads toward the node, and double-counted the node +height in dead-end segments. The two-strip fallback it had replaced had +its own overlap/gap issues (inner-turn overlaps, outer-turn center +splits). The mitered outline below is the geometrically exact solution +for every swept angle: + +- Outline polygon (`computeWedgeOutline()`): + `O → M_A → curbA → [X →] curbB → M_B`, where `curbA = M_A + w_A·r1`, + `curbB = M_B - w_B·r2`, `w_A = H1.lanesOut·laneWidth`, + `w_B = H2.lanesIn·laneWidth`, `r = roadRightVec(d) = d × UNIT_Y`. +- `X` is the intersection of the two curb lines (Cramer solve, + `|det| ≥ 0.05`) with NO parameter-range restriction — a corner behind + the node (swept > 180°) is exactly what closes the wrap-around piece. + Near-straight wedges (`|det| < 0.05`, curb lines collinear) drop the + corner: the outer edge is a straight line between the two midpoint curb + points, producing a clean rectangle for 180° through-roads. +- Triangulation by ear-clipping in the XZ projection + (`triangulateOutline()`), which handles concave outlines from oversized + miter corners on short edges; fan fallback as a numeric safety net. +- Straight segments (dead-end nodes) emit the full band + `s ∈ [-L_in·w, +L_out·w]` with node-end cap + both curb skirts; the far + end has no skirt (meets the neighbor's geometry). - All primitives go through `emitSlab()`: center-surface triangles are duplicated at `±roadThickness/2` with auto-flipped winding (normals from - cross products), boundary edges grow vertical skirts oriented away from an - interior reference point. This gives closed solids suitable for physics - mesh shapes (M5.9) instead of open surfaces. -- Heights interpolate `roadLevelA/B` from node to edge midpoint; lateral - direction stays flat. UVs: `u` along the road (phase-continuous across - the edge midpoint), `v` across the band (continuous at the center line); - wedge fans use a planar projection in the first half-edge's frame. UV - scaling replaces the physical 1-unit template repeat of M5.7's snapping - scheme. + cross products); only the exposed outer curb chain grows vertical skirts + (centerline rays and midpoint caps are shared with neighbouring pieces). + Closed solids suitable for physics mesh shapes (M5.9). +- Heights: one shared road level per node (`nodeRoadLevel()` = node Y + + mean incident `roadLevelAtNode`) so adjacent wedge pieces cannot crack; + half-edge profiles (`roadLevelA/B` interpolation) at midpoints and curb + ends; averaged profile heights at the miter corner. Segment heights are + absolute surface heights (the radial sweep's double-counted node Y is + fixed and covered by a regression test). +- UVs: planar projection in the first half-edge's frame with the `+in1` + offset, keeping `v` continuous with the piece covering the other side of + the same half-edge; segments use `halfEdgeU()` (phase-continuous across + the edge midpoint). UV scaling replaces the physical 1-unit template + repeat of M5.7's snapping scheme. - Degenerate wedges (> 270°) are logged and skipped (`false`). `getRoadTemplate()` (M5.3) is retained: its space conventions define the sign math above, and custom mesh templates may still be honored later. Headless coverage: `roadWedgeGeometry` test (segment extents incl. top and -bottom surfaces, asymmetric lanes, 90° fan without overshoot + outer corner -vertex, 270° fallback path, degenerate wedge rejection). +bottom surfaces, elevated-node height regression, asymmetric lanes, 90° +mitered hexagon without overshoot + outer corner (3,3) + node vertex, +270° wrap-around miter corner (-3,-3), 180° straight-through rectangles, +degenerate wedge rejection). --- diff --git a/src/features/editScene/road_demo/main.cpp b/src/features/editScene/road_demo/main.cpp new file mode 100644 index 0000000..daa39b6 --- /dev/null +++ b/src/features/editScene/road_demo/main.cpp @@ -0,0 +1,611 @@ +/* + * RoadGeometryDemo — standalone OGRE + ImGui app for debugging + * procedural road wedge geometry. + * + * Three world-space points A, B, C define two road edges A-B and B-C. + * The wedge at node B bounded by its incident half-edge midpoints is + * generated via RoadGeometryLib and rendered as a ManualObject. + * ImGui controls let you adjust points/config in real-time. + * + * Build: cmake --build --target RoadGeometryDemo + * Run: ./RoadGeometryDemo + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "../components/RoadGraph.hpp" +#include "../roadlib/RoadGeometryLib.hpp" + +/* ========================================================================= + * Render target listener that wraps ImGui around each viewport update. + * ========================================================================= */ + +class ImGuiFrameListener : public Ogre::RenderTargetListener { +public: + ImGuiFrameListener(std::function renderFn) + : m_renderFn(std::move(renderFn)) + { + } + + void preViewportUpdate(const Ogre::RenderTargetViewportEvent &evt) override + { + (void)evt; + if (m_shuttingDown) + return; + Ogre::ImGuiOverlay::NewFrame(); + if (m_renderFn) + m_renderFn(); + } + + void postViewportUpdate(const Ogre::RenderTargetViewportEvent &evt) override + { + (void)evt; + if (m_shuttingDown) + return; + ImGui::EndFrame(); + } + + void setShuttingDown(bool v) { m_shuttingDown = v; } + +private: + std::function m_renderFn; + bool m_shuttingDown = false; +}; + +/* ========================================================================= + * DemoApp + * ========================================================================= */ + +class DemoApp : public OgreBites::ApplicationContext, + public OgreBites::InputListener { +public: + DemoApp(); + ~DemoApp(); + + void setup() override; + void shutdown() override; + bool frameStarted(const Ogre::FrameEvent &evt) override; + + /* InputListener — forward camera events. */ + bool keyPressed(const OgreBites::KeyboardEvent &evt) override; + bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override; + bool mousePressed(const OgreBites::MouseButtonEvent &evt) override; + bool mouseReleased(const OgreBites::MouseButtonEvent &evt) override; + bool mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) override; + +private: + void rebuildWedgeGeometry(); + void renderImGui(); + + Ogre::SceneManager *m_sceneMgr = nullptr; + Ogre::ImGuiOverlay *m_imguiOverlay = nullptr; + std::unique_ptr m_frameListener; + + Ogre::SceneNode *m_wedgeNode = nullptr; + Ogre::ManualObject *m_wedgeTriangles = nullptr; + Ogre::ManualObject *m_wedgeWireframe = nullptr; + Ogre::ManualObject *m_visualAids = nullptr; + + Ogre::SceneNode *m_camNode = nullptr; + std::unique_ptr m_cameraMan; + + /* Points in world space. */ + Ogre::Vector3 m_pointA = Ogre::Vector3(-5, 0, 0); + Ogre::Vector3 m_pointB = Ogre::Vector3(0, 0, 0); + Ogre::Vector3 m_pointC = Ogre::Vector3(5, 0, 5); + + /* Road config. */ + float m_laneWidth = 3.0f; + int m_lanesPerDirection = 1; + float m_roadThickness = 0.3f; + + /* State. */ + uint64_t m_lastConfigHash = 0; + bool m_dirty = true; + + /* 0 = smaller-angle wedge, 1 = larger-angle wedge, 2 = both */ + int m_wedgeMode = 2; +}; + +DemoApp::DemoApp() + : OgreBites::ApplicationContext("RoadGeometryDemo") +{ +} + +DemoApp::~DemoApp() +{ +} + +void DemoApp::shutdown() +{ + /* Tell the frame listener to stop issuing ImGui calls before the + * base class tears down the ImGui overlay. */ + if (m_frameListener) + m_frameListener->setShuttingDown(true); + + /* Remove the listener from the render window so it doesn't fire + * during the remaining frames of the shutdown sequence. */ + if (m_frameListener) + getRenderWindow()->removeListener(m_frameListener.get()); + + OgreBites::ApplicationContext::shutdown(); +} + +void DemoApp::setup() +{ + OgreBites::ApplicationContext::setup(); + + m_sceneMgr = getRoot()->createSceneManager(); + m_sceneMgr->setAmbientLight(Ogre::ColourValue(0.5f, 0.5f, 0.5f)); + + /* RTSS integration. */ + Ogre::RTShader::ShaderGenerator *shadergen = + Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + shadergen->addSceneManager(m_sceneMgr); + + /* Overlay system (needed by ImGui). */ + Ogre::OverlaySystem *overlaySys = getOverlaySystem(); + m_sceneMgr->addRenderQueueListener(overlaySys); + + /* ImGui overlay via ApplicationContext helper. */ + m_imguiOverlay = initialiseImGui(); + m_imguiOverlay->setZOrder(300); + m_imguiOverlay->show(); + ImGui::StyleColorsDark(); + + /* Camera. */ + Ogre::SceneNode *targetNode = + m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + targetNode->setPosition(0, 0, 2); + + m_camNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + m_camNode->setPosition(0, 15, 20); + m_camNode->lookAt(Ogre::Vector3(0, 0, 2), Ogre::Node::TS_WORLD); + + Ogre::Camera *cam = m_sceneMgr->createCamera("MainCam"); + cam->setNearClipDistance(0.1f); + cam->setFarClipDistance(1000.0f); + cam->setAutoAspectRatio(true); + m_camNode->attachObject(cam); + + m_cameraMan = std::make_unique(m_camNode); + m_cameraMan->setStyle(OgreBites::CS_ORBIT); + m_cameraMan->setTarget(targetNode); + + /* Viewport. */ + getRenderWindow()->addViewport(cam); + + /* Render target listener for ImGui frame management. */ + m_frameListener = std::make_unique( + [this]() { renderImGui(); }); + getRenderWindow()->addListener(m_frameListener.get()); + + /* Input listeners — ImGui goes first so it gets first dibs. */ + addInputListener(getImGuiInputListener()); + addInputListener(this); + + /* Lighting. */ + m_sceneMgr->setShadowTechnique(Ogre::SHADOWTYPE_NONE); + + Ogre::Light *dLight = m_sceneMgr->createLight("DirLight"); + dLight->setType(Ogre::Light::LT_DIRECTIONAL); + dLight->setDiffuseColour(Ogre::ColourValue(0.8f, 0.8f, 0.7f)); + dLight->setSpecularColour(Ogre::ColourValue(0.3f, 0.3f, 0.3f)); + /* Direction is set via scene node. */ + Ogre::SceneNode *dNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + dNode->attachObject(dLight); + dNode->setDirection(Ogre::Vector3(0.5f, -1, 0.3f).normalisedCopy()); + + /* Wedge geometry node. */ + m_wedgeNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + + m_wedgeTriangles = m_sceneMgr->createManualObject("WedgeTriangles"); + m_wedgeWireframe = m_sceneMgr->createManualObject("WedgeWireframe"); + m_visualAids = m_sceneMgr->createManualObject("VisualAids"); + + m_wedgeNode->attachObject(m_wedgeTriangles); + m_wedgeNode->attachObject(m_wedgeWireframe); + m_wedgeNode->attachObject(m_visualAids); + m_wedgeWireframe->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + m_visualAids->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + + /* Grid for orientation. */ + Ogre::ManualObject *grid = m_sceneMgr->createManualObject("Grid"); + grid->begin("BaseWhiteNoLighting", Ogre::RenderOperation::OT_LINE_LIST); + Ogre::ColourValue gridCol(0.4f, 0.4f, 0.4f, 0.5f); + for (int i = -20; i <= 20; ++i) { + grid->position(i, 0, -20); + grid->colour(gridCol); + grid->position(i, 0, 20); + grid->colour(gridCol); + grid->position(-20, 0, i); + grid->colour(gridCol); + grid->position(20, 0, i); + grid->colour(gridCol); + } + grid->end(); + m_wedgeNode->attachObject(grid); + + m_dirty = true; +} + +bool DemoApp::frameStarted(const Ogre::FrameEvent &evt) +{ + /* Base class pumps SDL events (keyboard, mouse, window close). + * Without this no input reaches the ImGui or camera handlers. */ + OgreBites::ApplicationContextBase::frameStarted(evt); + + m_cameraMan->frameRendered(evt); + + /* Detect config changes and rebuild. */ + uint64_t hash = (uint64_t)(m_pointA.x * 1000.0f) + + ((uint64_t)(m_pointA.z * 1000.0f) << 12) + + ((uint64_t)(m_pointB.x * 1000.0f) << 24) + + ((uint64_t)(m_pointB.z * 1000.0f) << 36) + + ((uint64_t)(m_pointC.x * 1000.0f) << 48) + + ((uint64_t)(m_pointC.z * 1000.0f) << 56); + + if (hash != m_lastConfigHash || m_dirty) { + m_lastConfigHash = hash; + m_dirty = false; + rebuildWedgeGeometry(); + } + + return true; +} + +/* ========================================================================= + * Input forwarding + * ========================================================================= */ + +bool DemoApp::keyPressed(const OgreBites::KeyboardEvent &evt) +{ + /* ESC always exits, even if ImGui is active. */ + if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) { + getRoot()->queueEndRendering(); + return true; + } + + /* When ImGui wants the keyboard, don't forward to the camera. */ + ImGuiIO &io = ImGui::GetIO(); + if (io.WantCaptureKeyboard) + return false; + + m_cameraMan->keyPressed(evt); + return true; +} + +bool DemoApp::mouseMoved(const OgreBites::MouseMotionEvent &evt) +{ + m_cameraMan->mouseMoved(evt); + return true; +} +bool DemoApp::mousePressed(const OgreBites::MouseButtonEvent &evt) +{ + ImGuiIO &io = ImGui::GetIO(); + if (io.WantCaptureMouse) + return false; + m_cameraMan->mousePressed(evt); + return true; +} +bool DemoApp::mouseReleased(const OgreBites::MouseButtonEvent &evt) +{ + ImGuiIO &io = ImGui::GetIO(); + if (io.WantCaptureMouse) + return false; + m_cameraMan->mouseReleased(evt); + return true; +} +bool DemoApp::mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) +{ + ImGuiIO &io = ImGui::GetIO(); + if (io.WantCaptureMouse) + return false; + m_cameraMan->mouseWheelRolled(evt); + return true; +} + +/* ========================================================================= + * Geometry rebuild + * ========================================================================= */ + +void DemoApp::rebuildWedgeGeometry() +{ + /* Build graph: 3 nodes, 2 edges. */ + RoadGraph graph; + graph.config.laneWidth = m_laneWidth; + graph.config.lanesPerDirection = m_lanesPerDirection; + graph.config.roadThickness = m_roadThickness; + graph.config.roadMaterialName = "WedgeDebug"; + + Ogre::Vector3 posA = m_pointA; + Ogre::Vector3 posB = m_pointB; + Ogre::Vector3 posC = m_pointC; + posA.y = 0.0f; + posB.y = 0.0f; + posC.y = 0.0f; + + int idA = graph.addNode(posA, 0.0f); + int idB = graph.addNode(posB, 0.0f); + int idC = graph.addNode(posC, 0.0f); + graph.addEdge(idA, idB); + graph.addEdge(idB, idC); + + /* Enumerate wedges and find the interior one at node B. */ + std::vector wedges; + std::vector segments; + enumerateWedges(graph, wedges, segments); + + /* Collect both wedges at node B. With two incident edges there + * are exactly two wedges: one sweeps the smaller angle (typically + * the road interior for bends ≤ 180°) and the other sweeps the + * larger complement. */ + RoadWedge *wedgeSmall = nullptr, *wedgeLarge = nullptr; + for (auto &w : wedges) { + if (w.nodeId != idB || w.degenerate) + continue; + if (!wedgeSmall || w.sweptAngleDeg < wedgeSmall->sweptAngleDeg) + wedgeSmall = &w; + if (!wedgeLarge || w.sweptAngleDeg > wedgeLarge->sweptAngleDeg) + wedgeLarge = &w; + } + + /* Generate geometry for selected wedge mode. */ + Procedural::TriangleBuffer tb; + bool ok = false; + + auto buildWedge = [&](const RoadWedge &w, + Procedural::TriangleBuffer &buf) -> bool { + Procedural::TriangleBuffer tmp; + if (!RoadGeometryLib::buildWedgeGeometry(w, graph, tmp)) + return false; + int base = (int)buf.getVertices().size(); + for (const auto &v : tmp.getVertices()) { + buf.getVertices().push_back(v); + } + for (int idx : tmp.getIndices()) + buf.getIndices().push_back(base + idx); + return true; + }; + + if (m_wedgeMode == 0 && wedgeSmall) + ok = buildWedge(*wedgeSmall, tb); + else if (m_wedgeMode == 1 && wedgeLarge) + ok = buildWedge(*wedgeLarge, tb); + else if (m_wedgeMode == 2) { + if (wedgeSmall) + ok |= buildWedge(*wedgeSmall, tb); + if (wedgeLarge) + ok |= buildWedge(*wedgeLarge, tb); + } + + /* ---- Visual aids ---- */ + m_visualAids->clear(); + m_visualAids->begin("BaseWhiteNoLighting", + Ogre::RenderOperation::OT_LINE_LIST); + + Ogre::Vector3 midAB = (posA + posB) * 0.5f; + Ogre::Vector3 midBC = (posB + posC) * 0.5f; + + auto addBox = [&](const Ogre::Vector3 &c, float half, + const Ogre::ColourValue &col) { + for (int axis = 0; axis < 3; ++axis) { + int a1 = (axis + 1) % 3; + int a2 = (axis + 2) % 3; + for (int s1 = -1; s1 <= 1; s1 += 2) + for (int s2 = -1; s2 <= 1; s2 += 2) { + Ogre::Vector3 p = c; + p[a1] += s1 * half; + p[a2] += s2 * half; + Ogre::Vector3 q = p; + q[axis] += 2.0f * half; + m_visualAids->position(p); + m_visualAids->colour(col); + m_visualAids->position(q); + m_visualAids->colour(col); + } + } + }; + + addBox(posA, 0.2f, Ogre::ColourValue(0, 1, 0)); + addBox(posB, 0.3f, Ogre::ColourValue(1, 0.5f, 0)); + addBox(posC, 0.2f, Ogre::ColourValue(1, 0, 0)); + + /* Edge lines. */ + m_visualAids->colour(Ogre::ColourValue(0, 1, 1)); + m_visualAids->position(posA); + m_visualAids->position(posB); + m_visualAids->position(posB); + m_visualAids->position(posC); + + /* Midpoint crosses. */ + auto addCross = [&](const Ogre::Vector3 &p, float sz, + const Ogre::ColourValue &col) { + m_visualAids->colour(col); + m_visualAids->position(p + Ogre::Vector3(-sz, 0, 0)); + m_visualAids->position(p + Ogre::Vector3(sz, 0, 0)); + m_visualAids->position(p + Ogre::Vector3(0, 0, -sz)); + m_visualAids->position(p + Ogre::Vector3(0, 0, sz)); + }; + addCross(midAB, 0.5f, Ogre::ColourValue(0.5f, 1, 0.5f)); + addCross(midBC, 0.5f, Ogre::ColourValue(1, 0.5f, 0.5f)); + + /* Lines from B to midpoints. */ + m_visualAids->colour(Ogre::ColourValue(1, 1, 0, 0.5f)); + m_visualAids->position(posB); + m_visualAids->position(midAB); + m_visualAids->position(posB); + m_visualAids->position(midBC); + + m_visualAids->end(); + + /* ---- Wedge mesh ---- */ + m_wedgeTriangles->clear(); + m_wedgeWireframe->clear(); + + if (ok && !tb.getVertices().empty()) { + m_wedgeTriangles->setVisible(true); + m_wedgeWireframe->setVisible(true); + + m_wedgeTriangles->begin( + "BaseWhiteNoLighting", + Ogre::RenderOperation::OT_TRIANGLE_LIST); + for (const auto &v : tb.getVertices()) { + m_wedgeTriangles->position(v.mPosition); + m_wedgeTriangles->normal(v.mNormal); + m_wedgeTriangles->colour( + Ogre::ColourValue(1, 0.3f, 0.3f, 0.6f)); + } + for (int idx : tb.getIndices()) + m_wedgeTriangles->index(idx); + m_wedgeTriangles->end(); + + m_wedgeWireframe->begin( + "BaseWhiteNoLighting", + Ogre::RenderOperation::OT_LINE_LIST); + for (size_t t = 0; t + 2 < tb.getIndices().size(); t += 3) { + int i0 = tb.getIndices()[t]; + int i1 = tb.getIndices()[t + 1]; + int i2 = tb.getIndices()[t + 2]; + const auto &a = tb.getVertices()[i0].mPosition; + const auto &b = tb.getVertices()[i1].mPosition; + const auto &c = tb.getVertices()[i2].mPosition; + Ogre::ColourValue wc(1, 1, 0, 0.9f); + m_wedgeWireframe->position(a); + m_wedgeWireframe->colour(wc); + m_wedgeWireframe->position(b); + m_wedgeWireframe->colour(wc); + m_wedgeWireframe->position(b); + m_wedgeWireframe->colour(wc); + m_wedgeWireframe->position(c); + m_wedgeWireframe->colour(wc); + m_wedgeWireframe->position(c); + m_wedgeWireframe->colour(wc); + m_wedgeWireframe->position(a); + m_wedgeWireframe->colour(wc); + } + m_wedgeWireframe->end(); + } else { + m_wedgeTriangles->setVisible(false); + m_wedgeWireframe->setVisible(false); + } +} + +/* ========================================================================= + * ImGui panel + * ========================================================================= */ + +void DemoApp::renderImGui() +{ + ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(420, 520), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Wedge Geometry Debug", nullptr, 0)) { + ImGui::End(); + return; + } + + ImGui::Text("Wedge: midpoint AB -> B -> midpoint BC"); + ImGui::Separator(); + + bool changed = false; + + ImGui::TextColored(ImVec4(0, 1, 0, 1), "Point A (green)"); + changed |= ImGui::SliderFloat("A.x##A", &m_pointA.x, -20.0f, 20.0f); + changed |= ImGui::SliderFloat("A.z##A", &m_pointA.z, -20.0f, 20.0f); + + ImGui::Spacing(); + + ImGui::TextColored(ImVec4(1, 0.5f, 0, 1), "Point B (orange, seed)"); + changed |= ImGui::SliderFloat("B.x##B", &m_pointB.x, -20.0f, 20.0f); + changed |= ImGui::SliderFloat("B.z##B", &m_pointB.z, -20.0f, 20.0f); + + ImGui::Spacing(); + + ImGui::TextColored(ImVec4(1, 0, 0, 1), "Point C (red)"); + changed |= ImGui::SliderFloat("C.x##C", &m_pointC.x, -20.0f, 20.0f); + changed |= ImGui::SliderFloat("C.z##C", &m_pointC.z, -20.0f, 20.0f); + + ImGui::Separator(); + ImGui::Text("Road Configuration"); + changed |= + ImGui::SliderFloat("Lane Width", &m_laneWidth, 1.0f, 10.0f); + changed |= ImGui::SliderInt("Lanes per Direction", + &m_lanesPerDirection, 1, 4); + changed |= ImGui::SliderFloat("Road Thickness", &m_roadThickness, + 0.05f, 2.0f); + + ImGui::Separator(); + + /* Derived info. */ + Ogre::Vector3 dirAB = m_pointB - m_pointA; + dirAB.y = 0; + float lenAB = dirAB.length(); + Ogre::Vector3 dirBC = m_pointC - m_pointB; + dirBC.y = 0; + float lenBC = dirBC.length(); + + if (lenAB > 0.001f && lenBC > 0.001f) { + dirAB.normalise(); + dirBC.normalise(); + float dot = dirAB.dotProduct(dirBC); + float interiorDeg = + std::acos(std::max(-1.0f, std::min(1.0f, dot))) * + (180.0f / M_PI); + float exteriorDeg = 360.0f - interiorDeg; + + ImGui::Text("Edge A-B length: %.2f", lenAB); + ImGui::Text("Edge B-C length: %.2f", lenBC); + ImGui::Text("Interior angle B: %.1f deg", interiorDeg); + + ImGui::Spacing(); + ImGui::Text("Wedge to display:"); + int prevMode = m_wedgeMode; + ImGui::RadioButton("Smaller", &m_wedgeMode, 0); + ImGui::SameLine(); + float smallDeg = (interiorDeg <= 180.0f) ? interiorDeg : exteriorDeg; + ImGui::TextDisabled("~%.1f deg", smallDeg); + ImGui::RadioButton("Larger", &m_wedgeMode, 1); + ImGui::SameLine(); + float largeDeg = (interiorDeg > 180.0f) ? interiorDeg : exteriorDeg; + ImGui::TextDisabled("~%.1f deg", largeDeg); + ImGui::RadioButton("Both", &m_wedgeMode, 2); + if (m_wedgeMode != prevMode) + changed = true; + } + + ImGui::Spacing(); + ImGui::Text("Camera: Right-drag to orbit, Wheel to zoom"); + ImGui::Text("Press ESC to exit"); + + ImGui::End(); + + if (changed) + m_dirty = true; +} + +/* ========================================================================= + * main + * ========================================================================= */ + +int main(int argc, char **argv) +{ + (void)argc; + (void)argv; + + DemoApp app; + app.initApp(); + app.getRoot()->startRendering(); + app.closeApp(); + + return 0; +} diff --git a/src/features/editScene/roadlib/RoadGeometryLib.cpp b/src/features/editScene/roadlib/RoadGeometryLib.cpp new file mode 100644 index 0000000..bfe2e1a --- /dev/null +++ b/src/features/editScene/roadlib/RoadGeometryLib.cpp @@ -0,0 +1,643 @@ +/* + * RoadGeometryLib — implementation of road wedge/segment geometry. + * + * Extracted from RoadSystem.cpp; no dependency on Flecs, TerrainSystem + * or any ECS components. Only Ogre, OgreProcedural, and RoadGraph.hpp. + */ + +#include "RoadGeometryLib.hpp" + +#include +#include +#include +#include +#include +#include + +namespace RoadGeometryLib { + +const float SEAM_OVERLAP = 0.05f; + +/* ---------------------------------------------------------------- + * Utility helpers + * ---------------------------------------------------------------- */ + +Ogre::Vector3 roadRightVec(const Ogre::Vector3 &d) +{ + return d.crossProduct(Ogre::Vector3::UNIT_Y); +} + +static void halfEdgeHeights(const RoadHalfEdge &he, const RoadGraph &graph, + float &yNode, float &yMid) +{ + const RoadNode *node = graph.findNodeById(he.nodeId); + const RoadNode *neighbor = graph.findNodeById(he.neighborId); + float nodeY = node ? node->position.y : 0.0f; + float neighborY = neighbor ? neighbor->position.y : nodeY; + + yNode = nodeY + he.roadLevelAtNode; + yMid = 0.5f * (yNode + neighborY + he.roadLevelAtNeighbor); +} + +float halfEdgeHeightAt(const RoadHalfEdge &he, const RoadGraph &graph, float t) +{ + float yNode, yMid; + halfEdgeHeights(he, graph, yNode, yMid); + float l = he.halfLength > 1e-4f ? he.halfLength : 1e-4f; + return yNode + (yMid - yNode) * (t / l); +} + +float halfEdgeU(const RoadHalfEdge &he, const RoadGraph &graph, float t) +{ + if (he.edgeIndex >= 0 && he.edgeIndex < (int)graph.edges.size() && + graph.edges[he.edgeIndex].nodeB == he.nodeId) + return 2.0f * he.halfLength - t; + return t; +} + +float nodeRoadLevel(const RoadGraph &graph, int nodeId) +{ + float sum = 0.0f; + int count = 0; + for (const RoadEdge &e : graph.edges) { + if (e.nodeA == nodeId) { + sum += e.roadLevelA; + ++count; + } else if (e.nodeB == nodeId) { + sum += e.roadLevelB; + ++count; + } + } + return count > 0 ? sum / (float)count : 0.0f; +} + +void emitTri(Procedural::TriangleBuffer &out, + const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1, + const Ogre::Vector3 &p2, + const Ogre::Vector2 &uv0, + const Ogre::Vector2 &uv1, + const Ogre::Vector2 &uv2) +{ + Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); + if (n.squaredLength() < 1e-10f) + return; + n.normalise(); + + int base = (int)out.getVertices().size(); + const Ogre::Vector3 *pp[3] = { &p0, &p1, &p2 }; + const Ogre::Vector2 *uu[3] = { &uv0, &uv1, &uv2 }; + for (int i = 0; i < 3; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = *pp[i]; + v.mNormal = n; + v.mUV = *uu[i]; + out.getVertices().push_back(v); + out.getIndices().push_back(base + i); + } +} + +/* ---------------------------------------------------------------- + * Template helpers + * ---------------------------------------------------------------- */ + +Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness) +{ + float h = std::max(0.01f, roadThickness) * 0.5f; + + Procedural::TriangleBuffer tb; + auto &verts = tb.getVertices(); + auto &indices = tb.getIndices(); + verts.reserve(24); + indices.reserve(36); + + struct Corner { + Ogre::Vector3 p; + Ogre::Vector2 uv; + }; + + auto addFace = [&](const Corner &a, const Corner &b, const Corner &c, + const Corner &d, const Ogre::Vector3 &normal) { + int base = (int)verts.size(); + for (const Corner *q : { &a, &b, &c, &d }) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = q->p; + v.mNormal = normal; + v.mUV = q->uv; + verts.push_back(v); + } + indices.push_back(base + 0); + indices.push_back(base + 1); + indices.push_back(base + 2); + indices.push_back(base + 0); + indices.push_back(base + 2); + indices.push_back(base + 3); + }; + + /* Top (+Y): X in [0,1], Z in [-1,0]. */ + addFace({ { 0, h, 0 }, { 0, 0 } }, { { 1, h, 0 }, { 0, 1 } }, + { { 1, h, -1 }, { 1, 1 } }, { { 0, h, -1 }, { 1, 0 } }, + Ogre::Vector3::UNIT_Y); + /* Bottom (-Y). */ + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 0, -h, -1 }, { 1, 0 } }, + { { 1, -h, -1 }, { 1, 1 } }, { { 1, -h, 0 }, { 0, 1 } }, + Ogre::Vector3::NEGATIVE_UNIT_Y); + /* Start cap (+Z, z = 0; dropped from wedge strips). */ + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 1, -h, 0 }, { 0, 1 } }, + { { 1, h, 0 }, { 0, 1 } }, { { 0, h, 0 }, { 0, 0 } }, + Ogre::Vector3::UNIT_Z); + /* End cap (-Z, z = -1; dropped from wedge strips). */ + addFace({ { 1, -h, -1 }, { 1, 1 } }, { { 0, -h, -1 }, { 1, 0 } }, + { { 0, h, -1 }, { 1, 0 } }, { { 1, h, -1 }, { 1, 1 } }, + Ogre::Vector3::NEGATIVE_UNIT_Z); + /* Outer curb wall (+X). */ + addFace({ { 1, -h, 0 }, { 0, 1 } }, { { 1, -h, -1 }, { 1, 1 } }, + { { 1, h, -1 }, { 1, 1 } }, { { 1, h, 0 }, { 0, 1 } }, + Ogre::Vector3::UNIT_X); + /* Centerline wall (-X; dropped from wedge strips). */ + addFace({ { 0, -h, -1 }, { 1, 0 } }, { { 0, -h, 0 }, { 0, 0 } }, + { { 0, h, 0 }, { 0, 0 } }, { { 0, h, -1 }, { 1, 0 } }, + Ogre::Vector3::NEGATIVE_UNIT_X); + + return tb; +} + +/* ---------------------------------------------------------------- + * Phase 1 — Concatenated Strip + * ---------------------------------------------------------------- */ + +void buildConcatenatedStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + int N) +{ + out.getVertices().clear(); + out.getIndices().clear(); + for (int i = 0; i < N; ++i) { + int base = (int)out.getVertices().size(); + for (const auto &v : templ.getVertices()) { + Procedural::TriangleBuffer::Vertex cv = v; + cv.mPosition.z -= (float)i; + out.getVertices().push_back(cv); + } + for (int idx : templ.getIndices()) + out.getIndices().push_back(base + idx); + } +} + +/** + * Append one template copy shifted to z -= @p zOff, clamping the path + * distance of every vertex to @p clampD. + * + * Template faces lying fully in a copy-boundary Z plane are dropped: + * those are the template caps, which would otherwise stack coplanar + * faces at every copy join and at the edge midpoints (z-fighting). + * The centerline wall (template X=0) is dropped as well — it is + * interior to the joined road body. + */ +static void appendTemplateCopy(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + float zOff, float clampD) +{ + const auto &tverts = templ.getVertices(); + const auto &tidx = templ.getIndices(); + + int base = (int)out.getVertices().size(); + for (const auto &v : tverts) { + Procedural::TriangleBuffer::Vertex cv = v; + cv.mPosition.z -= zOff; + if (-cv.mPosition.z > clampD) + cv.mPosition.z = -clampD; + out.getVertices().push_back(cv); + } + + for (size_t t = 0; t + 2 < tidx.size(); t += 3) { + const Ogre::Vector3 &a = tverts[(size_t)tidx[t]].mPosition; + const Ogre::Vector3 &b = tverts[(size_t)tidx[t + 1]].mPosition; + const Ogre::Vector3 &c = tverts[(size_t)tidx[t + 2]].mPosition; + + bool cap0 = std::fabs(a.z) < 1e-6f && + std::fabs(b.z) < 1e-6f && + std::fabs(c.z) < 1e-6f; + bool cap1 = std::fabs(a.z + 1.0f) < 1e-6f && + std::fabs(b.z + 1.0f) < 1e-6f && + std::fabs(c.z + 1.0f) < 1e-6f; + if (cap0 || cap1) + continue; + + bool wall0 = std::fabs(a.x) < 1e-6f && + std::fabs(b.x) < 1e-6f && + std::fabs(c.x) < 1e-6f; + const Ogre::Vector3 &n = tverts[(size_t)tidx[t]].mNormal; + if (wall0 && std::fabs(n.x) > 0.9f) + continue; + + out.getIndices().push_back(base + tidx[t]); + out.getIndices().push_back(base + tidx[t + 1]); + out.getIndices().push_back(base + tidx[t + 2]); + } +} + +/** + * Two-run concatenated strip for one wedge. + * + * Run 1 covers d in [0, L1] with ceil(L1) uniform copies from d = 0; + * run 2 covers [L1, L1+L2] with ceil(L2) copies from d = L1. Vertices + * past each run's end are clamped onto it, so the miter corner at d=L1 + * is always sampled. + */ +static void buildWedgeStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + float L1, float L2) +{ + out.getVertices().clear(); + out.getIndices().clear(); + int k1 = std::max(1, (int)std::ceil(L1)); + int k2 = std::max(1, (int)std::ceil(L2)); + for (int i = 0; i < k1; ++i) + appendTemplateCopy(out, templ, (float)i, L1); + for (int j = 0; j < k2; ++j) + appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2); +} + +/* ---------------------------------------------------------------- + * Phase 2 — Vertex Transformation + * ---------------------------------------------------------------- */ + +Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d) +{ + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return Ogre::Vector3::ZERO; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + Ogre::Vector3 dir1 = h1.direction; + Ogre::Vector3 dir2 = h2.direction; + Ogre::Vector3 r1 = roadRightVec(dir1); + Ogre::Vector3 r2 = roadRightVec(dir2); + float lw = graph.config.laneWidth; + float L1 = h1.halfLength; + float L2 = h2.halfLength; + + Ogre::Vector3 offA = r1 * (h1.lanesOut * lw); + Ogre::Vector3 offB = r2 * (-h2.lanesIn * lw); + + /* Blend zone width. */ + float W = std::min(SEAM_OVERLAP * 4.0f, + std::min(L1 * 0.5f, L2 * 0.5f)); + if (L1 < SEAM_OVERLAP || L2 < SEAM_OVERLAP) + W = 0.0f; + + /* Miter corner: intersection of the two constant-width curb lines. */ + bool hasCorner = false; + Ogre::Vector3 cornerOff; + float det = dir1.z * dir2.x - dir1.x * dir2.z; + if (std::fabs(det) >= 0.05f) { + Ogre::Vector3 rhs = offB - offA; + float t1x = (-rhs.x * dir2.z + dir2.x * rhs.z) / det; + cornerOff = offA + dir1 * t1x; + hasCorner = true; + } + + if (W <= 0.0f) { + if (d < L1) + return offA; + if (d > L1) + return offB; + return hasCorner ? cornerOff : (offA + offB) * 0.5f; + } + + if (d <= L1 - W) + return offA; + if (d >= L1 + W) + return offB; + if (!hasCorner) { + float t = (d - (L1 - W)) / (2.0f * W); + return offA + (offB - offA) * t; + } + if (d <= L1) { + float t = (d - (L1 - W)) / W; + return offA + (cornerOff - offA) * t; + } + float t = (d - L1) / W; + return cornerOff + (offB - cornerOff) * t; +} + +void transformWedgeVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph) +{ + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 dir1 = h1.direction; + Ogre::Vector3 dir2 = h2.direction; + float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f; + float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f; + float L = L1 + L2; + float in1 = h1.lanesIn * graph.config.laneWidth; + + Ogre::Vector3 MA = O + dir1 * L1; + Ogre::Vector3 MB = O + dir2 * L2; + float yO = O.y + nodeRoadLevel(graph, wedge.nodeId); + + for (auto &v : strip.getVertices()) { + float d = -v.mPosition.z; + if (d < 0.0f) + d = 0.0f; + if (d > L) + d = L; + + /* Centerline (polyline MA -> O -> MB). */ + Ogre::Vector3 center; + if (d <= L1) + center = MA + (O - MA) * (d / L1); + else + center = O + (MB - O) * ((d - L1) / L2); + + /* World position from curb offset. */ + Ogre::Vector3 off = computeCurbOffset(wedge, graph, d); + Ogre::Vector3 worldXZ = center + off * v.mPosition.x; + + /* Surface height. */ + 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; + float worldY = surfY + v.mPosition.y; + + /* 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; + + /* Normal rotation. */ + const Ogre::Vector3 &segDir = (d <= L1) ? dir1 : dir2; + float theta = std::atan2(-segDir.x, -segDir.z); + Ogre::Quaternion q(Ogre::Radian(theta), + Ogre::Vector3::UNIT_Y); + Ogre::Vector3 n = q * v.mNormal; + + v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z); + v.mNormal = n; + } +} + +/* ---------------------------------------------------------------- + * Phase 3 — Center Seam Shifting + * ---------------------------------------------------------------- */ + +void shiftSeamVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph) +{ + std::vector nids = graph.getNeighborIds(wedge.nodeId); + if (nids.size() <= 2) + return; + + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return; + const Ogre::Vector3 &O = node->position; + + for (auto &v : strip.getVertices()) { + Ogre::Vector3 toNode(v.mPosition.x - O.x, 0, + v.mPosition.z - O.z); + float distToNode = toNode.length(); + if (distToNode >= SEAM_OVERLAP) + continue; + + Ogre::Vector3 radial = toNode.normalisedCopy(); + if (radial.isZeroLength()) + continue; + + float push = SEAM_OVERLAP - distToNode + SEAM_OVERLAP; + v.mPosition.x += radial.x * push; + v.mPosition.z += radial.z * push; + } +} + +/* ---------------------------------------------------------------- + * Slab extrusion + * ---------------------------------------------------------------- */ + +void extrudeToSlab(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer ¢erSurf, + float roadThickness, + const SkirtFilter &skirtFilter) +{ + float halfThick = std::max(0.01f, roadThickness) * 0.5f; + Ogre::Vector3 up(0.0f, halfThick, 0.0f); + + const auto &verts = centerSurf.getVertices(); + const auto &indices = centerSurf.getIndices(); + + Ogre::Vector3 refPoint = Ogre::Vector3::ZERO; + for (const auto &v : verts) + refPoint += v.mPosition; + if (!verts.empty()) + refPoint /= (float)verts.size(); + + /* Top and bottom. */ + for (size_t t = 0; t + 2 < indices.size(); t += 3) { + const auto &v0 = verts[(size_t)indices[t]]; + const auto &v1 = verts[(size_t)indices[t + 1]]; + const auto &v2 = verts[(size_t)indices[t + 2]]; + Ogre::Vector3 n = (v1.mPosition - v0.mPosition) + .crossProduct(v2.mPosition - + v0.mPosition); + if (n.squaredLength() < 1e-10f) + continue; + int i1 = n.y >= 0.0f ? 1 : 2; + int i2 = n.y >= 0.0f ? 2 : 1; + const Procedural::TriangleBuffer::Vertex *vv[3] = { &v0, &v1, + &v2 }; + emitTri(out, vv[0]->mPosition + up, + vv[i1]->mPosition + up, + vv[i2]->mPosition + up, + vv[0]->mUV, vv[i1]->mUV, + vv[i2]->mUV); + emitTri(out, vv[0]->mPosition - up, + vv[i2]->mPosition - up, + vv[i1]->mPosition - up, + vv[0]->mUV, vv[i2]->mUV, + vv[i1]->mUV); + } + + /* Boundary-edge detection by index counting. */ + struct EdgeUse { + int count = 0; + int a = 0, b = 0; + }; + std::map, EdgeUse> edgeUse; + for (size_t t = 0; t + 2 < indices.size(); t += 3) { + int tri[3] = { indices[t], indices[t + 1], indices[t + 2] }; + for (int e = 0; e < 3; ++e) { + int a = tri[e], b = tri[(e + 1) % 3]; + auto &eu = edgeUse[std::minmax(a, b)]; + if (eu.count == 0) { + eu.a = a; + eu.b = b; + } + ++eu.count; + } + } + + float thickness = 2.0f * halfThick; + for (const auto &kv : edgeUse) { + const EdgeUse &eu = kv.second; + if (eu.count != 1) + continue; + const auto &v0 = verts[(size_t)eu.a]; + const auto &v1 = verts[(size_t)eu.b]; + if (skirtFilter && + !skirtFilter(v0.mPosition, v1.mPosition)) + continue; + + Ogre::Vector3 t0 = v0.mPosition + up; + Ogre::Vector3 t1 = v1.mPosition + up; + Ogre::Vector3 b0 = v0.mPosition - up; + Ogre::Vector3 b1 = v1.mPosition - up; + Ogre::Vector2 uvB0(v0.mUV.x, v0.mUV.y - thickness); + Ogre::Vector2 uvB1(v1.mUV.x, v1.mUV.y - thickness); + + Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0); + if (n.squaredLength() < 1e-10f) + continue; + Ogre::Vector3 mid = (t0 + t1 + b0 + b1) * 0.25f; + bool outward = n.dotProduct(mid - refPoint) >= 0.0f; + if (outward) { + emitTri(out, t0, t1, b1, v0.mUV, v1.mUV, uvB1); + emitTri(out, t0, b1, b0, v0.mUV, uvB1, uvB0); + } else { + emitTri(out, t0, b1, t1, v0.mUV, uvB1, v1.mUV); + emitTri(out, t0, b0, b1, v0.mUV, uvB0, uvB1); + } + } +} + +/* ---------------------------------------------------------------- + * Entry points — wedge and segment geometry + * ---------------------------------------------------------------- */ + +bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + Procedural::TriangleBuffer fb = + makeFallbackTemplate(graph.config.roadThickness); + return buildWedgeGeometry(wedge, graph, fb, out); +} + +bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, + Procedural::TriangleBuffer &out) +{ + if (wedge.degenerate) { + Ogre::LogManager::getSingleton().logMessage( + "RoadGeometryLib: skipping degenerate wedge at node " + + std::to_string(wedge.nodeId)); + return false; + } + + /* Phase 1. */ + Procedural::TriangleBuffer strip; + buildWedgeStrip(strip, templ, wedge.first.halfLength, + wedge.second.halfLength); + + /* Phase 2. */ + transformWedgeVertices(strip, wedge, graph); + + /* Phase 3. */ + shiftSeamVertices(strip, wedge, graph); + + /* Slab extrusion. */ + extrudeToSlab(out, strip, graph.config.roadThickness); + return true; +} + +bool computeSegmentBand(const RoadStraightSegment &segment, + const RoadGraph &graph, + Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]) +{ + const RoadNode *node = graph.findNodeById(segment.nodeId); + if (!node) + return false; + + const RoadHalfEdge &he = segment.halfEdge; + if (he.lanesIn + he.lanesOut < 1) + return false; + + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 d = he.direction; + Ogre::Vector3 r = roadRightVec(d); + float lw = graph.config.laneWidth; + float inW = he.lanesIn * lw; + float outW = he.lanesOut * lw; + float L = he.halfLength; + float t0 = -SEAM_OVERLAP; + + c[0] = O + t0 * d - inW * r; + c[1] = O + L * d - inW * r; + c[2] = O + L * d + outW * r; + c[3] = O + t0 * d + outW * r; + float y0 = halfEdgeHeightAt(he, graph, t0); + float yL = halfEdgeHeightAt(he, graph, L); + 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); + return true; +} + +bool buildSegmentGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + Ogre::Vector3 c[4]; + Ogre::Vector2 uvc[4]; + if (!computeSegmentBand(segment, graph, c, uvc)) + return false; + + Procedural::TriangleBuffer centerSurf; + int base = (int)centerSurf.getVertices().size(); + for (int i = 0; i < 4; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = c[i]; + v.mNormal = Ogre::Vector3::UNIT_Y; + v.mUV = uvc[i]; + centerSurf.getVertices().push_back(v); + } + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 1); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 3); + + auto skirtFilter = [&](const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1) -> bool { + float d1 = p0.distance(c[1]) + p1.distance(c[2]); + float d2 = p0.distance(c[2]) + p1.distance(c[1]); + return (d1 > 0.001f && d2 > 0.001f); + }; + extrudeToSlab(out, centerSurf, graph.config.roadThickness, + skirtFilter); + return true; +} + +} // namespace RoadGeometryLib diff --git a/src/features/editScene/roadlib/RoadGeometryLib.hpp b/src/features/editScene/roadlib/RoadGeometryLib.hpp new file mode 100644 index 0000000..a2cb10f --- /dev/null +++ b/src/features/editScene/roadlib/RoadGeometryLib.hpp @@ -0,0 +1,147 @@ +/* + * RoadGeometryLib — standalone road wedge/segment geometry generation. + * + * This library contains the pure geometry functions extracted from + * RoadSystem.cpp. It has no dependency on Flecs, TerrainSystem, Jolt, + * or any ECS component. Only Ogre (Vector3/Quaternion), OgreProcedural + * (Procedural::TriangleBuffer), and RoadGraph.hpp are needed. + * + * All functions are declared in namespace RoadGeometryLib. + */ + +#ifndef ROAD_GEOMETRY_LIB_HPP +#define ROAD_GEOMETRY_LIB_HPP + +#include +#include + +#include "../components/RoadGraph.hpp" + +namespace RoadGeometryLib { + +/* ---------------------------------------------------------------- + * Public entry points + * ---------------------------------------------------------------- */ + +/** + * Build the world-space road slab for one wedge and append to @p out. + * + * The wedge piece is built by the three-phase pipeline described in + * ProceduralRoadGeometry.md: a strip of concatenated template copies + * is bent along the wedge's 2-segment centerline polyline with + * continuous curb offset through the miter corner, then extruded + * into a solid slab. + * + * @return false when the wedge is degenerate and nothing was emitted. + */ +bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); + +bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, + Procedural::TriangleBuffer &out); + +/** + * Build the world-space road slab for a straight segment (dead-end + * node) and append to @p out. + */ +bool buildSegmentGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); + +/* ---------------------------------------------------------------- + * Pipeline phases (exposed for testing) + * ---------------------------------------------------------------- */ + +/** Phase 1: concatenate N template copies into a straight strip along -Z. */ +void buildConcatenatedStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + int N); + +/** Phase 2: bend the strip into wedge shape, in place. */ +void transformWedgeVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + +/** Phase 3: shift centerline vertices past the node for overlap. */ +void shiftSeamVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + +/** + * Compute the curb offset vector at path distance @p d. + * + * The vector points from the centerline to the outer curb; it varies + * continuously through the node so no gaps open at the corner. + */ +Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d); + +/** + * Turn a center-surface triangle set into a closed solid slab. + * + * Adds top/bottom faces (at +/- roadThickness/2) plus vertical skirts + * on boundary edges. @p skirtFilter can exclude specific edges. + */ +using SkirtFilter = std::function; + +void extrudeToSlab(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer ¢erSurf, + float roadThickness, + const SkirtFilter &skirtFilter = nullptr); + +/* ---------------------------------------------------------------- + * Template mesh helpers + * ---------------------------------------------------------------- */ + +/** + * Create a unit-box fallback template in template space: + * X in [0,1] lateral, Y in [-thick/2, +thick/2], Z in [-1, 0]. + */ +Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness); + +/* ---------------------------------------------------------------- + * Utility helpers + * ---------------------------------------------------------------- */ + +/** Right-of-travel vector for a horizontal direction. */ +Ogre::Vector3 roadRightVec(const Ogre::Vector3 &d); + +/** Absolute road surface Y at distance @p t from the seed node. */ +float halfEdgeHeightAt(const RoadHalfEdge &he, const RoadGraph &graph, + float t); + +/** Phase-continuous along-road UV coordinate. */ +float halfEdgeU(const RoadHalfEdge &he, const RoadGraph &graph, + float t); + +/** Shared road level at a node (mean of incident edge levels). */ +float nodeRoadLevel(const RoadGraph &graph, int nodeId); + +/** Append one triangle; degenerate (zero-area) triangles are skipped. */ +void emitTri(Procedural::TriangleBuffer &out, + const Ogre::Vector3 &p0, const Ogre::Vector3 &p1, + const Ogre::Vector3 &p2, + const Ogre::Vector2 &uv0, const Ogre::Vector2 &uv1, + const Ogre::Vector2 &uv2); + +/** Seam overlap constant (0.05 units). */ +extern const float SEAM_OVERLAP; + +/** + * Compute the four corners of a segment's center-surface band. + * + * c[0..3] are the band corners; uvc[0..3] their UVs. + * @return false if the segment is invalid. + */ +bool computeSegmentBand(const RoadStraightSegment &segment, + const RoadGraph &graph, + Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]); + +} // namespace RoadGeometryLib + +#endif // ROAD_GEOMETRY_LIB_HPP diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 6a3e5fb..99a9b9b 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -461,9 +461,9 @@ void RoadSystem::buildPageMeshes(RoadPageGeometry &pg) auto buffer = std::make_shared(); for (const RoadWedge &w : pg.wedges) - buildWedgeGeometry(w, rg, getRoadTemplate(rg.config), *buffer); + buildWedgeGeometry(w, rg, *buffer); for (const RoadStraightSegment &s : pg.segments) - buildSegmentGeometry(s, rg, getRoadTemplate(rg.config), *buffer); + buildSegmentGeometry(s, rg, *buffer); if (buffer->getIndices().empty()) { /* No road content seeded on this page. */ @@ -1047,23 +1047,32 @@ bool RoadSystem::loadTemplateFromMesh(const std::string &meshName) if (tb.getVertices().empty() || tb.getIndices().empty()) return false; - /* Convention check: X within [0,1] and roughly 1 unit long, - * Z within [-1,1] and roughly 1 unit wide. Violations are warned - * about but the mesh is still used as-is. */ + /* + * Normalize into template space (ProceduralRoadGeometry.md + * section 2): X in [0, span] with X=0 at the centerline side, + * Z in [-span, 0] with 0 at the wedge start. A mesh spanning + * roughly 1 unit on both axes is conforming; violations are + * warned about but the mesh is still used as-is. + */ Ogre::Vector3 mn = tb.getVertices()[0].mPosition; Ogre::Vector3 mx = mn; for (const auto &v : tb.getVertices()) { mn.makeFloor(v.mPosition); mx.makeCeil(v.mPosition); } - if (mn.x < -0.001f || mx.x > 1.001f || (mx.x - mn.x) < 0.5f || - mn.z < -1.001f || mx.z > 1.001f || (mx.z - mn.z) < 0.5f) { + Ogre::Vector3 span = mx - mn; + for (auto &v : tb.getVertices()) { + v.mPosition.x -= mn.x; + v.mPosition.z -= mx.z; + } + if (span.x < 0.5f || span.x > 2.0f || span.z < 0.5f || + span.z > 2.0f) { Ogre::LogManager::getSingleton().logMessage( "RoadSystem: road mesh template '" + meshName + "' violates the template conventions " - "(X [0,1], Z [-1,1], unit extents); bounds are (" + - Ogre::StringConverter::toString(mn) + ") .. (" + - Ogre::StringConverter::toString(mx) + + "(X [0,1] lateral, Z [-1,0] longitudinal, unit " + "extents); spans are (" + + Ogre::StringConverter::toString(span) + "), using it anyway"); } @@ -1092,10 +1101,15 @@ RoadSystem::makeFallbackTemplate(float roadThickness) Ogre::Vector2 uv; }; - /* One quad face: 4 vertices, 2 triangles, counter-clockwise - * seen from outside (Ogre front face). Top/bottom faces map UV to - * (x, z); side faces map u along their horizontal extent and v - * along Y so every face spans (0,0)-(1,1). */ + /* + * One quad face: 4 vertices, 2 triangles, counter-clockwise + * seen from outside (Ogre front face). Template space + * (ProceduralRoadGeometry.md section 2): X in [0,1] is lateral + * (X=0 centerline, X=1 outer curb), Z in [-1,0] is longitudinal + * (0 at the wedge start). UVs map u to the longitudinal extent + * (-z) and v to the lateral extent (x) so every face spans + * (0,0)-(1,1); Phase 2 rescales v by the local road width. + */ auto addFace = [&](const Corner &a, const Corner &b, const Corner &c, const Corner &d, const Ogre::Vector3 &normal) { int base = (int)verts.size(); @@ -1114,29 +1128,29 @@ RoadSystem::makeFallbackTemplate(float roadThickness) indices.push_back(base + 3); }; - /* Top (+Y): Z in [0,1], X in [0,1]. */ - addFace({ { 0, h, 0 }, { 0, 0 } }, { { 0, h, 1 }, { 0, 1 } }, - { { 1, h, 1 }, { 1, 1 } }, { { 1, h, 0 }, { 1, 0 } }, + /* Top (+Y): X in [0,1], Z in [-1,0]. */ + addFace({ { 0, h, 0 }, { 0, 0 } }, { { 1, h, 0 }, { 0, 1 } }, + { { 1, h, -1 }, { 1, 1 } }, { { 0, h, -1 }, { 1, 0 } }, Ogre::Vector3::UNIT_Y); /* Bottom (-Y). */ - addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 1, -h, 0 }, { 1, 0 } }, - { { 1, -h, 1 }, { 1, 1 } }, { { 0, -h, 1 }, { 0, 1 } }, + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 0, -h, -1 }, { 1, 0 } }, + { { 1, -h, -1 }, { 1, 1 } }, { { 1, -h, 0 }, { 0, 1 } }, Ogre::Vector3::NEGATIVE_UNIT_Y); - /* Front (+Z). */ - addFace({ { 0, -h, 1 }, { 0, 0 } }, { { 1, -h, 1 }, { 1, 0 } }, - { { 1, h, 1 }, { 1, 1 } }, { { 0, h, 1 }, { 0, 1 } }, + /* Start cap (+Z, z = 0; dropped from wedge strips). */ + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 1, -h, 0 }, { 0, 1 } }, + { { 1, h, 0 }, { 0, 1 } }, { { 0, h, 0 }, { 0, 0 } }, Ogre::Vector3::UNIT_Z); - /* Back (-Z). */ - addFace({ { 1, -h, 0 }, { 0, 0 } }, { { 0, -h, 0 }, { 1, 0 } }, - { { 0, h, 0 }, { 1, 1 } }, { { 1, h, 0 }, { 0, 1 } }, + /* End cap (-Z, z = -1; dropped from wedge strips). */ + addFace({ { 1, -h, -1 }, { 1, 1 } }, { { 0, -h, -1 }, { 1, 0 } }, + { { 0, h, -1 }, { 1, 0 } }, { { 1, h, -1 }, { 1, 1 } }, Ogre::Vector3::NEGATIVE_UNIT_Z); - /* Right (+X). */ - addFace({ { 1, -h, 1 }, { 0, 0 } }, { { 1, -h, 0 }, { 1, 0 } }, - { { 1, h, 0 }, { 1, 1 } }, { { 1, h, 1 }, { 0, 1 } }, + /* Outer curb wall (+X). */ + addFace({ { 1, -h, 0 }, { 0, 1 } }, { { 1, -h, -1 }, { 1, 1 } }, + { { 1, h, -1 }, { 1, 1 } }, { { 1, h, 0 }, { 0, 1 } }, Ogre::Vector3::UNIT_X); - /* Left (-X). */ - addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 0, -h, 1 }, { 1, 0 } }, - { { 0, h, 1 }, { 1, 1 } }, { { 0, h, 0 }, { 0, 1 } }, + /* Centerline wall (-X; dropped from wedge strips). */ + addFace({ { 0, -h, -1 }, { 1, 0 } }, { { 0, -h, 0 }, { 0, 0 } }, + { { 0, h, 0 }, { 0, 0 } }, { { 0, h, -1 }, { 1, 0 } }, Ogre::Vector3::NEGATIVE_UNIT_X); return tb; @@ -1205,214 +1219,472 @@ static float halfEdgeU(const RoadHalfEdge &he, const RoadGraph &graph, return t; } -/** - * Build a straight strip by concatenating road template copies end-to-end - * along the strip's +Z axis and side-by-side along +X for multiple lanes. - * - * Strip local coordinates: - * X in [0, numLanes * laneWidth] — lateral, from outer curb inward - * Z in [0, stripLen] — along sweep (polyline) direction - * Y in [-halfThick, halfThick] — vertical, from template - * - * Template vertex mapping (template → strip): - * Template +X (along road) → strip +Z - * Template +Z (lateral) → strip +X - * - * UVs are set to tile continuously: u = sweep distance, v = lane index + - * template Z, so they are phase-continuous across copies and lanes. - * - * @param tmpl Road template buffer (unit-box). - * @param laneWidth World-space width of one lane. - * @param numLanes Number of lanes placed side-by-side. - * @param stripLen Total length of the strip along Z (world units). - * @param out Receives concatenated vertices and indices. - */ -static void buildTemplateStrip(const Procedural::TriangleBuffer &tmpl, - float laneWidth, int numLanes, - float stripLen, - Procedural::TriangleBuffer &out) -{ - const auto &sv = tmpl.getVertices(); - const auto &si = tmpl.getIndices(); - if (sv.empty() || si.empty()) - return; - - int copies = std::max(1, (int)std::ceil(stripLen)); - - for (int ci = 0; ci < copies; ++ci) { - float segLen = 1.0f; - if (ci == copies - 1 && stripLen > 0.0f) - segLen = stripLen - (float)ci; - if (segLen <= 0.0f) - continue; - - for (int lane = 0; lane < numLanes; ++lane) { - int base = (int)out.getVertices().size(); - float xOff = (float)lane * laneWidth; - - for (const auto &v : sv) { - Procedural::TriangleBuffer::Vertex nv; - nv.mPosition = Ogre::Vector3( - v.mPosition.z * laneWidth + xOff, - v.mPosition.y, - v.mPosition.x * segLen + (float)ci); - nv.mUV = Ogre::Vector2( - v.mPosition.x * segLen + (float)ci, - v.mPosition.z + (float)lane); - nv.mNormal = v.mNormal; - out.getVertices().push_back(nv); - } - for (int idx : si) - out.getIndices().push_back(base + idx); - } - } -} - -/** - * Sweep-deform a template strip along a world-space polyline. - * - * Each vertex at strip position (x, y, z) is mapped to world space: - * 1. Find world point Q on the polyline at distance z. - * 2. Compute horizontal radial r = (Q - O).normalised(). - * 3. worldPos = Q - x * r; worldPos.y += polylineHeight(z) + y. - * - * Strip Z outside [0, polyLen] is clamped to the nearest endpoint for - * polyline lookup, providing seam overlap at both ends. - * - * @param verts Strip vertices (mutated in place to world positions). - * @param poly World-space polyline control points [P1, X, P2]. - * @param polyD Cumulative distances along polyline: [0, d1, d1+d2]. - * @param polyY Road surface height at each polyline control point. - * @param O Seed node world position. - */ -static void sweepDeform( - std::vector &verts, - const std::vector &poly, - const std::vector &polyD, - const std::vector &polyY, - const Ogre::Vector3 &O) -{ - if (poly.size() < 2 || polyD.size() != poly.size() || - polyY.size() != poly.size()) - return; - - float totalLen = polyD.back(); - - for (auto &v : verts) { - float z = v.mPosition.z; - float x = v.mPosition.x; - float y = v.mPosition.y; - - /* Clamp z to valid range for polyline lookup. */ - float cz = std::max(0.0f, std::min(totalLen, z)); - - /* Locate segment containing cz. */ - Ogre::Vector3 Q = poly.back(); - float hQ = polyY.back(); - for (size_t i = 0; i + 1 < polyD.size(); ++i) { - if (cz <= polyD[i + 1]) { - float t = (cz - polyD[i]) / - std::max(1e-6f, - polyD[i + 1] - polyD[i]); - Q = poly[i] + (poly[i + 1] - poly[i]) * t; - hQ = polyY[i] + - (polyY[i + 1] - polyY[i]) * t; - break; - } - } - - /* Radial direction from node to polyline point. */ - Ogre::Vector3 radial(Q.x - O.x, 0.0f, Q.z - O.z); - float rlen = radial.length(); - if (rlen < 1e-4f) - radial = Ogre::Vector3::UNIT_X; - else - radial /= rlen; - - /* If z is behind the polyline start, extend along d1. */ - Ogre::Vector3 wp; - if (z < 0.0f) { - /* Push back from P1 toward the node along the - * direction from P1 to O. */ - Ogre::Vector3 toNode(poly[0].x - O.x, 0.0f, - poly[0].z - O.z); - float tnLen = toNode.length(); - if (tnLen < 1e-4f) - toNode = radial; - else - toNode /= tnLen; - wp = poly[0] + toNode * (-z) - x * toNode; - wp.y = hQ + y; - } else if (z > totalLen) { - /* Extend past P2 outward along the direction from O - * to P2. */ - Ogre::Vector3 outDir(poly.back().x - O.x, 0.0f, - poly.back().z - O.z); - float odLen = outDir.length(); - if (odLen < 1e-4f) - outDir = radial; - else - outDir /= odLen; - wp = poly.back() + outDir * (z - totalLen) - - x * outDir; - wp.y = hQ + y; - } else { - wp = Q - x * radial; - wp.y = hQ + y; - } - - v.mPosition = wp; - } -} - -/** Recompute per-vertex normals from triangle faces. */ -static void -recomputeNormals(std::vector &verts, - const std::vector &indices) -{ - for (auto &v : verts) - v.mNormal = Ogre::Vector3::ZERO; - - for (size_t i = 0; i + 2 < indices.size(); i += 3) { - int i0 = indices[i]; - int i1 = indices[i + 1]; - int i2 = indices[i + 2]; - if (i0 < 0 || i1 < 0 || i2 < 0 || - i0 >= (int)verts.size() || - i1 >= (int)verts.size() || - i2 >= (int)verts.size()) - continue; - - Ogre::Vector3 e1 = - verts[i1].mPosition - verts[i0].mPosition; - Ogre::Vector3 e2 = - verts[i2].mPosition - verts[i0].mPosition; - Ogre::Vector3 n = e1.crossProduct(e2); - float len2 = n.squaredLength(); - if (len2 < 1e-10f) - continue; - n /= std::sqrt(len2); - - verts[i0].mNormal += n; - verts[i1].mNormal += n; - verts[i2].mNormal += n; - } - - for (auto &v : verts) { - float len2 = v.mNormal.squaredLength(); - if (len2 > 1e-10f) - v.mNormal /= std::sqrt(len2); - else - v.mNormal = Ogre::Vector3::UNIT_Y; - } -} - /** Overlap distance to close gaps at wedge boundaries (spec 5.4 step 4). */ static const float ROAD_SEAM_OVERLAP = 0.05f; +/** Append one triangle; degenerate (zero-area) triangles are skipped. */ +static void emitTri(Procedural::TriangleBuffer &out, const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1, const Ogre::Vector3 &p2, + const Ogre::Vector2 &uv0, const Ogre::Vector2 &uv1, + const Ogre::Vector2 &uv2) +{ + Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); + if (n.squaredLength() < 1e-10f) + return; + n.normalise(); + + int base = (int)out.getVertices().size(); + const Ogre::Vector3 *pp[3] = { &p0, &p1, &p2 }; + const Ogre::Vector2 *uu[3] = { &uv0, &uv1, &uv2 }; + for (int i = 0; i < 3; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = *pp[i]; + v.mNormal = n; + v.mUV = *uu[i]; + out.getVertices().push_back(v); + out.getIndices().push_back(base + i); + } +} + +/** + * Turn a set of center-surface triangles into a solid slab (spec + * section 8). + * + * Every triangle is emitted twice: offset by +halfThick along Y and + * offset by -halfThick, with the winding chosen so the top normal + * points up (auto-oriented by the source triangle's normal Y sign — + * the published keep-winding rule flips the top face down for the + * section 7 band triangle order). Boundary edges (undirected edges + * used by exactly one triangle — requires centerSurf to share vertices + * along interior edges) grow vertical skirt quads whose normals point + * away from the center surface's centroid. @p skirtFilter may reject + * specific boundary edges (e.g. a segment's far end, which meets the + * neighbour node's piece exactly). + */ +void RoadSystem::extrudeToSlab(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer ¢erSurf, + float roadThickness, + const SkirtFilter &skirtFilter) +{ + float halfThick = std::max(0.01f, roadThickness) * 0.5f; + Ogre::Vector3 up(0.0f, halfThick, 0.0f); + + const auto &verts = centerSurf.getVertices(); + const auto &indices = centerSurf.getIndices(); + + /* Centroid: interior reference for skirt orientation. */ + Ogre::Vector3 refPoint = Ogre::Vector3::ZERO; + for (const auto &v : verts) + refPoint += v.mPosition; + if (!verts.empty()) + refPoint /= (float)verts.size(); + + /* Top and bottom. */ + for (size_t t = 0; t + 2 < indices.size(); t += 3) { + const auto &v0 = verts[(size_t)indices[t]]; + const auto &v1 = verts[(size_t)indices[t + 1]]; + const auto &v2 = verts[(size_t)indices[t + 2]]; + Ogre::Vector3 n = (v1.mPosition - v0.mPosition) + .crossProduct(v2.mPosition - v0.mPosition); + if (n.squaredLength() < 1e-10f) + continue; + int i1 = n.y >= 0.0f ? 1 : 2; + int i2 = n.y >= 0.0f ? 2 : 1; + const Procedural::TriangleBuffer::Vertex *vv[3] = { &v0, &v1, + &v2 }; + emitTri(out, vv[0]->mPosition + up, vv[i1]->mPosition + up, + vv[i2]->mPosition + up, vv[0]->mUV, vv[i1]->mUV, + vv[i2]->mUV); + emitTri(out, vv[0]->mPosition - up, vv[i2]->mPosition - up, + vv[i1]->mPosition - up, vv[0]->mUV, vv[i2]->mUV, + vv[i1]->mUV); + } + + /* Boundary-edge detection by index counting. */ + struct EdgeUse { + int count = 0; + int a = 0, b = 0; /* directed, from the first use */ + }; + std::map, EdgeUse> edgeUse; + for (size_t t = 0; t + 2 < indices.size(); t += 3) { + int tri[3] = { indices[t], indices[t + 1], indices[t + 2] }; + for (int e = 0; e < 3; ++e) { + int a = tri[e], b = tri[(e + 1) % 3]; + auto &eu = edgeUse[std::minmax(a, b)]; + if (eu.count == 0) { + eu.a = a; + eu.b = b; + } + ++eu.count; + } + } + + float thickness = 2.0f * halfThick; + for (const auto &kv : edgeUse) { + const EdgeUse &eu = kv.second; + if (eu.count != 1) + continue; + const auto &v0 = verts[(size_t)eu.a]; + const auto &v1 = verts[(size_t)eu.b]; + if (skirtFilter && + !skirtFilter(v0.mPosition, v1.mPosition)) + continue; + + Ogre::Vector3 t0 = v0.mPosition + up; + Ogre::Vector3 t1 = v1.mPosition + up; + Ogre::Vector3 b0 = v0.mPosition - up; + Ogre::Vector3 b1 = v1.mPosition - up; + Ogre::Vector2 uvB0(v0.mUV.x, v0.mUV.y - thickness); + Ogre::Vector2 uvB1(v1.mUV.x, v1.mUV.y - thickness); + + Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0); + if (n.squaredLength() < 1e-10f) + continue; + Ogre::Vector3 mid = (t0 + t1 + b0 + b1) * 0.25f; + bool outward = n.dotProduct(mid - refPoint) >= 0.0f; + if (outward) { + emitTri(out, t0, t1, b1, v0.mUV, v1.mUV, uvB1); + emitTri(out, t0, b1, b0, v0.mUV, uvB1, uvB0); + } else { + emitTri(out, t0, b1, t1, v0.mUV, uvB1, v1.mUV); + emitTri(out, t0, b0, b1, v0.mUV, uvB0, uvB1); + } + } +} + +/** + * Road surface level offset shared by every wedge seeded at one node. + * + * The level is the mean of the incident edges' roadLevelAtNode values, + * so all wedge pieces meeting at the node use the same center height + * and no cracks open between adjacent pieces when per-edge road levels + * differ. + */ +static float nodeRoadLevel(const RoadGraph &graph, int nodeId) +{ + float sum = 0.0f; + int count = 0; + for (const RoadEdge &e : graph.edges) { + if (e.nodeA == nodeId) { + sum += e.roadLevelA; + ++count; + } else if (e.nodeB == nodeId) { + sum += e.roadLevelB; + ++count; + } + } + return count > 0 ? sum / (float)count : 0.0f; +} + +/** + * Phase 1 (spec section 4): straight strip of N concatenated template + * copies along -Z. After this the strip occupies X in [0,1], + * Y in [-thick/2, +thick/2], Z in [-N, 0]. All template faces are + * kept. + */ +void RoadSystem::buildConcatenatedStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + int N) +{ + out.getVertices().clear(); + out.getIndices().clear(); + for (int i = 0; i < N; ++i) { + int base = (int)out.getVertices().size(); + for (const auto &v : templ.getVertices()) { + Procedural::TriangleBuffer::Vertex cv = v; + cv.mPosition.z -= (float)i; + out.getVertices().push_back(cv); + } + for (int idx : templ.getIndices()) + out.getIndices().push_back(base + idx); + } +} + +/** + * Append one template copy shifted to z -= @p zOff, clamping the path + * distance of every vertex to @p clampD. + * + * Template faces lying fully in a copy-boundary Z plane are dropped: + * those are the template caps, which would otherwise stack coplanar + * faces at every copy join and at the edge midpoints where the + * neighbour node's piece meets this one (z-fighting). The template's + * X = 0 wall is dropped as well: it runs along the centerline shared + * with the adjacent wedge and is interior to the joined road body. + */ +static void appendTemplateCopy(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + float zOff, float clampD) +{ + const auto &tverts = templ.getVertices(); + const auto &tidx = templ.getIndices(); + + int base = (int)out.getVertices().size(); + for (const auto &v : tverts) { + Procedural::TriangleBuffer::Vertex cv = v; + cv.mPosition.z -= zOff; + if (-cv.mPosition.z > clampD) + cv.mPosition.z = -clampD; + out.getVertices().push_back(cv); + } + + for (size_t t = 0; t + 2 < tidx.size(); t += 3) { + const Ogre::Vector3 &a = tverts[(size_t)tidx[t]].mPosition; + const Ogre::Vector3 &b = tverts[(size_t)tidx[t + 1]].mPosition; + const Ogre::Vector3 &c = tverts[(size_t)tidx[t + 2]].mPosition; + + bool cap0 = std::fabs(a.z) < 1e-6f && + std::fabs(b.z) < 1e-6f && std::fabs(c.z) < 1e-6f; + bool cap1 = std::fabs(a.z + 1.0f) < 1e-6f && + std::fabs(b.z + 1.0f) < 1e-6f && + std::fabs(c.z + 1.0f) < 1e-6f; + if (cap0 || cap1) + continue; /* open joins at copy boundaries/midpoints */ + + bool wall0 = std::fabs(a.x) < 1e-6f && + std::fabs(b.x) < 1e-6f && + std::fabs(c.x) < 1e-6f; + const Ogre::Vector3 &n = tverts[(size_t)tidx[t]].mNormal; + if (wall0 && std::fabs(n.x) > 0.9f) + continue; /* interior centerline wall */ + + out.getIndices().push_back(base + tidx[t]); + out.getIndices().push_back(base + tidx[t + 1]); + out.getIndices().push_back(base + tidx[t + 2]); + } +} + +/** + * Two-run concatenated strip for one wedge (spec correction C3). + * + * Run 1 covers d in [0, L1] with ceil(L1) uniform copies from d = 0, + * run 2 covers [L1, L1+L2] with ceil(L2) copies from d = L1; vertices + * past each run's end are clamped onto it. Vertex layers therefore + * land exactly on the corner distance L1 and on the strip end — the + * miter corner is always sampled, which the published uniform + * N = ceil(L) layout cannot guarantee for fractional half-lengths + * (e.g. L1 = 5.5). + */ +static void buildWedgeStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + float L1, float L2) +{ + out.getVertices().clear(); + out.getIndices().clear(); + int k1 = std::max(1, (int)std::ceil(L1)); + int k2 = std::max(1, (int)std::ceil(L2)); + for (int i = 0; i < k1; ++i) + appendTemplateCopy(out, templ, (float)i, L1); + for (int j = 0; j < k2; ++j) + appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2); +} + +Ogre::Vector3 RoadSystem::computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d) +{ + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return Ogre::Vector3::ZERO; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + Ogre::Vector3 dir1 = h1.direction; + Ogre::Vector3 dir2 = h2.direction; + Ogre::Vector3 r1 = roadRightVec(dir1); + Ogre::Vector3 r2 = roadRightVec(dir2); + float lw = graph.config.laneWidth; + float L1 = h1.halfLength; + float L2 = h2.halfLength; + + Ogre::Vector3 offA = r1 * (h1.lanesOut * lw); /* H1-side curb */ + Ogre::Vector3 offB = r2 * (-h2.lanesIn * lw); /* H2-side curb */ + + /* Narrow symmetric blend zone around the node (spec 5.2). */ + float W = std::min(ROAD_SEAM_OVERLAP * 4.0f, + std::min(L1 * 0.5f, L2 * 0.5f)); + if (L1 < ROAD_SEAM_OVERLAP || L2 < ROAD_SEAM_OVERLAP) + W = 0.0f; + + /* + * Miter corner: intersection of the two constant-width curb + * lines, used as the blend anchor at the node so the curb passes + * exactly through the outer corner (spec correction C1 — the + * published 50/50 vector lerp cut the corner and left a hole at + * every outer intersection corner). Near-straight wedges + * (|det| < 0.05, curb lines almost collinear) drop the corner + * and blend directly between the two side offsets. + */ + bool hasCorner = false; + Ogre::Vector3 cornerOff; + float det = dir1.z * dir2.x - dir1.x * dir2.z; + if (std::fabs(det) >= 0.05f) { + Ogre::Vector3 rhs = offB - offA; + float t1x = (-rhs.x * dir2.z + dir2.x * rhs.z) / det; + cornerOff = offA + dir1 * t1x; + hasCorner = true; + } + + if (W <= 0.0f) { + if (d < L1) + return offA; + if (d > L1) + return offB; + return hasCorner ? cornerOff : (offA + offB) * 0.5f; + } + + if (d <= L1 - W) + return offA; + if (d >= L1 + W) + return offB; + if (!hasCorner) { + float t = (d - (L1 - W)) / (2.0f * W); + return offA + (offB - offA) * t; + } + if (d <= L1) { + float t = (d - (L1 - W)) / W; + return offA + (cornerOff - offA) * t; + } + float t = (d - L1) / W; + return cornerOff + (offB - cornerOff) * t; +} + +void RoadSystem::transformWedgeVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph) +{ + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 dir1 = h1.direction; + Ogre::Vector3 dir2 = h2.direction; + float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f; + float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f; + float L = L1 + L2; + float in1 = h1.lanesIn * graph.config.laneWidth; + + Ogre::Vector3 MA = O + dir1 * L1; + Ogre::Vector3 MB = O + dir2 * L2; + + /* One shared road level for the whole d = L1 vertex layer so + * adjacent wedge pieces cannot crack at the node. */ + float yO = O.y + nodeRoadLevel(graph, wedge.nodeId); + + for (auto &v : strip.getVertices()) { + float d = -v.mPosition.z; + if (d < 0.0f) + d = 0.0f; + if (d > L) + d = L; + + /* Centerline position (polyline M_A -> O -> M_B). */ + Ogre::Vector3 center; + if (d <= L1) + center = MA + (O - MA) * (d / L1); + else + center = O + (MB - O) * ((d - L1) / L2); + + /* + * World position: template X maps along the curb offset + * (direction AND magnitude — the offset itself widens + * through the miter corner), template Y maps directly to + * the vertical offset from the road surface. + */ + Ogre::Vector3 off = computeCurbOffset(wedge, graph, d); + Ogre::Vector3 worldXZ = center + off * v.mPosition.x; + + /* + * Surface height: half-edge profiles, with the shared + * node level at the d = L1 break layer. The published + * formulas passed the wedge-start distance d to the H1 + * helpers, inverting the profile along the first + * half-edge; the helpers expect the distance from the + * seed node, i.e. L1 - d (spec correction C2). + */ + 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; + float worldY = surfY + v.mPosition.y; + + /* UV: phase-continuous longitudinal u; lateral v scaled + * by the local width with the +in1 continuity offset. */ + 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; + + /* + * Normal: rotate template-forward (-Z) to the segment + * direction by the SIGNED angle around Y (the published + * unsigned angleBetween rotated the wrong way for half + * the possible directions — spec correction C5). + */ + const Ogre::Vector3 &segDir = (d <= L1) ? dir1 : dir2; + float theta = std::atan2(-segDir.x, -segDir.z); + Ogre::Quaternion q(Ogre::Radian(theta), + Ogre::Vector3::UNIT_Y); + Ogre::Vector3 n = q * v.mNormal; + + v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z); + v.mNormal = n; + } +} + +/** + * Phase 3 (spec section 6): shift centerline-side vertices near the + * node slightly past it so adjacent wedges overlap at the center + * junction. Only needed for nodes with > 2 neighbors. + */ +void RoadSystem::shiftSeamVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph) +{ + std::vector nids = graph.getNeighborIds(wedge.nodeId); + if (nids.size() <= 2) + return; /* straight-through or endpoint */ + + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return; + const Ogre::Vector3 &O = node->position; + + for (auto &v : strip.getVertices()) { + Ogre::Vector3 toNode(v.mPosition.x - O.x, 0, + v.mPosition.z - O.z); + float distToNode = toNode.length(); + if (distToNode >= ROAD_SEAM_OVERLAP) + continue; + + Ogre::Vector3 radial = toNode.normalisedCopy(); + if (radial.isZeroLength()) + continue; + + float push = ROAD_SEAM_OVERLAP - distToNode + + ROAD_SEAM_OVERLAP; + v.mPosition.x += radial.x * push; + v.mPosition.z += radial.z * push; + } +} + bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, const RoadGraph &graph, - const Procedural::TriangleBuffer &tmpl, + Procedural::TriangleBuffer &out) +{ + Procedural::TriangleBuffer fb = + makeFallbackTemplate(graph.config.roadThickness); + return buildWedgeGeometry(wedge, graph, fb, out); +} + +bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, Procedural::TriangleBuffer &out) { if (wedge.degenerate) { @@ -1422,194 +1694,109 @@ bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, return false; } - const RoadNode *node = graph.findNodeById(wedge.nodeId); - if (!node) - return false; - - const RoadHalfEdge &h1 = wedge.first; - const RoadHalfEdge &h2 = wedge.second; - const Ogre::Vector3 &O = node->position; - Ogre::Vector3 d1 = h1.direction; - Ogre::Vector3 d2 = h2.direction; - Ogre::Vector3 r1 = roadRightVec(d1); - Ogre::Vector3 r2 = roadRightVec(d2); - float lw = graph.config.laneWidth; - float out1 = h1.lanesOut * lw; - float in2 = h2.lanesIn * lw; - float L1 = h1.halfLength; - float L2 = h2.halfLength; - - /* - * Wedge polyline (outer curb boundary): - * P1 — outbound curb of h1 at midpoint - * X — intersection of the two curb lines (if it exists) - * P2 — inbound curb of h2 at midpoint - * - * The road surface is a single combined mesh: templates are - * concatenated into a straight strip, then sweep-deformed so the - * strip's +Z follows the polyline and the strip's +X extends - * inward toward the seed node O. - */ - - /* --- Polyline endpoints --- */ - Ogre::Vector3 P1 = O + L1 * d1 + out1 * r1; - Ogre::Vector3 P2 = O + L2 * d2 - in2 * r2; - - /* --- Corner X: intersection of the two curb lines --- */ - Ogre::Vector3 rhs = -in2 * r2 - out1 * r1; - float det = d1.z * d2.x - d1.x * d2.z; - float t1x = 0.0f, t2x = 0.0f; - bool cornerOk = false; - if (std::fabs(det) >= 0.05f) { - t1x = (-rhs.x * d2.z + d2.x * rhs.z) / det; - t2x = (d1.x * rhs.z - rhs.x * d1.z) / det; - cornerOk = t1x >= 0.0f && t1x <= L1 && - t2x >= 0.0f && t2x <= L2; - } - - /* --- Polyline and cumulative distances --- */ - std::vector poly; - std::vector polyY; - std::vector polyD; - - float yP1 = halfEdgeHeightAt(h1, graph, L1); - float yP2 = halfEdgeHeightAt(h2, graph, L2); - poly.push_back(P1); - polyY.push_back(yP1); - polyD.push_back(0.0f); - - if (cornerOk) { - Ogre::Vector3 X = O + t1x * d1 + out1 * r1; - float yX = 0.5f * (halfEdgeHeightAt(h1, graph, t1x) + - halfEdgeHeightAt(h2, graph, t2x)); - poly.push_back(X); - polyY.push_back(yX); - polyD.push_back((X - P1).length()); - } - - poly.push_back(P2); - polyY.push_back(yP2); - polyD.push_back(polyD.back() + - (P2 - poly[poly.size() - 2]).length()); - - float polyLen = polyD.back(); - if (polyLen < 1e-4f) - return false; - - /* --- Determine lane count --- */ - if (tmpl.getVertices().empty() || tmpl.getIndices().empty()) - return false; - - - int numLanes = std::max(h1.lanesOut, h2.lanesIn); - if (numLanes < 1) - numLanes = 1; - - /* --- Build straight template strip --- */ + /* Phase 1: concatenated strip. */ Procedural::TriangleBuffer strip; - buildTemplateStrip(tmpl, lw, numLanes, polyLen, strip); + buildWedgeStrip(strip, templ, wedge.first.halfLength, + wedge.second.halfLength); - /* --- Seam suppression: extend strip ends by overlap --- */ - auto &sverts = strip.getVertices(); - for (auto &v : sverts) { - if (v.mPosition.z < ROAD_SEAM_OVERLAP) - v.mPosition.z -= ROAD_SEAM_OVERLAP; - else if (v.mPosition.z > polyLen - ROAD_SEAM_OVERLAP) - v.mPosition.z += ROAD_SEAM_OVERLAP; - } + /* Phase 2: bend into wedge shape. */ + transformWedgeVertices(strip, wedge, graph); - /* --- Sweep-deform along polyline --- */ - sweepDeform(sverts, poly, polyD, polyY, O); - - /* --- Recompute normals --- */ - recomputeNormals(sverts, strip.getIndices()); - - /* --- Append to output --- */ - int base = (int)out.getVertices().size(); - for (const auto &v : sverts) - out.getVertices().push_back(v); - for (int idx : strip.getIndices()) - out.getIndices().push_back(base + idx); + /* Phase 3: close center seam. */ + shiftSeamVertices(strip, wedge, graph); + /* Slab extrusion: turn the center surface into a closed solid. + * The centerline edges (template X=0) and cap faces (template Z=0, + * Z=-1) were dropped by appendTemplateCopy, leaving only the outer + * curb wall and road top/bottom in the strip index buffer. Those + * remaining faces form the center surface — extrudeToSlab detects + * boundary edges from the center-surface triangle soup and adds + * top, bottom, and skirt geometry. */ + extrudeToSlab(out, strip, graph.config.roadThickness); return true; } -bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment, - const RoadGraph &graph, - const Procedural::TriangleBuffer &tmpl, - Procedural::TriangleBuffer &out) +/** + * Center-surface band of a dead-end straight segment (spec section 7): + * the full road width s in [-inW, +outW] along the single half-edge + * with a small overlap past the node. c[0]/c[3] are at the node end, + * c[1]/c[2] at the edge midpoint. Heights are absolute road surface + * heights. Retained because complyTerrain uses the four corners. + */ +static bool computeSegmentBand(const RoadStraightSegment &segment, + const RoadGraph &graph, Ogre::Vector3 c[4], + Ogre::Vector2 uvc[4]) { const RoadNode *node = graph.findNodeById(segment.nodeId); if (!node) return false; const RoadHalfEdge &he = segment.halfEdge; + if (he.lanesIn + he.lanesOut < 1) + return false; + const Ogre::Vector3 &O = node->position; Ogre::Vector3 d = he.direction; Ogre::Vector3 r = roadRightVec(d); float lw = graph.config.laneWidth; - float L = he.halfLength; - int numLanes = he.lanesIn + he.lanesOut; - if (numLanes < 1) - return false; - - if (tmpl.getVertices().empty() || tmpl.getIndices().empty()) - return false; - - /* Straight road strip along the half-edge direction. - * Templates are placed directly in world space: - * world = O + d * (t) + r * (lateral) + Y offset. - * t starts at t0 = -ROAD_SEAM_OVERLAP for seam suppression. */ - float t0 = -ROAD_SEAM_OVERLAP; - float stripLen = L - t0; - int copies = std::max(1, (int)std::ceil(stripLen)); - - const auto &sv = tmpl.getVertices(); - const auto &si = tmpl.getIndices(); float inW = he.lanesIn * lw; + float outW = he.lanesOut * lw; + float L = he.halfLength; + float t0 = -ROAD_SEAM_OVERLAP; - for (int ci = 0; ci < copies; ++ci) { - float segLen = 1.0f; - if (ci == copies - 1 && stripLen > 0.0f) - segLen = stripLen - (float)ci; - if (segLen <= 0.0f) - continue; + c[0] = O + t0 * d - inW * r; + c[1] = O + L * d - inW * r; + c[2] = O + L * d + outW * r; + c[3] = O + t0 * d + outW * r; + float y0 = halfEdgeHeightAt(he, graph, t0); + float yL = halfEdgeHeightAt(he, graph, L); + c[0].y = c[3].y = y0; + c[1].y = c[2].y = yL; - float zStart = t0 + (float)ci; + 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; +} - for (int lane = 0; lane < numLanes; ++lane) { - int base = (int)out.getVertices().size(); - float xOff = -inW + (float)lane * lw; +bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + Ogre::Vector3 c[4]; + Ogre::Vector2 uvc[4]; + if (!computeSegmentBand(segment, graph, c, uvc)) + return false; - for (const auto &v : sv) { - Procedural::TriangleBuffer::Vertex nv; - /* t = distance along half-edge from O. */ - float t = zStart + v.mPosition.x * segLen; - float tClamped = - std::max(0.0f, std::min(L, t)); - float roadY = - halfEdgeHeightAt(he, graph, - tClamped); - - nv.mPosition = - O + d * t + - r * (v.mPosition.z * lw + xOff); - nv.mPosition.y += v.mPosition.y + roadY; - nv.mUV = Ogre::Vector2( - t, v.mPosition.z + (float)lane); - /* Rotate template normal to world frame: - * template +X → d, +Z → r, +Y → world Y. */ - nv.mNormal = - d * v.mNormal.x + - r * v.mNormal.z + - Ogre::Vector3::UNIT_Y * v.mNormal.y; - out.getVertices().push_back(nv); - } - for (int idx : si) - out.getIndices().push_back(base + idx); - } + /* Build center-surface as a TriangleBuffer. */ + Procedural::TriangleBuffer centerSurf; + int base = (int)centerSurf.getVertices().size(); + for (int i = 0; i < 4; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = c[i]; + v.mNormal = Ogre::Vector3::UNIT_Y; + v.mUV = uvc[i]; + centerSurf.getVertices().push_back(v); } + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 1); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 3); + /* Extrude to slab, keeping the far-end edge open (it meets the + * neighbor node's piece). */ + auto skirtFilter = [&](const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1) -> bool { + /* The far end is the edge (c1, c2) — exclude it. */ + float d1 = p0.distance(c[1]) + p1.distance(c[2]); + float d2 = p0.distance(c[2]) + p1.distance(c[1]); + return (d1 > 0.001f && d2 > 0.001f); + }; + extrudeToSlab(out, centerSurf, graph.config.roadThickness, + skirtFilter); return true; } @@ -1687,45 +1874,81 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, if (!terrainSystem || !m_terrainGroup) return; - /* Walk every loaded page's wedge/segment geometry and write fixup - * values at the vertices of the generated road mesh. The fixup - * target is the road underside: vertex.y - roadThickness. */ + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + const RoadGraph &rg = terrain.get().roadGraph; + float halfThick = std::max(0.01f, roadThickness) * 0.5f; + Procedural::TriangleBuffer fb = + makeFallbackTemplate(rg.config.roadThickness); + + /* + * Walk every loaded page's wedges and segments, generate road + * geometry into a temp buffer, then write fixup values under every + * top-surface vertex. The fixup target is the road underside: + * surfaceY - roadThickness. + */ for (auto &kv : m_pageGeometry) { RoadPageGeometry &pg = kv.second; for (const RoadWedge &wedge : pg.wedges) { - Procedural::TriangleBuffer buf; - if (!buildWedgeGeometry(wedge, m_world.entity(m_terrainEntityId).get().roadGraph, getRoadTemplate(m_world.entity(m_terrainEntityId).get().roadGraph.config), buf)) + Procedural::TriangleBuffer tmp; + if (!buildWedgeGeometry(wedge, rg, fb, tmp)) continue; - const auto &verts = buf.getVertices(); + /* Write fixups from the generated top-surface + * vertices. The top-surface Y after slab + * extrusion is at +halfThick; the road-surface Y + * is topY - halfThick, so the fixup target is + * topY - halfThick - roadThickness = + * topY - halfThick*2 - halfThick = + * topY - roadThickness - halfThick. + * + * Simpler: sample the Y of vertices whose normal + * points up and write target = Y - roadThickness + * underneath them. */ + const auto &verts = tmp.getVertices(); for (const auto &v : verts) { - const Ogre::Vector3 &p = v.mPosition; - /* Only write for top-surface vertices - * (Y near +roadThickness/2). */ - if (p.y < 0.0f) + if (v.mNormal.y <= 0.5f) continue; - - float targetY = p.y - roadThickness; - terrainSystem->writeFixup(p.x, p.z, - targetY); + terrainSystem->writeFixup( + v.mPosition.x, v.mPosition.z, + v.mPosition.y - roadThickness); } } for (const RoadStraightSegment &seg : pg.segments) { - Procedural::TriangleBuffer buf; - if (!buildSegmentGeometry(seg, m_world.entity(m_terrainEntityId).get().roadGraph, getRoadTemplate(m_world.entity(m_terrainEntityId).get().roadGraph.config), buf)) + Ogre::Vector3 c[4]; + Ogre::Vector2 uvc[4]; + if (!computeSegmentBand(seg, rg, c, uvc)) continue; - const auto &verts = buf.getVertices(); - for (const auto &v : verts) { - const Ogre::Vector3 &p = v.mPosition; - if (p.y < 0.0f) - continue; + /* Write fixups at the band corners. */ + for (int i = 0; i < 4; ++i) + terrainSystem->writeFixup( + c[i].x, c[i].z, + c[i].y - roadThickness); - float targetY = p.y - roadThickness; - terrainSystem->writeFixup(p.x, p.z, - targetY); + /* Sample intermediate points along the band edges + * and interior for smooth compliance. */ + Ogre::Vector3 d10 = c[1] - c[0]; + Ogre::Vector3 d32 = c[2] - c[3]; + Ogre::Vector3 d30 = c[3] - c[0]; + float edgeLen = d10.length(); + float widthLen = d30.length(); + int nSteps = std::max(1, (int)std::ceil(edgeLen)); + int nWidth = std::max(1, (int)std::ceil(widthLen)); + for (int s = 1; s < nSteps; ++s) { + float t = (float)s / (float)nSteps; + Ogre::Vector3 p0 = c[0] + d10 * t; + Ogre::Vector3 p1 = c[3] + d32 * t; + for (int w = 0; w <= nWidth; ++w) { + float wt = (float)w / (float)nWidth; + Ogre::Vector3 pos = p0 + (p1 - p0) * wt; + terrainSystem->writeFixup( + pos.x, pos.z, + pos.y - roadThickness); + } } } } @@ -1804,25 +2027,25 @@ void RoadSystem::rebuildDebugWedge() if (m_debugWedgeIndex < 0) { for (const auto &w : nodeWedges) { Procedural::TriangleBuffer wedgeTb; - if (buildWedgeGeometry(w, tc.roadGraph, getRoadTemplate(tc.roadGraph.config), wedgeTb)) + if (buildWedgeGeometry(w, tc.roadGraph, wedgeTb)) emitGeom(wedgeTb); } for (const auto &s : nodeSegs) { Procedural::TriangleBuffer segTb; - if (buildSegmentGeometry(s, tc.roadGraph, getRoadTemplate(tc.roadGraph.config), segTb)) + if (buildSegmentGeometry(s, tc.roadGraph, segTb)) emitGeom(segTb); } } else if (m_debugWedgeIndex < (int)nodeWedges.size()) { Procedural::TriangleBuffer wedgeTb; if (buildWedgeGeometry(nodeWedges[m_debugWedgeIndex], - tc.roadGraph, getRoadTemplate(tc.roadGraph.config), wedgeTb)) + tc.roadGraph, wedgeTb)) emitGeom(wedgeTb); } else { int segIdx = m_debugWedgeIndex - (int)nodeWedges.size(); if (segIdx >= 0 && segIdx < (int)nodeSegs.size()) { Procedural::TriangleBuffer segTb; if (buildSegmentGeometry(nodeSegs[segIdx], - tc.roadGraph, getRoadTemplate(tc.roadGraph.config), segTb)) + tc.roadGraph, segTb)) emitGeom(segTb); } } diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index 3bc8920..84f3cc7 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -132,45 +133,109 @@ public: * Road mesh template (M5.3). * * Returns the template road segment as a Procedural::TriangleBuffer in - * template space: X in [0, 1] along the edge, Y in - * [-roadThickness/2, +roadThickness/2], Z in [0, 1] across the road - * (+Z = right of the A->B travel direction), UVs spanning (0,0)-(1,1) - * over the X/Z extents. + * template space (ProceduralRoadGeometry.md section 2): X in [0, 1] + * lateral (X=0 at the centerline, X=1 at the outer curb), Y in + * [-roadThickness/2, +roadThickness/2], Z in [-1, 0] longitudinal + * (0 at the wedge start, -1 one unit along the road), UVs spanning + * (0,0)-(1,1) with u along Z and v along X. * * The template is loaded from cfg.roadMeshTemplate (General resource - * group). A missing or empty mesh falls back to a generated unit box - * (the supported prototyping path — no asset required). The buffer is - * cached and rebuilt only when cfg.roadMeshTemplate or - * cfg.roadThickness changes. + * group) and normalized into this space. A missing or empty mesh + * falls back to a generated unit box (the supported prototyping + * path — no asset required). The buffer is cached and rebuilt only + * when cfg.roadMeshTemplate or cfg.roadThickness changes. */ const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg); /** - * Geometry generation (M5.6). + * Geometry generation (M5.6, ProceduralRoadGeometry.md). * * Builds the world-space road slab for one wedge or one straight - * segment and appends it to @ out. The slab has top and bottom - * surfaces at +/- roadThickness/2 around the interpolated road level - * and side skirts along exposed edges (curbs and endpoint caps). + * segment and appends it to @p out. * - * Static so headless tests can call them without a scene. Returns - * false when the primitive is degenerate and nothing was emitted - * (e.g. a wedge wider than 270 degrees). + * The wedge piece is built by the three-phase pipeline: a strip of + * concatenated template copies is bent along the wedge's 2-segment + * centerline polyline (edge midpoint -> node -> edge midpoint) with + * the outer-curb offset interpolated through the miter corner, so + * the road keeps its exact width through turns with no gaps or + * overlaps. The template supplies the slab thickness (top and + * bottom at +/- roadThickness/2); template cap faces and the + * centerline wall are dropped because they are interior to the + * joined road body. + * + * The 3-argument overload uses the generated fallback box template; + * the 4-argument overload takes an explicit template (runtime path + * via getRoadTemplate()). Static so headless tests can call them + * without a scene. Returns false when the primitive is degenerate + * and nothing was emitted (e.g. a wedge wider than ~360 degrees). */ static bool buildWedgeGeometry(const RoadWedge &wedge, const RoadGraph &graph, - const Procedural::TriangleBuffer &tmpl, + Procedural::TriangleBuffer &out); + static bool buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, Procedural::TriangleBuffer &out); static bool buildSegmentGeometry(const RoadStraightSegment &segment, const RoadGraph &graph, - const Procedural::TriangleBuffer &tmpl, Procedural::TriangleBuffer &out); + /** + * Pipeline phases (ProceduralRoadGeometry.md section 10). + * + * Exposed as public statics so headless tests can exercise the key + * math without a scene. + */ + /** Phase 1: straight strip of N concatenated template copies + * along -Z (all faces kept). */ + static void buildConcatenatedStrip(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer &templ, + int N); + /** Phase 2: bend the strip into the wedge shape, in place. */ + static void transformWedgeVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + /** Phase 3: push centerline-side vertices near the node slightly + * past it so adjacent wedges overlap at the center junction. */ + static void shiftSeamVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph); + /** + * Outer-curb offset at path distance @p d from the wedge start. + * + * The vector from the centerline to the outer curb; it anchors at + * w1*r1 on the first half-edge, passes exactly through the miter + * corner at the node (no corner holes), and ends at -w2*r2 on the + * second half-edge, interpolated through the narrow blend zone. + */ + static Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d); + + /** Returns false for a boundary edge that must stay open. */ + using SkirtFilter = std::function; + + /** + * Turn a center-surface triangle set into a solid slab (spec + * section 8): top and bottom at +/- roadThickness/2 (winding + * auto-oriented by normal Y sign) plus vertical skirts on + * boundary edges (edges used by exactly one triangle; centerSurf + * must share vertices along interior edges). @p skirtFilter can + * exclude specific boundary edges (e.g. the segment far end, + * which meets the neighbor node's piece). + */ + static void extrudeToSlab(Procedural::TriangleBuffer &out, + const Procedural::TriangleBuffer ¢erSurf, + float roadThickness, + const SkirtFilter &skirtFilter = nullptr); + /** * Create a unit-box template for headless tests and fallback. * - * Returns a unit box occupying X=[0,1], Z=[0,1], - * Y=[-thick/2, +thick/2] with 6 faces, 24 vertices, 36 indices. + * Returns a unit box occupying X=[0,1] (lateral, 0=centerline), + * Y=[-thick/2, +thick/2], Z=[-1,0] (longitudinal, 0=wedge start) + * with 6 faces, 24 vertices, 36 indices. */ static Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness); diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 116b9be..9396a92 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -1466,7 +1466,7 @@ bool TerrainTestRunner::testRoadTemplate(EditorApp &app, TerrainSystem *ts) v.mPosition.x <= 1.0f + 1e-4f && v.mPosition.y >= -h - 1e-4f && v.mPosition.y <= h + 1e-4f && - v.mPosition.z >= -1e-4f && + v.mPosition.z >= -1.0f - 1e-4f && v.mPosition.z <= 1.0f + 1e-4f && v.mUV.x >= -1e-4f && v.mUV.x <= 1.0f + 1e-4f && v.mUV.y >= -1e-4f && v.mUV.y <= 1.0f + 1e-4f; @@ -2018,7 +2018,7 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, return fail("no straight segment for node A"); Procedural::TriangleBuffer buf; - if (!RoadSystem::buildSegmentGeometry(*segA, rg, RoadSystem::makeFallbackTemplate(rg.config.roadThickness), buf)) + if (!RoadSystem::buildSegmentGeometry(*segA, rg, buf)) return fail("buildSegmentGeometry returned false"); Scan s = scan(buf); @@ -2032,6 +2032,42 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, return fail("segment slab missing top/bottom surface"); } + /* + * Case 1b: elevated nodes (y = 10). Regression test: the road + * surface must sit at node height (+/- roadThickness/2), NOT at + * double the node height (absolute heights must not be added on + * top of the node position). + */ + { + RoadGraph rg; + int a = rg.addNode(Ogre::Vector3(0, 10, 0)); + int b = rg.addNode(Ogre::Vector3(20, 10, 0)); + rg.addEdge(a, b); + + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + + const RoadStraightSegment *segA = nullptr; + for (const auto &s : segs) + if (s.nodeId == a) + segA = &s; + if (!segA) + return fail("no straight segment for elevated node A"); + + Procedural::TriangleBuffer buf; + if (!RoadSystem::buildSegmentGeometry(*segA, rg, buf)) + return fail("buildSegmentGeometry failed (elevated)"); + + Scan s = scan(buf); + if (!s.ok) + return fail("elevated segment buffer invalid"); + if (s.max.y < 10.14f || s.max.y > 10.16f || + s.min.y < 9.84f || s.min.y > 9.86f) + return fail("elevated segment at wrong height " + "(double-counted node Y?)"); + } + /* * Case 2: asymmetric lanes (2 out, 1 in) shift the band to * z in [-3, +6] on the +right side of the A->B direction. @@ -2056,7 +2092,7 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, return fail("no straight segment for node A (asym)"); Procedural::TriangleBuffer buf; - if (!RoadSystem::buildSegmentGeometry(*segA, rg, RoadSystem::makeFallbackTemplate(rg.config.roadThickness), buf)) + if (!RoadSystem::buildSegmentGeometry(*segA, rg, buf)) return fail("buildSegmentGeometry failed (asym)"); Scan s = scan(buf); @@ -2068,11 +2104,12 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, } /* - * Case 3: 90-degree wedge (sweep-based, M5.6). - * Corner at origin, neighbors at +X and +Z, default 1+1 lanes. - * Polyline: P1=(10,0,3) → X=(3,0,3) → P2=(3,0,10). - * The sweep distributes vertices along the polyline with - * seamless curved transition at the outer corner. + * Case 3: 90-degree wedge is one mesh bent along the 2-segment + * centerline polyline with a mitered outer corner. Corner node at + * origin, neighbors at +X and +Z, default 1+1 lanes -> L-shaped + * hexagon with outer corner at (3, y, 3), all vertices inside + * [0, 10]^2 in XZ. The 270-degree wedge wraps around the node + * with its miter corner behind it at (-3, y, -3). */ { RoadGraph rg; @@ -2097,95 +2134,146 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, return fail("L-corner wedges not found"); Procedural::TriangleBuffer buf; - if (!RoadSystem::buildWedgeGeometry(*w90, rg, - RoadSystem::makeFallbackTemplate(rg.config.roadThickness), buf)) + if (!RoadSystem::buildWedgeGeometry(*w90, rg, buf)) return fail("buildWedgeGeometry failed for 90 deg"); Scan s = scan(buf); if (!s.ok) return fail("wedge buffer has NaN or bad indices"); + if (s.min.x < -0.06f || s.min.z < -0.06f || + s.max.x > 10.05f || s.max.z > 10.05f) + return fail("90 deg wedge overshoots the L-shape"); - /* Seam overlap may push vertices slightly beyond the - * ideal L-shape; allow generous bounds. */ - if (s.min.x < -1.0f || s.min.z < -1.0f || - s.max.x > 12.0f || s.max.z > 12.0f) - return fail("90 deg wedge overshoots expected bounds"); - - /* Verify top/bottom surfaces present. */ - float halfThick = 0.5f * std::max(0.01f, - rg.config.roadThickness); - if (s.min.y > -halfThick || s.max.y < halfThick || - s.min.y < -halfThick * 1.1f || - s.max.y > halfThick * 1.1f) - return fail("wedge missing top/bottom surface"); - - /* Vertices should exist near the node O (inner - * edge convergence) and near both polyline segments. */ - bool sawNearNode = false; - bool sawNearXseg = false; - bool sawNearZseg = false; + bool sawCorner = false; + bool sawNode = false; for (const auto &v : buf.getVertices()) { - const Ogre::Vector3 &p = v.mPosition; - float d2 = p.x * p.x + p.z * p.z; - if (d2 < 4.0f * 4.0f) - sawNearNode = true; - /* Near the +X polyline segment (z≈3, x in [3,10]). */ - if (fabsf(p.z - 3.0f) < 0.2f && p.x >= 2.8f && - p.x <= 10.2f) - sawNearXseg = true; - /* Near the +Z polyline segment (x≈3, z in [3,10]). */ - if (fabsf(p.x - 3.0f) < 0.2f && p.z >= 2.8f && - p.z <= 10.2f) - sawNearZseg = true; + if (fabsf(v.mPosition.x - 3.0f) < 0.05f && + fabsf(v.mPosition.z - 3.0f) < 0.05f) + sawCorner = true; + if (fabsf(v.mPosition.x) < 0.05f && + fabsf(v.mPosition.z) < 0.05f) + sawNode = true; } - if (!sawNearNode) - return fail("90 deg wedge missing inner vertices near O"); - if (!sawNearXseg) - return fail("90 deg wedge missing vertices near +X curb"); - if (!sawNearZseg) - return fail("90 deg wedge missing vertices near +Z curb"); + if (!sawCorner) + return fail("90 deg wedge missing outer corner (3,3)"); + if (!sawNode) + return fail("90 deg wedge missing node vertex (0,0)"); - /* The 270-degree wedge is also sweep-generated. */ + /* + * The 270-degree wedge takes the mitered wrap-around path: + * hexagon O, (0,10), (-3,10), (-3,-3), (10,-3), (10,0) with + * the outer corner behind the node. It must cover the + * other three quadrants exactly: bounds x/z in [-3, 10]. + */ Procedural::TriangleBuffer buf270; - if (!RoadSystem::buildWedgeGeometry(*w270, rg, - RoadSystem::makeFallbackTemplate(rg.config.roadThickness), buf270)) + if (!RoadSystem::buildWedgeGeometry(*w270, rg, buf270)) return fail("buildWedgeGeometry failed for 270 deg"); Scan s270 = scan(buf270); if (!s270.ok) return fail("270 deg wedge buffer invalid"); + if (s270.min.x < -3.05f || s270.min.z < -3.05f || + s270.max.x > 10.05f || s270.max.z > 10.05f || + s270.min.x > -2.95f || s270.min.z > -2.95f || + s270.max.x < 9.95f || s270.max.z < 9.95f) + return fail("270 deg wedge bounds wrong"); + + bool sawBackCorner = false; + for (const auto &v : buf270.getVertices()) { + if (fabsf(v.mPosition.x + 3.0f) < 0.05f && + fabsf(v.mPosition.z + 3.0f) < 0.05f) { + sawBackCorner = true; + break; + } + } + if (!sawBackCorner) + return fail("270 deg wedge missing miter corner " + "(-3,-3)"); } - /* - * Case 4: a nearly-collinear (~360 degree) wedge is - /* - * Case 4: a nearly-collinear (~360 degree) wedge is - * degenerate and emits nothing. - */ - { - RoadGraph rg; - int c = rg.addNode(Ogre::Vector3(0, 0, 0)); - int a2 = rg.addNode(Ogre::Vector3(10, 0, 0)); - int b2 = rg.addNode(Ogre::Vector3(10, 0, 0.001f)); - rg.addEdge(c, a2); - rg.addEdge(c, b2); + /* + * Case 4: a nearly-collinear (~360 degree) wedge is + * degenerate and emits nothing. + */ + { + RoadGraph rg; + int c = rg.addNode(Ogre::Vector3(0, 0, 0)); + int a2 = rg.addNode(Ogre::Vector3(10, 0, 0)); + int b2 = rg.addNode(Ogre::Vector3(10, 0, 0.001f)); + rg.addEdge(c, a2); + rg.addEdge(c, b2); - std::vector wedges; - std::vector segs; - enumerateWedges(rg, wedges, segs); + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); - const RoadWedge *wDeg = nullptr; - for (const auto &w : wedges) - if (w.degenerate) - wDeg = &w; - if (!wDeg) - return fail("near-360 deg wedge not marked degenerate"); + const RoadWedge *wDeg = nullptr; + for (const auto &w : wedges) + if (w.degenerate) + wDeg = &w; + if (!wDeg) + return fail("near-360 deg wedge not marked degenerate"); - Procedural::TriangleBuffer buf; - if (RoadSystem::buildWedgeGeometry(*wDeg, rg, RoadSystem::makeFallbackTemplate(rg.config.roadThickness), buf)) - return fail("degenerate wedge not rejected"); - if (!buf.getVertices().empty() || !buf.getIndices().empty()) - return fail("degenerate wedge emitted geometry"); + Procedural::TriangleBuffer buf; + if (RoadSystem::buildWedgeGeometry(*wDeg, rg, buf)) + return fail("degenerate wedge not rejected"); + if (!buf.getVertices().empty() || !buf.getIndices().empty()) + return fail("degenerate wedge emitted geometry"); + } + + /* + * Case 5: straight-through node (180-degree wedges). Three nodes + * on a line; the middle node's two wedges must be straight + * rectangular halves of the through-road — z in [0, 3] and + * z in [-3, 0] — with no bowing toward the node and no overlap. + */ + { + RoadGraph rg; + int nx = rg.addNode(Ogre::Vector3(-20, 0, 0)); + int c = rg.addNode(Ogre::Vector3(0, 0, 0)); + int px = rg.addNode(Ogre::Vector3(20, 0, 0)); + rg.addEdge(nx, c); + rg.addEdge(c, px); + + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + + const RoadWedge *wPlusZ = nullptr, *wMinusZ = nullptr; + for (const auto &w : wedges) { + if (w.nodeId != c) + continue; + if (fabsf(w.sweptAngleDeg - 180.0f) > 0.1f) + return fail("straight node wedge not 180 deg"); + if (w.first.direction.x > 0.0f) + wPlusZ = &w; + else + wMinusZ = &w; } + if (!wPlusZ || !wMinusZ) + return fail("straight node wedges not found"); + + Procedural::TriangleBuffer bufUp; + if (!RoadSystem::buildWedgeGeometry(*wPlusZ, rg, bufUp)) + return fail("buildWedgeGeometry failed (+Z half)"); + Scan sUp = scan(bufUp); + if (!sUp.ok) + return fail("+Z half buffer invalid"); + if (sUp.min.z < -0.001f || sUp.max.z > 3.05f || + sUp.max.z < 2.95f || + sUp.min.x < -10.05f || sUp.max.x > 10.05f) + return fail("+Z half is not a straight rectangle"); + + Procedural::TriangleBuffer bufDn; + if (!RoadSystem::buildWedgeGeometry(*wMinusZ, rg, bufDn)) + return fail("buildWedgeGeometry failed (-Z half)"); + Scan sDn = scan(bufDn); + if (!sDn.ok) + return fail("-Z half buffer invalid"); + if (sDn.max.z > 0.001f || sDn.min.z < -3.05f || + sDn.min.z > -2.95f || + sDn.min.x < -10.05f || sDn.max.x > 10.05f) + return fail("-Z half is not a straight rectangle"); + } Ogre::LogManager::getSingleton().logMessage( "TerrainTests: road wedge geometry test passed"); return true;