initial commit

This commit is contained in:
Dallas Groot 2026-04-10 21:34:23 -07:00
parent 6d8dec50c7
commit cddd491720
22 changed files with 2596 additions and 0 deletions

0
Delete
View file

View file

@ -0,0 +1,3 @@
// BlenderiOS-Bridging-Header.h
#pragma once
#import "ShaderTypes.h"

View file

@ -0,0 +1,15 @@
// BlenderiOSApp.swift
import SwiftUI
@main
struct BlenderiOSApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
.navigationBarTitleDisplayMode(.inline)
}
}
}
}

View file

@ -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<Float>(x, 0, 0, 0),
SIMD4<Float>(0, y, 0, 0),
SIMD4<Float>(0, 0, z, -1),
SIMD4<Float>(0, 0, z * near, 0)
))
}
// MARK: - View
static func lookAt(
eye: SIMD3<Float>,
center: SIMD3<Float>,
up: SIMD3<Float>
) -> simd_float4x4 {
let f = normalize(center - eye)
let s = normalize(cross(f, up))
let u = cross(s, f)
return simd_float4x4(columns: (
SIMD4<Float>(s.x, u.x, -f.x, 0),
SIMD4<Float>(s.y, u.y, -f.y, 0),
SIMD4<Float>(s.z, u.z, -f.z, 0),
SIMD4<Float>(-dot(s, eye), -dot(u, eye), dot(f, eye), 1)
))
}
// MARK: - Model transforms
static func translation(_ t: SIMD3<Float>) -> simd_float4x4 {
var m = matrix_identity_float4x4
m.columns.3 = SIMD4<Float>(t.x, t.y, t.z, 1)
return m
}
static func scale(_ s: SIMD3<Float>) -> simd_float4x4 {
return simd_float4x4(diagonal: SIMD4<Float>(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<Float>(1, 0, 0, 0),
SIMD4<Float>(0, c, s, 0),
SIMD4<Float>(0, -s, c, 0),
SIMD4<Float>(0, 0, 0, 1)
))
}
static func rotationY(_ angle: Float) -> simd_float4x4 {
let c = cosf(angle)
let s = sinf(angle)
return simd_float4x4(columns: (
SIMD4<Float>( c, 0, s, 0),
SIMD4<Float>( 0, 1, 0, 0),
SIMD4<Float>(-s, 0, c, 0),
SIMD4<Float>( 0, 0, 0, 1)
))
}
static func rotationZ(_ angle: Float) -> simd_float4x4 {
let c = cosf(angle)
let s = sinf(angle)
return simd_float4x4(columns: (
SIMD4<Float>( c, s, 0, 0),
SIMD4<Float>(-s, c, 0, 0),
SIMD4<Float>( 0, 0, 1, 0),
SIMD4<Float>( 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<Float>(inv.columns.0, 0),
SIMD4<Float>(inv.columns.1, 0),
SIMD4<Float>(inv.columns.2, 0),
SIMD4<Float>(0, 0, 0, 1)
))
}
}
// MARK: - SIMD helpers
extension SIMD4 where Scalar == Float {
var xyz: SIMD3<Float> { .init(x, y, z) }
}

View file

