Link Search Menu Expand Document

SwiftUI Views

Two types form the public entry point to the viewport: MetalViewportView is the SwiftUI View that renders the 3D scene and handles gestures; ViewportController is the @MainActor ObservableObject hub that everything—camera, display mode, picking, measurements, clip planes, post-processing—routes through.

Topics


MetalViewportView

A Metal-backed 3D viewport View. Wraps MTKView through platform-specific representables and wires up the full gesture stack: on iOS / visionOS — single-finger orbit (DragGesture), two-finger pan (UIPanGestureRecognizer overlay), pinch-to-zoom (MagnifyGesture), roll (RotateGesture), single tap (pick), double-tap (reset); on macOS — mouse drag (DragGesture), scroll-wheel zoom, trackpad magnify/rotate. Modifier-key interpretation for macOS (orbit / pan / zoom) lives in ViewportController.dispatch(_:) via GestureConfiguration.

Built-in overlays that the view manages automatically:

Overlay Shown when
Navigation cube (NavigationCubeView) controller.showViewCube == true
Orientation gnomon (OrientationGnomon) controller.showOrientationGnomon == true
Scale bar (ScaleBarView) controller.showScaleBar == true
Measurement annotations (MeasurementOverlay) controller.measurements is non-empty

init(controller:bodies:)

public init(
    controller: ViewportController,
    bodies: Binding<[ViewportBody]>
)

Creates the viewport, binding it to an external [ViewportBody] array. The binding is read each frame by the renderer; mutations to the array (add, remove, replace elements) are picked up automatically on the next render cycle.

struct ContentView: View {
    @StateObject private var controller = ViewportController(configuration: .cad)
    @State private var bodies: [ViewportBody] = [
        .box(id: "box", color: SIMD4<Float>(0.5, 0.7, 1.0, 1.0))
    ]

    var body: some View {
        MetalViewportView(controller: controller, bodies: $bodies)
    }
}

ViewportController

@MainActor
public final class ViewportController: ObservableObject

Central hub for all viewport state. Create one with @StateObject and pass it to MetalViewportView. Every @Published property causes SwiftUI to re-render observers on the main actor; no .receive(on:) is needed.


Initialisation

init(configuration:)

public init(configuration: ViewportConfiguration = .cad)

Creates a controller initialised from configuration. All @Published properties seed from the configuration’s values.

let controller = ViewportController(configuration: .cadHighQuality)

configuration

public let configuration: ViewportConfiguration

The configuration the controller was created with. Immutable after initialisation.

cameraController

public let cameraController: CameraController

The underlying camera controller. Exposed for advanced use (e.g. adjusting rotationStyle, minDistance, maxDistance, enableInertia at runtime). For common operations prefer the high-level methods below.

lastAspectRatio

public internal(set) var lastAspectRatio: Float

The most recently observed viewport aspect ratio (width / height). Updated automatically by MetalViewportView whenever the geometry changes.


Scene & Display

cameraState

@Published public private(set) var cameraState: CameraState

Current camera state (rotation, distance, pivot, projection). Read-only from outside; updated by the camera controller and animations.

displayMode

@Published public var displayMode: DisplayMode

Current display mode (e.g. .shaded, .wireframe, .shadedWithEdges). Writable; cycles via cycleDisplayMode().

showViewCube

@Published public var showViewCube: Bool

Whether the navigation cube overlay is visible. Toggled by toggleViewCube().

showAxes

@Published public var showAxes: Bool

Whether the world-space axis triad is rendered. Toggled by toggleAxes().

showGrid

@Published public var showGrid: Bool

Whether the adaptive dot grid is rendered. Toggled by toggleGrid().

showOrientationGnomon

@Published public var showOrientationGnomon: Bool

Whether the screen-space orientation gnomon (HUD corner axes) is visible.

showScaleBar

@Published public var showScaleBar: Bool

Whether the screen-space scale bar (HUD) is visible.

isAnimating

@Published public private(set) var isAnimating: Bool

true while a camera animation is in progress. Useful for disabling UI controls during a fly-to.

lightingConfiguration

@Published public var lightingConfiguration: LightingConfiguration

