Workspace System, Grease Pencil Pipeline

This commit is contained in:
Dallas Groot 2026-04-10 21:41:12 -07:00
parent 1daeee85c7
commit e9bac37371
11 changed files with 1405 additions and 55 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -10,23 +10,33 @@
04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3194BE92D558B2498E78A9A /* MultiViewportView.swift */; }; 04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3194BE92D558B2498E78A9A /* MultiViewportView.swift */; };
10DBD6D39988BB28432F6E20 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2797EBBC3A95912225AA56 /* ContentView.swift */; }; 10DBD6D39988BB28432F6E20 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2797EBBC3A95912225AA56 /* ContentView.swift */; };
1A259C6950EE9801949B1156 /* OrbitalCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60211B66F675CCAE9988537 /* OrbitalCamera.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 */; }; 4B4188022460B77F52241024 /* ViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2E2F1DF54C842C8F8FE0A8 /* ViewportView.swift */; };
4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4A2F33EEF87CEC04445F54D /* MetalContext.swift */; }; 4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4A2F33EEF87CEC04445F54D /* MetalContext.swift */; };
52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */; }; 52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */; };
6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243D22FA262638409E279A74 /* MathUtils.swift */; }; 6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243D22FA262638409E279A74 /* MathUtils.swift */; };
887672E4A0CAEAEAF5D2BA89 /* PropertiesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9762DC2B358EEC36A7A9C1EE /* PropertiesPanel.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 */; }; 9D7F22D1B33C5F204B5B3B08 /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36992EE3F9FF59CB64C9635B /* StatusBar.swift */; };
9F6FAE5B54D24BFA9FD741B1 /* SceneObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475B45D562F5D080F93366A5 /* SceneObject.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 */; }; B00DF6E051DA2B49CB101864 /* MeshData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A1289842AED01381EDCE6D /* MeshData.swift */; };
C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2C74721F4BD1D4598B0A786E /* Shaders.metal */; }; C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2C74721F4BD1D4598B0A786E /* Shaders.metal */; };
C79680762E33BE6A6E89ED9B /* BlenderiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658DAE62EE166FE88AE8CA6B /* BlenderiOSApp.swift */; }; 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 */; }; E16B4DE90C5ACA18E73C5194 /* PrimitiveGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABD6D900EBFC5797EB70A25 /* PrimitiveGenerator.swift */; };
EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */; }; EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */; };
EB4B94857FA1134ECB9A47E6 /* ViewportRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB63861639BB5E70747F0BE /* ViewportRenderer.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 */; }; F30954E11C15820EF8482F27 /* HeaderBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFCEC67F9602FF24F18A8B /* HeaderBar.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0006620C33A001D35C0711BF /* GPStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPStroke.swift; sourceTree = "<group>"; };
0A9AA75DBC75EFA8A826B0B1 /* WorkspaceLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceLayoutView.swift; sourceTree = "<group>"; };
193516DB800C0281CB3EDFE1 /* WorkspaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManager.swift; sourceTree = "<group>"; };
243D22FA262638409E279A74 /* MathUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MathUtils.swift; sourceTree = "<group>"; }; 243D22FA262638409E279A74 /* MathUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MathUtils.swift; sourceTree = "<group>"; };
2C74721F4BD1D4598B0A786E /* Shaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Shaders.metal; sourceTree = "<group>"; }; 2C74721F4BD1D4598B0A786E /* Shaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Shaders.metal; sourceTree = "<group>"; };
36992EE3F9FF59CB64C9635B /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = "<group>"; }; 36992EE3F9FF59CB64C9635B /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = "<group>"; };
@ -35,18 +45,22 @@
4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneGraph.swift; sourceTree = "<group>"; }; 4813D2738AA9CA9BAB5B032B /* SceneGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneGraph.swift; sourceTree = "<group>"; };
4B541F4B8B8BCF748E8855E3 /* BlenderiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlenderiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 4D2E2F1DF54C842C8F8FE0A8 /* ViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportView.swift; sourceTree = "<group>"; };
507568EE2ED3D511E6534680 /* UVPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UVPanels.swift; sourceTree = "<group>"; };
658DAE62EE166FE88AE8CA6B /* BlenderiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlenderiOSApp.swift; sourceTree = "<group>"; }; 658DAE62EE166FE88AE8CA6B /* BlenderiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlenderiOSApp.swift; sourceTree = "<group>"; };
68874646FB899BACC500E682 /* ShaderTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShaderTypes.h; sourceTree = "<group>"; }; 68874646FB899BACC500E682 /* ShaderTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShaderTypes.h; sourceTree = "<group>"; };
77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSidebar.swift; sourceTree = "<group>"; }; 77E46CC57FEABBE2DDF494FC /* ToolSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSidebar.swift; sourceTree = "<group>"; };
7A67FB3C7BBBE7CE189A156F /* PencilCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PencilCaptureView.swift; sourceTree = "<group>"; };
82A1289842AED01381EDCE6D /* MeshData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshData.swift; sourceTree = "<group>"; }; 82A1289842AED01381EDCE6D /* MeshData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshData.swift; sourceTree = "<group>"; };
9762DC2B358EEC36A7A9C1EE /* PropertiesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesPanel.swift; sourceTree = "<group>"; }; 9762DC2B358EEC36A7A9C1EE /* PropertiesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesPanel.swift; sourceTree = "<group>"; };
9ABD6D900EBFC5797EB70A25 /* PrimitiveGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimitiveGenerator.swift; sourceTree = "<group>"; }; 9ABD6D900EBFC5797EB70A25 /* PrimitiveGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimitiveGenerator.swift; sourceTree = "<group>"; };
A4A2F33EEF87CEC04445F54D /* MetalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalContext.swift; sourceTree = "<group>"; }; A4A2F33EEF87CEC04445F54D /* MetalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalContext.swift; sourceTree = "<group>"; };
ADB63861639BB5E70747F0BE /* ViewportRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportRenderer.swift; sourceTree = "<group>"; }; ADB63861639BB5E70747F0BE /* ViewportRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportRenderer.swift; sourceTree = "<group>"; };
C091D660A4829C8CB29571CA /* GPPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPPanels.swift; sourceTree = "<group>"; };
C3194BE92D558B2498E78A9A /* MultiViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiViewportView.swift; sourceTree = "<group>"; }; C3194BE92D558B2498E78A9A /* MultiViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiViewportView.swift; sourceTree = "<group>"; };
CD2797EBBC3A95912225AA56 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; CD2797EBBC3A95912225AA56 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
D60211B66F675CCAE9988537 /* OrbitalCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbitalCamera.swift; sourceTree = "<group>"; }; D60211B66F675CCAE9988537 /* OrbitalCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbitalCamera.swift; sourceTree = "<group>"; };
DBDFCEC67F9602FF24F18A8B /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = "<group>"; }; DBDFCEC67F9602FF24F18A8B /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = "<group>"; };
E469A7474A515E3294F263D2 /* GPCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPCanvasView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -67,6 +81,15 @@
path = Math; path = Math;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
41D5D1C1ED9693D6F18723FE /* Workspace */ = {
isa = PBXGroup;
children = (
0A9AA75DBC75EFA8A826B0B1 /* WorkspaceLayoutView.swift */,
193516DB800C0281CB3EDFE1 /* WorkspaceManager.swift */,
);
path = Workspace;
sourceTree = "<group>";
};
5379DF9111874486418F433C /* Products */ = { 5379DF9111874486418F433C /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -75,6 +98,17 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A07AB99DF130A8AC10FF10C3 /* GreasePencil */ = {
isa = PBXGroup;
children = (
E469A7474A515E3294F263D2 /* GPCanvasView.swift */,
C091D660A4829C8CB29571CA /* GPPanels.swift */,
0006620C33A001D35C0711BF /* GPStroke.swift */,
7A67FB3C7BBBE7CE189A156F /* PencilCaptureView.swift */,
);
path = GreasePencil;
sourceTree = "<group>";
};
ADBD2DAFB4BAD4A66023DB58 = { ADBD2DAFB4BAD4A66023DB58 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -87,16 +121,27 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0548701BAB861DC73AA4EFD9 /* App */, 0548701BAB861DC73AA4EFD9 /* App */,
A07AB99DF130A8AC10FF10C3 /* GreasePencil */,
128601FB1E9F41C354FDA194 /* Math */, 128601FB1E9F41C354FDA194 /* Math */,
CD76C28FBD7C99C9A4527E9D /* Primitives */, CD76C28FBD7C99C9A4527E9D /* Primitives */,
D9F0C729406B3D21928FCF9A /* Scene */, D9F0C729406B3D21928FCF9A /* Scene */,
BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */, BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */,
D4E87F4D56F649AED031166A /* UI */, D4E87F4D56F649AED031166A /* UI */,
B974BA8F307CDB4C05B18B27 /* UV */,
ECEF6DBA962C7AA044302958 /* Viewport */, ECEF6DBA962C7AA044302958 /* Viewport */,
41D5D1C1ED9693D6F18723FE /* Workspace */,
); );
path = Sources; path = Sources;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B974BA8F307CDB4C05B18B27 /* UV */ = {
isa = PBXGroup;
children = (
507568EE2ED3D511E6534680 /* UVPanels.swift */,
);
path = UV;
sourceTree = "<group>";
};
BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */ = { BE1CF3ED91CA5F5EA3F93EF4 /* Shaders */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -203,12 +248,16 @@
files = ( files = (
C79680762E33BE6A6E89ED9B /* BlenderiOSApp.swift in Sources */, C79680762E33BE6A6E89ED9B /* BlenderiOSApp.swift in Sources */,
10DBD6D39988BB28432F6E20 /* ContentView.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 */, F30954E11C15820EF8482F27 /* HeaderBar.swift in Sources */,
6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */, 6404E98AA8FB07D8724F68AC /* MathUtils.swift in Sources */,
B00DF6E051DA2B49CB101864 /* MeshData.swift in Sources */, B00DF6E051DA2B49CB101864 /* MeshData.swift in Sources */,
4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */, 4F78D507C0D5D2A8DCEF1CF8 /* MetalContext.swift in Sources */,
04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */, 04F5570CFB7FF0C91E7D7022 /* MultiViewportView.swift in Sources */,
1A259C6950EE9801949B1156 /* OrbitalCamera.swift in Sources */, 1A259C6950EE9801949B1156 /* OrbitalCamera.swift in Sources */,
2FAAA8849AF1E8BDE2E5F71A /* PencilCaptureView.swift in Sources */,
E16B4DE90C5ACA18E73C5194 /* PrimitiveGenerator.swift in Sources */, E16B4DE90C5ACA18E73C5194 /* PrimitiveGenerator.swift in Sources */,
887672E4A0CAEAEAF5D2BA89 /* PropertiesPanel.swift in Sources */, 887672E4A0CAEAEAF5D2BA89 /* PropertiesPanel.swift in Sources */,
52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */, 52E426CA913149FDBED8B042 /* SceneGraph.swift in Sources */,
@ -216,8 +265,11 @@
C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */, C4460E59C4875E1CC9401ABE /* Shaders.metal in Sources */,
9D7F22D1B33C5F204B5B3B08 /* StatusBar.swift in Sources */, 9D7F22D1B33C5F204B5B3B08 /* StatusBar.swift in Sources */,
EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */, EA0F89F0BEF0DA241E801D82 /* ToolSidebar.swift in Sources */,
DDAF7FF6785EA6DD6A51184A /* UVPanels.swift in Sources */,
EB4B94857FA1134ECB9A47E6 /* ViewportRenderer.swift in Sources */, EB4B94857FA1134ECB9A47E6 /* ViewportRenderer.swift in Sources */,
4B4188022460B77F52241024 /* ViewportView.swift in Sources */, 4B4188022460B77F52241024 /* ViewportView.swift in Sources */,
31EBB92C0BDE32AB13FBACE4 /* WorkspaceLayoutView.swift in Sources */,
9291D856153D200788DDD214 /* WorkspaceManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -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>(
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<Float> {
normalize(renderer.camera.eye - renderer.camera.target)
}
private func colorToSIMD(_ color: Color) -> SIMD4<Float> {
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>(Float(r), Float(g), Float(b), Float(a))
}
}

View file

@ -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<Double>
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)
}
}
}

View file

@ -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<Float> // in points
let pressure: Float // 01 (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<Float>
var normal: SIMD3<Float> // 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<Float> = 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..<points.count {
let p = points[i]
let tangent: SIMD3<Float>
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<Float>(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<MeshVertex>.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<Float>
let drawingPlaneNormal: SIMD3<Float>
let drawingPlanePoint: SIMD3<Float> // point on the plane
/// Unproject a screen point onto the drawing plane.
func unproject(screenPoint: SIMD2<Float>) -> SIMD3<Float> {
// 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<Float>(nx, ny, 0, 1)
let farClip = SIMD4<Float>(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)
}
}

View file

@ -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<UITouch>, with event: UIEvent?) {
guard let touch = touches.first, touch.type == .pencil else { return }
onStrokeBegan?()
emitSamples(touch: touch, event: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first, touch.type == .pencil else { return }
emitSamples(touch: touch, event: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first, touch.type == .pencil else { return }
emitSamples(touch: touch, event: event)
onStrokeEnded?()
}
override func touchesCancelled(_ touches: Set<UITouch>, 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>(Float(loc.x), Float(loc.y)),
pressure: pressure,
altitude: Float(ct.altitudeAngle),
azimuth: Float(ct.azimuthAngle(in: self)),
timestamp: ct.timestamp
)
onSample?(sample)
}
}
}

View file

@ -1,90 +1,153 @@
// ContentView.swift // ContentView.swift
// Root layout header, tool sidebar, multi-viewport, properties, status bar. // Root layout workspace header, dynamic panel layout, status bar.
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@State private var context = MetalContext() @State private var context = MetalContext()
@State private var workspace = WorkspaceManager()
@State private var currentMode: EditMode = .object @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 { var body: some View {
GeometryReader { geo in GeometryReader { geo in
VStack(spacing: 0) { VStack(spacing: 0) {
// Top bar // Workspace header
HeaderBar( WorkspaceHeader(
workspace: workspace,
currentMode: $currentMode, currentMode: $currentMode,
layout: $context.viewportLayout,
context: context context: context
) )
// Main area // Dynamic workspace layout
HStack(spacing: 0) { WorkspaceLayoutView(
// Left tool strip workspace: workspace,
ToolSidebar(activeTool: $activeTool) context: context
)
// Center: viewport(s) // Status bar
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) StatusBar(mode: currentMode, sceneGraph: context.sceneGraph)
} }
} }
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.toolbar { }
ToolbarItem(placement: .topBarTrailing) { }
HStack(spacing: 12) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
showOutliner.toggle()
}
} label: {
Image(systemName: "list.bullet")
}
Button { // MARK: - Workspace Header
withAnimation(.easeInOut(duration: 0.2)) {
showProperties.toggle() 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 Divider().frame(height: 20)
private func rightPanel(height: CGFloat) -> some View {
VStack(spacing: 0) { // Mode picker
if showOutliner { Menu {
OutlinerPanel(sceneGraph: context.sceneGraph) ForEach(EditMode.allCases) { mode in
.frame(height: height * 0.3) 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().frame(height: 20)
Divider()
// 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 { Button { context.deleteSelected() } label: {
PropertiesPanel( Image(systemName: "trash")
isExpanded: $showProperties, .font(.caption)
sceneGraph: context.sceneGraph }
) .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)
} }
} }

263
Sources/UV/UVPanels.swift Normal file
View file

@ -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..<rows {
for col in 0..<cols {
let isLight = (row + col) % 2 == 0
let rect = CGRect(
x: CGFloat(col) * cellSize + uvOffset.width.truncatingRemainder(dividingBy: cellSize * 2),
y: CGFloat(row) * cellSize + uvOffset.height.truncatingRemainder(dividingBy: cellSize * 2),
width: cellSize,
height: cellSize
)
context.fill(
Path(rect),
with: .color(isLight ? Color(white: 0.25) : Color(white: 0.20))
)
}
}
// UV space bounds (0,0 to 1,1 mapped to view)
let uvRect = CGRect(
x: uvOffset.width,
y: uvOffset.height,
width: size.width * uvZoom,
height: size.height * uvZoom
)
context.stroke(
Path(uvRect),
with: .color(.orange.opacity(0.4)),
lineWidth: 1
)
}
.gesture(uvDragGesture)
.gesture(uvZoomGesture)
// Toolbar overlay
VStack {
HStack(spacing: 8) {
Text("UV Editor")
.font(.caption2.weight(.medium))
.foregroundStyle(.white.opacity(0.5))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 4))
Spacer()
// Edit mode picker
Picker("Mode", selection: $uvMode) {
ForEach(UVEditMode.allCases) { mode in
Image(systemName: mode.icon)
.tag(mode)
}
}
.pickerStyle(.segmented)
.frame(width: 120)
}
.padding(8)
Spacer()
}
// Placeholder instruction
if true {
VStack {
Spacer()
Text("Select a mesh to view UVs")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom, 20)
}
}
}
.background(Color(white: 0.18))
}
}
private var uvDragGesture: some Gesture {
DragGesture()
.onChanged { value in
uvOffset = value.translation
}
}
private var uvZoomGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
uvZoom = max(0.1, min(value.magnification, 5.0))
}
}
}
enum UVEditMode: String, CaseIterable, Identifiable {
case vertex = "Vertex"
case edge = "Edge"
case face = "Face"
case island = "Island"
var id: String { rawValue }
var icon: String {
switch self {
case .vertex: "circle.fill"
case .edge: "line.diagonal"
case .face: "square.fill"
case .island: "square.on.square"
}
}
}
// MARK: - Texture Bake Panel
struct TextureBakePanel: View {
@State private var bakeType: BakeType = .diffuse
@State private var resolution: Int = 1024
@State private var margin: Int = 16
@State private var selectedOnly = false
@State private var isBaking = false
@State private var progress: Double = 0
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// Header
HStack {
Image(systemName: "square.grid.3x3.topleft.filled")
.foregroundStyle(.orange)
Text("Texture Bake")
.font(.subheadline.weight(.semibold))
}
.padding(.bottom, 4)
// Bake type
VStack(alignment: .leading, spacing: 4) {
Text("Bake Type")
.font(.caption)
.foregroundStyle(.secondary)
Picker("Type", selection: $bakeType) {
ForEach(BakeType.allCases) { type in
Text(type.rawValue).tag(type)
}
}
.pickerStyle(.menu)
.tint(.orange)
}
// Resolution
VStack(alignment: .leading, spacing: 4) {
Text("Resolution")
.font(.caption)
.foregroundStyle(.secondary)
Picker("Resolution", selection: $resolution) {
Text("512").tag(512)
Text("1024").tag(1024)
Text("2048").tag(2048)
Text("4096").tag(4096)
}
.pickerStyle(.segmented)
}
// Margin
VStack(alignment: .leading, spacing: 4) {
Text("Margin: \(margin)px")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: Binding(
get: { Double(margin) },
set: { margin = Int($0) }
), in: 0...64, step: 1)
.tint(.orange)
}
Toggle("Selected Only", isOn: $selectedOnly)
.font(.caption)
Divider()
// Target engine
VStack(alignment: .leading, spacing: 4) {
Text("Target Engine")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
engineTag("Unity")
engineTag("Unreal")
engineTag("Godot")
}
}
Divider()
// Bake button
Button {
isBaking = true
progress = 0
// Simulate progress
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
progress += 0.02
if progress >= 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 }
}

View file

@ -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)
}
}

View file

@ -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 (01)
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
}
}
}