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
33 changes: 20 additions & 13 deletions Scripts/repro/censuses/ClusterB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,19 +443,26 @@ enum ClusterB {
// MARK: - chamfer2D(edgePairs:distances:) [2D, face-level, batch]

do {
// NOT called live: rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0])
// SIGSEGVs (uncatchable -- an OS signal, the same shape of defect CLAUDE.md's "Known
// OCCT Bugs" section already documents for this family). Confirmed in isolation before
// writing this note: it crashes on the FIRST duplicated-pair call, immediately, with no
// other statement in this block reached first. Likely mechanism (not chased further,
// since diagnosing it is a fix, not a census): BRepFilletAPI_MakeFillet2d::AddChamfer
// rebuilds the face incrementally, so the edge handles resolved from the ORIGINAL
// `edgeMap` before the loop starts are stale by the second call on the same pair --
// `fillet2D`'s equivalent duplicate-vertex call does NOT crash (measured below), so
// this is specific to AddChamfer, not the shared TopTools_IndexedMapOfShape lookup.
// This is a NEW finding, not previously in #520/#568/#612/#633/#639, and out of scope
// to fix here per #665's own instruction that this is the census only.
let dupVerdict = "CRASH (SIGSEGV, uncatchable): a duplicated edge pair [(0,1),(0,1)] crashes BRepFilletAPI_MakeFillet2d::AddChamfer on its second call. Not run live in this census -- see comment above."
// #705: this used to be UNSAFE to call live. A duplicated edge pair, e.g.
// [(0,1),(0,1)], SIGSEGV'd (uncatchable -- an OS signal) inside the repeat call's own
// BRepFilletAPI_MakeFillet2d::AddChamfer, which rebuilds the face incrementally, so a
// second call naming the same pair resolved against edge handles the first call had
// already made stale. Fixed in OCCTFace2DChamfer (OCCTBridge_Modeling.mm) by rejecting
// the whole call when any two entries name the same pair, in either order, before
// AddChamfer ever sees the second one -- matching fillet2D's own contract for a
// duplicated vertex on this same builder (#568). Reusing ONE edge across two
// DIFFERENT pairs (chamfering adjacent corners of a polygon) is unaffected and still
// measured non-nil below; only the identical pair repeated is refused. Now run live.
let dup = rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0])
let reversedDup = rectFace.chamfer2D(edgePairs: [(0, 1), (1, 0)], distances: [1.0, 2.0])
let sharedEdgeDifferentPairs = rectFace.chamfer2D(
edgePairs: [(0, 1), (1, 2)], distances: [1.0, 1.0])
let dupVerdict: String
if dup == nil && reversedDup == nil && sharedEdgeDifferentPairs != nil {
dupVerdict = "REJECT (nil) on a duplicated edge pair, order-independent (fixed by #705, was CRASH: SIGSEGV, uncatchable); one edge across two DIFFERENT pairs stays non-nil"
} else {
dupVerdict = "unexpected: dup \(dup == nil ? "nil" : "non-nil"), reversedDup \(reversedDup == nil ? "nil" : "non-nil"), sharedEdgeDifferentPairs \(sharedEdgeDifferentPairs == nil ? "nil" : "non-nil")"
}
let outOfRange = rectFace.chamfer2D(edgePairs: [(0, 1), (2, 99_999)], distances: [1.0, 1.0])
record("2D chamfer (face)", "chamfer2D(edgePairs:distances:)",
duplicate: dupVerdict,
Expand Down
23 changes: 15 additions & 8 deletions Scripts/repro/cluster-b-fillet-edge-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ python3 Scripts/repro/cluster-b-fillet-edge-contract/classify_fillet_sites.py --
| chamfer (with history) | `chamferedWithFullHistory(distance:edges:)` | N/A: uniform distance (dup 995.000000 == single-edge 995.000000) | REJECT (nil) | SKIP (non-nil, area 476.191927 vs unfilleted 500.000000) | REJECT (nil) |
| offset-per-face | `offsetPerFace(defaultOffset:faceOffsets:)` | N/A: `faceOffsets` is a `Dictionary`, a duplicate key cannot be constructed | REJECT (nil), fixed by **#541** (see "Corrections" below) | N/A: `BRepOffset_MakeOffset` has no per-face decline analogous to `Add()` on a free-boundary edge | ACCEPTS (non-nil): applies `defaultOffset` uniformly, a legitimate no-override request |
| 2D fillet (face) | `fillet2D(vertexIndices:radii:)` | REJECT (nil) on a duplicated vertex index | REJECT (nil), fixed by #568 | UNMEASURED: no open/degenerate planar-face fixture built here | REJECT (nil), Swift-side guard |
| 2D chamfer (face) | `chamfer2D(edgePairs:distances:)` | **CRASH (SIGSEGV, uncatchable)** on a duplicated edge pair -- see "New findings" below | REJECT (nil), fixed by #568 | UNMEASURED, same reason as `fillet2D` | REJECT (nil), Swift-side guard |
| 2D chamfer (face) | `chamfer2D(edgePairs:distances:)` | REJECT (nil), order-independent, fixed by #705 (was **CRASH (SIGSEGV, uncatchable)**, see "New findings" below) | REJECT (nil), fixed by #568 | UNMEASURED, same reason as `fillet2D` | REJECT (nil), Swift-side guard |
| fillet (class API) | `FilletBuilder.addEdge(_:radius:)` | **OVERWRITE: last radius wins**, same mechanism as #633, unaudited by #489/#520/#568 | N/A: takes an `Edge`, not an index | foreign edge (a different `Shape` entirely): `addEdge` returns `true`, `build()` REJECT (nil) | `build()` with zero `addEdge` calls: REJECT (nil) |
| chamfer (class API) | `ChamferBuilder.addEdge(_:distance:)` | **FIRST WINS**, matches `chamferedTwoDistances` -- `addEdge` itself prefers the first call, not just the bridge's hand-rolled loop | N/A: takes an `Edge`, not an index | foreign edge: `addEdge` returns `true`, `build()` REJECT (nil) | `build()` with zero `addEdge` calls: REJECT (nil) |

Expand Down Expand Up @@ -102,16 +102,23 @@ family, not just deciding to reject/dedupe/document.

## New findings (not in #520/#568/#612/#633/#639)

1. **`chamfer2D(edgePairs:distances:)` SIGSEGVs (uncatchable) on a duplicated edge pair.**
`rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0])` crashes the process on
1. **`chamfer2D(edgePairs:distances:)` SIGSEGVs (uncatchable) on a duplicated edge pair --
fixed by #705, this census's own row now measures it live.** At the time this census landed,
`rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0])` crashed the process on
the pair's *second* occurrence. Confirmed in isolation before writing the census's own note
(not run live in the shipped artifact -- an OS signal is uncatchable, per this repo's own
`CLAUDE.md` precedent for this exact family). `fillet2D`'s equivalent duplicate-*vertex* call
does **not** crash (it rejects, cleanly), so this is specific to `BRepFilletAPI_MakeFillet2d
::AddChamfer`, not the shared `TopTools_IndexedMapOfShape` lookup both functions use. Likely
mechanism, not chased further since diagnosing it is a fix and this is a census: `AddChamfer`
rebuilds the face incrementally, so the edge handles resolved from the *original* `edgeMap`
before the loop starts are stale by a second call naming the same pair.
does **not** crash (it rejects, cleanly), so this was specific to `BRepFilletAPI_MakeFillet2d
::AddChamfer`, not the shared `TopTools_IndexedMapOfShape` lookup both functions use. The
mechanism this census guessed at without chasing further -- diagnosing it was a fix, not a
census -- was confirmed exactly by #705: `AddChamfer` rebuilds the face incrementally, so the
edge handles resolved from the *original* `edgeMap` before the loop starts were stale by a
second call naming the same pair, order-independent (`(0, 1)` then `(1, 0)` crashed identically
to `(0, 1)` twice). #705 fixed it by rejecting the whole call on a repeated pair, in either
order, before `AddChamfer` sees the second one -- reusing one edge across two *different* pairs
(e.g. chamfering adjacent corners of a polygon with `(0, 1)` then `(1, 2)`) is unaffected and
still measures non-nil. `ClusterB.swift`'s own `chamfer2D` block now calls the duplicate case
live rather than noting it as unsafe.
2. **The `FilletBuilder`/`ChamferBuilder` class API has zero index resolution or value validation
of any kind.** No `occtUseSubShapesByIndex`, no `occtValidFilletRadius`. A foreign edge (one
belonging to an entirely different `Shape`) is silently accepted by `addEdge` (returns `true`)
Expand Down
26 changes: 26 additions & 0 deletions Sources/OCCTBridge/src/OCCTBridge_Modeling.mm
Original file line number Diff line number Diff line change
Expand Up @@ -4280,6 +4280,32 @@ OCCTShapeRef OCCTFace2DChamfer(OCCTShapeRef shape,
TopoDS_Shape e1 = occtMappedSubShapeAt(edgeMap, edge1Indices[i]);
TopoDS_Shape e2 = occtMappedSubShapeAt(edgeMap, edge2Indices[i]);
if (e1.IsNull() || e2.IsNull()) return nullptr;

// #705: the exact same edge pair named twice SIGSEGVs, uncatchably, inside the repeat
// call's own BRepFilletAPI_MakeFillet2d::AddChamfer. Measured order-independent: (0,1)
// then (1,0) crashes the same way as (0,1) twice. Root cause is an upstream OCCT
// defect, not this bridge's: AddChamfer(edge1, edge2, ...) calls
// ChFi2d::FindConnectedEdges to look up the pair's shared vertex and dereferences the
// two edges it returns without checking the returned status first; that lookup leaves
// both edges null on every failure path, and the pair's second call fails it, because
// the shared vertex was already consumed chamfering the pair the first time. The
// sibling overload (AddChamfer(edge, vertex, distance, angle)) checks the identical
// status correctly. Filed upstream as OCCT#1431 (repro) / OCCT#1432 (fix). The kernel
// patch carrying that fix lands in its own PR and is inert until the pinned
// xcframework is rebuilt, so this guard is what protects callers meanwhile. Reusing ONE edge across two DIFFERENT
// pairs is ordinary and measured safe, e.g. chamfering adjacent corners of a rectangle
// with (0,1) then (1,2); only the identical pair repeated crashes, so this checks the
// pair, not the individual indices. Rejected rather than skipped, matching fillet2D's
// own contract for a duplicated vertex (#568): this site already rejects the whole
// batch on one bad index instead of dropping just that entry, and a repeated pair has
// the same "which distance wins" ambiguity a bad index does, so the whole call fails
// instead of guessing.
for (int32_t j = 0; j < i; j++) {
bool sameOrder = edge1Indices[i] == edge1Indices[j] && edge2Indices[i] == edge2Indices[j];
bool swappedOrder = edge1Indices[i] == edge2Indices[j] && edge2Indices[i] == edge1Indices[j];
if (sameOrder || swappedOrder) return nullptr;
}

chamfer.AddChamfer(TopoDS::Edge(e1), TopoDS::Edge(e2), distances[i], distances[i]);
}

Expand Down
13 changes: 13 additions & 0 deletions Sources/OCCTSwift/Shape+Geom2d.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ extension Shape {
/// rather than being skipped (#568). Previously the pair was dropped and the corners that
/// did resolve were cut, reported as a complete result.
///
/// - Note: The same edge pair named twice fails the whole call rather than crashing (#705).
/// This is an upstream OCCT defect in `BRepFilletAPI_MakeFillet2d::AddChamfer`, not this
/// wrapper's own: the pair's second call finds its shared vertex already consumed by the
/// first chamfer, and the resulting failure returns two null edges that `AddChamfer`
/// dereferences without checking for first. The process SIGSEGV'd, uncatchably, before this
/// guard existed. The check is order independent: `(0, 1)` and `(1, 0)` name the same pair
/// and both are refused. Reusing one edge across two *different* pairs is unaffected and
/// still works, e.g. chamfering every corner of a rectangle with
/// `(0, 1), (1, 2), (2, 3), (3, 0)`.
///
/// - Parameters:
/// - edgePairs: Array of (edge1Index, edge2Index) pairs identifying adjacent edges
/// - distances: Chamfer distance for each edge pair
Expand All @@ -64,6 +74,9 @@ extension Shape {
/// let face = Shape.face(from: Wire.rectangle(width: 20, height: 20)!)!
/// let cut = face.chamfer2D(edgePairs: [(0, 1), (2, 3)], distances: [2, 2])
/// print(cut?.edgeCount ?? 0) // 6: two corners replaced by chamfer edges
///
/// // A repeated pair is refused, not crashed, and not silently collapsed to one chamfer.
/// print(face.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1, 2]) == nil) // true
/// ```
public func chamfer2D(edgePairs: [(Int, Int)], distances: [Double]) -> Shape? {
guard !edgePairs.isEmpty, edgePairs.count == distances.count else { return nil }
Expand Down
30 changes: 30 additions & 0 deletions Tests/OCCTModelingTests/Issue568IndexSkipTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,34 @@ struct Issue568IndexSkipTests {
// 4 edges, two corners cut off: each chamfer adds one edge.
#expect(result?.edgeCount == 6)
}

// MARK: - 2D chamfer duplicate pair (#705)

/// #705: the same edge pair named twice used to SIGSEGV the process, uncatchably, inside the
/// second `BRepFilletAPI_MakeFillet2d::AddChamfer` call. Confirmed in a separate process
/// before this fix landed (raw exit code 139); this test only exercises the fixed, in-process
/// behaviour, matching the "ordinary, safe" half of that instruction. The pair is order
/// independent: `(0, 1)` and `(1, 0)` name the same two edges and both are refused.
@Test("A 2D chamfer is refused when the same edge pair is named twice")
func chamfer2DRejectsDuplicatePair() {
let face = Shape.face(from: Wire.rectangle(width: 20, height: 20)!)!
#expect(face.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0]) == nil)
#expect(face.chamfer2D(edgePairs: [(0, 1), (1, 0)], distances: [1.0, 2.0]) == nil)
#expect(face.chamfer2D(edgePairs: [(0, 1), (0, 1), (0, 1)], distances: [1.0, 1.0, 1.0]) == nil)
}

/// The duplicate-pair guard has to key on the PAIR, not on either index alone: chamfering
/// every corner of a rectangle legitimately reuses each edge across two DIFFERENT pairs
/// (edge 1 closes both the (0,1) and the (1,2) corner). Measured safe before this fix and
/// must stay safe after it.
@Test("A 2D chamfer still cuts every corner when adjacent pairs share an edge")
func chamfer2DAcceptsSharedEdgeAcrossDifferentPairs() {
let face = Shape.face(from: Wire.rectangle(width: 20, height: 20)!)!
let result = face.chamfer2D(
edgePairs: [(0, 1), (1, 2), (2, 3), (3, 0)],
distances: [1.0, 1.0, 1.0, 1.0])
#expect(result != nil)
// 4 edges, all four corners cut off: each chamfer adds one edge.
#expect(result?.edgeCount == 8)
}
}
57 changes: 57 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,63 @@ silent no-op on any machine that had built before: the old tag's sources were co
under the new version's number. It now requires `HEAD` to be at the tag the script names and aborts
otherwise, naming the tree so a diagnostic probe left by an investigation is not destroyed silently.

### `chamfer2D` SIGSEGVs, uncatchably, on a repeated edge pair (#705)

Found by Cluster B's edge/vertex-index census (#665, `Scripts/repro/cluster-b-fillet-edge-contract/`),
which records the crash rather than running it live, since an in-process OS signal would kill the
census itself. `Shape.chamfer2D(edgePairs:distances:)` crashed the whole process, uncatchably, when
the same edge pair appeared twice:

```swift
let wire = Wire.polygon3D([SIMD3(0, 0, 0), SIMD3(10, 0, 0), SIMD3(10, 10, 0), SIMD3(0, 10, 0)], closed: true)!
let rectFace = Shape.face(from: wire)!
_ = rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0]) // SIGSEGV, exit 139
```

Confirmed in a separate process (a temporarily-repointed `Sources/OCCTTest/main.swift`, restored
after), since an in-process crash kills the test runner rather than failing one test:

| Input | Before | After |
|---|---|---|
| `[(0, 1), (0, 1)]` | SIGSEGV, exit 139 | `nil`, exit 0 |
| `[(0, 1), (1, 0)]` (reversed, same pair) | SIGSEGV, exit 139 | `nil`, exit 0 |
| `[(0, 1), (0, 1), (0, 1)]` | SIGSEGV, exit 139 | `nil`, exit 0 |
| `[(0, 1), (1, 2)]` (one edge, two different pairs) | non-nil, unaffected | non-nil, unaffected |
| `[(0, 1), (1, 2), (2, 3), (3, 0)]` (every corner) | non-nil, unaffected | non-nil, unaffected |

The crash is inside the repeat call's own `BRepFilletAPI_MakeFillet2d::AddChamfer`, an OS signal
the bridge's `catch (...)` cannot absorb, and it is an upstream OCCT defect, not this bridge's own.
`AddChamfer(edge1, edge2, ...)` calls `ChFi2d::FindConnectedEdges` to look up the pair's shared
vertex and dereferences the two edges it returns without checking the returned status first, and
that lookup leaves both edges null on every failure path. A pair's second call fails the lookup,
because its shared vertex was already consumed chamfering the pair the first time. The sibling
overload (`AddChamfer(edge, vertex, distance, angle)`) checks the identical status correctly, which
is the precedent the upstream filing cites. Reusing one edge across two *different* pairs, i.e.
chamfering adjacent corners of a polygon, the ordinary multi-corner case, does not crash, measured
above. Only the identical pair repeated does, order-independent. A kernel patch is carried
separately, tracked in a follow-up PR; the guard below is what protects callers until it ships.

**Fixed**: `OCCTFace2DChamfer` (`OCCTBridge_Modeling.mm`) now checks each pair against every prior
pair in the same call before invoking `AddChamfer`, and rejects the whole request (returns `nullptr`)
on a match in either order. This matches `fillet2D(vertexIndices:radii:)`'s own contract for a
duplicated vertex on the same builder (#568) and the #568 idiom already used one line above in the
same function for an out-of-range index: the whole call fails rather than guessing which of two
distances to keep. #633 is open on the wider family's duplicate-index direction (fillet is
last-wins, chamfer is first-wins) and is deliberately not settled here; this fix removes a crash,
not a vote in that debate, though it is recorded as a data point for it.

This is a behaviour change on a public API with no compile error, recorded in
[`SEMVER.md`](SEMVER.md#recorded-exception-unreleased-chamfer2d-refuses-a-repeated-edge-pair-instead-of-crashing-705).
A call with no repeated pair, which includes every existing caller, is unaffected. Tests:
`Tests/OCCTModelingTests/Issue568IndexSkipTests.swift`'s `chamfer2DRejectsDuplicatePair` and
`chamfer2DAcceptsSharedEdgeAcrossDifferentPairs`, the latter proven to catch an overly broad fix by
injecting one (reject on any repeated single index rather than a repeated pair) and confirming it
turns the shared-edge test red, then restoring.

The census's own row for `chamfer2D` is updated from `CRASH (SIGSEGV, uncatchable)` to the measured
`REJECT (nil)`, now safe to run live: `swift run Censuses cluster-b` calls it directly rather than
noting it as unsafe to run.

### Pass 1b of the #377 duplication audit

#### `AAG` rode the lossy `faces()`, so `detectPocketsAAG()` answered 2 or 1 for the same geometry depending on compound member order (#642)
Expand Down
Loading