2026-04-10 21:34:23 -07:00
|
|
|
// MetalContext.swift
|
|
|
|
|
// Shared Metal state across the entire app.
|
|
|
|
|
// Creates and configures renderers for each viewport pane.
|
|
|
|
|
|
|
|
|
|
import Metal
|
|
|
|
|
import Observation
|
|
|
|
|
|
|
|
|
|
@Observable
|
|
|
|
|
final class MetalContext {
|
|
|
|
|
let device: MTLDevice
|
|
|
|
|
let library: MTLLibrary
|
|
|
|
|
let sceneGraph: SceneGraph
|
|
|
|
|
|
|
|
|
|
// Up to 4 renderers (for quad split)
|
|
|
|
|
let renderers: [ViewportRenderer]
|
|
|
|
|
|
|
|
|
|
// Layout state
|
|
|
|
|
var viewportLayout: ViewportLayout = .single
|
|
|
|
|
|
Animation Data Model
Mirrors Blender's internal structure exactly:
Keyframe — stores frame, value, handleLeft/handleRight (absolute SIMD2
coordinates for Bézier control points), handleType
(Free/Aligned/Vector/Auto/Auto Clamped), and interpolation mode
(Constant/Linear/Bézier/Bounce/Elastic/Back). The autoComputeHandles()
method computes smooth tangents from neighboring keyframes, matching
Blender's BKE_fcurve_correct_bezpart. moveHandleLeft/moveHandleRight
enforce the Aligned constraint (co-linear handles).
AnimationTrack (= Blender's FCurve) — one channel like location.X.
Contains a sorted keyframe array. The evaluate(at:) method performs
full cubic Bézier interpolation using Newton-Raphson iteration (8
steps, matching Blender's precision) to solve for the parametric t
given a frame x-coordinate, then evaluates the y-coordinate. Colors
follow Blender's convention: X=red, Y=green, Z=blue.
AnimAction (= Blender's bAction) — groups multiple tracks into a
reusable animation data block. evaluate(at:) returns all channel
values as a dictionary.
AnimationSystem — owns the action library, playback state
(currentFrame, fps, loop mode), and applyToObject() to push evaluated
transforms onto a SceneObject.
Dope Sheet (DopeSheetView.swift)
Faithful to Blender's Dope Sheet layout:
Left channel list with colored track indicators (red/green/blue bars),
mute (speaker icon) and lock buttons per channel
Summary track at top showing all keyframes merged as yellow diamonds
Per-channel tracks with diamond keyframes (yellow = normal, white =
selected)
Frame ruler at top with major ticks every 10 frames, minor every 5,
and frame number labels
Blue playhead line with triangular marker — tap the ruler to scrub
Keyframe interaction: tap to select, drag to move (snaps to integer
frames), auto-recomputes handles after drag
Pinch to zoom the frame axis, horizontal drag to scroll
Graph Editor (GraphEditorView.swift)
The most complex view — a full interactive F-Curve editor:
Adaptive grid with auto-scaling step sizes (1/2/5/10 pattern), axis
lines highlighted, frame numbers on bottom, value numbers on left
F-Curve rendering as cubic Bézier paths using SwiftUI's
addCurve(to:control1:control2:), with flat extensions before/after the
first/last keyframes. Constant interpolation draws step functions,
Linear draws straight lines.
Keyframe diamonds colored per-channel (red/green/blue), white when
selected
Bézier handles — visible only on selected keyframes, drawn as circles
connected to the diamond by lines. Supports:
Drag the diamond to move the keyframe (frame snaps to integer, value
is free)
Drag the left handle to change the incoming tangent
Drag the right handle to change the outgoing tangent
Aligned mode: moving one handle mirrors the other
Hit testing with 14pt touch radius, prioritizing handles over
keyframes over timeline scrubbing
Info overlay showing current frame/value coordinates during drag
Channel list overlay with per-track visibility toggles
Transport Controls (AnimationPanelView.swift)
Matches Blender's timeline footer pixel-for-pixel:
Editor switcher — segmented control toggling between Dope Sheet and
Graph Editor
Action name display with film icon
Interpolation menu — set selected keyframes to
Constant/Linear/Bézier/Bounce/Elastic/Back
Handle type menu — set selected handles to
Free/Aligned/Vector/Auto/Auto Clamped
Insert keyframe button (yellow diamond icon)
Transport bar: Jump to Start, Previous Keyframe, Step Back, Play/Pause
(highlighted blue when playing), Step Forward, Next Keyframe, Jump to
End
Frame range display (Start/End)
Current frame in blue monospaced bold
FPS indicator and loop toggle
Playback Engine (AnimationPlayer.swift)
CADisplayLink-driven with preferredFrameRateRange set to 30–120fps.
Delta-time accumulation advances currentFrame at the configured FPS
rate. nextKeyframe()/previousKeyframe() jump between actual keyframe
positions.
To see it: tap the Animation workspace tab, or tap the play icon in
any workspace header to toggle the timeline panel. The default
"CubeAction" ships with sample keyframes on all three location
channels so you'll see curves immediately in the Graph Editor. Tap a
keyframe diamond, then drag its Bézier handles to reshape the easing.
2026-04-10 21:52:58 -07:00
|
|
|
// Animation
|
|
|
|
|
let animationSystem = AnimationSystem()
|
|
|
|
|
|
2026-04-10 21:34:23 -07:00
|
|
|
init() {
|
|
|
|
|
guard let device = MTLCreateSystemDefaultDevice() else {
|
|
|
|
|
fatalError("Metal is not supported on this device.")
|
|
|
|
|
}
|
|
|
|
|
guard let library = device.makeDefaultLibrary() else {
|
|
|
|
|
fatalError("Failed to create Metal library.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.device = device
|
|
|
|
|
self.library = library
|
|
|
|
|
self.sceneGraph = SceneGraph.defaultScene(device: device)
|
|
|
|
|
|
|
|
|
|
// Create 4 renderers with preset cameras
|
|
|
|
|
let presets: [(CameraPreset, String)] = [
|
|
|
|
|
(.perspective, "Perspective"),
|
|
|
|
|
(.top, "Top"),
|
|
|
|
|
(.front, "Front"),
|
|
|
|
|
(.right, "Right"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
var rends: [ViewportRenderer] = []
|
|
|
|
|
for (preset, _) in presets {
|
|
|
|
|
let cam = OrbitalCamera()
|
|
|
|
|
preset.configure(cam)
|
|
|
|
|
let renderer = ViewportRenderer(camera: cam)
|
|
|
|
|
renderer.configure(device: device, library: library)
|
|
|
|
|
renderer.sceneGraph = sceneGraph
|
|
|
|
|
rends.append(renderer)
|
|
|
|
|
}
|
|
|
|
|
self.renderers = rends
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Add primitives
|
|
|
|
|
|
|
|
|
|
func addPrimitive(_ type: PrimitiveType) {
|
|
|
|
|
let data: PrimitiveMeshData
|
|
|
|
|
let name: String
|
|
|
|
|
|
|
|
|
|
switch type {
|
|
|
|
|
case .cube:
|
|
|
|
|
data = PrimitiveGenerator.cube()
|
|
|
|
|
name = uniqueName("Cube")
|
|
|
|
|
case .plane:
|
|
|
|
|
data = PrimitiveGenerator.plane()
|
|
|
|
|
name = uniqueName("Plane")
|
|
|
|
|
case .uvSphere:
|
|
|
|
|
data = PrimitiveGenerator.uvSphere()
|
|
|
|
|
name = uniqueName("Sphere")
|
|
|
|
|
case .icosphere:
|
|
|
|
|
data = PrimitiveGenerator.icosphere()
|
|
|
|
|
name = uniqueName("Icosphere")
|
|
|
|
|
case .cylinder:
|
|
|
|
|
data = PrimitiveGenerator.cylinder()
|
|
|
|
|
name = uniqueName("Cylinder")
|
|
|
|
|
case .cone:
|
|
|
|
|
data = PrimitiveGenerator.cone()
|
|
|
|
|
name = uniqueName("Cone")
|
|
|
|
|
case .torus:
|
|
|
|
|
data = PrimitiveGenerator.torus()
|
|
|
|
|
name = uniqueName("Torus")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let obj = SceneObject(name: name, type: .mesh)
|
|
|
|
|
obj.meshHandle = MeshUploader.upload(
|
|
|
|
|
vertices: data.vertices,
|
|
|
|
|
wireVertices: data.wireVertices,
|
|
|
|
|
faceCount: data.faceCount,
|
|
|
|
|
edgeCount: data.edgeCount,
|
|
|
|
|
label: name,
|
|
|
|
|
device: device
|
|
|
|
|
)
|
|
|
|
|
obj.boundingBoxMin = data.boundingBoxMin
|
|
|
|
|
obj.boundingBoxMax = data.boundingBoxMax
|
|
|
|
|
|
|
|
|
|
// Place at cursor (origin for now) with slight offset to avoid overlap
|
|
|
|
|
let existingCount = sceneGraph.allObjects.filter { $0.type == .mesh }.count
|
|
|
|
|
obj.transform.position = SIMD3<Float>(Float(existingCount) * 3.0, 0, 0)
|
|
|
|
|
|
|
|
|
|
sceneGraph.select(nil)
|
|
|
|
|
sceneGraph.addObject(obj)
|
|
|
|
|
sceneGraph.select(obj)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deleteSelected() {
|
|
|
|
|
guard let selected = sceneGraph.selectedObject else { return }
|
|
|
|
|
sceneGraph.removeObject(selected)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func uniqueName(_ base: String) -> String {
|
|
|
|
|
let existing = sceneGraph.allObjects.map { $0.name }
|
|
|
|
|
if !existing.contains(base) { return base }
|
|
|
|
|
var i = 1
|
|
|
|
|
while existing.contains("\(base).\(String(format: "%03d", i))") {
|
|
|
|
|
i += 1
|
|
|
|
|
}
|
|
|
|
|
return "\(base).\(String(format: "%03d", i))"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Primitive types
|
|
|
|
|
|
|
|
|
|
enum PrimitiveType: String, CaseIterable, Identifiable {
|
|
|
|
|
case cube = "Cube"
|
|
|
|
|
case plane = "Plane"
|
|
|
|
|
case uvSphere = "UV Sphere"
|
|
|
|
|
case icosphere = "Icosphere"
|
|
|
|
|
case cylinder = "Cylinder"
|
|
|
|
|
case cone = "Cone"
|
|
|
|
|
case torus = "Torus"
|
|
|
|
|
|
|
|
|
|
var id: String { rawValue }
|
|
|
|
|
|
|
|
|
|
var icon: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .cube: "cube"
|
|
|
|
|
case .plane: "square"
|
|
|
|
|
case .uvSphere: "globe"
|
|
|
|
|
case .icosphere: "circle"
|
|
|
|
|
case .cylinder: "cylinder"
|
|
|
|
|
case .cone: "triangle"
|
|
|
|
|
case .torus: "circle.circle"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|