@ -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<Float>
let boundingBoxMax: SIMD3<Float>
}
enum PrimitiveGenerator {
// MARK: - Cube
static func cube(
size: Float = 2.0,
color: SIMD4<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
let h = size * 0.5
let positions: [SIMD3<Float>] = [
.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<Float>)] = [
([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<Float>(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<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
let h = size * 0.5
let n: SIMD3<Float> = .init(0, 1, 0)
let p: [SIMD3<Float>] = [
.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<Float>(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<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
var verts: [MeshVertex] = []
var wireVerts: [MeshVertex] = []
let wc = SIMD4<Float>(0, 0, 0, 0.3)
// Generate grid of positions
var grid: [[SIMD3<Float>]] = []
for ring in 0...rings {
var row: [SIMD3<Float>] = []
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..<rings {
for seg in 0..<segments {
let p00 = grid[ring][seg]
let p10 = grid[ring + 1][seg]
let p01 = grid[ring][seg + 1]
let p11 = grid[ring + 1][seg + 1]
let n00 = normalize(p00)
let n10 = normalize(p10)
let n01 = normalize(p01)
let n11 = normalize(p11)
if ring != 0 {
verts.append(.init(position: p00, normal: n00, color: color))
verts.append(.init(position: p10, normal: n10, color: color))
verts.append(.init(position: p01, normal: n01, color: color))
}
if ring != rings - 1 {
verts.append(.init(position: p01, normal: n01, color: color))
verts.append(.init(position: p10, normal: n10, color: color))
verts.append(.init(position: p11, normal: n11, color: color))
}
// Wireframe: horizontal + vertical lines
wireVerts.append(.init(position: p00, normal: .zero, color: wc))
wireVerts.append(.init(position: p01, normal: .zero, color: wc))
wireVerts.append(.init(position: p00, normal: .zero, color: wc))
wireVerts.append(.init(position: p10, normal: .zero, color: wc))
}
}
return PrimitiveMeshData(
vertices: verts, wireVertices: wireVerts,
faceCount: segments * rings,
edgeCount: segments * rings * 2,
boundingBoxMin: .init(repeating: -radius),
boundingBoxMax: .init(repeating: radius)
)
}
// MARK: - Cylinder
static func cylinder(
radius: Float = 1.0,
height: Float = 2.0,
segments: Int = 32,
color: SIMD4<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
let halfH = height * 0.5
var verts: [MeshVertex] = []
var wireVerts: [MeshVertex] = []
let wc = SIMD4<Float>(0, 0, 0, 0.3)
for i in 0..<segments {
let a0 = 2.0 * Float.pi * Float(i) / Float(segments)
let a1 = 2.0 * Float.pi * Float(i + 1) / Float(segments)
let c0 = cosf(a0), s0 = sinf(a0)
let c1 = cosf(a1), s1 = sinf(a1)
let bl = SIMD3<Float>(radius * c0, -halfH, radius * s0)
let br = SIMD3<Float>(radius * c1, -halfH, radius * s1)
let tl = SIMD3<Float>(radius * c0, halfH, radius * s0)
let tr = SIMD3<Float>(radius * c1, halfH, radius * s1)
let n0 = normalize(SIMD3<Float>(c0, 0, s0))
let n1 = normalize(SIMD3<Float>(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<Float> = .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<Float> = .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<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
var verts: [MeshVertex] = []
var wireVerts: [MeshVertex] = []
let wc = SIMD4<Float>(0, 0, 0, 0.3)
func torusPoint(_ u: Float, _ v: Float) -> SIMD3<Float> {
let cu = cosf(u), su = sinf(u)
let cv = cosf(v), sv = sinf(v)
return SIMD3<Float>(
(majorRadius + minorRadius * cv) * cu,
minorRadius * sv,
(majorRadius + minorRadius * cv) * su
)
}
func torusNormal(_ u: Float, _ v: Float) -> SIMD3<Float> {
let cu = cosf(u), su = sinf(u)
let cv = cosf(v), sv = sinf(v)
return normalize(SIMD3<Float>(cv * cu, sv, cv * su))
}
for i in 0..<majorSegments {
let u0 = 2.0 * Float.pi * Float(i) / Float(majorSegments)
let u1 = 2.0 * Float.pi * Float(i + 1) / Float(majorSegments)
for j in 0..<minorSegments {
let v0 = 2.0 * Float.pi * Float(j) / Float(minorSegments)
let v1 = 2.0 * Float.pi * Float(j + 1) / Float(minorSegments)
let p00 = torusPoint(u0, v0); let n00 = torusNormal(u0, v0)
let p10 = torusPoint(u1, v0); let n10 = torusNormal(u1, v0)
let p01 = torusPoint(u0, v1); let n01 = torusNormal(u0, v1)
let p11 = torusPoint(u1, v1); let n11 = torusNormal(u1, v1)
verts.append(.init(position: p00, normal: n00, color: color))
verts.append(.init(position: p10, normal: n10, color: color))
verts.append(.init(position: p01, normal: n01, color: color))
verts.append(.init(position: p01, normal: n01, color: color))
verts.append(.init(position: p10, normal: n10, color: color))
verts.append(.init(position: p11, normal: n11, color: color))
// Wireframe (major + minor rings)
wireVerts.append(.init(position: p00, normal: .zero, color: wc))
wireVerts.append(.init(position: p10, normal: .zero, color: wc))
wireVerts.append(.init(position: p00, normal: .zero, color: wc))
wireVerts.append(.init(position: p01, normal: .zero, color: wc))
}
}
let ext = majorRadius + minorRadius
return PrimitiveMeshData(
vertices: verts, wireVertices: wireVerts,
faceCount: majorSegments * minorSegments,
edgeCount: majorSegments * minorSegments * 2,
boundingBoxMin: .init(-ext, -minorRadius, -ext),
boundingBoxMax: .init(ext, minorRadius, ext)
)
}
// MARK: - Cone
static func cone(
radius: Float = 1.0,
height: Float = 2.0,
segments: Int = 32,
color: SIMD4<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
let halfH = height * 0.5
let apex = SIMD3<Float>(0, halfH, 0)
var verts: [MeshVertex] = []
var wireVerts: [MeshVertex] = []
let wc = SIMD4<Float>(0, 0, 0, 0.3)
for i in 0..<segments {
let a0 = 2.0 * Float.pi * Float(i) / Float(segments)
let a1 = 2.0 * Float.pi * Float(i + 1) / Float(segments)
let b0 = SIMD3<Float>(radius * cosf(a0), -halfH, radius * sinf(a0))
let b1 = SIMD3<Float>(radius * cosf(a1), -halfH, radius * sinf(a1))
// Side normal (approximation)
let mid = normalize((b0 + b1) * 0.5 - SIMD3<Float>(0, -halfH, 0))
let sideN = normalize(SIMD3<Float>(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<Float>(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<Float> = SIMD4(0.63, 0.63, 0.63, 1)
) -> PrimitiveMeshData {
// Start with icosahedron
let t = (1.0 + sqrtf(5.0)) * 0.5
var positions: [SIMD3<Float>] = [
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..<subdivisions {
var newTris: [(Int,Int,Int)] = []
for (a,b,c) in triangles {
let ab = midpoint(a,b)
let bc = midpoint(b,c)
let ca = midpoint(c,a)
newTris.append((a, ab, ca))
newTris.append((b, bc, ab))
newTris.append((c, ca, bc))
newTris.append((ab, bc, ca))
}
triangles = newTris
midpointCache.removeAll()
}
var verts: [MeshVertex] = []
var wireVerts: [MeshVertex] = []
let wc = SIMD4<Float>(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<Float>(0.4, 0.4, 0.4, 1.0)
// Frustum shape
let body: [SIMD3<Float>] = [
.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<Float>(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..<segments {
let a0 = 2.0 * Float.pi * Float(i) / Float(segments)
let a1 = 2.0 * Float.pi * Float(i + 1) / Float(segments)
wireVerts.append(.init(position: .init(r * cosf(a0), r * sinf(a0), 0), normal: .zero, color: lc))
wireVerts.append(.init(position: .init(r * cosf(a1), r * sinf(a1), 0), normal: .zero, color: lc))
}
// Circle on XZ
for i in 0..<segments {
let a0 = 2.0 * Float.pi * Float(i) / Float(segments)
let a1 = 2.0 * Float.pi * Float(i + 1) / Float(segments)
wireVerts.append(.init(position: .init(r * cosf(a0), 0, r * sinf(a0)), normal: .zero, color: lc))
wireVerts.append(.init(position: .init(r * cosf(a1), 0, r * sinf(a1)), normal: .zero, color: lc))
}
// Rays
let rayLen: Float = 0.15
for i in 0..<8 {
let a = 2.0 * Float.pi * Float(i) / 8.0
let inner = SIMD3<Float>(r * cosf(a), r * sinf(a), 0)
let outer = SIMD3<Float>((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)
)
}
}

View file

@ -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>(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"
}
}
}

View file

@ -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<MeshVertex>.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<MeshVertex>.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
)
}
}

View file

@ -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<Float> = .zero
var rotation: SIMD3<Float> = .zero // Euler XYZ (radians)
var scale: SIMD3<Float> = .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<Float> = SIMD4<Float>(0.63, 0.63, 0.63, 1.0)
// Bounding box (local space, axis-aligned)
var boundingBoxMin: SIMD3<Float> = SIMD3<Float>(repeating: -1)
var boundingBoxMax: SIMD3<Float> = SIMD3<Float>(repeating: 1)
init(
name: String,
type: SceneObjectType,
position: SIMD3<Float> = .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<Float>, max: SIMD3<Float>) {
let m = worldMatrix
// Transform all 8 corners and find min/max
let lo = boundingBoxMin
let hi = boundingBoxMax
let corners: [SIMD3<Float>] = [
.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<Float>(repeating: .greatestFiniteMagnitude)
var wMax = SIMD3<Float>(repeating: -.greatestFiniteMagnitude)
for c in corners {
let w = (m * SIMD4<Float>(c, 1)).xyz
wMin = min(wMin, w)
wMax = max(wMax, w)
}
return (wMin, wMax)
}
}

View file

@ -0,0 +1,36 @@
// ShaderTypes.h
// Shared between Metal shaders and Swift via bridging header.
#pragma once
#include <simd/simd.h>
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;

View file

@ -0,0 +1,157 @@
// Shaders.metal
// BlenderiOS viewport shaders — cube, infinite grid, axis lines.
#include <metal_stdlib>
#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;
}

View file

@ -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)
}
}

127
Sources/UI/HeaderBar.swift Normal file
View file

@ -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"
}
}
}

View file

@ -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<Content: View>: 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())
}
}
}

View file

@ -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)
}
}

