fix: select recipe 01's inside-corner fillet edge geometrically (#105) - #106
Conversation
prism.concaveEdges() returns the wrong two edges on this L-bracket's extruded shape: the top-cap boundary segments at z = width (each bounded by the 5 mm leg thickness), not the one true reentrant edge that runs the full extrusion width (bounded only by legLength - thickness, 45 mm). filletRadius = 8 was infeasible for the wrong edges and BRepFilletAPI returned nil, hidden behind a `?? prism` fallback that shipped an un-filleted bracket while the header and README kept describing a filleted one. Select the true edge geometrically instead, with Shape.edges(where:): a line parallel to the extrusion axis positioned at (thickness, thickness). The same filletRadius = 8 now applies cleanly, comfortably under the corrected 45 mm limit, and adds 549.38 mm3 to the prism, matching the analytic r^2 * (1 - pi/4) * width prediction for one concave fillet to five significant figures. Verified against the real occtkit run + metrics path, not just the in-process diagnostic: solidCount stays 1, bounding box is unchanged (the fillet fills part of the existing envelope rather than growing it), and the final drilled bracket now measures 18779.69 mm3 against the previous un-filleted reference of 18230.31 mm3, exactly the fillet's added volume. Regenerated output.brep and output.png. Updated the recipe header, its README, and the top-level recipes/README.md line, all of which described concaveEdges() as the selection mechanism; that prose now matches what the code does and explains why concaveEdges() was wrong here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
blade = blade.union(hub) ?? blade is the same pattern being fixed in recipe 01 (#105): an optional-returning geometry op degrading silently through `??` rather than failing loudly. Found while auditing recipes/ for the same shape of defect per #105. The union has never actually failed on this recipe's parameters (verified: identical volume, bounding box, and solidCount before and after this change), so this is a dormant instance rather than a live bug, but it should fail loudly if it ever does rather than silently ship a blade missing its hub. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent recipes have now hit concaveEdges() / convexEdges() disagreeing with a shape's actual geometry: the pipe flange chamfer (#103, #104) and now the mounting bracket fillet (#105), where concaveEdges() returned two unrelated top-cap edges instead of the L-profile's one true reentrant edge. Worth its own decision rather than a footnote on the existing revolve-seams entry, since it is no longer a single-shape quirk. Also records the broader pattern audited in #105: a silent `?? fallback` on a Shape-returning geometry call is the same defect seen across #100, #103, and #105, plus one dormant instance found and fixed in recipe 06. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The classifier defect this PR works around is fixed in the 2.0.0 line. Verified against the published v2.0.0-kernel.1 prerelease with the same repro: 1.17.0 L-prism concave=2 (expected 1) insideCorner inConcave=false 2.0.0-kernel.1 L-prism concave=1 (expected 1) insideCorner inConcave=true A T-prism with two reentrant edges reports 3 on 1.17.0 and 2 on 2.0.0-kernel.1. A box, having no reentrant edges, is correct on both. The fix was too involved to backport to 1.x, so it is carried by the 2.0.0 refactor. No code change: the geometric selection stays, because this package is pinned to 1.17.0 and the defect is real there. What changes is that the workaround is now recorded as temporary rather than permanent, in the OKF entry and in the recipe header, so whoever migrates to 2.0.0 knows this can be simplified back to concaveEdges() and knows to re-run the repro rather than assume. Also corrected the upstream report, OCCTSwift#695, which claimed the bug was verified at 2.0.0-kernel.1. It was not: only the 1.17.0 run was the classifier repro, and at 2.0.0-kernel.1 the case passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upstream defect is 1.x only, and is already fixed in 2.0.0Pushed The A box, having no reentrant edges, is correct on both. The geometric selection stays, because this package is pinned to 1.17.0 and the defect is real there. What changed is that the workaround is now recorded as temporary rather than permanent, in both the OKF entry and the recipe header, so whoever migrates to 2.0.0 knows this can be simplified back to Volume unchanged and re-verified after the edit: Correction to the upstream reportI also corrected OCCTSwift#695. It claimed the bug was "verified at v1.17.0 and at v2.0.0-kernel.1", but only the 1.17.0 run was the classifier repro; the 2.0.0-kernel.1 run I had done was a different probe. At 2.0.0-kernel.1 the case passes. Left it for the maintainer to close or relabel as "fixed in 2.0.0, not backported", since that is a release-plan call. |
Review:
|
…eview) Adds `precondition(!insideCornerEdges.isEmpty, ...)` before the fillet, and renames `insideCorner` to `insideCornerEdges` since it holds a collection, for consistency with recipe 03's `chamferTargets`. On the review's uncertainty about whether an empty edge list no-ops: it does not. `filleted(edges: [], radius:)` returns nil on both 1.17.0 and 2.0.0-kernel.1, so the existing force-unwrap would already have caught an empty match. The guard is still worth having for two reasons the measurement does not remove: it names the actual fault instead of surfacing an anonymous nil-unwrap crash, and it stops the recipe depending on undocumented nil-on-empty behaviour that a future upstream change could alter. Verified the guard fires rather than being decorative: breaking the predicate so it matches nothing gives Script/main.swift:73: Precondition failed: inside-corner edge selector matched nothing and the recipe returns 18779.69 once restored. All 7 recipes pass, policy-check and verb-check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both addressed in The empty-selector concern does not materialise, but the guard is still worth havingYou flagged that it was "not certain" whether So the existing force-unwrap would already have caught an empty match, and the silent-regression scenario you described could not have happened as written. I added the
The code comment says exactly this, so the next reader knows the guard is defence in depth rather than a fix for an observed failure. Proved the guard firesOtherwise it would be another check that cannot fail, which is the pattern this whole run of PRs has been about. Breaking the predicate so it matches nothing: and 18779.69 once restored. Naming nitTaken: VerificationVolume unchanged at 18779.69, all 7 recipes pass, Thanks for the review, particularly the |
Review (pass 2)What changed since the last reviewTwo commits landed since my first review, both responsive to it:
Assessment of the new commitThe guard addition is done well, not just bolted on:
One residual, minor nit
Bottom lineBoth prior points are resolved correctly and verified empirically rather than assumed. No new issues introduced by the delta. I'd approve this as-is (modulo the repo's own "a human reviews every PR here" policy, which still applies regardless of what I say). 🤖 Generated with Claude Code |
The recipes already have a "crash loudly" idiom and it is not precondition:
recipes 04, 06 and 07 use guard ... else { fatalError(...) } or a bare
fatalError, four uses in total. The precondition added in 490b958 was the only
one of its kind, so this switches it for consistency.
Also strictly safer, not just tidier: fatalError is never elided, while
precondition is removed under -Ounchecked. Nothing here builds with that today,
but the guard's whole value is firing when it is needed.
Verified the guard still fires after the change:
Script/main.swift:73: Fatal error: inside-corner edge selector matched nothing
and the recipe returns 18779.69 once restored. All 7 recipes pass, policy-check
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What & why
Recipe 01's inside-corner fillet has never applied.
prism.filleted(edges: prism.concaveEdges(), radius: filletRadius)returns nil at the configuredfilletRadius = 8, and a?? prismfallback hid it, so the bracket shipped with a sharp inside cornerwhile its header, README, and the top-level recipes README all advertised a
concaveEdges()-driven fillet.Closes #105
Diagnosed mechanism: concaveEdges() returns the wrong two edges
The L-profile has exactly one reentrant vertex, at
(thickness, thickness). Extrudedalong Z, that vertex produces exactly one concave edge: a line parallel to the extrusion
axis, running its full 40 mm length.
prism.concaveEdges()does not return that edge. Itreturns two different edges instead, each 45 mm (
legLength - thickness), lying in theend-cap plane (
z = width):edge[7]sits exactly where the profile's reentrant vertex predicts a concave edgeshould be. It is classified convex. The two edges
concaveEdges()calls concave areboundary segments of the top cap, at the far end of the part from the reentrant corner.
Those two wrong edges are bounded by the 5 mm leg thickness (they sit on the boundary
between the end cap and a wall that is only 5 mm across), so a fillet there fails above
roughly that radius: reproduced
r=8.0 -> nil,r=5.0 -> nil,r=4.9 -> okexactly asmeasured in the issue. The true edge (
edge[7]) is bounded only bylegLength - thickness(45 mm), since it runs the full unconstrained length of eachwall: it fillets cleanly all the way from
r=0.1up tor=44.9, and only fails atr=45.0(the wall's own length).Both edges should not be filleted. The two
concaveEdges()picks are the top cap'sordinary perimeter edges (like any other box edge), not the bracket's structural inside
corner; filleting them would round a cosmetic top-face edge, not the corner that matters,
and they are excluded from the fix entirely.
A concave fillet adds material, not removes it
Filleting the true reentrant edge increases volume: it fills part of the sharp inside
corner with a rounded blend.
r^2 * (1 - pi/4) * lengthis the magnitude either way, butthe sign flips depending on which edge you hand it:
concaveEdges()(wrong, 2 edges, L=45 each)edges(where:)selector (correct, 1 edge, L=40)3^2*(1-pi/4)*40 = 77.26edges(where:)selector (correct, 1 edge, L=40)8^2*(1-pi/4)*40 = 549.38The configured
filletRadius = 8needed no change at all, once pointed at the rightedge. It was never too large for the part; it was only ever being measured against the
wrong edge's much tighter limit.
Fix
Select the inside-corner edge geometrically with
Shape.edges(where:): a line parallelto the extrusion axis, positioned at
(thickness, thickness), the same approach recipe03 uses for the pipe flange (#103). Force-unwrap the fillet result (
!), not?? prism:a future regression now crashes loudly instead of silently shipping an un-filleted
bracket.
Regenerated
output.brepandoutput.png. The header comment,README.md, and thetop-level
recipes/README.mdline all describedconcaveEdges()as the selectionmechanism; updated all three to describe the geometric selector and explain why
concaveEdges()was wrong on this shape, plus a Gotchas note that a concave fillet addsvolume rather than removing it.
Verified end to end
occtkit run+occtkit metrics(not just anin-process diagnostic):
solidCount = 1, bounding box unchanged ((0,0,0)to(50,50,40), since the fillet fills part of the existing envelope rather than growingit).
18230.31 mm3. Delta is +549.38 mm3, exactly the fillet's analytic prediction; the four
holes remove the same volume in both cases since they sit well clear of the corner.
output.pngbefore/after and diffed pixel-by-pixel: the sharp V at the insidecorner is now a visible rounded arc at that exact edge; everything else in the render
is unchanged bar 1px anti-aliasing jitter.
Audit: other
??fallbacks on geometry operations in recipes/Grepped every
recipes/*/main.swiftfor??. Found one other instance of the sameshape,
recipes/06-fan-blade/main.swift:72:blade = blade.union(hub) ?? blade.Verified it has never actually fired: volume, bounding box, and
solidCountarebyte-identical before and after removing the fallback (
blade.union(hub)!), so this is adormant instance, not a live bug, but it should fail loudly if it ever does. Fixed
alongside this PR since it's a one-line, zero-behavior-change fix of the exact same
pattern.
Every other
??inrecipes/*/main.swiftis.volume ?? 0inside aprintstatement,a display default for an already-emitted shape's optional volume. That does not affect
what geometry ships (only what number gets printed if
volumeitself returns nil), soit is not the same defect class; left alone.
Also checked
Sources/occtkit/Commands/Heal.swift:109(
let healed = fixer.shape ?? input), which is structurally similar but out of scope(not in
recipes/, and it already surfaces a warning plus before/after snapshots whendidChangeis false, so the caller can detect a no-op heal, unlike the silent recipepattern). Not filing a follow-up issue; flagging here in case it's useful.
Recorded a new decision,
concave-edge-classifier-can-select-wrong-edges,since this is now the second independent recipe (after the flange chamfer, #103/#104)
where
concaveEdges()/convexEdges()disagreed with a shape's actual geometry, nolonger a single-shape quirk worth only a footnote.
Checklist
Scripts/recipe-check.shin the same PR: theregenerated
output.brepreference assertssolidCount >= 1and the volume viaocctkit metrics, which is this repo's established test mechanism for recipes (nounit test framework exists here; see CLAUDE.md).
Notes for the reviewer
Scripts/recipe-check.sh(all 7 recipes),Scripts/policy-check.sh,Scripts/verb-check.sh.