initial commit
This commit is contained in:
parent
6d8dec50c7
commit
cddd491720
22 changed files with 2596 additions and 0 deletions
0
Delete
0
Delete
3
Sources/App/BlenderiOS-Bridging-Header.h
Normal file
3
Sources/App/BlenderiOS-Bridging-Header.h
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// BlenderiOS-Bridging-Header.h
|
||||||
|
#pragma once
|
||||||
|
#import "ShaderTypes.h"
|
||||||
15
Sources/App/BlenderiOSApp.swift
Normal file
15
Sources/App/BlenderiOSApp.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// BlenderiOSApp.swift
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct BlenderiOSApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
NavigationStack {
|
||||||
|
ContentView()
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
Sources/Math/MathUtils.swift
Normal file
112
Sources/Math/MathUtils.swift
Normal 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) }
|
||||||
|
}
|
||||||
515
Sources/Primitives/PrimitiveGenerator.swift
Normal file
515
Sources/Primitives/PrimitiveGenerator.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Sources/Scene/MetalContext.swift
Normal file
143
Sources/Scene/MetalContext.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Sources/Scene/SceneGraph.swift
Normal file
164
Sources/Scene/SceneGraph.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
Sources/Scene/SceneObject.swift
Normal file
169
Sources/Scene/SceneObject.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
Sources/Shaders/ShaderTypes.h
Normal file
36
Sources/Shaders/ShaderTypes.h
Normal 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;
|
||||||
157
Sources/Shaders/Shaders.metal
Normal file
157
Sources/Shaders/Shaders.metal
Normal 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;
|
||||||
|
}
|
||||||
90
Sources/UI/ContentView.swift
Normal file
90
Sources/UI/ContentView.swift
Normal 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
127
Sources/UI/HeaderBar.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
Sources/UI/PropertiesPanel.swift
Normal file
265
Sources/UI/PropertiesPanel.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/UI/StatusBar.swift
Normal file
41
Sources/UI/StatusBar.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Sources/UI/ToolSidebar.swift
Normal file
60
Sources/UI/ToolSidebar.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Sources/Viewport/MeshData.swift
Normal file
10
Sources/Viewport/MeshData.swift
Normal 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>
|
||||||
|
}
|
||||||
196
Sources/Viewport/MultiViewportView.swift
Normal file
196
Sources/Viewport/MultiViewportView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Sources/Viewport/OrbitalCamera.swift
Normal file
55
Sources/Viewport/OrbitalCamera.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
281
Sources/Viewport/ViewportRenderer.swift
Normal file
281
Sources/Viewport/ViewportRenderer.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
Sources/Viewport/ViewportView.swift
Normal file
114
Sources/Viewport/ViewportView.swift
Normal 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
14
generate.sh
Executable 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
29
project.yml
Normal 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
|
||||||
Loading…
Reference in a new issue