gsdali/occtswiftviewport
A reusable Metal-based 3D viewport library for CAD applications on iOS and macOS. Designed as a rendering companion to OCCTSwift — the two libraries are fully independent, with your app bridging geometry and display.
Features
- Metal renderer — Blinn-Phong shading, 3-light setup, shadow maps, environment mapping
- Camera system — Arcball, turntable, and first-person rotation with inertia and animation
- ViewCube — Interactive orientation widget with 26 clickable regions
- GPU picking — TBDR imageblock-based pick ID buffer for body and face selection
- Display modes — Wireframe, shaded, shaded-with-edges
- Lighting presets —
.threePoint,.studio,.architectural,.flat - Gesture presets —
.default,.blender,.fusion360 - Clip planes — Section views with configurable cut planes
- Measurements — Distance, angle, and radius overlays
- Grid and axes — Adaptive instanced dot grid, RGB axis lines
- Shadow maps — Directional light depth pass
- Swift 6 ready — Full
Sendableconformance,@MainActorisolation - Cross-platform — iOS 18+ and macOS 15+ from shared source
Requirements
- iOS 18+ / macOS 15+
- Swift 6.0+
- Xcode 16+
Installation
This package provides two library products:
| Product | Dependencies | Purpose | |---------|-------------|---------| | OCCTSwiftViewport | None | Pure Metal viewport — geometry-agnostic rendering | | OCCTSwiftTools | OCCTSwift + OCCTSwiftViewport | Shape→ViewportBody bridge, CAD file I/O, export |
// Package.swift
dependencies: [
.package(path: "../OCCTSwiftViewport"),
// or: .package(url: "https://github.com/gsdali/OCCTSwiftViewport.git", from: "1.0.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
// Pick what you need:
.product(name: "OCCTSwiftViewport", package: "OCCTSwiftViewport"), // viewport only
.product(name: "OCCTSwiftTools", package: "OCCTSwiftViewport"), // + OCCTSwift bridge
]
)
]Quick Start
import SwiftUI
import OCCTSwiftViewport
struct ContentView: View {
@StateObject private var controller = ViewportController()
@State private var bodies: [ViewportBody] = [
.box(size: 1, color: .gray)
]
var body: some View {
MetalViewportView(controller: controller, bodies: $bodies)
}
}Using with OCCTSwift
OCCTSwiftTools (recommended)
OCCTSwiftTools provides ready-made converters for all OCCTSwift geometry types:
import OCCTSwiftTools
// Load a CAD file (STEP, STL, OBJ, BREP)
let result = try await CADFileLoader.load(from: stepFileURL, format: .step)
// result.bodies: [ViewportBody], result.shapes: [Shape], result.metadata: [String: CADBodyMetadata]
// Convert a Shape directly
let shape = Shape.box(width: 10, height: 5, depth: 3)!
let (body, metadata) = CADFileLoader.shapeToBodyAndMetadata(shape, id: "box", color: SIMD4(0.7, 0.7, 0.75, 1))
// Convert wires, curves, surfaces
let wireBody = WireConverter.wireToBody(wire, id: "sketch", color: SIMD4(1, 1, 0, 1))
let curveBody = CurveConverter.curve3DToBody(helix, id: "helix", color: SIMD4(0, 0.8, 1, 1))
let curve2DBody = CurveConverter.curve2DToBody(circle, id: "circle", color: SIMD4(1, 0, 0, 1))
let gridBodies = SurfaceConverter.surfaceToGridBodies(surface, idPrefix: "surf",
uColor: SIMD4(0.8, 0.3, 0.3, 1), vColor: SIMD4(0.3, 0.3, 0.8, 1))
// Utility: position markers, offset bodies
let marker = BodyUtilities.makeMarkerSphere(at: SIMD3(5, 0, 0), radius: 0.3, id: "pt", color: .one)
let shifted = BodyUtilities.offsetBody(body!, dx: 10)
// Export shapes
try await ExportManager.export(shapes: [shape], format: .step, to: outputURL)OCCTSwiftTools API Reference
| Type | Purpose | |------|---------| | CADFileLoader | Shape→ViewportBody conversion, STEP/STL/OBJ/BREP loading, manifest loading | | CADBodyMetadata | Face/edge/vertex indices for sub-body selection | | CADLoadResult | Aggregated load result (bodies + metadata + shapes + GD&T) | | CADFileFormat | .step, .stl, .obj, .brep | | ExportManager | Shape export to OBJ/PLY/STEP/BREP | | ExportFormat | .obj, .ply, .step, .brep | | WireConverter | Wire→edge-only ViewportBody | | CurveConverter | Curve2D/Curve3D→sampled edge ViewportBody | | SurfaceConverter | Surface→UV isoparametric grid bodies | | BodyUtilities | makeMarkerSphere(), offsetBody() | | ScriptManifest | JSON manifest types for script harness integration |
Manual Bridging (viewport only, no OCCTSwift dependency)
If you only need OCCTSwiftViewport without the OCCTSwift dependency, construct ViewportBody directly:
import OCCTSwiftViewport
let body = ViewportBody(
id: "my-part",
vertexData: vertexData, // Interleaved [px, py, pz, nx, ny, nz, ...]
indices: indices, // Triangle indices
edges: edges, // Wireframe polylines [[SIMD3<Float>]]
color: SIMD4<Float>(0.7, 0.7, 0.75, 1.0)
)Camera Control
// Rotation styles
controller.cameraController.rotationStyle = .turntable // Z-up locked (CAD default)
controller.cameraController.rotationStyle = .arcball // Free rotation
// Standard views
controller.goToStandardView(.top)
controller.goToStandardView(.front)
controller.goToStandardView(.isometricFrontRight)
// Focus on geometry
controller.focusOnBounds() // Fit all bodies in view
controller.focusOn(point: SIMD3<Float>(0, 0, 0), distance: 10)
// Reset
controller.reset()Gesture Configuration
// Use a preset
let config = ViewportConfiguration(
gestureConfiguration: .blender // or .fusion360, .default
)
// Or customize
let config = ViewportConfiguration(
gestureConfiguration: GestureConfiguration(
singleFingerDrag: .orbit,
twoFingerDrag: .pan,
pinchGesture: .zoom,
enableInertia: true,
dampingFactor: 0.1
)
)
let controller = ViewportController(configuration: config)Display Modes and Lighting
// Display modes
controller.displayMode = .shaded
controller.displayMode = .wireframe
controller.displayMode = .shadedWithEdges
// Lighting presets
let config = ViewportConfiguration(
lightingConfiguration: .threePoint // Key, fill, and back lights
)
// Also: .studio, .architectural, .flatPerformance & Scaling
Large, many-body scenes (thousands of ViewportBody objects, hundreds of thousands of triangles) can stutter on mobile. The cost has two main sources — per-frame whole-scene passes and per-body CPU overhead — and there are two levers for each:
// 1. Use the performance preset: disables the expensive per-frame passes
// (directional shadow map, SSAO, MSAA, silhouettes).
let controller = ViewportController(configuration: .performance)
// or toggle the individual levers on any configuration:
// lightingConfiguration.shadowsEnabled = false
// lightingConfiguration.enableSSAO = false
// msaaSampleCount = 1
// enableSilhouettes = falseBatch many small static bodies. Per-body overhead (buffer-cache lookups, uniform updates, encoder state changes) scales with body count, independent of triangle count — thousands of tiny bodies are far more expensive than a handful of large ones with the same total geometry. If your source produces one body per component (e.g. per mesh connected-component), merge components that share a material into a single ViewportBody before handing them to the viewport. You can keep sub-component picking by maintaining a triangle→component map and using ViewportBody.faceIndices.
Rules of thumb
- Aim for low hundreds of bodies, not thousands. Merging ~thousands of
bodies down to dozens is typically the single biggest win.
- For dense scenes on iPhone/iPad, start from
.performance. - Triangle count matters less than body count for CPU cost; the GPU handles
a few hundred thousand triangles comfortably once the per-body overhead is contained.
Renderer-side scaling work (frustum culling, reduced per-body overhead) is tracked in issue #42.
Smooth Round Geometry
Round surfaces (cylinder / cone silhouettes, fillets) can look faceted if the mesh you supply is coarsely tessellated. The renderer has screen-space-adaptive PN-triangle (Phong) tessellation that smooths curved silhouettes on the GPU, refining by projected size each frame — so a cylinder stays round at any zoom without you guessing a tessellation density. It's off in the default .standard quality; turn it on with the CAD-quality preset (or the quality knobs directly):
// Smooth curved surfaces, adaptive to zoom:
let controller = ViewportController(configuration: .cadHighQuality)
// or set the knobs on any configuration:
// renderingQuality = .enhanced // enables GPU PN-triangle tessellation
// adaptiveTessellation = true // refine by projected size (vs fixed)
// tessellationMaxFactor = 48 // upper bound on subdivision (1...64)What controls smoothness
- Surfaces:
renderingQuality = .enhanced(or.maximum) enables adaptive
Phong tessellation. Requires an Apple3+ GPU (falls back gracefully). Smooth silhouettes need reasonable per-vertex normals — OCCT meshes provide these. For flat / per-face-normalled meshes (e.g. STL), set autoSmoothNormals = true (on by default in .cadHighQuality) and the renderer applies crease-aware smoothing when building each body, so hard edges stay sharp (tune via normalSmoothingCreaseAngle). You can also pre-smooth yourself with NormalSmoothing.smoothNormals(vertexData:indices:creaseAngle:).
- Feature edges: edges are drawn as polylines, so a circular edge is only as
smooth as its sampling. Sample BREP edges finely (e.g. 64+ points per circle) until analytic arc edges land — see issue #48.
- Tradeoff: tessellation adds GPU work — prefer
.performancefor very large
many-body scenes (see Performance & Scaling above).
GPU Picking and Selection
// Pick at a screen coordinate
if let hit = controller.pick(at: screenPoint) {
print("Hit body: \(hit.bodyIndex), face: \(hit.faceIndex)")
}
// CPU-side raycasting for more control
let ray = ProjectionUtility.ray(from: screenPoint, viewport: size,
camera: controller.cameraState)
let hits = SceneRaycast.cast(ray: ray, bodies: bodies)Clip Planes
// Add a section cut
let clip = ClipPlane(
normal: SIMD3<Float>(0, 1, 0), // Cut along Y axis
distance: 0.0
)
controller.clipPlanes = [clip]Measurements
// Distance between two points
let measurement = DistanceMeasurement(
from: SIMD3<Float>(0, 0, 0),
to: SIMD3<Float>(10, 0, 0)
)
controller.measurements = [.distance(measurement)]
// Overlay renders leader lines and labels automatically
MeasurementOverlay(controller: controller)ViewCube
// The ViewCube is built into MetalViewportView
// Click faces → orthographic views (Top, Front, Right, etc.)
// Click corners → isometric views
// Click edges → intermediate views
// Or use ViewCubeView standalone
ViewCubeView(controller: controller)
.frame(width: 100, height: 100)Script Harness (CadQuery/OpenSCAD-style workflow)
A companion package OCCTSwiftScripts provides a scripting workflow: edit Swift code, run it, and see geometry live in the viewport.
main.swift (full OCCTSwift API)
│ swift run Script (~1-2s)
▼
iCloud Drive / OCCTSwiftScripts / output /
├─ body-0.brep
├─ body-1.brep
├─ manifest.json ← triggers viewport reload
└─ output.step ← for external tools
│
▼ iCloud sync (Mac ↔ iPhone)
│
Demo app (ScriptWatcher auto-loads new geometry)Setup
git clone https://github.com/gsdali/OCCTSwiftScripts.git
cd OCCTSwiftScripts
swift build # First build ~30s (pulls OCCTSwift)Write a script
// Sources/Script/main.swift
import OCCTSwift
import ScriptHarness
let ctx = ScriptContext(metadata: ManifestMetadata(
name: "Bracket Assembly",
revision: "3",
source: "Customer drawing D-1234"
))
let C = ScriptContext.Colors.self
// Build geometry using the full OCCTSwift API
let base = Shape.box(width: 50, height: 10, depth: 30)!
let hole = Shape.cylinder(radius: 5, height: 12)!
.translated(by: SIMD3(20, -1, 15))
let bracket = base.subtracting(hole)!
.filleted(radius: 1.5)!
try ctx.add(bracket, id: "bracket", color: C.steel, name: "Main bracket")
try ctx.emit(description: "Bracket with mounting hole")swift run Script # Output appears in viewport automaticallyView on iPhone
- Scripts write to iCloud Drive (
~/Library/Mobile Documents/com~apple~CloudDocs/OCCTSwiftScripts/output/) - iCloud syncs BREP + manifest to iPhone
- Demo app → Settings → Script Watcher → toggle on
- Gallery view shows available scripts with metadata
Promoting Scripts to Libraries
Once geometry code is validated in a script, extract it into a shared library that both scripts and apps import:
// Sources/BracketLib/Bracket.swift
public struct BracketResult { ... }
public enum Bracket {
public static func build(holeRadius: Double = 5) -> BracketResult { ... }
}
// Sources/Script/main.swift — now a thin wrapper
let result = Bracket.build(holeRadius: 6)
try ctx.add(result.shape, id: "bracket", color: C.steel)
try ctx.emit(description: result.metadata.name)
// YourApp/ContentView.swift — same build() function
let result = Bracket.build(holeRadius: 6)
let body = convertToViewportBody(result.shape)See docs/SCRIPT_WORKFLOW.md for the full workflow guide including HLR 2D views, dimension annotations, and library extraction patterns.
Architecture
MetalViewportView (SwiftUI entry point)
└─ MTKView via UIViewRepresentable / NSViewRepresentable
└─ gesture handlers (iOS: drag/pinch/rotation/tap, macOS: mouse/scroll)
ViewportController (@MainActor, ObservableObject — central hub)
├─ CameraController (orbit/pan/zoom with inertia + SLERP animation)
│ └─ CameraState (immutable value — rotation, distance, pivot, projection)
├─ PivotStrategy (dynamic orbit center based on zoom level)
└─ ViewportRenderer (MTKViewDelegate — Metal render loop)
├─ Shaded pipeline (3-light Blinn-Phong + hemisphere ambient + Fresnel rim)
├─ Wireframe pipeline (contrast-adaptive edges, depth-biased)
├─ Grid pipeline (adaptive instanced dots)
├─ Axes pipeline (RGB colored lines)
├─ Shadow map (ShadowMapManager — directional light depth pass)
├─ Environment map (EnvironmentMapManager — image-based lighting)
└─ Pick ID texture (R32Uint second color attachment, TBDR imageblock)Key Types
| Type | Role | |------|------| | MetalViewportView | SwiftUI view wrapping MTKView | | ViewportController | Central observable state hub | | ViewportBody | Geometry container (vertices + edges + color) | | CameraState | Immutable camera orientation value | | CameraController | Input handling + animation | | ViewportConfiguration | Gesture + lighting + display settings | | GestureConfiguration | Input mapping presets | | LightingConfiguration | Light position/color presets | | ClipPlane | Section cut plane | | SceneRaycast | CPU-side ray intersection | | ProjectionUtility | Screen ↔ world coordinate conversion | | PickResult | GPU pick hit info | | ViewCubeView | Orientation widget |
Geometry Input
ViewportBody is geometry-source agnostic. It doesn't know about OCCT, BREP, or any CAD kernel:
ViewportBody(
id: String, // Unique identifier
vertexData: [Float], // Interleaved [px,py,pz, nx,ny,nz, ...]
indices: [UInt32], // Triangle indices
edges: [[SIMD3<Float>]], // Wireframe polylines
color: SIMD4<Float>, // RGBA color
faceIndices: [Int32]? = nil // Optional: maps triangles → face IDs
)Swift 6 Concurrency
- All mutable state holders are
@MainActor:ViewportController,CameraController,ViewportRenderer - All value types are
Sendable:CameraState,ViewportBody,BoundingBox,Ray, configurations - No
DispatchQueueusage — clean actor isolation throughout
Demo App
The OCCTSwiftMetalDemo app exercises 60+ OCCTSwift features through interactive galleries:
| Gallery | Features | Count | |---------|----------|-------| | Curves 2D | Showcases, intersections, hatching, tangent circles | 4 | | Curves 3D | Helix, spirals, curvature combs, BSpline fitting | 4 | | Surfaces | Analytic, swept, freeform, pipe, iso-curves | 5 | | Sweeps | Variable-section pipes with LawFunction | 4 | | Projections | Curve/point projection onto surfaces | 4 | | Plates | Plate surfaces, NLPlate deformation | 3 | | Medial Axis | Voronoi skeleton, wall thickness map | 4 | | Naming | TNaming topological history tracking | 4 | | Annotations | Dimensions, labels, point clouds | 4 | | OCCT 8 Features | v0.28–v0.93 comprehensive demos | 59 |
Running the Demo
# macOS
swift run OCCTSwiftMetalDemo
# iOS (requires Xcode project)
xcodegen # Generate from project.yml
open OCCTSwiftViewport.xcodeproj
# Select OCCTSwiftMetalDemo_iOS scheme → RunFile Import
The demo app imports STEP, STL, OBJ, and BREP files. On macOS, drag and drop or use the file picker. On iOS, use the Files integration.
Testing
37 tests across 9 suites using Swift Testing framework:
swift test # Run all tests
swift test --filter CameraStateTests # Single suite
swift test --filter "CameraStateTests/Default initialization" # Single testTest suites cover camera state, bounding box, ray casting, projection, pivot strategy, and viewport body primitives.
Build
swift build # Debug build
swift package clean && swift build # Clean build (stale PCH fix)
xcodegen # Regenerate Xcode project from project.ymlNote: OCCTSwift is a local path dependency (../OCCTSwift in Package.swift). Clone both repositories as siblings:
git clone https://github.com/gsdali/OCCTSwift.git
git clone https://github.com/gsdali/OCCTSwiftViewport.git
# They should be at the same directory levelChangelog
See docs/CHANGELOG.md for release notes.
License
LGPL-2.1-only with Open CASCADE Technology Exception 1.0. See LICENSE and OCCT_LGPL_EXCEPTION.md.
Package Metadata
Repository: gsdali/occtswiftviewport
Default branch: main
README: README.md