From b75ed784b4ddf8af92ef2148bde0d8caad3e305e Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:44:53 +1000 Subject: [PATCH 1/6] fix: select recipe 01's inside-corner fillet edge geometrically (#105) 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) --- recipes/01-mounting-bracket/README.md | 41 +- recipes/01-mounting-bracket/main.swift | 36 +- recipes/01-mounting-bracket/output.brep | 510 ++++++++++++++---------- recipes/01-mounting-bracket/output.png | Bin 18967 -> 18888 bytes recipes/README.md | 2 +- 5 files changed, 362 insertions(+), 227 deletions(-) diff --git a/recipes/01-mounting-bracket/README.md b/recipes/01-mounting-bracket/README.md index a32747d..abe2dec 100644 --- a/recipes/01-mounting-bracket/README.md +++ b/recipes/01-mounting-bracket/README.md @@ -18,28 +18,45 @@ An L-shaped mounting bracket with a rounded inside corner and four through-holes ## Algorithm The L cross-section is built as a closed polygon in the XY plane and extruded along Z into -a prism. The inside corner is then rounded by filleting the solid's **concave edge**, -located geometrically with `Shape.concaveEdges()`: no fragile edge-index bookkeeping, and -it tracks the corner as parameters change. The fillet is applied *before* drilling so -`concaveEdges()` returns only the reentrant corner. Finally four holes are cut: two through -the base leg (drilled along Y) and two through the upright leg (drilled along X). Each drill -starts 1 mm outside the entry face and over-runs the exit by 1 mm so the resulting cut faces -are clean and coincident-face artifacts are avoided. +a prism. The reentrant vertex at `(thickness, thickness)` extrudes to exactly one concave +edge: a straight line parallel to the extrusion axis. That is the edge this recipe fillets, +adding a rounded blend that fills part of the sharp inside corner. It is selected +**geometrically**, with `Shape.edges(where:)`, not with `Shape.concaveEdges()`: on this +shape that call returns a different pair of edges (see Gotchas), so the selector checks +directly for a line edge parallel to Z sitting at `(thickness, thickness)`. This tracks the +corner as parameters change without a fragile edge index. The fillet is applied *before* +drilling so the selector only ever sees the corner edge, not a drilled hole's rim. Finally +four holes are cut: two through the base leg (drilled along Y) and two through the upright +leg (drilled along X). Each drill starts 1 mm outside the entry face and over-runs the exit +by 1 mm so the resulting cut faces are clean and coincident-face artifacts are avoided. ## OCCTSwift APIs used - `Wire.polygon(_:closed:)`: L-shaped cross-section - `Shape.extrude(profile:direction:length:)`: profile → prism -- `Shape.concaveEdges()`: find the reentrant inside-corner edge (OCCTSwift v1.3.1) +- `Shape.edges(where:)`: select the inside-corner edge geometrically (OCCTSwift v1.2.1) - `Shape.filleted(edges:radius:)`: round that edge - `Shape.drilled(at:direction:radius:depth:)`: the four through-holes -- `Shape.volume`: sanity print +- `Shape.volume`: sanity print, and the check that the fillet actually ran ## Gotchas -- Fillet **before** drilling: `concaveEdges()` classifies *every* concave edge, and a - drilled hole's rim can read as concave. Filleting first keeps the selection to just the - inside corner. (Tighten `concaveEdges(angle:)` if a near-flat junction sneaks in.) +- **`Shape.concaveEdges()` picks the wrong edges on this shape.** It returns two edges + instead of one: the top-cap boundary segments at `z = width` where each wall meets the + end face (each running the leg length, not the extrusion width), rather than the true + reentrant edge. Those two are bounded by the 5 mm leg thickness, so a fillet on them fails + above roughly that radius; that mismatch is what let `filletRadius = 8` silently no-op + behind a `?? prism` fallback for as long as this recipe used `concaveEdges()` + (OCCTSwiftScripts #105). The true inside-corner edge has no such limit (its bound is + `legLength − thickness`, 45 mm here), which is why the same `filletRadius = 8` works fine + once the correct edge is selected. +- **A concave fillet adds material, it does not remove it.** Rounding the inside corner + fills part of the sharp reentrant point with a blend, so `bracket.volume` after the + fillet is *larger* than the prism's, by `filletRadius² · (1 − π/4) · width`. Do not expect + a volume decrease as evidence the fillet ran; check the increase against that formula + instead. +- Fillet **before** drilling: a drilled hole's rim sitting near the corner could otherwise + confuse a looser selector. Filleting first keeps the selection to just the inside corner. - Drill start points sit *outside* the part and `depth` over-runs the thickness so the hole punches fully through; drilling exactly on a face can leave a sliver. - The bracket is a single solid emitted as `body-0` (the reference `output.brep`). diff --git a/recipes/01-mounting-bracket/main.swift b/recipes/01-mounting-bracket/main.swift index 08d5027..49a4409 100644 --- a/recipes/01-mounting-bracket/main.swift +++ b/recipes/01-mounting-bracket/main.swift @@ -3,10 +3,21 @@ // Inputs: none (edit the parameter block below) // Outputs: one solid body: an L-bracket with a filleted inside corner and four // through-holes (two per leg). -// Notes: The inside corner is rounded by filleting the solid's concave edge, found -// geometrically with Shape.concaveEdges() (OCCTSwift v1.3.1, #171) rather than -// by a fragile edge index. Fillet before drilling so concaveEdges() returns only -// the reentrant corner. Holes are drilled through the leg thickness with a small +// Notes: The reentrant corner at (thickness, thickness) extrudes to exactly one +// concave edge, a straight line parallel to the extrusion axis. That is the +// edge this recipe fillets. It is NOT the edge `Shape.concaveEdges()` finds: +// on this shape that call returns two different edges instead, the top-cap +// boundary segments at z = width where each wall meets the end face, each +// running the leg length rather than the extrusion width (OCCTSwiftScripts +// #105). Those two are bounded by the 5 mm leg thickness and a fillet there +// fails above roughly that radius, which is why `filletRadius = 8` used to +// silently no-op behind a `?? prism` fallback. The true inside-corner edge has +// no such limit (its bound is legLength − thickness, 45 mm here), so this +// recipe selects it explicitly with `Shape.edges(where:)`: a line parallel to +// the extrusion axis positioned at (thickness, thickness), the same +// geometric-selection approach recipe 03 uses for the pipe flange (#103). +// Fillet before drilling so the selector only ever sees the corner edge, not a +// drilled hole's rim. Holes are drilled through the leg thickness with a small // overshoot so the cut faces stay clean. // // Run: swift run occtkit run recipes/01-mounting-bracket/main.swift --format brep @@ -18,7 +29,8 @@ import ScriptHarness let legLength: Double = 50 // length of each leg, measured from the heel (mm) let thickness: Double = 5 // material thickness of each leg (mm) let width: Double = 40 // bracket width (extrusion depth, mm) -let filletRadius: Double = 8 // inside-corner radius (mm) +let filletRadius: Double = 8 // inside-corner radius (mm); fits comfortably under the + // legLength − thickness = 45 mm geometric limit (see below) let holeRadius: Double = 3.5 // mounting-hole radius (mm) let ctx = ScriptContext(metadata: ManifestMetadata( @@ -37,7 +49,19 @@ let lProfile = Wire.polygon([ // ── Extrude to a solid prism, then round the concave (inside-corner) edge ───── let prism = Shape.extrude(profile: lProfile, direction: SIMD3(0, 0, 1), length: width)! -var bracket = prism.filleted(edges: prism.concaveEdges(), radius: filletRadius) ?? prism + +// The inside corner is the one straight edge parallel to the extrusion axis (Z) that +// sits at (thickness, thickness): select it geometrically rather than trusting +// concaveEdges(), which picks the wrong edges on this shape (see the header note). +let insideCorner = prism.edges { edge in + guard edge.isLine else { return false } + let b = edge.bounds + let runsFullWidth = abs((b.max.z - b.min.z) - width) < 1e-6 + && abs(b.max.x - b.min.x) < 1e-6 && abs(b.max.y - b.min.y) < 1e-6 + guard runsFullWidth else { return false } + return abs(b.min.x - thickness) < 1e-6 && abs(b.min.y - thickness) < 1e-6 +} +var bracket = prism.filleted(edges: insideCorner, radius: filletRadius)! // ── Four through-holes: two in the base leg (drill along Y), two in the upright // leg (drill along X). Start just outside the entry face and over-run the exit. diff --git a/recipes/01-mounting-bracket/output.brep b/recipes/01-mounting-bracket/output.brep index 44253a0..2a15ead 100644 --- a/recipes/01-mounting-bracket/output.brep +++ b/recipes/01-mounting-bracket/output.brep @@ -7,136 +7,163 @@ Locations 2 0 1 0 0 0 0 1 40 2 1 -1 0 -Curve2ds 40 -1 0 0 0 -1 -1 50 0 0 -1 -1 0 0 1 0 +Curve2ds 46 +1 0 -0 1 0 +1 0 0 0 1 +1 37 0 0 -1 +1 0 -0 0 -1 1 0 -40 1 0 -1 50 0 0 -1 -2 27.500000000000004 -28 0 -1 1 0 3.5 -1 0 1 1 0 -2 27.500000000000004 -12 0 -1 1 0 3.5 -1 0 1 1 0 -1 0 0 0 -1 +2 22.499999999999996 -28 0 -1 -1 -0 3.5 +1 0 6 1 0 +2 22.499999999999996 -12 0 -1 -1 -0 3.5 +1 0 6 1 0 +1 0 0 1 0 +2 -1.8750000000000018 -1.8750000000000018 0 -1 -1 -0 8 +1 0 0 1 0 1 0 0 1 0 -1 0 -40 1 0 -2 22.499999999999996 -28 0 -1 1 0 3.5 -1 0 1 1 0 -2 22.499999999999996 -12 0 -1 1 0 3.5 -1 0 1 1 0 -1 0 -0 1 0 1 0 -40 1 0 1 0 0 1 0 1 0 -40 1 0 -1 0 -0 0 -1 -2 22.499999999999996 -28 0 -1 -1 -0 3.5 -1 0 6 1 0 +1 1.5707963267948966 0 0 1 +1 7.9999999999999982 0 0 -1 +1 0 40 1 0 +2 -1.8750000000000018 -1.8750000000000018 0 -1 -1 -0 8 +1 50 0 0 -1 +1 0 -40 1 0 1 6.2831853071795862 -0 0 1 1 0 -0 0 1 -2 22.499999999999996 -12 0 -1 -1 -0 3.5 -1 0 6 1 0 +2 27.500000000000004 -28 0 -1 1 0 3.5 +1 0 1 1 0 1 6.2831853071795862 -0 0 1 1 0 -0 0 1 +2 27.500000000000004 -12 0 -1 1 0 3.5 +1 0 1 1 0 1 45 0 0 -1 2 22.500000000000004 -28 0 -1 -1 -0 3.5 1 0 6 1 0 -1 6.2831853071795862 -0 0 1 -1 0 -0 0 1 2 22.500000000000004 -12 0 -1 -1 -0 3.5 1 0 6 1 0 +1 0 0 0 -1 +1 50 0 0 -1 +1 0 0 0 -1 +2 22.499999999999996 -28 0 -1 1 0 3.5 +1 0 1 1 0 +2 22.499999999999996 -12 0 -1 1 0 3.5 +1 0 1 1 0 1 6.2831853071795862 -0 0 1 1 0 -0 0 1 -1 45 0 0 -1 -1 0 0 0 -1 -Curves 24 -1 0 0 0 0 0 1 -1 0 0 0 1 0 0 -1 50 0 0 0 0 1 -2 27.500000000000004 0 28 0 1 0 0 -0 1 1 0 -0 3.5 -2 27.500000000000004 0 12 0 1 0 0 -0 1 1 0 -0 3.5 -1 0 50 0 0 0 1 -1 0 50 0 0 -1 0 -2 0 27.500000000000004 28 1 0 0 -0 0 1 0 -1 0 3.5 -2 0 27.500000000000004 12 1 0 0 -0 0 1 0 -1 0 3.5 -1 50 0 0 0 1 0 +1 6.2831853071795862 -0 0 1 +1 0 -0 0 1 +Curves 27 1 50 5 0 -1 0 0 -1 5 5 0 0 1 0 -1 5 50 0 -1 0 0 +1 12.999999999999998 4.9999999999999982 0 0 0 1 1 50 5 0 0 0 1 2 27.500000000000004 5 28 0 1 0 0 -0 1 1 0 -0 3.5 -1 27.500000000000004 -1 31.5 0 1 0 2 27.500000000000004 5 12 0 1 0 0 -0 1 1 0 -0 3.5 +8 0 1.5707963267948966 +2 12.999999999999998 12.999999999999998 0 -0 -0 -1 0 -1 0 -1 0 0 8 +1 50 0 0 0 1 0 +1 5 5 0 0 1 0 +1 0 0 0 1 0 0 +1 5 50 0 -1 0 0 +1 0 50 0 0 -1 0 +1 4.9999999999999982 12.999999999999998 0 0 0 1 +8 0 1.5707963267948966 +2 12.999999999999998 12.999999999999998 40 -0 -0 -1 0 -1 0 -1 0 0 8 +1 50 0 0 0 0 1 +1 27.500000000000004 -1 31.5 0 1 0 +2 27.500000000000004 0 28 0 1 0 0 -0 1 1 0 -0 3.5 1 27.500000000000004 -1 15.5 0 1 0 +2 27.500000000000004 0 12 0 1 0 0 -0 1 1 0 -0 3.5 1 5 50 0 0 0 1 2 5 27.500000000000004 28 1 0 0 -0 0 1 0 -1 0 3.5 -1 -1 27.500000000000004 31.5 1 0 0 2 5 27.500000000000004 12 1 0 0 -0 0 1 0 -1 0 3.5 +1 0 0 0 0 0 1 +1 0 50 0 0 0 1 +2 0 27.500000000000004 28 1 0 0 -0 0 1 0 -1 0 3.5 +2 0 27.500000000000004 12 1 0 0 -0 0 1 0 -1 0 3.5 +1 -1 27.500000000000004 31.5 1 0 0 1 -1 27.500000000000004 15.5 1 0 0 -1 5 5 0 0 0 1 Polygon3D 0 PolygonOnTriangulations 0 -Surfaces 11 -1 0 0 0 0 1 0 1 0 0 0 0 -1 -1 0 50 0 1 0 -0 0 -1 0 0 -0 -1 +Surfaces 12 +1 50 5 0 0 -1 0 -1 0 0 -0 -0 -1 +2 12.999999999999998 12.999999999999998 0 0 0 1 0 -1 0 -1 -0 0 8 2 27.500000000000004 -1 28 0 1 0 0 -0 1 1 0 -0 3.5 2 27.500000000000004 -1 12 0 1 0 0 -0 1 1 0 -0 3.5 -2 -1 27.500000000000004 28 1 0 0 -0 0 1 0 -1 0 3.5 -2 -1 27.500000000000004 12 1 0 0 -0 0 1 0 -1 0 3.5 1 14.875 14.875 0 0 0 1 1 0 -0 -0 1 0 -1 50 5 0 0 -1 0 -1 0 0 -0 -0 -1 1 5 5 0 -1 0 0 0 1 0 0 0 -1 +1 0 0 0 0 1 0 1 0 0 0 0 -1 +1 0 50 0 1 0 -0 0 -1 0 0 -0 -1 1 50 0 0 -1 0 0 0 1 0 0 0 -1 +2 -1 27.500000000000004 28 1 0 0 -0 0 1 0 -1 0 3.5 +2 -1 27.500000000000004 12 1 0 0 -0 0 1 0 -1 0 3.5 1 5 50 0 0 -1 0 -1 0 0 -0 -0 -1 Triangulations 0 -TShapes 71 +TShapes 83 Ve 1e-07 -0 0 0 +13 5 0 +0 0 + +0101101 +* +Ve +1e-07 +50 5 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 1 0 0 40 -2 1 1 0 0 40 -2 2 2 0 0 40 +1 1 0 0 37 +2 1 1 0 0 37 0 0101000 -+71 0 -71 1 * +-83 0 +82 0 * Ve 1e-07 -50 0 0 +13 5 40 0 0 0101101 * Ed 1e-07 1 1 0 -1 2 0 0 50 -2 3 1 0 0 50 -2 4 1 2 0 50 +1 2 0 -0 40 +2 2 2 0 -0 40 +2 3 1 0 -0 40 +4 G1 2 0 1 0 0 0101000 -+71 0 -69 0 * ++83 0 -80 0 * Ed 1e-07 1 1 0 1 3 0 0 40 -2 5 1 0 0 40 +2 4 1 0 0 40 0 0101000 -+69 0 -69 1 * ++82 0 -82 1 * +Ed + 1e-07 1 1 0 +1 1 0 0 37 +2 5 1 2 0 37 +0 + +0101000 +-80 2 +82 0 * Wi 0101100 -+70 0 -68 0 +68 1 -67 0 * +-81 0 -79 0 +78 0 +77 1 * Ve 1e-07 -27.5 0 31.5 +27.5 5 31.5 0 0 0101101 @@ -149,14 +176,14 @@ Ed 0 0101100 -+65 0 -65 0 * ++75 0 -75 0 * Wi 0101100 --64 0 * ++74 0 * Ve 1e-07 -27.5 0 15.5 +27.5 5 15.5 0 0 0101101 @@ -169,251 +196,256 @@ Ed 0 0101100 -+62 0 -62 0 * ++72 0 -72 0 * Wi 0101100 --61 0 * ++71 0 * Fa 0 1e-07 1 0 0101000 -+66 0 +63 0 +60 0 * ++76 0 +73 0 +70 0 * Ve 1e-07 -0 50 0 +5 13 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 6 0 0 40 -2 10 2 0 0 40 -0 - -0101000 -+58 0 -58 1 * -Ed - 1e-07 1 1 0 -1 7 0 0 50 -2 11 2 0 0 50 -2 12 2 2 0 50 +1 6 0 0 1.5707963267949 +2 10 2 0 0 1.5707963267949 +2 11 5 0 0 1.5707963267949 0 0101000 -+58 0 -71 0 * -Wi - -0101100 -+57 0 -56 0 +56 1 -70 0 * ++83 0 -68 0 * Ve 1e-07 -0 27.5 31.5 +50 0 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 8 0 0 6.28318530717959 -2 13 2 0 0 6.28318530717959 -2 14 5 0 0 6.28318530717959 +1 7 0 0 5 0 -0101100 -+54 0 -54 0 * -Wi - -0101100 --53 0 * +0101000 ++66 0 -82 0 * Ve 1e-07 -0 27.5 15.5 +5 50 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 9 0 0 6.28318530717959 -2 15 2 0 0 6.28318530717959 -2 16 6 0 0 6.28318530717959 +1 8 0 8 45 +2 12 6 0 8 45 0 -0101100 -+51 0 -51 0 * -Wi - -0101100 --50 0 * -Fa -0 1e-07 2 0 - 0101000 -+55 0 +52 0 +49 0 * ++68 0 -64 0 * Ve 1e-07 -50 5 0 +0 0 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 10 0 0 5 +1 9 0 0 50 +2 13 7 0 0 50 +2 14 7 2 0 50 0 0101000 -+69 0 -47 0 * ++62 0 -66 0 * Ve 1e-07 -5 5 0 +0 50 0 0 0 0101101 * Ed 1e-07 1 1 0 -1 11 0 0 45 -2 17 8 0 0 45 -2 18 8 2 0 45 +1 10 0 0 5 +0 + +0101000 ++64 0 -60 0 * +Ed + 1e-07 1 1 0 +1 11 0 0 50 +2 15 8 0 0 50 +2 16 8 2 0 50 0 0101000 -+47 0 -45 0 * ++60 0 -62 0 * +Wi + +0101100 ++81 0 +67 0 +65 0 +63 0 +61 0 +59 0 +58 0 * +Fa +0 1e-07 5 0 + +0101000 ++57 0 * Ve 1e-07 -5 50 0 +5 13 40 0 0 0101101 * Ed 1e-07 1 1 0 -1 12 0 0 45 -2 19 9 0 0 45 -2 20 9 2 0 45 +1 12 0 -0 40 +2 17 2 0 -0 40 +2 18 6 0 -0 40 +4 G1 2 0 6 0 0 0101000 -+45 0 -43 0 * ++68 0 -55 0 * Ed 1e-07 1 1 0 -1 13 0 0 5 +1 13 0 0 1.5707963267949 +2 19 2 0 0 1.5707963267949 +2 20 5 1 0 1.5707963267949 0 0101000 -+43 0 -58 0 * ++80 0 -55 0 * Wi 0101100 -+68 0 +46 0 +44 0 +42 0 +41 0 +56 0 * ++67 0 -79 0 +54 0 -53 0 * Fa -0 1e-07 7 0 +0 0 2 0 0101000 -+40 0 * ++52 0 * Ed 1e-07 1 1 0 1 14 0 0 40 -2 21 8 0 0 40 +2 21 7 0 0 40 0 0101000 -+47 0 -47 1 * ++66 0 -66 1 * Wi 0101100 -+67 0 -38 0 -46 0 +46 1 * ++50 0 -78 0 -65 0 +65 1 * Fa -0 1e-07 10 0 +0 1e-07 9 0 + +0101000 ++49 0 * +Ed + 1e-07 1 1 0 +1 8 0 8 45 +2 22 6 2 8 45 +0 + +0101000 ++55 2 -64 0 * +Wi + +0101100 ++77 1 +53 0 +65 1 +47 1 +61 1 +59 1 +58 1 * +Fa +0 1e-07 5 0 0101000 -+37 0 * ++46 2 * Ve 1e-07 -27.5 5 31.5 +27.5 0 31.5 0 0 0101101 * Ed 1e-07 1 1 0 -1 15 0 0 6.28318530717959 -2 22 8 0 0 6.28318530717959 -2 23 3 0 0 6.28318530717959 +1 15 0 1 6 +3 23 24CN 3 0 1 6 0 -0101100 -+35 0 -35 0 * +0101000 ++44 0 -75 0 * Ed 1e-07 1 1 0 -1 16 0 1 6 -3 24 25CN 3 0 1 6 +1 16 0 0 6.28318530717959 +2 25 7 0 0 6.28318530717959 +2 26 3 0 0 6.28318530717959 0 -0101000 -+65 0 -35 0 * +0101100 ++44 0 -44 0 * Wi 0101100 --34 0 +33 0 +64 0 -33 0 * +-74 0 +43 0 +42 0 -43 0 * Fa 0 1e-07 3 0 0101000 -+32 0 * ++41 0 * Ve 1e-07 -27.5 5 15.5 +27.5 0 15.5 0 0 0101101 * Ed 1e-07 1 1 0 -1 17 0 0 6.28318530717959 -2 26 8 0 0 6.28318530717959 -2 27 4 0 0 6.28318530717959 +1 17 0 1 6 +3 27 28CN 4 0 1 6 0 -0101100 -+30 0 -30 0 * +0101000 ++39 0 -72 0 * Ed 1e-07 1 1 0 -1 18 0 1 6 -3 28 29CN 4 0 1 6 +1 18 0 0 6.28318530717959 +2 29 7 0 0 6.28318530717959 +2 30 4 0 0 6.28318530717959 0 -0101000 -+62 0 -30 0 * +0101100 ++39 0 -39 0 * Wi 0101100 --29 0 +28 0 +61 0 -28 0 * +-71 0 +38 0 +37 0 -38 0 * Fa 0 1e-07 4 0 0101000 -+27 0 * ++36 0 * Ed 1e-07 1 1 0 1 19 0 0 40 -2 30 9 0 0 40 +2 31 6 0 0 40 0 0101000 -+43 0 -43 1 * ++64 0 -64 1 * Wi 0101100 -+25 0 -57 0 -41 0 +41 1 * -Fa -0 1e-07 11 0 - -0101000 -+24 0 * +-63 0 +54 0 -34 0 +47 1 * Ve 1e-07 5 27.5 31.5 @@ -424,29 +456,16 @@ Ve Ed 1e-07 1 1 0 1 20 0 0 6.28318530717959 -2 31 9 0 0 6.28318530717959 -2 32 5 0 0 6.28318530717959 +2 32 6 0 0 6.28318530717959 +2 33 10 0 0 6.28318530717959 0 0101100 -+22 0 -22 0 * -Ed - 1e-07 1 1 0 -1 21 0 1 6 -3 33 34CN 5 0 1 6 -0 - -0101000 -+54 0 -22 0 * ++32 0 -32 0 * Wi 0101100 --21 0 +20 0 +53 0 -20 0 * -Fa -0 1e-07 5 0 - -0101000 -+19 0 * ++31 0 * Ve 1e-07 5 27.5 15.5 @@ -456,78 +475,153 @@ Ve * Ed 1e-07 1 1 0 -1 22 0 0 6.28318530717959 -2 35 9 0 0 6.28318530717959 -2 36 6 0 0 6.28318530717959 +1 21 0 0 6.28318530717959 +2 34 6 0 0 6.28318530717959 +2 35 11 0 0 6.28318530717959 0 0101100 -+17 0 -17 0 * ++29 0 -29 0 * +Wi + +0101100 ++28 0 * +Fa +0 1e-07 6 0 + +0101000 ++33 0 +30 0 +27 0 * Ed 1e-07 1 1 0 -1 23 0 1 6 -3 37 38CN 6 0 1 6 +1 22 0 0 40 +2 36 7 0 0 40 +2 37 8 0 0 40 0 0101000 -+51 0 -17 0 * ++62 0 -62 1 * +Wi + +0101100 ++25 0 -61 0 +61 1 -50 0 * +Wi + +0101100 +-42 0 * Wi 0101100 --16 0 +15 0 +50 0 -15 0 * +-37 0 * Fa -0 1e-07 6 0 +0 1e-07 7 0 0101000 -+14 0 * ++24 0 +23 0 +22 0 * Ed 1e-07 1 1 0 -1 24 0 0 40 -2 39 8 0 0 40 -2 40 9 0 0 40 +1 23 0 0 40 +2 38 8 0 0 40 0 0101000 -+45 0 -45 1 * ++60 0 -60 1 * Wi 0101100 -+38 0 -44 0 +44 1 -12 0 * ++34 0 -20 0 -59 0 +59 1 * +Fa +0 1e-07 12 0 + +0101000 ++19 0 * +Wi + +0101100 ++20 0 -58 0 +58 1 -25 0 * +Ve +1e-07 +0 27.5 31.5 +0 0 + +0101101 +* +Ed + 1e-07 1 1 0 +1 24 0 0 6.28318530717959 +2 39 8 0 0 6.28318530717959 +2 40 10 0 0 6.28318530717959 +0 + +0101100 ++16 0 -16 0 * Wi 0101100 -+34 0 * +-15 0 * +Ve +1e-07 +0 27.5 15.5 +0 0 + +0101101 +* +Ed + 1e-07 1 1 0 +1 25 0 0 6.28318530717959 +2 41 8 0 0 6.28318530717959 +2 42 11 0 0 6.28318530717959 +0 + +0101100 ++13 0 -13 0 * Wi 0101100 -+29 0 * +-12 0 * Fa 0 1e-07 8 0 0101000 -+11 0 +10 0 +9 0 * -Wi ++17 0 +14 0 +11 0 * +Ed + 1e-07 1 1 0 +1 26 0 1 6 +3 43 44CN 10 0 1 6 +0 -0101100 -+12 0 -42 0 +42 1 -25 0 * +0101000 ++16 0 -32 0 * Wi 0101100 -+21 0 * +-31 0 +9 0 +15 0 -9 0 * +Fa +0 1e-07 10 0 + +0101000 ++8 0 * +Ed + 1e-07 1 1 0 +1 27 0 1 6 +3 45 46CN 11 0 1 6 +0 + +0101000 ++13 0 -29 0 * Wi 0101100 -+16 0 * +-28 0 +6 0 +12 0 -6 0 * Fa -0 1e-07 9 0 +0 1e-07 11 0 0101000 -+7 0 +6 0 +5 0 * ++5 0 * Sh 0101100 --59 0 -48 0 -39 0 +39 1 -36 0 -31 0 -26 0 -23 0 -18 0 -13 0 --8 0 -4 0 * +-69 0 -56 0 +51 0 -48 0 +45 1 -40 0 -35 0 -26 0 -21 0 -18 0 +-10 0 -7 0 -4 0 * So 0100000 diff --git a/recipes/01-mounting-bracket/output.png b/recipes/01-mounting-bracket/output.png index 7151596252f3f6549a195ccebe943bb84459c412..c4ab90bc9ab774bab2f8d4339bcbf2ceb49f9177 100644 GIT binary patch literal 18888 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 Gwf_SskDFNl diff --git a/recipes/README.md b/recipes/README.md index bc587d7..f0931f4 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -9,7 +9,7 @@ tweak the parameters. | # | Recipe | Preview | What it shows | |---|--------|---------|---------------| -| 01 | [Mounting bracket](01-mounting-bracket/) | | sketch → extrude → `concaveEdges` fillet → drill | +| 01 | [Mounting bracket](01-mounting-bracket/) | | sketch → extrude → geometrically-selected inside-corner fillet → drill | | 02 | [Helical compression spring](02-helical-spring/) | | helix path + circular section → pipe sweep | | 03 | [Pipe flange](03-pipe-flange/) | | revolve + `circularPatternCut` bolt circle + chamfer | | 04 | [Involute spur gear](04-spur-gear/) | | involute tooth math → polygon → extrude → bore | From 81a21a9dee16a32f63ffbd6d981dbd0de79a7756 Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:45:03 +1000 Subject: [PATCH 2/6] fix: remove silent union fallback in recipe 06's fan blade 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) --- recipes/06-fan-blade/main.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/06-fan-blade/main.swift b/recipes/06-fan-blade/main.swift index 7b7a8a0..8f14930 100644 --- a/recipes/06-fan-blade/main.swift +++ b/recipes/06-fan-blade/main.swift @@ -69,7 +69,7 @@ guard var blade = Shape.loft(profiles: profiles, solid: true) else { fatalError( let hubR = rootChord * 0.55 let hub = Shape.cylinder(at: SIMD3(0, 0, -12), direction: SIMD3(0, 0, 1), radius: hubR, height: 14)! -blade = blade.union(hub) ?? blade +blade = blade.union(hub)! try ctx.add(blade, color: C.steel, name: "Fan blade") From 36d2217dd32c71b4a3186b8459bd36ee415456f0 Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:45:11 +1000 Subject: [PATCH 3/6] docs: record concave-edge-classifier-can-select-wrong-edges decision 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) --- ...-edge-classifier-can-select-wrong-edges.md | 99 +++++++++++++++++++ okf/decisions/index.md | 3 + okf/log.md | 14 +++ 3 files changed, 116 insertions(+) create mode 100644 okf/decisions/concave-edge-classifier-can-select-wrong-edges.md diff --git a/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md b/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md new file mode 100644 index 0000000..de9de65 --- /dev/null +++ b/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md @@ -0,0 +1,99 @@ +--- +type: decision +title: Shape.concaveEdges() can return the wrong edges entirely, not just a threshold quirk +description: On an extruded L-profile, concaveEdges() returns two top-cap boundary edges bounded by the leg thickness, not the one true reentrant edge that runs the extrusion length. Select fillet/chamfer edges geometrically instead of trusting the classifier. +resource: https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/105 +tags: [decision, occtswift, fillet, topology, recipes, edge-selection] +timestamp: 2026-08-05 +--- + +# Decision + +Do not trust `Shape.concaveEdges()` / `Shape.convexEdges()` to find the edge you mean to +fillet or chamfer, even when you can name the one edge you expect geometrically. Verify by +inspecting the returned edges' actual positions, and prefer `Shape.edges(where:)` with an +explicit geometric predicate once you know what you are looking for. + +# Why + +Recipe 01's L-bracket profile has exactly one reentrant vertex, at `(thickness, thickness)`. +Extruded along Z, that vertex produces exactly one concave edge: a line parallel to the +extrusion axis, running its full length. + +`prism.concaveEdges()` does not return that edge. It returns two different edges instead, +each length `legLength - thickness` (45 mm on the recipe's own parameters), lying in the +*end cap* plane (`z = width`) rather than running the extrusion axis: + +``` +edge[7] (the true reentrant edge) isLine, len=40 (=width), bounds x:[5,5] y:[5,5] z:[0,40] -> classified CONVEX +edge[9] (concaveEdges() result #1) isLine, len=45, bounds x:[5,50] y:[5,5] z:[40,40] -> classified CONCAVE +edge[12] (concaveEdges() result #2) isLine, len=45, bounds x:[5,5] y:[5,50] z:[40,40] -> classified CONCAVE +``` + +`edge[7]` is exactly where the reentrant vertex's profile predicts a concave edge should be. +It is classified **convex**. The two edges classified concave are boundary segments of the +top cap, at the far end of the part from where the reentrant corner's own edge runs. + +This is not a rounding or threshold problem (`concaveEdges(angle:)`'s default tolerance is +irrelevant here): the classifier is naming structurally different edges than the geometric +feature it is supposed to describe. + +## Consequence: the wrong edges have the wrong feasible radius + +The two edges `concaveEdges()` returns 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 filleting them +fails above roughly that radius. The true edge (`edge[7]`) is bounded only by +`legLength - thickness` (45 mm here), since it runs along the full unconstrained length of +each wall. A radius that is completely reasonable for the real feature (8 mm, comfortably +under 45 mm) reads as infeasible when applied to the wrong edges (over the 5 mm limit), and +`prism.filleted(edges:radius:)` returns `nil`. That nil, hidden behind a `?? prism` +fallback, is what recipe 01 shipped for as long as it trusted `concaveEdges()` +([OCCTSwiftScripts#105](https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/105)). + +## A concave fillet adds material; verify the sign, not just a nonzero delta + +Filleting the true reentrant edge **increases** the bracket's volume: it fills part of the +sharp inside corner with a rounded blend. `prism.filleted(edges: [edge7], radius: r).volume` +is `prism.volume + r² · (1 - π/4) · length`, not minus. Filleting the two edges +`concaveEdges()` actually returns *removes* material instead, because those two are +ordinary 90-degree corners from the fillet's point of view (the same +`r² · (1 - π/4) · length` formula, sign flipped): measured 176.42 mm3 removed at r=3 mm +across both wrong edges (2 × 45 mm), against 77.26 mm3 that a single correct fillet at r=3 +mm over the 40 mm extrusion length would add. A volume check that only asserts "some +material moved" would have passed on the wrong edges; the sign and the magnitude both have +to match the specific edge you intended. + +# How to select instead + +Once you know the geometric feature you want (here: a line parallel to the extrusion axis, +positioned at the profile's reentrant vertex), select it directly rather than filtering a +classifier's output: + +```swift +let insideCorner = prism.edges { edge in + guard edge.isLine else { return false } + let b = edge.bounds + let runsFullWidth = abs((b.max.z - b.min.z) - width) < 1e-6 + && abs(b.max.x - b.min.x) < 1e-6 && abs(b.max.y - b.min.y) < 1e-6 + guard runsFullWidth else { return false } + return abs(b.min.x - thickness) < 1e-6 && abs(b.min.y - thickness) < 1e-6 +} +``` + +# Related + +[Revolve seams cannot be chamfered](revolve-seams-cannot-be-chamfered.md) already noted, on +the pipe flange (#103/#104), that `convexEdges()` / `concaveEdges()` disagreed with what +chamfering the edges actually did volumetrically. This is the same finding on a second, +unrelated shape, which is why it is worth its own entry rather than a footnote: two +independent recipes have now hit a classifier/reality mismatch on `concaveEdges()` and +`convexEdges()`. Treat both as a hint, to be checked against the shape's actual geometry and +the operation's actual volumetric effect, not as ground truth. + +This is also the third instance of a broader pattern tracked across #100, #103, and #105: an +optional-returning geometry operation degrades silently via a `?? fallback`, the emitted +output stays volumetrically plausible, and the docs keep describing the intended behaviour. +A fourth, dormant instance (`recipes/06-fan-blade`'s `blade.union(hub) ?? blade`, which has +never actually failed) was fixed alongside #105 once the audit turned it up. Any new +`Shape`-returning call in a recipe that can return `nil` should fail loudly (force-unwrap or +an explicit `guard ... else { fatalError(...) }`), never degrade through `??`. diff --git a/okf/decisions/index.md b/okf/decisions/index.md index 0e4b881..dbba3b4 100644 --- a/okf/decisions/index.md +++ b/okf/decisions/index.md @@ -17,3 +17,6 @@ that need standalone rationale. called as `f || status=1` must `return 1` explicitly or its checks are decorative. * [Revolve seams cannot be chamfered](revolve-seams-cannot-be-chamfered.md): the all-edge `chamfered(distance:)` always fails on a full revolve; select edges explicitly. +* [Concave edge classifier can select wrong edges](concave-edge-classifier-can-select-wrong-edges.md): + `concaveEdges()` returned two unrelated edges instead of an L-bracket's one true reentrant + edge; verify a classifier's output geometrically before trusting it. diff --git a/okf/log.md b/okf/log.md index 4ef82e8..0624f5c 100644 --- a/okf/log.md +++ b/okf/log.md @@ -1,5 +1,19 @@ # Knowledge Log +## 2026-08-05 (fix/105-bracket-fillet) + +* **Update**: Fixed recipe 01's inside-corner fillet, which had never applied (#105). + `prism.concaveEdges()` returns the wrong two edges on this shape (top-cap boundary + segments bounded by the 5 mm leg thickness), not the one true reentrant edge (bounded + only by `legLength - thickness`, 45 mm). `filletRadius = 8` was infeasible for the wrong + edges and a `?? prism` fallback hid the resulting `nil`. Now selects the true edge + geometrically with `Shape.edges(where:)`; the same `filletRadius = 8` now applies, + adding 549.38 mm3 (matches the analytic `r² · (1 - pi/4) · width` prediction exactly). + Also fixed `recipes/06-fan-blade`'s `blade.union(hub) ?? blade`, the same pattern found + dormant during the `??`-fallback audit #105 requested (the union has never actually + failed; behaviour is unchanged). +* **Creation**: Recorded the concave-edge-classifier-can-select-wrong-edges decision. + ## 2026-08-05 * **Update**: Fixed two cookbook recipes that emitted shells while documenting themselves as From 39383ba819652a0baaa7689c507e83c7c40b6935 Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:34:39 +1000 Subject: [PATCH 4/6] docs: scope the concaveEdges workaround to OCCTSwift 1.x 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) --- ...-edge-classifier-can-select-wrong-edges.md | 28 +++++++++++++++++-- recipes/01-mounting-bracket/main.swift | 4 +++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md b/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md index de9de65..004ef7b 100644 --- a/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md +++ b/okf/decisions/concave-edge-classifier-can-select-wrong-edges.md @@ -1,7 +1,7 @@ --- type: decision title: Shape.concaveEdges() can return the wrong edges entirely, not just a threshold quirk -description: On an extruded L-profile, concaveEdges() returns two top-cap boundary edges bounded by the leg thickness, not the one true reentrant edge that runs the extrusion length. Select fillet/chamfer edges geometrically instead of trusting the classifier. +description: On OCCTSwift 1.x, concaveEdges() on an extruded L-profile returns two top-cap boundary edges rather than the one true reentrant edge, and classifies that edge convex. Fixed in the 2.0.0 line. Select fillet/chamfer edges geometrically while on 1.x. resource: https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/105 tags: [decision, occtswift, fillet, topology, recipes, edge-selection] timestamp: 2026-08-05 @@ -9,11 +9,33 @@ timestamp: 2026-08-05 # Decision -Do not trust `Shape.concaveEdges()` / `Shape.convexEdges()` to find the edge you mean to -fillet or chamfer, even when you can name the one edge you expect geometrically. Verify by +On the OCCTSwift 1.x line, do not trust `Shape.concaveEdges()` / `Shape.convexEdges()` to +find the edge you mean to fillet or chamfer, even when you can name the one edge you expect +geometrically. Verify by inspecting the returned edges' actual positions, and prefer `Shape.edges(where:)` with an explicit geometric predicate once you know what you are looking for. +# Version scope: 1.x only, fixed in 2.0.0 + +This is an **OCCTSwift 1.x defect**. It is already 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 MISMATCH +2.0.0-kernel.1 L-prism concave=1 (expected 1) insideCorner inConcave=true OK +``` + +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 the 1.x line, so it is carried by the 2.0.0 refactor. +Raised upstream as [OCCTSwift#695](https://github.com/SecondMouseAU/OCCTSwift/issues/695). + +**So the geometric selection below is a 1.x workaround with a known end date.** When this repo +moves to the 2.0.0 line, `concaveEdges()` becomes usable for this case again, and recipe 01 could +return to it. That would be a legitimate simplification rather than a regression. Re-run the +repro above before relying on it, rather than assuming the migration carried the fix. + # Why Recipe 01's L-bracket profile has exactly one reentrant vertex, at `(thickness, thickness)`. diff --git a/recipes/01-mounting-bracket/main.swift b/recipes/01-mounting-bracket/main.swift index 49a4409..e15afd9 100644 --- a/recipes/01-mounting-bracket/main.swift +++ b/recipes/01-mounting-bracket/main.swift @@ -53,6 +53,10 @@ let prism = Shape.extrude(profile: lProfile, direction: SIMD3(0, 0, 1), length: // The inside corner is the one straight edge parallel to the extrusion axis (Z) that // sits at (thickness, thickness): select it geometrically rather than trusting // concaveEdges(), which picks the wrong edges on this shape (see the header note). +// That classifier defect is OCCTSwift 1.x only: it is fixed in the 2.0.0 line +// (verified on v2.0.0-kernel.1, upstream OCCTSwift#695). This geometric selection is +// therefore a 1.x workaround, and this recipe could return to concaveEdges() once the +// package moves to 2.0.0. Re-run the check in the OKF entry before doing so. let insideCorner = prism.edges { edge in guard edge.isLine else { return false } let b = edge.bounds From 490b9588865e3dc0d4470ac1d610544f6d5d798b Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:29:46 +1000 Subject: [PATCH 5/6] fix: guard the inside-corner selector, and pluralise its name (#106 review) 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) --- recipes/01-mounting-bracket/main.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/recipes/01-mounting-bracket/main.swift b/recipes/01-mounting-bracket/main.swift index e15afd9..f5b7594 100644 --- a/recipes/01-mounting-bracket/main.swift +++ b/recipes/01-mounting-bracket/main.swift @@ -57,7 +57,7 @@ let prism = Shape.extrude(profile: lProfile, direction: SIMD3(0, 0, 1), length: // (verified on v2.0.0-kernel.1, upstream OCCTSwift#695). This geometric selection is // therefore a 1.x workaround, and this recipe could return to concaveEdges() once the // package moves to 2.0.0. Re-run the check in the OKF entry before doing so. -let insideCorner = prism.edges { edge in +let insideCornerEdges = prism.edges { edge in guard edge.isLine else { return false } let b = edge.bounds let runsFullWidth = abs((b.max.z - b.min.z) - width) < 1e-6 @@ -65,7 +65,13 @@ let insideCorner = prism.edges { edge in guard runsFullWidth else { return false } return abs(b.min.x - thickness) < 1e-6 && abs(b.min.y - thickness) < 1e-6 } -var bracket = prism.filleted(edges: insideCorner, radius: filletRadius)! +// Guard the selector separately from the fillet. `filleted(edges: [], radius:)` does +// return nil today (checked on both 1.17.0 and 2.0.0-kernel.1), so the force-unwrap +// below would catch an empty match, but only as an anonymous nil-unwrap crash. This +// names the actual fault, and avoids depending on undocumented nil-on-empty behaviour +// if a parameter change or an upstream tweak ever silently breaks the predicate. +precondition(!insideCornerEdges.isEmpty, "inside-corner edge selector matched nothing") +var bracket = prism.filleted(edges: insideCornerEdges, radius: filletRadius)! // ── Four through-holes: two in the base leg (drill along Y), two in the upright // leg (drill along X). Start just outside the entry face and over-run the exit. From cac27c37737dc931a66fa6d0e401c943346ed6da Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:00:10 +1000 Subject: [PATCH 6/6] style: use guard/fatalError for the selector check, matching the recipes 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) --- recipes/01-mounting-bracket/main.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/01-mounting-bracket/main.swift b/recipes/01-mounting-bracket/main.swift index f5b7594..ebf3cbc 100644 --- a/recipes/01-mounting-bracket/main.swift +++ b/recipes/01-mounting-bracket/main.swift @@ -70,7 +70,7 @@ let insideCornerEdges = prism.edges { edge in // below would catch an empty match, but only as an anonymous nil-unwrap crash. This // names the actual fault, and avoids depending on undocumented nil-on-empty behaviour // if a parameter change or an upstream tweak ever silently breaks the predicate. -precondition(!insideCornerEdges.isEmpty, "inside-corner edge selector matched nothing") +guard !insideCornerEdges.isEmpty else { fatalError("inside-corner edge selector matched nothing") } var bracket = prism.filleted(edges: insideCornerEdges, radius: filletRadius)! // ── Four through-holes: two in the base leg (drill along Y), two in the upright