From 2d60b20cca65c7b4203dd401795ca33f4c4d1489 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Mon, 10 Aug 2026 07:27:54 +0300 Subject: [PATCH] Added road geometry overlap test --- src/features/editScene/AGENTS.md | 6 + src/features/editScene/CMakeLists.txt | 18 + .../editScene/ProceduralRoadGeometry.md | 11 + .../tests/road_geometry_overlap_test.cpp | 375 ++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 src/features/editScene/tests/road_geometry_overlap_test.cpp diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index b01bbcb..9780606 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -40,6 +40,12 @@ cd build-vscode/src/features/editScene # Run terrain integration tests headless (1x1 hidden SDL window, no UI) ./editSceneEditor --headless --run-terrain-tests=1 + +# Road wedge/segment self-intersection regression test (no scene needed, +# also registered as CTest roadGeometryOverlapTest) +./road_geometry_overlap_test +# ...or analyse one custom A-B-C configuration: +./road_geometry_overlap_test -5 0 0 0 5 0 5 0 5 ``` The main test target is `component_lua_test`: diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index bf95cc3..7fe40f0 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -670,6 +670,24 @@ target_include_directories(RoadGeometryDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ) +# --------------------------------------------------------------------------- +# Road geometry overlap test — standalone self-intersection regression test +# --------------------------------------------------------------------------- +# Headless (no scene/ECS/render window): builds the demo's A-B-C wedge graph +# in several height configurations and fails if any generated slab has +# coplanar-overlapping or piercing triangle pairs. Optional args +# "Ax Ay Az Bx By Bz Cx Cy Cz" analyse a single custom configuration. +add_executable(road_geometry_overlap_test + tests/road_geometry_overlap_test.cpp +) + +target_link_libraries(road_geometry_overlap_test + RoadGeometryLib +) + +add_test(NAME roadGeometryOverlapTest + COMMAND road_geometry_overlap_test) + # --------------------------------------------------------------------------- # Package Archive Library # --------------------------------------------------------------------------- diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index ea0a311..c72edb7 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -529,6 +529,17 @@ zone it is `K - center(d)`. | Single triangle, thick=0.3 | 6 tris (top+bottom+3 skirts), Y∈[-0.15,0.15] | | Two adjacent triangles | 10 tris (shared edge has no skirt) | +### 11.4 Standalone Overlap Test + +`tests/road_geometry_overlap_test.cpp` (target +`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 +`Ax Ay Az Bx By Bz Cx Cy Cz` analyse a single custom configuration +(useful when debugging geometry reported by the demo). + ## 12. Migration from Current Implementation ### Kept unchanged diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp new file mode 100644 index 0000000..3b74f20 --- /dev/null +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -0,0 +1,375 @@ +/* + * road_geometry_overlap_test — standalone regression test for + * RoadGeometryLib wedge/segment generation. + * + * Builds the same A-B-C graph as RoadGeometryDemo in several height + * configurations and checks the generated slabs for self-intersecting + * geometry: + * - COPLANAR: two triangles on the same plane overlapping in area + * (z-fighting duplicates) + * - CROSSING: a triangle edge properly pierces the interior of another + * triangle (interpenetrating sheets) + * + * Regression coverage for the inner-corner fold (curb pinned at the + * miter corner K — ProceduralRoadGeometry.md sections 5.2/5.4) and the + * double slab extrusion fix. The same checks also run inside the + * headless terrain suite (roadWedgeGeometry, case 3b); this executable + * needs no scene, ECS or render window and runs in milliseconds. + * + * Usage: + * road_geometry_overlap_test + * Runs the built-in regression configurations (flat, raised/ + * lowered corner node, raised endpoints). Exit code 0 when no + * piece self-intersects, 1 otherwise. + * road_geometry_overlap_test Ax Ay Az Bx By Bz Cx Cy Cz + * Analyse a single custom configuration (same checks). + */ + +#include +#include + +#include "components/RoadGraph.hpp" +#include "roadlib/RoadGeometryLib.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using Ogre::Vector2; +using Ogre::Vector3; + +typedef std::pair P2; /* (x, z) */ + +static double cross2d(const P2 &a, const P2 &b) +{ + return a.first * b.second - a.second * b.first; +} + +static 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; +} + +static 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; +} + +static double triOverlapAreaXZ(const Vector3 t0[3], const 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); returns false if degenerate. */ +static bool bary(const Vector3 &p, const Vector3 &a, const Vector3 &b, + const Vector3 &c, float &u, float &v, float &w) +{ + 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. + * Intersection must be strictly inside the triangle and strictly inside + * the segment (so shared vertices/edges do not count). */ +static bool segTriPierce(const Vector3 &p0, const Vector3 &p1, + const Vector3 &a, const Vector3 &b, const Vector3 &c, + Vector3 &hit) +{ + 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; + 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; + hit = p; + return true; +} + +struct Analysis { + int coplanar = 0; + int crossing = 0; + bool nan = false; + Vector3 mn = Vector3(FLT_MAX, FLT_MAX, FLT_MAX); + Vector3 mx = Vector3(-FLT_MAX, -FLT_MAX, -FLT_MAX); +}; + +static Analysis analyze(const Procedural::TriangleBuffer &buf, + const char *name) +{ + const auto &verts = buf.getVertices(); + const auto &indices = buf.getIndices(); + size_t nTri = indices.size() / 3; + printf("== %s: %zu vertices, %zu tris\n", name, verts.size(), nTri); + + Analysis res; + for (const auto &v : verts) { + const Vector3 &p = v.mPosition; + if (std::isnan(p.x) || std::isnan(p.y) || std::isnan(p.z)) + res.nan = true; + res.mn.makeFloor(p); + res.mx.makeCeil(p); + } + printf(" bounds min (%g, %g, %g) max (%g, %g, %g)%s\n", res.mn.x, + res.mn.y, res.mn.z, res.mx.x, res.mx.y, res.mx.z, + res.nan ? " *** NaN ***" : ""); + + for (size_t i = 0; i < nTri; ++i) { + Vector3 t0[3]; + for (int k = 0; k < 3; ++k) + t0[k] = verts[(size_t)indices[i * 3 + k]].mPosition; + 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) { + Vector3 t1[3]; + for (int k = 0; k < 3; ++k) + t1[k] = verts[(size_t)indices[j * 3 + k]] + .mPosition; + Vector3 n1 = (t1[1] - t1[0]).crossProduct(t1[2] - t1[0]); + float l1 = n1.length(); + if (l1 > 1e-8f) + n1 /= l1; + + bool reported = false; + 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) { + if (res.coplanar < 400) + printf(" COPLANAR %zu/%zu " + "area %.3f near " + "(%.2f,%.2f,%.2f)\n", + i, j, + triOverlapAreaXZ(t0, t1), + t0[0].x, t0[0].y, + t0[0].z); + ++res.coplanar; + reported = true; + } + } + if (reported) + continue; + Vector3 hit; + 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], hit); + for (int e = 0; e < 3 && !pierce; ++e) + pierce = segTriPierce(t1[e], t1[(e + 1) % 3], + t0[0], t0[1], t0[2], hit); + if (pierce) { + if (res.crossing < 400) + printf(" CROSS %zu/%zu at " + "(%.3f,%.3f,%.3f)\n", + i, j, hit.x, hit.y, hit.z); + ++res.crossing; + } + } + } + printf(" coplanar-overlap pairs: %d crossing pairs: %d\n", + res.coplanar, res.crossing); + return res; +} + +/* Build one piece and check it. Returns false on any defect. */ +static bool checkPiece(bool built, const char *name, + Procedural::TriangleBuffer &buf, Analysis *out) +{ + if (!built) { + printf("== %s: build failed\n", name); + return false; + } + Analysis a = analyze(buf, name); + if (out) + *out = a; + bool ok = !a.nan && a.coplanar == 0 && a.crossing == 0; + if (!ok) + printf(" *** %s FAILED ***\n", name); + return ok; +} + +static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C) +{ + printf("A=(%g,%g,%g) B=(%g,%g,%g) C=(%g,%g,%g)\n", A.x, A.y, A.z, B.x, + B.y, B.z, C.x, C.y, C.z); + + RoadGraph graph; + graph.config.laneWidth = 3.0f; + graph.config.lanesPerDirection = 1; + graph.config.roadThickness = 0.3f; + int idA = graph.addNode(A, 0.0f); + int idB = graph.addNode(B, 0.0f); + int idC = graph.addNode(C, 0.0f); + graph.addEdge(idA, idB); + graph.addEdge(idB, idC); + + std::vector wedges; + std::vector segs; + enumerateWedges(graph, wedges, segs); + + const RoadWedge *wSmall = nullptr, *wLarge = nullptr; + for (const auto &w : wedges) { + if (w.nodeId != idB || w.degenerate) + continue; + if (!wSmall || w.sweptAngleDeg < wSmall->sweptAngleDeg) + wSmall = &w; + if (!wLarge || w.sweptAngleDeg > wLarge->sweptAngleDeg) + wLarge = &w; + } + printf("wedges: small=%.1f deg large=%.1f deg, segments=%zu\n", + wSmall ? wSmall->sweptAngleDeg : -1.0f, + wLarge ? wLarge->sweptAngleDeg : -1.0f, segs.size()); + + bool ok = true; + if (!wSmall || !wLarge) { + printf("*** wedges at node B not found ***\n"); + return false; + } + + { + Procedural::TriangleBuffer buf; + bool built = RoadGeometryLib::buildWedgeGeometry(*wSmall, graph, + buf); + Analysis a; + ok &= checkPiece(built, "smaller wedge", buf, &a); + + /* The fallback box template supplies the slab thickness: + * flat nodes must give Y within +/- thickness/2 (a second + * extrusion would double it). */ + if (built && A.y == 0.0f && B.y == 0.0f && C.y == 0.0f && + (a.mn.y < -0.16f || a.mx.y > 0.16f)) { + printf(" *** slab thickness wrong " + "(double extrusion?) ***\n"); + ok = false; + } + } + { + Procedural::TriangleBuffer buf; + bool built = RoadGeometryLib::buildWedgeGeometry(*wLarge, graph, + buf); + ok &= checkPiece(built, "larger wedge", buf, nullptr); + } + + for (const auto &s : segs) { + Procedural::TriangleBuffer buf; + bool built = RoadGeometryLib::buildSegmentGeometry(s, graph, + buf); + ok &= checkPiece(built, + s.nodeId == idA ? "segment A" : "segment C", + buf, nullptr); + } + return ok; +} + +int main(int argc, char **argv) +{ + bool ok = true; + + if (argc == 10) { + Vector3 A(atof(argv[1]), atof(argv[2]), atof(argv[3])); + Vector3 B(atof(argv[4]), atof(argv[5]), atof(argv[6])); + Vector3 C(atof(argv[7]), atof(argv[8]), atof(argv[9])); + ok = runConfig(A, B, C); + } else if (argc == 1) { + /* Regression configurations: flat, raised/lowered corner + * node, raised endpoints. */ + ok &= runConfig(Vector3(-5, 0, 0), Vector3(0, 0, 0), + Vector3(5, 0, 5)); + ok &= runConfig(Vector3(-5, 0, 0), Vector3(0, 5, 0), + Vector3(5, 0, 5)); + ok &= runConfig(Vector3(-5, 0, 0), Vector3(0, -4, 0), + Vector3(5, 0, 5)); + ok &= runConfig(Vector3(-5, 5, 0), Vector3(0, 0, 0), + Vector3(5, 0, 5)); + ok &= runConfig(Vector3(-5, 0, 0), Vector3(0, 0, 0), + Vector3(5, 5, 5)); + } else { + fprintf(stderr, + "usage: %s [Ax Ay Az Bx By Bz Cx Cy Cz]\n", + argv[0]); + return 2; + } + + printf("%s\n", ok ? "ROAD GEOMETRY OVERLAP TEST: PASSED" + : "ROAD GEOMETRY OVERLAP TEST: FAILED"); + return ok ? 0 : 1; +}