diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a3ea15c Binary files /dev/null and b/.DS_Store differ diff --git a/BlenderiOS.xcodeproj/project.pbxproj b/BlenderiOS.xcodeproj/project.pbxproj index 721e2ab..648d418 100644 --- a/BlenderiOS.xcodeproj/project.pbxproj +++ b/BlenderiOS.xcodeproj/project.pbxproj @@ -10,23 +10,33 @@ 04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3194BE92D558B2498E78A9A /* MultiViewportView.swift */; }; 10DBD6D39988BB28432F6E20 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2797EBBC3A95912225AA56 /* ContentView.swift */; }; 1A259C6950EE9801949B1156 /* OrbitalCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60211B66F675CCAE9988537 /* OrbitalCamera.swift */; }; + 2FAAA8849AF1E8BDE2E5F71A /* PencilCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A67FB3C7BBBE7CE189A156F /* PencilCaptureView.swift */; }; + 31EBB92C0BDE32AB13FBACE4 /* WorkspaceLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9AA75DBC75EFA8A826B0B1 /* WorkspaceLayoutView.swift */; }; 4B4188022460B77F52241024 /* ViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2E2F1DF54C842C8F8FE0A8 /* ViewportView.swift */; }; 4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4A2F33EEF87CEC04445F54D /* MetalContext.swift */; }; 52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */; }; 6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243D22FA262638409E279A74 /* MathUtils.swift */; }; 887672E4A0CAEAEAF5D2BA89 /* PropertiesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9762DC2B358EEC36A7A9C1EE /* PropertiesPanel.swift */; }; + 9291D856153D200788DDD214 /* WorkspaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 193516DB800C0281CB3EDFE1 /* WorkspaceManager.swift */; }; 9D7F22D1B33C5F204B5B3B08 /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36992EE3F9FF59CB64C9635B /* StatusBar.swift */; }; 9F6FAE5B54D24BFA9FD741B1 /* SceneObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475B45D562F5D080F93366A5 /* SceneObject.swift */; }; + A206C890BC5BD08C9339C989 /* GPCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E469A7474A515E3294F263D2 /* GPCanvasView.swift */; }; B00DF6E051DA2B49CB101864 /* MeshData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A1289842AED01381EDCE6D /* MeshData.swift */; }; C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2C74721F4BD1D4598B0A786E /* Shaders.metal */; }; C79680762E33BE6A6E89ED9B /* BlenderiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658DAE62EE166FE88AE8CA6B /* BlenderiOSApp.swift */; }; + D58709872C4361E7E9B07860 /* GPStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0006620C33A001D35C0711BF /* GPStroke.swift */; }; + DDAF7FF6785EA6DD6A51184A /* UVPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507568EE2ED3D511E6534680 /* UVPanels.swift */; }; E16B4DE90C5ACA18E73C5194 /* PrimitiveGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABD6D900EBFC5797EB70A25 /* PrimitiveGenerator.swift */; }; EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */; }; EB4B94857FA1134ECB9A47E6 /* ViewportRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB63861639BB5E70747F0BE /* ViewportRenderer.swift */; }; + F1572682753507CE9F88500F /* GPPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C091D660A4829C8CB29571CA /* GPPanels.swift */; }; F30954E11C15820EF8482F27 /* HeaderBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFCEC67F9602FF24F18A8B /* HeaderBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0006620C33A001D35C0711BF /* GPStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPStroke.swift; sourceTree = ""; }; + 0A9AA75DBC75EFA8A826B0B1 /* WorkspaceLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceLayoutView.swift; sourceTree = ""; }; + 193516DB800C0281CB3EDFE1 /* WorkspaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManager.swift; sourceTree = ""; }; 243D22FA262638409E279A74 /* MathUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MathUtils.swift; sourceTree = ""; }; 2C74721F4BD1D4598B0A786E /* Shaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Shaders.metal; sourceTree = ""; }; 36992EE3F9FF59CB64C9635B /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = ""; }; @@ -35,18 +45,22 @@ 4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneGraph.swift; sourceTree = ""; }; 4B541F4B8B8BCF748E8855E3 /* BlenderiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlenderiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4D2E2F1DF54C842C8F8FE0A8 /* ViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportView.swift; sourceTree = ""; }; + 507568EE2ED3D511E6534680 /* UVPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UVPanels.swift; sourceTree = ""; }; 658DAE62EE166FE88AE8CA6B /* BlenderiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlenderiOSApp.swift; sourceTree = ""; }; 68874646FB899BACC500E682 /* ShaderTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShaderTypes.h; sourceTree = ""; }; 77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSidebar.swift; sourceTree = ""; }; + 7A67FB3C7BBBE7CE189A156F /* PencilCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PencilCaptureView.swift; sourceTree = ""; }; 82A1289842AED01381EDCE6D /* MeshData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshData.swift; sourceTree = ""; }; 9762DC2B358EEC36A7A9C1EE /* PropertiesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesPanel.swift; sourceTree = ""; }; 9ABD6D900EBFC5797EB70A25 /* PrimitiveGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimitiveGenerator.swift; sourceTree = ""; }; A4A2F33EEF87CEC04445F54D /* MetalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalContext.swift; sourceTree = ""; }; ADB63861639BB5E70747F0BE /* ViewportRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportRenderer.swift; sourceTree = ""; }; + C091D660A4829C8CB29571CA /* GPPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPPanels.swift; sourceTree = ""; }; C3194BE92D558B2498E78A9A /* MultiViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiViewportView.swift; sourceTree = ""; }; CD2797EBBC3A95912225AA56 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; D60211B66F675CCAE9988537 /* OrbitalCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbitalCamera.swift; sourceTree = ""; }; DBDFCEC67F9602FF24F18A8B /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = ""; }; + E469A7474A515E3294F263D2 /* GPCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPCanvasView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -67,6 +81,15 @@ path = Math; sourceTree = ""; }; + 41D5D1C1ED9693D6F18723FE /* Workspace */ = { + isa = PBXGroup; + children = ( + 0A9AA75DBC75EFA8A826B0B1 /* WorkspaceLayoutView.swift */, + 193516DB800C0281CB3EDFE1 /* WorkspaceManager.swift */, + ); + path = Workspace; + sourceTree = ""; + }; 5379DF9111874486418F433C /* Products */ = { isa = PBXGroup; children = ( @@ -75,6 +98,17 @@ name = Products; sourceTree = ""; }; + A07AB99DF130A8AC10FF10C3 /* GreasePencil */ = { + isa = PBXGroup; + children = ( + E469A7474A515E3294F263D2 /* GPCanvasView.swift */, + C091D660A4829C8CB29571CA /* GPPanels.swift */, + 0006620C33A001D35C0711BF /* GPStroke.swift */, + 7A67FB3C7BBBE7CE189A156F /* PencilCaptureView.swift */, + ); + path = GreasePencil; + sourceTree = ""; + }; ADBD2DAFB4BAD4A66023DB58 = { isa = PBXGroup; children = ( @@ -87,16 +121,27 @@ isa = PBXGroup; children = ( 0548701BAB861DC73AA4EFD9 /* App */, + A07AB99DF130A8AC10FF10C3 /* GreasePencil */, 128601FB1E9F41C354FDA194 /* Math */, CD76C28FBD7C99C9A4527E9D /* Primitives */, D9F0C729406B3D21928FCF9A /* Scene */, BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */, D4E87F4D56F649AED031166A /* UI */, + B974BA8F307CDB4C05B18B27 /* UV */, ECEF6DBA962C7AA044302958 /* Viewport */, + 41D5D1C1ED9693D6F18723FE /* Workspace */, ); path = Sources; sourceTree = ""; }; + B974BA8F307CDB4C05B18B27 /* UV */ = { + isa = PBXGroup; + children = ( + 507568EE2ED3D511E6534680 /* UVPanels.swift */, + ); + path = UV; + sourceTree = ""; + }; BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */ = { isa = PBXGroup; children = ( @@ -203,12 +248,16 @@ files = ( C79680762E33BE6A6E89ED9B /* BlenderiOSApp.swift in Sources */, 10DBD6D39988BB28432F6E20 /* ContentView.swift in Sources */, + A206C890BC5BD08C9339C989 /* GPCanvasView.swift in Sources */, + F1572682753507CE9F88500F /* GPPanels.swift in Sources */, + D58709872C4361E7E9B07860 /* GPStroke.swift in Sources */, F30954E11C15820EF8482F27 /* HeaderBar.swift in Sources */, 6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */, B00DF6E051DA2B49CB101864 /* MeshData.swift in Sources */, 4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */, 04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */, 1A259C6950EE9801949B1156 /* OrbitalCamera.swift in Sources */, + 2FAAA8849AF1E8BDE2E5F71A /* PencilCaptureView.swift in Sources */, E16B4DE90C5ACA18E73C5194 /* PrimitiveGenerator.swift in Sources */, 887672E4A0CAEAEAF5D2BA89 /* PropertiesPanel.swift in Sources */, 52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */, @@ -216,8 +265,11 @@ C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */, 9D7F22D1B33C5F204B5B3B08 /* StatusBar.swift in Sources */, EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */, + DDAF7FF6785EA6DD6A51184A /* UVPanels.swift in Sources */, EB4B94857FA1134ECB9A47E6 /* ViewportRenderer.swift in Sources */, 4B4188022460B77F52241024 /* ViewportView.swift in Sources */, + 31EBB92C0BDE32AB13FBACE4 /* WorkspaceLayoutView.swift in Sources */, + 9291D856153D200788DDD214 /* WorkspaceManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BlenderiOS.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate b/BlenderiOS.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate index 22ed305..702365c 100644 Binary files a/BlenderiOS.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate and b/BlenderiOS.xcodeproj/project.xcworkspace/xcuserdata/dallasgroot.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sources/GreasePencil/GPCanvasView.swift b/Sources/GreasePencil/GPCanvasView.swift new file mode 100644 index 0000000..96e7459 --- /dev/null +++ b/Sources/GreasePencil/GPCanvasView.swift @@ -0,0 +1,113 @@ +// GPCanvasView.swift +// Composites the Metal viewport with a PencilCaptureOverlay on top. +// Pencil strokes are projected into 3D and drawn by the renderer. + +import SwiftUI +import simd + +struct GPCanvasView: View { + let renderer: ViewportRenderer + @State private var gpData = GPObjectData() + @State private var activeStroke: GPStroke? + @State private var brushColor: Color = .black + @State private var brushWidth: Float = 0.05 + + var body: some View { + GeometryReader { geo in + ZStack { + // 3D viewport (renders scene + finalized GP strokes) + ViewportPane(renderer: renderer, label: "GP Canvas") + + // Pencil input overlay + PencilCaptureOverlay( + onStrokeBegan: { + beginStroke() + }, + onSample: { sample in + addSample(sample, viewportSize: geo.size) + }, + onStrokeEnded: { + finalizeStroke() + } + ) + .allowsHitTesting(true) + + // Drawing indicator + if activeStroke != nil { + VStack { + HStack { + Spacer() + Label("Drawing", systemImage: "pencil.tip") + .font(.caption2) + .foregroundStyle(.orange) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.black.opacity(0.6), in: Capsule()) + .padding(8) + } + Spacer() + } + } + } + } + } + + // MARK: - Stroke lifecycle + + private func beginStroke() { + let stroke = GPStroke() + stroke.color = colorToSIMD(brushColor) + stroke.baseWidth = brushWidth + activeStroke = stroke + } + + private func addSample(_ sample: PencilSample, viewportSize: CGSize) { + guard let stroke = activeStroke, + let device = renderer.device else { return } + + let projector = StrokeProjector( + viewMatrix: renderer.camera.viewMatrix, + projectionMatrix: MathUtils.perspective( + fovYRadians: .pi / 4, + aspect: Float(viewportSize.width / viewportSize.height), + near: 0.1, far: 500 + ), + viewportSize: SIMD2( + Float(viewportSize.width), + Float(viewportSize.height) + ), + drawingPlaneNormal: drawingPlaneNormal(), + drawingPlanePoint: renderer.camera.target + ) + + stroke.addSample(sample, projector: projector) + + // Live tessellation for preview + stroke.tessellate(device: device) + } + + private func finalizeStroke() { + guard let stroke = activeStroke, + stroke.points.count >= 2 else { + activeStroke = nil + return + } + + // Commit to the active layer + gpData.activeLayer?.strokes.append(stroke) + activeStroke = nil + } + + // MARK: - Helpers + + /// Drawing plane faces the camera (billboard mode). + private func drawingPlaneNormal() -> SIMD3 { + normalize(renderer.camera.eye - renderer.camera.target) + } + + private func colorToSIMD(_ color: Color) -> SIMD4 { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) + return SIMD4(Float(r), Float(g), Float(b), Float(a)) + } +} diff --git a/Sources/GreasePencil/GPPanels.swift b/Sources/GreasePencil/GPPanels.swift new file mode 100644 index 0000000..b52de5c --- /dev/null +++ b/Sources/GreasePencil/GPPanels.swift @@ -0,0 +1,240 @@ +// GPPanels.swift +// SwiftUI panels for the Grease Pencil workspace. + +import SwiftUI + +// MARK: - Brush Panel + +struct GPBrushPanel: View { + @State private var selectedBrush: GPBrushType = .draw + @State private var brushSize: Float = 25 + @State private var brushStrength: Float = 1.0 + @State private var brushColor: Color = .black + + var body: some View { + VStack(spacing: 8) { + // Brush type selector + ForEach(GPBrushType.allCases) { brush in + Button { + selectedBrush = brush + } label: { + Image(systemName: brush.icon) + .font(.system(size: 16)) + .frame(width: 36, height: 36) + .background( + selectedBrush == brush + ? Color.orange.opacity(0.25) + : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .tint(selectedBrush == brush ? .orange : .secondary) + } + + Divider().padding(.horizontal, 8) + + // Color + ColorPicker("", selection: $brushColor) + .labelsHidden() + .frame(width: 36, height: 36) + + Divider().padding(.horizontal, 8) + + // Quick presets + VStack(spacing: 4) { + Text("Size") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + Text("\(Int(brushSize))") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.primary) + } + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + .background(.ultraThinMaterial) + } +} + +enum GPBrushType: String, CaseIterable, Identifiable { + case draw = "Draw" + case fill = "Fill" + case erase = "Erase" + case tint = "Tint" + case smooth = "Smooth" + case grab = "Grab" + + var id: String { rawValue } + + var icon: String { + switch self { + case .draw: "pencil.tip" + case .fill: "paintbrush.fill" + case .erase: "eraser" + case .tint: "eyedropper" + case .smooth: "wand.and.stars" + case .grab: "hand.point.up.left" + } + } +} + +// MARK: - Layer Panel + +struct GPLayerPanel: View { + @State private var layers: [GPLayerInfo] = [ + .init(name: "Fills", isVisible: true, isActive: false), + .init(name: "Lines", isVisible: true, isActive: true), + .init(name: "Sketch", isVisible: false, isActive: false), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Text("Layers") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Button { + let new = GPLayerInfo( + name: "Layer \(layers.count + 1)", + isVisible: true, + isActive: false + ) + layers.insert(new, at: 0) + } label: { + Image(systemName: "plus") + .font(.caption2) + } + .tint(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + + Divider() + + // Layer list + ScrollView { + LazyVStack(spacing: 0) { + ForEach(layers) { layer in + GPLayerRow(layer: layer) { + // Select + for i in layers.indices { + layers[i].isActive = (layers[i].id == layer.id) + } + } + } + } + } + + Spacer() + } + .background(.ultraThinMaterial) + } +} + +struct GPLayerInfo: Identifiable { + let id = UUID() + var name: String + var isVisible: Bool + var isActive: Bool +} + +struct GPLayerRow: View { + let layer: GPLayerInfo + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 8) { + // Color swatch + RoundedRectangle(cornerRadius: 2) + .fill(layer.isActive ? Color.orange : Color.gray.opacity(0.3)) + .frame(width: 4, height: 24) + + Text(layer.name) + .font(.caption) + .foregroundStyle(layer.isActive ? .primary : .secondary) + + Spacer() + + Image(systemName: layer.isVisible ? "eye" : "eye.slash") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(layer.isActive ? Color.orange.opacity(0.08) : .clear) + } + .buttonStyle(.plain) + } +} + +// MARK: - Onion Skin Panel + +struct OnionSkinPanel: View { + @State private var enabled = true + @State private var framesBefore: Double = 3 + @State private var framesAfter: Double = 1 + @State private var opacity: Double = 0.5 + @State private var colorBefore: Color = .red.opacity(0.3) + @State private var colorAfter: Color = .green.opacity(0.3) + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Onion Skin") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Toggle("", isOn: $enabled) + .labelsHidden() + .scaleEffect(0.7) + } + .padding(.horizontal, 10) + .padding(.top, 8) + + if enabled { + VStack(alignment: .leading, spacing: 10) { + SliderRow(label: "Before", value: $framesBefore, range: 0...10) + SliderRow(label: "After", value: $framesAfter, range: 0...5) + SliderRow(label: "Opacity", value: $opacity, range: 0...1) + + HStack(spacing: 12) { + ColorPicker("Before", selection: $colorBefore) + .font(.caption2) + ColorPicker("After", selection: $colorAfter) + .font(.caption2) + } + } + .padding(.horizontal, 10) + } + + Spacer() + } + .background(.ultraThinMaterial) + } +} + +struct SliderRow: View { + let label: String + @Binding var value: Double + let range: ClosedRange + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: range.upperBound > 1 ? "%.0f" : "%.2f", value)) + .font(.system(size: 10, design: .monospaced)) + } + Slider(value: $value, in: range) + .tint(.orange) + } + } +} diff --git a/Sources/GreasePencil/GPStroke.swift b/Sources/GreasePencil/GPStroke.swift new file mode 100644 index 0000000..7910dce --- /dev/null +++ b/Sources/GreasePencil/GPStroke.swift @@ -0,0 +1,214 @@ +// GPStroke.swift +// Grease Pencil data model — captures 2D pencil input, projects to 3D, +// tessellates into renderable triangle strips. + +import Observation +import simd +import Metal + +// MARK: - Raw 2D sample from Apple Pencil + +struct PencilSample { + let screenPosition: SIMD2 // in points + let pressure: Float // 0…1 (force / maxForce) + let altitude: Float // radians, 0 = flat, π/2 = upright + let azimuth: Float // radians, direction pencil points + let timestamp: Double // seconds +} + +// MARK: - Projected 3D point + +struct GPStrokePoint { + var worldPosition: SIMD3 + var normal: SIMD3 // drawing plane normal + var pressure: Float + var width: Float // world-space stroke width +} + +// MARK: - A single stroke (one continuous pencil drag) + +@Observable +final class GPStroke: Identifiable { + let id = UUID() + var points: [GPStrokePoint] = [] + var color: SIMD4 = SIMD4(0.1, 0.1, 0.1, 1.0) + var baseWidth: Float = 0.05 + + // GPU buffer (rebuilt when points change) + var vertexBuffer: MTLBuffer? + var vertexCount: Int = 0 + + // MARK: - Add a raw sample and project + + func addSample( + _ sample: PencilSample, + projector: StrokeProjector + ) { + let worldPos = projector.unproject(screenPoint: sample.screenPosition) + let width = baseWidth * (0.3 + sample.pressure * 0.7) + + let point = GPStrokePoint( + worldPosition: worldPos, + normal: projector.drawingPlaneNormal, + pressure: sample.pressure, + width: width + ) + points.append(point) + } + + // MARK: - Tessellate to triangle strip + + func tessellate(device: MTLDevice) { + guard points.count >= 2 else { return } + + var vertices: [MeshVertex] = [] + + for i in 0.. + + if i == 0 { + tangent = normalize(points[1].worldPosition - p.worldPosition) + } else if i == points.count - 1 { + tangent = normalize(p.worldPosition - points[i-1].worldPosition) + } else { + tangent = normalize(points[i+1].worldPosition - points[i-1].worldPosition) + } + + // Perpendicular in the drawing plane + let bitangent = normalize(cross(tangent, p.normal)) * p.width * 0.5 + + let left = p.worldPosition - bitangent + let right = p.worldPosition + bitangent + + // Fade alpha at stroke tips + let tipFade: Float + let tipDistance = 3 + if i < tipDistance { + tipFade = Float(i) / Float(tipDistance) + } else if i > points.count - 1 - tipDistance { + tipFade = Float(points.count - 1 - i) / Float(tipDistance) + } else { + tipFade = 1.0 + } + + let c = SIMD4(color.x, color.y, color.z, color.w * tipFade) + + vertices.append(MeshVertex(position: left, normal: p.normal, color: c)) + vertices.append(MeshVertex(position: right, normal: p.normal, color: c)) + } + + vertexCount = vertices.count + if !vertices.isEmpty { + vertexBuffer = device.makeBuffer( + bytes: vertices, + length: MemoryLayout.stride * vertices.count, + options: .storageModeShared + ) + } + } +} + +// MARK: - Stroke Projector + +/// Projects 2D screen coordinates into 3D world space on a drawing plane. +struct StrokeProjector { + let viewMatrix: simd_float4x4 + let projectionMatrix: simd_float4x4 + let viewportSize: SIMD2 + let drawingPlaneNormal: SIMD3 + let drawingPlanePoint: SIMD3 // point on the plane + + /// Unproject a screen point onto the drawing plane. + func unproject(screenPoint: SIMD2) -> SIMD3 { + // Normalize to clip space [-1, 1] + let nx = (2.0 * screenPoint.x / viewportSize.x) - 1.0 + let ny = 1.0 - (2.0 * screenPoint.y / viewportSize.y) + + let invProj = projectionMatrix.inverse + let invView = viewMatrix.inverse + + // Near and far points in clip space + let nearClip = SIMD4(nx, ny, 0, 1) + let farClip = SIMD4(nx, ny, 1, 1) + + // To eye space + var nearEye = invProj * nearClip + nearEye /= nearEye.w + var farEye = invProj * farClip + farEye /= farEye.w + + // To world space + let nearWorld = (invView * nearEye).xyz + let farWorld = (invView * farEye).xyz + + // Ray + let rayOrigin = nearWorld + let rayDir = normalize(farWorld - nearWorld) + + // Intersect with drawing plane + let denom = dot(drawingPlaneNormal, rayDir) + if abs(denom) < 1e-6 { + // Ray parallel to plane — return plane point as fallback + return drawingPlanePoint + } + let t = dot(drawingPlanePoint - rayOrigin, drawingPlaneNormal) / denom + return rayOrigin + rayDir * max(t, 0) + } +} + +// MARK: - Layer + +@Observable +final class GPLayer: Identifiable { + let id = UUID() + var name: String + var strokes: [GPStroke] = [] + var isVisible: Bool = true + var opacity: Float = 1.0 + var blendMode: GPBlendMode = .normal + + // Per-frame strokes (for animation) + var frameStrokes: [Int: [GPStroke]] = [:] + + init(name: String) { + self.name = name + } +} + +enum GPBlendMode: String, CaseIterable { + case normal = "Normal" + case multiply = "Multiply" + case add = "Add" +} + +// MARK: - Grease Pencil Object Data + +@Observable +final class GPObjectData { + var layers: [GPLayer] = [GPLayer(name: "Layer")] + var activeLayerIndex: Int = 0 + + var activeLayer: GPLayer? { + guard layers.indices.contains(activeLayerIndex) else { return nil } + return layers[activeLayerIndex] + } + + var currentFrame: Int = 1 + + // Onion skinning + var onionSkinEnabled: Bool = true + var onionSkinBefore: Int = 3 + var onionSkinAfter: Int = 1 + + func addLayer(name: String) { + layers.append(GPLayer(name: name)) + activeLayerIndex = layers.count - 1 + } + + func removeLayer(at index: Int) { + guard layers.count > 1 else { return } + layers.remove(at: index) + activeLayerIndex = min(activeLayerIndex, layers.count - 1) + } +} diff --git a/Sources/GreasePencil/PencilCaptureView.swift b/Sources/GreasePencil/PencilCaptureView.swift new file mode 100644 index 0000000..40468aa --- /dev/null +++ b/Sources/GreasePencil/PencilCaptureView.swift @@ -0,0 +1,74 @@ +// PencilCaptureView.swift +// UIViewRepresentable overlay that captures Apple Pencil touches +// with full pressure/tilt/azimuth fidelity and forwards them +// as PencilSample values to a callback. + +import SwiftUI +import UIKit + +struct PencilCaptureOverlay: UIViewRepresentable { + let onStrokeBegan: () -> Void + let onSample: (PencilSample) -> Void + let onStrokeEnded: () -> Void + + func makeUIView(context: Context) -> PencilTouchView { + let view = PencilTouchView() + view.onStrokeBegan = onStrokeBegan + view.onSample = onSample + view.onStrokeEnded = onStrokeEnded + view.backgroundColor = .clear + view.isMultipleTouchEnabled = false + return view + } + + func updateUIView(_ uiView: PencilTouchView, context: Context) {} +} + +/// Raw UIView that intercepts pencil touches. +final class PencilTouchView: UIView { + var onStrokeBegan: (() -> Void)? + var onSample: ((PencilSample) -> Void)? + var onStrokeEnded: (() -> Void)? + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, touch.type == .pencil else { return } + onStrokeBegan?() + emitSamples(touch: touch, event: event) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, touch.type == .pencil else { return } + emitSamples(touch: touch, event: event) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, touch.type == .pencil else { return } + emitSamples(touch: touch, event: event) + onStrokeEnded?() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + onStrokeEnded?() + } + + private func emitSamples(touch: UITouch, event: UIEvent?) { + // Coalesced touches give us sub-frame samples for smooth strokes + let coalescedTouches = event?.coalescedTouches(for: touch) ?? [touch] + + for ct in coalescedTouches { + let loc = ct.preciseLocation(in: self) + let pressure = ct.maximumPossibleForce > 0 + ? Float(ct.force / ct.maximumPossibleForce) + : 0.5 + + let sample = PencilSample( + screenPosition: SIMD2(Float(loc.x), Float(loc.y)), + pressure: pressure, + altitude: Float(ct.altitudeAngle), + azimuth: Float(ct.azimuthAngle(in: self)), + timestamp: ct.timestamp + ) + onSample?(sample) + } + } +} diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index ae98b7f..f8f655d 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -1,90 +1,153 @@ // ContentView.swift -// Root layout — header, tool sidebar, multi-viewport, properties, status bar. +// Root layout — workspace header, dynamic panel layout, status bar. import SwiftUI struct ContentView: View { @State private var context = MetalContext() + @State private var workspace = WorkspaceManager() @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( + // ── Workspace header ── + WorkspaceHeader( + workspace: workspace, currentMode: $currentMode, - layout: $context.viewportLayout, context: context ) - // ── Main area ── - HStack(spacing: 0) { - // Left tool strip - ToolSidebar(activeTool: $activeTool) + // ── Dynamic workspace layout ── + WorkspaceLayoutView( + workspace: workspace, + context: context + ) - // Center: viewport(s) - MultiViewportView( - renderers: context.renderers, - layout: context.viewportLayout - ) - - // Right side: outliner + properties - if showProperties || showOutliner { - rightPanel(height: geo.size.height) - } - } - - // ── Bottom status ── + // ── Status bar ── 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() +// MARK: - Workspace Header + +struct WorkspaceHeader: View { + let workspace: WorkspaceManager + @Binding var currentMode: EditMode + let context: MetalContext + + var body: some View { + HStack(spacing: 10) { + // App icon + Image(systemName: "cube.fill") + .font(.title3) + .foregroundStyle(.orange) + + Divider().frame(height: 20) + + // Workspace tabs + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(WorkspaceType.allCases) { ws in + WorkspaceTab( + type: ws, + isActive: workspace.activeWorkspace == ws + ) { + workspace.switchWorkspace(to: ws) } - } 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) + 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(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 5)) } - if showOutliner && showProperties { - Divider() + 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(.caption.weight(.medium)) } - if showProperties { - PropertiesPanel( - isExpanded: $showProperties, - sceneGraph: context.sceneGraph - ) + Button { context.deleteSelected() } label: { + Image(systemName: "trash") + .font(.caption) + } + .tint(.secondary) + + Spacer() + + // Timeline toggle + Button { + withAnimation(.easeInOut(duration: 0.2)) { + workspace.showTimeline.toggle() + } + } label: { + Image(systemName: "play.rectangle") + .font(.caption) + .foregroundStyle(workspace.showTimeline ? .orange : .secondary) } } - .frame(width: 260) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + } +} + +// MARK: - Workspace Tab + +struct WorkspaceTab: View { + let type: WorkspaceType + let isActive: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: type.icon) + .font(.system(size: 10)) + Text(type.rawValue) + .font(.system(size: 11, weight: isActive ? .semibold : .regular)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + isActive + ? Color.orange.opacity(0.15) + : Color.clear, + in: RoundedRectangle(cornerRadius: 5) + ) + .foregroundStyle(isActive ? .orange : .secondary) + } + .buttonStyle(.plain) } } diff --git a/Sources/UV/UVPanels.swift b/Sources/UV/UVPanels.swift new file mode 100644 index 0000000..ac9aa08 --- /dev/null +++ b/Sources/UV/UVPanels.swift @@ -0,0 +1,263 @@ +// UVPanels.swift +// UV Editor viewport and Texture Bake settings panel. + +import SwiftUI + +// MARK: - UV Editor View + +struct UVEditorView: View { + @State private var uvZoom: CGFloat = 1.0 + @State private var uvOffset: CGSize = .zero + @State private var showGrid = true + @State private var uvMode: UVEditMode = .vertex + + var body: some View { + GeometryReader { geo in + ZStack { + // UV background (checkerboard) + Canvas { context, size in + let cellSize: CGFloat = 20 * uvZoom + let cols = Int(size.width / cellSize) + 2 + let rows = Int(size.height / cellSize) + 2 + + for row in 0..= 1.0 { + timer.invalidate() + isBaking = false + } + } + } label: { + HStack { + Image(systemName: isBaking ? "hourglass" : "bolt.fill") + Text(isBaking ? "Baking..." : "Bake") + } + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(isBaking ? Color.gray : Color.orange, in: RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(.white) + } + .disabled(isBaking) + + if isBaking { + ProgressView(value: progress) + .tint(.orange) + } + } + .padding(12) + } + .background(.ultraThinMaterial) + } + + private func engineTag(_ name: String) -> some View { + Text(name) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.primary.opacity(0.08), in: Capsule()) + } +} + +enum BakeType: String, CaseIterable, Identifiable { + case diffuse = "Diffuse" + case normal = "Normal" + case ao = "AO" + case roughness = "Roughness" + case emission = "Emission" + case combined = "Combined" + + var id: String { rawValue } +} diff --git a/Sources/Workspace/WorkspaceLayoutView.swift b/Sources/Workspace/WorkspaceLayoutView.swift new file mode 100644 index 0000000..9d42801 --- /dev/null +++ b/Sources/Workspace/WorkspaceLayoutView.swift @@ -0,0 +1,164 @@ +// WorkspaceLayoutView.swift +// Reads the WorkspaceManager's panel configuration and builds the +// layout dynamically using GeometryReader. Panels are resolved to +// concrete SwiftUI views via a factory. + +import SwiftUI + +struct WorkspaceLayoutView: View { + let workspace: WorkspaceManager + let context: MetalContext + + var body: some View { + GeometryReader { geo in + let totalWidth = geo.size.width + let mainHeight = workspace.showTimeline + ? geo.size.height * (1 - workspace.timelineHeight) + : geo.size.height + + VStack(spacing: 0) { + // ── Main row ── + HStack(spacing: 0) { + // Leading panels + ForEach(workspace.leadingPanels) { slot in + panelView(for: slot) + .frame(width: totalWidth * slot.size) + } + + // Center panels (split evenly among themselves) + let centerSlots = workspace.centerPanels + let centerTotalProportion = centerSlots.reduce(0) { $0 + $1.size } + ForEach(Array(centerSlots.enumerated()), id: \.element.id) { idx, slot in + let proportion = slot.size / max(centerTotalProportion, 0.01) + let usedByEdges = workspace.leadingPanels.reduce(0) { $0 + $1.size } + + workspace.trailingPanels.reduce(0) { $0 + $1.size } + let centerWidth = totalWidth * (1 - usedByEdges) + + panelView(for: slot) + .frame(width: centerWidth * proportion) + + // Divider between center panels + if idx < centerSlots.count - 1 { + PanelDivider(axis: .vertical) + } + } + + // Trailing panels + ForEach(workspace.trailingPanels) { slot in + PanelDivider(axis: .vertical) + panelView(for: slot) + .frame(width: totalWidth * slot.size) + } + } + .frame(height: mainHeight) + + // ── Timeline (optional bottom panel) ── + if workspace.showTimeline { + PanelDivider(axis: .horizontal) + TimelinePlaceholder() + .frame(height: geo.size.height * workspace.timelineHeight) + } + } + } + .clipped() + } + + // MARK: - Panel factory + + @ViewBuilder + private func panelView(for slot: PanelSlot) -> some View { + switch slot.id { + case .toolSidebar: + ToolSidebar(activeTool: .constant(.cursor)) + + case .viewport3D: + if context.renderers.indices.contains(0) { + ViewportPane(renderer: context.renderers[0], label: "3D Viewport") + } + + case .uvEditor: + UVEditorView() + + case .textureBake: + TextureBakePanel() + + case .brushPanel: + GPBrushPanel() + + case .gpCanvas: + if context.renderers.indices.contains(0) { + GPCanvasView(renderer: context.renderers[0]) + } + + case .gpLayers: + GPLayerPanel() + + case .onionSkin: + OnionSkinPanel() + + case .outliner: + OutlinerPanel(sceneGraph: context.sceneGraph) + + case .properties: + PropertiesPanel( + isExpanded: .constant(true), + sceneGraph: context.sceneGraph + ) + + case .renderSettings: + RenderSettingsPlaceholder() + + case .timeline: + TimelinePlaceholder() + } + } +} + +// MARK: - Panel divider + +struct PanelDivider: View { + let axis: Axis + private let thickness: CGFloat = 1.5 + + var body: some View { + Rectangle() + .fill(Color.white.opacity(0.1)) + .frame( + width: axis == .vertical ? thickness : nil, + height: axis == .horizontal ? thickness : nil + ) + } +} + +// MARK: - Placeholders for panels not yet built + +struct RenderSettingsPlaceholder: View { + var body: some View { + VStack { + Image(systemName: "sun.max.fill") + .font(.title2) + .foregroundStyle(.orange.opacity(0.5)) + Text("Render Settings") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + } +} + +struct TimelinePlaceholder: View { + var body: some View { + HStack { + Image(systemName: "play.rectangle") + .foregroundStyle(.secondary) + Text("Timeline") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + } +} diff --git a/Sources/Workspace/WorkspaceManager.swift b/Sources/Workspace/WorkspaceManager.swift new file mode 100644 index 0000000..062ab44 --- /dev/null +++ b/Sources/Workspace/WorkspaceManager.swift @@ -0,0 +1,167 @@ +// WorkspaceManager.swift +// Controls which workspace layout is active and panel sizing/visibility. + +import Observation +import SwiftUI + +// MARK: - Workspace definitions + +enum WorkspaceType: String, CaseIterable, Identifiable { + case modeling = "Modeling" + case uvTexture = "UV / Texture Bake" + case greasePencil = "Grease Pencil" + case animation = "Animation" + case rendering = "Rendering" + case sculpting = "Sculpting" + + var id: String { rawValue } + + var icon: String { + switch self { + case .modeling: "cube" + case .uvTexture: "square.grid.3x3.topleft.filled" + case .greasePencil: "pencil.tip" + case .animation: "play.rectangle" + case .rendering: "sun.max.fill" + case .sculpting: "paintbrush.pointed.fill" + } + } + + /// Default panel configuration for each workspace. + var defaultPanels: [PanelSlot] { + switch self { + case .modeling: + [ + .init(.toolSidebar, edge: .leading, size: 0.04), + .init(.viewport3D, edge: .center, size: 0.66), + .init(.outliner, edge: .trailing, size: 0.15), + .init(.properties, edge: .trailing, size: 0.15), + ] + case .uvTexture: + [ + .init(.toolSidebar, edge: .leading, size: 0.04), + .init(.viewport3D, edge: .center, size: 0.40), + .init(.uvEditor, edge: .center, size: 0.36), + .init(.textureBake, edge: .trailing, size: 0.20), + ] + case .greasePencil: + [ + .init(.brushPanel, edge: .leading, size: 0.06), + .init(.gpCanvas, edge: .center, size: 0.64), + .init(.gpLayers, edge: .trailing, size: 0.16), + .init(.onionSkin, edge: .trailing, size: 0.14), + ] + case .animation: + [ + .init(.toolSidebar, edge: .leading, size: 0.04), + .init(.viewport3D, edge: .center, size: 0.70), + .init(.outliner, edge: .trailing, size: 0.12), + .init(.properties, edge: .trailing, size: 0.14), + ] + case .rendering: + [ + .init(.viewport3D, edge: .center, size: 0.70), + .init(.renderSettings, edge: .trailing, size: 0.30), + ] + case .sculpting: + [ + .init(.brushPanel, edge: .leading, size: 0.06), + .init(.viewport3D, edge: .center, size: 0.76), + .init(.properties, edge: .trailing, size: 0.18), + ] + } + } +} + +// MARK: - Panel identity + +enum PanelID: String, Identifiable, CaseIterable { + case toolSidebar = "Tools" + case viewport3D = "3D Viewport" + case uvEditor = "UV Editor" + case textureBake = "Texture Bake" + case brushPanel = "Brushes" + case gpCanvas = "GP Canvas" + case gpLayers = "Layers" + case onionSkin = "Onion Skin" + case outliner = "Outliner" + case properties = "Properties" + case renderSettings = "Render" + case timeline = "Timeline" + + var id: String { rawValue } +} + +enum PanelEdge { + case leading, center, trailing, bottom +} + +struct PanelSlot: Identifiable { + let id: PanelID + let edge: PanelEdge + var size: CGFloat // proportion of total width (0…1) + var isVisible: Bool = true + var minSize: CGFloat = 0.04 + + init(_ id: PanelID, edge: PanelEdge, size: CGFloat, minSize: CGFloat = 0.04) { + self.id = id + self.edge = edge + self.size = size + self.minSize = minSize + } +} + +// MARK: - Manager + +@Observable +final class WorkspaceManager { + var activeWorkspace: WorkspaceType { + didSet { panels = activeWorkspace.defaultPanels } + } + var panels: [PanelSlot] + + // Bottom panels (shared across workspaces) + var showTimeline: Bool = false + var timelineHeight: CGFloat = 0.2 + + init(workspace: WorkspaceType = .modeling) { + self.activeWorkspace = workspace + self.panels = workspace.defaultPanels + } + + // MARK: - Panel queries + + func panel(_ id: PanelID) -> PanelSlot? { + panels.first { $0.id == id } + } + + var leadingPanels: [PanelSlot] { + panels.filter { $0.edge == .leading && $0.isVisible } + } + + var centerPanels: [PanelSlot] { + panels.filter { $0.edge == .center && $0.isVisible } + } + + var trailingPanels: [PanelSlot] { + panels.filter { $0.edge == .trailing && $0.isVisible } + } + + // MARK: - Panel mutation + + func togglePanel(_ id: PanelID) { + guard let idx = panels.firstIndex(where: { $0.id == id }) else { return } + panels[idx].isVisible.toggle() + } + + func resizePanel(_ id: PanelID, to newSize: CGFloat) { + guard let idx = panels.firstIndex(where: { $0.id == id }) else { return } + panels[idx].size = max(panels[idx].minSize, min(newSize, 0.8)) + } + + func switchWorkspace(to type: WorkspaceType) { + withAnimation(.easeInOut(duration: 0.25)) { + activeWorkspace = type + } + } +}