Skip to content

Modeling Algorithms - Guard silently-uncapped solid in BRepOffsetAPI_ThruSections - #1462

Open
gsdali wants to merge 1 commit into
Open-Cascade-SAS:masterfrom
gsdali:fix/thrusections-silent-solid-capping
Open

Modeling Algorithms - Guard silently-uncapped solid in BRepOffsetAPI_ThruSections#1462
gsdali wants to merge 1 commit into
Open-Cascade-SAS:masterfrom
gsdali:fix/thrusections-silent-solid-capping

Conversation

@gsdali

@gsdali gsdali commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Pre-Submission Checks

  • I checked existing issues, pull requests, discussions, and forum topics for related work.
  • I followed the contribution guidance in .github/CONTRIBUTING.md.
  • I used a PR title in the Group - Summary format.

Problem / Motivation

BRepOffsetAPI_ThruSections(isSolid: true) silently omits both end-cap faces when a closed
section wire has two or more periods of out-of-plane variation around the loop, a genuinely
non-planar closed curve. Build() still calls Done() and reports IsDone() == true, and
Shape() returns a TopoDS_Solid that is missing two faces and is not actually closed.
BRepCheck_Analyzer catches this later, but with no localized error (errorCount == 0, no
detailed check statuses), so a caller has already trusted IsDone() by the time anything flags
it.

Root cause: MakeSolid() (the static helper both CreateRuled() and CreateSmoothed() call to
close a loft's two open ends) caps each end via PerformPlan(), which tries
BRepBuilderAPI_FindPlane and then a BRepLib_FindSurface-backed MakeFace(wire) fallback.
Neither finds anything but a plane or a surface already attached to the wire's edges. A section
wire with k >= 2 periods of out-of-plane variation around the loop has no such surface, so
PerformPlan() fails for it. MakeSolid() already tracks this in its own local B, threaded
through both PerformPlan() calls — and discards it: it force-marks the shell and solid
Closed(true) unconditionally before returning, and the caller then calls Done()
unconditionally too.

A single period of out-of-plane variation (e.g. z = amp * cos(theta) at constant radius) caps
fine, because that curve is secretly planar: it is exactly the intersection of the cylinder
r = const with a tilted plane, so BRepBuilderAPI_FindPlane finds it. k >= 2 has no such
plane and no other analytic surface either.

Found downstream of a bevel gear ThruSections loft in the OCCTSwift ecosystem, where each
section is a toothed profile whose out-of-plane period equals the tooth count, always >= 2 for
a real gear.

Proposed Solution

MakeSolid()'s local B already distinguishes exactly the two cases that matter: it stays
true when capping either succeeded or wasn't needed (PerformPlan()'s own degenerate-wire
shortcut — every edge of the wire is degenerate, a single-vertex "point" section built via
AddVertex(), e.g. a cone's apex, which needs no cap face and correctly reports success with a
null face), and ends false only when capping was genuinely attempted and failed. After the
capping block, if B is still false, throw StdFail_NotDone, matching the exception this same
function already throws for a null shell two lines above:

if (!B)
{
  throw StdFail_NotDone("BRepOffsetAPI_ThruSections: could not close a non-planar extremity");
}

No signature change and no call-site change: both call sites already run inside Build()'s only
try/catch (catch (Standard_Failure const&) { NotDone(); return; }), so the throw propagates
there and IsDone() correctly reads false.

This does not make BRepOffsetAPI_ThruSections able to cap a genuinely non-planar wire (that
would need a real capping algorithm, e.g. an N-sided patch, out of scope here). It converts a
silent wrong success into a correctly reported failure, matching every other failure path in this
file.

