From 10f2e260398981f1db50da9cd7484fb93cd44ad4 Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:44:20 +1000 Subject: [PATCH] fix(#580): the nearest point on an edge, and the edge it was measuring 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) --- .../539-nearest-point-on-curve/README.md | 7 + Sources/OCCTBridge/include/OCCTBridge.h | 43 ++-- Sources/OCCTBridge/src/OCCTBridge_Topology.mm | 73 ++++--- Sources/OCCTSwift/Shape.swift | 47 ++++- .../Issue580PointEdgeExtremaTests.swift | 183 ++++++++++++++++++ .../OCCTAnalysisTests/OCCTAnalysisTests.swift | 28 +-- docs/CHANGELOG.md | 72 ++++++- docs/reference/Shape-Measurement.md | 36 +++- 8 files changed, 421 insertions(+), 68 deletions(-) create mode 100644 Tests/OCCTAnalysisTests/Issue580PointEdgeExtremaTests.swift diff --git a/Scripts/repro/539-nearest-point-on-curve/README.md b/Scripts/repro/539-nearest-point-on-curve/README.md index b508727f..5863e2f0 100644 --- a/Scripts/repro/539-nearest-point-on-curve/README.md +++ b/Scripts/repro/539-nearest-point-on-curve/README.md @@ -90,6 +90,13 @@ was left out of #539 because fixing it needs a decision about its `solutionCount `580-repair-options.mm` measures that decision rather than leaving it open. Over 189 edge/point combinations: +> **Shipped.** #580 took the recommendation below: `Shape.pointEdgeExtrema` now computes its +> distance, parameter and point with `occtNearestPointOnCurveRange` and keeps `BRepExtrema_ExtPC` +> solely for `solutionCount`, which stops doubling as a success flag. Fixing it also turned up a +> second, unrelated defect the probes here do not cover: `edgeIndex` walked a bare `TopExp_Explorer` +> (one entry per *occurrence* — a box's 12 edges are 24), so from index 9 it named a different edge +> than `Shape.edges()`. It now uses `occtEdgeAt`, #541's enumeration. + | candidate set | correct distances | |---|---| | all extrema, `nil` when `IsDone()` is false (today) | 101 correct, 34 wrong, 54 `nil` | diff --git a/Sources/OCCTBridge/include/OCCTBridge.h b/Sources/OCCTBridge/include/OCCTBridge.h index 800bf910..a8eac95e 100644 --- a/Sources/OCCTBridge/include/OCCTBridge.h +++ b/Sources/OCCTBridge/include/OCCTBridge.h @@ -132,7 +132,10 @@ // BRepExtrema_ExtCC → OCCTBRepExtremaExtCC // BRepExtrema_ExtCF → OCCTBRepExtremaExtCF // BRepExtrema_ExtFF → OCCTBRepExtremaExtFF -// BRepExtrema_ExtPC → OCCTBRepExtremaExtPC +// BRepExtrema_ExtPC → OCCTBRepExtremaExtPC (for its extrema COUNT only since +// #580; the distance/parameter/point come from +// occtNearestPointOnCurveRange, because extrema exclude the +// edge's ends and the one in range can be a maximum) // BRepExtrema_ExtPF → OCCTBRepExtremaExtPF // BRepExtrema_Poly → OCCTShapePolyhedralDistance // @@ -352,8 +355,9 @@ // (NOT OCCTSurfacePlateThrough; see GeomPlate) // GeomAPI_ProjectPointOnCurve → OCCTCurve3DNearestParameter, OCCTExtremaLocateOnCurve, // OCCTExtremaPointCurve, OCCTProjOnCurve*, -// OCCTEdgeProjectPoint, OCCTCurve3DProjectPoint -// (the last two only as one of the three candidate sources +// OCCTEdgeProjectPoint, OCCTCurve3DProjectPoint, +// OCCTBRepExtremaExtPC +// (the last three only as one of the three candidate sources // occtNearestPointOnCurveRange takes a minimum over; see // ShapeAnalysis_Curve for the other, and #539 for why // neither class answers correctly on its own) @@ -482,8 +486,9 @@ // --- ShapeAnalysis --- // ShapeAnalysis_Curve → OCCTCurve3DProjectPoint, OCCTCurve3DValidateRange, // OCCTCurve3DGetSamplePoints3D, OCCTCurve3DIsClosedWithPreci, -// OCCTCurve3DIsPeriodicSA, OCCTEdgeProjectPoint -// (the two projection entry points reach ::Project through +// OCCTCurve3DIsPeriodicSA, OCCTEdgeProjectPoint, +// OCCTBRepExtremaExtPC +// (the three projection entry points reach ::Project through // occtNearestPointOnCurveRange, which does not trust its // answer alone; see GeomAPI_ProjectPointOnCurve) // ShapeAnalysis_FreeBounds → OCCTShapeFreeBounds, OCCTShapeFreeBoundsClosedCount, @@ -6508,17 +6513,31 @@ OCCTShapeRef _Nullable OCCTShapeUpgradeDivideContinuity(OCCTShapeRef shape, int3 /// Point-edge extrema result typedef struct { - double distance; // Minimum distance - double parameter; // Parameter on edge at closest point - double ptx, pty, ptz; // Closest point on edge - int32_t solutionCount; // Number of extrema found + double distance; // Minimum distance over the edge, ends included (#580) + double parameter; // Parameter on edge at the nearest point + double ptx, pty, ptz; // Nearest point on edge + int32_t solutionCount; // Number of perpendicular feet BRepExtrema_ExtPC found; 0 is a + // reportable state, not a failure -- see below + bool isValid; // false only when there is no such edge, or it has no 3D curve } OCCTPointEdgeExtremaResult; -/// Compute distance from a point to an edge. +/// Compute the minimum distance from a point to an edge of a shape, over the whole edge. +/// +/// The distance/parameter/point are the minimum over the edge's own range including its two ends, +/// via occtNearestPointOnCurveRange -- the same helper behind OCCTEdgeProjectPoint, so the two +/// cannot disagree about the same point and the same edge. This used to report the minimum over +/// BRepExtrema_ExtPC's extrema instead, which excludes the ends and can consist of a single +/// MAXIMUM: a point below a half circle of radius 5 read as 11 away when it is 7.81 away, and a +/// point past the end of a trimmed segment had no answer at all (#580). +/// +/// `solutionCount` keeps both its meaning and its source: how many extrema BRepExtrema_ExtPC found, +/// which is how many perpendicular feet the point has on the edge. It is no longer a success flag. +/// Zero means the nearest point is one of the ends, which is worth reporting rather than refusing. +/// /// @param px,py,pz Point coordinates /// @param shape Shape containing the edge -/// @param edgeIndex Edge index (0-based) -/// @return Extrema result (minimum distance solution) +/// @param edgeIndex Edge index (0-based, in the enumeration Shape.edges() reads) +/// @return Nearest-point result; check isValid, not solutionCount OCCTPointEdgeExtremaResult OCCTBRepExtremaExtPC(double px, double py, double pz, OCCTShapeRef shape, int32_t edgeIndex); diff --git a/Sources/OCCTBridge/src/OCCTBridge_Topology.mm b/Sources/OCCTBridge/src/OCCTBridge_Topology.mm index b636b2dc..3de18eba 100644 --- a/Sources/OCCTBridge/src/OCCTBridge_Topology.mm +++ b/Sources/OCCTBridge/src/OCCTBridge_Topology.mm @@ -1207,39 +1207,64 @@ OCCTFaceFaceExtremaResult OCCTBRepExtremaExtFF(OCCTShapeRef shape1, int32_t face } // MARK: - BRepExtrema Ext PC/CF (v0.49) + +// #580: the nearest point on the edge, not the nearest of BRepExtrema_ExtPC's extrema. +// +// BRepExtrema_ExtPC searches for perpendicular feet, so it excludes the edge's own two ends and +// makes no distinction between a minimum and a maximum. Reporting the smallest of what it found is +// therefore not the smallest distance to the edge, and measurably so: over 189 edge/point +// combinations (Scripts/repro/539-nearest-point-on-curve/580-repair-options.mm) it answered 101 +// correctly, 34 with a distance that was too large -- a point below a half circle of radius 5 read +// as 11 rather than 7.81, because the sole extremum in range is the far side of the arc -- and +// refused 54 outright, IsDone() being false whenever no foot exists at all. +// +// The measured trap: filtering the extrema to the IsMin ones scores 101, exactly what it scored +// before. The cases that filter drops are precisely the ones it leaves with no candidate. +// +// occtNearestPointOnCurveRange (#539) is what answers this, so both this entry point and +// OCCTEdgeProjectPoint reach one implementation and cannot disagree about the same edge and the +// same point. Adding BRepExtrema_ExtPC's own ends via TrimmedSquareDistances would be the smaller +// diff but tops out at 188/189: on a BSpline queried from (2, 0, 0) Extrema_ExtPC does not +// converge, leaving the nearer end to answer 2 against a truth of 1.996434, where the helper's +// GeomAPI_ProjectPointOnCurve finds the interior minimum. OCCTPointEdgeExtremaResult OCCTBRepExtremaExtPC(double px, double py, double pz, OCCTShapeRef shape, int32_t edgeIndex) { OCCTPointEdgeExtremaResult result = {}; if (!shape) return result; try { - TopoDS_Edge edge; - int idx = 0; - for (TopExp_Explorer exp(shape->shape, TopAbs_EDGE); exp.More(); exp.Next()) { - if (idx == edgeIndex) { edge = TopoDS::Edge(exp.Current()); break; } - idx++; - } + // #541's enumeration, which is the one Shape.edges() and Shape.edge(at:) read. This used to + // walk its own bare explorer, which counts one entry per *occurrence*: a box's 12 edges are + // 24 occurrences, since each belongs to two faces, and measured on the pinned kernel the two + // orders diverge from index 9 onwards -- edgeIndex 9 was the edge through (10, 0, 5) here + // and the edge through (5, 0, 10) to every other entry point. A caller holding an index from + // edges() measured to an edge it had not selected, on the most ordinary shape there is. + TopoDS_Edge edge = occtEdgeAt(shape->shape, edgeIndex); if (edge.IsNull()) return result; - TopoDS_Vertex vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)); - BRepExtrema_ExtPC ext(vertex, edge); - if (!ext.IsDone()) return result; + Standard_Real first, last; + Handle(Geom_Curve) curve = BRep_Tool::Curve(edge, first, last); + if (curve.IsNull()) return result; - result.solutionCount = ext.NbExt(); - if (result.solutionCount >= 1) { - // Find minimum distance - double minDist2 = ext.SquareDistance(1); - int minIdx = 1; - for (int i = 2; i <= ext.NbExt(); i++) { - if (ext.SquareDistance(i) < minDist2) { - minDist2 = ext.SquareDistance(i); - minIdx = i; - } - } - result.distance = sqrt(minDist2); - result.parameter = ext.Parameter(minIdx); - gp_Pnt pt = ext.Point(minIdx); - result.ptx = pt.X(); result.pty = pt.Y(); result.ptz = pt.Z(); + gp_Pnt nearest; + if (!occtNearestPointOnCurveRange(curve, gp_Pnt(px, py, pz), first, last, + Precision::Confusion(), + &nearest, &result.parameter, &result.distance)) { + return result; + } + result.ptx = nearest.X(); result.pty = nearest.Y(); result.ptz = nearest.Z(); + + // Still BRepExtrema_ExtPC's own count, reported for its own sake: how many perpendicular + // feet the point has on this edge. Zero now travels to the caller instead of erasing the + // answer, and a search that cannot finish leaves it zero rather than failing the call. + try { + TopoDS_Vertex vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)); + BRepExtrema_ExtPC ext(vertex, edge); + if (ext.IsDone()) result.solutionCount = ext.NbExt(); + } catch (...) { + // A count we could not take is zero feet reported, not a failed distance. } + + result.isValid = true; return result; } catch (...) { return result; diff --git a/Sources/OCCTSwift/Shape.swift b/Sources/OCCTSwift/Shape.swift index 8fb165d9..508a722a 100644 --- a/Sources/OCCTSwift/Shape.swift +++ b/Sources/OCCTSwift/Shape.swift @@ -8011,27 +8011,56 @@ extension Shape { /// Result of point-edge distance extrema computation public struct PointEdgeExtrema: Sendable { - /// Minimum distance from the point to the edge + /// Minimum distance from the point to the edge, over the whole edge including its ends public let distance: Double - /// Parameter on edge at closest point + /// Parameter on the edge at the nearest point public let parameter: Double - /// Closest point on edge + /// The nearest point on the edge public let pointOnEdge: SIMD3 - /// Number of extrema solutions found + /// How many perpendicular feet the point has on this edge (`BRepExtrema_ExtPC`'s extrema). + /// + /// Zero is an ordinary, informative answer rather than a failure: it means the nearest + /// point is one of the edge's two ends. A non-zero count does *not* mean the nearest point + /// is one of those feet — an extremum can be a maximum — so read `distance`, `parameter` + /// and `pointOnEdge` for the answer and this only for the count. public let solutionCount: Int } - /// Compute minimum distance from a point to an edge of this shape. + /// Compute the minimum distance from a point to an edge of this shape. + /// + /// The answer is the nearest point over the whole edge, its two ends included, and matches + /// ``Edge/project(point:)`` on the same edge and the same point — both take a minimum over + /// `ShapeAnalysis_Curve`, `GeomAPI_ProjectPointOnCurve` and the edge's ends. + /// + /// ```swift + /// let arc = Shape.fromWire(Wire.arc( + /// center: SIMD3(0, 0, 0), radius: 5, startAngle: 0, endAngle: .pi)!)! /// - /// Uses BRepExtrema_ExtPC to find the closest point on the specified edge. + /// // Below the arc, the nearest point is an end — the only extremum is the far side of it. + /// if let hit = arc.pointEdgeExtrema(point: SIMD3(0, -6, 0), edgeIndex: 0) { + /// print(hit.distance) // 7.81, to the end at (5, 0, 0). Was 11, the far side. + /// print(hit.solutionCount) // 1 — and that one extremum is a maximum + /// } + /// + /// let segment = Shape.fromWire(Wire.line(from: SIMD3(3, 0, 0), to: SIMD3(8, 0, 0))!)! + /// if let hit = segment.pointEdgeExtrema(point: SIMD3(100, 0, 0), edgeIndex: 0) { + /// print(hit.distance) // 92. Was nil: no extremum exists past the end. + /// print(hit.solutionCount) // 0 — no perpendicular foot, not a failure + /// } + /// ``` + /// + /// Before #580 this reported the smallest of `BRepExtrema_ExtPC`'s extrema, which excludes the + /// edge's ends and can be a single *maximum*: the arc above answered 11 (the far side), and a + /// point beyond the end of a trimmed segment answered `nil`. /// /// - Parameters: /// - point: 3D point - /// - edgeIndex: 0-based edge index - /// - Returns: Extrema result, or nil if computation fails + /// - edgeIndex: 0-based edge index, in the enumeration ``edges()`` reads + /// - Returns: The nearest-point result, or nil if there is no such edge index or that edge has + /// no 3D curve. public func pointEdgeExtrema(point: SIMD3, edgeIndex: Int) -> PointEdgeExtrema? { let result = OCCTBRepExtremaExtPC(point.x, point.y, point.z, handle, Int32(edgeIndex)) - guard result.solutionCount > 0 else { return nil } + guard result.isValid else { return nil } return PointEdgeExtrema( distance: result.distance, parameter: result.parameter, diff --git a/Tests/OCCTAnalysisTests/Issue580PointEdgeExtremaTests.swift b/Tests/OCCTAnalysisTests/Issue580PointEdgeExtremaTests.swift new file mode 100644 index 00000000..2328d378 --- /dev/null +++ b/Tests/OCCTAnalysisTests/Issue580PointEdgeExtremaTests.swift @@ -0,0 +1,183 @@ +import Testing +import Foundation +import simd +@testable import OCCTSwift + +// MARK: - #580: the nearest point on an edge of a shape + +/// `Shape.pointEdgeExtrema(point:edgeIndex:)` is the third entry point promising the closest point +/// on an edge, after the two #539 fixed. It carried the same defect and one of its own: +/// +/// - 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 only +/// 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. When no foot exists at all — an ordinary point past the end of a segment — +/// `IsDone()` is false and the whole call answered `nil`. Over 189 edge/point combinations it was +/// right 101 times, wrong 34 and silent 54. +/// +/// - Its `edgeIndex` walked a bare `TopExp_Explorer`, which counts one entry per *occurrence*. A +/// box's 12 edges are 24 occurrences, so from index 9 onwards it measured to a different edge +/// than `Shape.edges()` and `Shape.edge(at:)` name (#541's contract). +/// +/// Both now route through what everything else uses: `occtNearestPointOnCurveRange` for the +/// geometry and `occtEdgeAt` for the index. `solutionCount` keeps its own meaning — how many +/// perpendicular feet the point has — and stops doubling as a success flag. +@Suite("The nearest point on an edge of a shape (#580)") +struct Issue580PointEdgeExtremaTests { + + /// A segment from (3, 0, 0) to (8, 0, 0), as one edge. + private static func segment() throws -> Shape { + let wire = try #require(Wire.line(from: SIMD3(3, 0, 0), to: SIMD3(8, 0, 0))) + return try #require(Shape.fromWire(wire)) + } + + /// The upper half of a circle of radius 5 in the XY plane, as one edge. + private static func halfArc() throws -> Shape { + let wire = try #require(Wire.arc(center: .zero, radius: 5, startAngle: 0, endAngle: .pi)) + return try #require(Shape.fromWire(wire)) + } + + // MARK: The four rows the issue was filed on + + /// A point below the arc has no perpendicular foot on it, so the nearest point is an end at + /// (±5, 0, 0), sqrt(25 + 36) = 7.81025 away. The sole extremum `BRepExtrema_ExtPC` finds is the + /// top of the arc — a maximum — and it was reported as the answer. + @Test("A point below a half arc gets the nearer end, not the far side") + func belowTheArcGetsTheEnd() throws { + let arc = try Self.halfArc() + let result = try #require(arc.pointEdgeExtrema(point: SIMD3(0, -6, 0), edgeIndex: 0)) + + #expect(abs(result.distance - (25.0 + 36.0).squareRoot()) < 1e-6) // 7.81025, was 11 + #expect(abs(result.pointOnEdge.y) < 1e-6) + #expect(abs(abs(result.pointOnEdge.x) - 5) < 1e-6) + + // There is exactly one extremum, and it is the maximum at the top of the arc — the answer + // this used to give. So a non-zero solutionCount does not mean the nearest point is one of + // the extrema, only that the point has that many perpendicular feet on the edge. + #expect(result.solutionCount == 1) + #expect(simd_distance(result.pointOnEdge, SIMD3(0, 5, 0)) > 1) + } + + /// (3, -4, 0) is exactly on the radius-5 circle and exactly not on the upper half of it. The + /// nearest point on the arc is its start at (5, 0, 0), sqrt(4 + 16) = 4.47214 away. + @Test("A point on the circle but off the arc measures to the arc") + func onTheCircleButOffTheArc() throws { + let arc = try Self.halfArc() + let result = try #require(arc.pointEdgeExtrema(point: SIMD3(3, -4, 0), edgeIndex: 0)) + + #expect(abs(result.distance - (4.0 + 16.0).squareRoot()) < 1e-6) // 4.47214, was 10 + #expect(simd_distance(result.pointOnEdge, SIMD3(5, 0, 0)) < 1e-6) + } + + /// Past the end of a straight edge there is no foot at all, so `IsDone()` was false and the + /// method answered `nil` — for a point 92 units from the edge. + @Test("A point past the end of a segment is answered, not refused") + func pastTheEndIsAnswered() throws { + let segment = try Self.segment() + + let beyond = try #require(segment.pointEdgeExtrema(point: SIMD3(100, 0, 0), edgeIndex: 0)) + #expect(abs(beyond.distance - 92) < 1e-9) // was nil + #expect(simd_distance(beyond.pointOnEdge, SIMD3(8, 0, 0)) < 1e-9) + #expect(beyond.solutionCount == 0) + + let before = try #require(segment.pointEdgeExtrema(point: .zero, edgeIndex: 0)) + #expect(abs(before.distance - 3) < 1e-9) // was nil + #expect(simd_distance(before.pointOnEdge, SIMD3(3, 0, 0)) < 1e-9) + } + + // MARK: solutionCount is a count, and nil is a missing edge + + /// A point that does have a perpendicular foot reports one, and the geometry is the foot. This + /// is the case the pre-#580 implementation already answered correctly, so it pins that the + /// field still means what it meant and still comes from `BRepExtrema_ExtPC`. + @Test("A point with a perpendicular foot reports it, and lands on it") + func perpendicularFootIsReported() throws { + let segment = try Self.segment() + let onSegment = try #require(segment.pointEdgeExtrema(point: SIMD3(5, 2, 0), edgeIndex: 0)) + #expect(abs(onSegment.distance - 2) < 1e-9) + #expect(simd_distance(onSegment.pointOnEdge, SIMD3(5, 0, 0)) < 1e-9) + #expect(onSegment.solutionCount >= 1) + + let arc = try Self.halfArc() + let above = try #require(arc.pointEdgeExtrema(point: SIMD3(0, 6, 0), edgeIndex: 0)) + #expect(abs(above.distance - 1) < 1e-6) + #expect(simd_distance(above.pointOnEdge, SIMD3(0, 5, 0)) < 1e-6) + #expect(above.solutionCount >= 1) + } + + /// `nil` now means only what the documentation says: no such edge index, or an edge with no 3D + /// curve. Under the old guard it also meant "this point has no perpendicular foot", which is + /// the case the three tests above cover. + @Test("nil is reserved for an index that names no edge") + func nilMeansNoSuchEdge() throws { + let segment = try Self.segment() + #expect(segment.pointEdgeExtrema(point: .zero, edgeIndex: 1) == nil) + #expect(segment.pointEdgeExtrema(point: .zero, edgeIndex: -1) == nil) + #expect(segment.pointEdgeExtrema(point: .zero, edgeIndex: 99) == nil) + } + + // MARK: The index contract, and agreement with the other entry point + + /// The index is a position in the enumeration `Shape.edges()` reads. The bare explorer this + /// used to walk counts a box's 12 edges as 24 occurrences, so from index 9 the two named + /// different edges — measured on the pinned kernel, edge 9 was (10, 0, 5) here against + /// (5, 0, 10) everywhere else. + @Test("edgeIndex names the edge Shape.edges() names") + func edgeIndexMatchesEdgesEnumeration() throws { + let box = try #require(Shape.box(width: 10, height: 10, depth: 10)) + let edges = box.edges() + #expect(edges.count == 12) + + // A probe inside the box, so every edge has a perpendicular foot and this measures the + // index contract on its own rather than tripping the old nil guard first. Asymmetric, so + // that naming the wrong edge cannot coincidentally agree. + let probe = SIMD3(1, 2, 3) + for (index, edge) in edges.enumerated() { + let viaShape = try #require(box.pointEdgeExtrema(point: probe, edgeIndex: index), + "edge \(index)") + let viaEdge = try #require(edge.project(point: probe), "edge \(index)") + #expect(abs(viaShape.distance - viaEdge.distance) < 1e-9, "edge \(index)") + #expect(simd_distance(viaShape.pointOnEdge, viaEdge.point) < 1e-9, "edge \(index)") + } + } + + /// The whole point of routing through `occtNearestPointOnCurveRange`: two entry points asking + /// the same question about the same edge and the same point cannot answer differently. Before + /// #580 they disagreed on every arc case above. + @Test("Shape.pointEdgeExtrema and Edge.project agree") + func agreesWithEdgeProject() throws { + let arc = try Self.halfArc() + let edge = try #require(arc.edges().first) + + for point in [SIMD3(0, -6, 0), SIMD3(3, -4, 0), SIMD3(0, 6, 0), + SIMD3(0, -1, 0), SIMD3(6, 0, 0), .zero] { + let viaShape = try #require(arc.pointEdgeExtrema(point: point, edgeIndex: 0), "\(point)") + let viaEdge = try #require(edge.project(point: point), "\(point)") + #expect(abs(viaShape.distance - viaEdge.distance) < 1e-6, "\(point)") + #expect(simd_distance(viaShape.pointOnEdge, viaEdge.point) < 1e-6, "\(point)") + #expect(abs(viaShape.parameter - viaEdge.parameter) < 1e-6, "\(point)") + } + } + + /// The caller this broke: a proximity test on a shape's edges. Every edge of a box is within + /// 200 of a far probe, and the nearest of them is the one the truth says it is. + @Test("A proximity scan over a shape's edges reads the real distances") + func proximityScanOverEdges() throws { + let box = try #require(Shape.box(width: 10, height: 10, depth: 10)) + let probe = SIMD3(20, 0, 0) + + var nearest = Double.greatestFiniteMagnitude + var answered = 0 + for index in 0.. 0` — which the guard it was testing made unfalsifiable. See #580. @Test("Point to edge distance on box") func pointToEdge() throws { let box = Shape.box(width: 10, height: 10, depth: 10)! let edgeCount = box.edges().count - #expect(edgeCount > 0) + #expect(edgeCount == 12) - // Try each edge until we find one that gives a valid extremum - var foundResult = false for i in 0..= 0) - #expect(result.solutionCount > 0) - foundResult = true - break - } - } - #expect(foundResult) + let result = try #require(box.pointEdgeExtrema(point: SIMD3(5, 5, 15), edgeIndex: i)) + // Every edge of the box is a bounded segment, so no answer can exceed the box's + // diagonal plus the probe's own offset from it. + #expect(result.distance > 0) + #expect(result.distance < 30) + } + + // The box is centred on the origin, so (5, 5, 15) is the corner (5, 5, 5) plus 10 in z: the + // nearest edge point is that corner itself. + let nearest = (0.. 0` unfalsifiable for any +non-`nil` result. `edgeIndex` 9 and above name different edges on any shape whose edges are shared +between faces, which is every solid. + +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 `#expect(result.solutionCount > 0)` 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). +Bridge-only — no kernel patch, no `OCCT.xcframework` rebuild. + #### The cross-reference index stops naming 135 symbols that never existed (#510) `OCCTBridge.h` opens with a hand-maintained index mapping each wrapped OCCT class to the bridge @@ -329,14 +387,12 @@ points above. The two are different questions — the nearest point, which exist point, versus the nearest perpendicular foot, which does not — and `Issue500Curve3DNearestParameterTests` pins the distinction, updated here to the corrected answer it recorded as-is. -**Measured, not fixed.** `Shape.pointEdgeExtrema(point:edgeIndex:)` (`BRepExtrema_ExtPC`) is a third -entry point documented as finding "the closest point on the edge" with the same defect: 11 for the -half-circle query above, and `IsDone()` false for both trimmed-segment queries. It is left alone -because fixing it means first deciding what its `solutionCount`, which is the extrema count it -deliberately exposes, should say when the answer is an end. Filed as #580 — with that decision -measured rather than left open: `solutionCount` keeps its meaning (OCCT models the ends separately -from the extrema, via `BRepExtrema_ExtPC::TrimmedSquareDistances`), the `nil` guard is what changes, -and routing through this PR's shared helper scores 189/189 against the in-place repair's 188/189. +**Measured here, fixed in #580.** `Shape.pointEdgeExtrema(point:edgeIndex:)` (`BRepExtrema_ExtPC`) is +a third entry point documented as finding "the closest point on the edge" with the same defect: 11 +for the half-circle query above, and `IsDone()` false for both trimmed-segment queries. It was left +out of this change because fixing it meant first deciding what its `solutionCount`, the extrema +count it deliberately exposes, should say when the answer is an end — so that decision was measured +here rather than left open, and acted on in #580 above, in the same release. New suite `Issue539NearestPointOnCurveTests` (`OCCTCurveTests`), 12 tests. Proved rather than assumed: reinstating the two original implementations fails 9 of the 12, and the 3 that still pass diff --git a/docs/reference/Shape-Measurement.md b/docs/reference/Shape-Measurement.md index 45e1d149..49efbfb2 100644 --- a/docs/reference/Shape-Measurement.md +++ b/docs/reference/Shape-Measurement.md @@ -2103,21 +2103,49 @@ public struct PointEdgeExtrema: Sendable { } ``` +`solutionCount` is how many perpendicular feet the point has on the edge — `BRepExtrema_ExtPC`'s +extrema count, reported for its own sake. Zero means the nearest point is one of the edge's two +ends. A non-zero count does **not** mean the nearest point is one of those feet: an extremum can be +a maximum. Read `distance` / `parameter` / `pointOnEdge` for the answer (#580). + --- ### `pointEdgeExtrema(point:edgeIndex:)` -Compute minimum distance from a point to an edge of this shape. +Compute the minimum distance from a point to an edge of this shape, over the whole edge. ```swift public func pointEdgeExtrema(point: SIMD3, edgeIndex: Int) -> PointEdgeExtrema? ``` +```swift +let arc = Shape.fromWire(Wire.arc( + center: SIMD3(0, 0, 0), radius: 5, startAngle: 0, endAngle: .pi)!)! + +// Below the arc, the nearest point is an end — the only extremum is the far side of it. +if let hit = arc.pointEdgeExtrema(point: SIMD3(0, -6, 0), edgeIndex: 0) { + print(hit.distance) // 7.81, to the end at (5, 0, 0). Was 11, the far side. + print(hit.solutionCount) // 1 — and that one extremum is a maximum +} + +let segment = Shape.fromWire(Wire.line(from: SIMD3(3, 0, 0), to: SIMD3(8, 0, 0))!)! +if let hit = segment.pointEdgeExtrema(point: SIMD3(100, 0, 0), edgeIndex: 0) { + print(hit.distance) // 92. Was nil: no extremum exists past the end. +} +``` + - **Parameters:** - `point` — 3D point. - - `edgeIndex` — 0-based edge index. -- **Returns:** Extrema result, or `nil` on failure. -- **OCCT:** `BRepExtrema_ExtPC` (via `OCCTBRepExtremaExtPC`). + - `edgeIndex` — 0-based edge index, in the enumeration `edges()` reads. +- **Returns:** The nearest-point result, or `nil` if there is no such edge index or that edge has no + 3D curve. +- **OCCT:** `ShapeAnalysis_Curve` + `GeomAPI_ProjectPointOnCurve` + the edge's ends, via + `occtNearestPointOnCurveRange` (the helper behind `Edge.project(point:)`, so the two agree); + `BRepExtrema_ExtPC` supplies `solutionCount` only. +- **Changed in #580:** this used to report the minimum over `BRepExtrema_ExtPC`'s extrema, which + excludes the edge's ends, and to answer `nil` whenever no extremum existed. It also indexed edges + by a bare `TopExp_Explorer` walk, which counts one entry per occurrence — from index 9 a box's + edges disagreed with `edges()`. ---