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
7 changes: 5 additions & 2 deletions docs/SCRIPT_WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,12 @@ shape.subtracting(other) // Cut
shape.intersection(with: other) // Common volume
shape.split(by: tool) // Returns [Shape]

shape.filleted(radius: 1.0) // All edges
shape.filleted(radius: 1.0) // All edges; same seam caveat as chamfered below
shape.filleted(edges: [e1, e2], radius: 1.0)
shape.chamfered(distance: 0.5)
shape.chamfered(distance: 0.5) // ALL edges at once; returns nil on a full revolve,
// whose periodic faces each carry an unblendable seam.
// Select edges instead: chamferedWithFullHistory(distance:edges:).
// See okf/decisions/revolve-seams-cannot-be-chamfered.md

shape.shelled(thickness: 1.0) // Hollow out
shape.offset(by: 2.0) // Offset surface
Expand Down
2 changes: 2 additions & 0 deletions okf/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ that need standalone rationale.
faces a wire for you, `revolve` and `sweep` do not. Assert `solidCount >= 1`.
* [errexit is suppressed in `||` context](errexit-is-suppressed-in-or-context.md): a function
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.
63 changes: 63 additions & 0 deletions okf/decisions/revolve-seams-cannot-be-chamfered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
type: decision
title: A full revolve cannot be chamfered or filleted with the all-edge convenience call
description: Every periodic face a full revolve creates contributes a seam edge, and BRepFilletAPI cannot blend a seam because both adjacent faces are the same face. Select edges explicitly instead.
resource: https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/103
tags: [decision, occtswift, chamfer, fillet, revolve, topology]
timestamp: 2026-08-05
---

# Decision

Do not call `Shape.chamfered(distance:)` or the equivalent all-edge fillet on the result of a
full 360 degree revolve. It will return nil. Select the edges you actually want and use
`Shape.chamferedWithFullHistory(distance:edges:)` or `filleted(edges:radius:)`.

# Why

A full revolve produces **periodic** faces, and every periodic face carries a **seam edge** where
the surface closes on itself. `BRepFilletAPI_MakeChamfer` cannot blend a seam: a chamfer or fillet
is defined between two adjacent faces, and at a seam both "adjacent" faces are the same face.

The all-edge convenience call bundles every edge into a single operation, so one unblendable seam
fails the entire call. It returns nil rather than skipping the edge.

Measured on the pipe flange in
[#103](https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/103): 11 of 33 edges were seams,
one per periodic face (bore, OD, raised-face wall, and each of the eight bolt holes). Isolating
every edge and chamfering it alone showed each circular edge succeeds by itself, while all 11
seams fail alone at every distance tried, from 1 mm down to 0.001 mm. It is structural, not a
size problem.

# How to select instead

Prefer a predicate that states the real geometric condition. For circles concentric with the
revolve axis, `centerOfCurvature(at:)` pins the centre to the axis and `1 / curvature(at:)` gives
the true radius:

```swift
func axisCircleRadius(_ edge: Edge) -> Double? {
guard edge.isCircle, let b = edge.parameterBounds else { return nil }
let mid = (b.first + b.last) / 2
guard let c = edge.centerOfCurvature(at: mid),
let k = edge.curvature(at: mid), k > 1e-9,
abs(c.x) < 1e-6, abs(c.z) < 1e-6 else { return nil } // axis is Y here
return 1 / k
}
```

Sampling a single point and taking its distance from the axis answers the weaker question "does
some point on this edge lie at radius R", which is not the same test.

**Caveat on using `isCircle` to exclude seams.** The seam edges of a revolve are the profile edges
themselves. With a polygonal half-section they are lines, so `isCircle` happens to exclude them.
Put an arc or a fillet in the profile and the seam becomes circular, and that exclusion silently
stops working. `Edge.isSeam(on:)` is the direct test when a profile is not purely polygonal.

# Related

Reentrant edges are a second trap in the same area: chamfering one **adds** material rather than
breaking a corner. On the flange, chamfering the raised-face step base alone increased volume by
about 158 mm3. Note also that `convexEdges()` and `concaveEdges()` classified that shape in a way
that disagreed with what chamfering the edges actually did, so geometric selection was preferred
over the classifier there.
5 changes: 5 additions & 0 deletions okf/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
made every check it performed decorative.
* **Creation**: Recorded the wire-sweep-factories-are-not-symmetric decision.
* **Creation**: Recorded the errexit-is-suppressed-in-or-context decision.
* **Update**: Fixed recipe 03's chamfer, which had never applied (#103). `chamfered(distance:)`
bundles all edges into one operation and a full revolve always contributes unblendable seam
edges, so the call returned nil and a `??` fallback hid it. Now selects the OD and raised-face
rim explicitly.
* **Creation**: Recorded the revolve-seams-cannot-be-chamfered decision.

## 2026-08-04

Expand Down
35 changes: 30 additions & 5 deletions recipes/03-pipe-flange/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ never crosses the axis. The section is faced with `Shape.face(from:)`, and the *
what closes the result into a solid rather than a shell (see Gotchas). The bolt circle
is cut with `Shape.circularPatternCut`: a single cylindrical hole tool is built at the
bolt-circle radius (oriented along Y), then patterned `boltCount` times around the axis
and subtracted as one compound. Finally a small all-edge chamfer breaks the sharp
corners; it falls back to the un-chamfered body if the blend fails.
and subtracted as one compound. Finally a chamfer breaks the OD and the raised-face rim,
selected by geometry (a circle concentric with the revolve axis, at a known radius) rather
than by chamfering every edge; see Gotchas.

## OCCTSwift APIs used

Expand All @@ -38,7 +39,8 @@ corners; it falls back to the un-chamfered body if the blend fails.
- `Shape.revolved(axisOrigin:axisDirection:)`: surface of revolution (on the faced section)
- `Shape.cylinder(at:direction:radius:height:)`: the bolt-hole tool
- `Shape.circularPatternCut(tool:axisPoint:axisDirection:count:angle:)`: the bolt circle (OCCTSwift v1.3.1)
- `Shape.chamfered(distance:)`: edge break (optional)
- `Shape.edges(where:)`: select the OD and raised-face rim edges geometrically
- `Shape.chamferedWithFullHistory(distance:edges:)`: chamfer just those edges

## Gotchas

Expand All @@ -52,8 +54,31 @@ corners; it falls back to the un-chamfered body if the blend fails.
position. Revolving an XY profile about Z would sweep a flat disk, not a solid.
- Keep every profile radius `≥ boreRadius`: a profile that touches or crosses the axis
produces a degenerate or self-intersecting revolution.
- `chamfered(distance:)` blends **all** edges. On a flange with many bolt-hole edges this
can be slow or fail; the recipe guards it with `?? flange` so the body still emits.
- `Shape.chamfered(distance:)` blends **all** edges, and cannot build a chamfer at all on
this shape (OCCTSwiftScripts #103): the revolve produces a seam edge on every periodic
cylindrical face it creates, the bore, the OD, the raised-face wall, and each of the
eight bolt holes, and `BRepFilletAPI_MakeChamfer` cannot resolve a blend on a seam,
where both "adjacent" faces are really the same face. That failure held at every
distance tested, from 1 mm down to 0.001 mm, so it is not a size problem, and it is not
fixed by using a smaller chamfer. The recipe instead selects only the OD and the
raised-face rim edges with `Shape.edges(where:)` and chamfers those with
`Shape.chamferedWithFullHistory(distance:edges:)`, which avoids every seam. The step's
base (where the disk-front annulus meets the raised-face wall) is left alone: it is a
reentrant corner, so chamfering it adds material instead of breaking a corner, and
chamfering it together with the rim exhausts the 2mm-tall wall and fails outright.
A failed chamfer here is not swallowed: the recipe force-unwraps the result, so a
regression crashes loudly instead of silently shipping an un-chamfered body.
- **The selector tests concentricity, not "some point at radius R".** It uses
`centerOfCurvature(at:)` to require the circle's centre on the revolve axis, and
`1 / curvature(at:)` for its true radius. Sampling one point and measuring its distance from
the axis answers a weaker question, and would leave the exclusion of bolt-hole circles resting
on the y-coordinate gate rather than on the geometry itself.
- **Excluding seams by `isCircle` alone only works for a polygonal profile.** A revolve's seam
edges are the profile edges themselves, so with this straight-sided half-section they are
lines. Put an arc or a fillet in the profile and the seam becomes circular, at which point
`isCircle` stops excluding it. `Edge.isSeam(on:)` is the direct test if a variant ever needs
one. Here the concentricity requirement excludes seams regardless, since a seam lies in a
plane through the axis rather than on a circle around it.
- Use `circularPatternCut`, **not** `circularPattern`, for the bolt circle. `circularPattern`
patterns the whole *body*, applied to a holed flange it produces overlapping flange copies
(≈8× the volume) with the holes filled in. `circularPatternCut` patterns the *tool* and
Expand Down
52 changes: 48 additions & 4 deletions recipes/03-pipe-flange/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@
// with Shape.face(from:) and revolving the face closes the ends into a solid
// (OCCTSwiftScripts #100). The bolt circle is cut with Shape.circularPatternCut
// (OCCTSwift v1.3.1, #169), which patterns a single hole tool around the axis
// and subtracts the whole compound in one call. A small all-edge chamfer breaks
// sharp corners; it degrades gracefully if it fails.
// and subtracts the whole compound in one call. A chamfer breaks the OD and
// the raised-face rim; it targets those edges specifically because
// Shape.chamfered(distance:), which chamfers every edge, cannot handle this
// shape at all (OCCTSwiftScripts #103): every seam edge produced by the
// revolve (the closing edge of each periodic cylindrical face: the bore, the
// OD, the raised-face wall, and all eight bolt holes) fails to chamfer in
// isolation at any distance from 1 mm down to 0.001 mm, because
// BRepFilletAPI_MakeChamfer cannot resolve a blend on the seam of a periodic
// surface, where both "adjacent" faces are really the same face. Excluding the
// seams and chamfering only the OD and the raised-face rim's outer edge with
// Shape.chamferedWithFullHistory(distance:edges:) avoids every seam.
//
// Run: swift run occtkit run recipes/03-pipe-flange/main.swift --format brep

Expand All @@ -27,6 +36,11 @@ let raisedHeight: Double = 2 // raised-face height above the disk (mm)
let boltCircleRadius: Double = 60 // bolt-circle radius (mm)
let boltCount: Int = 8 // number of bolt holes
let boltRadius: Double = 7 // bolt-hole radius (mm)
// Must stay below both `raisedHeight` and `thickness`: the chamfer is cut into those
// walls, and one taller than the wall it breaks exhausts the material and fails. The
// failure is now a hard crash rather than a silent no-op (see the chamfer step below),
// so this is a real constraint, not a preference.
let chamferDistance: Double = 1 // edge-break size on the OD and raised-face rim (mm)

let ctx = ScriptContext(metadata: ManifestMetadata(
name: "Pipe flange",
Expand Down Expand Up @@ -60,8 +74,38 @@ flange = flange.circularPatternCut(tool: holeTool, axisPoint: .zero,
axisDirection: SIMD3(0, 1, 0),
count: boltCount, angle: 2 * .pi)!

// ── Break all sharp edges (optional; falls back if the chamfer fails) ─────────
flange = flange.chamfered(distance: 1.0) ?? flange
// ── Break the exposed sharp edges: the OD (front + back) and the raised face's
// outer rim. The step's base, where the disk-front annulus meets the raised-face
// wall, is a reentrant corner, not a sharp edge, so it is excluded: chamfering it
// would add material rather than break a corner, and doing that alongside the rim
// exhausts the 2mm-tall wall and fails outright. Select by geometry, not raw edge
// index, so the recipe still finds the right edges if the parameters change
// (same reasoning as recipe 01's concaveEdges() selection).
//
// The test is "is this a circle concentric with the revolve axis, of radius R", not
// "does some point on this edge lie at radius R". Sampling a single point would answer
// the weaker question: a bolt-hole circle spans radius 53 to 67 here, so it would alias
// onto `raisedRadius` the moment `boltCircleRadius` dropped to 55, leaving only the
// incidental y-gate to exclude it. `centerOfCurvature` pins the circle to the axis and
// `1 / curvature` gives its true radius, so the predicate says what it means.
func axisCircleRadius(_ edge: Edge) -> Double? {
guard edge.isCircle,
let bounds = edge.parameterBounds else { return nil }
let mid = (bounds.first + bounds.last) / 2
guard let centre = edge.centerOfCurvature(at: mid),
let k = edge.curvature(at: mid), k > 1e-9 else { return nil }
// Revolve axis is Y, so a concentric circle's centre sits on x = z = 0.
guard abs(centre.x) < 1e-6, abs(centre.z) < 1e-6 else { return nil }
return 1 / k
}
let chamferTargets = flange.edges { edge in
guard let r = axisCircleRadius(edge) else { return false }
if abs(r - outerRadius) < 1e-3 { return true } // OD, front + back
if abs(r - raisedRadius) < 1e-3 && edge.bounds.min.y > thickness + 1e-3 { return true } // raised-face rim
return false
}
flange = flange.chamferedWithFullHistory(distance: chamferDistance,
edges: chamferTargets.map(\.index))!.result

try ctx.add(flange, color: C.brass, name: "Pipe flange")

Expand Down
Loading
Loading