OCCTSwift Architecture Overview
Purpose
OCCTSwift provides a Swift-native interface to OpenCASCADE Technology (OCCT), a professional-grade B-Rep (Boundary Representation) solid modeling kernel. This enables iOS/macOS applications to perform CAD-level geometric operations that are impossible with SceneKit or RealityKit alone.
Why This Exists
Apple’s 3D frameworks (SceneKit, RealityKit) are designed for visualization and gaming, not CAD:
| Capability | SceneKit/RealityKit | OCCT |
|---|---|---|
| Boolean operations (CSG) | No | Yes |
| Sweep along curved path | No | Yes |
| NURBS curves/surfaces | No | Yes |
| STEP file export | No | Yes |
| 64-bit precision | No (32-bit only) | Yes |
| Filleting/chamfering | No | Yes |
OCCTSwift bridges this gap by using OCCT for geometry generation and Apple frameworks for visualization.
Layer Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Swift Application │
│ (RailwayCAD, or any CAD app) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ OCCTSwift (Swift API) │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Shape │ │ Wire │ │ Mesh │ │ Exporter │ │
│ │ │ │ │ │ │ │ │ │
│ │ - box() │ │ - rect() │ │ - verts │ │ - STL │ │
│ │ - sweep() │ │ - arc() │ │ - normals │ │ - STEP │ │
│ │ - union() │ │ - bspline │ │ - indices │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ Swift calls Obj-C
▼
┌─────────────────────────────────────────────────────────────────┐
│ OCCTBridge (Objective-C++) │
│ │
│ C-style functions with opaque handles: │
│ - OCCTShapeRef, OCCTWireRef, OCCTMeshRef │
│ - OCCTShapeCreateBox(), OCCTShapeUnion(), etc. │
│ │
│ Internally wraps OCCT C++ objects: │
│ - TopoDS_Shape, TopoDS_Wire, etc. │
└─────────────────────────────────────────────────────────────────┘
│
│ C++ calls
▼
┌─────────────────────────────────────────────────────────────────┐
│ OpenCASCADE Technology (C++) │
│ │
│ Modules used: │
│ - TKernel, TKMath (core utilities) │
│ - TKG2d, TKG3d (2D/3D geometry) │
│ - TKBRep, TKTopAlgo (B-Rep topology) │
│ - TKPrim (primitive shapes) │
│ - TKBO (boolean operations) │
│ - TKFillet, TKOffset (modifications) │
│ - TKMesh (triangulation) │
│ - TKSTEP, TKSTL (file export) │
└─────────────────────────────────────────────────────────────────┘
Design Decisions
1. C-Style Bridge (not Objective-C Classes)
We use C functions with opaque pointers rather than Objective-C classes because:
- Simpler memory model: Explicit
OCCTShapeRelease()avoids ARC/C++ destructor conflicts - No bridging overhead: Direct function calls, no message dispatch
- Easier to maintain: Clear ownership semantics
- Thread safety: No hidden retain/release cycles
// Bridge API uses opaque handles
typedef struct OCCTShape* OCCTShapeRef;
OCCTShapeRef OCCTShapeCreateBox(double w, double h, double d);
void OCCTShapeRelease(OCCTShapeRef shape);
2. Value Semantics in Swift (Internally Reference)
Swift Shape class wraps the handle and releases on deinit:
public final class Shape {
internal let handle: OCCTShapeRef
deinit {
OCCTShapeRelease(handle) // Clean C++ resources
}
}
Operations return new Shape instances (immutable pattern):
let box = Shape.box(width: 10, height: 5, depth: 3)!
let rounded = box.filleted(radius: 0.5) // New shape, box unchanged
3. SIMD Types for Vectors
We use Swift’s SIMD3<Double> for 3D points/vectors:
- Consistent with Apple frameworks
- Hardware-accelerated operations
- Clear semantics (vs tuple or array)
let offset = SIMD3<Double>(10, 0, 0)
let moved = shape.translated(by: offset)
4. Mesh as Separate Type
Mesh is distinct from Shape because:
- Different data: Shape is B-Rep topology; Mesh is triangles
- One-way conversion: Shape → Mesh (tessellation), not reversible
- Different uses: Mesh for display/export; Shape for operations
let shape = Shape.box(width: 10, height: 5, depth: 3)!
let mesh = shape.mesh(linearDeflection: 0.1)! // Tessellate
let geometry = mesh.sceneKitGeometry() // For display
5. Error Handling Strategy
OCCT operations can fail (e.g., self-intersecting boolean). Current strategy:
- Return empty/null shapes on failure (OCCT’s default behavior)
isValidproperty for validationhealed()for repair attempts
Every bridge function does this by wrapping its OCCT calls in try { } catch (...) { return <refusal>; }, which is what keeps a Standard_Failure from reaching the Swift boundary, where it would be uncatchable (#345). The cost is that the failure’s type and message were discarded with it, measured in #1161 as 3,652 catch-all handlers and none catching Standard_Failure.
The answer to that is a side channel, not a signature change: instrumented catch sites record the exception’s OCCT class and message, and Swift reads them back through OCCTDiagnostics. Every signature stays as it was, so nothing in the public surface has to become Result<T, OCCTError> for a caller to find out why a call refused. Coverage is per catch site and partial; see Bridge Diagnostics for how to derive it, and for the one thing the channel cannot see, an OS signal.
6. The Bridge Is Internal: No Frozen C ABI
OCCTBridge is a target, not a product. Package.swift declares exactly one product, .library(name: "OCCTSwift").
That does not make it unreachable, and this section said it did until #967 measured it. Both of these work from a consumer package that depends only on the OCCTSwift product:
- a Swift target writing
import OCCTBridge, becausepublicHeadersPath: "include"and the hand-writtenmodule.modulemapin it travel along the transitive include path; - a plain
.mwriting#import "OCCTBridge.h", with no.mmand no C++17.
Both compile, link and run, printing the same 23.999999999999996 for a 2x3x4 box. The evidence is Scripts/repro/967-consumer-compile/bridge-reach.txt, a captured copy of measure-bridge-reach.sh’s output, beside it.
Nothing about the contract changes, and being exact matters more than being reassuring. The C surface in OCCTBridge.h carries no compatibility promise of its own: renaming, merging, or deleting a bridge function is an internal refactor, and the only stability contract is the Swift API’s, governed by SEMVER.md. A consumer taking the C route is opting out of that, deliberately, rather than being prevented from it by the package.
Two practical consequences:
- A bridge function with no Swift caller is deleted, not retained. “Keep it exported for C consumers” is not a reason this repo recognises. Not because there can be none, which the section above measures to be false, but because a consumer on that surface is on an implementation detail with no compatibility promise and knew it. Retaining one instead preserves whatever contract it had at the moment it was orphaned, which is how #506 came to hold three arc-length functions that still returned
0on failure years after the Swift layer moved to a-1.0sentinel, and still extrapolated past a curve’s knots after #477 fixed that everywhere reachable. - The published
OCCTBridge.xcframeworkis a per-release artifact, not an ABI. TheOCCTSWIFT_BRIDGE_PREBUILT=1path (seePackage.swift) resolves a version-pinned URL, so the binary and the headers of any one release stay self-consistent. That is reproducibility within a release, not a promise across releases.
Memory Management
OCCT Handles
OCCT uses reference-counted handles (opencascade::handle<T>). Our bridge:
- Creates OCCT objects on the heap
- Wraps in our struct containing the handle
- Returns opaque pointer to Swift
- Swift class releases via bridge function on deinit
Swift Shape OCCTShape struct OCCT Handle
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ handle ──┼──────►│ TopoDS_Shape │──────►│ Actual data │
└──────────┘ └──────────────┘ └──────────────┘
│ ▲
│ deinit │
▼ │
OCCTShapeRelease() ─── delete struct ─── releases handle
Value views onto a handle
A handle is owned by exactly one reference type, whose deinit releases it. A value type has no deinit, so it cannot own one, and a struct that stores a raw OCCT*Ref is borrowing a handle whose lifetime it does not control. When such a value outlives the object it borrowed from, every read through it is a use-after-free.
The nineteen *Properties views on Curve2D, Curve3D and Surface were written that way, which made edge.curve3D?.circleProperties.radius crash: Edge.curve3D builds a fresh Curve3D per read, nothing holds it past the expression, and its deinit runs before .radius (#965).
They now conform to NativeHandleView (Sources/OCCTSwift/NativeHandleView.swift), which stores the owner and reads the handle through it:
public struct CircleProperties: Sendable, NativeHandleView {
let owner: Curve3D // strong, so the handle outlives it
public var radius: Double { OCCTCurve3DCircleRadius(handle) } // handle comes from `owner`
}
Scripts/check-borrowed-handles.py fails the build on any struct or enum in Sources/OCCTSwift that stores an OCCT*Ref, so a new view cannot reintroduce the borrow.
Reading a handle off a collection element or a temporary
handle is a raw pointer, and the compiler does not tie it to the wrapper that owns it. When the owner is a collection element or a call result, an optimised build may release it once the .handle load is its last use, which is before the C call that receives the pointer runs (#3130). A debug build extends every lifetime to scope end, so the same code passes there. Hold the owner for the call instead:
```swift no-typecheck: internal API, illustrative fragment edges[0].withHandle { OCCTEdgeSetSameParameter($0, false) } // not OCCTEdgeSetSameParameter(edges[0].handle, false)
`withHandle` is on every `NativeHandleOwner` (`Shape`, `Edge`, `Wire`, `Face` and the three
curve and surface parents), and `Scripts/check-borrowed-handle-temporaries.py` fails the build on a
`.handle` read off a subscript or a call result. An owner bound to its own `let` is not affected.
A split read, `let h = doc.handle` with the owner unused afterwards and `h` used by later calls,
did not reproduce either (variants SPL and SPS in the probe: correct 8/8 under `MallocScribble`),
so the gate does not flag it; that is a measurement, not a guarantee, and `withExtendedLifetime`
is the form that holds.
Seven public classes still expose a bare `public let handle`: `GeomPoint3D`, `GeomDirection`,
`GeomVector3D`, `Axis1Placement`, `Axis2Placement`, `Interval` and `IntervalSet`. Making them
`internal` is a source-breaking change, so each carries the rule on the property's documentation
instead; hold the owner with `withExtendedLifetime(x) { ... x.handle ... }`.
`Scripts/repro/3130-borrowed-handle/` reproduces and measures it.
Because a debug `swift test` cannot see this class, CI also runs the tests in an optimised build:
the `release-mode-tests` job runs `Scripts/release-mode-test.sh` (`subset` on a pull request, `full`
on `main`, on a manual dispatch and when a release is published) under `MallocScribble`. Run the
same thing locally with `Scripts/release-mode-test.sh subset`; the design and measurements are in
[`okf/policies/release-mode-tests.md`](../../okf/policies/release-mode-tests.md).
### A release the bridge never handed out is refused
A bridge release gives back a reference the matching create took. Giving back one it did not take
is not a no-op, so where the create hands out a raw `Standard_Transient*` the release checks an
address-keyed registry (`occtBorrowRegister` / `occtBorrowGiveBack` in `OCCTBridge_Internal.h`)
before touching the object. A null, a second release of the same pointer, or a pointer this bridge
never produced is declined and counted by `OCCTBridgeRefusedReleaseCount`.
The contract comes from OCCT, not from us. The kernel has no precedent to copy for handing a raw
`Standard_Transient*` to a foreign caller: `opencascade::handle`'s `BeginScope` and `EndScope` are
the only callers of `IncrementRefCounter` and `DecrementRefCounter` anywhere in the pinned tree, so
the handle is its whole borrow protocol. Two narrower questions it does answer decide the rest.
`EndScope` destroys only when `DecrementRefCounter()` returns zero, so the reference the bridge
holds keeps the storage alive for the whole borrow and a live object's address can never collide
with an entry already in the registry. And when a reference count says something impossible,
`Standard_Transient::This()` throws `Standard_ProgramError` in every build rather than tolerating
it. So `occtBorrowRegister` returns nothing and throws on a collision, which the create's existing
`catch` records as a diagnostic and reports as a null handle.
A release likewise does what `EndScope` does: act on the value `DecrementRefCounter()` returns and
call the virtual `Delete()`, never a second `GetRefCount()` read and never a bare `delete`. The
sources, and the answers weighed and rejected, are at the declaration.
`_Nonnull` on the declaration is not what stops the null: it is a promise the compiler does not
enforce, and section 6 above measures that a consumer can call these entry points directly.
Two defects are behind this, with the same cause and different endings. `TObj_Application` is a
process-wide singleton whose own function-local static `Handle` holds a permanent reference, so an
over-release freed nothing at the time and left the count one below the truth; the object died
later, in the next ordinary handle to fall out of scope, about 660 test lines from the call that
doomed it, as a SIGSEGV on Apple and an `indirect call type mismatch` on wasm
([#2897](https://github.com/SecondMouseAU/OCCTSwift/issues/2897)). `Message_Messenger` and
`Message_Report` have no such static, so there the first release already deletes and a second one
reads `GetRefCount()` out of freed memory and may delete the block again
([#2952](https://github.com/SecondMouseAU/OCCTSwift/issues/2952)).
The two read differently in one respect. The singleton's release refuses through a borrow *count*
and can expose `OCCTTObjApplicationRefCount`, because the object survives and its reference count
is readable across the operation. Nothing survives a correct messenger or report release, so there
the refusal itself is the only observable, which is what `OCCTBridgeRefusedReleaseCount` is for.
### Thread Safety
- OCCT is not thread-safe for shared objects
- Each `Shape` should be used from one thread
- For concurrent operations, create separate shapes
## File Organization
OCCTSwift/ ├── Package.swift # SPM configuration ├── README.md # Quick start guide ├── Sources/ │ ├── OCCTSwift/ # Swift public API │ │ ├── Shape.swift # 3D shapes, booleans, modifications │ │ ├── Wire.swift # 2D/3D wire profiles and paths │ │ ├── Face.swift # Face surface analysis │ │ ├── Edge.swift # Edge curve analysis │ │ ├── Curve2D.swift # 2D parametric curves (Geom2d) │ │ ├── Curve3D.swift # 3D parametric curves (Geom) │ │ ├── Surface.swift # Parametric surfaces (Geom) │ │ ├── Document.swift # XDE assembly + OCAF │ │ ├── Mesh.swift # Triangulated mesh data │ │ └── Exporter.swift # Multi-format export │ └── OCCTBridge/ # Objective-C++ bridge │ ├── include/ │ │ ├── OCCTBridge.h # Umbrella: handle typedefs, class index, imports (#395) │ │ └── OCCTBridge_
## Performance Considerations
### Expensive Operations
1. **Boolean operations**: O(n²) or worse; avoid in tight loops
2. **Meshing**: Depends on deflection; smaller = more triangles
3. **Sweep along complex path**: B-spline evaluation is costly
### Optimization Strategies
1. **Batch operations**: Build compound, then mesh once
2. **Cache meshes**: Don't re-mesh unchanged shapes
3. **Appropriate deflection**: 0.1mm for preview, 0.01mm for export
4. **Background threading**: Heavy operations off main thread
## Integration with SceneKit
Typical workflow:
```swift
// 1. Create geometry with OCCT
let rail = Shape.sweep(profile: railProfile, along: trackPath)
// 2. Tessellate for display
let mesh = rail.mesh(linearDeflection: 0.1)
// 3. Create SceneKit geometry
let geometry = mesh.sceneKitGeometry()
geometry.materials = [railMaterial]
// 4. Add to scene
let node = SCNNode(geometry: geometry)
scene.rootNode.addChildNode(node)
Extension Points
Adding New Shape Operations
- Add C function declaration to the matching
OCCTBridge_<Domain>.h(e.g.OCCTBridge_Modeling.h) - Implement in the matching
OCCTBridge_<Domain>.mmusing OCCT classes - Add Swift wrapper method to
Shape.swift - Add tests and documentation
Adding New Export Formats
- Add export function to
OCCTBridge_IO.h - Implement using OCCT’s TKDExxx modules
- Add Swift wrapper to
Exporter.swift
Adding New Wire/Curve Types
- Add creation function to the matching
OCCTBridge_<Domain>.h - Implement using OCCT’s Geom/BRepBuilderAPI classes
- Add Swift factory method to
Wire.swift