Compare commits

...

3 Commits

Author SHA1 Message Date
slapin e82190ec6d Procedural road geometry fixes 2026-08-10 07:07:57 +03:00
slapin 33f76a1e54 Added demo for road geometry 2026-08-04 17:37:49 +03:00
slapin c48ff9f4e2 Road geometry snapshot 2026-08-02 04:12:30 +03:00
11 changed files with 2966 additions and 709 deletions
+1
View File
@@ -2,6 +2,7 @@ cmake_minimum_required(VERSION 3.13.0)
project(world2)
set(CMAKE_CXX_STANDARD 17)
enable_testing()
set(BLENDER "${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender" CACHE STRING "Blender path")
set(CREATE_DIRECTORIES
${CMAKE_BINARY_DIR}/assets/blender/shapes/male
+41
View File
@@ -402,6 +402,7 @@ target_link_libraries(editSceneEditor
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
@@ -629,6 +630,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,627 @@
# 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
follows the mitered curb chain — pinned at the miter corner K
for inner wedges (sweep < 180°, so cross-sections cannot fold
over each other), blended through K over a narrow zone for
outer wedges — so the cross-section direction varies
continuously: no gaps, no folds.
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.
The single implementation lives in `roadlib/RoadGeometryLib.cpp`
(namespace `RoadGeometryLib`); the public `RoadSystem` statics forward
to it. The transformed wedge strip is already a closed tube (the
template supplies top, bottom and curb faces), so it is appended to
the output verbatim — slab extrusion (§8) applies to straight
segments only. `RoadGeometryLib` also provides
`loadTemplateFromMesh()` (template loading per §2) and
`makeFallbackTemplate()`.
## 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 Corner Regimes and Blend Zone
The two constant-width curb lines
```
A(s) = O + offA + dir1 * s s in [0, L1]
B(s) = O + offB + dir2 * s s in [0, L2]
offA = w1 * r1 // H1-side curb end at the node
offB = -w2 * r2 // H2-side curb end at the node
```
meet at the miter corner K. Expressed as parameters along each
direction from the node-side curb ends:
```
det = dir1.z * dir2.x - dir1.x * dir2.z
rhs = offB - offA
t1 = (dir2.x * rhs.z - dir2.z * rhs.x) / det
t2 = (dir1.x * rhs.z - dir1.z * rhs.x) / det
cornerOff = offA + dir1 * t1 == offB + dir2 * t2
K = O + cornerOff
```
`|det| < 0.05` means the curb lines are (nearly) collinear —
near-straight wedges drop the corner entirely.
* **Inner corner (converging, sweep < 180°): `t1 > 0` and `t2 > 0`** —
the curb lines meet ahead of the node, inside the wedge. When K
lies on both curb segments (`t1 <= L1` and `t2 <= L2`) the curb is
**pinned at K** over the whole corner zone `[L1 - t1, L1 + t2]` (see
§5.4): the curb arc around an inner corner is shorter than the
centerline arc, so blending parallel cross-sections through the zone
would fold them over each other — overlapping geometry when flat,
turning into grossly intersecting ramp sheets once the two ends
differ in height.
* **Outer corner (diverging, sweep > 180°): `t1 < 0`** — the curb
lines meet behind the node; cross-sections fan out and cannot fold.
The offset blends through K over a narrow symmetric zone around the
node:
```
W = min(ROAD_SEAM_OVERLAP * 4, // ~0.2 units — 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).
* **Notch fallback** — an inner corner whose K falls outside either
curb segment (`t1 > L1` or `t2 > L2`; a very wide road on very short
edges) uses the same narrow blend as an outer corner.
### 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. Its behaviour depends on the corner
regime (§5.2).
**Inner corner — curb pinned at the miter corner K:**
```
zoneStart = L1 - t1
zoneEnd = L1 + t2
if d <= zoneStart: offset(d) = offA
if d >= zoneEnd: offset(d) = offB
else: offset(d) = K - center(d)
```
Inside the zone every cross-section aims its outer-curb end exactly at
K, so consecutive sections share the endpoint K and cannot cross each
other. The rule is C0-continuous: at `zoneStart` it equals offA
exactly (K lies on curb line A) and at `zoneEnd` it equals offB. The
outer curb wall collapses to the vertical line at K inside the zone
(zero-area quads) — the geometrically correct miter joint.
**Outer corner, notch fallback, or no corner — narrow blend:**
```
if W == 0 or d <= L1 - W:
offset(d) = offA
elif d >= L1 + W:
offset(d) = offB
elif no corner (|det| < 0.05):
t = (d - (L1 - W)) / (2 * W) // 0 → 1 across blend zone
offset(d) = lerp(offA, offB, t)
elif d <= L1:
t = (d - (L1 - W)) / W
offset(d) = lerp(offA, cornerOff, t)
else:
t = (d - L1) / W
offset(d) = lerp(cornerOff, offB, t)
```
The blend passes exactly through the miter corner at the node, so no
hole opens at the outer corner. Both `offA` and `offB` point into the
wedge interior, so the interpolated vector never passes through zero
for non-degenerate wedges.
### 5.5 Effective Road Width
The scalar road half-width at distance d is the offset magnitude:
```
width(d) = |offset(d)|
```
It equals w1 on the first half-edge and w2 on the second half-edge,
widens through outer miter corners, and shrinks toward the pinned
corner K for inner wedges. It is used for the lateral UV scale only
(§5.7).
### 5.6 Surface Height
```
roadSurfaceY(d):
if d <= L1: return halfEdgeHeightAt(H1, graph, L1 - d)
else: return halfEdgeHeightAt(H2, graph, d - L1)
```
`halfEdgeHeightAt(he, graph, t)` returns the absolute world Y of the
road surface at distance t **from the seed node** O. d is the distance
from the wedge start M_A, so the distance from O along H1 is L1 - d
(back toward the midpoint), while the distance from O along H2 is
d - L1 (forward toward M_B). Passing d directly to H1 inverts the
height profile along the first half-edge.
### 5.7 Per-Vertex Transform
For each vertex `v` at template position (vx, vy, vz):
```
d = -vz // guaranteed to be in [0, L]
// Template X maps along the curb offset (direction AND magnitude —
// the offset itself widens through outer miter corners and aims at
// the pinned corner K for inner wedges):
worldXZ = center(d) + offset(d) * vx
worldY = roadSurfaceY(d) + vy
v.position = Vector3(worldXZ.x, worldY, worldXZ.z)
// UV — longitudinal U from halfEdgeU (phase-continuous), lateral V
// scaled by the effective width:
v.uv.x = (d <= L1) ? halfEdgeU(H1, graph, L1 - d)
: halfEdgeU(H2, graph, d - L1)
v.uv.y = v.uv.y * width(d) + in1
// Normal — rotate template-forward (-Z) to segment direction by the
// SIGNED angle around Y:
segDir = (d <= L1) ? dir1 : dir2
theta = atan2(-segDir.x, -segDir.z)
Ogre::Quaternion q(Ogre::Radian(theta), 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**: no extrusion. After Phase 2+3 the strip is already a
closed tube around the road body — the template supplies top, bottom
and curb faces, and `appendTemplateCopy` drops only the template
caps and the centerline wall (the open ends butt exactly against the
neighbouring pieces at the edge midpoints, the open centerline side
against the adjacent wedge). The transformed strip is appended to
the output verbatim. (Re-extruding it additionally stacked coplanar
sheets at the strip's center surface and doubled the slab
thickness.)
- **Segment**: the center-surface band (§7) is flat, so it is passed
to `extrudeToSlab`, keeping the far-end edge open (it meets the
neighbour node's piece exactly).
In the segment case 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 road 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 |
| Curb pinned at miter corner K (§5.4) | Inner-corner cross-section fold (overlapping geometry) | 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 | Segments |
## 10. Internal Functions (Testable)
All of these live in namespace `RoadGeometryLib`
(`roadlib/RoadGeometryLib.cpp`); the public `RoadSystem` statics for
the entry points forward to them.
```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) | (3,0,3) — pinned at K | (3,0,0) |
| 270° wedge, w1=w2=3 | dir1=+Z, dir2=+X | (-3,0,0) | (-3,0,-3) — blend through K | (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) | (3,0,6) — pinned at K | (3,0,0) |
| Blend zone continuity | Any | offset varies with d | no discontinuity at L1 | — |
For converging (inner) wedges the offset at the zone boundaries is
exactly offA (at d = L1 - t1) and offB (at d = L1 + t2); inside the
zone it is `K - center(d)`.
### 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 at (3,y,3), node vertex at (0,y,0), Y∈[-0.15,+0.15] (no second extrusion) |
| 135° converging corner, flat and with corner node raised | No coplanar-overlapping or piercing triangle pairs in any wedge or segment (fold regression) |
| 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.
## 14. Standalone Demo (RoadGeometryDemo)
Target `RoadGeometryDemo` (`road_demo/main.cpp`) is a small OGRE +
ImGui application for interactive inspection of the wedge pipeline.
It links only `RoadGeometryLib` — no ECS, terrain or physics — so it
builds and starts fast.
- Three world-space points A, B, C define two road edges AB and BC.
Sliders adjust every point coordinate (including Y, for height
differences at the corner node); the two wedges at node B (smaller
and larger sweep) are rebuilt live via
`RoadGeometryLib::buildWedgeGeometry`.
- Road configuration sliders: lane width, lanes per direction, road
thickness.
- "Wedge to display" radio: Smaller / Larger / Both.
- "Template Mesh" panel: type an OGRE `.mesh` resource name and press
**Load** — the mesh is loaded through
`RoadGeometryLib::loadTemplateFromMesh` and normalised into template
space per §2 (convention violations are logged but the mesh is still
used). Enable **Use custom template** to build the wedges with it
instead of the generated fallback box (§2); a status line shows the
current template state.
- The generated slab is drawn solid plus a wireframe overlay, with
visual aids for the nodes, edge midpoints and edge lines.
- Camera: right-drag to orbit, mouse wheel to zoom, ESC to exit.
Build and run:
```bash
cmake --build <build-dir> --target RoadGeometryDemo
./RoadGeometryDemo
```
@@ -279,34 +279,39 @@ interactive mode (with display).
These are the concrete code changes needed to close all gaps. Each item is
ordered by dependency.
| # | Item | Files to modify | Depends on |
|---|------|-----------------|------------|
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | — |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | W1 |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W1 |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | |
| # | Item | Files to modify | Status |
|---|------|-----------------|--------|
| 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 |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W6 |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | W3, W4, W7 |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | ✅ DONE |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | ✅ DONE |
## 5. Summary
| Area | Status |
|------|--------|
| M5.1M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.9 automated coverage | ⚠️ Partial → W4 adds raycast+rebuild verification |
| 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 | ❌ Missing from implementation → W1+W2 |
| M5.10 automated coverage | ❌ None → W3 |
| M5.11 automated coverage | ❌ None → W6+W7 |
| M5.10 perpendicular falloff | ✅ Implemented → W1+W2 |
| M5.10 automated coverage | ✅ W3 covers falloff + save/load |
| M5.11 automated coverage | ✅ W6+W7 cover prefab spawn + teardown |
| M5.12 automated coverage | ✅ Adequate |
| Manual verification steps | 📋 Defined (sections 3.13.7) |
| Open questions | ✅ All resolved (section 0) |
**Exit criteria** — Milestone 5 is fully verified when:
- [ ] All 8 work items (W1W8) are implemented.
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
existing + new M5 tests green (expect 2223 tests per iteration).
- [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).
- [ ] Manual verification walkthroughs 3.13.7 are executed and pass.
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
- [ ] W5 (road collider debug draw toggle) implemented.
+82 -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 | template-strip polyline sweep in `RoadGeometryLib` (curb pinned at the miter corner for inner wedges), forwarded from `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,95 @@ 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).
**Status update (2026-08-09): inner-corner fold and double extrusion
fixed; implementation consolidated in `RoadGeometryLib`.** Two defects
were found with the standalone demo (`road_demo/main.cpp`, target
`RoadGeometryDemo`; see ProceduralRoadGeometry.md §14):
1. Inner corner wedges (sweep < 180°) self-intersected: blending
cross-sections through the miter corner folded consecutive sections
over each other near the corner (coplanar z-fighting sheets when
flat, grossly intersecting ramps once the two ends differed in
height). The curb is now **pinned at the miter corner K** over the
whole corner zone `[L1 - t1, L1 + t2]` (t1/t2 are the corner
parameters along each curb line — ProceduralRoadGeometry.md
§5.2/§5.4); outer wedges keep the narrow blend through K.
2. Wedge strips were extruded a second time (`extrudeToSlab` on top of
the already closed template tube), doubling the slab thickness and
stacking coplanar sheets. The transformed strip is now appended
verbatim; `extrudeToSlab` remains for the flat segment bands only.
The geometry pipeline now lives once in `roadlib/RoadGeometryLib.cpp`
(namespace `RoadGeometryLib`) — including `loadTemplateFromMesh()`
and the `RoadSystem` statics forward to it, replacing the duplicated
copy in `RoadSystem.cpp`. The `roadWedgeGeometry` headless test gained
a self-intersection checker (coplanar-overlap + piercing triangle
pairs) with a 135° converging-corner regression case (flat and with a
raised corner node) plus wedge slab-thickness bounds; all 22 headless
tests pass.
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, wedge slab-thickness bounds, and a
self-intersection scan — coplanar-overlap + piercing triangle pairs — on
a 135° converging corner, flat and with a raised corner node).
---
+642
View File
@@ -0,0 +1,642 @@
/*
* 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; a custom
* template mesh (OGRE .mesh resource name) can be loaded to preview
* user road cross-sections instead of the generated fallback box.
*
* 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. */
bool m_dirty = true;
/* 0 = smaller-angle wedge, 1 = larger-angle wedge, 2 = both */
int m_wedgeMode = 2;
/* Custom template mesh selection. */
char m_templateName[256] = {};
bool m_useCustomTemplate = false;
bool m_customTemplateLoaded = false;
std::string m_templateStatus = "no custom template loaded";
Procedural::TriangleBuffer m_customTemplate;
};
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);
/* Rebuild if any parameter changed. */
if (m_dirty) {
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;
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;
bool built;
if (m_useCustomTemplate && m_customTemplateLoaded)
built = RoadGeometryLib::buildWedgeGeometry(
w, graph, m_customTemplate, tmp);
else
built = RoadGeometryLib::buildWedgeGeometry(w, graph,
tmp);
if (!built)
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(
"BaseWhite",
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.y##A", &m_pointA.y, -10.0f, 10.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.y##B", &m_pointB.y, -10.0f, 10.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.y##C", &m_pointC.y, -10.0f, 10.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();
ImGui::Text("Template Mesh (optional)");
ImGui::InputText("Mesh name", m_templateName, sizeof(m_templateName));
if (ImGui::Button("Load")) {
Procedural::TriangleBuffer buf;
if (RoadGeometryLib::loadTemplateFromMesh(m_templateName,
buf)) {
m_customTemplate = buf;
m_customTemplateLoaded = true;
m_templateStatus =
std::string("loaded: ") + m_templateName;
} else {
m_customTemplateLoaded = false;
m_useCustomTemplate = false;
m_templateStatus = std::string("load failed: ") +
m_templateName;
}
changed = true;
}
ImGui::TextDisabled("%s", m_templateStatus.c_str());
if (m_customTemplateLoaded &&
ImGui::Checkbox("Use custom template", &m_useCustomTemplate))
changed = true;
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,870 @@
/*
* 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
* ---------------------------------------------------------------- */
/** Centerline position (polyline MA -> O -> MB) at path distance d. */
static Ogre::Vector3 wedgeCenterAt(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;
const Ogre::Vector3 &O = node->position;
float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f;
float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f;
Ogre::Vector3 MA = O + h1.direction * L1;
Ogre::Vector3 MB = O + h2.direction * L2;
if (d <= L1)
return MA + (O - MA) * (d / L1);
return O + (MB - O) * ((d - L1) / L2);
}
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);
/*
* Miter corner: intersection of the two constant-width curb
* lines, expressed as parameters t1/t2 along each direction from
* the node-side curb ends:
*
* offA + dir1 * t1 == offB + dir2 * t2 == cornerOff
*
* t1 > 0 (and t2 > 0) means the curb lines converge ahead of the
* node: the wedge is an inner corner (sweep < 180 deg).
*/
bool hasCorner = false;
bool converging = false;
float t1 = 0.0f;
float t2 = 0.0f;
Ogre::Vector3 cornerOff;
float det = dir1.z * dir2.x - dir1.x * dir2.z;
if (std::fabs(det) >= 0.05f) {
Ogre::Vector3 rhs = offB - offA;
t1 = (dir2.x * rhs.z - dir2.z * rhs.x) / det;
t2 = (dir1.x * rhs.z - dir1.z * rhs.x) / det;
cornerOff = offA + dir1 * t1;
hasCorner = true;
converging = t1 > 0.0f && t2 > 0.0f;
}
/*
* Inner corner with the miter corner K lying on both curb
* segments: pin the curb at K over the whole corner zone
* [L1 - t1, L1 + t2]. Rows before the zone use offA, rows after
* use offB, rows inside aim straight at K (offset K - center(d)).
* A blend through K would fold the cross-sections over each
* other, because the curb arc around the inner corner is shorter
* than the centerline arc; pinned rows share the endpoint K and
* cannot cross. When K falls outside either curb segment (wide
* road on short edges) the old blend is kept instead.
*/
if (converging && t1 <= L1 && t2 <= L2) {
float zoneStart = L1 - t1;
float zoneEnd = L1 + t2;
if (d <= zoneStart)
return offA;
if (d >= zoneEnd)
return offB;
Ogre::Vector3 K = node->position + cornerOff;
return K - wedgeCenterAt(wedge, graph, d);
}
/* 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;
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 L = L1 + (h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f);
float in1 = h1.lanesIn * graph.config.laneWidth;
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 = wedgeCenterAt(wedge, graph, d);
/* 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);
/*
* The transformed strip is already a closed tube around the road
* body (the template supplies top, bottom and curb faces; the
* template caps and centerline wall are dropped by
* appendTemplateCopy and butt exactly against the neighbouring
* pieces), so it is appended verbatim. Re-extruding it into a
* slab would double the road thickness and stack coplanar sheets
* at the strip's center surface.
*/
int base = (int)out.getVertices().size();
for (const auto &v : strip.getVertices())
out.getVertices().push_back(v);
for (int idx : strip.getIndices())
out.getIndices().push_back(base + idx);
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;
}
/* ----------------------------------------------------------------
* Template mesh loading
* ---------------------------------------------------------------- */
bool loadTemplateFromMesh(const std::string &meshName,
Procedural::TriangleBuffer &out)
{
if (meshName.empty())
return false;
Ogre::MeshPtr mesh;
try {
mesh = Ogre::MeshManager::getSingleton().load(
meshName,
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"RoadGeometryLib: road mesh template '" + meshName +
"' unavailable (" + e.what() +
"), using fallback box");
return false;
}
if (!mesh || mesh->getNumSubMeshes() == 0)
return false;
Procedural::TriangleBuffer tb;
for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) {
Ogre::SubMesh *sub = mesh->getSubMesh(si);
Ogre::VertexData *vd = sub->useSharedVertices ?
mesh->sharedVertexData :
sub->vertexData;
if (!vd || !sub->indexData || !sub->indexData->indexBuffer)
continue;
const Ogre::VertexElement *posElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_POSITION);
if (!posElem)
continue;
const Ogre::VertexElement *normElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_NORMAL);
const Ogre::VertexElement *uvElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_TEXTURE_COORDINATES, 0);
int base = (int)tb.getVertices().size();
tb.getVertices().reserve(base + vd->vertexCount);
/* Positions (required). */
{
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
posElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
posElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
Procedural::TriangleBuffer::Vertex tv;
tv.mPosition =
Ogre::Vector3(p[0], p[1], p[2]);
tv.mNormal = Ogre::Vector3::UNIT_Y;
/* Convention fallback: UVs span the X/Z
* extents. */
tv.mUV = Ogre::Vector2(p[0], p[2]);
tb.getVertices().push_back(tv);
}
vbuf->unlock();
}
/* Normals (optional). */
if (normElem) {
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
normElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
normElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
tb.getVertices()[base + v].mNormal =
Ogre::Vector3(p[0], p[1], p[2]);
}
vbuf->unlock();
}
/* UVs (optional). */
if (uvElem) {
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
uvElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
uvElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
tb.getVertices()[base + v].mUV =
Ogre::Vector2(p[0], p[1]);
}
vbuf->unlock();
}
/* Indices (16- or 32-bit). */
Ogre::HardwareIndexBufferSharedPtr ibuf =
sub->indexData->indexBuffer;
size_t start = sub->indexData->indexStart;
size_t count = sub->indexData->indexCount;
tb.getIndices().reserve(tb.getIndices().size() + count);
if (ibuf->getType() == Ogre::HardwareIndexBuffer::IT_16BIT) {
const uint16_t *p = static_cast<const uint16_t *>(
ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t i = start; i < start + count; ++i)
tb.getIndices().push_back(base + (int)p[i]);
ibuf->unlock();
} else {
const uint32_t *p = static_cast<const uint32_t *>(
ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t i = start; i < start + count; ++i)
tb.getIndices().push_back(base + (int)p[i]);
ibuf->unlock();
}
}
if (tb.getVertices().empty() || tb.getIndices().empty())
return false;
/*
* 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);
}
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(
"RoadGeometryLib: road mesh template '" + meshName +
"' violates the template conventions "
"(X [0,1] lateral, Z [-1,0] longitudinal, unit "
"extents); spans are (" +
Ogre::StringConverter::toString(span) +
"), using it anyway");
}
out = tb;
return true;
}
} // namespace RoadGeometryLib
@@ -0,0 +1,163 @@
/*
* 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 the
* outer-curb offset following the mitered curb chain (pinned at the
* miter corner for inner wedges so cross-sections cannot fold).
* The transformed strip is already a closed tube around the road
* body (the template supplies top/bottom/curb faces), so it is
* appended verbatim no second extrusion is applied.
*
* @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);
/**
* Load a road cross-section template from an OGRE mesh: the mesh
* triangles are read verbatim and normalised into template space
* (X in [0, span] with X=0 at the centerline side, Z in [-span, 0]
* with 0 at the wedge start). Meshes violating the unit-extent
* template conventions are warned about but still used. Returns
* false when the mesh could not be loaded or has no usable
* geometry. Used by RoadSystem and by the road demo for
* user-selected template meshes.
*/
bool loadTemplateFromMesh(const std::string &meshName,
Procedural::TriangleBuffer &out);
/* ----------------------------------------------------------------
* 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
+75 -614
View File
@@ -10,6 +10,7 @@
#include "../components/Lod.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../physics/physics.h"
#include "../roadlib/RoadGeometryLib.hpp"
#include "PrefabSystem.hpp"
#include <OgreTerrainGroup.h>
#include <OgreMaterialManager.h>
@@ -915,619 +916,50 @@ RoadSystem::getRoadTemplate(const RoadConfig &cfg)
m_templateThickness = cfg.roadThickness;
m_templateBuffer = Procedural::TriangleBuffer();
if (!loadTemplateFromMesh(cfg.roadMeshTemplate))
buildFallbackTemplate(cfg.roadThickness);
if (!RoadGeometryLib::loadTemplateFromMesh(cfg.roadMeshTemplate,
m_templateBuffer))
m_templateBuffer =
RoadGeometryLib::makeFallbackTemplate(cfg.roadThickness);
return m_templateBuffer;
}
bool RoadSystem::loadTemplateFromMesh(const std::string &meshName)
Procedural::TriangleBuffer
RoadSystem::makeFallbackTemplate(float roadThickness)
{
if (meshName.empty())
return false;
Ogre::MeshPtr mesh;
try {
mesh = Ogre::MeshManager::getSingleton().load(
meshName,
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"RoadSystem: road mesh template '" + meshName +
"' unavailable (" + e.what() +
"), using fallback box");
return false;
}
if (!mesh || mesh->getNumSubMeshes() == 0)
return false;
Procedural::TriangleBuffer tb;
for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) {
Ogre::SubMesh *sub = mesh->getSubMesh(si);
Ogre::VertexData *vd = sub->useSharedVertices ?
mesh->sharedVertexData :
sub->vertexData;
if (!vd || !sub->indexData || !sub->indexData->indexBuffer)
continue;
const Ogre::VertexElement *posElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_POSITION);
if (!posElem)
continue;
const Ogre::VertexElement *normElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_NORMAL);
const Ogre::VertexElement *uvElem =
vd->vertexDeclaration->findElementBySemantic(
Ogre::VES_TEXTURE_COORDINATES, 0);
int base = (int)tb.getVertices().size();
tb.getVertices().reserve(base + vd->vertexCount);
/* Positions (required). */
{
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
posElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
posElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
Procedural::TriangleBuffer::Vertex tv;
tv.mPosition =
Ogre::Vector3(p[0], p[1], p[2]);
tv.mNormal = Ogre::Vector3::UNIT_Y;
/* Convention fallback: UVs span the X/Z
* extents. */
tv.mUV = Ogre::Vector2(p[0], p[2]);
tb.getVertices().push_back(tv);
}
vbuf->unlock();
}
/* Normals (optional). */
if (normElem) {
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
normElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
normElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
tb.getVertices()[base + v].mNormal =
Ogre::Vector3(p[0], p[1], p[2]);
}
vbuf->unlock();
}
/* UVs (optional). */
if (uvElem) {
Ogre::HardwareVertexBufferSharedPtr vbuf =
vd->vertexBufferBinding->getBuffer(
uvElem->getSource());
unsigned char *data = static_cast<unsigned char *>(
vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t v = 0; v < vd->vertexCount; ++v) {
float *p;
uvElem->baseVertexPointerToElement(
data + v * vbuf->getVertexSize(), &p);
tb.getVertices()[base + v].mUV =
Ogre::Vector2(p[0], p[1]);
}
vbuf->unlock();
}
/* Indices (16- or 32-bit). */
Ogre::HardwareIndexBufferSharedPtr ibuf =
sub->indexData->indexBuffer;
size_t start = sub->indexData->indexStart;
size_t count = sub->indexData->indexCount;
tb.getIndices().reserve(tb.getIndices().size() + count);
if (ibuf->getType() == Ogre::HardwareIndexBuffer::IT_16BIT) {
const uint16_t *p = static_cast<const uint16_t *>(
ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t i = start; i < start + count; ++i)
tb.getIndices().push_back(base + (int)p[i]);
ibuf->unlock();
} else {
const uint32_t *p = static_cast<const uint32_t *>(
ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (size_t i = start; i < start + count; ++i)
tb.getIndices().push_back(base + (int)p[i]);
ibuf->unlock();
}
}
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. */
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::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) +
"), using it anyway");
}
m_templateBuffer = tb;
return true;
}
void RoadSystem::buildFallbackTemplate(float roadThickness)
{
float h = std::max(0.01f, roadThickness) * 0.5f;
m_templateBuffer = Procedural::TriangleBuffer();
auto &verts = m_templateBuffer.getVertices();
auto &indices = m_templateBuffer.getIndices();
verts.reserve(24);
indices.reserve(36);
struct Corner {
Ogre::Vector3 p;
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). */
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): 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 } },
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 } },
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 } },
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 } },
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 } },
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 } },
Ogre::Vector3::NEGATIVE_UNIT_X);
return RoadGeometryLib::makeFallbackTemplate(roadThickness);
}
/* ------------------------------------------------------------------ */
/* Wedge / segment geometry generation (M5.6) */
/* */
/* All geometry generation lives in RoadGeometryLib */
/* (roadlib/RoadGeometryLib.cpp); the RoadSystem methods below */
/* forward to it so headless tests can keep calling the RoadSystem */
/* statics. */
/* ------------------------------------------------------------------ */
/**
* Right-of-travel direction for a horizontal road direction @p d.
*
* Template convention: +X forward x +Y up = +Z right, so for any
* normalized horizontal direction the right side is d x UNIT_Y. This is
* also the direction of increasing atan2(z, x) angle, i.e. the side a
* wedge sweeps toward from its first half-edge.
*/
static Ogre::Vector3 roadRightVec(const Ogre::Vector3 &d)
{
return d.crossProduct(Ogre::Vector3::UNIT_Y);
}
/**
* Absolute road surface heights at both ends of a half-edge.
*
* yNode is the surface height at the seed node; yMid is the surface
* height at the edge midpoint, averaged between the linearly
* interpolated heights of both edge ends.
*/
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);
}
/** Road surface height at distance @p t along a half-edge. */
static 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);
}
/**
* Along-road texture coordinate at distance @p t from the seed node.
*
* For the nodeA half of an edge this is simply t; for the nodeB half it
* is 2*halfLength - t, so u stays phase-continuous across the edge
* midpoint where the two halves meet.
*/
static 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;
}
/** One center-surface triangle with UVs, input to emitSlab(). */
struct RoadSurfTri {
Ogre::Vector3 p[3];
Ogre::Vector2 uv[3];
};
/** One exposed center-surface boundary edge that gets a side skirt. */
struct RoadSkirtEdge {
Ogre::Vector3 p0, p1;
Ogre::Vector2 uv0, uv1;
};
/** 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.
*
* Every triangle is emitted twice: offset by +halfThick along Y with the
* winding chosen so the normal points up, and offset by -halfThick with
* the opposite winding. Each listed boundary edge grows a vertical
* skirt quad whose normal points away from @p refPoint (an interior
* reference, e.g. the primitive's centroid).
*/
static void emitSlab(Procedural::TriangleBuffer &out,
const std::vector<RoadSurfTri> &tris,
const std::vector<RoadSkirtEdge> &skirts, float halfThick,
const Ogre::Vector3 &refPoint)
{
Ogre::Vector3 up(0.0f, halfThick, 0.0f);
for (const RoadSurfTri &t : tris) {
Ogre::Vector3 n =
(t.p[1] - t.p[0]).crossProduct(t.p[2] - t.p[0]);
if (n.squaredLength() < 1e-10f)
continue;
int i1 = n.y >= 0.0f ? 1 : 2;
int i2 = n.y >= 0.0f ? 2 : 1;
/* Top surface. */
emitTri(out, t.p[0] + up, t.p[i1] + up, t.p[i2] + up,
t.uv[0], t.uv[i1], t.uv[i2]);
/* Bottom surface, flipped. */
emitTri(out, t.p[0] - up, t.p[i2] - up, t.p[i1] - up,
t.uv[0], t.uv[i2], t.uv[i1]);
}
float thickness = 2.0f * halfThick;
for (const RoadSkirtEdge &e : skirts) {
Ogre::Vector3 t0 = e.p0 + up;
Ogre::Vector3 t1 = e.p1 + up;
Ogre::Vector3 b0 = e.p0 - up;
Ogre::Vector3 b1 = e.p1 - up;
Ogre::Vector2 uvB0(e.uv0.x, e.uv0.y - thickness);
Ogre::Vector2 uvB1(e.uv1.x, e.uv1.y - thickness);
Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0);
if (n.squaredLength() < 1e-10f)
continue;
Ogre::Vector3 mid = 0.5f * (t0 + t1);
bool outward = n.dotProduct(mid - refPoint) >= 0.0f;
if (outward) {
emitTri(out, t0, t1, b1, e.uv0, e.uv1, uvB1);
emitTri(out, t0, b1, b0, e.uv0, uvB1, uvB0);
} else {
emitTri(out, t0, b1, t1, e.uv0, uvB1, e.uv1);
emitTri(out, t0, b0, b1, e.uv0, uvB0, uvB1);
}
}
}
/** Overlap of strip quads behind the node so no seam gap shows. */
static const float ROAD_SEAM_OVERLAP = 0.05f;
bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge,
const RoadGraph &graph,
Procedural::TriangleBuffer &out)
{
if (wedge.degenerate) {
Ogre::LogManager::getSingleton().logMessage(
"RoadSystem: skipping degenerate road wedge at node " +
Ogre::StringConverter::toString(wedge.nodeId));
return false;
}
return RoadGeometryLib::buildWedgeGeometry(wedge, graph, out);
}
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 halfThick =
std::max(0.01f, graph.config.roadThickness) * 0.5f;
float out1 = h1.lanesOut * lw;
float in1 = h1.lanesIn * lw;
float in2 = h2.lanesIn * lw;
float L1 = h1.halfLength;
float L2 = h2.halfLength;
/*
* The wedge region is the union of two one-sided band strips:
* strip1 = O + t*d1 + s*r1, t in [0, L1], s in [0, out1] (h1's
* outbound side) and strip2 = O + t*d2 + s*r2, t in [0, L2],
* s in [-in2, 0] (h2's inbound side). For swept angles up to
* 180 degrees the union is an L-shaped hexagon with a single
* outer corner X where the two outer curbs intersect; emit it as
* a triangle fan around X. Otherwise fall back to two
* independent strip quads with their own curb and cap skirts.
*/
/* Outer corner: X = O + t1*d1 + out1*r1 = O + t2*d2 - in2*r2. */
Ogre::Vector3 rhs = -in2 * r2 - out1 * r1;
float det = d1.z * d2.x - d1.x * d2.z;
float t1 = 0.0f, t2 = 0.0f;
bool cornerOk = false;
if (std::fabs(det) >= 0.05f) {
t1 = (-rhs.x * d2.z + d2.x * rhs.z) / det;
t2 = (d1.x * rhs.z - rhs.x * d1.z) / det;
cornerOk = t1 >= 0.0f && t1 <= L1 && t2 >= 0.0f &&
t2 <= L2;
}
if (cornerOk && wedge.sweptAngleDeg <= 180.0f) {
float yNode1, yMid1, yNode2, yMid2;
halfEdgeHeights(h1, graph, yNode1, yMid1);
halfEdgeHeights(h2, graph, yNode2, yMid2);
Ogre::Vector3 vX = O + t1 * d1 + out1 * r1;
float yO = 0.5f * (yNode1 + yNode2);
float yX = 0.5f * (halfEdgeHeightAt(h1, graph, t1) +
halfEdgeHeightAt(h2, graph, t2));
Ogre::Vector3 poly[6] = {
O, O + L1 * d1, O + L1 * d1 + out1 * r1,
vX, O + L2 * d2 - in2 * r2, O + L2 * d2
};
float polyY[6] = { yO, yMid1, yMid1, yX, yMid2, yMid2 };
/* Planar UVs in the (d1, r1) frame. */
Ogre::Vector2 polyUV[6];
for (int i = 0; i < 6; ++i) {
Ogre::Vector3 rel = poly[i] - O;
polyUV[i] = Ogre::Vector2(rel.dotProduct(d1),
rel.dotProduct(r1) + in1);
}
std::vector<RoadSurfTri> tris;
for (int i = 0; i < 6; ++i) {
int j = (i + 1) % 6;
RoadSurfTri tri;
tri.p[0] = Ogre::Vector3(vX.x, yX, vX.z);
tri.p[1] =
Ogre::Vector3(poly[i].x, polyY[i], poly[i].z);
tri.p[2] =
Ogre::Vector3(poly[j].x, polyY[j], poly[j].z);
tri.uv[0] = polyUV[3];
tri.uv[1] = polyUV[i];
tri.uv[2] = polyUV[j];
tris.push_back(tri);
}
/*
* The fan closes the whole L-shape; the only exposed
* boundary is the two outer curbs meeting at X.
*/
std::vector<RoadSkirtEdge> skirts;
RoadSkirtEdge curb1;
curb1.p0 = Ogre::Vector3(poly[2].x, polyY[2], poly[2].z);
curb1.p1 = Ogre::Vector3(vX.x, yX, vX.z);
curb1.uv0 = polyUV[2];
curb1.uv1 = polyUV[3];
skirts.push_back(curb1);
RoadSkirtEdge curb2;
curb2.p0 = Ogre::Vector3(vX.x, yX, vX.z);
curb2.p1 = Ogre::Vector3(poly[4].x, polyY[4], poly[4].z);
curb2.uv0 = polyUV[3];
curb2.uv1 = polyUV[4];
skirts.push_back(curb2);
emitSlab(out, tris, skirts, halfThick, O);
return true;
}
/* Fallback: two independent one-sided strip quads. */
float t0 = -ROAD_SEAM_OVERLAP;
struct StripDef {
const RoadHalfEdge &he;
const Ogre::Vector3 &d;
const Ogre::Vector3 &r;
float s0, s1; /* lateral range along r */
float L;
};
StripDef strips[2] = { { h1, d1, r1, 0.0f, out1, L1 },
{ h2, d2, r2, -in2, 0.0f, L2 } };
for (const StripDef &sd : strips) {
Ogre::Vector3 c00 = O + t0 * sd.d + sd.s0 * sd.r;
Ogre::Vector3 c10 = O + sd.L * sd.d + sd.s0 * sd.r;
Ogre::Vector3 c11 = O + sd.L * sd.d + sd.s1 * sd.r;
Ogre::Vector3 c01 = O + t0 * sd.d + sd.s1 * sd.r;
float y0 = halfEdgeHeightAt(sd.he, graph, t0);
float yL = halfEdgeHeightAt(sd.he, graph, sd.L);
c00.y = c01.y = y0;
c10.y = c11.y = yL;
float inW = sd.he.lanesIn * lw;
Ogre::Vector2 uv00(halfEdgeU(sd.he, graph, t0), sd.s0 + inW);
Ogre::Vector2 uv10(halfEdgeU(sd.he, graph, sd.L),
sd.s0 + inW);
Ogre::Vector2 uv11(halfEdgeU(sd.he, graph, sd.L),
sd.s1 + inW);
Ogre::Vector2 uv01(halfEdgeU(sd.he, graph, t0), sd.s1 + inW);
std::vector<RoadSurfTri> tris;
RoadSurfTri tA;
tA.p[0] = c00; tA.p[1] = c10; tA.p[2] = c11;
tA.uv[0] = uv00; tA.uv[1] = uv10; tA.uv[2] = uv11;
tris.push_back(tA);
RoadSurfTri tB;
tB.p[0] = c00; tB.p[1] = c11; tB.p[2] = c01;
tB.uv[0] = uv00; tB.uv[1] = uv11; tB.uv[2] = uv01;
tris.push_back(tB);
std::vector<RoadSkirtEdge> skirts;
RoadSkirtEdge curb;
curb.p0 = c01; curb.p1 = c11;
curb.uv0 = uv01; curb.uv1 = uv11;
skirts.push_back(curb);
RoadSkirtEdge cap;
cap.p0 = c00; cap.p1 = c01;
cap.uv0 = uv00; cap.uv1 = uv01;
skirts.push_back(cap);
Ogre::Vector3 ref = 0.25f * (c00 + c10 + c11 + c01);
emitSlab(out, tris, skirts, halfThick, ref);
}
return true;
bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge,
const RoadGraph &graph,
const Procedural::TriangleBuffer &templ,
Procedural::TriangleBuffer &out)
{
return RoadGeometryLib::buildWedgeGeometry(wedge, graph, templ, out);
}
bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment,
const RoadGraph &graph,
Procedural::TriangleBuffer &out)
{
const RoadNode *node = graph.findNodeById(segment.nodeId);
if (!node)
return false;
const RoadHalfEdge &he = segment.halfEdge;
const Ogre::Vector3 &O = node->position;
Ogre::Vector3 d = he.direction;
Ogre::Vector3 r = roadRightVec(d);
float lw = graph.config.laneWidth;
float halfThick =
std::max(0.01f, graph.config.roadThickness) * 0.5f;
float inW = he.lanesIn * lw;
float outW = he.lanesOut * lw;
float L = he.halfLength;
float t0 = -ROAD_SEAM_OVERLAP;
/* Full-width band: s in [-inW, +outW], t in [t0, L]. */
Ogre::Vector3 c00 = O + t0 * d - inW * r;
Ogre::Vector3 c10 = O + L * d - inW * r;
Ogre::Vector3 c11 = O + L * d + outW * r;
Ogre::Vector3 c01 = O + t0 * d + outW * r;
float y0 = halfEdgeHeightAt(he, graph, t0);
float yL = halfEdgeHeightAt(he, graph, L);
c00.y = c01.y = y0;
c10.y = c11.y = yL;
Ogre::Vector2 uv00(halfEdgeU(he, graph, t0), 0.0f);
Ogre::Vector2 uv10(halfEdgeU(he, graph, L), 0.0f);
Ogre::Vector2 uv11(halfEdgeU(he, graph, L), inW + outW);
Ogre::Vector2 uv01(halfEdgeU(he, graph, t0), inW + outW);
std::vector<RoadSurfTri> tris;
RoadSurfTri tA;
tA.p[0] = c00; tA.p[1] = c10; tA.p[2] = c11;
tA.uv[0] = uv00; tA.uv[1] = uv10; tA.uv[2] = uv11;
tris.push_back(tA);
RoadSurfTri tB;
tB.p[0] = c00; tB.p[1] = c11; tB.p[2] = c01;
tB.uv[0] = uv00; tB.uv[1] = uv11; tB.uv[2] = uv01;
tris.push_back(tB);
/* Skirts: node-end cap plus both curbs (not the far end). */
std::vector<RoadSkirtEdge> skirts;
RoadSkirtEdge cap;
cap.p0 = c00; cap.p1 = c01;
cap.uv0 = uv00; cap.uv1 = uv01;
skirts.push_back(cap);
RoadSkirtEdge curbIn;
curbIn.p0 = c00; curbIn.p1 = c10;
curbIn.uv0 = uv00; curbIn.uv1 = uv10;
skirts.push_back(curbIn);
RoadSkirtEdge curbOut;
curbOut.p0 = c01; curbOut.p1 = c11;
curbOut.uv0 = uv01; curbOut.uv1 = uv11;
skirts.push_back(curbOut);
Ogre::Vector3 ref = 0.25f * (c00 + c10 + c11 + c01);
emitSlab(out, tris, skirts, halfThick, ref);
return true;
return RoadGeometryLib::buildSegmentGeometry(segment, graph, out);
}
/* ------------------------------------------------------------------ */
@@ -1604,45 +1036,74 @@ 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<TerrainComponent>())
return;
const RoadGraph &rg = terrain.get<TerrainComponent>().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<TerrainComponent>().roadGraph, buf))
Procedural::TriangleBuffer tmp;
if (!buildWedgeGeometry(wedge, rg, fb, tmp))
continue;
const auto &verts = buf.getVertices();
/* Write fixups from the generated top-surface
* vertices: sample the Y of vertices whose normal
* points up and write target = Y - roadThickness
* underneath them (the slab bottom). */
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<TerrainComponent>().roadGraph, buf))
Ogre::Vector3 c[4];
Ogre::Vector2 uvc[4];
if (!RoadGeometryLib::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);
}
}
}
}
+47 -18
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,38 +133,69 @@ 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 following the mitered curb chain pinned
* at the miter corner for inner wedges so cross-sections cannot
* fold, blended through the miter corner for outer wedges 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).
*
* All of these forward to RoadGeometryLib
* (roadlib/RoadGeometryLib.cpp), which holds the single
* implementation.
*/
static bool buildWedgeGeometry(const RoadWedge &wedge,
const RoadGraph &graph,
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,
Procedural::TriangleBuffer &out);
/**
* Create a unit-box template for headless tests and fallback.
*
* 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);
/**
* Bind the terrain group used for page tracking (M5.4).
*
@@ -256,9 +288,6 @@ private:
/* Roadside prefab spawning (M5.11). */
void spawnSidePrefabs(RoadPageGeometry &pg);
bool loadTemplateFromMesh(const std::string &meshName);
void buildFallbackTemplate(float roadThickness);
Ogre::Vector3 getNodePosition(int nodeId) const;
bool getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
Ogre::Vector3 &outB) const;
+397 -32
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;
@@ -1941,6 +1941,190 @@ bool TerrainTestRunner::testRoadPageAssignment(EditorApp &app,
return true;
}
/* ------------------------------------------------------------------ */
/* Road slab self-intersection analysis */
/* */
/* Counts COPLANAR pairs (two triangles on the same plane overlapping */
/* in area — z-fighting duplicates) and CROSSING pairs (a triangle */
/* edge properly piercing another triangle's interior — */
/* interpenetrating sheets). Triangles sharing vertices or edges are */
/* not counted. Used by testRoadWedgeGeometry. */
/* ------------------------------------------------------------------ */
namespace {
typedef std::pair<double, double> P2; /* (x, z) */
double cross2d(const P2 &a, const P2 &b)
{
return a.first * b.second - a.second * b.first;
}
std::vector<P2> clipHalfPlane(const std::vector<P2> &poly, const P2 &a,
const P2 &b)
{
std::vector<P2> out;
if (poly.empty())
return out;
P2 edge(b.first - a.first, b.second - a.second);
auto inside = [&](const P2 &p) {
P2 rel(p.first - a.first, p.second - a.second);
return cross2d(edge, rel) >= 0.0;
};
auto intersect = [&](const P2 &p0, const P2 &p1) {
P2 e0(p0.first - a.first, p0.second - a.second);
P2 e1(p1.first - a.first, p1.second - a.second);
double d0 = cross2d(edge, e0);
double d1 = cross2d(edge, e1);
double t = d0 / (d0 - d1);
return P2(p0.first + (p1.first - p0.first) * t,
p0.second + (p1.second - p0.second) * t);
};
for (size_t i = 0; i < poly.size(); ++i) {
const P2 &cur = poly[i];
const P2 &prv = poly[(i + poly.size() - 1) % poly.size()];
bool inCur = inside(cur), inPrv = inside(prv);
if (inCur) {
if (!inPrv)
out.push_back(intersect(prv, cur));
out.push_back(cur);
} else if (inPrv) {
out.push_back(intersect(prv, cur));
}
}
return out;
}
double polyArea(const std::vector<P2> &poly)
{
if (poly.size() < 3)
return 0.0;
double s = 0.0;
for (size_t i = 0; i < poly.size(); ++i) {
const P2 &p = poly[i];
const P2 &q = poly[(i + 1) % poly.size()];
s += p.first * q.second - q.first * p.second;
}
return 0.5 * s;
}
double triOverlapAreaXZ(const Ogre::Vector3 t0[3], const Ogre::Vector3 t1[3])
{
std::vector<P2> subject;
for (int i = 0; i < 3; ++i)
subject.push_back(P2(t0[i].x, t0[i].z));
std::vector<P2> clip;
for (int i = 0; i < 3; ++i)
clip.push_back(P2(t1[i].x, t1[i].z));
if (polyArea(clip) < 0.0)
std::reverse(clip.begin(), clip.end());
std::vector<P2> poly = subject;
for (int e = 0; e < 3 && !poly.empty(); ++e)
poly = clipHalfPlane(poly, clip[e], clip[(e + 1) % 3]);
return std::fabs(polyArea(poly));
}
/* Barycentric coords of p in triangle (a,b,c); false if degenerate. */
bool bary(const Ogre::Vector3 &p, const Ogre::Vector3 &a,
const Ogre::Vector3 &b, const Ogre::Vector3 &c, float &u, float &v,
float &w)
{
Ogre::Vector3 v0 = b - a, v1 = c - a, v2 = p - a;
float d00 = v0.dotProduct(v0);
float d01 = v0.dotProduct(v1);
float d11 = v1.dotProduct(v1);
float d20 = v2.dotProduct(v0);
float d21 = v2.dotProduct(v1);
float denom = d00 * d11 - d01 * d01;
if (std::fabs(denom) < 1e-12f)
return false;
v = (d11 * d20 - d01 * d21) / denom;
w = (d00 * d21 - d01 * d20) / denom;
u = 1.0f - v - w;
return true;
}
/* Segment (p0,p1) vs triangle (a,b,c): proper interior piercing test.
* The intersection must be strictly inside the triangle and strictly
* inside the segment (shared vertices/edges do not count). */
bool segTriPierce(const Ogre::Vector3 &p0, const Ogre::Vector3 &p1,
const Ogre::Vector3 &a, const Ogre::Vector3 &b,
const Ogre::Vector3 &c)
{
Ogre::Vector3 n = (b - a).crossProduct(c - a);
float len = n.length();
if (len < 1e-8f)
return false;
n /= len;
float d0 = n.dotProduct(p0 - a);
float d1 = n.dotProduct(p1 - a);
if (d0 * d1 >= 0.0f)
return false; /* same side or touching */
float t = d0 / (d0 - d1);
if (t < 1e-4f || t > 1.0f - 1e-4f)
return false;
Ogre::Vector3 p = p0 + (p1 - p0) * t;
float u, v, w;
if (!bary(p, a, b, c, u, v, w))
return false;
if (u < 1e-4f || v < 1e-4f || w < 1e-4f)
return false;
return true;
}
void countSlabOverlaps(const Procedural::TriangleBuffer &buf, int &coplanar,
int &crossing)
{
coplanar = 0;
crossing = 0;
const auto &verts = buf.getVertices();
const auto &indices = buf.getIndices();
size_t nTri = indices.size() / 3;
for (size_t i = 0; i < nTri; ++i) {
Ogre::Vector3 t0[3];
for (int k = 0; k < 3; ++k)
t0[k] = verts[(size_t)indices[i * 3 + k]].mPosition;
Ogre::Vector3 n0 = (t0[1] - t0[0]).crossProduct(t0[2] - t0[0]);
float l0 = n0.length();
if (l0 > 1e-8f)
n0 /= l0;
for (size_t j = i + 1; j < nTri; ++j) {
Ogre::Vector3 t1[3];
for (int k = 0; k < 3; ++k)
t1[k] = verts[(size_t)indices[j * 3 + k]]
.mPosition;
Ogre::Vector3 n1 =
(t1[1] - t1[0]).crossProduct(t1[2] - t1[0]);
float l1 = n1.length();
if (l1 > 1e-8f)
n1 /= l1;
if (std::fabs(n0.dotProduct(n1)) > 0.9999f) {
/* Parallel planes: coplanar z-fight check. */
float dist =
std::fabs(n0.dotProduct(t1[0] - t0[0]));
if (dist < 1e-3f &&
triOverlapAreaXZ(t0, t1) > 1e-3) {
++coplanar;
continue;
}
}
bool pierce = false;
for (int e = 0; e < 3 && !pierce; ++e)
pierce = segTriPierce(t0[e], t0[(e + 1) % 3],
t1[0], t1[1], t1[2]);
for (int e = 0; e < 3 && !pierce; ++e)
pierce = segTriPierce(t1[e], t1[(e + 1) % 3],
t0[0], t0[1], t0[2]);
if (pierce)
++crossing;
}
}
}
} // namespace
bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app,
TerrainSystem *ts)
{
@@ -2032,6 +2216,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.
@@ -2068,10 +2288,12 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app,
}
/*
* Case 3: 90-degree wedge fans around the outer corner without
* overshooting the L-shape. Corner at origin, neighbors at +X
* and +Z, default 1+1 lanes -> outer corner at (3, y, 3), all
* vertices inside [0, 10]^2 in XZ.
* 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;
@@ -2105,58 +2327,201 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app,
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");
/* The template supplies the slab thickness: top and bottom
* at +/- roadThickness/2, no second extrusion on top. */
if (s.min.y < -0.16f || s.min.y > -0.14f ||
s.max.y < 0.14f || s.max.y > 0.16f)
return fail("90 deg wedge slab thickness wrong "
"(double extrusion?)");
bool sawCorner = false;
bool sawNode = false;
for (const auto &v : buf.getVertices()) {
if (fabsf(v.mPosition.x - 3.0f) < 0.05f &&
fabsf(v.mPosition.z - 3.0f) < 0.05f) {
fabsf(v.mPosition.z - 3.0f) < 0.05f)
sawCorner = true;
break;
}
if (fabsf(v.mPosition.x) < 0.05f &&
fabsf(v.mPosition.z) < 0.05f)
sawNode = true;
}
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 takes the two-strip fallback path. */
/*
* 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, 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.
*/
{
/*
* Case 3b: a converging (inner, sweep < 180 deg) wedge must not
* self-intersect no coplanar duplicate sheets and no piercing
* triangles flat or with a height difference at the corner
* node. Regression test for the miter-corner fold: the curb is
* pinned at the miter corner K through the whole corner zone so
* consecutive cross-sections cannot fold over each other.
*/
{
const float nodeYs[2] = { 0.0f, 5.0f };
for (int iter = 0; iter < 2; ++iter) {
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);
int a3 = rg.addNode(Ogre::Vector3(-10, 0, 0));
int b3 = rg.addNode(
Ogre::Vector3(0, nodeYs[iter], 0));
int c3 = rg.addNode(Ogre::Vector3(10, 0, 10));
rg.addEdge(a3, b3);
rg.addEdge(b3, c3);
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");
bool saw135 = false;
for (const auto &w : wedges) {
if (w.nodeId != b3 || w.degenerate)
continue;
if (fabsf(w.sweptAngleDeg - 135.0f) < 0.1f)
saw135 = true;
Procedural::TriangleBuffer buf;
if (!RoadSystem::buildWedgeGeometry(w, rg,
buf))
return fail("135 deg corner wedge "
"build failed");
int cop = 0, cro = 0;
countSlabOverlaps(buf, cop, cro);
if (cop != 0 || cro != 0)
return fail("converging wedge "
"self-intersects");
}
if (!saw135)
return fail("135 deg wedge not found");
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");
for (const auto &sg : segs) {
Procedural::TriangleBuffer buf;
if (!RoadSystem::buildSegmentGeometry(sg, rg,
buf))
return fail("segment build failed "
"(case 3b)");
int cop = 0, cro = 0;
countSlabOverlaps(buf, cop, cro);
if (cop != 0 || cro != 0)
return fail("segment self-intersects "
"(case 3b)");
}
}
}
/*
* 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);
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, 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;