Camera & Navigation
The camera system is split across three types:
CameraState— immutable value type (Hashable, Codable, Sendable) capturing the full camera pose.CameraController—@MainActorclass that mutates state in response to gestures and drives animations.ViewportController— the top-level observable hub; exposes the controller ascameraControllerand mirrors its@Published cameraStatefor observation.
CameraState at a glance
public struct CameraState: Hashable, Codable, Sendable {
public var rotation: simd_quatf // normalised quaternion
public var distance: Float // from pivot, default 10
public var pivot: SIMD3<Float> // orbit centre in world space
public var fieldOfView: Float // degrees, perspective only, default 45
public var orthographicScale: Float // world-height of viewport, ortho only, default 10
public var isOrthographic: Bool // default false
public var panOffset: SIMD2<Float> // camera-relative fine offset
// Derived, read-only
public var position: SIMD3<Float> // world-space eye position
public var viewDirection: SIMD3<Float> // normalised, toward pivot
public var upVector: SIMD3<Float>
public var rightVector: SIMD3<Float>
public var viewMatrix: simd_float4x4
}
CameraState is a plain value — capture it, persist it with Codable, diff it, or hand it to animateTo(_:duration:).
// Snapshot and restore
let bookmark = viewport.cameraState
// … later …
viewport.animateTo(bookmark, duration: 0.4)
Projection matrix
let proj = cameraState.projectionMatrix(
aspectRatio: Float(viewSize.width / viewSize.height),
near: 0.01,
far: 1000.0
)
The matrix is perspective or orthographic depending on isOrthographic. Both use Metal NDC (z in [0, 1]).
Fitting the camera to geometry
fit(to:aspectRatio:padding:) returns a new CameraState with the pivot moved to the bounding-box centre and the distance (perspective) or orthographicScale (orthographic) adjusted so the bounding sphere fills the view. padding is a multiplier: 1.1 gives 10 % breathing room.
// Fit to an explicit bounding box
if let bb = body.boundingBox {
let fitted = viewport.cameraState.fit(
to: bb,
aspectRatio: Float(viewSize.width / viewSize.height),
padding: 1.1
)
viewport.animateTo(fitted, duration: 0.4)
}
// Convenience overload: fits to all visible bodies
let bodies: [ViewportBody] = …
if let fitted = viewport.cameraState.fit(
to: bodies,
aspectRatio: Float(viewSize.width / viewSize.height)
) {
viewport.animateTo(fitted, duration: 0.4)
}
The overload skips invisible bodies and returns nil when no body has geometry.
Standard and isometric views
StandardView enumerates ten axis-aligned orientations. The six orthographic cases (top, bottom, front, back, right, left) set isOrthographic: true; the four isometric corners remain perspective.
public enum StandardView: String, CaseIterable, Sendable {
// Orthographic (isOrthographic = true)
case top, bottom, front, back, right, left
// Perspective isometric corners
case isometricFrontRight, isometricFrontLeft
case isometricBackRight, isometricBackLeft
}
Navigating via the controller
// Animated (default 0.3 s)
viewport.goToStandardView(.top)
viewport.goToStandardView(.isometricFrontRight, duration: 0.5)
Building a view-picker toolbar
Menu("Views") {
Button("Top") { viewport.goToStandardView(.top) }
Button("Front") { viewport.goToStandardView(.front) }
Button("Right") { viewport.goToStandardView(.right) }
Button("Isometric") { viewport.goToStandardView(.isometricFrontRight) }
}
Keyboard shortcuts
StandardView carries single-character shortcuts:
| View | Key |
|---|---|
| Top | t |
| Front | f |
| Right | r |
| Left | l |
| Isometric (front-right) | i |
These are automatically handled by ViewportController.handleKeyPress(_:).
Building a CameraState directly from a StandardView
let state = StandardView.front.cameraState(
pivot: SIMD3<Float>(0, 0, 0),
distance: 20,
fieldOfView: 45,
orthographicScale: 10
)
viewport.animateTo(state, duration: 0.3)
Animated transitions (SLERP + ease-out)
All animated moves go through CameraController.animateTo(_:duration:), which:
- Captures
animationStartandanimationTarget. - Runs a 60 FPS
Timer. - Applies an ease-out curve (
t = 1 - (1 - progress)³) at each tick. - SLERPs
rotationviasimd_slerp; linearly interpolatesdistance,pivot,fieldOfView,orthographicScale, andpanOffset. - Snaps
isOrthographicat the midpoint.
// Direct state animation through the controller
viewport.animateTo(mySavedState, duration: 0.4)
// Instant snap (duration = 0)
viewport.animateTo(mySavedState, duration: 0)
// Cancel mid-flight
viewport.cameraController.cancelAnimation()
Observe viewport.isAnimating (a @Published Bool) to gate UI interactions during the transition.
Orbit, pan, and zoom
These calls go through ViewportController’s gesture-forwarding API, which in turn drives CameraController.
// Orbit (drag translation in points)
viewport.handleOrbit(translation: gesture.translation)
// Release with inertia — auto-snaps to a nearby axis view if velocity < 200 pt/s
viewport.endOrbit(velocity: gesture.velocity)
// Pan
viewport.handlePan(translation: gesture.translation)
viewport.endPan(velocity: gesture.velocity) // pan inertia
// Zoom (magnification factor: >1 = in, <1 = out)
viewport.handleZoom(magnification: CGFloat(gesture.magnification))
// Pinch-to-zoom keeping the gesture centre stationary (NDC −1…+1)
viewport.handleZoom(
magnification: CGFloat(gesture.magnification),
centerNormalized: pinchCenterNDC,
aspectRatio: Float(viewSize.width / viewSize.height)
)
// Scroll-wheel zoom (positive delta = zoom in)
viewport.handleScrollZoom(delta: scrollDelta)
// Scroll-wheel zoom toward cursor (NDC)
viewport.handleScrollZoom(
delta: scrollDelta,
cursorNormalized: cursorNDC,
aspectRatio: Float(viewSize.width / viewSize.height)
)
// Roll (radians)
viewport.handleRoll(angle: rotationGesture.rotation)
Sensitivity knobs on CameraController
let cam = viewport.cameraController
cam.orbitSensitivity = 0.005 // radians per point
cam.panSensitivity = 0.002 // world-units per point, scaled by distance
cam.zoomSensitivity = 1.0 // pinch factor multiplier
cam.scrollZoomSensitivity = 0.1 // exponential scale for scroll delta
cam.minPanSpeed = 0.001 // floor preventing imperceptible pan when close
cam.minDistance = 0.1
cam.maxDistance = 10_000
cam.enableInertia = true
cam.dampingFactor = 0.1 // 0 = no damping, 1 = instant stop
Rotation styles
RotationStyle controls how drag gestures rotate the camera.
public enum RotationStyle: String, CaseIterable, Sendable {
case arcball // Ken Shoemake free-rotation; unrestricted axes
case turntable // Z-up locked; horizontal = yaw, vertical = tilt
case firstPerson // Yaw + pitch; camera-centric walk-through
}
Two named presets:
RotationStyle.cadDefault // == .turntable
RotationStyle.modelingDefault // == .arcball
Set the style on the camera controller at any time:
viewport.cameraController.rotationStyle = .arcball
Or configure it at init via ViewportConfiguration:
let config = ViewportConfiguration.cad // ships with .turntable
// customise:
var custom = ViewportConfiguration.cad
// (rotationStyle lives on the configuration; set it before constructing the controller)
Choosing a style
| Style | When to use |
|---|---|
.turntable | CAD, architecture — “up is always up”. Default. |
.arcball | Freeform 3D modeling, inspecting objects from any angle. |
.firstPerson | Walk-through, VR-style navigation. |
Dynamic pivot (auto orbit centre)
PivotStrategy adjusts the orbit centre automatically as the user zooms:
- Zoomed out (
distance / sceneDiagonalLength > zoomThreshold + halfBand): orbit around the scene centre. - Zoomed in (below threshold): orbit around the raycast hit point at the screen centre.
- Blend zone: smoothstep between the two over
blendBand × zoomThreshold.
The strategy is configured via DynamicPivotConfiguration:
public struct DynamicPivotConfiguration: Sendable {
public var isEnabled: Bool // default true
public var animationDuration: Float // pivot transition time, default 0.15 s
public var zoomThreshold: Float // distance/diagonal ratio, default 0.5
public var blendBand: Float // fraction of threshold for blend zone, default 0.3
public static let `default` = DynamicPivotConfiguration()
}
Configure via ViewportConfiguration.dynamicPivotConfiguration. The controller schedules updates automatically with a 50 ms coalesce delay after each orbit/zoom event — no manual calls needed.
To focus on a specific world point programmatically (bypassing the strategy):
// Animate pivot to a pick result's world position
viewport.focusOn(point: pickResult.worldPosition, distance: 5.0, animated: true)
Projection toggle
// Toggle perspective ↔ orthographic (animated)
viewport.toggleProjection()
// Or set directly and animate
var state = viewport.cameraState
state.isOrthographic = true
viewport.animateTo(state, duration: 0.3)
Persisting camera state
CameraState is Codable, making save/restore straightforward:
// Save
if let data = try? JSONEncoder().encode(viewport.cameraState) {
UserDefaults.standard.set(data, forKey: "savedCamera")
}
// Restore
if let data = UserDefaults.standard.data(forKey: "savedCamera"),
let saved = try? JSONDecoder().decode(CameraState.self, from: data) {
viewport.animateTo(saved, duration: 0.4)
}