From 5ea7a6bde8d6ed502c30c499c67bae6448b46e61 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sat, 15 Aug 2026 08:29:22 +0300 Subject: [PATCH] Fixed normals for road geometry --- .../editScene/ProceduralRoadGeometry.md | 89 +++++++++++++++---- .../editScene/roadlib/RoadGeometryLib.cpp | 46 ++++++++-- .../tests/road_geometry_overlap_test.cpp | 19 +++- 3 files changed, 124 insertions(+), 30 deletions(-) diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index c72edb7..e2353c2 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -19,7 +19,10 @@ The template from `getRoadTemplate(cfg)`: - **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. +- **Normals**: the template axes are mapped explicitly during the bend + (+X → outer-curb direction, +Y → up, -Z → travel), because the wedge + bend is a *reflection* of the template (see §5.7) and therefore cannot + be represented by a single rotation around Y. 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]). @@ -62,8 +65,8 @@ 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 +the output with winding reversed (§5.9) — slab extrusion (§8) applies +to straight segments only. `RoadGeometryLib` also provides `loadTemplateFromMesh()` (template loading per §2) and `makeFallbackTemplate()`. @@ -283,12 +286,16 @@ 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; +// Normal — map the template axes onto the bent world frame explicitly. +// The bend is a REFLECTION of the template: travel is -dir1 on the first +// half-edge and the outer curb runs along +offset(d), which is +// -roadRight(dir2) on the second half-edge. A single rotation around Y +// would invert those axes, so the template axes are mapped one by one: +lateral = normalize(offset(d)) // +X -> outer-curb direction +travel = (d <= L1) ? -dir1 : dir2 // -Z -> travel direction +v.normal = lateral * v.normal.x + + Vector3(0, v.normal.y, 0) + + travel * (-v.normal.z); ``` Since Phase 1 guarantees d ∈ [0, L] (we use exactly ceil(L) copies and @@ -303,14 +310,42 @@ 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 +`dir(d)` only affects the normal mapping (§5.7) 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. +### 5.9 The Bend Is a Reflection (Normals and Winding) + +The template's local frame — X = outer-curb lateral, Y = up, Z = +longitudinal with travel along -Z — is **left-handed** (X × Y = +Z = +-forward), while the world road frame (outer-curb lateral, up, travel) +is **right-handed** (lateral × up = +travel). The position mapping + +``` +world = center(d) + offset(d) * x + (0, y, 0) +``` + +is therefore a *reflection* of the template, not a rotation. That has +two consequences, both handled explicitly: + +* **Normals** cannot be recovered by a single rotation around Y. The + template-forward -Z does not map to `dir1`/`dir2`: travel is -dir1 on + the first half-edge (midpoint → node) and the outer curb runs along + `offset(d)`, which equals -roadRight(dir2) on the second half-edge. + `transformWedgeVertices` therefore maps the template axes one by one + (§5.7): +X → normalize(offset(d)), +Y → +Y, -Z → travel. + +* **Winding** comes out inverted — every triangle's front face flips to + the back. `buildWedgeGeometry` reverses each triangle's index order + before appending the strip (§8.3) so the closed solid is front-facing + outward; otherwise the top surface would be culled by back-face + culling and the underside (with downward normals) would show through — + the "inverted normals" symptom reported when nodes are repositioned. + ## 6. Phase 3 — Center Seam Shifting **Function**: `static void shiftSeamVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph)` @@ -437,10 +472,12 @@ Where `refPoint` is the centroid of `centerSurf`. 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.) + against the adjacent wedge). Because the bend is a reflection of the + template, each triangle's winding is reversed before the strip is + appended, so the closed solid is front-facing outward (its top + surface survives back-face culling). (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). @@ -458,6 +495,8 @@ interior and get no skirts — they meet adjacent road pieces. | 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 | +| Explicit normal mapping (§5.7) | Outer-curb wall normal inverted on the second half-edge | Phase 2 | +| Winding reversal (§5.9, §8.3) | Inside-out wedge (top surface culled; "inverted normals") | buildWedgeGeometry | ## 10. Internal Functions (Testable) @@ -535,8 +574,10 @@ zone it is `K - center(d)`. `road_geometry_overlap_test`, CTest `roadGeometryOverlapTest`) builds the demo's A–B–C graph headlessly — flat, corner node raised/lowered, endpoints raised — and fails when any wedge or segment slab contains -coplanar-overlapping or piercing triangle pairs, or when the flat -wedge's slab thickness exceeds roadThickness/2. Optional arguments +coplanar-overlapping or piercing triangle pairs, when the flat wedge's +slab thickness exceeds roadThickness/2, or when any triangle's stored +vertex normal disagrees with its winding (`dot(geometric, stored) < 0`), +which indicates an inside-out (reflected) face. Optional arguments `Ax Ay Az Bx By Bz Cx Cy Cz` analyse a single custom configuration (useful when debugging geometry reported by the demo). @@ -594,9 +635,21 @@ benefit for the narrow blend zone (W ≈ 0.2 units). `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 +road intersections. `dir(d)` only affects the normal mapping and UV lookup — vertex positions are driven by the continuous `offset(d)`. +### Why map the template axes instead of rotating normals? + +The template frame (X = outer-curb lateral, Y = up, -Z = forward) is +left-handed while the bent world frame (lateral, up, travel) is +right-handed, so the bend is a reflection and no rotation around Y can +map the template normals onto the surface. Rotating the normal toward +`dir(d)` inverted the outer-curb wall normal on the second half-edge +(where the curb runs along -roadRight(dir2)) and the travel axis on the +first half-edge (travel = -dir1), producing inside-out faces. Mapping +the axes one by one (§5.7) and reversing the winding (§5.9) restores a +correctly oriented closed solid. + ### Template mesh is finally used The current implementation ignores the template from M5.3. This diff --git a/src/features/editScene/roadlib/RoadGeometryLib.cpp b/src/features/editScene/roadlib/RoadGeometryLib.cpp index 141cae9..4439919 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.cpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.cpp @@ -432,12 +432,27 @@ void transformWedgeVertices(Procedural::TriangleBuffer &strip, : 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; + /* + * Normal. The wedge bend is a reflection of the template: + * the outer curb runs along +offset(d), which is + * -roadRight(dir2) on the second half-edge, and travel is + * -dir1 on the first half-edge. A single rotation around Y + * therefore cannot map the template normals onto the bent + * surface (it would invert the outer-curb wall normal on the + * second half-edge and the longitudinal axis on the first). + * Map the template axes explicitly instead: +X -> outer-curb + * direction, +Y -> up, -Z -> travel. + */ + Ogre::Vector3 lateral = off; + if (lateral.length() < 1e-4f) + lateral = (d <= L1) ? roadRightVec(dir1) + : -roadRightVec(dir2); + else + lateral.normalise(); + Ogre::Vector3 travel = (d <= L1) ? -dir1 : dir2; + Ogre::Vector3 n = lateral * v.mNormal.x + + Ogre::Vector3(0, v.mNormal.y, 0) + + travel * (-v.mNormal.z); v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z); v.mNormal = n; @@ -618,15 +633,28 @@ bool buildWedgeGeometry(const RoadWedge &wedge, * 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 + * pieces), so it is appended as-is. 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); + + /* + * The wedge bend is a reflection of the template (the outer curb + * runs along -roadRight on the second half-edge), so the template + * winding comes out inverted. Reverse each triangle so the closed + * road solid is front-facing outward (and back-face culling keeps + * the top surface). + */ + const std::vector &si = strip.getIndices(); + out.getIndices().reserve(out.getIndices().size() + si.size()); + for (size_t t = 0; t + 2 < si.size(); t += 3) { + out.getIndices().push_back(base + si[t]); + out.getIndices().push_back(base + si[t + 2]); + out.getIndices().push_back(base + si[t + 1]); + } return true; } diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 3b74f20..bd75d20 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -164,6 +164,7 @@ static bool segTriPierce(const Vector3 &p0, const Vector3 &p1, struct Analysis { int coplanar = 0; int crossing = 0; + int invertedNormal = 0; bool nan = false; Vector3 mn = Vector3(FLT_MAX, FLT_MAX, FLT_MAX); Vector3 mx = Vector3(-FLT_MAX, -FLT_MAX, -FLT_MAX); @@ -197,6 +198,17 @@ static Analysis analyze(const Procedural::TriangleBuffer &buf, float l0 = n0.length(); if (l0 > 1e-8f) n0 /= l0; + /* The stored vertex normal must agree with the winding: a + * reflection in the wedge bend would otherwise flip the face + * inside-out (inverted shading / back-face culling). */ + const Vector3 &sn = verts[(size_t)indices[i * 3]].mNormal; + if (sn.squaredLength() > 1e-6f && n0.dotProduct(sn) < 0.0f) { + if (res.invertedNormal < 20) + printf(" INVERTED-NORMAL tri %zu near " + "(%.2f,%.2f,%.2f)\n", + i, t0[0].x, t0[0].y, t0[0].z); + ++res.invertedNormal; + } for (size_t j = i + 1; j < nTri; ++j) { Vector3 t1[3]; for (int k = 0; k < 3; ++k) @@ -245,8 +257,8 @@ static Analysis analyze(const Procedural::TriangleBuffer &buf, } } } - printf(" coplanar-overlap pairs: %d crossing pairs: %d\n", - res.coplanar, res.crossing); + printf(" coplanar-overlap pairs: %d crossing pairs: %d inverted-normal faces: %d\n", + res.coplanar, res.crossing, res.invertedNormal); return res; } @@ -261,7 +273,8 @@ static bool checkPiece(bool built, const char *name, Analysis a = analyze(buf, name); if (out) *out = a; - bool ok = !a.nan && a.coplanar == 0 && a.crossing == 0; + bool ok = !a.nan && a.coplanar == 0 && a.crossing == 0 && + a.invertedNormal == 0; if (!ok) printf(" *** %s FAILED ***\n", name); return ok;