Skip to content

fix(#613): seven entry points indexed sub-shapes by occurrence, and the bracket's concave edge was invisible - #650

Merged
gsdali merged 2 commits into
refactor/381-pass1bfrom
fix/613-index-contract-sites
Aug 2, 2026
Merged

fix(#613): seven entry points indexed sub-shapes by occurrence, and the bracket's concave edge was invisible#650
gsdali merged 2 commits into
refactor/381-pass1bfrom
fix/613-index-contract-sites

Conversation

@gsdali

@gsdali gsdali commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #613

The defect

#541 put sub-shape indexing on one enumeration: TopExp::MapShapes, one entry per distinct
sub-shape (TopoDS_Shape::IsSame, orientation ignored). Seven entry points stayed on a bare
TopExp_Explorer, which yields one entry per occurrence. Measured on the pinned kernel, a plain
10 mm box:

occurrences distinct
edges 24 12
vertices 48 8

Every edge is reached once per adjacent face, so these disagree on any ordinary solid, not only on
an exotic one.

The end-to-end failure is the composition this branch's own documentation recommends.
Shape.filleted(edges:radius:) carries the snippet
bracket.filleted(edges: bracket.concaveEdges(), radius: 3). Measured on an L-bracket (two fused
boxes, inner corner at x = 10, z = 10, map edge 27):

before after
bracket.concaveEdges() [] [27]
edgeConcavityCount(.concave) 2 (occurrences of that one edge) 1
box.edgeConcavityCount(.convex) on a 12-edge box 24 12

So the recommended one-liner filleted an empty list. Measured on base, filleted(edges: [], radius: 2)
returns nil — it reported failure, not success. The harm is a misattributed error rather
than a silent wrong answer: a nil from a fillet reads as "the fillet failed", and nothing pointed at
the edge selection as the cause. On the fix it returns 28034.3 mm³ against the bracket's 28000.0.

Per-site verdicts — re-derived, and one overturned

The audit comment on #613 was re-verified rather than taken on trust. Sites 1-5 confirmed;
site 6 overturned; one site added that neither the issue nor the audit names.

# Site Audit verdict My verdict Evidence
1 OCCTShapeAnalyzeEdgeConcavity safe → map confirmed see table above; ground truth: BRepOffset_Analyse::Type identical for both orientations of all 12 box edges, 0 differing
OCCTShapeCountEdgeConcavity (sibling, unlisted) not named added 12-edge box reported 24 convex edges
2 OCCTBRepExtremaExtCC safe → map confirmed matched edges() to index 8, named a different edge from 9 (closest point 14.14 mm and 10.0 mm away); answered for 12…23
3 checkSubShape (Healing) safe → map confirmed checkEdge(at: 12) reported valid although edge(at: 12) is nil, and kept answering to 23; checkVertex to 47 on an 8-vertex box
4 OCCTLocOpeSplitShapeByVertex safe → map confirmed index 9 split edges()[4], index 11 split edges()[0]; 12 and 13 split successfully
5 OCCTShapeCreateMesh / …WithParams the real one — use the oriented walk confirmed 12 face indices emitted on an 11-face compound; winding evidence below
6 edgesInFace "already on the map bridge-side" OVERTURNED true of the faceIndex argument, false of the Edge.index handed back — the issue's actual complaint. edgesInFace(at: 3) returned 0, 1, 2, 3 for edges at 2, 6, 10, 11 — all four naming a different edge, 10.00, 12.25, 7.07 and 12.25 mm away
commonEdges(with:) (sibling, unlisted) not named added identical defect 20 lines away: returned 0…7 where the true indices are 5, 8, 10, 11, 8, 10, 11, 5
OCCTPolyMergeNodes (sibling) winding hazard, include it confirmed, and it needs no conversion it emits no index at all, so there was nothing to converge. It is a do-not-convert site; now documented and pinned
7 OCCTBiTgteBlend / OCCTBiTgteBlendInfo_ named by neither added a std::vector filled from an explorer, subscripted by the caller's indices. No index blended the bracket's concave edge at all. Also silently dropped an unresolvable index (a #568 violation)

Sites 1-4 are safe on the map — measured, not argued

The audit reasons from the headers (BRepOffset_Analyse::myMapEdgeType,
LocOpe_SplitShape::myMap/myDblE, and BiTgte_Blend::myEdges for site 7, all
TopTools_ShapeMapHasher-keyed, and that hasher's equality operator is IsSame,
TopTools_ShapeMapHasher.hxx:35-38). That is the reason to check, not the check. A ground-truth C++
program handed every consumer both orientations of every box edge (12 pairs) and vertex
(8 pairs):

A. box EDGE occurrences=24 distinct=12
A. box VERTEX occurrences=48 distinct=8
Q1. RESULT differing Type() answers across orientation: 0  -> ORIENTATION-INSENSITIVE
Q2. RESULT ExtCC compared 12 pairs, differing: 0          -> ORIENTATION-INSENSITIVE
Q3. RESULT BRepCheck EDGE   pairs=12 differing=0          -> ORIENTATION-INSENSITIVE
Q3. RESULT BRepCheck VERTEX pairs=8  differing=0          -> ORIENTATION-INSENSITIVE
Q4. RESULT BRep_Tool::Range          differing: 0         -> ORIENTATION-INSENSITIVE
Q4. RESULT LocOpe_SplitShape descendants differing: 0     -> ORIENTATION-INSENSITIVE

A plain box has no WIRE or SHELL occurring twice, so six further fixtures were built to reach them —
compound{solid, solid.Reversed()} and the same for a shell, a face, a wire, an open (invalid,
non-closed)
wire, and a fused two-body solid:

fixture WIRE pairs SHELL pairs BRepCheck differing
compound{solid, solid.Reversed()} 6 1 0
compound{shell, shell.Reversed()} 6 1 0
compound{face, face.Reversed()} 1 0 0
compound{wire, wire.Reversed()} 1 0 0
compound{openWire, …Reversed()} 1 0 0
compound{fused, fused.Reversed()} 10 1 0
total 26 4 0

Note this does not make the conversion a no-op for them: their index domain moves, and should —
on compound{solid, solid.Reversed()} the WIRE enumeration goes 12 occurrences → 6 distinct, which
SEMVER.md records. What is unchanged is the answer for a wire or shell both enumerations name.

The winding evidence (site 5 and the OCCTPolyMergeNodes sibling)

Meshing is the one site where the two enumerations pull opposite ways. Triangle winding is set by
the face's orientation as it occurs in the parent (if REVERSED swap(n2, n3)), and that sets the
sign of every emitted triangle normal. On a BRepAlgoAPI_Splitter cut of a 20×10×10 block at x = 10:

Q5. split compound FACE occurrences=12 distinct=11
Q5. faces present in both orientations: 1
Q5.   mapIndex=5  first=FORWARD second=REVERSED  IsSame=1 IsEqual=0
Q5.   map stores: FORWARD  <- the one a map walk would hand a mesher

Shared wall at x = 10, triangles by winding:

+x −x
oriented walk (shipped) 2 2
deduplicated map walk (the wrong conversion) 2 0

The upper solid's floor simply absent — #614's defect one level down. So neither enumeration alone
is correct, and the fix uses both: winding from the occurrence, index from the map, via a new
occtForEachOrientedFace. FindIndex is the IsSame lookup, so a REVERSED occurrence resolves to
its FORWARD twin's index, and both sides of the shared wall carry the one index (5) that names it —
no second traversal.

OCCTPolyMergeNodes carries the same hazard and is deliberately not converted. It emits no
index; the reversed flag it derives per occurrence goes straight to
Poly_MergeNodesTool::AddTriangulation. Converting it measures +x 2 / −x 0 — the upper solid
loses its wall. It is now commented as a do-not-convert site and pinned by a test.

Inject-the-bug results, per site

Each site was reverted alone (file copied aside and restored by copy — never git checkout --),
rebuilt, and the suite re-run.

Injection Tests that failed Signature
1 concavity → explorer 3 concave.map(\.index) → [] vs [27]; counter 2 vs classifier 0 concave, 42 vs 19 convex, 12 vs 9 tangent; edgeConcavityCount(.convex) → 24 vs 12
2 ExtCC → explorer 2 closest point offBy → 14.142 and → 10.0 mm; surplus indices 12/17/23 answered with real extrema
3 checkSubShape → explorer 2 checkEdge(at:) valid for 12/17/23; checkVertex(at:) valid for 8/20/47
4 splitEdge → explorer 2 split landed 7.071, 14.142, 12.247 mm from the target; 12/17/23 succeeded
5a mesh → deduplicated map 2 plusX > 0 && minusX > 0true && false; plusX → 2 vs minusX → 0
5b mesh → occurrence counter 4 face(at: 11) → nil; triangles 10.0 mm off the plane of the face they name; wall stamped with 2 indices
6 Edge.index → array position 3 dist(a, b) → 10.0 / 12.247 / 7.071 mm for both finders; the "not array positions" test could find no non-trivial index set
sibling OCCTPolyMergeNodes → map 1 minusX → 0; plusX → 2 vs minusX → 0
7 biTgteBlend → explorer vector 2 changed → [] vs [target] → [27] — no index reached the concave edge; surplus and mixed batches accepted

Restored after each, and the full set re-verified green.

Three vacuous assertions were found in this PR — two by my own injections, the third only in
review.
All fixed. The lesson taken: an inject-the-bug pass proves a test can fail, but only if
the injection reaches that specific assertion; a test with several assertions can hide a dead one
behind a live sibling, which is the shape of all three.

The first two:

  1. A vacuous assertion. "every concavity entry carries the index of the edge it describes" asserted
    pair.0.index == n, which is true by construction — Swift builds each pair from edges()[n]
    whatever the bridge returns. It passed with the defect present. Replaced with a falsifiable
    cross-check: the classifier and the per-type counter are two independent bridge computations over
    the same enumeration and must agree (2 vs 0 concave under injection).
  2. Two site-7 tests coupled to site 1. They located the target via concaveEdges(), so they failed
    under injection 1 and could not attribute a failure. They now locate the inner corner
    geometrically.

The third, found in review: the commonEdges array-position check compared against a hardcoded
Set(0..<4) while the finder returns 8 entries, so it could never fire. Now
Set(0..<common.count), proven by injecting site 6 with the sibling cross-check neutralised:
(Set(common.map(\.index)) → [0…7]) != (Set(0..<common.count) → [0…7]).

One test is labelled a control rather than a detector, because the injections showed it does not
fail under either mesh injection: "every mesh triangle is wound outward for the solid that owns it".
Losing a facet removes a triangle rather than inverting one, and the copy that survives on this
fixture is stored FORWARD, outward for its own owner. Said plainly in its doc comment.

Tests

Tests/OCCTTopologyTests/Issue613IndexContractTests.swift (16) and
Tests/OCCTMeshTests/Issue613MeshIndexContractTests.swift (9) — 25 tests. Every fixture is a
shape whose explorer count exceeds its map count, and every identity assertion cross-checks against a
geometric lookup that knows nothing about either indexing scheme, so it cannot pass by agreeing
with itself.

swift build clean; swift test green — 5298 tests in 1396 suites.
count-operations.py 4,304, both documents ✓ (unchanged — no new public entry point).
check-bridge-index.py, check-null-handle-guards.py, check-docs-defaults.py all exit 0.

Docs

  • docs/CHANGELOG.md — Unreleased entry with the measurements (no invented version).
  • docs/SEMVER.mda recorded exception was needed and is added. Eight silent behaviour
    changes, none a compile error, each with what a caller does. Notably: the edge- and vertex-indexed
    entry points move on ordinary solids, not only on shapes that share a sub-shape, because every
    edge of every solid is shared between two faces. The running count in the intro is updated from
    five exceptions to six.
  • docs/reference/Shape-Measurement.md, Shape-HLR-Geom.md, Mesh.md, plus /// snippets on every
    changed API. Shape-HLR-Geom.md documented biTgteBlend's indices as "as returned by the shape's
    topology explorer" — the stale contract, stated outright — now corrected.

Not a complete sweep

Shape.nbEdges / nbVertices / nbFaces return per-occurrence counts (box 24 and 48 against
edgeCount 12 / vertexCount 8; split compound nbFaces 12 against faceCount 11) while their
own docs assert the deduplicated answers — filed separately as #651 and unchanged here.
OCCTShapeFixEdgeSameParameter / FixEdgeVertexTolerance count explorer occurrences behind a
"number of edges fixed" contract; source-visible but unmeasured (a box returns 0 either way), so
recorded as a candidate rather than converted on a pattern match.

Scope notes

Bridge-only plus two Swift wrappers. No kernel patch, no OCCT.xcframework rebuild.
OCCTLocOpeFindEdges and OCCTLocOpeFindEdgesInFace gained an optional outIndices parameter
(OCCTBridge is not an SPM product, so this is not consumer-visible Swift API).

macOS CI is red branch-wide (#585) and is not affected by this change; the gate is base-vs-PR failure
parity, and this PR has zero test failures.

🤖 Generated with Claude Code

…he bracket's concave edge was invisible

stayed on a bare TopExp_Explorer, which yields one entry per occurrence: a plain 10mm box has 24
edge occurrences over 12 edges and 48 vertex occurrences over 8 vertices, so the two disagree on any
ordinary solid.

The end-to-end failure is the composition the API's own docs recommend. edgeConcavities() zips the
bridge's per-occurrence answer against edges(), so on an L-bracket the one concave edge (map index
27) was labelled convex and concaveEdges() returned []. filleted(edges: concaveEdges()) rounded
nothing and reported success. edgeConcavityCount(.convex) answered 24 on a 12-edge box.

Converted: the concavity classifier and its count, OCCTBRepExtremaExtCC, checkSubShape (behind
checkEdge/Wire/Shell/Vertex), OCCTLocOpeSplitShapeByVertex, both mesh entry points, and the
Edge.index handed back by edgesInFace(at:) and commonEdges(with:). Plus OCCTBiTgteBlend and
OCCTBiTgteBlendInfo_, which neither the issue nor its audit named, found by sweeping for the idiom.

Meshing uses both enumerations rather than choosing: triangle winding comes from the occurrence's
orientation and the stamped faceIndex from the map, via a new occtForEachOrientedFace. Meshing off
the map alone would drop a shared wall's second side (measured: +x 2, -x 0). OCCTPolyMergeNodes is
deliberately NOT converted for the same reason, and is now pinned by a test that fails if a later
sweep converts it.

Sites 1-4 were confirmed orientation-insensitive by measurement, not by reading headers: every
consumer was handed both orientations of every box edge (12 pairs) and vertex (8 pairs), 0 differing.

Closes #613
…me from the fixture it described

B1. edgesInFace(at: 3) on a 10mm box: the real indices are 2, 6, 10, 11 and ALL FOUR were wrong, by
10.00, 12.25, 7.07 and 12.25 mm. I had written 2, 8, 10, 11 / 10.00, 16.75, 0.71, 13.80 and "the
fourth matched by coincidence", in six places including SEMVER.md, a user-facing contract.

Root cause, two compounding errors, both from transcribing an exploratory probe instead of measuring
the claim: the offsets came from Edge.point(at: 0.5) -- a raw CURVE PARAMETER, not an arc-length
midpoint -- on the origin-centred box spelling, while the text described midpoints; and the "real
indices" were never measured at all, only inferred, so the 8 was simply wrong (edges()[8] is at
(5,0,0) and is not on face 3). The reviewer's decisive check is right: 16.75 and 13.80 are not
achievable between two midpoints of a 10mm box, which is why they could not reproduce.

B2. "rounded nothing and reported success" is false. Measured: filleted(edges: [], radius: 2)
returns nil -- it reported FAILURE. The defect is still severe but the harm is a misattributed
error, not a silent wrong answer: a nil from a fillet reads as "the fillet failed" and nothing
pointed at the selection. Corrected in CHANGELOG and SEMVER.

B3. A third vacuous assertion, and the class was not exhausted after my own two. The commonEdges
array-position check compared against a hardcoded Set(0..<4) while the finder returns 8 entries, so
it could never fire. Now Set(0..<common.count); proven by injecting site 6 with the sibling
cross-check neutralised.

B4. Removed two indices.max()! force-unwraps inside #expect (CLAUDE.md bans this; Swift Testing does
not short-circuit, so an empty mesh crashed the process instead of failing).

WIRE/SHELL: my "no fixture has one" limitation was a fixture-finding failure, not a real gap. Six
constructions give 26 WIRE and 4 SHELL two-orientation pairs, BRepCheck identical in every one,
invalid geometry included. Also corrected "cannot move their answer at all" -- the domain does move
(12 occurrences to 6 distinct), which SEMVER already recorded.

Completeness claim softened and #651 (nbEdges/nbVertices/nbFaces) cited; the unmeasured
fixEdgeSameParameter/fixEdgeVertexTolerance pair recorded as a candidate, not converted blind.

Nits: test counts are 16+9 not 17+8; removed the dead ShapeType guard from both concavity loops
(it would have desynced the result array if it ever fired, since count does not advance); dropped
Wire.rectangle force-unwraps; corrected splitBoxCompound's doc.

Closes #613
@gsdali

gsdali commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

All four blockers fixed in 571f4b1. Thank you for catching B1 — you were right that those numbers could not have come from the fixture, and the root cause is worse than a typo.

B1 — corrected, and how it happened

Re-measured on both box spellings (identical answers):

claimed measured
real indices of face 3's edges 2, 8, 10, 11 2, 6, 10, 11
offsets from edges()[slot] 10.00, 16.75, 0.71, 13.80 10.00, 12.25, 7.07, 12.25
how many matched "the fourth matched by coincidence" none — all four wrong

Face 3's edges are at (0,10,5), (10,10,5), (5,10,0), (5,10,10), all on the y=10 face; edges()[8] is
at (5,0,0) and is not on face 3 at all, exactly as you said.

Root cause — two compounding errors, both from transcribing an exploratory probe instead of
measuring the claim I was writing.
The offsets came from Edge.point(at: 0.5), which is a raw
curve parameter, not an arc-length midpoint, evaluated on the origin-centred box spelling —
while the prose described midpoints. That reproduces my numbers exactly: on the centred box the
raw-parameter points are (-5,5,-4.5), (5,5,-4.5), (-4.5,5,-5), (-4.5,5,5) against
(-5,-5,-4.5), (-5,-4.5,5), (-5,5,-4.5), (-5,-4.5,-5), giving 10.00, 16.75, 0.71, 13.80.
The 0.71 is what led me to write "matched by coincidence" — it is small but nonzero, and I narrated
it as a match. And the "real indices" were never measured at all, only inferred, which is where
the 8 came from.

So your impossibility check is the right one: those distances are between two arbitrary sample points
on different edges, not between midpoints, and no amount of re-deriving from the stated fixture could
have produced them. Corrected in all six places, including SEMVER.md.

B2 — corrected

Measured on base: filleted(edges: [], radius: 2) returns nil. It reported failure. I have
restated it in CHANGELOG.md and SEMVER.md as what it is — a misattributed error rather than a
silent wrong answer: a nil from a fillet reads as "the fillet failed", so nothing pointed at the
edge selection as the cause. On the fix the same one-liner returns 28034.3 mm³ against the
bracket's 28000.0. Sorry for propagating that upstream.

B3 — fixed, and the class was indeed not exhausted

Set(0..<4) against a finder that returns 8 entries. Fixed to Set(0..<common.count) and proven
by your method — site 6 injected, sibling cross-check neutralised:

✘ Expectation failed: (Set(common.map(\.index)) → [0…7]) != (Set(0..<common.count) → [0…7])

That is three vacuous assertions across this PR (two caught by my own injections, this one not).
Taking the lesson: an inject-the-bug pass proves a test can fail, but only if the injection reaches
that specific assertion — a test with several assertions can have one dead one hiding behind a live
sibling, which is exactly the shape of all three.

B4 — fixed

Both indices.max()! replaced with a #required binding, with a comment naming why (Swift Testing
does not short-circuit).