A null-face check (myFirst.IsNull() || myLast.IsNull()) looks like an equally natural fix and
is not: PerformPlan() also leaves the output face null, and returns true (no failure), when
the wire is degenerate, so a null-face check cannot tell "no cap needed" apart from "cap needed
and failed." Checked directly against BOPAlgo_PaveFillerTest.FuseConeLoftWithBox_DegeneratedEdge
(a circle-to-vertex loft, in this same PR's GTest additions) before settling on the B-based fix
— a null-face guard regresses that test, B does not.

Limitation: GetStatus() is not updated by this fix and stays
BRepFill_ThruSectionErrorStatus_Done on this path. Build()'s catch block doesn't touch
myStatus for any exception source, not one this fix introduces — IsDone() is the correct,
fixed signal.

Validation

Checks performed:

  • clang-format --dry-run --Werror against this tree's own .clang-format: zero violations
    on both changed files.
  • Added GTest cases in BRepOffsetAPI_ThruSections_Test.cxx:
    NonPlanarClosedWireCappingFails (a two-wire k=2 loft, must now fail) and
    DegenerateVertexEndStillSucceeds (a circle-to-vertex loft, must still succeed — the same
    shape as BOPAlgo_PaveFillerTest.FuseConeLoftWithBox_DegeneratedEdge, added as a direct,
    local regression guard for it in this file too).
  • CI on this branch (macOS/Linux/Windows builds, all Run GTest/Test/Regression Test
    jobs including the No-PCH debug build) is green.
  • Locally, before pushing: override-link tested (compile the changed .cxx standalone, link
    it ahead of a prebuilt libOCCT-macos.a, no full OCCT rebuild) both an unpatched and a
    patched build. Against the unpatched build, NonPlanarClosedWireCappingFails fails exactly
    as described above (IsDone() reads true). Against the patched build, it passes, along
    with DegenerateVertexEndStillSucceeds and all pre-existing tests in the file. Also
    compiled and ran the actual BOPAlgo_PaveFiller_Test.cxx
    FuseConeLoftWithBox_DegeneratedEdge test, unmodified, against the patched build: passes.

🤖 Generated with Claude Code

CreateRuled() and CreateSmoothed() both call the static MakeSolid() helper to
close a loft's two open ends, then call Done() unconditionally regardless of
whether MakeSolid() actually built the cap faces.

MakeSolid() caps each end via PerformPlan(), which finds a plane
(BRepBuilderAPI_FindPlane, then a BRepLib_FindSurface-backed MakeFace(wire)
fallback that likewise only matches a plane or a surface already attached to
the wire's edges, never a cylinder, cone, or general surface). A closed
section wire with two or more periods of out-of-plane variation around the
loop is genuinely non-planar and has no such surface, so PerformPlan() fails.
MakeSolid() already tracks this in its own local `B`, threaded through both
PerformPlan() calls, and discards it: it force-marks the shell and solid
Closed(true) unconditionally before returning. The unconditional Done()
afterward makes IsDone() report true and Shape() return a TopoDS_Solid that
is missing both end faces and is not actually closed. BRepCheck_Analyzer
catches this later with no localized error (errorCount 0, empty
detailedCheckStatuses), by which point most callers have already trusted
IsDone().

Fix: after the capping block, if `B` is still false, nullify both output
faces and throw StdFail_NotDone, matching the exception MakeSolid() already
throws for a null shell two lines above. No signature change and no
call-site change: both call sites already run inside Build()'s only
try/catch (catch (Standard_Failure const&) { NotDone(); return; }), so the
throw propagates there and IsDone() correctly reads false.

The nullify matters because face1/face2 alias the caller's myFirst/myLast,
exposed publicly via FirstShape()/LastShape(): if wire1's PerformPlan()
succeeds (writing a real face) but wire2's fails, `B` ends false on wire2's
failure alone, and without the nullify myFirst would still hold a genuine,
but never-added-to-the-shell, face after a failed Build().

This intentionally does not check face nullness (myFirst.IsNull() ||
myLast.IsNull()): PerformPlan() also leaves the output face null, and
returns true (no failure), when every edge of the wire is degenerate -- a
single-vertex "point" section built via AddVertex(), e.g. a cone's apex,
which needs no cap face at all. A null-face check cannot tell that apart
from a genuine capping failure; `B` already carries the distinction, since
PerformPlan()'s degenerate-wire shortcut leaves it true. An earlier attempt
at this fix used the null-face check and regressed
BOPAlgo_PaveFillerTest.FuseConeLoftWithBox_DegeneratedEdge (a
circle-to-vertex loft) for exactly this reason; this version does not.

Repro: a closed wire with r held constant and z = amp * cos(k * theta) caps
fine at k = 0 (flat) and k = 1 (nonzero z-spread but secretly planar, the
intersection of the cylinder r = const with a tilted plane) and fails
silently at k >= 2, for isSolid = true with isRuled either true or false.
Root-caused from a bevel gear ThruSections loft (period == teeth >= 2 from a
per-vertex radial * sin(pitchAngle) term) in
SecondMouseAU/OCCTSwiftScripts#108; the resulting heal()-demotes-solid-to-shell
symptom is SecondMouseAU/OCCTSwift#702; this is filed as its own root-cause
issue at SecondMouseAU/OCCTSwift#905.

Adds two GTest cases to BRepOffsetAPI_ThruSections_Test.cxx:
NonPlanarClosedWireCappingFails (a two-wire k=2 loft, must now fail) and
DegenerateVertexEndStillSucceeds (a circle-to-vertex loft, the shape of the
regression above, must still succeed).

Verified with clang-format (no diff against this tree's .clang-format) and
by override-linking both the unpatched and patched .cxx ahead of the
Libraries/OCCT.xcframework archive bundled in the downstream OCCTSwift repo:
against the unpatched override the new NonPlanarClosedWireCappingFails test
fails exactly as described above (IsDone() reads true); against the patched
override it passes, along with DegenerateVertexEndStillSucceeds and all
three pre-existing tests in the file. Also compiled and ran the actual
BOPAlgo_PaveFiller_Test.cxx FuseConeLoftWithBox_DegeneratedEdge test
unmodified against the patched override: passes, directly confirming this
fix does not reintroduce the first attempt's regression.
@gsdali
gsdali force-pushed the fix/thrusections-silent-solid-capping branch from b3e7d4b to 43309fd Compare August 14, 2026 12:13
@gsdali

gsdali commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a small strengthening found during downstream review (SecondMouseAU/OCCTSwift#909): if
wire1's PerformPlan() succeeds but wire2's fails, face1 (aliased to myFirst, exposed via
FirstShape()) could still hold a real face that was never added to the shell, even though the
overall build correctly reports IsDone() == false. Nullifying face1/face2 in the failure
branch closes that. No change to the core logic (B, the throw, the exception type) — re-verified
with the same override-link technique against both the two new GTests and
FuseConeLoftWithBox_DegeneratedEdge, all still pass.

gsdali added a commit to SecondMouseAU/OCCTSwift that referenced this pull request Aug 14, 2026
Real findings from /code-review, addressed:

- MakeSolid()'s new throw could leave face1/face2 (aliased to myFirst/
  myLast, exposed via FirstShape()/LastShape()) holding a real,
  never-added-to-the-shell face when one wire's PerformPlan() succeeded
  before the other's failed. Nullify both before throwing. Re-verified via
  override-link: same 5/5 GTest pass, same FuseConeLoftWithBox_DegeneratedEdge
  pass. Pushed the corrected commit to the same gsdali/OCCT fork branch
  backing Open-Cascade-SAS/OCCT#1462 (force-push, not a new PR) -- CI
  re-running there.

- Scripts/patches/README.md's "Pin consequence" paragraph was self-
  contradictory: said 0026 was outside the pin "same as 0022-0025" while
  also listing the pin as including 0014-0025 (which includes them).
  Package.swift's own census says all fifteen, through 0025, are baked into
  the v2.0.0 release asset. Corrected.

- Documented, not fixed: why GetStatus() stays _Done on this failure path
  (Build()'s generic catch doesn't touch myStatus for any exception source;
  fixing it needs a call-site change the whole point of this fix was to
  avoid, for a value nothing in this tree reads), and why this patch throws
  rather than following 0025's (#597) surface-a-diagnostic-number pattern
  (different problem shape: wrong pass/fail vs. wrong diagnostic).

- Added Scripts/repro/905-thrusections-capping-guard/, matching every other
  carried patch's convention (a standalone ground-truth probe + real
  before/after transcripts) -- missing from the first commit.

- CLAUDE.md: added a pointer to the existing FillingSurface/BRepFill_Filling
  composable workaround for a caller who hits this on a real non-planar
  loft, since ThruSections' own capping stays plane-only.

Filed separately rather than folded in here: a pre-existing, unrelated
bridge gap the reviewer found (ThruSectionsBuilder.generatedFace(from:)
doesn't check IsDone(), can return stale data after a failed reused
build() -- not introduced or worsened by this fix, same shape already
existed for every other exception this file can throw). #910.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gsdali added a commit to SecondMouseAU/OCCTSwift that referenced this pull request Aug 14, 2026
…patch

ThruSectionsBuilder(isSolid: true) silently omits both end-cap faces for a
closed section wire with k >= 2 periods of out-of-plane variation around the
loop. MakeSolid()'s local `B` already tracks whether capping succeeded and
discards it, unconditionally marking the result Closed(true) regardless.

Carry Scripts/patches/0026-BRepOffsetAPI_ThruSections-capping-guard-905.patch:
throw StdFail_NotDone when B is still false after the capping block, matching
the exception this same function already throws for a null shell. No
signature or call-site change. Distinguishes a genuine capping failure from a
degenerate (point) end needing no cap, which an earlier null-face-based
attempt could not (it regressed
BOPAlgo_PaveFillerTest.FuseConeLoftWithBox_DegeneratedEdge).

Validated via override-link (compile the changed source standalone, link
ahead of the prebuilt OCCT archive, no full rebuild): the new upstream GTest
fails against the unpatched source and passes against the patched one, and
the actual FuseConeLoftWithBox_DegeneratedEdge GTest passes unmodified
against the patched source.

Filed upstream as Open-Cascade-SAS/OCCT#1462, CI green.

Documents the process correction: an earlier session had opened a same-repo
staging PR on the gsdali/OCCT fork (gsdali/OCCT#1, that fork's first PR) to
CI-validate a first attempt before submitting upstream. That isn't how any
other carried patch here was validated, and the first attempt's null-face
guard is what regressed the GTest above. gsdali/OCCT#1 is closed as
erroneous; this commit's Scripts/patches/README.md entry says so.

Not yet in a rebuilt xcframework -- Package.swift's pin is unchanged. That is
a separate release-engineering step, tracked the same way patches 0022-0025
already are.

Closes #905

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gsdali added a commit to SecondMouseAU/OCCTSwift that referenced this pull request Aug 14, 2026
Real findings from /code-review, addressed:

- MakeSolid()'s new throw could leave face1/face2 (aliased to myFirst/
  myLast, exposed via FirstShape()/LastShape()) holding a real,
  never-added-to-the-shell face when one wire's PerformPlan() succeeded
  before the other's failed. Nullify both before throwing. Re-verified via
  override-link: same 5/5 GTest pass, same FuseConeLoftWithBox_DegeneratedEdge
  pass. Pushed the corrected commit to the same gsdali/OCCT fork branch
  backing Open-Cascade-SAS/OCCT#1462 (force-push, not a new PR) -- CI
  re-running there.

- Scripts/patches/README.md's "Pin consequence" paragraph was self-
  contradictory: said 0026 was outside the pin "same as 0022-0025" while
  also listing the pin as including 0014-0025 (which includes them).
  Package.swift's own census says all fifteen, through 0025, are baked into
  the v2.0.0 release asset. Corrected.

- Documented, not fixed: why GetStatus() stays _Done on this failure path
  (Build()'s generic catch doesn't touch myStatus for any exception source;
  fixing it needs a call-site change the whole point of this fix was to
  avoid, for a value nothing in this tree reads), and why this patch throws
  rather than following 0025's (#597) surface-a-diagnostic-number pattern
  (different problem shape: wrong pass/fail vs. wrong diagnostic).

- Added Scripts/repro/905-thrusections-capping-guard/, matching every other
  carried patch's convention (a standalone ground-truth probe + real
  before/after transcripts) -- missing from the first commit.

- CLAUDE.md: added a pointer to the existing FillingSurface/BRepFill_Filling
  composable workaround for a caller who hits this on a real non-planar
  loft, since ThruSections' own capping stays plane-only.

Filed separately rather than folded in here: a pre-existing, unrelated
bridge gap the reviewer found (ThruSectionsBuilder.generatedFace(from:)
doesn't check IsDone(), can return stale data after a failed reused
build() -- not introduced or worsened by this fix, same shape already
existed for every other exception this file can throw). #910.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant