diff --git a/CMakeLists.txt b/CMakeLists.txt index 42c54d2..ebde4b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 781ec80..bf95cc3 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -402,6 +402,7 @@ target_link_libraries(editSceneEditor RecastNavigation::DetourCrowd RecastNavigation::DebugUtils PackageArchive + RoadGeometryLib lua SDL2::SDL2 ) diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index f49a14a..ea0a311 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -32,9 +32,12 @@ Phase 1: buildConcatenatedStrip(template, N) → Straight strip of N concatenated template copies along -Z. Phase 2: transformWedgeVertices(strip, wedge, graph) - → Bend the strip into the wedge shape. The outer-curb offset is - interpolated through a narrow blend zone at the node, so the - cross-section direction varies continuously — no gaps. + → 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 @@ -55,6 +58,15 @@ static bool buildSegmentGeometry(const RoadStraightSegment &segment, 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)` @@ -104,18 +116,58 @@ in1 = H1.lanesIn * lw // UV lateral offset for continuity yO = O.y + nodeRoadLevel(graph, seedNode) ``` -### 5.2 Blend Zone +### 5.2 Corner Regimes and Blend Zone -A narrow symmetric zone around the node where the outer-curb offset -transitions continuously from `w1 * r1` to `-w2 * r2`: +The two constant-width curb lines ``` -W = min(ROAD_SEAM_OVERLAP * 4, // ~0.2 units — tight, keeps corners sharp - L1 * 0.5f, L2 * 0.5f) // clamped for very short edges +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 ``` -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). +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 @@ -137,45 +189,78 @@ at O is shared between adjacent wedges. ### 5.4 Outer-Curb Offset (Continuous Across the Node) -The outer-curb offset `offset(d)` is the vector from `center(d)` to the -outer curb at distance d. It transitions **continuously** from `w1 * r1` -(H1 side) to `-w2 * r2` (H2 side) through the blend zone: +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) = w1 * r1 + offset(d) = offA elif d >= L1 + W: - offset(d) = -w2 * r2 -else: + offset(d) = offB +elif no corner (|det| < 0.05): t = (d - (L1 - W)) / (2 * W) // 0 → 1 across blend zone - offset(d) = lerp(w1 * r1, -w2 * r2, t) + 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) ``` -Linear vector interpolation works because both `w1*r1` and `-w2*r2` -point into the wedge interior (they are the directions to the outer -curb on each side). The interpolated vector never passes through -zero for non-degenerate wedges — it always points somewhere within -the wedge. +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 Road Width Interpolation +### 5.5 Effective Road Width -The scalar road half-width varies linearly across the wedge: +The scalar road half-width at distance d is the offset magnitude: ``` -width(d) = w1 + (w2 - w1) * (d / L) +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, d) + if d <= L1: return halfEdgeHeightAt(H1, graph, L1 - d) else: return halfEdgeHeightAt(H2, graph, d - L1) ``` -`halfEdgeHeightAt(he, graph, d)` (existing helper, RoadSystem.cpp:1183) -returns the absolute world Y of the road surface at distance d from -the seed node, using linear interpolation of the edge's roadLevel values. +`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 @@ -184,24 +269,25 @@ For each vertex `v` at template position (vx, vy, vz): ``` d = -vz // guaranteed to be in [0, L] -localWidth = width(d) -lateral = vx * localWidth // template X∈[0,1] → world distance -lateralDir = normalize(offset(d)) // unit vector toward outer curb - -worldXZ = center(d) + lateral * lateralDir +// 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 -v.uv.x = (d <= L1) ? halfEdgeU(H1, graph, d) +// 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 * localWidth + in1 +v.uv.y = v.uv.y * width(d) + in1 -// Normal — rotate template-forward (-Z) to segment direction: +// Normal — rotate template-forward (-Z) to segment direction by the +// SIGNED angle around Y: segDir = (d <= L1) ? dir1 : dir2 -Ogre::Quaternion q(segDir.angleBetween(Ogre::Vector3::NEGATIVE_UNIT_Z), - Ogre::Vector3::UNIT_Y); +theta = atan2(-segDir.x, -segDir.z) +Ogre::Quaternion q(Ogre::Radian(theta), Ogre::Vector3::UNIT_Y); v.normal = q * v.normal; ``` @@ -346,25 +432,39 @@ Where `refPoint` is the centroid of `centerSurf`. ### 8.3 Application -- **Wedge**: After Phase 2+3, the strip contains the center-surface triangles - (from the template index buffer, transformed). Pass to `extrudeToSlab`. -- **Segment**: After building the center-surface band, pass to `extrudeToSlab`. +- **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 both cases, the boundary edges are: the outer curb chain, the start cap, -and the end cap. Centerline edges (O→M_A, O→M_B) are interior and get no -skirts — they meet adjacent wedge pieces. +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 | Post-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, @@ -398,12 +498,16 @@ static Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, | Test | Setup | d=0 expected | d=L1 (node) expected | d=L expected | |------|-------|-------------|---------------------|-------------| -| 90° wedge, w1=w2=3 | dir1=+X, dir2=+Z | (0,0,3) | (1.5,0,1.5) | (3,0,0) | -| 270° wedge, w1=w2=3 | dir1=+Z, dir2=+X | (-3,0,0) | (-1.5,0,-1.5) | (0,0,-3) | +| 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) | (1.5,0,4.5) | (3,0,0) | +| 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 | @@ -411,7 +515,8 @@ static Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, | Segment slab (0,0,0)–(20,0,0), 1+1 lanes | X∈[-0.05,10], Z∈[-3,3], Y top≈+0.15, bot≈-0.15 | | Elevated nodes y=10 | Top Y≈10.15 (not 20.15 — regression test) | | Asymmetric (2 out, 1 in) | Z∈[-3,6] | -| 90° L-corner | All XZ∈[0,10]², outer corner near (3,y,3), node vertex at (0,y,0) | +| 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 | @@ -487,3 +592,36 @@ 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 A–B and B–C. + 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 --target RoadGeometryDemo +./RoadGeometryDemo +``` diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index cedf610..2ad837d 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -2135,7 +2135,7 @@ verification plan, manual test procedures, and open questions. | M5.3 Road mesh template | ✅ complete | `RoadSystem::getRoadTemplate()`, fallback box, `roadTemplate` test | | M5.4 Road geometry generation | ✅ complete | page tracking + wedge bucketing in `RoadSystem`, `roadPageAssignment` test | | M5.5 Wedge enumeration | ✅ complete | `enumerateWedges()` in `RoadGraph.hpp`, `validate()` angle checks, `roadWedgeEnumeration` test | -| M5.6 Wedge geometry | ✅ complete | mitered polyline sweep: `computeWedgeOutline`/`triangulateOutline` + `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` | @@ -2656,6 +2656,33 @@ 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. +**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, @@ -2705,7 +2732,9 @@ Headless coverage: `roadWedgeGeometry` test (segment extents incl. top and 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). +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). --- diff --git a/src/features/editScene/road_demo/main.cpp b/src/features/editScene/road_demo/main.cpp index daa39b6..0b74d15 100644 --- a/src/features/editScene/road_demo/main.cpp +++ b/src/features/editScene/road_demo/main.cpp @@ -5,7 +5,9 @@ * 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. + * 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 --target RoadGeometryDemo * Run: ./RoadGeometryDemo @@ -109,11 +111,17 @@ private: float m_roadThickness = 0.3f; /* State. */ - uint64_t m_lastConfigHash = 0; bool m_dirty = true; /* 0 = smaller-angle wedge, 1 = larger-angle wedge, 2 = both */ int m_wedgeMode = 2; + + /* 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() @@ -246,16 +254,8 @@ bool DemoApp::frameStarted(const Ogre::FrameEvent &evt) m_cameraMan->frameRendered(evt); - /* Detect config changes and rebuild. */ - uint64_t hash = (uint64_t)(m_pointA.x * 1000.0f) + - ((uint64_t)(m_pointA.z * 1000.0f) << 12) + - ((uint64_t)(m_pointB.x * 1000.0f) << 24) + - ((uint64_t)(m_pointB.z * 1000.0f) << 36) + - ((uint64_t)(m_pointC.x * 1000.0f) << 48) + - ((uint64_t)(m_pointC.z * 1000.0f) << 56); - - if (hash != m_lastConfigHash || m_dirty) { - m_lastConfigHash = hash; + /* Rebuild if any parameter changed. */ + if (m_dirty) { m_dirty = false; rebuildWedgeGeometry(); } @@ -330,9 +330,6 @@ void DemoApp::rebuildWedgeGeometry() Ogre::Vector3 posA = m_pointA; Ogre::Vector3 posB = m_pointB; Ogre::Vector3 posC = m_pointC; - posA.y = 0.0f; - posB.y = 0.0f; - posC.y = 0.0f; int idA = graph.addNode(posA, 0.0f); int idB = graph.addNode(posB, 0.0f); @@ -366,7 +363,14 @@ void DemoApp::rebuildWedgeGeometry() auto buildWedge = [&](const RoadWedge &w, Procedural::TriangleBuffer &buf) -> bool { Procedural::TriangleBuffer tmp; - if (!RoadGeometryLib::buildWedgeGeometry(w, graph, 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()) { @@ -457,7 +461,7 @@ void DemoApp::rebuildWedgeGeometry() m_wedgeWireframe->setVisible(true); m_wedgeTriangles->begin( - "BaseWhiteNoLighting", + "BaseWhite", Ogre::RenderOperation::OT_TRIANGLE_LIST); for (const auto &v : tb.getVertices()) { m_wedgeTriangles->position(v.mPosition); @@ -521,18 +525,21 @@ void DemoApp::renderImGui() 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(); @@ -544,6 +551,30 @@ void DemoApp::renderImGui() 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. */ diff --git a/src/features/editScene/roadlib/RoadGeometryLib.cpp b/src/features/editScene/roadlib/RoadGeometryLib.cpp index bfe2e1a..141cae9 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.cpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.cpp @@ -263,6 +263,28 @@ static void buildWedgeStrip(Procedural::TriangleBuffer &out, * 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) @@ -284,23 +306,59 @@ Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, 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; - /* Miter corner: intersection of the two constant-width curb lines. */ - bool hasCorner = false; - Ogre::Vector3 cornerOff; - float det = dir1.z * dir2.x - dir1.x * dir2.z; - if (std::fabs(det) >= 0.05f) { - Ogre::Vector3 rhs = offB - offA; - float t1x = (-rhs.x * dir2.z + dir2.x * rhs.z) / det; - cornerOff = offA + dir1 * t1x; - hasCorner = true; - } - if (W <= 0.0f) { if (d < L1) return offA; @@ -339,12 +397,9 @@ void transformWedgeVertices(Procedural::TriangleBuffer &strip, Ogre::Vector3 dir1 = h1.direction; Ogre::Vector3 dir2 = h2.direction; float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f; - float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f; - float L = L1 + L2; + float L = L1 + (h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f); float in1 = h1.lanesIn * graph.config.laneWidth; - Ogre::Vector3 MA = O + dir1 * L1; - Ogre::Vector3 MB = O + dir2 * L2; float yO = O.y + nodeRoadLevel(graph, wedge.nodeId); for (auto &v : strip.getVertices()) { @@ -355,11 +410,7 @@ void transformWedgeVertices(Procedural::TriangleBuffer &strip, d = L; /* Centerline (polyline MA -> O -> MB). */ - Ogre::Vector3 center; - if (d <= L1) - center = MA + (O - MA) * (d / L1); - else - center = O + (MB - O) * ((d - L1) / L2); + Ogre::Vector3 center = wedgeCenterAt(wedge, graph, d); /* World position from curb offset. */ Ogre::Vector3 off = computeCurbOffset(wedge, graph, d); @@ -562,8 +613,20 @@ bool buildWedgeGeometry(const RoadWedge &wedge, /* Phase 3. */ shiftSeamVertices(strip, wedge, graph); - /* Slab extrusion. */ - extrudeToSlab(out, strip, graph.config.roadThickness); + /* + * 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; } @@ -640,4 +703,168 @@ bool buildSegmentGeometry(const RoadStraightSegment &segment, 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( + 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( + 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( + 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( + 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( + 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 diff --git a/src/features/editScene/roadlib/RoadGeometryLib.hpp b/src/features/editScene/roadlib/RoadGeometryLib.hpp index a2cb10f..f437677 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.hpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.hpp @@ -28,9 +28,12 @@ namespace RoadGeometryLib { * * The wedge piece is built by the three-phase pipeline described in * ProceduralRoadGeometry.md: a strip of concatenated template copies - * is bent along the wedge's 2-segment centerline polyline with - * continuous curb offset through the miter corner, then extruded - * into a solid slab. + * 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. */ @@ -104,6 +107,19 @@ void extrudeToSlab(Procedural::TriangleBuffer &out, */ 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 * ---------------------------------------------------------------- */ diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 99a9b9b..1d2b9cc 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -10,6 +10,7 @@ #include "../components/Lod.hpp" #include "../components/PhysicsCollider.hpp" #include "../physics/physics.h" +#include "../roadlib/RoadGeometryLib.hpp" #include "PrefabSystem.hpp" #include #include @@ -915,771 +916,35 @@ 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) -{ - 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( - 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( - 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( - 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( - 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( - 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( - "RoadSystem: 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"); - } - - m_templateBuffer = tb; - return true; -} - -void RoadSystem::buildFallbackTemplate(float roadThickness) -{ - m_templateBuffer = makeFallbackTemplate(roadThickness); -} - Procedural::TriangleBuffer RoadSystem::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; - }; - - /* - * One quad face: 4 vertices, 2 triangles, counter-clockwise - * seen from outside (Ogre front face). Template space - * (ProceduralRoadGeometry.md section 2): X in [0,1] is lateral - * (X=0 centerline, X=1 outer curb), Z in [-1,0] is longitudinal - * (0 at the wedge start). UVs map u to the longitudinal extent - * (-z) and v to the lateral extent (x) so every face spans - * (0,0)-(1,1); Phase 2 rescales v by the local road width. - */ - auto addFace = [&](const Corner &a, const Corner &b, const Corner &c, - const Corner &d, const Ogre::Vector3 &normal) { - int base = (int)verts.size(); - 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; + 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; -} - -/** Overlap distance to close gaps at wedge boundaries (spec 5.4 step 4). */ -static const float ROAD_SEAM_OVERLAP = 0.05f; - -/** Append one triangle; degenerate (zero-area) triangles are skipped. */ -static void emitTri(Procedural::TriangleBuffer &out, const Ogre::Vector3 &p0, - const Ogre::Vector3 &p1, const Ogre::Vector3 &p2, - const Ogre::Vector2 &uv0, const Ogre::Vector2 &uv1, - const Ogre::Vector2 &uv2) -{ - Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); - if (n.squaredLength() < 1e-10f) - return; - n.normalise(); - - int base = (int)out.getVertices().size(); - const Ogre::Vector3 *pp[3] = { &p0, &p1, &p2 }; - const Ogre::Vector2 *uu[3] = { &uv0, &uv1, &uv2 }; - for (int i = 0; i < 3; ++i) { - Procedural::TriangleBuffer::Vertex v; - v.mPosition = *pp[i]; - v.mNormal = n; - v.mUV = *uu[i]; - out.getVertices().push_back(v); - out.getIndices().push_back(base + i); - } -} - -/** - * Turn a set of center-surface triangles into a solid slab (spec - * section 8). - * - * Every triangle is emitted twice: offset by +halfThick along Y and - * offset by -halfThick, with the winding chosen so the top normal - * points up (auto-oriented by the source triangle's normal Y sign — - * the published keep-winding rule flips the top face down for the - * section 7 band triangle order). Boundary edges (undirected edges - * used by exactly one triangle — requires centerSurf to share vertices - * along interior edges) grow vertical skirt quads whose normals point - * away from the center surface's centroid. @p skirtFilter may reject - * specific boundary edges (e.g. a segment's far end, which meets the - * neighbour node's piece exactly). - */ -void RoadSystem::extrudeToSlab(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer ¢erSurf, - float roadThickness, - const SkirtFilter &skirtFilter) -{ - float halfThick = std::max(0.01f, roadThickness) * 0.5f; - Ogre::Vector3 up(0.0f, halfThick, 0.0f); - - const auto &verts = centerSurf.getVertices(); - const auto &indices = centerSurf.getIndices(); - - /* Centroid: interior reference for skirt orientation. */ - Ogre::Vector3 refPoint = Ogre::Vector3::ZERO; - for (const auto &v : verts) - refPoint += v.mPosition; - if (!verts.empty()) - refPoint /= (float)verts.size(); - - /* Top and bottom. */ - for (size_t t = 0; t + 2 < indices.size(); t += 3) { - const auto &v0 = verts[(size_t)indices[t]]; - const auto &v1 = verts[(size_t)indices[t + 1]]; - const auto &v2 = verts[(size_t)indices[t + 2]]; - Ogre::Vector3 n = (v1.mPosition - v0.mPosition) - .crossProduct(v2.mPosition - v0.mPosition); - if (n.squaredLength() < 1e-10f) - continue; - int i1 = n.y >= 0.0f ? 1 : 2; - int i2 = n.y >= 0.0f ? 2 : 1; - const Procedural::TriangleBuffer::Vertex *vv[3] = { &v0, &v1, - &v2 }; - emitTri(out, vv[0]->mPosition + up, vv[i1]->mPosition + up, - vv[i2]->mPosition + up, vv[0]->mUV, vv[i1]->mUV, - vv[i2]->mUV); - emitTri(out, vv[0]->mPosition - up, vv[i2]->mPosition - up, - vv[i1]->mPosition - up, vv[0]->mUV, vv[i2]->mUV, - vv[i1]->mUV); - } - - /* Boundary-edge detection by index counting. */ - struct EdgeUse { - int count = 0; - int a = 0, b = 0; /* directed, from the first use */ - }; - std::map, EdgeUse> edgeUse; - for (size_t t = 0; t + 2 < indices.size(); t += 3) { - int tri[3] = { indices[t], indices[t + 1], indices[t + 2] }; - for (int e = 0; e < 3; ++e) { - int a = tri[e], b = tri[(e + 1) % 3]; - auto &eu = edgeUse[std::minmax(a, b)]; - if (eu.count == 0) { - eu.a = a; - eu.b = b; - } - ++eu.count; - } - } - - float thickness = 2.0f * halfThick; - for (const auto &kv : edgeUse) { - const EdgeUse &eu = kv.second; - if (eu.count != 1) - continue; - const auto &v0 = verts[(size_t)eu.a]; - const auto &v1 = verts[(size_t)eu.b]; - if (skirtFilter && - !skirtFilter(v0.mPosition, v1.mPosition)) - continue; - - Ogre::Vector3 t0 = v0.mPosition + up; - Ogre::Vector3 t1 = v1.mPosition + up; - Ogre::Vector3 b0 = v0.mPosition - up; - Ogre::Vector3 b1 = v1.mPosition - up; - Ogre::Vector2 uvB0(v0.mUV.x, v0.mUV.y - thickness); - Ogre::Vector2 uvB1(v1.mUV.x, v1.mUV.y - thickness); - - Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0); - if (n.squaredLength() < 1e-10f) - continue; - Ogre::Vector3 mid = (t0 + t1 + b0 + b1) * 0.25f; - bool outward = n.dotProduct(mid - refPoint) >= 0.0f; - if (outward) { - emitTri(out, t0, t1, b1, v0.mUV, v1.mUV, uvB1); - emitTri(out, t0, b1, b0, v0.mUV, uvB1, uvB0); - } else { - emitTri(out, t0, b1, t1, v0.mUV, uvB1, v1.mUV); - emitTri(out, t0, b0, b1, v0.mUV, uvB0, uvB1); - } - } -} - -/** - * Road surface level offset shared by every wedge seeded at one node. - * - * The level is the mean of the incident edges' roadLevelAtNode values, - * so all wedge pieces meeting at the node use the same center height - * and no cracks open between adjacent pieces when per-edge road levels - * differ. - */ -static float nodeRoadLevel(const RoadGraph &graph, int nodeId) -{ - float sum = 0.0f; - int count = 0; - for (const RoadEdge &e : graph.edges) { - if (e.nodeA == nodeId) { - sum += e.roadLevelA; - ++count; - } else if (e.nodeB == nodeId) { - sum += e.roadLevelB; - ++count; - } - } - return count > 0 ? sum / (float)count : 0.0f; -} - -/** - * Phase 1 (spec section 4): straight strip of N concatenated template - * copies along -Z. After this the strip occupies X in [0,1], - * Y in [-thick/2, +thick/2], Z in [-N, 0]. All template faces are - * kept. - */ -void RoadSystem::buildConcatenatedStrip(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer &templ, - int N) -{ - out.getVertices().clear(); - out.getIndices().clear(); - for (int i = 0; i < N; ++i) { - int base = (int)out.getVertices().size(); - for (const auto &v : templ.getVertices()) { - Procedural::TriangleBuffer::Vertex cv = v; - cv.mPosition.z -= (float)i; - out.getVertices().push_back(cv); - } - for (int idx : templ.getIndices()) - out.getIndices().push_back(base + idx); - } -} - -/** - * Append one template copy shifted to z -= @p zOff, clamping the path - * distance of every vertex to @p clampD. - * - * Template faces lying fully in a copy-boundary Z plane are dropped: - * those are the template caps, which would otherwise stack coplanar - * faces at every copy join and at the edge midpoints where the - * neighbour node's piece meets this one (z-fighting). The template's - * X = 0 wall is dropped as well: it runs along the centerline shared - * with the adjacent wedge and is interior to the joined road body. - */ -static void appendTemplateCopy(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer &templ, - float zOff, float clampD) -{ - const auto &tverts = templ.getVertices(); - const auto &tidx = templ.getIndices(); - - int base = (int)out.getVertices().size(); - for (const auto &v : tverts) { - Procedural::TriangleBuffer::Vertex cv = v; - cv.mPosition.z -= zOff; - if (-cv.mPosition.z > clampD) - cv.mPosition.z = -clampD; - out.getVertices().push_back(cv); - } - - for (size_t t = 0; t + 2 < tidx.size(); t += 3) { - const Ogre::Vector3 &a = tverts[(size_t)tidx[t]].mPosition; - const Ogre::Vector3 &b = tverts[(size_t)tidx[t + 1]].mPosition; - const Ogre::Vector3 &c = tverts[(size_t)tidx[t + 2]].mPosition; - - bool cap0 = std::fabs(a.z) < 1e-6f && - std::fabs(b.z) < 1e-6f && std::fabs(c.z) < 1e-6f; - bool cap1 = std::fabs(a.z + 1.0f) < 1e-6f && - std::fabs(b.z + 1.0f) < 1e-6f && - std::fabs(c.z + 1.0f) < 1e-6f; - if (cap0 || cap1) - continue; /* open joins at copy boundaries/midpoints */ - - bool wall0 = std::fabs(a.x) < 1e-6f && - std::fabs(b.x) < 1e-6f && - std::fabs(c.x) < 1e-6f; - const Ogre::Vector3 &n = tverts[(size_t)tidx[t]].mNormal; - if (wall0 && std::fabs(n.x) > 0.9f) - continue; /* interior centerline wall */ - - out.getIndices().push_back(base + tidx[t]); - out.getIndices().push_back(base + tidx[t + 1]); - out.getIndices().push_back(base + tidx[t + 2]); - } -} - -/** - * Two-run concatenated strip for one wedge (spec correction C3). - * - * Run 1 covers d in [0, L1] with ceil(L1) uniform copies from d = 0, - * run 2 covers [L1, L1+L2] with ceil(L2) copies from d = L1; vertices - * past each run's end are clamped onto it. Vertex layers therefore - * land exactly on the corner distance L1 and on the strip end — the - * miter corner is always sampled, which the published uniform - * N = ceil(L) layout cannot guarantee for fractional half-lengths - * (e.g. L1 = 5.5). - */ -static void buildWedgeStrip(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer &templ, - float L1, float L2) -{ - out.getVertices().clear(); - out.getIndices().clear(); - int k1 = std::max(1, (int)std::ceil(L1)); - int k2 = std::max(1, (int)std::ceil(L2)); - for (int i = 0; i < k1; ++i) - appendTemplateCopy(out, templ, (float)i, L1); - for (int j = 0; j < k2; ++j) - appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2); -} - -Ogre::Vector3 RoadSystem::computeCurbOffset(const RoadWedge &wedge, - const RoadGraph &graph, - float d) -{ - const RoadNode *node = graph.findNodeById(wedge.nodeId); - if (!node) - return Ogre::Vector3::ZERO; - - const RoadHalfEdge &h1 = wedge.first; - const RoadHalfEdge &h2 = wedge.second; - Ogre::Vector3 dir1 = h1.direction; - Ogre::Vector3 dir2 = h2.direction; - Ogre::Vector3 r1 = roadRightVec(dir1); - Ogre::Vector3 r2 = roadRightVec(dir2); - float lw = graph.config.laneWidth; - float L1 = h1.halfLength; - float L2 = h2.halfLength; - - Ogre::Vector3 offA = r1 * (h1.lanesOut * lw); /* H1-side curb */ - Ogre::Vector3 offB = r2 * (-h2.lanesIn * lw); /* H2-side curb */ - - /* Narrow symmetric blend zone around the node (spec 5.2). */ - float W = std::min(ROAD_SEAM_OVERLAP * 4.0f, - std::min(L1 * 0.5f, L2 * 0.5f)); - if (L1 < ROAD_SEAM_OVERLAP || L2 < ROAD_SEAM_OVERLAP) - W = 0.0f; - - /* - * Miter corner: intersection of the two constant-width curb - * lines, used as the blend anchor at the node so the curb passes - * exactly through the outer corner (spec correction C1 — the - * published 50/50 vector lerp cut the corner and left a hole at - * every outer intersection corner). Near-straight wedges - * (|det| < 0.05, curb lines almost collinear) drop the corner - * and blend directly between the two side offsets. - */ - bool hasCorner = false; - Ogre::Vector3 cornerOff; - float det = dir1.z * dir2.x - dir1.x * dir2.z; - if (std::fabs(det) >= 0.05f) { - Ogre::Vector3 rhs = offB - offA; - float t1x = (-rhs.x * dir2.z + dir2.x * rhs.z) / det; - cornerOff = offA + dir1 * t1x; - hasCorner = true; - } - - if (W <= 0.0f) { - if (d < L1) - return offA; - if (d > L1) - return offB; - return hasCorner ? cornerOff : (offA + offB) * 0.5f; - } - - if (d <= L1 - W) - return offA; - if (d >= L1 + W) - return offB; - if (!hasCorner) { - float t = (d - (L1 - W)) / (2.0f * W); - return offA + (offB - offA) * t; - } - if (d <= L1) { - float t = (d - (L1 - W)) / W; - return offA + (cornerOff - offA) * t; - } - float t = (d - L1) / W; - return cornerOff + (offB - cornerOff) * t; -} - -void RoadSystem::transformWedgeVertices(Procedural::TriangleBuffer &strip, - const RoadWedge &wedge, - const RoadGraph &graph) -{ - const RoadNode *node = graph.findNodeById(wedge.nodeId); - if (!node) - return; - - const RoadHalfEdge &h1 = wedge.first; - const RoadHalfEdge &h2 = wedge.second; - const Ogre::Vector3 &O = node->position; - Ogre::Vector3 dir1 = h1.direction; - Ogre::Vector3 dir2 = h2.direction; - float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f; - float L2 = h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f; - float L = L1 + L2; - float in1 = h1.lanesIn * graph.config.laneWidth; - - Ogre::Vector3 MA = O + dir1 * L1; - Ogre::Vector3 MB = O + dir2 * L2; - - /* One shared road level for the whole d = L1 vertex layer so - * adjacent wedge pieces cannot crack at the node. */ - float yO = O.y + nodeRoadLevel(graph, wedge.nodeId); - - for (auto &v : strip.getVertices()) { - float d = -v.mPosition.z; - if (d < 0.0f) - d = 0.0f; - if (d > L) - d = L; - - /* Centerline position (polyline M_A -> O -> M_B). */ - Ogre::Vector3 center; - if (d <= L1) - center = MA + (O - MA) * (d / L1); - else - center = O + (MB - O) * ((d - L1) / L2); - - /* - * World position: template X maps along the curb offset - * (direction AND magnitude — the offset itself widens - * through the miter corner), template Y maps directly to - * the vertical offset from the road surface. - */ - Ogre::Vector3 off = computeCurbOffset(wedge, graph, d); - Ogre::Vector3 worldXZ = center + off * v.mPosition.x; - - /* - * Surface height: half-edge profiles, with the shared - * node level at the d = L1 break layer. The published - * formulas passed the wedge-start distance d to the H1 - * helpers, inverting the profile along the first - * half-edge; the helpers expect the distance from the - * seed node, i.e. L1 - d (spec correction C2). - */ - float surfY; - if (d < L1 - 1e-4f) - surfY = halfEdgeHeightAt(h1, graph, L1 - d); - else if (d > L1 + 1e-4f) - surfY = halfEdgeHeightAt(h2, graph, d - L1); - else - surfY = yO; - float worldY = surfY + v.mPosition.y; - - /* UV: phase-continuous longitudinal u; lateral v scaled - * by the local width with the +in1 continuity offset. */ - float widthD = off.length(); - v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d) - : halfEdgeU(h2, graph, d - L1); - v.mUV.y = v.mUV.y * widthD + in1; - - /* - * Normal: rotate template-forward (-Z) to the segment - * direction by the SIGNED angle around Y (the published - * unsigned angleBetween rotated the wrong way for half - * the possible directions — spec correction C5). - */ - const Ogre::Vector3 &segDir = (d <= L1) ? dir1 : dir2; - float theta = std::atan2(-segDir.x, -segDir.z); - Ogre::Quaternion q(Ogre::Radian(theta), - Ogre::Vector3::UNIT_Y); - Ogre::Vector3 n = q * v.mNormal; - - v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z); - v.mNormal = n; - } -} - -/** - * Phase 3 (spec section 6): shift centerline-side vertices near the - * node slightly past it so adjacent wedges overlap at the center - * junction. Only needed for nodes with > 2 neighbors. - */ -void RoadSystem::shiftSeamVertices(Procedural::TriangleBuffer &strip, - const RoadWedge &wedge, - const RoadGraph &graph) -{ - std::vector nids = graph.getNeighborIds(wedge.nodeId); - if (nids.size() <= 2) - return; /* straight-through or endpoint */ - - const RoadNode *node = graph.findNodeById(wedge.nodeId); - if (!node) - return; - const Ogre::Vector3 &O = node->position; - - for (auto &v : strip.getVertices()) { - Ogre::Vector3 toNode(v.mPosition.x - O.x, 0, - v.mPosition.z - O.z); - float distToNode = toNode.length(); - if (distToNode >= ROAD_SEAM_OVERLAP) - continue; - - Ogre::Vector3 radial = toNode.normalisedCopy(); - if (radial.isZeroLength()) - continue; - - float push = ROAD_SEAM_OVERLAP - distToNode + - ROAD_SEAM_OVERLAP; - v.mPosition.x += radial.x * push; - v.mPosition.z += radial.z * push; - } -} - bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, const RoadGraph &graph, Procedural::TriangleBuffer &out) { - Procedural::TriangleBuffer fb = - makeFallbackTemplate(graph.config.roadThickness); - return buildWedgeGeometry(wedge, graph, fb, out); + return RoadGeometryLib::buildWedgeGeometry(wedge, graph, out); } bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, @@ -1687,117 +952,14 @@ bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, const Procedural::TriangleBuffer &templ, Procedural::TriangleBuffer &out) { - if (wedge.degenerate) { - Ogre::LogManager::getSingleton().logMessage( - "RoadSystem: skipping degenerate road wedge at node " + - Ogre::StringConverter::toString(wedge.nodeId)); - return false; - } - - /* Phase 1: concatenated strip. */ - Procedural::TriangleBuffer strip; - buildWedgeStrip(strip, templ, wedge.first.halfLength, - wedge.second.halfLength); - - /* Phase 2: bend into wedge shape. */ - transformWedgeVertices(strip, wedge, graph); - - /* Phase 3: close center seam. */ - shiftSeamVertices(strip, wedge, graph); - - /* Slab extrusion: turn the center surface into a closed solid. - * The centerline edges (template X=0) and cap faces (template Z=0, - * Z=-1) were dropped by appendTemplateCopy, leaving only the outer - * curb wall and road top/bottom in the strip index buffer. Those - * remaining faces form the center surface — extrudeToSlab detects - * boundary edges from the center-surface triangle soup and adds - * top, bottom, and skirt geometry. */ - extrudeToSlab(out, strip, graph.config.roadThickness); - return true; -} - -/** - * Center-surface band of a dead-end straight segment (spec section 7): - * the full road width s in [-inW, +outW] along the single half-edge - * with a small overlap past the node. c[0]/c[3] are at the node end, - * c[1]/c[2] at the edge midpoint. Heights are absolute road surface - * heights. Retained because complyTerrain uses the four corners. - */ -static bool computeSegmentBand(const RoadStraightSegment &segment, - const RoadGraph &graph, Ogre::Vector3 c[4], - Ogre::Vector2 uvc[4]) -{ - const RoadNode *node = graph.findNodeById(segment.nodeId); - if (!node) - return false; - - const RoadHalfEdge &he = segment.halfEdge; - if (he.lanesIn + he.lanesOut < 1) - return false; - - const Ogre::Vector3 &O = node->position; - Ogre::Vector3 d = he.direction; - Ogre::Vector3 r = roadRightVec(d); - float lw = graph.config.laneWidth; - float inW = he.lanesIn * lw; - float outW = he.lanesOut * lw; - float L = he.halfLength; - float t0 = -ROAD_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; + return RoadGeometryLib::buildWedgeGeometry(wedge, graph, templ, out); } bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment, const RoadGraph &graph, Procedural::TriangleBuffer &out) { - Ogre::Vector3 c[4]; - Ogre::Vector2 uvc[4]; - if (!computeSegmentBand(segment, graph, c, uvc)) - return false; - - /* Build center-surface as a TriangleBuffer. */ - Procedural::TriangleBuffer centerSurf; - int base = (int)centerSurf.getVertices().size(); - for (int i = 0; i < 4; ++i) { - Procedural::TriangleBuffer::Vertex v; - v.mPosition = c[i]; - v.mNormal = Ogre::Vector3::UNIT_Y; - v.mUV = uvc[i]; - centerSurf.getVertices().push_back(v); - } - centerSurf.getIndices().push_back(base + 0); - centerSurf.getIndices().push_back(base + 1); - centerSurf.getIndices().push_back(base + 2); - centerSurf.getIndices().push_back(base + 0); - centerSurf.getIndices().push_back(base + 2); - centerSurf.getIndices().push_back(base + 3); - - /* Extrude to slab, keeping the far-end edge open (it meets the - * neighbor node's piece). */ - auto skirtFilter = [&](const Ogre::Vector3 &p0, - const Ogre::Vector3 &p1) -> bool { - /* The far end is the edge (c1, c2) — exclude it. */ - float d1 = p0.distance(c[1]) + p1.distance(c[2]); - float d2 = p0.distance(c[2]) + p1.distance(c[1]); - return (d1 > 0.001f && d2 > 0.001f); - }; - extrudeToSlab(out, centerSurf, graph.config.roadThickness, - skirtFilter); - return true; + return RoadGeometryLib::buildSegmentGeometry(segment, graph, out); } /* ------------------------------------------------------------------ */ @@ -1897,16 +1059,9 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, continue; /* Write fixups from the generated top-surface - * vertices. The top-surface Y after slab - * extrusion is at +halfThick; the road-surface Y - * is topY - halfThick, so the fixup target is - * topY - halfThick - roadThickness = - * topY - halfThick*2 - halfThick = - * topY - roadThickness - halfThick. - * - * Simpler: sample the Y of vertices whose normal + * vertices: sample the Y of vertices whose normal * points up and write target = Y - roadThickness - * underneath them. */ + * underneath them (the slab bottom). */ const auto &verts = tmp.getVertices(); for (const auto &v : verts) { if (v.mNormal.y <= 0.5f) @@ -1920,7 +1075,7 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, for (const RoadStraightSegment &seg : pg.segments) { Ogre::Vector3 c[4]; Ogre::Vector2 uvc[4]; - if (!computeSegmentBand(seg, rg, c, uvc)) + if (!RoadGeometryLib::computeSegmentBand(seg, rg, c, uvc)) continue; /* Write fixups at the band corners. */ diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index 84f3cc7..181d85f 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -156,8 +156,10 @@ public: * The wedge piece is built by the three-phase pipeline: a strip of * concatenated template copies is bent along the wedge's 2-segment * centerline polyline (edge midpoint -> node -> edge midpoint) with - * the outer-curb offset interpolated through the miter corner, so - * the road keeps its exact width through turns with no gaps or + * 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 @@ -168,6 +170,10 @@ public: * 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, @@ -180,56 +186,6 @@ public: const RoadGraph &graph, Procedural::TriangleBuffer &out); - /** - * Pipeline phases (ProceduralRoadGeometry.md section 10). - * - * Exposed as public statics so headless tests can exercise the key - * math without a scene. - */ - /** Phase 1: straight strip of N concatenated template copies - * along -Z (all faces kept). */ - static void buildConcatenatedStrip(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer &templ, - int N); - /** Phase 2: bend the strip into the wedge shape, in place. */ - static void transformWedgeVertices(Procedural::TriangleBuffer &strip, - const RoadWedge &wedge, - const RoadGraph &graph); - /** Phase 3: push centerline-side vertices near the node slightly - * past it so adjacent wedges overlap at the center junction. */ - static void shiftSeamVertices(Procedural::TriangleBuffer &strip, - const RoadWedge &wedge, - const RoadGraph &graph); - /** - * Outer-curb offset at path distance @p d from the wedge start. - * - * The vector from the centerline to the outer curb; it anchors at - * w1*r1 on the first half-edge, passes exactly through the miter - * corner at the node (no corner holes), and ends at -w2*r2 on the - * second half-edge, interpolated through the narrow blend zone. - */ - static Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, - const RoadGraph &graph, - float d); - - /** Returns false for a boundary edge that must stay open. */ - using SkirtFilter = std::function; - - /** - * Turn a center-surface triangle set into a solid slab (spec - * section 8): top and bottom at +/- roadThickness/2 (winding - * auto-oriented by normal Y sign) plus vertical skirts on - * boundary edges (edges used by exactly one triangle; centerSurf - * must share vertices along interior edges). @p skirtFilter can - * exclude specific boundary edges (e.g. the segment far end, - * which meets the neighbor node's piece). - */ - static void extrudeToSlab(Procedural::TriangleBuffer &out, - const Procedural::TriangleBuffer ¢erSurf, - float roadThickness, - const SkirtFilter &skirtFilter = nullptr); - /** * Create a unit-box template for headless tests and fallback. * @@ -332,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; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 9396a92..73b96ef 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -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 P2; /* (x, z) */ + +double cross2d(const P2 &a, const P2 &b) +{ + return a.first * b.second - a.second * b.first; +} + +std::vector clipHalfPlane(const std::vector &poly, const P2 &a, + const P2 &b) +{ + std::vector 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 &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 subject; + for (int i = 0; i < 3; ++i) + subject.push_back(P2(t0[i].x, t0[i].z)); + std::vector 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 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) { @@ -2143,6 +2327,12 @@ 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; @@ -2190,6 +2380,64 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, "(-3,-3)"); } + /* + * 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 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 wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + + 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"); + + 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.