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