fix(area): decide segment touching by cross product, not by division - #7692
Conversation
intersect() already means to treat a touch as not a crossing: with std::less_equal it rejects the parameters at 0 and at 1, so a segment ending on another does not cross it. It says so by dividing, and division does not land on 1. Worse, whether it does depends on the compiler. For a segment ending exactly on the endpoint of another, at the scale the engine stores coordinates, the parameter comes out as exactly 1 when multiply-add is contracted into fma and as 0.99999999999985489385 when it is not. The same predicate on the same points answers differently in two translation units of one build. A parameter is exactly 0 or 1 precisely when the corresponding endpoint lies on the other segment's line, and that is a cross product rather than a quotient: it is exactly zero whenever the two differences it multiplies are, which is the whole of this case, and no rounding or contraction can move it off zero. So the degenerate cases are decided by the cross products and only the genuinely interior ones by the division. The cost of getting this wrong is a sight line deleted from a visibility graph. Walking along a wall is allowed, so a line that grazes an obstacle's corner and continues along its face is real, and dropping it sends the planner the long way round. Two places in this repository already work around the same weakness by hand, one of them noting that intersect() "is meant to ignore endpoint hits by itself, but it cannot be relied on". Measured near 1.0 with differences around 1e-6, which is where plaza coordinates live, 543 of 2400 touching configurations were reported as crossings before this and none are after. A single hand written case is kept but is deliberately not the guard, because under contraction it passes either way and would go on passing while the predicate was wrong everywhere else.
There was a problem hiding this comment.
Pull request overview
This PR makes the extractor::area::intersect() predicate deterministic for “touching at endpoints” cases by avoiding reliance on floating-point division landing exactly on 0/1, preventing compiler-dependent endpoint touches from being misclassified as proper crossings (which can incorrectly remove visibility edges in area routing).
Changes:
- Snap the intersection parameters to
0/1when an endpoint lies exactly on the other segment’s supporting line (via cross products), and only rely on the quotient for non-degenerate cases. - Add regression tests covering both a concrete real-world coordinate case and a larger deterministic grid of lon/lat-scale “T-touch” configurations.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| include/extractor/area/util.hpp | Makes intersect() robust to FMA/contraction differences by snapping endpoint-touch parameters using cross products before endpoint-exclusive comparisons. |
| unit_tests/extractor/area/util.cpp | Adds targeted and grid-based unit tests ensuring endpoint touches are never reported as crossings at relevant coordinate scales. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #7692 +/- ##
==========================================
- Coverage 94.74% 94.74% -0.01%
==========================================
Files 519 519
Lines 41534 41582 +48
==========================================
+ Hits 39353 39398 +45
- Misses 2181 2184 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The problem
intersect()already means to treat a touch as not a crossing. Withstd::less_equalitrejects the parameters at 0 and at 1, so a segment that ends on another does not cross it.
It says this by dividing, and the division does not land on 1.
Whether it does depends on the compiler. For a segment ending exactly on the endpoint of
another, at the scale we store coordinates, the parameter is exactly 1 when multiply-add is
contracted into fma, and 0.99999999999985489385 when it is not:
So the same predicate on the same points can answer differently in two translation units of
one build. That is how a unit test on these coordinates passed while the fuzz harness saw
the same line blocked.
The fix
A parameter is exactly 0 or 1 exactly when the matching endpoint lies on the other segment's
line. That is a cross product, not a quotient. It is exactly zero whenever the two
differences it multiplies are zero, which is this whole case, and no rounding or contraction
can move it off zero.
Degenerate cases are now decided by the cross products. Only genuinely interior crossings
are decided by the division. The change is additive: four cross products and a snap.
Why it matters
A wrong answer here deletes a sight line from a visibility graph. Walking along a wall is
allowed, so a line that grazes an obstacle's corner and then runs along its face is real,
and dropping it sends the planner the long way round.
Two places in this repository already work around the same weakness by hand. One of them
says so directly:
Testing
At the scale plaza coordinates live at, near 1.0 with differences around 1e-6, 543 of 2400
touching configurations were reported as crossings before this change and none are after.
That test fails without the fix, checked by reverting it.
A single hand written case is also included but is deliberately not the guard. Under
contraction it passes either way, so on its own it would keep passing while the predicate
was wrong everywhere else.
Full cucumber suite: 1478 scenarios, 1463 passed, 15 skipped, 0 failed, the same as before
the change. Unit suites for engine, extractor and util all pass.
🤖 Claude Code, Claude Opus 5