Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions okf/decisions/concave-edge-classifier-can-select-wrong-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
type: decision
title: Shape.concaveEdges() can return the wrong edges entirely, not just a threshold quirk
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
---

# Decision

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)`.
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 `??`.
3 changes: 3 additions & 0 deletions okf/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions okf/log.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
41 changes: 29 additions & 12 deletions recipes/01-mounting-bracket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
46 changes: 40 additions & 6 deletions recipes/01-mounting-bracket/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -37,7 +49,29 @@ 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).
// 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 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
&& 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
}
// 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.
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
// leg (drill along X). Start just outside the entry face and over-run the exit.
Expand Down
Loading
Loading