fix(#910): guard ThruSectionsBuilder.generatedFace(from:) on IsDone() - #912
Conversation
OCCTThruSectionsGeneratedFace read BRepOffsetAPI_ThruSections::GeneratedFace() unconditionally. GeneratedFace() is a bare lookup into myEdgeFace, which Build() never clears between calls, so a builder reused after a failed rebuild kept answering from the prior successful build's (or the failed build's own partial) bindings. OCCTThruSectionsShape already guards the equivalent Shape() read on IsDone(); generatedFace(from:) gets the same guard. New test generatedFaceNilAfterFailedRebuild() (StressBuilderLifecycleTests.swift): builds a two-circle loft successfully, reuses the same builder with an open third section (BRepFill_CompatibleWires rejects mixing open/closed sections, so the rebuild fails for real, not just the sectionCount < 2 guard), and confirms generatedFace(from:) on an edge from the first build now returns nil. Reverted the fix and watched the test fail with the exact stale-data assertion before restoring it. Closes #910 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
secondmouseAU-bot
left a comment
There was a problem hiding this comment.
xhigh-effort review of 268e94f. The --comment flag was silently dropped by the review tooling again (a known issue), so these are being posted directly. Every OCCT-behaviour claim below was re-verified against the actual OCCT V8_0_0 source before posting — file/line citations are to src/ModelingAlgorithms/TKOffset/BRepOffsetAPI/BRepOffsetAPI_ThruSections.{hxx,cxx} at that tag.
The diagnosis in the PR body is right: GeneratedFace() is a bare myEdgeFace lookup and Build() genuinely never clears it (confirmed — myEdgeFace is a plain NCollection_DataMap member, and no path in Build() touches it before CreateRuled()/CreateSmoothed() re-Bind() into it). But IsDone() is the wrong gate, and the guard as written still lets the #910 defect through.
1. IsDone() does not mean "this build succeeded" — the guard is incomplete (Sources/OCCTBridge/src/OCCTBridge_Modeling.mm:8770)
BRepOffsetAPI_ThruSections::Build() opens with two punctual-section validation loops, and both return without calling NotDone():
// BRepOffsetAPI_ThruSections.cxx:341
myStatus = BRepFill_ThruSectionErrorStatus_Done;
...
for (i = 2; i <= myWires.Length() - 1; i++) { // :346
...
if (wdeg) {
myStatus = BRepFill_ThruSectionErrorStatus_WrongUsage; // :356
return; // :357 <-- no NotDone()
}
}
if (myWires.Length() <= 2) { // :360
...
if (wdeg) {
myStatus = BRepFill_ThruSectionErrorStatus_WrongUsage; // :373
return; // :374 <-- no NotDone()
}
}The terminal if (myStatus != Done) { NotDone(); return; } at :531-535 is never reached on those two paths. So on a reused builder whose earlier build succeeded (Done() at :598/:604/:1053 set myDone = true), myDone stays true through a failed rebuild.
This is reachable straight through the public Swift API, because AddVertex appends a wire made of a single degenerated edge — exactly what the first loop scans for:
// BRepOffsetAPI_ThruSections.cxx:311-328
void BRepOffsetAPI_ThruSections::AddVertex(const TopoDS_Vertex& aVertex) {
...
BB.Degenerated(DegEdge, true);
...
myWires.Append(DegWire);and ThruSectionsBuilder.addVertex(_:) is public. Concrete sequence:
loft.addWire(c1); loft.addWire(c2)
#expect(loft.build()) // true, myEdgeFace populated
loft.addVertex(v); loft.addWire(c3) // myWires = [c1, c2, degWire, c3]
#expect(!loft.build()) // FAILS — build() returns true
loft.generatedFace(from: edge) // still build #1's faceThe first loop hits degWire at i == 3, sets WrongUsage, returns with myDone untouched. OCCTThruSectionsBuild returns IsDone() == true for a build that never ran, the new guard passes, and generatedFace(from:) hands back the previous build's face — plus shape hands back the previous build's solid, since it is fooled identically. That is #910, on the same accessor, still open.
Fix: OCCT 8.0 exposes the real outcome — BRepFill_ThruSectionErrorStatus GetStatus() const { return myStatus; } (.hxx, public, inline). Gate on IsDone() && GetStatus() == BRepFill_ThruSectionErrorStatus_Done.
2. Altitude: record the outcome once on the wrapper instead of probing OCCT per accessor (OCCTBridge_Modeling.mm:8711)
struct OCCTThruSections already carries bridge-side state (sectionCount), and there are now three ways OCCTThruSectionsBuild can report failure while leaving OCCT's myDone untouched: the sectionCount < 2 early return (:8715, never calls NotDone()), the catch (...) (:8719), and OCCT's own WrongUsage returns above. Every accessor that probes IsDone() independently gets all three wrong.
One field covers all of them:
ts->built = false;
ts->builder->Build();
ts->built = ts->builder->IsDone()
&& ts->builder->GetStatus() == BRepFill_ThruSectionErrorStatus_Done;then gate both OCCTThruSectionsShape (:8726) and OCCTThruSectionsGeneratedFace on ts->built. Wires(), FirstShape(), LastShape() and Generated() are all still unwrapped; as written, the next one wrapped will copy the same incomplete pattern.
3. The regression test passes silently if its setup yields nil (Tests/OCCTStressTests/StressBuilderLifecycleTests.swift:454)
guard let openWire = Wire.polygon3D([...], closed: false),
let openShape = Shape.fromWire(openWire) else { return }sits immediately before the only two assertions that test #910. Wire.polygon3D returns nil on points.count < 2 and on any bridge failure; Shape.fromWire is likewise fallible. If either ever starts returning nil — an OCCT bump, a polygon3D regression, a change to open-wire handling — the test returns early and reports green while asserting nothing about the guard it exists to protect. Same hazard at :447 and :441.
This undercuts okf/policies/prove-the-test-fails.md ("Adding a self-test is not the rule, watching it fail is"): the failure was proved today, but the structure lets it stop proving anything silently. try #require(...) on the setup turns a nil into a failure rather than a skip.
4. Only the smoothed path is covered; the ruled path is the more exposed one (:442)
The test builds with isRuled: false, so only CreateSmoothed() runs. OCCT documents two different contracts for the same accessor ("if Ruled Returns the Face generated by each edge except the last wire; if smoothed Returns the Face generated by each edge of the first wire"), and the two implementations bind myEdgeFace from different sources. Since Build() does myWires = WorkingSections; (.cxx:466) on every successful compatibility pass, the ruled path — whose keys come from myWires — is the one most exposed to cross-build aliasing, and it gets zero coverage. An isRuled: true variant is ~5 lines.
5. The positive assertion passes incidentally and is fragile across OCCT bumps (:448)
generatedFace(from: edge) != nil uses an edge from the caller's own input wire, but myEdgeFace is keyed by post-compatibility edges: Build() reassigns myWires = WorkingSections (.cxx:466) and CreateSmoothed() binds from those working sections, while the originals are kept separately in myInputWires (.hxx). The test asks for s1.subShapes(ofType: .edge).first and resolves only because two same-topology closed circles need no re-splitting, so the TShape survives unchanged. The test also enables checkCompatibility(true) — the very flag gating that rewrite. If a future OCCT re-creates that edge, :448 fails for a reason unrelated to #910. Derive the edge from loft.shape, or assert the mapping via a path that does not depend on CompatibleWires leaving input edges untouched.
6. Public behaviour changed; neither doc surface moved (docs/reference/Document-Completions.md:905)
The reference entry still reads "Returns: The generated face shape, or nil if not found", and Sources/OCCTSwift/ThruSectionsBuilder.swift's comment is still the bare "/// Get the face generated from an edge after building." (confirmed at this PR's head). Neither mentions the new "nil if the last build did not succeed" contract — which the PR body itself calls out as the only observable change. This is the CLAUDE.md Documentation Standards rule ("when wrapping or changing a public API, give it a /// summary + parameter docs + at least one fenced snippet", and "update the relevant doc in the same commit"). Cost: a caller reading either doc still believes nil means "edge not found".
7. The parity the PR claims is only half-asserted (:459)
The bridge comment and PR body both justify the change as "matching the existing guard on ThruSectionsBuilder.shape", but the test pins only the new half. One extra line — #expect(loft.shape == nil) — locks in the sibling invariant on the same fixture, and would have surfaced finding 1, where both accessors go stale together.
8. The new test walks toward the uncharacterised SIGSEGV reported in the same PR body (:458)
The "Notes for the reviewer" report a SIGSEGV from checkCompatibility(false) + mismatched section edge counts on a reused builder, attributed to CreateSmoothed()'s per-edge lockstep walk, and defer filing it. This test is the same shape (reused builder, mismatched section topology) with compatibility checking left on. The only thing keeping it on the safe side is if (myWCheck) (.cxx:380) routing into BRepFill_CompatibleWires' NotSameTopology rejection — which does call NotDone() (.cxx:461-463), so the test's own failure path is sound — before CreateSmoothed() ever sees the mismatch. CLAUDE.md is explicit that "OS signals raised inside OCCT are still uncatchable in-process", and ThruSections has prior form here (#176/#178). If a kernel bump shifts which branch handles this input, the failure lands in OCCTStressTests as a whole-process abort with no test name, indistinguishable from the known #344/#345 parallel-run crashes. Worth characterising the deferred defect before landing a test that walks toward it.
9. loft.checkCompatibility(true) is a no-op presented as load-bearing (:443)
Both the constructor and Init() already set myWCheck = true (.cxx:268, :288), so the call changes nothing. The neighbouring comment reads as though this line is what routes the build into the NotSameTopology path, so a future reader will treat a dead line as essential. Either drop it, or keep it and say in the comment that it restates OCCT's default rather than changing it.
Bottom line: the direction is right and the stale-myEdgeFace analysis holds up, but IsDone() is the wrong gate — a reused builder can still report build() == true and serve both stale shape and stale generatedFace(from:). The robust form is a bridge-side outcome flag built on GetStatus(), gating both accessors (findings 1 + 2), with try #require on the test setup (finding 3).
Finding 1 (critical, verified empirically before fixing): IsDone() is not a reliable "did the last build succeed" signal on a reused builder. BRepOffsetAPI_ThruSections::Build()'s two punctual-section validation loops (a wholly-degenerate MIDDLE section, reached via the public addVertex()) return WrongUsage without ever calling OCCT's NotDone() -- so on a builder that already built successfully once, IsDone() stays stale-true through a failed rebuild. The original #910 fix's IsDone() guard does not catch this: build() incorrectly returns true, and both shape and generatedFace(from:) still serve the prior build's stale data. Finding 2: fix by recording the real outcome once, in OCCTThruSectionsBuild (the one place that calls Build()), via a new `built` field on the wrapper struct -- gated on IsDone() && GetStatus() == BRepFill_ThruSectionErrorStatus_Done, matching OCCT's own documented GetStatus() accessor. OCCTThruSectionsShape and OCCTThruSectionsGeneratedFace both gate on this bridge-side flag instead of re-deriving from OCCT state per call, closing the gap for all three ways OCCTThruSectionsBuild could previously under/over-report (sectionCount < 2, catch(...), and this WrongUsage path). Findings 3/7/9: existing test rewritten to `throws` + `try #require(...)` so a setup failure fails loudly instead of silently skipping; added the sibling `loft.shape == nil` assertion; dropped the no-op checkCompatibility(true) call (already OCCT's own default). Finding 4: added a ruled-path variant (isRuled: true) -- CreateRuled() binds myEdgeFace via a different mechanism (BRepFill_Generator) than CreateSmoothed(), and had no coverage. Finding 1 also gets its own dedicated regression test (generatedFaceNilAfterWrongUsageOnReusedBuilder), a distinct failure mechanism from the existing open/closed-mismatch test. Reverted the bridge fix to the pre-review IsDone()-only guard and confirmed this new test fails with exactly the three symptoms the review predicted (build() == true, shape != nil, generatedFace != nil); restored the fix and confirmed all 8 tests in the suite pass, plus the full OCCTModelingTests (645) and OCCTStressTests (363) targets. Finding 5: noted, not changed. The positive assertion's edge identity depends on BRepFill_CompatibleWires leaving already-matching-topology input edges untouched -- true today, not a guarantee the API makes. Documented the dependency inline rather than removing it: the alternative (deriving the edge from loft.shape) tests different, untested key-derivation semantics of GeneratedFace() and isn't obviously more correct, and the negative assertion this test exists to protect does not depend on this identity holding across OCCT versions because it deals in nil either way. Finding 6: updated ThruSectionsBuilder.swift's `///` doc comments for both generatedFace(from:) and shape, and docs/reference/Document-Completions.md, to state the new "nil if the last build did not succeed" contract. Finding 8: the deferred SIGSEGV this PR's own body flagged (checkCompatibility(false) + mismatched section edge counts on a reused builder) has since been root-caused, fixed, and filed separately -- OCCTSwift#913, OCCT#1466, OCCTSwift PR #915 -- de-risking the concern this finding raised about walking toward an uncharacterized crash. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed a response to the review's findings (commit 1ac2129):
|
…atedFace Sources/OCCTSwift/ThruSectionsBuilder.swift's doc comment was updated in the review-response commit; the underlying C header's own comment (a separate doc surface, not auto-derived from the Swift one) still said the pre-#910 "or nil if not found" text. Caught by grepping for the old wording after the Swift-level fix, not by any gate script -- none of the docs gates compare prose between the two layers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Finding 4 (test coverage gap, real): all three new tests' successful
build used exactly 2 sections, and Build() dispatches ANY 2-section
call to CreateRuled() regardless of isRuled (myWires.Length() == 2 ||
myIsRuled) -- so CreateSmoothed()'s own myEdgeFace binding was never
exercised by any test in this PR, including the two whose comments
claimed to cover the "smoothed path". Redesigned
generatedFaceNilAfterFailedRebuild and
generatedFaceNilAfterWrongUsageOnReusedBuilder to use 3 sections for
the successful build, correctly forcing CreateSmoothed(); reverted the
built-flag fix and re-confirmed generatedFaceNilAfterWrongUsageOnReusedBuilder
still fails with the redesigned fixture before restoring it.
generatedFaceNilAfterFailedRebuildRuledPath's comment corrected: 2
sections reach CreateRuled() because of the section count, not because
isRuled was set to true.
Finding 5: generatedFaceNilAfterFailedRebuild's comment overclaimed
coverage of finding 1's specific WrongUsage/stale-IsDone() mechanism;
its own failure trigger (open/closed mismatch) was already handled
correctly pre-review. Corrected the comment to say so explicitly.
Finding 6 (real, pre-existing, not a regression): OCCTThruSectionsAddWire/
AddVertex never reset ts->built, so a section added after a successful
build without a following build() call left shape/generatedFace(from:)
silently answering with the prior build's stale geometry. Fixed: both
now reset built = false. No existing test relied on the old behavior
(checked first).
Finding 7: the new doc comment on generatedFace(from:) was
self-contradictory on a literal read ("...never invalidates... on its
own" reads as the bug still existing). Reworded to state plainly that
this accessor checks the outcome explicitly because OCCT's own state
doesn't reset itself.
Finding 3: documented, not changed. Added the missing rationale for
fixing this bridge-side rather than via a kernel patch (unlike
#905/#913, nothing here is memory-unsafe to leave as-is; the bridge's
"did the last build() on this Swift instance succeed" contract needs
bridge-owned state regardless of how precise OCCT's own GetStatus()
bookkeeping is) directly on the `built` field, and noted
OCCTSectionBuilder already carries an identical field, predating this
PR -- this is an established pattern here, not a one-off workaround.
Also: brought Sources/OCCTSwift/ThruSectionsBuilder.swift into full
swift-format/SwiftLint compliance (one import-order fix) and removed it
from Scripts/style-manifest-swift.txt, clearing that half of the
code-style CI failure the review found. The remaining half
(Sources/OCCTBridge/include/OCCTBridge_Modeling.h,
Sources/OCCTBridge/src/OCCTBridge_Modeling.mm) is NOT addressed here --
measured at 3,392 and 24,133 diff lines respectively for a full
clang-format pass, an order of magnitude beyond this PR's actual
change and squarely the "disproportionate for the PR at hand" case
okf/policies/code-style.md's own #877 precedent describes. code-style
is not a required status check (gate-scripts is the repo's only one),
so this does not block merging; left for a human call on whether to
defer via a tracked follow-up (matching #877) or absorb here.
Verified: full StressThruSectionsBuilderLifecycleTests (8/8),
OCCTModelingTests (645/645), OCCTStressTests (363/363),
check-null-handle-guards, check-bridge-index, check-docs-defaults,
check-docs-existence, derive-bridge-header-split, count-operations all
clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Ran my own review pass before the formal one, including dispatching
Verified: full |
secondmouseAU-bot
left a comment
There was a problem hiding this comment.
xhigh-effort review of 7b0abee (second round, after commits 1ac2129 + 7b0abee). Every OCCT claim below was re-verified against Open-Cascade-SAS/OCCT at V8_0_1 — the tag this repo is currently pinned to — not the V8_0_0 the first round cited; line numbers have moved. I also read Scripts/patches/README.md's 0026 entry, since patch 0026's MakeSolid() throw interacts with the new gate.
The core fix holds up. I independently confirmed the two load-bearing claims:
Build()setsmyStatus = BRepFill_ThruSectionErrorStatus_DoneatBRepOffsetAPI_ThruSections.cxx:341, on entry, so there is no stale-status false-negative —IsDone() && GetStatus() == Donecannot wrongly reject a genuinely successful build.- Both punctual-section loops (
:346-359,:360-375)returnwithmyStatus = WrongUsageand noNotDone(), soGetStatus()is genuinely the pieceIsDone()was missing. - And the AND-form is correct against patch
0026specifically: a capping failure throws throughBuild()'s catch →NotDone()whileGetStatus()staysDone(asScripts/patches/README.mddocuments deliberately), soIsDone()carries that case andGetStatus()carries theWrongUsagecase. Neither alone is sufficient; the conjunction is.
Nothing from round 1 is re-raised. Findings 1-9 are addressed or consciously deferred with reasons I accept. The 12 comments below are new, and they cluster into three groups:
Correctness — the fix is one layer short (1, 2). built gates the read, but myEdgeFace is still never cleared, so the moment a later build succeeds the flag flips back to true and generatedFace(from:) can hand back an earlier build's — or a failed build's partial — face. Build A ✅ → build B ❌ → build C ✅ is enough to reproduce, and none of the three new tests reaches a second successful build. Separately, six configuration setters on the same struct don't invalidate built while the two input mutators now do, so setContinuity(3) after a successful build leaves .shape serving the C2 solid while addWire one line away returns nil.
Contract drift introduced by this round's own fix (3, 4, 7). 7b0abee's built = false in AddWire/AddVertex is a good change, but it makes all three doc surfaces literally false ("nil if the most recent build() did not succeed" — the most recent build() did succeed), it isn't in the CHANGELOG entry or the SemVer paragraph even though it's the one change here that can break working code, it has no test (delete both lines and all 8 tests still pass), and the sibling accessor's header doc wasn't updated alongside the one that was.
Process/CI (5, 6, 10). code-style is failing on this head — check-style-manifest.py rule 2, because both touched OCCTBridge_Modeling.* files are still on style-manifest-bridge.txt. Deferring is defensible; deferring in a PR comment rather than in the tree isn't, and the checklist's "every static gate script clean" isn't accurate as written. The new struct comment also cites OCCTSectionBuilder as an existing identical precedent when it's actually the same bug unfixed (your own #916) — inverted, that comment is useful; as written it will get copied. And the isRuled: true variant added for round 1's finding 4 was removed again in 7b0abee, leaving CreateRuled()-via-myIsRuled untested under a test named RuledPath.
Also noted, lower stakes: no fenced snippet on either changed public member (9, CLAUDE.md's context7 rule), an unsynchronised bool reached by every accessor on an @unchecked Sendable type (11), two silent-discard paths for criterium weights that this PR's new "GetStatus() is the authority" framing can't see (8), and ~25 comment lines restating three facts four times (12).
Bottom line: the built + GetStatus() mechanism is the right answer to #910 and I'd land it, but (1) means #910 is still reachable through the same accessor on a three-build sequence, and (3)/(4) mean this round's own behaviour change ships undocumented and unproven. I'd fix 1-4 before merge and take 5-12 as follow-ups.
| // (or the failed build's own partial) bindings. Gate on the same `built` flag | ||
| // OCCTThruSectionsShape uses (see the struct's comment for why IsDone() alone isn't | ||
| // enough) so a caller can't read post-build state past a build that didn't happen. | ||
| if (!ts->built) return nullptr; |
There was a problem hiding this comment.
1. built only gates the read immediately after a failed build — myEdgeFace is still never cleared, so a later successful build re-opens #910.
The comment three lines up states the real defect precisely: Build() never clears myEdgeFace, so it accumulates "the prior successful build's (or the failed build's own partial) bindings". The fix then guards only the case where built == false. But built goes back to true on the next successful build, and the contaminated map is still there.
Concrete sequence, all through the public Swift API:
let loft = ThruSectionsBuilder(isSolid: true, isRuled: false)
loft.addWire(a); loft.addWire(b); loft.addWire(c)
#expect(loft.build()) // build A: myEdgeFace ← A's bindings
loft.addVertex(v); loft.addWire(d) // punctual middle section
#expect(!loft.build()) // build B: WrongUsage — built == false ✅ guard works
// caller removes the bad section by constructing nothing new — just rebuilds after
// a valid mutation, or (equivalently) any third build that succeeds:
#expect(loft.build()) // build C succeeds → built == true
loft.generatedFace(from: edgeFromA) // ← returns build A's face, guard passesCreateSmoothed()/CreateRuled() only ever Bind() into myEdgeFace (BRepOffsetAPI_ThruSections.cxx:664, :676, :1012 at V8_0_1); nothing calls Clear(), and Build() (:339) doesn't either. So after C, the map holds A's bindings, B's partial bindings (CreateSmoothed() binds per-edge as it goes, then bails at :816 with myStatus = Failed), and C's — all indistinguishable to GeneratedFace(), which is a bare IsBound/lookup (:1638-1647).
The same thing happens without any failed build at all whenever a rebuild re-splits sections: Build() does myWires = WorkingSections (:466), so build A's working edges become build B's input, and if B's new section forces BRepFill_CompatibleWires to re-split them, A's edges keep their A-era face bindings while B binds new TShapes.
This directly contradicts the new doc contract added in this PR (ThruSectionsBuilder.swift:83): "Returns nil if edge isn't a profile edge the build used" — for a stale edge it returns a face, not nil.
The bridge-side flag is the right shape; it just needs to also invalidate the map, not only the read. E.g. record a monotonically increasing buildGeneration and keep the bridge's own edge → (generation, face) map populated from GeneratedFace() results, or (cheaper, and closer to what OCCT's own contract implies) re-derive the answer only from the current build by checking the returned face is actually a sub-shape of builder->Shape() before handing it back. Either way, please add a test for "build A succeeds → build B fails → build C succeeds → generatedFace(from: edgeFromA)", since nothing in the three new tests reaches a second successful build.
| // #910 review (PR #912) finding 6: a section added after a successful build belongs to a | ||
| // build that hasn't happened yet. Shape()/GeneratedFace() must not keep answering from | ||
| // the PRIOR build until the caller actually calls Build() again. | ||
| ts->built = false; |
There was a problem hiding this comment.
2. Only the two input mutators invalidate built; the six configuration mutators on the same struct don't — same staleness, no signal.
The rationale you wrote here — "a section added after a successful build belongs to a build that hasn't happened yet" — applies verbatim to every setter that changes what the next Build() would produce, and none of them resets the flag:
| function | line | changes the next build? | resets built? |
|---|---|---|---|
OCCTThruSectionsAddWire |
8690 | yes | ✅ (this PR) |
OCCTThruSectionsAddVertex |
8703 | yes | ✅ (this PR) |
OCCTThruSectionsSetSmoothing |
8713 | yes | ❌ |
OCCTThruSectionsSetMaxDegree |
8719 | yes | ❌ |
OCCTThruSectionsSetContinuity |
8725 | yes | ❌ |
OCCTThruSectionsCheckCompatibility |
8762 | yes | ❌ |
OCCTThruSectionsSetParType |
8768 | yes | ❌ |
OCCTThruSectionsSetCriteriumWeight |
8782 | yes | ❌ |
Concrete failure:
loft.addWire(a); loft.addWire(b); loft.addWire(c)
#expect(loft.build()) // C2 loft
loft.setContinuity(3) // asks for C3
loft.shape // ← still the C2 solid, no nil, no errorvs. the sibling mutator one line away:
loft.addWire(d)
loft.shape // ← nil, because addWire resets `built`Two mutators on the same object, the same "your cached result no longer matches the configuration" situation, opposite answers. Whichever contract you pick is defensible; picking different ones per mutator is what will confuse callers (and is what the next person wrapping Wires()/FirstShape()/Generated() will have to guess at). Suggest resetting built in all eight, behind a one-line static inline void invalidate(OCCTThruSections* ts) { ts->built = false; } so the set can't drift again.
| } | ||
|
|
||
| /// Get the result shape. | ||
| /// Get the result shape, or `nil` if the most recent ``build()`` call did not succeed. |
There was a problem hiding this comment.
3. The documented contract is now false on all three doc surfaces, because of this PR's own addWire/addVertex change.
This line promises nil only when "the most recent build() call did not succeed". Commit 7b0abee added a second nil condition that has nothing to do with a failed build:
loft.addWire(a); loft.addWire(b)
#expect(loft.build()) // succeeds
loft.addWire(c) // OCCTThruSectionsAddWire sets built = false
loft.shape // ← nil, even though the most recent build() DID succeedSame wrong claim in two more places added by this PR:
ThruSectionsBuilder.swift:83-84— "Returnsnilifedgeisn't a profile edge the build used, or if the most recentbuild()call on this instance did not succeed." Neither disjunct covers the add-then-read case.docs/reference/Document-Completions.md:903-904— "or if the builder's most recentbuild()call did not succeed (including on a reused builder, after a later failed rebuild)". The parenthetical actively narrows it to the failed-rebuild case.
It's also an observable behaviour change the PR body doesn't declare. The CHANGELOG entry describes only failed-rebuild staleness, and the SemVer section says "the only observable difference is a previously-buggy return value becoming correct" — but a caller who has never had a failed build, and who reads .shape after appending a section, goes from getting the last build's solid to getting nil. That deserves its own sentence in the CHANGELOG entry, since it's the one change here that can break working code.
Suggested wording for all three: "nil if no successful build() has happened for the builder's current set of sections".
| loft.addWire(openShape) | ||
| #expect(!loft.build()) | ||
| #expect(loft.shape == nil) | ||
| #expect(loft.generatedFace(from: edge) == nil) |
There was a problem hiding this comment.
4. The built = false-on-add behaviour added in 7b0abee has zero test coverage.
Three new tests, all the same shape: mutate → build() → assert. Every one of them calls build() between the mutation and the assertions, so built is false because the rebuild failed, not because addWire/addVertex reset it. Delete ts->built = false; from OCCTThruSectionsAddWire (OCCTBridge_Modeling.mm:8699) and OCCTThruSectionsAddVertex (:8709) and all 8 tests in this suite still pass.
That's the repo's own checklist item — "New or changed behavior is covered by a unit test in the same PR (not just manual verification)" — and CLAUDE.md's Prove the test fails policy ("Every new test, and every new --self-test case, is run once with its subject broken … Adding a self-test is not the rule, watching it fail is", okf/policies/prove-the-test-fails.md). The PR body's revert-and-confirm log covers finding 1's GetStatus() gate and the original IsDone() guard, but not this.
Four lines closes it:
@Test func accessorsInvalidatedByAddingASectionWithoutRebuilding() throws {
let w1 = try #require(Wire.circle(origin: SIMD3(0, 0, 0), normal: SIMD3(0, 0, 1), radius: 5))
let w2 = try #require(Wire.circle(origin: SIMD3(0, 0, 10), normal: SIMD3(0, 0, 1), radius: 3))
let w3 = try #require(Wire.circle(origin: SIMD3(0, 0, 20), normal: SIMD3(0, 0, 1), radius: 2))
let s1 = try #require(Shape.fromWire(w1))
let s2 = try #require(Shape.fromWire(w2))
let s3 = try #require(Shape.fromWire(w3))
let loft = ThruSectionsBuilder(isSolid: true, isRuled: false)
loft.addWire(s1)
loft.addWire(s2)
#expect(loft.build())
let edge = try #require(s1.subShapes(ofType: .edge).first)
#expect(loft.shape != nil)
loft.addWire(s3) // no build() — the prior result is now stale input
#expect(loft.shape == nil)
#expect(loft.generatedFace(from: edge) == nil)
}| // answer "what did the last Build() call decide", and this bridge's own contract is "did the | ||
| // last build() call on THIS Swift-visible instance succeed", which needs bridge-owned state | ||
| // regardless of how precise OCCT's own bookkeeping is. OCCTSectionBuilder (below in this same | ||
| // file) already carries an identical `built` field for the same reason, predating this PR. |
There was a problem hiding this comment.
5. This sentence cites a known-broken sibling as precedent — OCCTSectionBuilder's built is not "for the same reason", it's the same bug unfixed.
OCCTSectionBuilder(below in this same file) already carries an identicalbuiltfield for the same reason, predating this PR.
OCCTSectionBuilder (:9877) has a field spelled the same way, but it is only ever written true, at :9959:
OCCTShapeRef OCCTSectionBuilderBuild(OCCTSectionBuilderRef builder) {
...
if (!builder->section.IsDone()) return nullptr;
builder->built = true; // never set back to false, anywhere in the fileNothing resets it — not a failed rebuild, not Init1/Init2 (:9901-:9937), which are exactly the "input changed after a successful build" mutators this PR just taught OCCTThruSections to handle. AncestorFaceOn1/On2 (:9964, :9975) then gate on a permanently-true flag. That's precisely the #910 defect, which is why you filed it as #916 in the same session as this commit.
So the comment is claiming an established, working pattern where the only other instance is a live bug — and the whole point of putting the rationale in a struct comment is that the next person wrapping a builder copies it. Please either drop the sentence or invert it, e.g. "OCCTSectionBuilder (:9877) has the same field but never resets it — see #916; do not use it as the model."
Altitude note while you're here: this is now the second wrapper in this file that needs "did the last build() on this instance succeed", and both hand-roll it. The generalizable form is a tiny shared mixin (struct BuildOutcome { bool built = false; void invalidate(); }) that every builder wrapper embeds, so the invariant is stated once and #916 becomes a one-line adoption rather than a second bespoke fix.
| // Build() actually decided, including the two WrongUsage returns that leave IsDone() in | ||
| // whatever state a PRIOR successful build left it. | ||
| ts->built = ts->builder->IsDone() | ||
| && ts->builder->GetStatus() == BRepFill_ThruSectionErrorStatus_Done; |
There was a problem hiding this comment.
8. GetStatus() is not the outcome oracle this comment implies — Build() resets myStatus on entry, so a whole class of Failed statuses is unobservable by construction.
The gate itself is sound (I re-checked V8_0_1: Build() sets myStatus = BRepFill_ThruSectionErrorStatus_Done at BRepOffsetAPI_ThruSections.cxx:341, so there is no stale-status false-negative, and the two WrongUsage early returns at :356/:373 are caught exactly as described — nice catch). Two adjacent consequences of that reset are worth recording, because the comment reads as "GetStatus() reports what Build() decided" full stop:
(a) SetCriteriumWeight sets myStatus = Failed and this bridge can never see it.
// BRepOffsetAPI_ThruSections.cxx:1665-1676
void BRepOffsetAPI_ThruSections::SetCriteriumWeight(const double W1, const double W2, const double W3)
{
if (W1 < 0 || W2 < 0 || W3 < 0)
{
myStatus = BRepFill_ThruSectionErrorStatus_Failed;
return; // <-- weights NOT applied
}
myCritWeights[0] = W1; ...OCCTThruSectionsSetCriteriumWeight (:8782) returns void and swallows nothing (no throw is raised), so from Swift:
loft.setCriteriumWeight(w1: -1, w2: 1, w3: 1) // silently ignored
#expect(loft.build()) // true — Build() reset myStatus to Done firstthe caller gets a loft approximated with the previous weights, build() returns true, and the new GetStatus() check cannot report it because Build() cleared the flag before it ran. If GetStatus() is now the bridge's notion of truth, this is the one caller-visible place it silently isn't.
(b) OCCTThruSectionsSetContinuity (:8728) clobbers whatever weights the caller set:
ts->builder->SetCriteriumWeight(1.0, 1.0, 1.0); // ensure defaults
ts->builder->SetContinuity(occtGeomAbsFromParametricContinuity(continuity));so loft.setCriteriumWeight(w1: 2, w2: 1, w3: 3); loft.setContinuity(2) throws the weights away with no diagnostic. Both are pre-existing and neither is in this diff — flagging because this PR makes GetStatus() the bridge's authority on ThruSections outcomes, and these are the two places that authority is silently empty. A follow-up issue is a fine home for them.
| } | ||
|
|
||
| /// Get the face generated from an edge after building. | ||
| /// Get the face generated from a profile edge after the loft is built. |
There was a problem hiding this comment.
9. Public API changed; the /// block still has no fenced snippet (CLAUDE.md Documentation Standards).
The prose half of last round's finding 6 is fixed — thanks. The rule it cites has a second half that isn't:
Document with a runnable Swift snippet so context7 indexes it. … So when wrapping or changing a public API, give it a
///summary + parameter docs + at least one fenced```swiftsnippet … Snippets are what context7 harvests — terse one-line summaries don't surface in answers.
—CLAUDE.md, Documentation Standards → Rules
Both members whose contract this PR changes ship prose only: generatedFace(from:) here (also missing - Parameter edge: / - Returns:, which docs/reference/Document-Completions.md:902-904 does carry) and shape at :57. The new contract is precisely the kind that a snippet communicates better than a paragraph, because the interesting part is a sequence:
/// - Parameter edge: A profile edge from one of the input wires.
/// - Returns: The generated face, or `nil` if `edge` isn't a profile edge of the current
/// build, or if no successful ``build()`` has happened for the builder's current sections.
///
/// ```swift
/// let loft = ThruSectionsBuilder(isSolid: true)
/// loft.addWire(bottom)
/// loft.addWire(top)
/// guard loft.build(), let edge = bottom.subShapes(ofType: .edge).first else { return }
/// let face = loft.generatedFace(from: edge) // the side face swept from `edge`
///
/// loft.addWire(mismatchedSection)
/// _ = loft.build() // false
/// loft.generatedFace(from: edge) // nil — not the previous build's face
/// ```| let w2 = try #require(Wire.circle(origin: SIMD3(0, 0, 10), normal: SIMD3(0, 0, 1), radius: 3)) | ||
| let s1 = try #require(Shape.fromWire(w1)) | ||
| let s2 = try #require(Shape.fromWire(w2)) | ||
| let loft = ThruSectionsBuilder(isSolid: true, isRuled: false) |
There was a problem hiding this comment.
10. The isRuled: true coverage added for last round's finding 4 was removed again in 7b0abee — this test is named RuledPath but never sets isRuled.
Commit 1ac2129's reply says "Fixed (finding 4): added a ruled-path variant (isRuled: true)". 7b0abee replaced it with isRuled: false + 2 sections. The comment above justifies that correctly — myWires.Length() == 2 || myIsRuled (BRepOffsetAPI_ThruSections.cxx:504) does route 2 sections to CreateRuled() — but the two dispatch arms are not equivalent inside CreateRuled():
nbSects == 2: the history loop runs once, andmyEdgeFacegets exactly the first wire's edges.myIsRuledwithnbSects >= 3: the loop runsnbSects - 1times (for (i = 1; i <= nbSects - 1; i++)), binding edges from every wire but the last — which is the behaviour OCCT's own doc forGeneratedFacedescribes ("if Ruled Returns the Face generated by each edge except the last wire"), and the arm where cross-build rebinding of the same edge actually happens.
So after this change no test reaches CreateRuled() via myIsRuled at all, and the parameter a user who wants a ruled loft actually passes is untested. Either restore the isRuled: true variant alongside this one, or rename this test (...TwoSectionPath) and update the comment so the gap is visible rather than looking covered.
Worth noting for the same reason: the #expect(loft.generatedFace(from: edge) != nil) positive assertion at :534 inherits the fragility called out in last round's finding 5, which you documented inline in the first test (:462-464) but not here or in generatedFaceNilAfterWrongUsageOnReusedBuilder (:505). Same dependency on BRepFill_CompatibleWires leaving matching-topology input edges untouched, same one-line caveat needed.
| import simd | ||
|
|
||
| /// Builder for lofted shapes through multiple wire sections. | ||
| public final class ThruSectionsBuilder: @unchecked Sendable { |
There was a problem hiding this comment.
11. @unchecked Sendable + a new unsynchronised bool read on every accessor.
built is a plain non-atomic bool written by OCCTThruSectionsBuild/AddWire/AddVertex and now read by OCCTThruSectionsShape and OCCTThruSectionsGeneratedFace. ThruSectionsBuilder declares @unchecked Sendable, so the compiler permits:
let loft = ThruSectionsBuilder(isSolid: true)
// ... sections added ...
Task { _ = loft.build() }
Task { _ = loft.shape } // torn read of `built`; TSan data raceBefore this PR the accessors read OCCT's myDone instead, which is equally unsynchronised, so this isn't a regression — but the PR is explicitly introducing bridge-owned state as the correctness mechanism, which is the moment to say what its concurrency contract is. docs/thread-safety.md and OCCTSerial already exist for exactly this. Minimum: a /// note on the class that instances are not safe to use from multiple threads without external serialisation; better: route the flag through the same serialisation the rest of the bridge uses, so @unchecked is actually earned.
| struct OCCTThruSections { | ||
| BRepOffsetAPI_ThruSections* builder; | ||
| int sectionCount = 0; | ||
| // #910 review (PR #912): OCCT's own IsDone() is not a reliable "did the last Build() succeed" |
There was a problem hiding this comment.
12. Simplification: the same three facts are re-explained in four places, 25 comment lines for 11 lines of code.
"IsDone() isn't a reliable last-build signal", "Build()'s WrongUsage paths skip NotDone()", and "Build() never clears myEdgeFace" are each stated more than once:
:8658-8672— the struct comment (16 lines), states all three.:8696-8698—AddWire(3 lines), the invalidation rationale.:8739-8741—Build(3 lines), re-states "IsDone() alone is not enough" + theWrongUsagereturns, then says "see the struct's own comment".:8792-8796—GeneratedFace(5 lines), re-states themyEdgeFacemechanism and points at both the struct comment andOCCTThruSectionsShape.
The last two both cross-reference the first, which is the right instinct — they just also duplicate what they're referencing. Collapsing them to one line each (// gate on ts->built — see the struct's comment) keeps every pointer intact and drops ~15 lines. Concrete cost of leaving it: four copies of a claim about OCCT internals that will drift apart at the next kernel pin (three of them already cite V8_0_0 line-number reasoning that V8_0_1 has moved), and the file is already at the top of comment-ratio-check.py's report.
Worth keeping in full: the "bridge-side, not a kernel patch" paragraph (:8666-8672) — that's the decision record, it isn't duplicated anywhere, and it's the part a future reader can't reconstruct. (Minus its last sentence, see the separate comment on :8672.)
Round 2 review findings 1, 2, 3, 6, 7, 10, 12 fixed here; 4, 5 addressed in the PR body update; 8 filed as a follow-up (#919, out of scope for this fix); 9 addressed with an in-source snippet; 11 is the existing, already-documented project-wide thread-safety model (OCCTSerial.withLock, not per-field locking — every sibling builder class has the same unsynchronized-flag shape), not a regression introduced here. Finding 1 (the blocking one): `built` alone isn't enough. OCCT's `myEdgeFace` is never cleared, so a THIRD build succeeding after an intervening failure (build ok -> add a mismatched section, build fails -> CheckCompatibility(true) reconciles it, build ok again) can rebuild every section's edges, not just the new one's, stranding an earlier edge's binding in the map without ever overwriting it. Measured empirically before this fix: `generatedFace(from:)` answered non-nil with a face that was provably not part of the successful rebuild's own `shape`. `OCCTThruSectionsGeneratedFace` now confirms the face it finds is actually a member of the current `Shape()` (TopExp_Explorer) before returning it, instead of trusting the map. Finding 2: the six setters (setSmoothing, setMaxDegree, setContinuity, checkCompatibility, setParType, setCriteriumWeight) didn't invalidate `built` the way addWire/addVertex do, so e.g. setContinuity(_:) right after a successful build left `.shape` serving the pre-change geometry. All eight mutators now invalidate consistently. Finding 3: doc comments on `.shape`/`generatedFace(from:)` (Swift and bridge header) rewritten to describe the actual invalidate-on-any- mutation contract instead of the now-inaccurate "nil if the most recent build() call did not succeed". Finding 6: the struct comment's claim that OCCTSectionBuilder carries a "precedent" `built` field was misleading — it has the identical unfixed bug (#916), not a working precedent. Comment corrected and trimmed. Finding 7: no test proved the AddWire/AddVertex built=false reset (or the new setter resets) mattered — verified directly by deleting them and confirming all existing tests still passed. Added shapeNilAfterSettingChangedWithoutRebuild and confirmed it fails without the fix (both call sites), then restored. Finding 10: generatedFaceNilAfterFailedRebuildRuledPath used isRuled: false and relied on the 2-section CreateRuled() shortcut, so the explicit myIsRuled == true dispatch had no coverage under a test named "RuledPath". Now uses isRuled: true with 3 sections. New test generatedFaceIsMemberOfShapeAfterSuccessFailureSuccessOnReused Builder covers finding 1's scenario directly; confirmed it fails without the membership check, then restored. All 5 required static gates clean. Full OCCTModelingTests (645/645) and OCCTStressTests (365/365, +2 new) pass. See the PR's review-response comment for the finding-by-finding disposition.
|
Pushed round 2 fixes (
All 5 required static gates clean, full |
|
swift build + test (macOS) investigation, resolved. This job failed 3/3 reruns on
Net: a real, CI-runner-specific signal (not general infra flakiness — the control rules that out; not a deterministic bug — 7 clean local runs rule that out), not reproducible locally, root cause not established. Filed as #920 with the full evidence rather than dismissed or left silent. |
…at got fixed The prior commit worked from the review's summary body, which compressed detail the 12 individual inline comments carried. Cross-checking each inline comment against the actual diff surfaced six real gaps: - docs/reference/Document-Completions.md and Shape-Completions.md: two more copies of the now-inaccurate "nil if the most recent build() call did not succeed" contract (finding 3 named a third location; a fourth turned up in Shape-Completions.md's `shape` entry). Both rewritten to match the actual invalidate-on-any-mutation contract. - Scripts/style-manifest-bridge.txt: finding 6 asked for a `# deferred, see #NNN` annotation next to the two entries this PR leaves on the manifest, not just a PR-comment mention. Added, self-test still 6/6. - Sources/OCCTSwift/ThruSectionsBuilder.swift: finding 6 also predicted `swift-format lint --strict` would fail once the file left the Swift manifest, independent of the bridge-manifest question. Confirmed directly (BeginDocumentationCommentWithOneLineSummary on `shape`'s doc comment) and fixed. Finding 9's ask for a `- Parameter`/`- Returns` block and fenced snippet specifically on `generatedFace(from:)` (the class-level snippet added last commit doesn't cover it) — added. Finding 11's minimum ask (a `///` note that instances aren't safe for concurrent use without OCCTSerial.withLock) — added. - Sources/OCCTBridge/src/OCCTBridge_Modeling.mm: finding 12 explicitly asked to keep the "bridge-side, not a kernel patch" decision-record paragraph in full while trimming the rest — the prior commit's trim dropped it entirely. Restored. - Tests/OCCTStressTests/StressBuilderLifecycleTests.swift: finding 10's second half — the fragility caveat on `generatedFace(from: edge) != nil` (this holds because same-topology closed circles need no BRepFill_CompatibleWires re-splitting, not as a general guarantee) — was on the original test but missing from two others this PR added or rewrote. Added to both. Also: finding 8's "SetContinuity always resets criterium weights to 1,1,1, silently discarding a caller's prior setCriteriumWeight" is a second silent-discard I missed when filing #919 for finding 8's first half — added as a follow-up comment on that issue rather than refiling. Finding 5's shared-mixin suggestion (a `BuildOutcome` struct so `OCCTThruSections`/`OCCTSectionBuilder` don't each hand-roll this) is consciously still not done — real refactor, touches #916's in-flight fix too, out of proportion for a review-response commit. All 5 required static gates clean (including check-style-manifest.py against the actual merge-base, not the since-moved origin/main tip). Full OCCTModelingTests (645/645) and OCCTStressTests (365/365) pass.
|
Went back through this round's 12 inline comments individually rather than relying on the summary body I'd worked from — the inline comments carried more precise detail, and cross-checking each one against the actual diff (
Also caught, while re-reading finding 8 closely: a second silent-discard I'd missed when filing #919 ( Finding 5's shared-mixin suggestion is still consciously not done (real refactor, touches #916's in-flight fix, out of proportion here) — flagging that explicitly rather than letting it look overlooked. All 5 required gates clean (re-verified |
…generatedface-guard
Per okf/policies/changelog-on-merge.md: transcribed verbatim from the PR body as the last commit before merging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…createsmoothed-section-edge-count-guard Conflict in Tests/OCCTStressTests/StressBuilderLifecycleTests.swift: both #915 (this branch) and #912 (just-merged, via origin/main) added independent new tests right after ThruSectionsBuilder's doubleBuild() test. Resolved by keeping both sets of additions, no substantive overlap.
code-style fix: merged origin/main (brings in PR #912's already-merged "# deferred, see #917" manifest annotation for OCCTBridge_Modeling.mm); updated that annotation to note PR #918 hit the identical wall days later — every PR touching this file does, not just #912's. Resolved a real conflict in Scripts/style-manifest-swift.txt (this branch removed SectionBuilder.swift, an unrelated already-merged PR removed Section2D.swift — kept both removals). Self-review, independently verified rather than trusting the PR body's claims at face value: - Re-ran the repro's before/after comparison myself (not just read the transcripts): compiled the unmodified bridge, reverted the full fix via `git apply -R`, and reproduced the claimed SIGSEGV directly (exit 139, matching before.txt). A first attempt at this reverted only OCCTSectionBuilderBuild and got a false negative — the driver's own Init1Shape call, still carrying the OTHER half of the fix, independently reset `built` before the crash trigger could fire. Redid it as a full reverse-patch to get an authentic revert. - Re-ran ancestorFaceNilAfterReinitWithoutRebuild's "prove the test fails" claim by injecting the same defect (removing Init1Shape's reset) myself: failed with both assertions, matching the PR body. - Verified the Shape.box(origin:...) "corner not center" fixture-bug claim directly against the doc comments. - The one question PR #912's own history raised but this PR's body didn't address: does OCCTSectionBuilder have #912's finding-1 class of gap (a `built` flag insufficient because internal state accumulates across builds, needing a membership check too)? Checked the actual OCCT source: myDSFiller is delete+new'd fresh on every Build() (BRepAlgoAPI_BuilderAlgo::IntersectShapes), architecturally the OPPOSITE of ThruSections' additive-only myEdgeFace map — a simple built flag is genuinely sufficient here, confirmed empirically via a throwaway probe (setApproximation/computePCurveOn1/2 not resetting `built` is correct, not a gap: SectionBuilder has no cached .shape accessor to go stale, unlike ThruSectionsBuilder — build() always re-executes fresh). - Found and fixed one real gap: no fenced ```swift snippet on the changed public API (CLAUDE.md's Documentation Standards, the same finding-9 class from PR #912's own round 2) — added to ancestorFaceOn1(edge:)/ancestorFaceOn2(edge:), plus - Parameter/ - Returns structure to match. All 5 required static gates clean (code-style still red for the tracked, deferred #917 reason only — same as #912/#915). Full `swift test` (5611/5611) passes. SectionBuilder-specific suites (11/11) re-verified after every change.
check-style-manifest.py correctly flagged the GCE2d_MakeSegment -> GC_MakeSegment2d rename this PR made in OCCTBridge_Modeling.mm: that file is grandfathered on Scripts/style-manifest-bridge.txt, so touching even one line mechanically requires bringing the whole ~11,000-line file into clang-format compliance (~24,000 diff lines) in the same PR -- the exact situation #917 already tracks (deferred there from PR #912 for the same reason). Reverted the rename (file is now byte-identical to main). Corrected refman_census.py's GCE2d_MakeSegment note and the matching docs/occtswift-wrapping-gaps.md prose, both of which had claimed the rename was made -- now describe what's actually in the diff (one GCE2d_MakeSegment call remains, tracked on #917). Total verdict counts are unaffected (GCE2d_MakeSegment is curated as 'deliberate, recorded' either way). All 6 static gates clean, swift build clean, diff against main for OCCTBridge_Modeling.mm confirmed empty. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pays down the deferred code-style debt that PR #912 and this PR (#918) both hit and deferred, tracked as #917 — "every PR touching this file hits this wall." Rather than defer a third time, applied the file's own OCCT house style (Sources/OCCTBridge/.clang-format: 2-space indent, Allman braces, 100-col wrap, one-parameter-per-line for long signatures) for real and removed both files from Scripts/style-manifest-bridge.txt. Pure `clang-format -i -style=file`, applied once to each file, no manual edits. Mechanically proven safe before applying, not assumed: - A token-equivalence script stripped all comments and whitespace from both the original and clang-format's dry-run output and confirmed the CODE TOKEN STREAM is byte-identical for both files (256,949 and 67,392 chars respectively) — proof that this is a pure layout transformation with zero token added, removed, or reordered. - A second script confirmed COMMENT TEXT content (`//`, `///`, `/* */`) is word-for-word identical between original and reformatted, only re-wrapped across a different number of physical lines where prose exceeded the new column width (54,397 and 83,983 comment characters respectively, whitespace-stripped, byte-identical). Independently self-reviewed beyond the mechanical proof: dispatched 7 parallel agents (6 covering ~1,800-2,900-line chunks of the .mm file at its own MARK-comment section boundaries, 1 covering the whole .h file), each given both the original and reformatted text for their chunk with no shared context, instructed to do a human-style read for things a token-diff can't catch — comment misattachment, awkward wrapping, macro/lambda/preprocessor edge cases, logical structure drift. All 7 reported clean; findings were exclusively cosmetic (alignment quirks, a couple of long-signature wraps flagged as mildly verbose but not confusing) — see PR discussion for the full per-chunk reports. One agent caught and correctly flagged a pre-existing doc/param-name mismatch in the header as NOT introduced by this PR (verified against the original). Verified this doesn't regress any bridge-structure-parsing gate script (all five, plus count-operations, are line/brace-shape sensitive by construction, not immune to a reformat that changes how guards read textually): - check-null-handle-guards.py: confirmed the detector isn't just reporting clean because it went blind on the new two-line Allman `if (!x)\n return Y;` guard shape (previously one-line) — injected a real defect (stripped an IsNull() check from a genuine two-line post-reformat guard site) and confirmed it was caught, then restored. - derive-bridge-header-split.py --verify: mapped count unchanged (4021) before/after. - check-docs-defaults.py / check-docs-existence.py: all counts unchanged (1472 defaults compared, 0 drift; 6411 symbol refs, 0 stale) before/after. - count-operations.py: unchanged (4339). - check-style-manifest.py: clean against origin/main (both files removed from the manifest, no other file touched). - file-size-check.py scope is Sources/OCCTSwift/*.swift only and is report-only regardless — the .mm file's raw line count growing from 11,045 to 16,804 (Allman braces + wrap) doesn't trip anything, but noting it here since it's a large number to see in a diff stat with no code-content explanation otherwise. Full `swift test`: 5611/5611, unchanged from before the reformat. All 5 required static gates clean.
…compliance Done ahead of the Pass 4a sweep rather than inside a fix PR. This file backs 15 of the lane's 32 bridge calls, so the sweep will find things in it, and the style manifest is a one-way ratchet: whoever touches it first has to carry a 2,208-line reformat on top of their actual change. That is the shape that got #917 deferred out of PR #912, and deferring it again would stall the lane. Both files of the pair, since the .h was on the manifest too and a finding in one reaches the other. OCCTBridge_ProjLib_NLPlate.h 310 LOC, 345 diff lines OCCTBridge_ProjLib_NLPlate.mm 1255 LOC, 2208 diff lines Behaviour-neutral, verified rather than assumed: with comments and whitespace stripped, both files are byte-identical to their previous versions (5,748 and 30,337 code characters). The .mm is whitespace-only even with comments left in. swift build clean, swift test --filter "Plate|ProjLib" 69 tests in 24 suites passing, all eight gate scripts green. Removing a file from the manifest without actually reformatting it just moves the failure to code-style.yml's clang-format --dry-run --Werror, which is how main sat red for five merges (#942), so the removal and the reformat are the same commit. Refs #385
What & why
ThruSectionsBuilder.generatedFace(from:)(OCCTThruSectionsGeneratedFace) read OCCT'sGeneratedFace()without checking whether the last build actually succeeded.GeneratedFace()isa bare lookup into
myEdgeFace, whichBuild()never clears between calls, so a builder reusedafter a failed rebuild kept answering from a prior successful build's (or the failed build's own
partial) bindings — silently, no null, no error.
Revised after review round 1 (see the PR's own comment thread): the first commit guarded on
ts->builder->IsDone(), matchingOCCTThruSectionsShape's existing pattern — and that guard isincomplete.
BRepOffsetAPI_ThruSections::Build()has two punctual-section validation loops (awholly-degenerate MIDDLE section, reachable via the public
addVertex()at an interior position)that return
WrongUsagewithout ever calling OCCT'sNotDone(). On a builder that alreadybuilt successfully once,
IsDone()stays stale-true through that failed rebuild. The shipped fixis a bridge-side
builtfield on theOCCTThruSectionswrapper, set exactly once inOCCTThruSectionsBuildviaIsDone() && GetStatus() == BRepFill_ThruSectionErrorStatus_Done.Revised again after review round 2:
builtalone is still not enough. OCCT'smyEdgeFacemapis never cleared, only ever added to — so a THIRD build succeeding after an intervening failure
(build ok → add a mismatched section, build fails →
checkCompatibility(true)reconciles it,build ok again) can rebuild every section's edges, not just the newly-added one's, stranding an
earlier build's edge → face binding in the map without ever overwriting it. Measured empirically
before this round's fix:
generatedFace(from:)answered non-nil with a face that was provably notpart of the successful third build's own
shape.OCCTThruSectionsGeneratedFacenow confirms theface it finds is actually a member of the current
Shape()(TopExp_Explorer) before returningit, instead of trusting the map. Round 2 also found and fixed a related inconsistency: the six
builder-configuration setters (
setSmoothing,setMaxDegree,setContinuity,checkCompatibility,setParType,setCriteriumWeight) didn't invalidatebuiltthe wayaddWire/addVertexalready did, so changing a setting right after a successful build used toleave
.shapesilently serving pre-change geometry. All eight mutators now invalidateconsistently, and the doc comments on
.shape/generatedFace(from:)were rewritten to describethat actual contract instead of the narrower "nil if the last build() call failed" one they no
longer matched.
Closes #910
CHANGELOG entry
ThruSectionsBuilderno longer returns stale results after a failed rebuild, or after a build on a changed builder that hasn't been rebuilt (#910)generatedFace(from:)andshapeboth read post-build OCCT state without reliably checkingwhether the last
build()call on that instance actually succeeded, and without accounting forOCCT's own internal state never resetting between builds or setting changes on a reused builder.
Fixed in two rounds:
ThruSectionsBuilderreused across multiplebuild()calls couldsilently keep returning a prior successful build's geometry — through
generatedFace(from:),shape, andbuild()'s own return value — after a laterbuild()call on the same instancegenuinely failed, including failures that OCCT's own
IsDone()does not reliably report on areused builder (a punctual middle section added via
addVertex()).then failed, then succeeded again could still have
generatedFace(from:)answer with a face leftover from the first success rather than the current one — OCCT's internal edge→face map is
additive-only.
generatedFace(from:)now confirms its answer is actually part of the currentshapebefore returning it.setSmoothing,setMaxDegree,setContinuity,checkCompatibility,setParType, andsetCriteriumWeightnow invalidate a prior successfulbuild the same way
addWire/addVertexalready did — previously, changing a setting after asuccessful build left
.shape/generatedFace(from:)silently serving geometry built under theold setting until the caller happened to add a new section too.
Fixed by tracking the real build outcome bridge-side rather than trusting
IsDone()alone, and byverifying
generatedFace(from:)'s answer against the current build's own shape rather thantrusting OCCT's internal map.
SemVer impact
PATCH. A caller of
ThruSectionsBuilder.build(),.shape, or.generatedFace(from:)who reuses abuilder across
build()calls now correctly getsfalse/nilafter any failed rebuild, or afterchanging a setting without rebuilding, instead of stale or wrong geometry from an earlier build. No
signature change; the only observable difference is a previously-buggy return value becoming
correct — including for callers who call
setSmoothing/setMaxDegree/setContinuity/checkCompatibility/setParType/setCriteriumWeightbetween a successfulbuild()and reading.shape/generatedFace(from:)without an intervening rebuild: that combination previouslyreturned the pre-change build's geometry and now correctly returns
niluntilbuild()is calledagain. No migration.
Checklist
verification): five tests in
Tests/OCCTStressTests/StressBuilderLifecycleTests.swift—generatedFaceNilAfterFailedRebuild(the original open/closed-mismatch scenario),generatedFaceNilAfterWrongUsageOnReusedBuilder(theIsDone()-defeating scenario round 1'sreview found),
generatedFaceNilAfterFailedRebuildRuledPath(coverage forCreateRuled()'sdifferent
myEdgeFace-binding mechanism, now genuinely reached viaisRuled: true+ 3sections rather than the 2-section shortcut round 2's review caught it silently relying on),
generatedFaceIsMemberOfShapeAfterSuccessFailureSuccessOnReusedBuilder(round 2's finding 1:success → failure → success no longer returns a stale binding), and
shapeNilAfterSettingChangedWithoutRebuild(round 2's finding 2: all six setters invalidatea prior build, not just
addWire/addVertex).--self-testcase was run once with its subject broken, and thefailure is reported here:
- Round 1, first commit: reverted the
IsDone()guard and re-ran the original test — itfailed with
Expectation failed: (loft.generatedFace(from: edge) → OCCTSwift.Shape) == nil.- Round 1, second commit: reverted the bridge back to the
IsDone()-only guard and re-rangeneratedFaceNilAfterWrongUsageOnReusedBuilder— it failed with exactly the threesymptoms the review predicted (
build() == true,shape != nil,generatedFace != nil).- Round 2: removed the
TopExp_Explorermembership check and re-rangeneratedFaceIsMemberOfShapeAfterSuccessFailureSuccessOnReusedBuilder— failed withExpectation failed: isMember. Removed all six setters'built = falseresets and re-ranshapeNilAfterSettingChangedWithoutRebuild— failed at both assertions (setContinuity andcheckCompatibility). Also independently verified — by deleting the
addWire/addVertexresets and running the full pre-round-2
ThruSectionsBuilder-touching test set (130 testsacross 6 files) — that no existing test caught that reset's removal, confirming review
round 2's finding 7 before adding coverage for it.
- Restored the real fix after each injection; final state: all 5 required static gates clean,
full
OCCTModelingTests(645/645) andOCCTStressTests(365/365, +2 over round 1's 363)pass.
docs/CHANGELOG.mdis not in this diff.docs/SEMVER.mdis not in this diff.code-style:check-style-manifest.pyfails onthis head because both touched
OCCTBridge_Modeling.*files are still onScripts/style-manifest-bridge.txt.code-styleis not a required check (onlygate-scriptsis); bringing a 24k/3.4k-line pair of files into clang-format compliance isout of scope for this fix and is tracked as a deferred sub-issue of the format-check work,
#917 — round 2's review flagged that
the previous version of this checklist implied full cleanliness inaccurately; this line now
says what's actually true.
Notes for the reviewer
Found during PR #909's review (the #905 fix). Filed separately per this project's scope-boundary
policy rather than folded into #909.
The deferred SIGSEGV originally noted here (
checkCompatibility(false)+ mismatched section edgecounts on a reused builder) has since been root-caused, patched, and shipped separately:
#913,
OCCT#1466,
#915 (verified against a full local kernel
rebuild, 5533/5533 tests).
Round 2 review disposition (12 findings, all independently re-verified against the pinned
V8_0_1OCCT source before acting on any of them, per this project's own practice — severalfindings were confirmed by reading
BRepOffsetAPI_ThruSections.cxxdirectly rather than trustingthe review's claim, and one candidate fix (finding 8's original framing) turned out to need its own
separate issue rather than a quick patch once the actual mechanism was traced):
generatedFace(from:)membership check against the currentshape.built.behavior change.
code-style's real, deferredstatus instead of implying it's clean.
OCCTSectionBuilderas a working precedent — ithas the identical unfixed bug, tracked at OCCTSectionBuilder's built flag never resets to false on a failed rebuild (same class as #910) #916.
shapeNilAfterSettingChangedWithoutRebuild; independently confirmed viainjection that no prior test caught either the setter gap or the original
addWire/addVertexreset's removal.
#919 — traced to
BRepOffsetAPI_ThruSections::SetCriteriumWeightitself silently no-oping on a negative weight(verified directly in the OCCT source,
myStatus = FailedbutmyCritWeightsleft unchanged),compounded by
Build()unconditionally resettingmyStatus = Doneon entry so the failure isunobservable even via
GetStatus()by the time a caller checks. Unrelated togeneratedFace(from:)staleness; needs its own bridge-signature change (
setCriteriumWeightwould need to returnBool) rather than fitting this PR's shape.swiftsnippet toThruSectionsBuilder's type doc, since noneof its doc comments had one before.
generatedFaceNilAfterFailedRebuildRuledPathnow usesisRuled: truewith 3sections instead of silently relying on the 2-section
CreateRuled()dispatch shortcut.ThruSectionsBuilder's unsynchronizedbuiltfield on an@unchecked Sendabletype matches every sibling builder class in this codebase (none guard theirinternal mutable state with a per-instance lock) — this project's documented thread-safety model
is caller-side serialization via
OCCTSerial.withLock, not per-field locking(
docs/thread-safety.md), and the class-wide question is already tracked project-wide by Bridge-level thread-handling contract: per-call safety classification, scoped/controllable internal parallelism, encapsulated global state — safety AND throughput #342.Adding a mutex to just this one field would be inconsistent with every other builder and
wouldn't actually make concurrent same-instance use safe (the underlying OCCT builder pointer
itself isn't reentrant either).
builtcomment and theAddWire/GeneratedFacecommentsthat had grown to restate the same three facts multiple times across this PR's two rounds.