…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>
Closes #580
The defect
Shape.pointEdgeExtrema(point:edgeIndex:)is the third entry point promising the closest point onan edge, after the two #539 fixed. It reported the minimum over
BRepExtrema_ExtPC's extrema, whichis 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.
[0, π](0, -6, 0)[0, π](3, -4, 0)(on the circle, off the arc)[3, 8]along +X(100, 0, 0)nil[3, 8]along +X(0, 0, 0)nilOver 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.
IsMinonly, no endsTrimmedSquareDistances)occtNearestPointOnCurveRangeThe measured trap is worth restating: filtering to
IsMinscores 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_ExtPCdoes not convergeon a BSpline queried from
(2, 0, 0), leaving the nearer end to answer 2 against a truth of1.996434, where
GeomAPI_ProjectPointOnCurvefinds the interior minimum. Routing through the sharedhelper 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 framingsolutionCountkeeps its meaning, its source and its value; thenilguard is what changed (a newisValidfield on the C struct carries it). Zero now travels to the caller as the informative stateit is instead of erasing the answer.
But
solutionCount == 0is not the signal for "the nearest point is an end." The half-circlerow above reports
solutionCount == 1— and that one extremum is the maximum it used to answerwith. 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
edgeIndexwalked a bareTopExp_Explorer, which counts one entry per occurrence: a box's 12edges are 24 occurrences, since each belongs to two faces. Measured on the pinned kernel, that
diverges from the enumeration
Shape.edges()andShape.edge(at:)read (#541's contract) fromindex 9 onwards —
edgeIndex: 9measured to the edge through(10, 0, 5)where every other entrypoint names the one through
(5, 0, 10). Not a shared-sub-shape curiosity like #541's splitterfixture: a plain box. Now uses
occtEdgeAt.Blast radius
Re-ran the issue's census across the local ecosystem checkouts:
PointEdgeExtrema.solutionCounthasexactly one reader anywhere, the
#expect(result.solutionCount > 0)this PR replaces. The othersolutionCounthits inOCCTSwiftViewport's gallery are different types (DistSS,ShapeDist).That demo does read
pointOnEdgeto place a marker (OCCT8Gallery.swift:3730), onedgeIndex: 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-existingBRepExtremaExtPCTests.pointToEdgerewritten — its "loop until we find one that gives a validextremum" was itself a workaround for this defect, and its
solutionCount > 0assertion wasunfalsifiable 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
swift test: 5051 tests, 3 failures, all pre-existing on the base. All three are inIssue496CylindricalHoleTests; confirmed by running that suite on a throwaway worktree atorigin/refactor/381-pass1b— identical failures, same lines, same values. They are what open PRfix(#532): the cylindrical-hole drill selected parts of the cut result, not parts of its tool #587 (BRepFeat_MakeCylindricalHole: PerformUntilEnd and the ranged Perform report NoError while removing no material across a multi-body stack #532) fixes.
check-bridge-index.py: 0 stale (two entries updated, plus theBRepExtrema_ExtPCentry nowrecords that it supplies the count only).
count-operations.py: 4299 ✓✓, unchanged — no new operations.Bridge-only: no kernel patch, no
OCCT.xcframeworkrebuild.🤖 Generated with Claude Code