// SceneGraph.swift // Observable scene graph that owns all objects and drives the outliner + renderer. import Observation import Metal import simd @Observable final class SceneGraph { // All top-level objects (children are nested inside each) var rootObjects: [SceneObject] = [] // Quick access to the selected object var selectedObject: SceneObject? { allObjects.first { $0.isSelected } } // Flattened list of every object in the hierarchy var allObjects: [SceneObject] { var result: [SceneObject] = [] func walk(_ objects: [SceneObject]) { for obj in objects { result.append(obj) walk(obj.children) } } walk(rootObjects) return result } // MARK: - Stats var totalVertexCount: Int { allObjects.compactMap { $0.meshHandle?.vertexCount }.reduce(0, +) } var totalFaceCount: Int { allObjects.compactMap { $0.meshHandle?.faceCount }.reduce(0, +) } // MARK: - Mutation func addObject(_ object: SceneObject) { rootObjects.append(object) } func removeObject(_ object: SceneObject) { object.removeFromParent() rootObjects.removeAll { $0.id == object.id } } func select(_ object: SceneObject?) { for obj in allObjects { obj.isSelected = (obj.id == object?.id) } } func deselectAll() { for obj in allObjects { obj.isSelected = false } } // MARK: - Default Scene /// Creates the classic Blender startup scene: Cube + Camera + Light. static func defaultScene(device: MTLDevice) -> SceneGraph { let graph = SceneGraph() // Default cube let cube = SceneObject(name: "Cube", type: .mesh) let cubeMeshData = PrimitiveGenerator.cube(size: 2.0, color: SIMD4(0.63, 0.63, 0.63, 1)) cube.meshHandle = MeshUploader.upload( vertices: cubeMeshData.vertices, wireVertices: cubeMeshData.wireVertices, faceCount: cubeMeshData.faceCount, edgeCount: cubeMeshData.edgeCount, label: "Cube", device: device ) cube.isSelected = true graph.addObject(cube) // Camera let camera = SceneObject(name: "Camera", type: .camera, position: SIMD3(7.36, 4.95, -6.93)) camera.transform.rotation = SIMD3(1.11, 0.0, 2.17) let cameraMeshData = PrimitiveGenerator.cameraWireframe() camera.meshHandle = MeshUploader.upload( vertices: [], wireVertices: cameraMeshData.wireVertices, faceCount: 0, edgeCount: cameraMeshData.edgeCount, label: "Camera", device: device ) camera.color = SIMD4(0.4, 0.4, 0.4, 1.0) graph.addObject(camera) // Light let light = SceneObject(name: "Light", type: .light, position: SIMD3(4.08, 5.90, -1.0)) let lightMeshData = PrimitiveGenerator.lightIcon() light.meshHandle = MeshUploader.upload( vertices: [], wireVertices: lightMeshData.wireVertices, faceCount: 0, edgeCount: lightMeshData.edgeCount, label: "Light", device: device ) light.color = SIMD4(1.0, 0.85, 0.4, 1.0) graph.addObject(light) return graph } } // MARK: - Mesh Uploader Utility enum MeshUploader { static func upload( vertices: [MeshVertex], wireVertices: [MeshVertex], faceCount: Int, edgeCount: Int, label: String, device: MTLDevice ) -> MeshHandle { let vBuf: MTLBuffer? if !vertices.isEmpty { vBuf = device.makeBuffer( bytes: vertices, length: MemoryLayout.stride * vertices.count, options: .storageModeShared ) } else { // Empty placeholder buffer (1 byte) for objects with no solid geometry vBuf = device.makeBuffer(length: 1, options: .storageModeShared) } let wBuf: MTLBuffer? if !wireVertices.isEmpty { wBuf = device.makeBuffer( bytes: wireVertices, length: MemoryLayout.stride * wireVertices.count, options: .storageModeShared ) } else { wBuf = nil } return MeshHandle( vertexBuffer: vBuf!, vertexCount: vertices.count, wireVertexBuffer: wBuf, wireVertexCount: wireVertices.count, faceCount: faceCount, edgeCount: edgeCount, label: label ) } }