ViewCube
The ViewCube is an interactive 3D navigation widget — familiar from Shapr3D and FreeCAD — that tracks the camera orientation and lets the user snap to any of 26 standard views with a single tap. Dragging the cube orbits the camera in real time.
Two widgets — use the right one
| Widget | Type | Purpose |
|---|---|---|
NavigationCubeView | Interactive cube | Tap → snap to view; drag → orbit. Use this one. |
ViewCubeView | Orientation gizmo | Read-only orientation indicator with compass ring. Legacy; kept for compatibility. |
NavigationCubeView is the default overlay rendered by MetalViewportView. ViewCubeView is available for apps that only want a passive orientation indicator without interaction.
Built-in overlay (zero setup)
MetalViewportView draws NavigationCubeView automatically when showViewCube is true — which is the default. No extra code is required:
let controller = ViewportController()
// controller.showViewCube is true by default
MetalViewportView(controller: controller, bodies: $bodies)
The overlay is 96 × 96 pt, placed 12 pt inside the corner specified by configuration.viewCubePosition (default .bottomTrailing).
Configuring position
Pass a ViewportConfiguration with the desired viewCubePosition:
var config = ViewportConfiguration.default
config.viewCubePosition = .topTrailing // move cube to top-right
let controller = ViewportController(configuration: config)
ViewCubePosition has four cases — .topLeading, .topTrailing, .bottomLeading, .bottomTrailing — each mapping to the matching SwiftUI Alignment corner.
Hiding and showing at runtime
ViewportController publishes showViewCube directly, so you can toggle it without recreating the controller:
// Hide the cube
controller.showViewCube = false
// Show it again
controller.showViewCube = true
The 26 ViewCubeRegions
A tap on the cube resolves to a ViewCubeRegion — an enum with 26 cases covering every clickable target:
| Category | Count | Examples |
|---|---|---|
| Faces | 6 | .top, .bottom, .front, .back, .left, .right |
| Edges | 12 | .topFront, .frontLeft, .bottomRight, … |
| Corners | 8 | .topFrontLeft, .topBackRight, .bottomFrontRight, … |
Each case exposes .isFace, .isEdge, .isCorner, and a human-readable .displayName (e.g. "Top-Front-Left"). Faces additionally provide .standardView — the matching StandardView case, or nil for edges and corners.
Snapping the camera with goToRegion
ViewportController.goToRegion(_:duration:) animates to the region’s camera orientation, preserving the current pivot, zoom distance and projection type:
// Snap to the top-front-right isometric corner
controller.goToRegion(.topFrontRight)
// Custom animation duration (default 0.3 s)
controller.goToRegion(.front, duration: 0.5)
The rotation is computed by ViewCubeRegion.cameraState(pivot:distance:):
- Faces use the corresponding
StandardViewrotation directly (exact orthographic alignment). - Edges SLERP halfway between the two adjacent face rotations.
- Corners use isometric-like rotations (top corners) or a tilt-from-below construction (bottom corners).
How tap classification works
The pure model lives in NavigationCube (no SwiftUI dependency, fully unit-tested). The algorithm:
- Project — the tap point is un-projected from widget coordinates into the unit-cube’s rotated frame using the camera quaternion inverse.
- Ray-cast — a ray is built from that screen point through the cube, intersected with
[-1, 1]³via a slab test. The frontmost surface point is taken. - Classify — each tangent axis coordinate is compared against ±⅓. Any coordinate outside that band activates the corresponding face region (e.g. x > ⅓ →
.right). One active face = face hit; two = edge hit; three = corner hit.
// Use NavigationCube directly for custom hit-testing
let cube = NavigationCube(
rotation: controller.cameraState.rotation,
size: 96
)
if let region = cube.region(at: tapPoint) {
controller.goToRegion(region)
}
Drag-to-orbit
Dragging the cube more than 4 pt triggers orbit mode. The delta is forwarded to controller.handleOrbit(translation:) — the same call the main viewport uses — so inertia and rotation style settings apply identically.
The sign is inverted relative to viewport drags: dragging the cube clockwise rotates the camera clockwise around the model (grab-the-camera, not grab-the-model).
Embedding NavigationCubeView manually
If you build your own overlay layout instead of using MetalViewportView, embed NavigationCubeView directly:
ZStack {
MetalViewportView(controller: controller, bodies: $bodies)
.ignoresSafeArea()
// Disable the built-in overlay first to avoid double-rendering
// (showViewCube defaults to true).
// controller.showViewCube = false — set before MetalViewportView appears
VStack {
NavigationCubeView(controller: controller)
.frame(width: 96, height: 96)
.padding(16)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
NavigationCubeView(controller:) is the only initialiser. The view observes controller.cameraState.rotation and redraws automatically on every camera update.
Legacy: ViewCubeView
ViewCubeView is a passive orientation indicator with a compass ring. It has no tap or drag interaction — tapping a face calls controller.goToStandardView(_:duration:) internally but does not support edges or corners. Prefer NavigationCubeView for all new code.
// Legacy usage — orientation indicator only
ViewCubeView(controller: controller)
.frame(width: 80, height: 80)
Summary
| Task | API |
|---|---|
| Enable / disable built-in overlay | controller.showViewCube = Bool |
| Place overlay in a different corner | ViewportConfiguration.viewCubePosition = ViewCubePosition |
| Snap camera from code | controller.goToRegion(_ region: ViewCubeRegion, duration: Float = 0.3) |
| Custom hit-test | NavigationCube(rotation:size:).region(at: CGPoint) -> ViewCubeRegion? |
| Embed widget manually | NavigationCubeView(controller: ViewportController) |