Added demo for road geometry

This commit is contained in:
2026-08-04 17:37:49 +03:00
parent c48ff9f4e2
commit 33f76a1e54
10 changed files with 2885 additions and 554 deletions
+40
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -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 &centerSurf, 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 &centerSurf,
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.
@@ -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.1M5.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] W1W4, W6W8 implemented.
- [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
22 tests green per iteration (verified 2026-07-31).
+53 -29
View File
@@ -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).
---
+611
View File
@@ -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 <build> --target RoadGeometryDemo
* Run: ./RoadGeometryDemo
*/
#include <Ogre.h>
#include <OgreApplicationContext.h>
#include <OgreCameraMan.h>
#include <OgreImGuiOverlay.h>
#include <OgreImGuiInputListener.h>
#include <OgreOverlaySystem.h>
#include <imgui.h>
#include <memory>
#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<void()> 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<void()> 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<ImGuiFrameListener> 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<OgreBites::CameraMan> 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<OgreBites::CameraMan>(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<ImGuiFrameListener>(
[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<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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;
}
@@ -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 <OgreLogManager.h>
#include <algorithm>
#include <cmath>
#include <functional>
#include <map>
#include <set>
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<int> 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 &centerSurf,
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<std::pair<int, int>, 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
@@ -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 <Ogre.h>
#include <ProceduralTriangleBuffer.h>
#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<bool(const Ogre::Vector3 &p0,
const Ogre::Vector3 &p1)>;
void extrudeToSlab(Procedural::TriangleBuffer &out,
const Procedural::TriangleBuffer &centerSurf,
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
File diff suppressed because it is too large Load Diff
+84 -19
View File
@@ -9,6 +9,7 @@
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
@@ -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<bool(const Ogre::Vector3 &p0,
const Ogre::Vector3 &p1)>;
/**
* 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 &centerSurf,
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);
+165 -77
View File
@@ -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<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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;