Live lighting configuration. Assign a preset (.threePoint, .studio, .architectural, .flat) or a custom value; changes take effect on the next frame.

edgeIntensity

@Published public var edgeIntensity: Float

Edge / wireframe intensity multiplier. 0 = invisible, 1 = default, >1 = bold. Default 1.0.

clipPlanes

@Published public var clipPlanes: [ClipPlane]

Active clipping planes (up to 4). Only planes with isOn == true are applied by the renderer each frame.

measurements

@Published public var measurements: [ViewportMeasurement]

Committed measurement annotations rendered by MeasurementOverlay. Append directly for programmatic annotations, or let the tap-to-measure flow populate it via measurementMode.


Post-Processing

enableDepthOfField

@Published public var enableDepthOfField: Bool

Whether depth-of-field blur is active. Default false.

dofAperture

@Published public var dofAperture: Float

DoF aperture value. Smaller = shallower depth of field. Default 2.8.

dofFocalDistance

@Published public var dofFocalDistance: Float

DoF focal distance in world units. 0 enables auto-focus. Default 0.

dofMaxBlurRadius

@Published public var dofMaxBlurRadius: Float

Maximum DoF blur radius in pixels. Default 8.0.

enableTAA

@Published public var enableTAA: Bool

Whether temporal anti-aliasing is active.

taaBlendFactor

@Published public var taaBlendFactor: Float

TAA blend factor: 0 = no history, 1 = full history. Default 0.9.

enableProgressiveAccumulation

@Published public var enableProgressiveAccumulation: Bool

When true (requires enableTAA), history weight grows as N/(N+1) while the camera is still, giving unbounded supersampling during idle. Default false.

debugDisableCurvature

@Published public var debugDisableCurvature: Bool

Debug toggle: disables screen-space curvature enhancement in the shaded fragment shader. Default false.

debugDisableTessellation

@Published public var debugDisableTessellation: Bool

Debug toggle: disables GPU tessellation and falls back to standard triangles. Default false.


Camera Control

goToStandardView(_:duration:)

public func goToStandardView(_ view: StandardView, duration: Float = 0.3)

Animates to a preset standard view (.top, .front, .right, .isometric, etc.) over duration seconds.

Button("Top") { controller.goToStandardView(.top) }
Button("ISO") { controller.goToStandardView(.isometric, duration: 0.5) }

animateTo(_:duration:)

public func animateTo(_ state: CameraState, duration: Float = 0.3)

Animates to an arbitrary CameraState. Use this to restore saved camera positions.

goToRegion(_:duration:)

public func goToRegion(_ region: ViewCubeRegion, duration: Float = 0.3)

Animates to the orientation corresponding to a ViewCubeRegion (face, edge, or corner), preserving the current pivot, distance, and projection. Called by NavigationCubeView when the user taps the cube.

focusOn(point:distance:animated:)

public func focusOn(
    point: SIMD3<Float>,
    distance: Float? = nil,
    animated: Bool = true
)

Moves the camera orbit pivot to point. Optionally sets the camera-to-pivot distance; if nil, the current distance is kept. Pass animated: false for an instant jump.

reset(animated:)

public func reset(animated: Bool = true)

Resets the camera to the initial state defined by configuration.initialCameraState. A double-tap in MetalViewportView calls this automatically.

toggleProjection()

public func toggleProjection()

Toggles between perspective and orthographic projection, animated over 0.3 s.

handleOrbit(translation:)

public func handleOrbit(translation: CGSize)

Applies an incremental orbit delta (in points). Prefer dispatch(_:) for custom input sources.

endOrbit(velocity:)

public func endOrbit(velocity: CGSize)

Ends an orbit gesture, optionally applying inertia from the release velocity. At low speeds snaps to the nearest standard view if within 3°.

handlePan(translation:)

public func handlePan(translation: CGSize)

Applies an incremental pan translation (in points).

endPan(velocity:)

public func endPan(velocity: CGSize)

Ends a pan gesture, optionally applying inertia.

handleZoom(magnification:)

public func handleZoom(magnification: CGFloat)

Zooms by a scale ratio relative to the current distance.

handleZoom(magnification:centerNormalized:aspectRatio:)

public func handleZoom(
    magnification: CGFloat,
    centerNormalized: SIMD2<Float>,
    aspectRatio: Float
)

Zooms toward a specific point in NDC (−1…+1 on both axes), so the world point under the pinch centre stays fixed on screen. Used for pinch-to-zoom and scroll-at-cursor.

handleScrollZoom(delta:)

public func handleScrollZoom(delta: CGFloat)

Applies a scroll-wheel zoom delta toward the view centre.

handleScrollZoom(delta:cursorNormalized:aspectRatio:)

public func handleScrollZoom(
    delta: CGFloat,
    cursorNormalized: SIMD2<Float>,
    aspectRatio: Float
)

Applies a scroll-wheel zoom delta toward the cursor position in NDC.

handleRoll(angle:)

public func handleRoll(angle: CGFloat)

Applies an incremental roll (in-plane rotation) in radians.


Toggles & Display Mode

cycleDisplayMode()

public func cycleDisplayMode()

Advances displayMode through all DisplayMode cases in order.

toggleViewCube()

public func toggleViewCube()

Flips showViewCube.

toggleAxes()

public func toggleAxes()

Flips showAxes.

toggleGrid()

public func toggleGrid()

Flips showGrid.


Selection & Picking

pickResult

@Published public private(set) var pickResult: PickResult?

The most recent pick result for the user-geometry layer, or nil if nothing is selected. Updated by handlePick(result:ndc:). Widget-layer picks are routed to widgetPickResult instead.

widgetPickResult

@Published public private(set) var widgetPickResult: PickResult?

The most recent pick result for the widget layer (bodies with pickLayer == .widget). Kept at its last value across miss-taps—the external consumer (e.g. OCCTSwiftAIS) decides when to clear it via clearWidgetPick().

selectedBodyIDs

@Published public var selectedBodyIDs: Set<String>

The set of selected body IDs. Bodies in this set render with a highlight outline. Assign directly for programmatic selection, or use selectBody(_:toggle:) / deselectAll().

hoveredBodyID

@Published public var hoveredBodyID: String?

The ID of the hovered body (macOS mouse hover), or nil.

lastPickNDC

@Published public private(set) var lastPickNDC: SIMD2<Float>

NDC coordinates (−1…+1) of the most recent pick tap, for sub-body operations.

selectionFilter

public var selectionFilter: SelectionFilter?

Optional filter constraining the user-geometry pick stream. A pick that fails the filter is treated as a miss (clearing pickResult). Widget-layer picks bypass the filter.

onPick

public var onPick: ((PickResult?) -> Void)?

Callback invoked whenever a user-geometry pick resolves (including misses, where the argument is nil).

onWidgetPick

public var onWidgetPick: ((PickResult?) -> Void)?

Callback invoked whenever a widget-layer pick resolves.

handlePick(result:)

public func handlePick(result: PickResult?)

Routes a GPU pick result to the appropriate stream. Normally called by the view layer; call this from a custom renderer or test harness.

handlePick(result:ndc:)

public func handlePick(result: PickResult?, ndc: SIMD2<Float>)

As handlePick(result:), additionally recording ndc in lastPickNDC.

selectBody(_:toggle:)

public func selectBody(_ bodyID: String, toggle: Bool = false)

Selects a body by ID. When toggle is true, the body is added to or removed from selectedBodyIDs (multi-select); when false, selectedBodyIDs is replaced with just this ID.

// Single-select on pick:
controller.selectBody(result.bodyID)

// Add to existing selection:
controller.selectBody(result.bodyID, toggle: true)

deselectAll()

public func deselectAll()

Clears selectedBodyIDs.

clearSelection()

public func clearSelection()

Clears pickResult, clears selectedBodyIDs, and fires onPick(nil).

clearWidgetPick()

public func clearWidgetPick()

Clears widgetPickResult and fires onWidgetPick(nil).


Measurement

measurementMode

@Published public var measurementMode: MeasurementMode

Active measurement mode. When not .none, taps on geometry feed the measurement accumulator instead of the selection stream. Changing the mode discards any in-progress points. Default .none.

Value Points needed
.none
.distance 2 (start, end)
.angle 3 (armA, vertex, armB)
.radius 2 (center, edge point)

pendingMeasurementPoints

@Published public private(set) var pendingMeasurementPoints: [SIMD3<Float>]

World-space points accumulated so far for the in-progress measurement, in tap order. Expose these to drive a rubber-band line overlay before the measurement commits.

pointCount(for:)

public nonisolated static func pointCount(for mode: MeasurementMode) -> Int

Returns the number of world-space points required before a measurement in mode is committed. Returns 0 for .none.

addMeasurementPoint(_:)

public func addMeasurementPoint(_ point: SIMD3<Float>)

Feeds a world-space point into the active measurement. When enough points for the current mode are accumulated, a ViewportMeasurement is appended to measurements and pendingMeasurementPoints is cleared. A no-op when measurementMode == .none. MetalViewportView calls this automatically on tap; call it directly for programmatic measurement.

handleMeasurementPick(result:ndc:bodies:aspectRatio:)

public func handleMeasurementPick(
    result: PickResult?,
    ndc: SIMD2<Float>,
    bodies: [ViewportBody],
    aspectRatio: Float
)

Converts a GPU pick result into a world-space surface point (ray / triangle intersection, respecting the body’s transform) and feeds it to addMeasurementPoint(_:). Only .face picks are accepted; edge / vertex picks and misses are ignored. Called by MetalViewportView while measurementMode != .none.

cancelPendingMeasurement()

public func cancelPendingMeasurement()

Discards in-progress points without committing a measurement. measurementMode is unchanged.

// Cancel in-progress angle measurement:
controller.cancelPendingMeasurement()

clearMeasurements()

public func clearMeasurements()

Removes all committed measurements from measurements and clears any in-progress points.


Keyboard

handleKeyPress(_:)

public func handleKeyPress(_ key: Character)

Processes a single keystroke. Checks StandardView.keyboardShortcut and DisplayMode.keyboardShortcut; the first match executes the corresponding action. Wire this to a onKeyPress modifier or a Button keyboard shortcut handler.

.onKeyPress { press in
    controller.handleKeyPress(press.characters.first ?? Character(""))
    return .handled
}

Input Dispatch

dispatch(_:)

public func dispatch(_ event: ViewportInputEvent)

The single entry point for portable input. MetalViewportView calls this for every gesture; custom input sources (visionOS spatial input, tests, scripting) produce the same events.

onInputEvent fires before any interpretation, so observers see every event regardless of how it is handled.

// Synthetic orbit from an external gamepad:
controller.dispatch(.dragChanged(
    delta: SIMD2<Float>(dx, dy),
    modifiers: []
))

onInputEvent

public var onInputEvent: ((ViewportInputEvent) -> Void)?

Observational callback fired for every event passed to dispatch(_:). Does not affect how the event is interpreted. Useful for HUD input inspectors and debugging.


ViewportInputEvent

public enum ViewportInputEvent: Sendable, Equatable

Platform-neutral viewport input event. Pass to ViewportController.dispatch(_:). All deltas and velocities are in points / points-per-second; sign conventions and gesture-action mapping live in the dispatch layer, not the platform translation layer.

Case Description
.dragChanged(delta:modifiers:) Primary pointer drag (mouse on macOS, single finger on iOS)
.dragEnded(velocity:modifiers:) Release with velocity for inertia
.twoFingerPanChanged(translation:) Two-finger pan translation
.twoFingerPanEnded(velocity:) Two-finger pan release
.pinchChanged(scale:) Pinch, incremental scale ratio (1.0 = no change)
.pinchAtChanged(scale:centerNDC:aspectRatio:) Pinch with known gesture centre in NDC
.pinchEnded Pinch gesture ended
.rotateChanged(radians:) In-plane rotation, incremental radians
.rotateEnded Rotation gesture ended
.scroll(delta:cursorNDC:aspectRatio:) Scroll-wheel zoom toward cursor (macOS)
.tap(ndc:count:) Tap / click; count >= 2 resets the view