Workspace System, Grease Pencil Pipeline
This commit is contained in:
parent
1daeee85c7
commit
e9bac37371
11 changed files with 1405 additions and 55 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Binary file not shown.
113
Sources/GreasePencil/GPCanvasView.swift
Normal file
113
Sources/GreasePencil/GPCanvasView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
240
Sources/GreasePencil/GPPanels.swift
Normal file
240
Sources/GreasePencil/GPPanels.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Sources/GreasePencil/GPStroke.swift
Normal file
214
Sources/GreasePencil/GPStroke.swift
Normal 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 // 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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Sources/GreasePencil/PencilCaptureView.swift
Normal file
74
Sources/GreasePencil/PencilCaptureView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
263
Sources/UV/UVPanels.swift
Normal 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 }
|
||||||
|
}
|
||||||
164
Sources/Workspace/WorkspaceLayoutView.swift
Normal file
164
Sources/Workspace/WorkspaceLayoutView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
167
Sources/Workspace/WorkspaceManager.swift
Normal file
167
Sources/Workspace/WorkspaceManager.swift
Normal 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 (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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue