Skip to content

fix: recipes 02 and 03 emit a solid, not a shell (closes #100) - #102

Merged
gsdali merged 7 commits into
mainfrom
fix/100-revolve-shell
Aug 4, 2026
Merged

fix: recipes 02 and 03 emit a solid, not a shell (closes #100)#102
gsdali merged 7 commits into
mainfrom
fix/100-revolve-shell

Conversation

@gsdali

@gsdali gsdali commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What & why

Recipes 02 (helical spring) and 03 (pipe flange) each emitted a shell where they should
emit a solid: Shape.revolve(profile: wire, ...) revolves the curve itself, and
Shape.sweep(profile:along:) never caps the ends of the swept tube, so both are correct
OCCT behaviour on a wire/uncapped pipe, not OCCTSwift bugs, but both are traps relative to
their extrude/pipeShell siblings. Fixed each on its own terms (face-then-revolve for 03,
pipeShell(..., solid: true) for 02, see per-commit messages), regenerated the affected
recipes' reference output.brep / output.png, and hardened Scripts/recipe-check.sh to
assert solidCount >= 1 on every recipe's emitted body so this class of bug cannot ship
silently again.

Closes #100.

Before / after solid counts

recipe before after
02-helical-spring shapeType=shell solids=0 shapeType=solid solids=1
03-pipe-flange shapeType=compound solids=0 shapeType=compound solids=1

Measured via Shape.subShapes(ofType: .solid).count (now exposed as metrics's new
solidCount field), not by reading shapeType, per the issue's own scoping note: five of
the seven recipes legitimately report compound (one solid wrapped by
circularPatternCut), so shapeType == "solid" would reject correct recipes.

Volume deltas on the regenerated references

Both changed. Investigated both rather than assuming "same geometry, only topology
changed":

  • 03-pipe-flange: 241242.90 to 228927.86 mm^3 (-5.1%). The raw revolve-only volume is
    identical either way (247400.42 mm^3), confirming the old shell was already a closed,
    well-defined surface. The drop happens at the circularPatternCut step: cutting a
    boolean tool against a non-solid shell only removed 6157.52 mm^3 across the 8 bolt
    holes, not the correct 8 * pi * 7^2 * 15 = 18472.56 mm^3. That is a second,
    previously-hidden bug the shell was masking (some bolt holes were not being fully cut
    through). The new volume matches the expected geometry exactly.
  • 02-helical-spring: 9729.67 to 8575.15 mm^3 (-11.9%). An open (uncapped) shell has no
    well-defined enclosed volume, so Shape.volume on the old shell was reading an artifact
    of the GProp algorithm, not a physical quantity. The new capped-solid volume matches the
    analytic round-wire coil formula pi * r^2 * L (r = 2mm, L = the helical length over 6
    turns at mean radius 18mm / pitch 12mm) to within 0.06%; the old value was off by 13%.

Negative-control evidence that the hardened check has teeth

Temporarily reverted the recipe 03 fix back to Shape.revolve(profile: wire, ...) and
reran the check:

$ OCCTKIT="swift run occtkit" Scripts/recipe-check.sh recipes/03-pipe-flange
▶ 03-pipe-flange
  ✗ solidCount not >= 1: 0 (shapeType alone is not enough, see header)
  ✓ volume = 241242.900
EXIT=1

Restored the fix, reconfirmed a clean pass (solidCount = 1, EXIT=0).

Separately, found and fixed a pre-existing bug in recipe-check.sh itself while wiring
this up: check_one() is always called as check_one ... || status=1, and bash disables
errexit for every command inside a function while that function is the left-hand side of
||. The Python validation block was a bare statement, so any die() failure (missing or
empty manifest/body, volume not > 0, now solidCount < 1, or a reference drift) printed
its message and then fell straight through to the unconditional final echo "... OK", so
the script exited 0 regardless. Confirmed this on the unmodified script using a real,
unrelated failure (see below): it printed the failure line and then "OK" and exited 0.
Fixed by wrapping the Python invocation in if ! ...; then return 1; fi.

make recipes-test result

▶ 01-mounting-bracket
  ✗ volume drift 2.27e-02 > tol 1.0e-03 (ref 18654.517)
  ✓ volume = 18230.310
  ✓ solidCount = 1
▶ 02-helical-spring .. ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
▶ 03-pipe-flange ..... ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
▶ 04-spur-gear ....... ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
▶ 05-lattice-cube .... ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
▶ 06-fan-blade ....... ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
▶ 07-sheet-metal-channel . ✓ solidCount = 1, matches reference (Δvol 0.00e+00)
overall: exit nonzero (01-mounting-bracket fails)

01-mounting-bracket was already out of scope for #100 (its solidCount was already 1,
the issue's own audit confirmed it unaffected) and this PR does not touch it. Its failure
is a real, pre-existing, unrelated 2.27% volume drift against its committed reference,
deterministic across repeated runs, not flaky, and reproduced with only the
Package.resolved OCCTSwift 1.15.0 to 1.17.0 repin already staged on this branch (no
other recipe drifts on that same repin). It was previously invisible because of the
recipe-check.sh bug above. Filed separately as
#101 rather than folded
into this PR, since it is a numeric fillet-result drift, not a topology bug, and the issue
explicitly scoped recipe 01 as unaffected and not to be changed.

Checklist

  • New or changed behavior is covered by a unit test in the same PR. This repo has no
    unit test framework (CLAUDE.md: "No tests exist. No linter is configured.");
    Scripts/recipe-check.sh's solidCount >= 1 assertion plus the regenerated
    reference output.brep comparisons are its equivalent, and the negative-control
    evidence above demonstrates the assertion actually catches the regression it
    targets.

Notes for the reviewer

  • Both recipe fixes were reached independently, per the issue's own guidance not to
    assume the revolve fix applies to the sweep: recipe 03 needed Shape.face(from:) before
    revolving; recipe 02 needed Shape.pipeShell(..., solid: true) in place of
    Shape.sweep, which is the documented canonical spring recipe in the OCCTSwift cookbook
    and has no Shape.sweep-equivalent "face it first" fix, BRepOffsetAPI_MakePipe simply
    has no capping mode.
  • Added solidCount to the metrics verb (Shape.subShapes(ofType: .solid).count)
    rather than building a separate one-off check, since recipe-check.sh already shells
    out to occtkit metrics for volume/boundingBox and no existing verb exposed a solid
    count. Documented in docs/reference/occtkit-verbs.md.
  • Did not touch Package.swift or Package.resolved beyond what the branch already had
    staged (OCCTSwift 1.15.0 to 1.17.0) per the task setup; included that pre-staged diff in
    its own commit so the branch is self-contained and buildable from a fresh checkout.

gsdali added 5 commits August 4, 2026 23:28
Pins the dependency this branch was set up against; no other pin changes.
Shape.revolve(profile: wire, ...) revolves the curve itself and returns a shell
(BRepPrimAPI_MakeRevol on a wire), not a solid. That is standard OCCT behaviour,
not an OCCTSwift bug, but it is a trap: Shape.extrude(profile: wire, ...) faces
the wire for you, so the two neighbouring sweep factories are not symmetric.

Face the half-section first with Shape.face(from:), then revolve the face
instead of the wire. Regenerated the committed output.brep and output.png: the
raw revolve volume is unchanged (247400.42 mm^3 both ways, confirming the old
shell was already topologically closed), but the final volume after
circularPatternCut drops from 241242.90 to 228927.86 mm^3 (-5.1%). That drop is
not the intended fix, it is a second, previously-hidden bug the shell was
masking: circularPatternCut's boolean subtract against a non-solid shell only
removed 6157.52 mm^3 across the 8 bolt holes instead of the correct
8 * pi * 7^2 * 15 = 18472.56 mm^3 (verified by isolating the raw-revolve and
post-cut volumes on both the old and new construction). The new volume matches
the expected geometry exactly; the old one did not.

Part of #100.
Shape.sweep(profile:along:) wraps BRepOffsetAPI_MakePipe, which never caps the
ends of the swept tube: it returns a shell even for a closed circular profile.
Switch to Shape.pipeShell(spine:profile:mode:solid:), which wraps
BRepOffsetAPI_MakePipeShell and has an explicit solid: flag that caps the ends
into a genuine solid. This is the documented canonical spring recipe in the
OCCTSwift cookbook (Helices & Springs), reached independently of the recipe 03
fix since this is a pipe sweep, not a revolve.

Regenerated the committed output.brep and output.png. Volume drops from
9729.67 to 8575.15 mm^3 (-11.9%). Unlike recipe 03, this is not a hidden second
bug: an open (uncapped) shell has no well-defined enclosed volume, so
Shape.volume on the old shell was already reading an artifact of the algorithm,
not a physical quantity. The new, capped-solid volume matches the analytic
round-wire coil formula pi * r^2 * L (r = 2mm, L = coil length over 6 turns at
mean radius 18mm and pitch 12mm) to within 0.06%, the old one was off by 13%.

Part of #100.
Add solidCount (Shape.subShapes(ofType: .solid).count) as an opt-in metric
alongside volume / surfaceArea / boundingBox / principalAxes. shapeType alone
cannot distinguish a healthy circularPatternCut result (compound wrapping one
solid) from a wire-based revolve or sweep that never got faced/capped
(compound or shell wrapping zero solids), both of which can report the same
top-level shapeType. Needed by Scripts/recipe-check.sh's hardened solids >= 1
assertion, next commit.

Part of #100.
…ss bug

Assert the emitted body's solidCount (via the new metrics field) is at least
1, not shapeType == "solid": five of the seven recipes legitimately report
compound, since a compound wrapping one solid is the normal result of
circularPatternCut. Asserting solidCount is what actually would have caught
both recipe 02 and 03 shipping a shell.

While wiring this up, found and fixed a pre-existing bug that made every
assertion in this script unable to fail. check_one() is always invoked as
check_one ... || status=1, and bash disables errexit for every command inside
a function while that function is the left-hand side of ||. The Python
validation block was a bare statement, so a nonzero exit from any die() call
(missing/empty manifest or body, volume <= 0, now solidCount < 1, or a
reference drift) fell straight through to the unconditional final echo and
"OK" was printed and the function returned 0 regardless. Confirmed on the
unmodified script before this fix: a real reference-drift failure on
01-mounting-bracket (unrelated to #100, see #101) printed the die() message
and then "OK" and exited 0. Fixed by wrapping the Python invocation in
if ! ...; then return 1; fi.

Proved the hardened check has teeth: temporarily reverted the recipe 03 face
fix back to Shape.revolve(profile: wire, ...), reran
Scripts/recipe-check.sh recipes/03-pipe-flange, got a clean failure
("solidCount not >= 1: 0") and exit 1, then restored the fix and reconfirmed
a clean pass.

make recipes-test now fails on 01-mounting-bracket for an unrelated, pre-
existing reason (a 2.27% volume drift against its committed reference,
tracked separately as #101) rather than passing silently; 02 through 07 all
pass with solidCount == 1 and match their reference output.brep exactly.

Closes #100.
@gsdali

gsdali commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review

Verified the diff against the OCCTSwift API surface at the declared floor, the regenerated BREP topology, and the volume arithmetic.

What I verified independently (all holds up)

  • Recipe 02 arithmetic. meanRadius = (40 - 4)/2 = 18, r = 2, pitch 12, 6 turns. Coil length 6 * sqrt((2*pi*18)^2 + 12^2) = 682.4 mm, so pi*r^2*L = 8575.6 mm^3 against the measured 8575.15. The capped-solid claim is confirmed analytically, and the old 9729.67 really was a GProp artifact.
  • Recipe 03 arithmetic. Pappus on the half-section gives 2*pi*(750*50 + 50*37.5) = 247400.42 mm^3, exactly the stated raw-revolve figure. Bolt holes at r = 60 pass through only the 15 mm disk (the raised face stops at r = 50), so the correct cut is 8 * pi * 7^2 * 15 = 18472.57, and 247400.42 - 18472.57 = 228927.86, exactly the new committed volume. The holes also do not overlap (chord at 45 degree spacing is 45.9 mm vs 14 mm diameter). The "the shell was masking a second bug" claim stands.
  • Regenerated references have the claimed topology. 02 has one So inside one Sh (top-level solid); 03 has one Co wrapping one So. Matches the before/after table.
  • API availability at the declared floor. Shape.face(from:planar:) (Shape.swift:1472), revolved(axisOrigin:axisDirection:) (:438), pipeShell(spine:profile:mode:solid:) (:2698) all exist at v1.15.0, so no Package.swift floor bump is needed and the CLAUDE.md dependency block stays accurate. The repin is also not a new risk: OCCTSwift ships v1.15.1 through v1.17.0, so any fresh resolve against >= 1.15.0 already picks 1.17.0. The repin records reality.
  • pipeShell(..., .correctedFrenet, solid: true) is verbatim the OCCTSwift cookbook's canonical spring. Right call, and stating solid: explicitly despite it being the default is right here since it is the entire point of the change.
  • The bash fix. if ! python3 ... <<'PY' ... PY followed by then is valid (the heredoc body terminates before then), and the errexit diagnosis is correct. Checked the rest of check_one for the same shape: lines 27 and 32 already use explicit return 1, so the Python block was the only hole. The fix is complete for that function.

Issues to address in this PR

1. The same two traps remain documented as correct elsewhere in this repo. This is the significant gap.

docs/guides/cookbook/sweeps-lofts-patterns.md section 1 is a verbatim mirror of recipe 02, links recipes/02-helical-spring/ by path, still builds the spring with Shape.sweep(profile: section, along: path)! (line 38), and its gotchas still describe the result as "a negative-volume solid" (line 53). After this PR the cookbook contradicts the recipe it claims to document, and the cookbook is what people copy.

Same class, docs/SCRIPT_WORKFLOW.md:

  • :117-119, a "From profiles" cheat sheet listing Shape.extrude(profile: wire, ...), Shape.revolve(... profile: wire) and Shape.sweep(profile: wire, ...) on consecutive lines with no note that only extrude faces the wire for you. That asymmetry is exactly what this PR identifies as the trap.
  • :261 and :422, which build a rail from Shape.sweep and label it "Rail solid".

The docs-current policy is explicit that documentation ships with the change.

2. No OKF entry. Repo precedent is direct: #82 produced okf/decisions/single-source-verb-inventory.md, #98 produced swiftpm-path-dependency-identity.md, both added under code review on those PRs. This PR carries two durable, generalizable rules and neither is recorded: (a) wire-profile sweep factories are not symmetric, extrude faces for you and revolve/sweep do not; (b) an assertion inside a function invoked as f ... || status=1 needs explicit propagation because errexit is suppressed there. Right now (b) survives only as a shell comment. Needs okf/decisions/<slug>.md plus okf/decisions/index.md and okf/log.md.

3. CLAUDE.md:91 not updated. It enumerates exactly what metrics wraps; solidCount / Shape.subShapes(ofType: .solid) is missing. docs/reference/occtkit-verbs.md was updated correctly, so this is the second hand-maintained copy drifting.

4. Sources/occtkit/Commands/Metrics.swift:111, use the purpose-built counter.

let solidCount: Int? = wants("solidCount") ? shape.subShapeCount(ofType: .solid) : nil

OCCTSwift's subShapes(ofType:) is literally (0..<subShapeCount(ofType:)).compactMap { subShape(type:index:) } (Shape.swift:2057-2060), so the current form allocates one Shape handle per solid only to discard them. subShapeCount(ofType:) is public and returns the same value. This is also the search-before-building case: the counting API already exists.

5. Scripts/recipe-check.sh:59 asks the reference for solidCount and then ignores it. Both metrics calls now request it, but only the emitted side is asserted. Cheap upgrade with data already in hand:

if os.path.exists(ref):
    r = metrics(ref)
    if solids != r.get("solidCount"):
        die(f"solidCount drift: {solids} vs reference {r.get('solidCount')}")

>= 1 catches "a shell shipped". It does not catch "the compound went from 1 solid to 3 because a boolean stopped fusing", which is the same family of silent topology regression that #100 was.

Risks

The recipes workflow goes red on merge and stays red. gh pr checks 102 already reports recipes fail on both runs. It is continue-on-error: true (allowed-to-fail seed phase, #16) so it will not block, and scoping #101 out is defensible. But two consequences belong in the PR body: it blocks #16 (promoting the workflow to required), and a permanently-red allowed-to-fail check is precisely how the next real failure gets ignored. Worth landing #101's reference regeneration adjacent to this rather than leaving the gap open.

Follow-up worth filing (pre-existing, out of scope)

Recipe 03's chamfer has never applied. This PR's own evidence proves it: 247400.42 - 18472.57 = 228927.86 agrees with the committed volume to 8 significant figures, while a real 1 mm chamfer on the OD alone would remove roughly 235 mm^3 (about 0.1%). So flange.chamfered(distance: 1.0) returns nil and the ?? flange fallback fires every time, yet the file header, the README prose, and the API list all advertise the edge break. That silent-fallback-into-shipping-something-other-than-the-docs pattern is the same shape as #100 itself.

Nits

  • recipes/03-pipe-flange/README.md, the APIs-used list has bare `revolved(axisOrigin:axisDirection:)` while every sibling bullet is Type.member (Wire.polygon, Shape.face(from:), Shape.cylinder). Use Shape.revolved(...).
  • Recipe 02's README dropped the pointer to Shape.signedVolume / orientedForward() along with the Shape.sweep note. Defensible since it was specific to sweep, but those remain useful for anyone debugging a sweep orientation.

Style compliance

Clean. No em-dashes in any added line (checked across the whole non-generated diff), no banned words. Commit messages are unusually good: each states the OCCT mechanism, the numbers, and the verification. Comment density in recipe-check.sh and Metrics.swift matches the surrounding house style, and the negative-control evidence in the PR body is the right way to demonstrate an assertion has teeth in a repo with no test framework.

Verdict

The geometry work is correct and well evidenced. Items 1 through 3 (cookbook / SCRIPT_WORKFLOW docs, OKF entry, CLAUDE.md) should land before merge under the repo's own docs-current policy; 4 and 5 are small and worth taking now.

…des)

Item 1, docs drift. The two traps this PR fixes were still documented as correct
elsewhere, and the cookbook is what people copy:

* docs/guides/cookbook/sweeps-lofts-patterns.md mirrored recipe 02 verbatim,
  still built the spring with Shape.sweep, and described the result as a solid.
  Now uses pipeShell(solid: true) and its gotcha explains why sweep returns a
  shell that still reports a plausible volume.
* docs/SCRIPT_WORKFLOW.md's "From profiles" cheat sheet listed extrude, revolve
  and sweep on consecutive lines with nothing saying only extrude faces the wire
  for you. That adjacency is where the wrong assumption forms, so each line is
  now annotated solid or SHELL, with the facing alternative alongside.
* The same file's two "Rail solid" sweeps now use pipeShell.

Item 2, OKF entries. Two durable rules were carried only in commit messages and
a shell comment:

* wire-sweep-factories-are-not-symmetric: which factories face a wire for you,
  and why to assert solidCount >= 1 rather than shapeType or a positive volume.
* errexit-is-suppressed-in-or-context: why a function invoked as `f || status=1`
  must return 1 explicitly, with the recipe-check silent-pass bug as the worked
  example. Cross-linked to single-source-verb-inventory as the same
  guard-that-cannot-fail family.

Item 3, CLAUDE.md:91 now lists solidCount alongside the rest of what metrics
wraps, including the assert-solidCount-not-shapeType guidance. It was the second
hand-maintained copy drifting from docs/reference/occtkit-verbs.md.

Item 4, Metrics.swift uses subShapeCount(ofType:) rather than
subShapes(ofType:).count. The latter allocates a Shape handle per solid only to
discard them, and the counting API already existed. Verified behaviour-neutral:
solidCount unchanged across recipes 02, 03 and 05.

Item 5, recipe-check.sh now compares solidCount against the committed reference,
not just >= 1. That catches the other half of the family: a topology regression
that keeps a positive volume, such as a compound going from 1 solid to 3 because
a boolean stopped fusing. Negative-tested by rewriting a reference to a 2-solid
compound, confirming "solidCount drift: 1 vs reference 2", then restoring.

Nits: qualified Shape.revolved(...) in recipe 03's API list, and restored the
signedVolume / orientedForward pointer to recipe 02's gotchas, scoped to
orientation debugging rather than the topology check.

Also filed #103: recipe 03's chamfer has never applied. chamfered(distance: 1.0)
returns nil and the ?? fallback hides it, confirmed directly and corroborated by
this PR's own volume arithmetic. Out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsdali

gsdali commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

All five items addressed in bc8b89e, plus both nits and the follow-up filed.

1. Docs drift

The sharpest catch in the review: the cookbook is what people copy, and it contradicted the recipe it documents.

  • sweeps-lofts-patterns.md now builds the spring with pipeShell(..., solid: true), and its gotcha explains why Shape.sweep returns a shell that still reports a plausible volume.
  • SCRIPT_WORKFLOW.md cheat sheet: rather than just correcting it, I annotated each line with what it returns, and grouped the two solid-producing calls together with the facing alternative beside each shell-producing one. That list sitting on consecutive lines is precisely where the wrong assumption forms.
  • Both "Rail solid" sweeps updated.

2. OKF entries

Two added, wire-sweep-factories-are-not-symmetric and errexit-is-suppressed-in-or-context, with index and log.md lines. The second is cross-linked to single-source-verb-inventory as the same guard-that-cannot-fail family, which is now the third instance in this repo.

3. CLAUDE.md:91

Updated, including the assert-solidCount-not-shapeType guidance. You were right that this was the second hand-maintained copy drifting from occtkit-verbs.md.

4. subShapeCount(ofType:)

Confirmed against the docs that subShapes(ofType:) is implemented as count-then-iterate, so the array form was allocating a handle per solid to discard it. Switched. Verified behaviour-neutral: solidCount unchanged at 1 across recipes 02, 03 and 05.

5. Reference solidCount comparison

Added, and negative-tested rather than assumed. I rewrote recipe 05's reference into a 2-solid compound:

▶ 05-lattice-cube
  ✗ solidCount drift: 1 vs reference 2

then restored it (git status clean). Your framing was right: >= 1 catches "a shell shipped", this catches "the compound went from 1 solid to 3".

Nits

Both done. Shape.revolved(...) qualified, and the signedVolume / orientedForward() pointer restored to recipe 02's gotchas, scoped to orientation debugging so it does not read as an alternative to the topology check.

Follow-up filed: #103

Verified your chamfer deduction directly before filing:

flange: solids=1 vol=228707.95
chamfered(1.0) -> nil (FALLBACK FIRES)

So ?? flange fires every time and the recipe ships without the edge break it advertises in three places. Your volume argument was independently right.

On the red recipes workflow

Noted, and I agree a permanently-red allowed-to-fail check is how the next real failure gets ignored. I have not folded #101 in, since regenerating recipe 01's reference is a behaviour question (is the 2.27% drift under OCCTSwift 1.17.0 correct, or a regression?) and that deserves its own answer rather than being absorbed into a fix for #100. Flagging it as a merge decision rather than deciding it unilaterally: this PR can land with that check red, or wait on #101.

Verification

policy-check.sh and verb-check.sh both pass, and the em-dash and banned-word scan is clean across the changed files.

Separately, the same one-line fix has now been applied to the bevel gear spike and answers #86 in the affirmative: circularPatternCut survives up to 60 near-tangential cuts against a solid blank with a clean analyze() throughout. Details on that issue.

@gsdali

gsdali commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review

Verification run against the branch

  • Recipe 03's new volume is analytically exact. Disk pi(75^2 - 25^2)*15 = 235619.45 plus raised face pi(50^2 - 25^2)*2 = 11780.97 gives 247400.42, matching the stated raw revolve volume to the decimal. Subtracting 8*pi*7^2*15 = 18472.56 (the holes only pierce the disk, since boltCircleRadius 60 > raisedRadius 50) gives 228927.86, which is the new committed reference exactly. The old 241242.90 was the under-cut shell, so the diagnosis of a second hidden bug holds, and the new number is the correct flange rather than merely a different one.
  • Recipe 02's new volume matches the analytic coil. pi*2^2*6*sqrt((2*pi*18)^2 + 12^2) = 8575.5 against the committed 8575.154, about 0.004%. The old 9729.67 was 13.5% high, consistent with the "open shell has no well-defined volume" explanation.
  • CI log confirms the claims (job 92024200819): 02 at 8575.154, 03 at 228927.857, solidCount = 1 and zero reference drift on both, every other recipe green, and 01-mounting-bracket the sole failure.
  • subShapeCount(ofType:) is the right primitive. It is TopExp::MapShapes-backed, so it counts solids at any nesting depth, which is what makes solidCount >= 1 correct for a compound-wrapped circularPatternCut result and still fatal for a shell. Choosing it over shapeType == "solid" is well reasoned and well documented.
  • The errexit analysis is correct and the fix is sound: if ! python3 ... <<'PY' ... PY then return 1; fi is valid, and the trap 'rm -rf "$tmp"' RETURN still fires on the early return.

The negative control in the PR body, and the OKF entry that codifies "break it deliberately, watch it go red, put the evidence in the PR", are the strongest parts of this change.

Issues and suggestions

1. Merging leaves the recipes workflow red on main (medium, process). continue-on-error: true keeps it non-blocking, so this is not a merge blocker, but the workflow's own comment says it gets promoted to required "once the seed batch settles", and the errexit fix means the red is now real rather than cosmetic. Landing a knowingly-failing suite trains every subsequent PR author to ignore that check, which is the same "a guard that reads as coverage" failure this PR's own OKF entry warns about. Prefer landing #101 first, or add an explicit, documented expected-failure entry for recipe 01 (for example an XFAIL_RECIPES list that reports the drift, keeps exit 0, and fails loudly if the recipe ever starts passing) so the suite's signal stays binary.

2. The hardened check's own output is misordered under buffering (medium). Python's stdout is block-buffered when piped while die() writes to stderr unbuffered, so failures print above the passing lines that logically preceded them. Visible in both the PR body's negative control and the CI log:

  ✗ volume drift 2.27e-02 > tol 1.0e-03 (ref 18654.517)
  ✓ volume = 18230.310
  ✓ solidCount = 1

The real order is volume, solidCount, then drift. Fix with python3 -u, or print(..., flush=True). Worth doing in this PR specifically, because the point of the change is making this script's output trustworthy.

3. Reference doc names an API the verb does not call (low). docs/reference/occtkit-verbs.md describes solidCount as Shape.subShapes(ofType: .solid).count, while Metrics.swift calls shape.subShapeCount(ofType: .solid). Same value, and the updated CLAUDE.md paragraph has the right one, so just align the verbs reference.

4. CLAUDE.md's OCCTSwift floor is stale (low). It still says (>= 1.15.0) and "Floored at v1.15.0", but Package.swift has said from: "1.17.0" since d5d31e8. Pre-existing drift, not introduced here, but this PR is already editing CLAUDE.md and syncing Package.resolved to 1.17.0, so it is the natural place to close it under docs-current.

5. The two OKF texts describe the same number differently (low). okf/log.md says the flange's cut was "removing one third of the correct material", the decision entry says "two thirds of the bolt holes were missing". The figures (6157.52 of 18472.57) mean it removed one third of what it should have, so two thirds of the intended material stayed behind. The log line reads as though a third of the flange vanished; reword it to match the decision entry.

6. solidCount joins default-all --metrics (low, downstream note). Every existing occtkit metrics consumer, including OCCTMCP's compute_metrics, now gets an extra field in the default response. Additive and optional, so risk is low, and given how cheap the traversal is, default-all is the right call here in contrast to opt-in boundingBoxOptimal. Worth a line in the release notes for downstream consumers.

7. Trivial. okf/decisions/index.md gains a trailing blank line.

Observations, no action needed

…green

Item 1, the red suite. Rather than land a knowingly-failing check, this
regenerates recipe 01's reference and closes #101. That is safe now because #101
is fully diagnosed: the 2.27% is OCCTSwift#272, Shape.drilled ignoring direction
and hardcoding +Z, fixed between 1.12.0 and 1.12.9. Proven arithmetically, a
+Z-forced bore entering at y = -1 removes only the circular segment above the
face, 12.3387 mm2 x 7.0 = 86.37 mm3, exactly the per-hole figure the old
reference was built from at 1.3.1. Current removes pi*r^2*t = 192.42 exactly and
holds at 2.0.0-kernel.1. So the old reference baked in an upstream bug and the
new one is correct. Full suite is now green, exit 0.

Note the bracket was already un-filleted in the old reference: recipe 01's
fillet has never applied at any version, since filletRadius 8 exceeds the 5mm
leg thickness. That is #105 and will regenerate this reference again.

Item 2, python3 -u. stdout was block-buffered when piped while die() wrote to
stderr unbuffered, so failures printed above the passing lines that preceded
them. Verified fixed: a forced drift now prints volume, then solidCount, then the
failure, in that order. This mattered because the point of the change was making
this script's output trustworthy.

Item 3, docs/reference/occtkit-verbs.md said subShapes(ofType:).count while
Metrics.swift calls subShapeCount(ofType:). Aligned.

Item 4, CLAUDE.md's OCCTSwift floor still read >= 1.15.0 and "Floored at
v1.15.0" while Package.swift has said from: "1.17.0" since d5d31e8. Updated,
including the stale gsdali URL, and noted that the 1.17.0 floor also carries the
#272 drilling fix.

Item 5, okf/log.md said the flange cut was "removing one third of the correct
material", which reads as though a third of the flange vanished. Reworded to
match the decision entry: it removed one third of what it should have, leaving
two thirds of each bolt hole unfinished.

Item 6, added a downstream note to the metrics verb reference: solidCount is in
the default-all set, so existing consumers including OCCTMCP's compute_metrics
get one extra optional field, and why it is default-all rather than opt-in.

Item 7, trailing blank line in okf/decisions/index.md.

Closes #101.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsdali

gsdali commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

All seven addressed in 7d181dd. Item 1 I resolved by closing #101 rather than by adding machinery.

1. The red suite: closed #101 instead

You framed the risk exactly right, that landing a knowingly-failing suite trains people to ignore the check, which is the failure this PR's own OKF entry warns about. So rather than add an XFAIL_RECIPES list, I regenerated recipe 01's reference. The full suite is now green, exit 0.

That is safe because #101 is now fully diagnosed rather than merely suspected. It is
OCCTSwift#272, Shape.drilled ignoring direction and hardcoding +Z, fixed between 1.12.0 and 1.12.9. Proven arithmetically: a +Z-forced bore entering at y = -1 removes only the circular segment above the face, 12.3387 mm2 x 7.0 = 86.37 mm3, which is exactly the per-hole figure the old reference was built from at 1.3.1. Current removes pi*r^2*t = 192.42 exactly, and that holds at 2.0.0-kernel.1.

So the old reference baked in an upstream bug, and the new one is correct. Details on #101.

One thing worth noting: the bracket was already un-filleted in the old reference, because recipe 01's fillet has never applied at any version (radius 8 on a 5 mm leg). So this regeneration corrects only the drilling. #105 will regenerate it once more.

2. Buffering

python3 -u, with the reason in a comment. Verified against a forced drift:

▶ 05-lattice-cube
  ✓ volume = 5737.722
  ✓ solidCount = 1
  ✗ volume drift 4.53e-01 > tol 1.0e-03 (ref 10496.524)

Correct order now. Your point that this mattered because the change is about making this script's output trustworthy was the right reason to fix it here rather than later.

3 to 7

  • 3. occtkit-verbs.md aligned to subShapeCount(ofType:).
  • 4. CLAUDE.md floor updated >= 1.15.0 to >= 1.17.0, plus the stale gsdali URL, and noted that the 1.17.0 floor also carries the #272 drilling fix.
  • 5. Reworded. "Removing one third of the correct material" did read as though a third of the flange vanished; it now matches the decision entry.
  • 6. Downstream note added to the metrics verb reference, covering both the extra default field and why it is default-all rather than opt-in like boundingBoxOptimal.
  • 7. Trailing blank line gone.

On your observations

The note that if rs is not None is effectively always true is correct, since metrics recomputes the field from the reference rather than reading a stored one. Left as defensive code.

The double reference churn across the stack is real and I agree the sequencing is right: #102 regenerates 03 for the shell fix, #104 regenerates it again for the chamfer. Recipe 01 will churn twice too, once here and once for #105.

Verification

Full suite green at exit 0, policy-check.sh and verb-check.sh pass, em-dash and banned-word scan clean across the diff.

@gsdali
gsdali merged commit 5812e17 into main Aug 4, 2026
6 checks passed
@gsdali
gsdali deleted the fix/100-revolve-shell branch August 4, 2026 22:12
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.

recipe 03 revolves a wire, so the flange is a shell rather than a solid

1 participant