View file

@ -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"
}
}
}

View file

@ -0,0 +1,10 @@
// MeshData.swift
// Vertex type shared between CPU mesh generation and Metal rendering.
import simd
struct MeshVertex {
var position: SIMD3<Float>
var normal: SIMD3<Float>
var color: SIMD4<Float>
}

View file

@ -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)
}
}
)
}
}

View file

@ -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<Float> = .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<Float> {
let x = distance * cosf(elevation) * sinf(azimuth)
let y = distance * sinf(elevation)
let z = distance * cosf(elevation) * cosf(azimuth)
return target + SIMD3<Float>(x, y, z)
}
var viewMatrix: simd_float4x4 {
MathUtils.lookAt(eye: eye, center: target, up: SIMD3<Float>(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<Float>(cosf(azimuth), 0, -sinf(azimuth))
let up = SIMD3<Float>(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
}
}

View file

@ -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<Float> = SIMD2<Float>(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<MeshVertex>.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<Float>(-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<Uniforms>.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<Uniforms>.size, index: 1)
encoder.setFragmentBytes(&sceneConstants, length: MemoryLayout<SceneConstants>.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<Uniforms>.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<Uniforms>.size, index: 0)
var gridConstants = GridConstants(
color: SIMD4<Float>(0.5, 0.5, 0.5, 1),
gridSpacing: 1.0, gridLineWidth: 1.0,
gridFadeDistance: 40.0, _pad: 0
)
encoder.setFragmentBytes(&gridConstants, length: MemoryLayout<GridConstants>.size, index: 0)
encoder.setFragmentBytes(&sceneConstants, length: MemoryLayout<SceneConstants>.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<Uniforms>.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<Float>(0.78, 0.14, 0.22, 1.0)
let green = SIMD4<Float>(0.22, 0.68, 0.28, 1.0)
let blue = SIMD4<Float>(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)
}
}

View file

@ -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>(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)
}
}

14
generate.sh Executable file
View file

@ -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

29
project.yml Normal file
View file

@ -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