Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ editor/resources/help/help.css text eol=lf
# test reads in binary, so without this the Windows runner's autocrlf checkout
# fails the gate on line endings alone — which is exactly how it first failed.
tests/esmini/*.xosc text eol=lf

# Third instance (#356): the straight-approach junction-floor golden is what the
# mesher emits, compared line by line by TJunctionStraightIdentity
# .StraightApproachFloorsMatchTheGolden. The generator writes '\n' and the test
# reads in binary, so without this the Windows runner's autocrlf checkout would
# fail the gate on line endings alone.
core/tests/data/junction/straight_floor_golden.txt text eol=lf
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,41 @@ Current version on `main`: **0.0.1**.
about silently never matched.

### Fixed
- **A road curving through a junction no longer paves a spike of pavement that
belongs to no road** ([#356](https://github.com/Robomous/RoadMaker/issues/356)).
A junction splits its through road into two arms, and the corner solver
extrapolated each arm's facing edge as an infinite **straight ray** from a
single end station. Two collinear edges never meet, so straight roads were
always fine — but on a curve the rays met at a fabricated apex metres outside
the pavement, and the fillet built there paved out to it. For a 30 m radius
the apex sat **7.18 m** past the true edge and the fillet dipped 4.08 m past
it.

The curved case is now detected the way the straight one always was: when the
two arms' facing edges share an osculating circle they are one pavement edge
running through the junction, so there is no corner to fillet and the mesher
paves the arc corridor the edge itself describes. The corridor is anchored to
an arc through **both** face corners rather than to either arm's osculating
circle — the arms are clothoid-fitted, so those two circles differ by
centimetres, and anchoring to one leaves a sliver at the other. The floor's
southernmost point now lands on the true pavement edge to the millimetre.

A straight arm has zero edge curvature, so the solve stays exactly the
line-line one it has always been: all seven straight-approach fixtures mesh
**bit-identically**, measured by a before/after differential of every fixture.
The committed golden that guards it from here on compares geometry and
connectivity *canonically* rather than line by line — mesh vertex ORDER turns
out to differ between x86_64 and arm64, and it has never been a kernel
guarantee (the determinism tests pin it only within a single run), so a golden
asserting it would be asserting something the mesher does not promise. `#356`'s
reproduction is no longer disabled — it is a matrix case, and three new
geometric gates assert the floor stays on the pavement the curve describes and
that no corner is reported between a road and itself.

Worth recording, because it is why this survived so long: without sidewalks
the spike was there too and **every gate passed it**, since a spike is a convex
corner and the fillet gate exempts those. Only the sidewalk band wrapped it
into a concave needle the matrix could see.
- **Virtual-junction linkage and connection internals round-trip**
([#537](https://github.com/Robomous/RoadMaker/issues/537)). Three holes, all
in modeled scopes that #453's preservation sweep deliberately does not cover:
Expand Down
54 changes: 54 additions & 0 deletions core/src/mesh/junction_corner_detail.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ std::vector<CornerFace> corner_faces(const RoadNetwork& network, const Junction&
// frame is flipped relative to the entering direction, so its road-left
// corner is the entering-right one.
const bool flipped = arm.contact == ContactPoint::Start;
// True pavement-edge curvature at the face. The centerline curvature is
// left-positive along +s; an edge offset t metres left of it rides a
// radius (1/kappa - t), and traversing the arm backwards (a Start contact
// enters the junction along -s) negates the signed curvature.
const double kappa_c = road->plan_view.evaluate(station).curvature;
const auto edge_kappa = [&](double t) {
const double denom = 1.0 - (kappa_c * t);
if (std::abs(denom) < tol::kLength) {
return 0.0;
}
return sign * kappa_c / denom;
};
const double k_front = edge_kappa(offsets.front());
const double k_back = edge_kappa(offsets.back());
faces.push_back(CornerFace{
.left = flipped ? std::array<double, 2>{right[0], right[1]}
: std::array<double, 2>{left[0], left[1]},
Expand All @@ -172,6 +186,8 @@ std::vector<CornerFace> corner_faces(const RoadNetwork& network, const Junction&
.ix = ix,
.iy = iy,
.arm = arm,
.kappa_left = flipped ? k_back : k_front,
.kappa_right = flipped ? k_front : k_back,
});
}
if (faces.size() < 2) {
Expand Down Expand Up @@ -254,6 +270,44 @@ CornerSolution solve_corner(const RoadNetwork& network,
solution.parallel_edges = true;
return solution;
}
// A curved road split by the junction presents its two halves as two arms
// whose facing edges are one continuous pavement edge. Straight rays from
// the two faces meet at a fabricated apex metres outside the pavement, and
// filleting it paves a spike that belongs to no road (#356). Detect it the
// way `parallel_edges` detects the straight case — by the edges being the
// same curve — and report the arc instead of a corner.
if (std::abs(a.kappa_right) > kThroughEdgeMinCurvature &&
std::abs(b.kappa_left) > kThroughEdgeMinCurvature) {
// Osculating circle of each edge: centre one signed radius to the left.
const double ra = 1.0 / a.kappa_right;
const double rb = 1.0 / b.kappa_left;
const std::array<double, 2> ca{pa[0] - (ra * a.iy), pa[1] + (ra * a.ix)};
const std::array<double, 2> cb{pb[0] - (rb * b.iy), pb[1] + (rb * b.ix)};
// Traversed in opposing directions, one continuous edge has curvatures of
// OPPOSITE sign, so compare |r| and the centres.
if (std::hypot(ca[0] - cb[0], ca[1] - cb[1]) < kThroughEdgeTolerance &&
std::abs(std::abs(ra) - std::abs(rb)) < kThroughEdgeTolerance) {
// Pave the arc THROUGH both face corners rather than either osculating
// circle. The arms are clothoid-fitted, so their two osculating circles
// differ by centimetres; an arc anchored to one of them misses the other
// arm's face and leaves exactly the sliver this fix exists to remove.
// Tangent to A's edge at pa and passing through pb:
// centre = pa + t * normal(A), t = |chord|^2 / (2 * normal . chord)
const std::array<double, 2> normal{-a.iy, a.ix};
const std::array<double, 2> chord{pb[0] - pa[0], pb[1] - pa[1]};
const double denom = 2.0 * ((normal[0] * chord[0]) + (normal[1] * chord[1]));
if (std::abs(denom) > tol::kLength) {
const double t = ((chord[0] * chord[0]) + (chord[1] * chord[1])) / denom;
solution.through_edge = true;
solution.arc_center = {pa[0] + (t * normal[0]), pa[1] + (t * normal[1])};
// `normal` is the interior side (the same one the straight strips use),
// so the sign of t already says which side of the edge the pavement is
// on: keep it rather than take the magnitude.
solution.arc_radius = t;
return solution;
}
}
}
// Edge-line intersection pa + ta·A_dir = pb + tb·B_dir.
const double dx = pb[0] - pa[0];
const double dy = pb[1] - pa[1];
Expand Down
26 changes: 26 additions & 0 deletions core/src/mesh/junction_corner_detail.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ inline constexpr double kMinFilletRadius = 0.05;
/// Smallest authored tangent-leg setback [m].
inline constexpr double kMinFilletExtent = 0.05;

/// Below this curvature [1/m] an arm's edge is treated as straight, so the
/// straight-arm corner solve stays exactly the line-line one it always was
/// (2 km radius — far beyond any junction-scale corner).
inline constexpr double kThroughEdgeMinCurvature = 5e-4;

/// How closely two arms' facing edges must share an osculating circle [m] to
/// count as one pavement edge running through the junction.
inline constexpr double kThroughEdgeTolerance = 1.5;

/// Connecting roads of this junction, in connection order, de-duplicated.
[[nodiscard]] std::vector<RoadId> connecting_roads(const Junction& junction);

Expand Down Expand Up @@ -105,6 +114,11 @@ struct CornerFace {
double ix = 0.0;
double iy = 0.0;
RoadEnd arm;
/// Signed plan curvature [1/m] of the `left`/`right` pavement edge at the
/// face, traversed INTO the junction (left-positive). Zero for a straight
/// arm, which is what makes the straight-arm corner solve bit-identical.
double kappa_left = 0.0;
double kappa_right = 0.0;
};

/// The arm faces of `junction`, INTO the junction, sorted CCW around their
Expand All @@ -131,6 +145,18 @@ struct CornerSolution {
bool parallel_edges = false;
bool corner_exists = false;

/// The two faces' edges are the SAME pavement edge curving through the
/// junction (a curved road split by it) — the curved analogue of
/// `parallel_edges`. There is no corner to fillet; the mesher paves the
/// arc corridor described by `arc_center` / `arc_radius`.
bool through_edge = false;
std::array<double, 2> arc_center{};
/// SIGNED radius [m] of that arc: positive when the centre lies on the
/// pavement side of the edge (a convex through-arm), negative when the
/// pavement is on the far side (a concave one). The magnitude is the radius;
/// the sign is which way "one strip width inward" points.
double arc_radius = 0.0;

/// Edge-line intersection: A's right edge meeting B's left edge.
std::array<double, 2> corner{};

Expand Down
38 changes: 38 additions & 0 deletions core/src/mesh/junction_surface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,44 @@ void append_corner_fillets(const RoadNetwork& network,
}
continue;
}
if (solution.through_edge) {
// One pavement edge curving through the junction: pave the corridor the
// edge itself describes, sampled at the fillet sagitta, rather than the
// straight-ray corner it has no business having (#356).
const std::array<double, 2>& c = solution.arc_center;
const double r = std::abs(solution.arc_radius);
const double ang_a = std::atan2(pa[1] - c[1], pa[0] - c[0]);
double ang_b = std::atan2(pb[1] - c[1], pb[0] - c[0]);
while (ang_b - ang_a > std::numbers::pi) {
ang_b -= 2.0 * std::numbers::pi;
}
while (ang_a - ang_b > std::numbers::pi) {
ang_b += 2.0 * std::numbers::pi;
}
const double sweep = ang_b - ang_a;
const double step =
2.0 *
std::acos(std::clamp(1.0 - (junction_corner_detail::kFilletArcSagitta / r), 0.0, 1.0));
const int steps =
std::max(4, static_cast<int>(std::ceil(std::abs(sweep) / std::max(step, 1e-3))));
// One strip width INWARD, exactly as the straight strips run. Inward is
// toward the arc centre only for a convex through-arm; for a concave one
// (the arms meeting on the inside of the curve) the pavement is on the
// far side, so the strip has to widen the radius instead. The sign of
// `arc_radius` carries that.
const double r_in = std::max(r - std::copysign(kEdgeStripWidth, solution.arc_radius), 0.0);
Clipper2Lib::PathD corridor;
for (int k = 0; k <= steps; ++k) {
const double ang = ang_a + (sweep * static_cast<double>(k) / steps);
corridor.emplace_back(c[0] + (r * std::cos(ang)), c[1] + (r * std::sin(ang)));
}
for (int k = steps; k >= 0; --k) {
const double ang = ang_a + (sweep * static_cast<double>(k) / steps);
corridor.emplace_back(c[0] + (r_in * std::cos(ang)), c[1] + (r_in * std::sin(ang)));
}
push_ccw(std::move(corridor));
continue;
}
if (!solution.valid) {
continue;
}
Expand Down
Loading
Loading