Skip to content

fix(#580): the nearest point on an edge, and the edge it was measuring to - #588

Merged
gsdali merged 1 commit into
refactor/381-pass1bfrom
fix/580-point-edge-extrema
Jul 31, 2026
Merged

fix(#580): the nearest point on an edge, and the edge it was measuring to#588
gsdali merged 1 commit into
refactor/381-pass1bfrom
fix/580-point-edge-extrema

Conversation

@gsdali

@gsdali gsdali commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #580

The defect

Shape.pointEdgeExtrema(point:edgeIndex:) is the third entry point promising the closest point on
an edge, after the two #539 fixed. It reported the minimum over BRepExtrema_ExtPC's extrema, which
is not the minimum over the edge: extrema are perpendicular feet, so they exclude the edge's own two
ends, and the one in range can be a maximum.

edge query point was truth
half circle r=5, [0, π] (0, -6, 0) 11 (the far side) 7.81025
half circle r=5, [0, π] (3, -4, 0) (on the circle, off the arc) 10 4.47214
segment [3, 8] along +X (100, 0, 0) nil 92
segment [3, 8] along +X (0, 0, 0) nil 3

Over 189 edge/point combinations: right 101 times, wrong 34, silent 54.

The fix, and why not the smaller one

Re-ran the issue's own probe (Scripts/repro/539-nearest-point-on-curve/580-repair-options.mm)
against the pinned kernel before acting on it — the filed numbers reproduce exactly.

candidate set correct, of 189
today 101
extrema flagged IsMin only, no ends 101
all extrema + the two ends (TrimmedSquareDistances) 188
#539's occtNearestPointOnCurveRange 189

The measured trap is worth restating: filtering to IsMin scores exactly what it already scored,
because the cases it drops are the ones it then leaves with no candidate at all. Adding the ends is
what fixes it.

Repairing in place would be the smaller diff and tops out at 188 — Extrema_ExtPC does not converge
on a BSpline queried from (2, 0, 0), leaving the nearer end to answer 2 against a truth of
1.996434, where GeomAPI_ProjectPointOnCurve finds the interior minimum. Routing through the shared
helper also means all three entry points cannot disagree about the same edge and the same point,
which is the divergence the #377 audit exists to remove.

solutionCount, and one correction to the issue's framing

solutionCount keeps its meaning, its source and its value; the nil guard is what changed (a new
isValid field on the C struct carries it). Zero now travels to the caller as the informative state
it is instead of erasing the answer.

But solutionCount == 0 is not the signal for "the nearest point is an end." The half-circle
row above reports solutionCount == 1 — and that one extremum is the maximum it used to answer
with. The implication only runs one way: zero implies an end, non-zero implies nothing. My first
draft of the doc snippet claimed 0 there; the new test caught it, and the docs now state the
asymmetry.

A second defect, found while fixing the first

edgeIndex walked a bare TopExp_Explorer, which counts one entry per occurrence: a box's 12
edges are 24 occurrences, since each belongs to two faces. Measured on the pinned kernel, that
diverges from the enumeration Shape.edges() and Shape.edge(at:) read (#541's contract) from
index 9 onwards
edgeIndex: 9 measured to the edge through (10, 0, 5) where every other entry
point names the one through (5, 0, 10). Not a shared-sub-shape curiosity like #541's splitter
fixture: a plain box. Now uses occtEdgeAt.

Blast radius

Re-ran the issue's census across the local ecosystem checkouts: PointEdgeExtrema.solutionCount has
exactly one reader anywhere, the #expect(result.solutionCount > 0) this PR replaces. The other
solutionCount hits in OCCTSwiftViewport's gallery are different types (DistSS, ShapeDist).
That demo does read pointOnEdge to place a marker (OCCT8Gallery.swift:3730), on edgeIndex: 0
below the index-divergence threshold, so only the nearest-point change can move it. Worth a manual
look downstream; nothing in this repo depends on it.

Tests

New suite Issue580PointEdgeExtremaTests (OCCTAnalysisTests), 8 tests, plus the pre-existing
BRepExtremaExtPCTests.pointToEdge rewritten — its "loop until we find one that gives a valid
extremum" was itself a workaround for this defect, and its solutionCount > 0 assertion was
unfalsifiable under the guard it was testing.

Proved rather than assumed: reinstating the old implementation fails 7 of the 10, and the 3 that
pass are exactly the deliberately-unchanged ones (a point with a perpendicular foot, an out-of-range
index, and the pre-existing in-range case). The index test fails with 6 issues — 3 edges × 2
assertions, i.e. indices 9, 10, 11.

Verification

Bridge-only: no kernel patch, no OCCT.xcframework rebuild.

🤖 Generated with Claude Code

@gsdali

gsdali commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Review: #588fix(#580): the nearest point on an edge, and the edge it was measuring to

(Note: /review 580 was requested, but #580 is the tracked issue, not a PR — #588 is the open PR that closes it.)

Overview

Shape.pointEdgeExtrema(point:edgeIndex:) reported the minimum over BRepExtrema_ExtPC's extrema (perpendicular feet), which excludes an edge's own two ends and can consist of a single maximum — the same class of defect #539 already fixed for Curve3D.projectPoint/Edge.project(point:). This PR routes the third entry point through the same shared occtNearestPointOnCurveRange helper, repurposes solutionCount from an implicit success flag into a plain (possibly-zero) count via a new isValid field, and fixes a second, independently-discovered defect: edgeIndex walked a bare TopExp_Explorer (one entry per occurrence) instead of #541's occtEdgeAt, diverging from Shape.edges() from index 9 onward on an ordinary box.

Correctness — verified against actual OCCT/bridge source, not the PR's narrative

  • The core BRepExtrema_ExtPC claim checks out. BRepExtrema_ExtPC.hxx documents IsMin(N) ("True if the Nth extremum distance is a minimum") and a separate TrimmedSquareDistances for the two trim-range ends — confirming extrema and ends are genuinely distinct concepts on this class, exactly as the PR states. An extremum is a critical point of the distance function (a perpendicular foot); it has no obligation to include the domain boundary.
  • occtNearestPointOnCurveRange and occtEdgeAt are called with matching, correct signatures. Checked both against their actual definitions in OCCTBridge_Internal.h (introduced by Curve3D.projectPoint ignores a trimmed curve's parameter range, reporting distance 0 for a point far off the curve #539/fix(#539): the closest point on a curve is now on the curve #581 and Shape.faces() enumerates face occurrences while faceCount/face(at:) enumerate distinct faces, so faces() hands out indices face(at:) cannot address #541/fix(#541): one meaning for a face index, and the wrong face it was naming #569 respectively, both already merged): occtNearestPointOnCurveRange(handle<Geom_Curve>, gp_Pnt, first, last, precision, gp_Pnt*, double*, double*) and occtEdgeAt(const TopoDS_Shape&, int32_t) — the call sites in this diff pass exactly these types in this order.
  • occtEdgeAt's negative-index handling is documented and correct, confirming the new nilMeansNoSuchEdge test's edgeIndex: -1 case is a real, safe assertion rather than relying on undefined behavior: occtSubShapeAt (which occtEdgeAt forwards to) explicitly returns a null shape for negative, past-the-end, or wrong-type indices.
  • Exception safety is correct. OCCTBRepExtremaExtPC wraps the BRepExtrema_ExtPC construction (needed only for solutionCount) in its own inner try/catch, separate from the outer one guarding the actual distance computation — so a solutionCount failure degrades to 0 (already the zero-initialized default) without invalidating the primary answer, matching the doc comment "a count we could not take is zero feet reported, not a failed distance." result.isValid = true is set only after both blocks complete, so every early-return path (null shape, null edge, null curve, occtNearestPointOnCurveRange failing) correctly leaves isValid at its zero-initialized false.
  • The asymmetry callout is correct and well-tested. solutionCount == 0 implies the nearest point is an end, but a non-zero count does not imply the nearest point is one of those feet (an extremum can be a maximum) — the PR is explicit that its own first draft got this backwards and a test caught it. belowTheArcGetsTheEnd pins exactly this case (solutionCount == 1, and that one extremum is provably not the answer).

Test coverage

Genuinely discriminating, not just non-nil checks:

  • All four rows from the issue's own repro table are reproduced as individual assertions with the expected numeric answer, not just "no longer nil."
  • edgeIndexMatchesEdgesEnumeration checks all 12 box edges against Shape.edges()'s own enumeration using an off-axis probe point specifically chosen so a wrong-edge answer can't coincidentally match.
  • agreesWithEdgeProject cross-checks against the sibling entry point Edge.project(point:) across 6 points on the arc fixture — direct evidence the shared-helper convergence claim (all three entry points can no longer disagree) actually holds, not just asserted.
  • The pre-existing BRepExtremaExtPCTests.pointToEdge rewrite is a real strengthening: the old version looped "until we find one that gives a valid extremum" and asserted solutionCount > 0, which was unfalsifiable under the very bug being fixed (any edge with a perpendicular foot would trivially pass, masking the whole class of edges that used to answer nil). The new version requires every edge to answer and pins the actual nearest distance (10, to the corner) via a geometric argument that's checkable by hand.
  • PR body's fault-injection claim (reverting to the old implementation fails 7 of 10 in the new suite, the 3 passes being exactly the deliberately-unchanged cases) is specific enough to be a real check rather than boilerplate, consistent with this project's established "prove the test catches it" convention — not independently re-run here, but the specificity is credible.

Documentation

Risk / merge notes

Verdict

Approve. Both the primary defect and the independently-found index defect are verified against actual OCCT/bridge source, not just the PR's own account of it. Exception safety, the isValid/solutionCount semantic split, and the asymmetry documentation are all correct on inspection. Test coverage is substantive, cross-checks against the sibling entry point, and includes a real strengthening of a previously-unfalsifiable existing test. No blocking correctness, convention, or coverage issues.

…g to

Shape.pointEdgeExtrema(point:edgeIndex:) is the third entry point promising the closest
point on an edge, after the two #539 fixed. It reported the minimum over
BRepExtrema_ExtPC's extrema, which is not the minimum over the edge: extrema are
perpendicular feet, so they exclude the edge's own two ends, and the one in range can be
a maximum. A point below a half circle of radius 5 read as 11 away when it is 7.81 away,
and a point 92 units past the end of a segment had no answer at all. Over 189 edge/point
combinations it was right 101 times, wrong 34 and silent 54.

The measured trap: filtering the extrema to the IsMin ones scores 101 -- exactly what it
already scored -- because the cases that filter drops are the ones it then leaves with no
candidate. Adding the ends is what fixes it.

It now routes through #539's occtNearestPointOnCurveRange, so all three entry points reach
one implementation and cannot disagree about the same edge and the same point: 189/189.
Repairing in place with BRepExtrema_ExtPC::TrimmedSquareDistances is the smaller diff and
tops out at 188 -- Extrema_ExtPC does not converge on a BSpline queried from (2, 0, 0),
leaving the nearer end to answer 2 against a truth of 1.996434.

solutionCount keeps its meaning, its source and its value; the nil guard is what changed.
OCCT models the extrema and the ends as separate things on one object, so "how many
extrema were found" was never the wrong number -- the ends were simply never consulted.
Zero now travels to the caller as the informative state it is (the nearest point is an
end) instead of erasing the answer. A non-zero count does not mean the nearest point is
one of those feet: the half-circle case reports solutionCount == 1, and that one extremum
is the maximum it used to answer with.

And a second defect, found while fixing the first. edgeIndex walked a bare
TopExp_Explorer, which counts one entry per occurrence: a box's 12 edges are 24
occurrences, since each belongs to two faces. Measured on the pinned kernel, that diverges
from the enumeration Shape.edges() and Shape.edge(at:) read (#541's contract) from index 9
onwards -- edgeIndex 9 measured to the edge through (10, 0, 5) where every other entry
point names the one through (5, 0, 10). Not a shared-sub-shape curiosity like #541's
splitter fixture: a plain box. Now uses occtEdgeAt.

New suite Issue580PointEdgeExtremaTests (OCCTAnalysisTests), 8 tests, plus the pre-existing
BRepExtremaExtPCTests.pointToEdge rewritten -- its "loop until we find one that gives a
valid extremum" was itself a workaround for this defect, and its solutionCount > 0
assertion was unfalsifiable under the guard it was testing. Proved rather than assumed:
reinstating the old implementation fails 7 of the 10, and the 3 that pass are exactly the
deliberately-unchanged ones.

Bridge-only: no kernel patch, no OCCT.xcframework rebuild.

Closes #580

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsdali
gsdali force-pushed the fix/580-point-edge-extrema branch from a76691b to 10f2e26 Compare July 31, 2026 22:35
@gsdali
gsdali merged commit 4c69a8b into refactor/381-pass1b Jul 31, 2026
1 of 2 checks passed
@gsdali
gsdali deleted the fix/580-point-edge-extrema branch July 31, 2026 22:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant