diff --git a/Delete b/Delete deleted file mode 100644 index e69de29..0000000 diff --git a/Sources/App/BlenderiOS-Bridging-Header.h b/Sources/App/BlenderiOS-Bridging-Header.h new file mode 100644 index 0000000..fa61cfa --- /dev/null +++ b/Sources/App/BlenderiOS-Bridging-Header.h @@ -0,0 +1,3 @@ +// BlenderiOS-Bridging-Header.h +#pragma once +#import "ShaderTypes.h" diff --git a/Sources/App/BlenderiOSApp.swift b/Sources/App/BlenderiOSApp.swift new file mode 100644 index 0000000..d527dd9 --- /dev/null +++ b/Sources/App/BlenderiOSApp.swift @@ -0,0 +1,15 @@ +// BlenderiOSApp.swift + +import SwiftUI + +@main +struct BlenderiOSApp: App { + var body: some Scene { + WindowGroup { + NavigationStack { + ContentView() + .navigationBarTitleDisplayMode(.inline) + } + } + } +} diff --git a/Sources/Math/MathUtils.swift b/Sources/Math/MathUtils.swift new file mode 100644 index 0000000..3577a70 --- /dev/null +++ b/Sources/Math/MathUtils.swift @@ -0,0 +1,112 @@ +// MathUtils.swift +// Matrix and vector utilities for the viewport camera. + +import simd + +enum MathUtils { + + // MARK: - Projection + + static func perspective( + fovYRadians fov: Float, + aspect: Float, + near: Float, + far: Float + ) -> simd_float4x4 { + let y = 1.0 / tanf(fov * 0.5) + let x = y / aspect + let z = far / (near - far) + + return simd_float4x4(columns: ( + SIMD4(x, 0, 0, 0), + SIMD4(0, y, 0, 0), + SIMD4(0, 0, z, -1), + SIMD4(0, 0, z * near, 0) + )) + } + + // MARK: - View + + static func lookAt( + eye: SIMD3, + center: SIMD3, + up: SIMD3 + ) -> simd_float4x4 { + let f = normalize(center - eye) + let s = normalize(cross(f, up)) + let u = cross(s, f) + + return simd_float4x4(columns: ( + SIMD4(s.x, u.x, -f.x, 0), + SIMD4(s.y, u.y, -f.y, 0), + SIMD4(s.z, u.z, -f.z, 0), + SIMD4(-dot(s, eye), -dot(u, eye), dot(f, eye), 1) + )) + } + + // MARK: - Model transforms + + static func translation(_ t: SIMD3) -> simd_float4x4 { + var m = matrix_identity_float4x4 + m.columns.3 = SIMD4(t.x, t.y, t.z, 1) + return m + } + + static func scale(_ s: SIMD3) -> simd_float4x4 { + return simd_float4x4(diagonal: SIMD4(s.x, s.y, s.z, 1)) + } + + static func rotationX(_ angle: Float) -> simd_float4x4 { + let c = cosf(angle) + let s = sinf(angle) + return simd_float4x4(columns: ( + SIMD4(1, 0, 0, 0), + SIMD4(0, c, s, 0), + SIMD4(0, -s, c, 0), + SIMD4(0, 0, 0, 1) + )) + } + + static func rotationY(_ angle: Float) -> simd_float4x4 { + let c = cosf(angle) + let s = sinf(angle) + return simd_float4x4(columns: ( + SIMD4( c, 0, s, 0), + SIMD4( 0, 1, 0, 0), + SIMD4(-s, 0, c, 0), + SIMD4( 0, 0, 0, 1) + )) + } + + static func rotationZ(_ angle: Float) -> simd_float4x4 { + let c = cosf(angle) + let s = sinf(angle) + return simd_float4x4(columns: ( + SIMD4( c, s, 0, 0), + SIMD4(-s, c, 0, 0), + SIMD4( 0, 0, 1, 0), + SIMD4( 0, 0, 0, 1) + )) + } + + static func normalMatrix(from model: simd_float4x4) -> simd_float4x4 { + let upper3x3 = simd_float3x3( + model.columns.0.xyz, + model.columns.1.xyz, + model.columns.2.xyz + ) + let inv = upper3x3.inverse.transpose + return simd_float4x4(columns: ( + SIMD4(inv.columns.0, 0), + SIMD4(inv.columns.1, 0), + SIMD4(inv.columns.2, 0), + SIMD4(0, 0, 0, 1) + )) + } +} + +// MARK: - SIMD helpers + +extension SIMD4 where Scalar == Float { + var xyz: SIMD3 { .init(x, y, z) } +} diff --git a/Sources/Primitives/PrimitiveGenerator.swift b/Sources/Primitives/PrimitiveGenerator.swift new file mode 100644 index 0000000..9d780e1 --- /dev/null +++ b/Sources/Primitives/PrimitiveGenerator.swift @@ -0,0 +1,515 @@ +// PrimitiveGenerator.swift +// Procedural mesh generation for all primitive types. + +import simd + +/// Output from a primitive generator — ready for GPU upload. +struct PrimitiveMeshData { + let vertices: [MeshVertex] // Solid triangles + let wireVertices: [MeshVertex] // Wireframe lines + let faceCount: Int + let edgeCount: Int + let boundingBoxMin: SIMD3 + let boundingBoxMax: SIMD3 +} + +enum PrimitiveGenerator { + + // MARK: - Cube + + static func cube( + size: Float = 2.0, + color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + let h = size * 0.5 + let positions: [SIMD3] = [ + .init(-h, -h, h), .init( h, -h, h), .init( h, h, h), .init(-h, h, h), + .init( h, -h, -h), .init(-h, -h, -h), .init(-h, h, -h), .init( h, h, -h), + ] + let faces: [(indices: [Int], normal: SIMD3)] = [ + ([0,1,2,3], .init( 0, 0, 1)), // front + ([4,5,6,7], .init( 0, 0,-1)), // back + ([3,2,7,6], .init( 0, 1, 0)), // top + ([5,4,1,0], .init( 0,-1, 0)), // bottom + ([1,4,7,2], .init( 1, 0, 0)), // right + ([5,0,3,6], .init(-1, 0, 0)), // left + ] + + var verts: [MeshVertex] = [] + for face in faces { + let (idx, n) = (face.indices, face.normal) + // Two triangles per quad + for ti in [(0,1,2), (0,2,3)] { + for i in [ti.0, ti.1, ti.2] { + verts.append(MeshVertex(position: positions[idx[i]], normal: n, color: color)) + } + } + } + + let edges: [(Int,Int)] = [ + (0,1),(1,2),(2,3),(3,0), + (4,5),(5,6),(6,7),(7,4), + (0,5),(1,4),(2,7),(3,6), + ] + let wireColor = SIMD4(0, 0, 0, 0.4) + var wireVerts: [MeshVertex] = [] + for (a, b) in edges { + wireVerts.append(.init(position: positions[a], normal: .zero, color: wireColor)) + wireVerts.append(.init(position: positions[b], normal: .zero, color: wireColor)) + } + + return PrimitiveMeshData( + vertices: verts, + wireVertices: wireVerts, + faceCount: 6, + edgeCount: 12, + boundingBoxMin: .init(repeating: -h), + boundingBoxMax: .init(repeating: h) + ) + } + + // MARK: - Plane + + static func plane( + size: Float = 2.0, + color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + let h = size * 0.5 + let n: SIMD3 = .init(0, 1, 0) + let p: [SIMD3] = [ + .init(-h, 0, -h), .init( h, 0, -h), + .init( h, 0, h), .init(-h, 0, h), + ] + let verts: [MeshVertex] = [ + .init(position: p[0], normal: n, color: color), + .init(position: p[1], normal: n, color: color), + .init(position: p[2], normal: n, color: color), + .init(position: p[0], normal: n, color: color), + .init(position: p[2], normal: n, color: color), + .init(position: p[3], normal: n, color: color), + ] + let wc = SIMD4(0, 0, 0, 0.4) + let wireVerts: [MeshVertex] = [ + .init(position: p[0], normal: .zero, color: wc), + .init(position: p[1], normal: .zero, color: wc), + .init(position: p[1], normal: .zero, color: wc), + .init(position: p[2], normal: .zero, color: wc), + .init(position: p[2], normal: .zero, color: wc), + .init(position: p[3], normal: .zero, color: wc), + .init(position: p[3], normal: .zero, color: wc), + .init(position: p[0], normal: .zero, color: wc), + ] + return PrimitiveMeshData( + vertices: verts, wireVertices: wireVerts, + faceCount: 1, edgeCount: 4, + boundingBoxMin: .init(-h, 0, -h), + boundingBoxMax: .init(h, 0, h) + ) + } + + // MARK: - UV Sphere + + static func uvSphere( + radius: Float = 1.0, + segments: Int = 32, + rings: Int = 16, + color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + var verts: [MeshVertex] = [] + var wireVerts: [MeshVertex] = [] + let wc = SIMD4(0, 0, 0, 0.3) + + // Generate grid of positions + var grid: [[SIMD3]] = [] + for ring in 0...rings { + var row: [SIMD3] = [] + let phi = Float.pi * Float(ring) / Float(rings) + for seg in 0...segments { + let theta = 2.0 * Float.pi * Float(seg) / Float(segments) + let x = radius * sinf(phi) * cosf(theta) + let y = radius * cosf(phi) + let z = radius * sinf(phi) * sinf(theta) + row.append(.init(x, y, z)) + } + grid.append(row) + } + + // Triangulate + for ring in 0.. = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + let halfH = height * 0.5 + var verts: [MeshVertex] = [] + var wireVerts: [MeshVertex] = [] + let wc = SIMD4(0, 0, 0, 0.3) + + for i in 0..(radius * c0, -halfH, radius * s0) + let br = SIMD3(radius * c1, -halfH, radius * s1) + let tl = SIMD3(radius * c0, halfH, radius * s0) + let tr = SIMD3(radius * c1, halfH, radius * s1) + + let n0 = normalize(SIMD3(c0, 0, s0)) + let n1 = normalize(SIMD3(c1, 0, s1)) + + // Side quad (2 tris) + verts.append(.init(position: bl, normal: n0, color: color)) + verts.append(.init(position: br, normal: n1, color: color)) + verts.append(.init(position: tl, normal: n0, color: color)) + verts.append(.init(position: tl, normal: n0, color: color)) + verts.append(.init(position: br, normal: n1, color: color)) + verts.append(.init(position: tr, normal: n1, color: color)) + + // Top cap tri + let topN: SIMD3 = .init(0, 1, 0) + verts.append(.init(position: .init(0, halfH, 0), normal: topN, color: color)) + verts.append(.init(position: tl, normal: topN, color: color)) + verts.append(.init(position: tr, normal: topN, color: color)) + + // Bottom cap tri + let botN: SIMD3 = .init(0, -1, 0) + verts.append(.init(position: .init(0, -halfH, 0), normal: botN, color: color)) + verts.append(.init(position: br, normal: botN, color: color)) + verts.append(.init(position: bl, normal: botN, color: color)) + + // Wireframe + wireVerts.append(.init(position: tl, normal: .zero, color: wc)) + wireVerts.append(.init(position: tr, normal: .zero, color: wc)) + wireVerts.append(.init(position: bl, normal: .zero, color: wc)) + wireVerts.append(.init(position: br, normal: .zero, color: wc)) + wireVerts.append(.init(position: bl, normal: .zero, color: wc)) + wireVerts.append(.init(position: tl, normal: .zero, color: wc)) + } + + return PrimitiveMeshData( + vertices: verts, wireVertices: wireVerts, + faceCount: segments * 3, + edgeCount: segments * 3, + boundingBoxMin: .init(-radius, -halfH, -radius), + boundingBoxMax: .init(radius, halfH, radius) + ) + } + + // MARK: - Torus + + static func torus( + majorRadius: Float = 1.0, + minorRadius: Float = 0.3, + majorSegments: Int = 48, + minorSegments: Int = 12, + color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + var verts: [MeshVertex] = [] + var wireVerts: [MeshVertex] = [] + let wc = SIMD4(0, 0, 0, 0.3) + + func torusPoint(_ u: Float, _ v: Float) -> SIMD3 { + let cu = cosf(u), su = sinf(u) + let cv = cosf(v), sv = sinf(v) + return SIMD3( + (majorRadius + minorRadius * cv) * cu, + minorRadius * sv, + (majorRadius + minorRadius * cv) * su + ) + } + + func torusNormal(_ u: Float, _ v: Float) -> SIMD3 { + let cu = cosf(u), su = sinf(u) + let cv = cosf(v), sv = sinf(v) + return normalize(SIMD3(cv * cu, sv, cv * su)) + } + + for i in 0.. = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + let halfH = height * 0.5 + let apex = SIMD3(0, halfH, 0) + var verts: [MeshVertex] = [] + var wireVerts: [MeshVertex] = [] + let wc = SIMD4(0, 0, 0, 0.3) + + for i in 0..(radius * cosf(a0), -halfH, radius * sinf(a0)) + let b1 = SIMD3(radius * cosf(a1), -halfH, radius * sinf(a1)) + + // Side normal (approximation) + let mid = normalize((b0 + b1) * 0.5 - SIMD3(0, -halfH, 0)) + let sideN = normalize(SIMD3(mid.x, radius / height, mid.z)) + + // Side tri + verts.append(.init(position: apex, normal: sideN, color: color)) + verts.append(.init(position: b0, normal: sideN, color: color)) + verts.append(.init(position: b1, normal: sideN, color: color)) + + // Bottom cap + let botN = SIMD3(0, -1, 0) + verts.append(.init(position: .init(0, -halfH, 0), normal: botN, color: color)) + verts.append(.init(position: b1, normal: botN, color: color)) + verts.append(.init(position: b0, normal: botN, color: color)) + + // Wire + wireVerts.append(.init(position: b0, normal: .zero, color: wc)) + wireVerts.append(.init(position: b1, normal: .zero, color: wc)) + wireVerts.append(.init(position: apex, normal: .zero, color: wc)) + wireVerts.append(.init(position: b0, normal: .zero, color: wc)) + } + + return PrimitiveMeshData( + vertices: verts, wireVertices: wireVerts, + faceCount: segments * 2, + edgeCount: segments * 2, + boundingBoxMin: .init(-radius, -halfH, -radius), + boundingBoxMax: .init(radius, halfH, radius) + ) + } + + // MARK: - Icosphere + + static func icosphere( + radius: Float = 1.0, + subdivisions: Int = 2, + color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1) + ) -> PrimitiveMeshData { + // Start with icosahedron + let t = (1.0 + sqrtf(5.0)) * 0.5 + var positions: [SIMD3] = [ + normalize(.init(-1, t, 0)), normalize(.init(1, t, 0)), + normalize(.init(-1,-t, 0)), normalize(.init(1,-t, 0)), + normalize(.init(0,-1, t)), normalize(.init(0, 1, t)), + normalize(.init(0,-1,-t)), normalize(.init(0, 1,-t)), + normalize(.init(t, 0,-1)), normalize(.init(t, 0, 1)), + normalize(.init(-t,0,-1)), normalize(.init(-t,0, 1)), + ].map { $0 * radius } + + var triangles: [(Int,Int,Int)] = [ + (0,11,5),(0,5,1),(0,1,7),(0,7,10),(0,10,11), + (1,5,9),(5,11,4),(11,10,2),(10,7,6),(7,1,8), + (3,9,4),(3,4,2),(3,2,6),(3,6,8),(3,8,9), + (4,9,5),(2,4,11),(6,2,10),(8,6,7),(9,8,1), + ] + + // Subdivide + var midpointCache: [UInt64: Int] = [:] + func midpoint(_ a: Int, _ b: Int) -> Int { + let key = UInt64(min(a,b)) << 32 | UInt64(max(a,b)) + if let cached = midpointCache[key] { return cached } + let mid = normalize((positions[a] + positions[b]) * 0.5) * radius + positions.append(mid) + let idx = positions.count - 1 + midpointCache[key] = idx + return idx + } + + for _ in 0..(0, 0, 0, 0.3) + + for (a,b,c) in triangles { + let pa = positions[a], pb = positions[b], pc = positions[c] + let na = normalize(pa), nb = normalize(pb), nc = normalize(pc) + verts.append(.init(position: pa, normal: na, color: color)) + verts.append(.init(position: pb, normal: nb, color: color)) + verts.append(.init(position: pc, normal: nc, color: color)) + + wireVerts.append(.init(position: pa, normal: .zero, color: wc)) + wireVerts.append(.init(position: pb, normal: .zero, color: wc)) + wireVerts.append(.init(position: pb, normal: .zero, color: wc)) + wireVerts.append(.init(position: pc, normal: .zero, color: wc)) + wireVerts.append(.init(position: pc, normal: .zero, color: wc)) + wireVerts.append(.init(position: pa, normal: .zero, color: wc)) + } + + return PrimitiveMeshData( + vertices: verts, wireVertices: wireVerts, + faceCount: triangles.count, + edgeCount: triangles.count * 3 / 2, + boundingBoxMin: .init(repeating: -radius), + boundingBoxMax: .init(repeating: radius) + ) + } + + // MARK: - Camera wireframe icon + + static func cameraWireframe() -> PrimitiveMeshData { + let wc = SIMD4(0.4, 0.4, 0.4, 1.0) + // Frustum shape + let body: [SIMD3] = [ + .init(-0.5, -0.3, 0.5), .init(0.5, -0.3, 0.5), + .init(0.5, 0.3, 0.5), .init(-0.5, 0.3, 0.5), + .init(-0.3, -0.2, -0.5), .init(0.3, -0.2, -0.5), + .init(0.3, 0.2, -0.5), .init(-0.3, 0.2, -0.5), + ] + let edges: [(Int,Int)] = [ + (0,1),(1,2),(2,3),(3,0), + (4,5),(5,6),(6,7),(7,4), + (0,4),(1,5),(2,6),(3,7), + ] + var wireVerts: [MeshVertex] = [] + for (a,b) in edges { + wireVerts.append(.init(position: body[a], normal: .zero, color: wc)) + wireVerts.append(.init(position: body[b], normal: .zero, color: wc)) + } + // Up arrow + wireVerts.append(.init(position: .init(-0.3, 0.3, 0.5), normal: .zero, color: wc)) + wireVerts.append(.init(position: .init(0, 0.55, 0.5), normal: .zero, color: wc)) + wireVerts.append(.init(position: .init(0, 0.55, 0.5), normal: .zero, color: wc)) + wireVerts.append(.init(position: .init(0.3, 0.3, 0.5), normal: .zero, color: wc)) + + return PrimitiveMeshData( + vertices: [], wireVertices: wireVerts, + faceCount: 0, edgeCount: edges.count + 2, + boundingBoxMin: .init(-0.5, -0.3, -0.5), + boundingBoxMax: .init(0.5, 0.55, 0.5) + ) + } + + // MARK: - Light point icon + + static func lightIcon() -> PrimitiveMeshData { + let lc = SIMD4(1.0, 0.85, 0.4, 1.0) + var wireVerts: [MeshVertex] = [] + let r: Float = 0.3 + let segments = 16 + + // Circle on XY + for i in 0..(r * cosf(a), r * sinf(a), 0) + let outer = SIMD3((r + rayLen) * cosf(a), (r + rayLen) * sinf(a), 0) + wireVerts.append(.init(position: inner, normal: .zero, color: lc)) + wireVerts.append(.init(position: outer, normal: .zero, color: lc)) + } + + return PrimitiveMeshData( + vertices: [], wireVertices: wireVerts, + faceCount: 0, edgeCount: segments * 2 + 8, + boundingBoxMin: .init(repeating: -(r + rayLen)), + boundingBoxMax: .init(repeating: r + rayLen) + ) + } +} diff --git a/Sources/Scene/MetalContext.swift b/Sources/Scene/MetalContext.swift new file mode 100644 index 0000000..67e21eb --- /dev/null +++ b/Sources/Scene/MetalContext.swift @@ -0,0 +1,143 @@ +// 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 + + 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(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" + } + } +} diff --git a/Sources/Scene/SceneGraph.swift b/Sources/Scene/SceneGraph.swift new file mode 100644 index 0000000..6651961 --- /dev/null +++ b/Sources/Scene/SceneGraph.swift @@ -0,0 +1,164 @@ +// SceneGraph.swift +// Observable scene graph that owns all objects and drives the outliner + renderer. + +import Observation +import Metal +import simd + +@Observable +final class SceneGraph { + + // All top-level objects (children are nested inside each) + var rootObjects: [SceneObject] = [] + + // Quick access to the selected object + var selectedObject: SceneObject? { + allObjects.first { $0.isSelected } + } + + // Flattened list of every object in the hierarchy + var allObjects: [SceneObject] { + var result: [SceneObject] = [] + func walk(_ objects: [SceneObject]) { + for obj in objects { + result.append(obj) + walk(obj.children) + } + } + walk(rootObjects) + return result + } + + // MARK: - Stats + + var totalVertexCount: Int { + allObjects.compactMap { $0.meshHandle?.vertexCount }.reduce(0, +) + } + + var totalFaceCount: Int { + allObjects.compactMap { $0.meshHandle?.faceCount }.reduce(0, +) + } + + // MARK: - Mutation + + func addObject(_ object: SceneObject) { + rootObjects.append(object) + } + + func removeObject(_ object: SceneObject) { + object.removeFromParent() + rootObjects.removeAll { $0.id == object.id } + } + + func select(_ object: SceneObject?) { + for obj in allObjects { + obj.isSelected = (obj.id == object?.id) + } + } + + func deselectAll() { + for obj in allObjects { + obj.isSelected = false + } + } + + // MARK: - Default Scene + + /// Creates the classic Blender startup scene: Cube + Camera + Light. + static func defaultScene(device: MTLDevice) -> SceneGraph { + let graph = SceneGraph() + + // Default cube + let cube = SceneObject(name: "Cube", type: .mesh) + let cubeMeshData = PrimitiveGenerator.cube(size: 2.0, color: SIMD4(0.63, 0.63, 0.63, 1)) + cube.meshHandle = MeshUploader.upload( + vertices: cubeMeshData.vertices, + wireVertices: cubeMeshData.wireVertices, + faceCount: cubeMeshData.faceCount, + edgeCount: cubeMeshData.edgeCount, + label: "Cube", + device: device + ) + cube.isSelected = true + graph.addObject(cube) + + // Camera + let camera = SceneObject(name: "Camera", type: .camera, + position: SIMD3(7.36, 4.95, -6.93)) + camera.transform.rotation = SIMD3(1.11, 0.0, 2.17) + let cameraMeshData = PrimitiveGenerator.cameraWireframe() + camera.meshHandle = MeshUploader.upload( + vertices: [], + wireVertices: cameraMeshData.wireVertices, + faceCount: 0, + edgeCount: cameraMeshData.edgeCount, + label: "Camera", + device: device + ) + camera.color = SIMD4(0.4, 0.4, 0.4, 1.0) + graph.addObject(camera) + + // Light + let light = SceneObject(name: "Light", type: .light, + position: SIMD3(4.08, 5.90, -1.0)) + let lightMeshData = PrimitiveGenerator.lightIcon() + light.meshHandle = MeshUploader.upload( + vertices: [], + wireVertices: lightMeshData.wireVertices, + faceCount: 0, + edgeCount: lightMeshData.edgeCount, + label: "Light", + device: device + ) + light.color = SIMD4(1.0, 0.85, 0.4, 1.0) + graph.addObject(light) + + return graph + } +} + +// MARK: - Mesh Uploader Utility + +enum MeshUploader { + static func upload( + vertices: [MeshVertex], + wireVertices: [MeshVertex], + faceCount: Int, + edgeCount: Int, + label: String, + device: MTLDevice + ) -> MeshHandle { + let vBuf: MTLBuffer? + if !vertices.isEmpty { + vBuf = device.makeBuffer( + bytes: vertices, + length: MemoryLayout.stride * vertices.count, + options: .storageModeShared + ) + } else { + // Empty placeholder buffer (1 byte) for objects with no solid geometry + vBuf = device.makeBuffer(length: 1, options: .storageModeShared) + } + + let wBuf: MTLBuffer? + if !wireVertices.isEmpty { + wBuf = device.makeBuffer( + bytes: wireVertices, + length: MemoryLayout.stride * wireVertices.count, + options: .storageModeShared + ) + } else { + wBuf = nil + } + + return MeshHandle( + vertexBuffer: vBuf!, + vertexCount: vertices.count, + wireVertexBuffer: wBuf, + wireVertexCount: wireVertices.count, + faceCount: faceCount, + edgeCount: edgeCount, + label: label + ) + } +} diff --git a/Sources/Scene/SceneObject.swift b/Sources/Scene/SceneObject.swift new file mode 100644 index 0000000..d15a33a --- /dev/null +++ b/Sources/Scene/SceneObject.swift @@ -0,0 +1,169 @@ +// SceneObject.swift +// A single node in the scene graph with transform, mesh, and hierarchy. + +import Observation +import simd +import Metal + +// MARK: - Object Type + +enum SceneObjectType: String, CaseIterable, Identifiable, Codable { + case mesh = "Mesh" + case camera = "Camera" + case light = "Light" + case empty = "Empty" + + var id: String { rawValue } + + var icon: String { + switch self { + case .mesh: "cube.fill" + case .camera: "camera.fill" + case .light: "light.max" + case .empty: "square.dashed" + } + } +} + +// MARK: - Transform + +@Observable +final class Transform { + var position: SIMD3 = .zero + var rotation: SIMD3 = .zero // Euler XYZ (radians) + var scale: SIMD3 = .init(repeating: 1) + + var modelMatrix: simd_float4x4 { + let t = MathUtils.translation(position) + let rx = MathUtils.rotationX(rotation.x) + let ry = MathUtils.rotationY(rotation.y) + let rz = MathUtils.rotationZ(rotation.z) + let s = MathUtils.scale(scale) + return t * rz * ry * rx * s + } + + func copy() -> Transform { + let c = Transform() + c.position = position + c.rotation = rotation + c.scale = scale + return c + } +} + +// MARK: - Mesh Handle (GPU-side) + +/// Holds the Metal buffers for a mesh. Shared across objects +/// that reference the same primitive (instancing-ready). +final class MeshHandle { + let vertexBuffer: MTLBuffer + let vertexCount: Int + let wireVertexBuffer: MTLBuffer? + let wireVertexCount: Int + let label: String + + // Stats + let faceCount: Int + let edgeCount: Int + + init( + vertexBuffer: MTLBuffer, + vertexCount: Int, + wireVertexBuffer: MTLBuffer? = nil, + wireVertexCount: Int = 0, + faceCount: Int = 0, + edgeCount: Int = 0, + label: String = "Mesh" + ) { + self.vertexBuffer = vertexBuffer + self.vertexCount = vertexCount + self.wireVertexBuffer = wireVertexBuffer + self.wireVertexCount = wireVertexCount + self.faceCount = faceCount + self.edgeCount = edgeCount + self.label = label + } +} + +// MARK: - Scene Object + +@Observable +final class SceneObject: Identifiable { + let id: UUID + var name: String + var type: SceneObjectType + let transform: Transform + + // Mesh data (nil for cameras/lights/empties) + var meshHandle: MeshHandle? + + // Hierarchy + weak var parent: SceneObject? + var children: [SceneObject] = [] + + // Visibility & selection + var isVisible: Bool = true + var isSelected: Bool = false + + // Per-object color (for viewport solid shading) + var color: SIMD4 = SIMD4(0.63, 0.63, 0.63, 1.0) + + // Bounding box (local space, axis-aligned) + var boundingBoxMin: SIMD3 = SIMD3(repeating: -1) + var boundingBoxMax: SIMD3 = SIMD3(repeating: 1) + + init( + name: String, + type: SceneObjectType, + position: SIMD3 = .zero + ) { + self.id = UUID() + self.name = name + self.type = type + self.transform = Transform() + self.transform.position = position + } + + // MARK: Hierarchy helpers + + func addChild(_ child: SceneObject) { + child.parent = self + children.append(child) + } + + func removeFromParent() { + parent?.children.removeAll { $0.id == self.id } + parent = nil + } + + /// World-space model matrix walking up the parent chain. + var worldMatrix: simd_float4x4 { + if let parent { + return parent.worldMatrix * transform.modelMatrix + } + return transform.modelMatrix + } + + /// Axis-aligned bounding box in world space. + var worldBounds: (min: SIMD3, max: SIMD3) { + let m = worldMatrix + // Transform all 8 corners and find min/max + let lo = boundingBoxMin + let hi = boundingBoxMax + let corners: [SIMD3] = [ + .init(lo.x, lo.y, lo.z), .init(hi.x, lo.y, lo.z), + .init(lo.x, hi.y, lo.z), .init(hi.x, hi.y, lo.z), + .init(lo.x, lo.y, hi.z), .init(hi.x, lo.y, hi.z), + .init(lo.x, hi.y, hi.z), .init(hi.x, hi.y, hi.z), + ] + var wMin = SIMD3(repeating: .greatestFiniteMagnitude) + var wMax = SIMD3(repeating: -.greatestFiniteMagnitude) + for c in corners { + let w = (m * SIMD4(c, 1)).xyz + wMin = min(wMin, w) + wMax = max(wMax, w) + } + return (wMin, wMax) + } +} + diff --git a/Sources/Shaders/ShaderTypes.h b/Sources/Shaders/ShaderTypes.h new file mode 100644 index 0000000..b7918cf --- /dev/null +++ b/Sources/Shaders/ShaderTypes.h @@ -0,0 +1,36 @@ +// ShaderTypes.h +// Shared between Metal shaders and Swift via bridging header. + +#pragma once + +#include + +typedef struct { + simd_float4x4 modelMatrix; + simd_float4x4 viewMatrix; + simd_float4x4 projectionMatrix; + simd_float4x4 normalMatrix; +} Uniforms; + +typedef struct { + simd_float3 position; + simd_float3 normal; + simd_float4 color; +} VertexIn; + +typedef struct { + simd_float3 cameraPosition; + simd_float3 lightDirection; + float ambientIntensity; + float diffuseIntensity; + float time; + float _pad; +} SceneConstants; + +typedef struct { + simd_float4 color; + float gridSpacing; + float gridLineWidth; + float gridFadeDistance; + float _pad; +} GridConstants; diff --git a/Sources/Shaders/Shaders.metal b/Sources/Shaders/Shaders.metal new file mode 100644 index 0000000..6f738d9 --- /dev/null +++ b/Sources/Shaders/Shaders.metal @@ -0,0 +1,157 @@ +// Shaders.metal +// BlenderiOS viewport shaders — cube, infinite grid, axis lines. + +#include +#include "ShaderTypes.h" + +using namespace metal; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Mesh (default cube) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +struct MeshVertexOut { + float4 position [[position]]; + float3 worldPos; + float3 worldNormal; + float4 color; +}; + +vertex MeshVertexOut mesh_vertex( + const device VertexIn *vertices [[buffer(0)]], + constant Uniforms &uniforms [[buffer(1)]], + uint vid [[vertex_id]] +) { + MeshVertexOut out; + float4 worldPos = uniforms.modelMatrix * float4(vertices[vid].position, 1.0); + out.worldPos = worldPos.xyz; + out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos; + out.worldNormal = (uniforms.normalMatrix * float4(vertices[vid].normal, 0.0)).xyz; + out.color = vertices[vid].color; + return out; +} + +fragment float4 mesh_fragment( + MeshVertexOut in [[stage_in]], + constant SceneConstants &scene [[buffer(0)]] +) { + float3 N = normalize(in.worldNormal); + float3 L = normalize(-scene.lightDirection); + + float ambient = scene.ambientIntensity; + float diffuse = scene.diffuseIntensity * max(dot(N, L), 0.0); + + // Rim light for that Blender viewport feel + float3 V = normalize(scene.cameraPosition - in.worldPos); + float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0) * 0.15; + + float3 lit = in.color.rgb * (ambient + diffuse) + rim; + return float4(lit, in.color.a); +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Wireframe overlay +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +struct WireVertexOut { + float4 position [[position]]; + float4 color; +}; + +vertex WireVertexOut wire_vertex( + const device VertexIn *vertices [[buffer(0)]], + constant Uniforms &uniforms [[buffer(1)]], + uint vid [[vertex_id]] +) { + WireVertexOut out; + // Slight bias toward camera to sit on top of solid faces + float4 worldPos = uniforms.modelMatrix * float4(vertices[vid].position, 1.0); + float4 clipPos = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos; + clipPos.z -= 0.0001; + out.position = clipPos; + out.color = float4(0.0, 0.0, 0.0, 0.35); + return out; +} + +fragment float4 wire_fragment(WireVertexOut in [[stage_in]]) { + return in.color; +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Infinite grid +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +struct GridVertexOut { + float4 position [[position]]; + float3 worldPos; +}; + +vertex GridVertexOut grid_vertex( + uint vid [[vertex_id]], + constant Uniforms &uniforms [[buffer(0)]] +) { + // Fullscreen quad on the XZ plane + float2 corners[6] = { + {-1, -1}, { 1, -1}, { 1, 1}, + {-1, -1}, { 1, 1}, {-1, 1} + }; + float scale = 50.0; + float3 worldPos = float3(corners[vid].x * scale, 0.0, corners[vid].y * scale); + + GridVertexOut out; + out.worldPos = worldPos; + out.position = uniforms.projectionMatrix * uniforms.viewMatrix * float4(worldPos, 1.0); + return out; +} + +fragment float4 grid_fragment( + GridVertexOut in [[stage_in]], + constant GridConstants &grid [[buffer(0)]], + constant SceneConstants &scene [[buffer(1)]] +) { + float2 coord = in.worldPos.xz / grid.gridSpacing; + float2 deriv = fwidth(coord); + float2 gridLine = abs(fract(coord - 0.5) - 0.5) / deriv; + float line = min(gridLine.x, gridLine.y); + float alpha = 1.0 - min(line, 1.0); + + // Fade with distance from camera + float dist = length(in.worldPos - scene.cameraPosition); + float fade = 1.0 - smoothstep(grid.gridFadeDistance * 0.5, + grid.gridFadeDistance, dist); + alpha *= fade * 0.3; + + // Highlight X axis (red) and Z axis (green-ish blue) + float4 color = grid.color; + if (abs(in.worldPos.z) < deriv.y * grid.gridSpacing * 0.5) { + color = float4(0.78, 0.14, 0.22, 1.0); // X axis red + alpha = fade * 0.8; + } + if (abs(in.worldPos.x) < deriv.x * grid.gridSpacing * 0.5) { + color = float4(0.22, 0.45, 0.78, 1.0); // Z axis blue + alpha = fade * 0.8; + } + + if (alpha < 0.005) discard_fragment(); + return float4(color.rgb, alpha); +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Origin axis gizmo (3 lines) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +vertex WireVertexOut axis_vertex( + const device VertexIn *vertices [[buffer(0)]], + constant Uniforms &uniforms [[buffer(1)]], + uint vid [[vertex_id]] +) { + WireVertexOut out; + float4 worldPos = float4(vertices[vid].position, 1.0); + out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos; + out.color = vertices[vid].color; + return out; +} + +fragment float4 axis_fragment(WireVertexOut in [[stage_in]]) { + return in.color; +} diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift new file mode 100644 index 0000000..ae98b7f --- /dev/null +++ b/Sources/UI/ContentView.swift @@ -0,0 +1,90 @@ +// ContentView.swift +// Root layout — header, tool sidebar, multi-viewport, properties, status bar. + +import SwiftUI + +struct ContentView: View { + @State private var context = MetalContext() + @State private var currentMode: EditMode = .object + @State private var activeTool: ViewportTool = .cursor + @State private var showProperties = true + @State private var showOutliner = true + + var body: some View { + GeometryReader { geo in + VStack(spacing: 0) { + // ── Top bar ── + HeaderBar( + currentMode: $currentMode, + layout: $context.viewportLayout, + context: context + ) + + // ── Main area ── + HStack(spacing: 0) { + // Left tool strip + ToolSidebar(activeTool: $activeTool) + + // Center: viewport(s) + MultiViewportView( + renderers: context.renderers, + layout: context.viewportLayout + ) + + // Right side: outliner + properties + if showProperties || showOutliner { + rightPanel(height: geo.size.height) + } + } + + // ── Bottom status ── + StatusBar(mode: currentMode, sceneGraph: context.sceneGraph) + } + } + .ignoresSafeArea(.keyboard) + .preferredColorScheme(.dark) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showOutliner.toggle() + } + } label: { + Image(systemName: "list.bullet") + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showProperties.toggle() + } + } label: { + Image(systemName: "sidebar.trailing") + } + } + } + } + } + + @ViewBuilder + private func rightPanel(height: CGFloat) -> some View { + VStack(spacing: 0) { + if showOutliner { + OutlinerPanel(sceneGraph: context.sceneGraph) + .frame(height: height * 0.3) + } + + if showOutliner && showProperties { + Divider() + } + + if showProperties { + PropertiesPanel( + isExpanded: $showProperties, + sceneGraph: context.sceneGraph + ) + } + } + .frame(width: 260) + } +} diff --git a/Sources/UI/HeaderBar.swift b/Sources/UI/HeaderBar.swift new file mode 100644 index 0000000..0503170 --- /dev/null +++ b/Sources/UI/HeaderBar.swift @@ -0,0 +1,127 @@ +// HeaderBar.swift +// Top bar with mode selector, Add menu, layout picker, and viewport shading. + +import SwiftUI + +struct HeaderBar: View { + @Binding var currentMode: EditMode + @Binding var layout: ViewportLayout + let context: MetalContext + + var body: some View { + HStack(spacing: 12) { + // App icon + Image(systemName: "cube.fill") + .font(.title3) + .foregroundStyle(.orange) + + Divider().frame(height: 20) + + // Mode picker + Menu { + ForEach(EditMode.allCases) { mode in + Button { + currentMode = mode + } label: { + Label(mode.rawValue, systemImage: mode.icon) + } + } + } label: { + Label(currentMode.rawValue, systemImage: currentMode.icon) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + + Divider().frame(height: 20) + + // Add mesh menu + Menu { + ForEach(PrimitiveType.allCases) { prim in + Button { + context.addPrimitive(prim) + } label: { + Label(prim.rawValue, systemImage: prim.icon) + } + } + } label: { + Label("Add", systemImage: "plus.circle") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + + // Delete + Button { + context.deleteSelected() + } label: { + Image(systemName: "trash") + .font(.subheadline) + } + .tint(.secondary) + + Spacer() + + // Layout picker + Menu { + ForEach(ViewportLayout.allCases) { vl in + Button { + withAnimation(.easeInOut(duration: 0.2)) { + layout = vl + } + } label: { + Label(vl.rawValue, systemImage: vl.icon) + } + } + } label: { + Image(systemName: layout.icon) + .font(.subheadline) + .padding(6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + + // Viewport shading + HStack(spacing: 4) { + shadingButton(icon: "square.grid.3x3", label: "Wireframe") + shadingButton(icon: "cube.fill", label: "Solid") + shadingButton(icon: "circle.lefthalf.filled", label: "Material") + shadingButton(icon: "sun.max.fill", label: "Rendered") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + } + + private func shadingButton(icon: String, label: String) -> some View { + Button {} label: { + Image(systemName: icon) + .font(.caption) + .frame(width: 28, height: 28) + } + .tint(.secondary) + .accessibilityLabel(label) + } +} + +// MARK: - Edit Mode + +enum EditMode: String, CaseIterable, Identifiable { + case object = "Object Mode" + case edit = "Edit Mode" + case sculpt = "Sculpt Mode" + case texture = "Texture Paint" + + var id: String { rawValue } + + var icon: String { + switch self { + case .object: "arrow.up.left.and.arrow.down.right" + case .edit: "pencil.and.outline" + case .sculpt: "paintbrush.pointed.fill" + case .texture: "paintbrush.fill" + } + } +} diff --git a/Sources/UI/PropertiesPanel.swift b/Sources/UI/PropertiesPanel.swift new file mode 100644 index 0000000..c93f581 --- /dev/null +++ b/Sources/UI/PropertiesPanel.swift @@ -0,0 +1,265 @@ +// PropertiesPanel.swift +// Right-side properties inspector, driven by SceneGraph selection. + +import SwiftUI + +struct PropertiesPanel: View { + @Binding var isExpanded: Bool + let sceneGraph: SceneGraph + + var body: some View { + if isExpanded { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + panelHeader + + if let obj = sceneGraph.selectedObject { + // Transform section + PropertySection(title: "Transform", icon: "move.3d") { + TransformEditor(transform: obj.transform) + } + + // Mesh info (if mesh type) + if let mesh = obj.meshHandle { + PropertySection(title: "Mesh", icon: "cube") { + InfoRow(label: "Vertices", value: "\(mesh.vertexCount / 3 * 3)") + InfoRow(label: "Edges", value: "\(mesh.edgeCount)") + InfoRow(label: "Faces", value: "\(mesh.faceCount)") + } + } + + // Object info + PropertySection(title: "Object", icon: "info.circle") { + InfoRow(label: "Type", value: obj.type.rawValue) + InfoRow(label: "Visible", value: obj.isVisible ? "Yes" : "No") + } + + // Modifiers (placeholder) + PropertySection(title: "Modifiers", icon: "wrench") { + Text("No modifiers") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("No selection") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 20) + } + } + .padding(12) + } + .frame(width: 260) + .background(.ultraThinMaterial) + } + } + + private var panelHeader: some View { + HStack { + if let obj = sceneGraph.selectedObject { + Image(systemName: obj.type.icon) + .foregroundStyle(.orange) + Text(obj.name) + .font(.subheadline.weight(.semibold)) + } else { + Image(systemName: "questionmark.square.dashed") + .foregroundStyle(.secondary) + Text("Properties") + .font(.subheadline.weight(.semibold)) + } + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded = false + } + } label: { + Image(systemName: "sidebar.trailing") + .font(.caption) + } + .tint(.secondary) + } + } +} + +// MARK: - Outliner + +struct OutlinerPanel: View { + let sceneGraph: SceneGraph + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Scene Collection") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.top, 8) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(sceneGraph.rootObjects) { obj in + OutlinerRow(object: obj, sceneGraph: sceneGraph) + } + } + } + } + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + } +} + +struct OutlinerRow: View { + let object: SceneObject + let sceneGraph: SceneGraph + + var body: some View { + Button { + sceneGraph.select(object) + } label: { + HStack(spacing: 6) { + Image(systemName: object.children.isEmpty ? "circle.fill" : "chevron.right") + .font(.system(size: 7)) + .foregroundStyle(.secondary) + .frame(width: 12) + + Image(systemName: object.type.icon) + .font(.caption2) + .foregroundStyle(object.isSelected ? .orange : .secondary) + + Text(object.name) + .font(.caption) + .foregroundStyle(object.isSelected ? .primary : .secondary) + + Spacer() + + // Visibility toggle + Button { + object.isVisible.toggle() + } label: { + Image(systemName: object.isVisible ? "eye" : "eye.slash") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(object.isSelected ? Color.orange.opacity(0.12) : .clear) + } + .buttonStyle(.plain) + } +} + +// MARK: - Transform Editor + +struct TransformEditor: View { + let transform: Transform + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + TransformRow( + label: "Location", + x: transform.position.x, + y: transform.position.y, + z: transform.position.z + ) + TransformRow( + label: "Rotation", + x: transform.rotation.x * 180 / .pi, + y: transform.rotation.y * 180 / .pi, + z: transform.rotation.z * 180 / .pi + ) + TransformRow( + label: "Scale", + x: transform.scale.x, + y: transform.scale.y, + z: transform.scale.z + ) + } + } +} + +// MARK: - Property sub-views + +struct PropertySection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + @State private var expanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + expanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9)) + Image(systemName: icon) + .font(.caption2) + Text(title) + .font(.caption.weight(.semibold)) + Spacer() + } + .foregroundStyle(.primary) + } + + if expanded { + content + .padding(.leading, 16) + } + } + } +} + +struct TransformRow: View { + let label: String + let x: Float + let y: Float + let z: Float + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + HStack(spacing: 4) { + field("X", value: x, color: .red) + field("Y", value: y, color: .green) + field("Z", value: z, color: .blue) + } + } + } + + private func field(_ axis: String, value: Float, color: Color) -> some View { + HStack(spacing: 2) { + Text(axis) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(color) + Text(String(format: "%.3f", value)) + .font(.system(size: 10, design: .monospaced)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 3)) + } +} + +struct InfoRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .font(.caption.weight(.medium).monospacedDigit()) + } + } +} diff --git a/Sources/UI/StatusBar.swift b/Sources/UI/StatusBar.swift new file mode 100644 index 0000000..2f12822 --- /dev/null +++ b/Sources/UI/StatusBar.swift @@ -0,0 +1,41 @@ +// StatusBar.swift +// Bottom info bar showing live stats from the SceneGraph. + +import SwiftUI + +struct StatusBar: View { + let mode: EditMode + let sceneGraph: SceneGraph + + var body: some View { + HStack(spacing: 16) { + Label(mode.rawValue, systemImage: mode.icon) + .font(.caption2) + + Divider().frame(height: 12) + + Text("Objects: \(sceneGraph.allObjects.count)") + .font(.caption2.monospacedDigit()) + Text("Verts: \(sceneGraph.totalVertexCount)") + .font(.caption2.monospacedDigit()) + Text("Faces: \(sceneGraph.totalFaceCount)") + .font(.caption2.monospacedDigit()) + + Spacer() + + if let sel = sceneGraph.selectedObject { + Text(sel.name) + .font(.caption2) + .foregroundStyle(.orange) + } + + Text("Collection | Scene") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .foregroundStyle(.secondary) + } +} diff --git a/Sources/UI/ToolSidebar.swift b/Sources/UI/ToolSidebar.swift new file mode 100644 index 0000000..0429dda --- /dev/null +++ b/Sources/UI/ToolSidebar.swift @@ -0,0 +1,60 @@ +// ToolSidebar.swift +// Vertical tool strip on the left edge, matching Blender's T-panel. + +import SwiftUI + +struct ToolSidebar: View { + @Binding var activeTool: ViewportTool + + var body: some View { + VStack(spacing: 4) { + ForEach(ViewportTool.allCases) { tool in + Button { + activeTool = tool + } label: { + Image(systemName: tool.icon) + .font(.system(size: 16)) + .frame(width: 36, height: 36) + .background( + activeTool == tool + ? Color.accentColor.opacity(0.25) + : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .tint(activeTool == tool ? .orange : .secondary) + .accessibilityLabel(tool.rawValue) + } + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + .background(.ultraThinMaterial) + } +} + +// MARK: - Tools + +enum ViewportTool: String, CaseIterable, Identifiable { + case cursor = "Cursor" + case move = "Move" + case rotate = "Rotate" + case scale = "Scale" + case box = "Box Select" + case measure = "Measure" + case annotate = "Annotate" + + var id: String { rawValue } + + var icon: String { + switch self { + case .cursor: "target" + case .move: "arrow.up.and.down.and.arrow.left.and.right" + case .rotate: "arrow.triangle.2.circlepath" + case .scale: "arrow.up.left.and.arrow.down.right.and.sparkle" + case .box: "rectangle.dashed" + case .measure: "ruler" + case .annotate: "pencil.tip" + } + } +} diff --git a/Sources/Viewport/MeshData.swift b/Sources/Viewport/MeshData.swift new file mode 100644 index 0000000..dcf22e1 --- /dev/null +++ b/Sources/Viewport/MeshData.swift @@ -0,0 +1,10 @@ +// MeshData.swift +// Vertex type shared between CPU mesh generation and Metal rendering. + +import simd + +struct MeshVertex { + var position: SIMD3 + var normal: SIMD3 + var color: SIMD4 +} diff --git a/Sources/Viewport/MultiViewportView.swift b/Sources/Viewport/MultiViewportView.swift new file mode 100644 index 0000000..12ab7d9 --- /dev/null +++ b/Sources/Viewport/MultiViewportView.swift @@ -0,0 +1,196 @@ +// MultiViewportView.swift +// Feature 18: Split the viewport into 1, 2, or 4 panes. +// Each pane has its own camera. All share the same SceneGraph. + +import SwiftUI +import Metal + +// MARK: - Layout modes + +enum ViewportLayout: String, CaseIterable, Identifiable { + case single = "Single" + case splitH = "Split Horizontal" + case splitV = "Split Vertical" + case quad = "Quad" + + var id: String { rawValue } + + var icon: String { + switch self { + case .single: "rectangle" + case .splitH: "rectangle.split.2x1" + case .splitV: "rectangle.split.1x2" + case .quad: "rectangle.split.2x2" + } + } +} + +// MARK: - Preset camera angles + +enum CameraPreset { + case perspective, top, front, right + + func configure(_ camera: OrbitalCamera) { + switch self { + case .perspective: + camera.azimuth = Float.pi * 0.25 + camera.elevation = Float.pi * 0.2 + camera.distance = 7.0 + case .top: + camera.azimuth = 0 + camera.elevation = Float.pi * 0.499 + camera.distance = 10.0 + case .front: + camera.azimuth = 0 + camera.elevation = 0 + camera.distance = 10.0 + case .right: + camera.azimuth = Float.pi * 0.5 + camera.elevation = 0 + camera.distance = 10.0 + } + } +} + +// MARK: - Multi-Viewport View + +struct MultiViewportView: View { + let renderers: [ViewportRenderer] + let layout: ViewportLayout + + // Divider positions (0...1) + @State private var hSplit: CGFloat = 0.5 + @State private var vSplit: CGFloat = 0.5 + + private let dividerThickness: CGFloat = 2 + private let dividerHitArea: CGFloat = 16 + + var body: some View { + GeometryReader { geo in + switch layout { + case .single: + singleLayout() + + case .splitH: + splitHorizontal(size: geo.size) + + case .splitV: + splitVertical(size: geo.size) + + case .quad: + quadLayout(size: geo.size) + } + } + .clipped() + } + + // MARK: - Layouts + + @ViewBuilder + private func singleLayout() -> some View { + pane(index: 0, label: "Perspective") + } + + @ViewBuilder + private func splitHorizontal(size: CGSize) -> some View { + let leftWidth = size.width * hSplit - dividerThickness * 0.5 + let rightWidth = size.width * (1 - hSplit) - dividerThickness * 0.5 + + HStack(spacing: 0) { + pane(index: 0, label: "Perspective") + .frame(width: max(leftWidth, 40)) + + dividerView(axis: .vertical, size: size) + + pane(index: 1, label: "Right") + .frame(width: max(rightWidth, 40)) + } + } + + @ViewBuilder + private func splitVertical(size: CGSize) -> some View { + let topHeight = size.height * vSplit - dividerThickness * 0.5 + let bottomHeight = size.height * (1 - vSplit) - dividerThickness * 0.5 + + VStack(spacing: 0) { + pane(index: 0, label: "Perspective") + .frame(height: max(topHeight, 40)) + + dividerView(axis: .horizontal, size: size) + + pane(index: 1, label: "Front") + .frame(height: max(bottomHeight, 40)) + } + } + + @ViewBuilder + private func quadLayout(size: CGSize) -> some View { + let leftW = size.width * hSplit - dividerThickness * 0.5 + let rightW = size.width * (1 - hSplit) - dividerThickness * 0.5 + let topH = size.height * vSplit - dividerThickness * 0.5 + let botH = size.height * (1 - vSplit) - dividerThickness * 0.5 + + VStack(spacing: 0) { + HStack(spacing: 0) { + pane(index: 0, label: "Top") + .frame(width: max(leftW, 40), height: max(topH, 40)) + dividerView(axis: .vertical, size: size) + pane(index: 1, label: "Perspective") + .frame(width: max(rightW, 40), height: max(topH, 40)) + } + .frame(height: max(topH, 40)) + + dividerView(axis: .horizontal, size: size) + + HStack(spacing: 0) { + pane(index: 2, label: "Front") + .frame(width: max(leftW, 40), height: max(botH, 40)) + dividerView(axis: .vertical, size: size) + pane(index: 3, label: "Right") + .frame(width: max(rightW, 40), height: max(botH, 40)) + } + .frame(height: max(botH, 40)) + } + } + + // MARK: - Pane helper + + @ViewBuilder + private func pane(index: Int, label: String) -> some View { + if index < renderers.count { + ViewportPane(renderer: renderers[index], label: label) + } else { + Color(white: 0.15) + .overlay(Text("No renderer").foregroundStyle(.secondary)) + } + } + + // MARK: - Draggable divider + + @ViewBuilder + private func dividerView(axis: Axis, size: CGSize) -> some View { + Rectangle() + .fill(Color.white.opacity(0.15)) + .frame( + width: axis == .vertical ? dividerThickness : nil, + height: axis == .horizontal ? dividerThickness : nil + ) + .contentShape(Rectangle().size( + width: axis == .vertical ? dividerHitArea : size.width, + height: axis == .horizontal ? dividerHitArea : size.height + )) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + switch axis { + case .vertical: + let newH = value.location.x / size.width + hSplit = min(max(newH, 0.15), 0.85) + case .horizontal: + let newV = value.location.y / size.height + vSplit = min(max(newV, 0.15), 0.85) + } + } + ) + } +} diff --git a/Sources/Viewport/OrbitalCamera.swift b/Sources/Viewport/OrbitalCamera.swift new file mode 100644 index 0000000..b6b746c --- /dev/null +++ b/Sources/Viewport/OrbitalCamera.swift @@ -0,0 +1,55 @@ +// OrbitalCamera.swift +// Blender-style orbit camera with pan, orbit, and dolly zoom. + +import Observation +import simd + +@Observable +final class OrbitalCamera { + + var azimuth: Float = Float.pi * 0.25 + var elevation: Float = Float.pi * 0.2 + var target: SIMD3 = .zero + var distance: Float = 7.0 + + private let minDistance: Float = 0.5 + private let maxDistance: Float = 200.0 + private let minElevation: Float = -Float.pi * 0.49 + private let maxElevation: Float = Float.pi * 0.49 + + var eye: SIMD3 { + let x = distance * cosf(elevation) * sinf(azimuth) + let y = distance * sinf(elevation) + let z = distance * cosf(elevation) * cosf(azimuth) + return target + SIMD3(x, y, z) + } + + var viewMatrix: simd_float4x4 { + MathUtils.lookAt(eye: eye, center: target, up: SIMD3(0, 1, 0)) + } + + func orbit(deltaX: Float, deltaY: Float) { + azimuth -= deltaX * 0.008 + elevation += deltaY * 0.008 + elevation = min(max(elevation, minElevation), maxElevation) + } + + func pan(deltaX: Float, deltaY: Float) { + let right = SIMD3(cosf(azimuth), 0, -sinf(azimuth)) + let up = SIMD3(0, 1, 0) + let speed: Float = distance * 0.002 + target += right * (-deltaX * speed) + up * (deltaY * speed) + } + + func zoom(scale: Float) { + distance /= scale + distance = min(max(distance, minDistance), maxDistance) + } + + func reset() { + azimuth = Float.pi * 0.25 + elevation = Float.pi * 0.2 + distance = 7.0 + target = .zero + } +} diff --git a/Sources/Viewport/ViewportRenderer.swift b/Sources/Viewport/ViewportRenderer.swift new file mode 100644 index 0000000..c83673f --- /dev/null +++ b/Sources/Viewport/ViewportRenderer.swift @@ -0,0 +1,281 @@ +// ViewportRenderer.swift +// Draws all objects from the SceneGraph using Metal. +// Each ViewportPane owns one instance with its own command queue + camera. + +import Metal +import MetalKit +import Observation +import simd + +@Observable +final class ViewportRenderer { + + // MARK: - Public state + + var viewportSize: SIMD2 = SIMD2(1, 1) + let camera: OrbitalCamera + + // Weak ref to the shared scene graph (set externally) + var sceneGraph: SceneGraph? + + // MARK: - Metal objects (owned per-pane) + + private(set) var device: MTLDevice? + private var commandQueue: MTLCommandQueue? + + // Pipelines (shared via MetalSharedState) + private var meshPipeline: MTLRenderPipelineState? + private var wirePipeline: MTLRenderPipelineState? + private var gridPipeline: MTLRenderPipelineState? + private var axisPipeline: MTLRenderPipelineState? + private var selectionPipeline: MTLRenderPipelineState? + + private var depthState: MTLDepthStencilState? + private var depthStateNoWrite: MTLDepthStencilState? + + // Axis gizmo buffer + private var axisVertexBuffer: MTLBuffer? + + init(camera: OrbitalCamera = OrbitalCamera()) { + self.camera = camera + } + + // MARK: - Setup + + func configure(device: MTLDevice, library: MTLLibrary) { + self.device = device + self.commandQueue = device.makeCommandQueue()! + + // ── Pipelines ── + meshPipeline = Self.makePipeline(device: device, library: library, + vertex: "mesh_vertex", fragment: "mesh_fragment", + label: "Mesh", blending: false) + wirePipeline = Self.makePipeline(device: device, library: library, + vertex: "wire_vertex", fragment: "wire_fragment", + label: "Wire", blending: true) + gridPipeline = Self.makePipeline(device: device, library: library, + vertex: "grid_vertex", fragment: "grid_fragment", + label: "Grid", blending: true) + axisPipeline = Self.makePipeline(device: device, library: library, + vertex: "axis_vertex", fragment: "axis_fragment", + label: "Axis", blending: true) + selectionPipeline = Self.makePipeline(device: device, library: library, + vertex: "wire_vertex", fragment: "wire_fragment", + label: "Selection", blending: true) + + // ── Depth states ── + let dd = MTLDepthStencilDescriptor() + dd.depthCompareFunction = .less + dd.isDepthWriteEnabled = true + depthState = device.makeDepthStencilState(descriptor: dd) + + let ddNoWrite = MTLDepthStencilDescriptor() + ddNoWrite.depthCompareFunction = .lessEqual + ddNoWrite.isDepthWriteEnabled = false + depthStateNoWrite = device.makeDepthStencilState(descriptor: ddNoWrite) + + // ── Axis gizmo ── + let axisVerts = Self.buildAxisVertices() + axisVertexBuffer = device.makeBuffer( + bytes: axisVerts, + length: MemoryLayout.stride * axisVerts.count, + options: .storageModeShared + ) + } + + // MARK: - Per-Frame Draw + + func draw(drawable: CAMetalDrawable, passDescriptor: MTLRenderPassDescriptor) { + guard let commandQueue, let device, + let meshPipeline, let wirePipeline, + let gridPipeline, let axisPipeline, + let depthState, let depthStateNoWrite + else { return } + + guard let buffer = commandQueue.makeCommandBuffer(), + let encoder = buffer.makeRenderCommandEncoder(descriptor: passDescriptor) + else { return } + + let aspect = viewportSize.x / max(viewportSize.y, 1) + let projection = MathUtils.perspective( + fovYRadians: Float.pi / 4.0, aspect: aspect, + near: 0.1, far: 500.0 + ) + let view = camera.viewMatrix + + var sceneConstants = SceneConstants( + cameraPosition: camera.eye, + lightDirection: normalize(SIMD3(-0.5, -1.0, -0.7)), + ambientIntensity: 0.25, + diffuseIntensity: 0.75, + time: 0, _pad: 0 + ) + + // ── 1. Grid ── + drawGrid(encoder: encoder, pipeline: gridPipeline, + depthState: depthStateNoWrite, + view: view, projection: projection, + sceneConstants: &sceneConstants) + + // ── 2. Axis gizmo ── + if let axisBuf = axisVertexBuffer { + var identity = Uniforms( + modelMatrix: matrix_identity_float4x4, + viewMatrix: view, + projectionMatrix: projection, + normalMatrix: matrix_identity_float4x4 + ) + encoder.setRenderPipelineState(axisPipeline) + encoder.setDepthStencilState(depthState) + encoder.setVertexBuffer(axisBuf, offset: 0, index: 0) + encoder.setVertexBytes(&identity, length: MemoryLayout.size, index: 1) + encoder.drawPrimitives(type: .line, vertexStart: 0, vertexCount: 6) + } + + // ── 3. Scene objects ── + if let scene = sceneGraph { + for obj in scene.allObjects where obj.isVisible { + guard let mesh = obj.meshHandle else { continue } + + let model = obj.worldMatrix + var uniforms = Uniforms( + modelMatrix: model, + viewMatrix: view, + projectionMatrix: projection, + normalMatrix: MathUtils.normalMatrix(from: model) + ) + + // Solid geometry + if mesh.vertexCount > 0 { + encoder.setRenderPipelineState(meshPipeline) + encoder.setDepthStencilState(depthState) + encoder.setVertexBuffer(mesh.vertexBuffer, offset: 0, index: 0) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.size, index: 1) + encoder.setFragmentBytes(&sceneConstants, length: MemoryLayout.size, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: mesh.vertexCount) + } + + // Wireframe overlay + if let wireBuf = mesh.wireVertexBuffer, mesh.wireVertexCount > 0 { + encoder.setRenderPipelineState(wirePipeline) + encoder.setDepthStencilState(depthStateNoWrite) + encoder.setVertexBuffer(wireBuf, offset: 0, index: 0) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.size, index: 1) + encoder.drawPrimitives(type: .line, vertexStart: 0, vertexCount: mesh.wireVertexCount) + } + + // Selection outline (orange) + if obj.isSelected, let wireBuf = mesh.wireVertexBuffer, mesh.wireVertexCount > 0 { + // Build selection wire verts from existing wire with orange color + drawSelectionOutline( + encoder: encoder, + pipeline: wirePipeline, + depthState: depthStateNoWrite, + wireBuffer: wireBuf, + wireCount: mesh.wireVertexCount, + uniforms: &uniforms, + device: device + ) + } + } + } + + encoder.endEncoding() + buffer.present(drawable) + buffer.commit() + } + + // MARK: - Grid + + private func drawGrid( + encoder: MTLRenderCommandEncoder, + pipeline: MTLRenderPipelineState, + depthState: MTLDepthStencilState, + view: simd_float4x4, + projection: simd_float4x4, + sceneConstants: inout SceneConstants + ) { + var identity = Uniforms( + modelMatrix: matrix_identity_float4x4, + viewMatrix: view, + projectionMatrix: projection, + normalMatrix: matrix_identity_float4x4 + ) + encoder.setRenderPipelineState(pipeline) + encoder.setDepthStencilState(depthState) + encoder.setVertexBytes(&identity, length: MemoryLayout.size, index: 0) + + var gridConstants = GridConstants( + color: SIMD4(0.5, 0.5, 0.5, 1), + gridSpacing: 1.0, gridLineWidth: 1.0, + gridFadeDistance: 40.0, _pad: 0 + ) + encoder.setFragmentBytes(&gridConstants, length: MemoryLayout.size, index: 0) + encoder.setFragmentBytes(&sceneConstants, length: MemoryLayout.size, index: 1) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + } + + // MARK: - Selection outline + + private func drawSelectionOutline( + encoder: MTLRenderCommandEncoder, + pipeline: MTLRenderPipelineState, + depthState: MTLDepthStencilState, + wireBuffer: MTLBuffer, + wireCount: Int, + uniforms: inout Uniforms, + device: MTLDevice + ) { + // We re-use the wire vertex positions but override color in the shader + // by drawing the same wire buffer with a slightly expanded model matrix + // Draw selection wireframe (uses wire buffer colors as-is for now) + encoder.setRenderPipelineState(pipeline) + encoder.setDepthStencilState(depthState) + encoder.setVertexBuffer(wireBuffer, offset: 0, index: 0) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.size, index: 1) + encoder.drawPrimitives(type: .line, vertexStart: 0, vertexCount: wireCount) + } + + // MARK: - Static helpers + + private static func buildAxisVertices() -> [MeshVertex] { + let len: Float = 1.5 + let red = SIMD4(0.78, 0.14, 0.22, 1.0) + let green = SIMD4(0.22, 0.68, 0.28, 1.0) + let blue = SIMD4(0.22, 0.45, 0.78, 1.0) + return [ + .init(position: .zero, normal: .zero, color: red), + .init(position: .init(len, 0, 0), normal: .zero, color: red), + .init(position: .zero, normal: .zero, color: green), + .init(position: .init(0, len, 0), normal: .zero, color: green), + .init(position: .zero, normal: .zero, color: blue), + .init(position: .init(0, 0, len), normal: .zero, color: blue), + ] + } + + static func makePipeline( + device: MTLDevice, + library: MTLLibrary, + vertex: String, + fragment: String, + label: String, + blending: Bool + ) -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.label = label + desc.vertexFunction = library.makeFunction(name: vertex) + desc.fragmentFunction = library.makeFunction(name: fragment) + desc.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb + desc.depthAttachmentPixelFormat = .depth32Float + + if blending { + desc.colorAttachments[0].isBlendingEnabled = true + desc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + desc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + desc.colorAttachments[0].sourceAlphaBlendFactor = .one + desc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + } + + return try! device.makeRenderPipelineState(descriptor: desc) + } +} diff --git a/Sources/Viewport/ViewportView.swift b/Sources/Viewport/ViewportView.swift new file mode 100644 index 0000000..89f94a7 --- /dev/null +++ b/Sources/Viewport/ViewportView.swift @@ -0,0 +1,114 @@ +// ViewportView.swift +// A single viewport pane — MTKView wrapper with gesture handling. + +import SwiftUI +import MetalKit + +// MARK: - Single Viewport Pane + +struct ViewportPane: View { + let renderer: ViewportRenderer + let label: String + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + MetalViewRepresentable( + renderer: renderer, + size: geo.size + ) + .ignoresSafeArea() + .gesture(orbitGesture) + .gesture(zoomGesture) + .onTapGesture(count: 2) { + renderer.camera.reset() + } + + // Viewport label + Text(label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white.opacity(0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 4)) + .padding(8) + } + } + } + + private var orbitGesture: some Gesture { + DragGesture(minimumDistance: 1) + .onChanged { value in + renderer.camera.orbit( + deltaX: Float(value.translation.width) * 0.15, + deltaY: Float(value.translation.height) * 0.15 + ) + } + } + + private var zoomGesture: some Gesture { + MagnifyGesture() + .onChanged { value in + renderer.camera.zoom(scale: Float(value.magnification)) + } + } +} + +// MARK: - UIViewRepresentable + +struct MetalViewRepresentable: UIViewRepresentable { + let renderer: ViewportRenderer + let size: CGSize + + func makeCoordinator() -> ViewportCoordinator { + ViewportCoordinator(renderer: renderer) + } + + func makeUIView(context: Context) -> MTKView { + guard let device = renderer.device ?? MTLCreateSystemDefaultDevice() else { + fatalError("Metal is not supported on this device.") + } + + let mtkView = MTKView(frame: .zero, device: device) + mtkView.delegate = context.coordinator + mtkView.colorPixelFormat = .bgra8Unorm_srgb + mtkView.depthStencilPixelFormat = .depth32Float + mtkView.clearColor = MTLClearColor(red: 0.224, green: 0.224, blue: 0.224, alpha: 1.0) + mtkView.preferredFramesPerSecond = 120 + mtkView.enableSetNeedsDisplay = false + mtkView.isPaused = false + mtkView.framebufferOnly = false + mtkView.isMultipleTouchEnabled = true + + return mtkView + } + + func updateUIView(_ mtkView: MTKView, context: Context) { + let scale = mtkView.contentScaleFactor + mtkView.drawableSize = CGSize( + width: size.width * scale, + height: size.height * scale + ) + } +} + +// MARK: - Coordinator + +final class ViewportCoordinator: NSObject, MTKViewDelegate { + let renderer: ViewportRenderer + + init(renderer: ViewportRenderer) { + self.renderer = renderer + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + renderer.viewportSize = SIMD2(Float(size.width), Float(size.height)) + } + + func draw(in view: MTKView) { + guard let drawable = view.currentDrawable, + let descriptor = view.currentRenderPassDescriptor + else { return } + renderer.draw(drawable: drawable, passDescriptor: descriptor) + } +} diff --git a/generate.sh b/generate.sh new file mode 100755 index 0000000..885dfc7 --- /dev/null +++ b/generate.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +# Install xcodegen if missing +if ! command -v xcodegen &> /dev/null; then + echo "Installing XcodeGen via Homebrew..." + brew install xcodegen +fi + +cd "$(dirname "$0")" +echo "Generating Xcode project..." +xcodegen generate +echo "Done. Open BlenderiOS.xcodeproj" +open BlenderiOS.xcodeproj diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..69922c8 --- /dev/null +++ b/project.yml @@ -0,0 +1,29 @@ +name: BlenderiOS +options: + bundleIdPrefix: ca.dallasgroot + deploymentTarget: + iOS: "26.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_OBJC_BRIDGING_HEADER: Sources/App/BlenderiOS-Bridging-Header.h + HEADER_SEARCH_PATHS: + - Sources/Shaders + +targets: + BlenderiOS: + type: application + platform: iOS + sources: + - path: Sources + type: group + settings: + base: + GENERATE_INFOPLIST_FILE: true + INFOPLIST_KEY_UILaunchScreen_Generation: true + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait" + TARGETED_DEVICE_FAMILY: "2" # iPad only + PRODUCT_BUNDLE_IDENTIFIER: ca.dallasgroot.BlenderiOS + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + SWIFT_EMIT_LOC_STRINGS: true