WIRE/SHELL — you were right, and it is stronger than you stated

My "no fixture has one" was a fixture-finding failure. I built your construction plus five more and
measured independently:

fixture WIRE pairs SHELL pairs BRepCheck differing
plain box (my original) 0 0
compound{solid, solid.Reversed()} 6 1 0
compound{shell, shell.Reversed()} 6 1 0
compound{face, face.Reversed()} 1 0 0
compound{wire, wire.Reversed()} 1 0 0
compound{openWire, …Reversed()} (invalid, non-closed) 1 0 0
compound{fused, fused.Reversed()} 10 1 0
total 26 4 0

Invalid geometry included. And your correction stands: "cannot move their answer at all" was
false
in the domain sense — on compound{solid, solid.Reversed()} the WIRE enumeration goes 12
occurrences → 6 distinct. The comment now says precisely what is unchanged: the answer for a wire
or shell that both enumerations name; the domain does move, and should.

#651 and completeness

Completeness claim softened in both CHANGELOG.md and SEMVER.md, citing #651 for
nbEdges/nbVertices/nbFaces (reproduced: box 24/48 vs 12/8, split compound nbFaces
12 vs faceCount 11) and noting it is unchanged here, so no caller of it is affected by this
entry. OCCTShapeFixEdgeSameParameter/FixEdgeVertexTolerance are recorded as a candidate
rather than converted — source-visible but unmeasured (a box returns 0 either way), and converting on
a pattern match without a reproduction is the thing that produced B1.

Nits

  • Test counts corrected to 16 + 9 (verified by running each file: 16 and 9).
  • The dead if (sub.ShapeType() != TopAbs_EDGE) continue; removed from both concavity loops.
    Good catch — occtMapSubShapes filters by type so it can never fire, but if it ever did it would
    advance i without advancing count and desynchronise the result array from edges(), which is
    the exact defect this PR fixes. Replaced with a comment saying so.
  • Wire.rectangle(...)! force-unwraps removed from both fixtures.
  • splitBoxCompound()'s doc now says what it actually does: every caller #requires it, so a kernel
    that stopped sharing the wall fails these tests rather than skipping silently — which is what we
    want, since the shared wall is the fixture.

Verification

swift build clean; swift test green — 5298 tests in 1396 suites, unchanged.
count-operations.py 4,304, both documents ✓.
check-bridge-index.py, check-null-handle-guards.py, check-docs-defaults.py all exit 0.
Base unchanged since the last push, so no rebase was needed.

@gsdali
gsdali merged commit 71c6030 into refactor/381-pass1b Aug 2, 2026
2 of 3 checks passed
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