Contents

secondmouseau/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 — PBR (Cook-Torrance GGX) 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, face, edge and vertex selection, with an optional pick tolerance radius
  • Direct-mesh path — render a kernel's triangulation from de-interleaved arrays, skipping the interleave repack
  • Display modes — Wireframe, shaded, shaded-with-edges, with configurable edge width and dash patterns
  • 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 Sendable conformance, @MainActor isolation
  • 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(url: "https://github.com/SecondMouseAU/OCCTSwiftViewport.git", from: "1.2.3")
],
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(id: "box", up: .z)  // 1×1×1 grey box; `up: .z` matches the Z-up viewport
    ]

    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, .flat

Performance & 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 = false

Batch 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. GPU picks carry 14 bits of primitive index, so on a merged body with more than 16,384 triangles (or edge segments, or points) a pick sets PickResult.isPrimitiveIndexAmbiguous. Tapped faces are resolved for you; see Picking limits. A frame picks at most 65,536 bodies.

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.

Direct-mesh bodies (cutting load time)

The above levers address frame cost. Load cost has its own lever: ViewportBody.directMesh(...) takes de-interleaved position and normal arrays and uploads them as two GPU buffers, instead of one interleaved stride-6 buffer:

let body = ViewportBody.directMesh(
    id: "bracket",
    positions: mesh.vertexData,   // [Float] — flat [px, py, pz, …]
    normals: mesh.normalData,     // [Float] — flat [nx, ny, nz, …], parallel
    indices: mesh.indices,
    color: SIMD4<Float>(0.72, 0.74, 0.76, 1.0)
)

The standard path walks every vertex to repack position+normal before upload — an extra pass and an extra in-memory copy. A CAD kernel's triangulation already stores those arrays flat and contiguous, so that repack is pure overhead. Skipping it is a load-time and peak-memory win that scales with mesh size (it matters on a 700k-triangle part, not on a box). It also skips NormalSmoothing, which is correct for OCCT-sourced geometry — those normals are already analytically accurate.

It is opt-in: interleaved bodies are unchanged, and both kinds coexist in one scene. All passes support direct bodies (shaded, transparent, shadows, SSAO, silhouettes, overlay, GPU pick) in both renderers. Two caveats: autoSmoothNormals and adaptive tessellation do not apply to direct bodies (their normals are used verbatim), and a direct body carries no separate B-Rep corner-vertex / per-segment edge pick indices. See the Loading Geometry cookbook.

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 uniform; see uniformTessellationFactor)
//   tessellationMaxFactor = 48           // upper bound on subdivision (2...64, even)

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 .performance for 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/SecondMouseAU/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 automatically

View on iPhone

  1. Scripts write to iCloud Drive (~/Library/Mobile Documents/com~apple~CloudDocs/OCCTSwiftScripts/output/)
  2. iCloud syncs BREP + manifest to iPhone
  3. Demo app → Settings → Script Watcher → toggle on
  4. 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 Cook-Torrance GGX + 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 DispatchQueue usage — 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 (the demo is its own SwiftPM package)
cd Examples/MetalDemo && swift run OCCTSwiftMetalDemo

# iOS (requires Xcode project; needs ../OCCTSwift checked out, see Build)
xcodegen                    # Generate from project.yml
open OCCTSwiftViewport.xcodeproj
# Select OCCTSwiftMetalDemo_iOS scheme → Run

File 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

228 tests across 40 suites using Swift Testing framework:

swift test                                    # Run all tests
swift test --filter CameraStateTests          # Single suite
swift test --filter "CameraStateTests/Default initialization"  # Single test

Test suites cover camera state and control, bounding boxes, clip planes, ray casting, projection, pivot strategy, picking and pick filters, viewport body primitives, and headless renders of the live renderer.

Build

swift build                           # Debug build
swift package clean && swift build    # Clean build (stale PCH fix)
xcodegen                             # Regenerate Xcode project from project.yml
xcodegen --spec project-verify.yml   # DirectMeshVerify rig only (no OCCTSwift needed)
swift build --package-path Samples/CADInterface   # Compile the CAD interface sample

The library itself (Package.swift) has no OCCTSwift dependency. The Xcode demo project does: project.yml takes OCCTSwift as a local path dependency (../OCCTSwift), and xcodegen refuses to generate without it. Clone both repositories as siblings:

git clone https://github.com/SecondMouseAU/OCCTSwift.git
git clone https://github.com/SecondMouseAU/OCCTSwiftViewport.git
# They should be at the same directory level

project-verify.yml generates the OCCT-free DirectMeshVerify rig on its own, so it works without the sibling checkout (for example from a git worktree).

The example packages under Examples/ take the viewport as .package(name: "OCCTSwiftViewport", path: "../.."), so DocFigures and DirectMeshLiveDemo build from any checkout directory name. MetalDemo and CookbookRender also pull in OCCTSwiftTools, which depends on the viewport itself; the local copy only replaces Tools' copy when the checkout directory is named OCCTSwiftViewport, so build those two from a checkout with that name, not from a worktree or a renamed clone.

Changelog

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: secondmouseau/occtswiftviewport

Default branch: main

README: README.md