From e9bac3737119974e13e56b98a7192db15f38db9b Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 21:41:12 -0700 Subject: [PATCH] Workspace System, Grease Pencil Pipeline --- .DS_Store | Bin 0 -> 6148 bytes BlenderiOS.xcodeproj/project.pbxproj | 52 ++++ .../UserInterfaceState.xcuserstate | Bin 12021 -> 17069 bytes Sources/GreasePencil/GPCanvasView.swift | 113 ++++++++ Sources/GreasePencil/GPPanels.swift | 240 ++++++++++++++++ Sources/GreasePencil/GPStroke.swift | 214 ++++++++++++++ Sources/GreasePencil/PencilCaptureView.swift | 74 +++++ Sources/UI/ContentView.swift | 173 ++++++++---- Sources/UV/UVPanels.swift | 263 ++++++++++++++++++ Sources/Workspace/WorkspaceLayoutView.swift | 164 +++++++++++ Sources/Workspace/WorkspaceManager.swift | 167 +++++++++++ 11 files changed, 1405 insertions(+), 55 deletions(-) create mode 100644 .DS_Store create mode 100644 Sources/GreasePencil/GPCanvasView.swift create mode 100644 Sources/GreasePencil/GPPanels.swift create mode 100644 Sources/GreasePencil/GPStroke.swift create mode 100644 Sources/GreasePencil/PencilCaptureView.swift create mode 100644 Sources/UV/UVPanels.swift create mode 100644 Sources/Workspace/WorkspaceLayoutView.swift create mode 100644 Sources/Workspace/WorkspaceManager.swift diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a3ea15c79f2b511135dff59defb4e9b6c959c45e GIT binary patch literal 6148 zcmeHKu};G<5Pb)gD9X~2(O*!BKZvS!V5K7qG$0TS1hsTv%g8_Raq#ZWphS&eLkQhT z_B-469Q#SJeE?*(yFLXb0LCneqRxP6_u$Y$4jvK3=Gfp0&$z)9+kuJxVwbMHz%AGO z$kJW^-C5mKtGZb+g7U~Kas6&)8!7O__y+gnRowLyFL*#bz{K_5@*Gp@|oI_fOiZ**cj1Wb^qLV;gU-~&6VM5h1% literal 0 HcmV?d00001 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 22ed305a6806089b981e43460469a2f265919811..702365c8887528c84c566f98b272e2a8ac9a34ca 100644 GIT binary patch literal 17069 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 blzdn6YsoP=vLkTZQT*YalK=ZZOMd@vaIehM delta 6328 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 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 + } + } +}