From 430ce5e766f1ec45fa190b911f6bddd870a3af74 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 7 Nov 2026 22:09:11 +0300 Subject: [PATCH] Add Mesh Terrain MVP and terrain runtime dependencies --- .github/workflows/swift-macos.yml | 5 + .github/workflows/swift-windows.yml | 7 + .../0029-mesh-terrain-triangle-kernel.md | 51 + Documentation/Designs/terrain-authoring-v2.md | 337 +++ .../Designs/terrain-mesh-composition.md | 758 +++++ .../Plans/2026-11-04-TerrainAuthoringV2.md | 1625 +++++++++++ .../2026-11-06-MeshTerrainComposition.md | 209 ++ .../Research/2026-11-06-UnrealMeshTerrain.md | 406 +++ Editor/Documentation/AgentGuides/scenes.md | 4 +- Editor/Documentation/EditorGuide.md | 8 + Editor/Documentation/Terrain.md | 210 +- Editor/Package.swift | 2 +- Editor/README.md | 6 + .../MeshTerrainWorkshop/.ada/project.json | 32 + .../Assets/Models/root-tree.glb | Bin 0 -> 5304 bytes .../Assets/Scenes/Main.ascn | 207 ++ .../Assets/Terrain/Workshop.aterrain | 1 + .../Assets/Terrain/Workshop.atmesh | 1 + .../Assets/Terrain/grass-sprig.vox | Bin 0 -> 1148 bytes .../Assets/Terrain/loose-stone.vox | Bin 0 -> 1168 bytes Editor/Samples/MeshTerrainWorkshop/README.md | 32 + .../MeshTerrainWorkshop/Sources/Main.ada | 1 + .../Assets/Documentation/catalog.json | 1283 ++++++++- .../AdaEditor/Assets/Legal/Manifold/AUTHORS | 10 + .../AdaEditor/Assets/Legal/Manifold/LICENSE | 201 ++ .../AdaEditor/Assets/Legal/Manifold/README.md | 10 + .../Legal/Manifold/THIRD_PARTY_NOTICES.txt | 120 + .../Assets/Legal/Manifold/UPSTREAM.json | 59 + .../AdaEditor/Assets/Localization/en.json | 160 +- .../AdaEditor/Assets/Localization/ru.json | 162 +- .../AdaEditor/EditorComponentRegistry.swift | 13 +- .../AdaEditor/EditorMeshTerrainCreation.swift | 113 + .../EditorMeshTerrainDragSession.swift | 58 + .../AdaEditor/EditorMeshTerrainHistory.swift | 188 ++ .../EditorMeshTerrainHistoryQueue.swift | 46 + .../EditorMeshTerrainPoseQueue.swift | 75 + .../EditorMeshTerrainSculptStroke.swift | 257 ++ .../EditorMeshTerrainTransaction.swift | 114 + .../AdaEditor/EditorModelAssetImporter.swift | 36 +- .../AdaEditor/EditorSceneFileLoader.swift | 87 +- .../EditorSceneModel+Hierarchy.swift | 2 +- .../EditorTerrainResourceWriter.swift | 134 + .../EditorTerrainSourceAuthoring.swift | 275 ++ .../EditorTerrainSourceDescriptor.swift | 65 + .../Sources/AdaEditor/EditorTerrainTool.swift | 81 + .../Sources/AdaEditor/Tooling/EditorCLI.swift | 4 + .../AdaEditor/UI/Editor/EditorEnumField.swift | 60 +- .../UI/Editor/EditorInspectorSidebar.swift | 45 +- .../EditorInspectorSidebarViewModel.swift | 41 + .../UI/Editor/EditorMeshPicking.swift | 73 +- .../EditorMeshTerrainJoiningPanel.swift | 134 + .../AdaEditor/UI/Editor/EditorPicking.swift | 6 +- .../UI/Editor/EditorSceneToolbarButton.swift | 3 +- .../Editor/EditorSceneViewportEditing.swift | 9 +- .../UI/Editor/EditorSceneViewportGizmos.swift | 2 +- .../EditorSceneViewportMeshTerrain.swift | 188 ++ ...itorSceneViewportMeshTerrainControls.swift | 82 + .../EditorSceneViewportMeshTerrainDrag.swift | 179 ++ ...torSceneViewportMeshTerrainGroupDrag.swift | 134 + ...ditorSceneViewportMeshTerrainHistory.swift | 49 + ...ditorSceneViewportMeshTerrainPalette.swift | 53 + ...EditorSceneViewportMeshTerrainSculpt.swift | 227 ++ .../UI/Editor/EditorSceneViewportModel.swift | 316 ++- .../EditorSceneViewportSourceTerrain.swift | 315 +++ .../Editor/EditorSceneViewportTerrain.swift | 176 +- .../EditorSceneViewportTerrainTools.swift | 291 ++ .../UI/Editor/EditorSceneViewportView.swift | 172 +- .../UI/Editor/EditorTerrainDockPanel.swift | 239 ++ .../UI/Editor/EditorTerrainPanel.swift | 8 + .../AdaEditorTests/EditorEnumMenuTests.swift | 66 + .../EditorMeshTerrainBackendTests.swift | 47 + .../EditorMeshTerrainControlTests.swift | 57 + .../EditorMeshTerrainDragTests.swift | 461 ++++ .../EditorMeshTerrainFastBrushTests.swift | 51 + .../EditorMeshTerrainHistoryTests.swift | 171 ++ .../EditorMeshTerrainImportTests.swift | 65 + .../EditorMeshTerrainPaintTests.swift | 70 + .../EditorMeshTerrainPaletteTests.swift | 49 + ...ditorMeshTerrainPopulationBrushTests.swift | 65 + .../EditorMeshTerrainSculptTests.swift | 155 ++ .../EditorTerrainPickingTests.swift | 33 + .../EditorTerrainSourceAuthoringTests.swift | 279 ++ .../EditorTerrainSourceOptionalTests.swift | 31 + .../EditorTerrainSourcePickingTests.swift | 68 + .../EditorTerrainSourceTests.swift | 83 + .../EditorTerrainSourceViewportTests.swift | 524 ++++ Editor/scripts/stage-build-sdk.py | 8 +- Editor/scripts/test_stage_build_sdk.py | 46 + .../scripts/update-offline-documentation.py | 30 +- Package.swift | 102 +- .../AdaShaderTranspilerTool.swift | 56 +- .../AdaWebExportPlugin.swift | 65 +- Sources/AdaAssets/Asset.swift | 5 + .../AdaAssets/AssetsManager+BoundedRead.swift | 105 + Sources/AdaAssets/AssetsManager.swift | 62 +- .../GLTF/GLTFImportResult+Validation.swift | 50 + Sources/AdaAssets/GLTF/GLTFLoader.swift | 2 + .../GLTF/NativeGLTFLoader+Accessors.swift | 73 +- .../GLTF/NativeGLTFLoader+Budget.swift | 181 ++ Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 63 +- Sources/AdaAssets/ModelImportLimits.swift | 43 + .../OBJ/NativeOBJLoader+Budget.swift | 42 + Sources/AdaAssets/OBJ/NativeOBJLoader.swift | 114 +- Sources/AdaAssets/OBJ/OBJLoader.swift | 8 + .../Terrain/AssetsManager+Terrain.swift | 57 + .../Terrain/MeshTerrainAuthoringSession.swift | 115 + ...MeshTerrainDocumentAsset+Compilation.swift | 51 + .../Terrain/MeshTerrainDocumentAsset.swift | 161 ++ .../Terrain/TerrainAssetWriter.swift | 247 ++ .../Terrain/TerrainLayerReference.swift | 82 + .../Terrain/TerrainManifestAsset.swift | 266 ++ .../Terrain/TerrainMaterialPalette.swift | 53 + .../Terrain/TerrainMaterialRule.swift | 87 + .../TerrainMeshPrototypeReference.swift | 25 + .../TerrainRegionAsset+Instances.swift | 166 ++ .../Terrain/TerrainRegionAsset+Surface.swift | 206 ++ .../Terrain/TerrainRegionAsset.swift | 217 ++ .../Terrain/TerrainRegionByteBudget.swift | 22 + .../Terrain/TerrainRegionInstance.swift | 173 ++ .../Terrain/TerrainRegionSurfaceSample.swift | 67 + .../Terrain/TerrainSurfacePaint.swift | 58 + .../Terrain/TerrainTextureVariation.swift | 59 + .../Terrain/TerrainVolumeAssetWriter.swift | 186 ++ .../Terrain/TerrainVolumeBrickAsset.swift | 109 + .../Terrain/TerrainVolumeDocumentAsset.swift | 124 + .../Terrain/TerrainVolumeOperation.swift | 47 + .../AdaAssets/Terrain/TerrainVolumeSave.swift | 16 + .../AdaAssets/Voxel/VOXLoader+Budget.swift | 29 + Sources/AdaAssets/Voxel/VOXLoader.swift | 47 +- .../Assets/Shaders/flat3d.glsl | 297 +- .../CorePipelines/Core3DPlugin.swift | 1 + .../CorePipelines/DebugView3D.swift | 15 +- .../CorePipelines/MeshVisibility3D.swift | 10 +- .../CorePipelines/Meshlet3D.swift | 44 +- .../CorePipelines/PathTracing3DScene.swift | 283 +- .../RenderNodes3D/Main3DRenderNode.swift | 16 +- .../PathTracing3DRenderNode.swift | 2 +- .../RenderPipelines3D/Flat3DPipeline.swift | 3 +- .../RenderPipelines3D/PBR3DPipeline.swift | 6 + .../CorePipelines/ShadowSceneDigest3D.swift | 7 + .../CorePipelines/Terrain3DUniforms.swift | 41 + .../CorePipelines/VisibleMesh3DLists.swift | 333 ++- .../CorePipelines/Wireframe3DResources.swift | 49 + .../AdaECS/Component/Component+Runtime.swift | 4 + .../Component/RelationshipComponent.swift | 9 +- .../AdaECS/Component/TransientEntity.swift | 5 + .../AdaECS/Entity/Entity+ComponentSet.swift | 12 +- Sources/AdaECS/System/SystemDependency.swift | 19 +- Sources/AdaECS/System/SystemsGraph.swift | 10 + Sources/AdaECS/World/Archetype.swift | 10 +- Sources/AdaECS/World/World.swift | 35 +- Sources/AdaEngine/AdaEngine.docc/AdaEngine.md | 1 + .../AdaEngine.docc/Articles/3D/Terrain.md | 1045 +++++++ .../Articles/Developers/ImageDecoders.md | 88 +- Sources/AdaEngine/Exported.swift | 1 + .../MovieMaker/MovieSequenceWriter.swift | 7 +- .../AdaGeometry/MeshTerrainContentKey.swift | 50 + Sources/AdaGeometry/MeshTerrainCoverage.swift | 39 + Sources/AdaGeometry/MeshTerrainDecoding.swift | 37 + Sources/AdaGeometry/MeshTerrainDocument.swift | 180 ++ .../AdaGeometry/MeshTerrainEvaluator.swift | 157 ++ Sources/AdaGeometry/MeshTerrainPaint.swift | 91 + .../AdaGeometry/MeshTerrainPaintBaker.swift | 76 + .../MeshTerrainPaintTopology.swift | 174 ++ .../AdaGeometry/MeshTerrainPopulation.swift | 165 ++ .../MeshTerrainPopulationCells.swift | 271 ++ .../MeshTerrainPopulationCoverage.swift | 107 + .../MeshTerrainPopulationSampling.swift | 186 ++ .../MeshTerrainSourceAppearance.swift | 107 + .../MeshTerrainSurfaceAnchor.swift | 69 + .../MeshTerrainSurfaceSculpt.swift | 37 + .../TriangleContactRelaxation.swift | 185 ++ .../TriangleGeometryAdmission.swift | 46 + .../AdaGeometry/TrianglePatchClosure.swift | 92 + Sources/AdaGeometry/TriangleSheetSculpt.swift | 148 + .../TriangleSurface+Decoding.swift | 27 + Sources/AdaGeometry/TriangleSurface.swift | 236 ++ .../AdaGeometry/TriangleSurfaceEditing.swift | 89 + .../AdaGeometry/TriangleSurfaceNormals.swift | 28 + .../TriangleSurfacePrimitives.swift | 60 + Sources/AdaPhysics/Physics3D/Body3D.swift | 34 +- .../CharacterController3DSystem.swift | 4 + .../Components/PhysicsBody3DComponent.swift | 25 + .../Physics3D/NativeTriangleMesh3D.swift | 11 +- .../Physics3D/Physics3DPlugin.swift | 1 + .../Physics3D/Physics3DSystem.swift | 93 +- .../PhysicsCollisionPreparationMetrics.swift | 30 + .../Physics3D/PhysicsSimulationGate3D.swift | 23 + .../AdaPhysics/Physics3D/PhysicsWorld3D.swift | 3 + .../Physics3D/Shape3DResource+Bounds.swift | 25 + .../Physics3D/Shape3DResource.swift | 49 +- .../Headless/HeadlessRenderBackend.swift | 13 +- .../Backends/Metal/Misc/MetalGPUTexture.swift | 20 +- .../Backends/WebGPU/Misc/WGPUGPUTexture.swift | 45 +- .../WebGPU/Misc/WGPURenderPipeline.swift | 6 +- Sources/AdaRender/Image/Image.swift | 34 +- .../Image/ImageLoaders/ImageDecoder.swift | 11 + .../Image/ImageLoaders/PNGSerializer.swift | 10 + .../ImageLoaders/RasterImageDecoder.swift | 11 + .../Materials/PBRMaterial+Instance.swift | 1 + Sources/AdaRender/Materials/PBRMaterial.swift | 9 +- .../Materials/TerrainMaterialBindings.swift | 19 + .../TerrainMaterialTextureArrays.swift | 19 + Sources/AdaRender/Mesh/MeshDescriptor.swift | 24 +- .../AdaRender/Mesh/MeshletLODGeometry.swift | 9 +- Sources/AdaRender/Misc/Mesh3DInstance.swift | 14 + .../AdaRender/Misc/TextureDescriptor.swift | 7 + .../RenderItems/Opaque3DRenderItem.swift | 5 +- Sources/AdaRender/Shaders/ShaderModule.swift | 18 +- .../AdaRender/Shaders/WGSLReflection.swift | 13 +- .../Texture/Texture2D+Subresources.swift | 43 + Sources/AdaRender/Texture/Texture2D.swift | 8 + .../AdaRender/Texture/Texture2DProxy.swift | 2 +- .../Components/Mesh3DInstancesComponent.swift | 22 + .../AdaScene/3D/Model3DExtractionCache.swift | 175 +- Sources/AdaScene/3D/Model3DPlugin.swift | 157 +- Sources/AdaScene/3D/Model3DSource.swift | 32 +- .../AdaScene/3D/ModelAsset3D+Materials.swift | 76 +- .../3D/ModelAsset3D+SidecarBudget.swift | 78 + .../3D/ModelAsset3D+VoxelBudget.swift | 99 + Sources/AdaScene/3D/ModelAsset3D.swift | 144 +- .../MeshTerrain3DSourceModelResource.swift | 12 + .../3D/Terrain/MeshTerrainBinding.swift | 54 + .../Terrain/MeshTerrainBinding3DSystem.swift | 171 ++ .../Terrain/MeshTerrainBindingLifecycle.swift | 85 + .../3D/Terrain/MeshTerrainBindingUpdate.swift | 95 + .../3D/Terrain/MeshTerrainContactField.swift | 139 + .../3D/Terrain/MeshTerrainDrawMask.swift | 82 + .../3D/Terrain/MeshTerrainEdit+Runtime.swift | 134 + .../AdaScene/3D/Terrain/MeshTerrainEdit.swift | 75 + .../3D/Terrain/MeshTerrainEditTicket.swift | 28 + .../MeshTerrainMaterialModelAsset.swift | 23 + .../MeshTerrainMaterialPreparation.swift | 148 + .../3D/Terrain/MeshTerrainMeshletLOD.swift | 180 ++ .../MeshTerrainPreparedSourceDraws.swift | 179 ++ .../3D/Terrain/MeshTerrainSourceCapture.swift | 161 ++ .../Terrain/MeshTerrainSourceCollision.swift | 76 + .../MeshTerrainSourcePreparation.swift | 200 ++ .../AdaScene/3D/Terrain/Terrain3D+Tiles.swift | 103 + .../AdaScene/3D/Terrain/Terrain3DSource.swift | 106 + .../3D/Terrain/Terrain3DSourceSystem.swift | 850 ++++++ .../AdaScene/3D/Terrain/Terrain3DSystem.swift | 164 +- .../Terrain/TerrainAuthoringFootprint.swift | 71 + .../3D/Terrain/TerrainCameraLOD.swift | 58 + .../3D/Terrain/TerrainGenerated3D.swift | 68 + .../3D/Terrain/TerrainHeightBrush.swift | 125 + .../3D/Terrain/TerrainHeightBrushKernel.swift | 181 ++ .../3D/Terrain/TerrainHeightBrushPlan.swift | 42 + .../Terrain/TerrainHeightBrushSampler.swift | 177 ++ .../3D/Terrain/TerrainHeightEdit.swift | 244 ++ .../3D/Terrain/TerrainHeightLOD.swift | 135 + .../3D/Terrain/TerrainHeightStorage.swift | 989 +++++++ .../3D/Terrain/TerrainInstanceBrush.swift | 81 + .../Terrain/TerrainInstanceBrushKernel.swift | 181 ++ .../3D/Terrain/TerrainInstanceBrushPlan.swift | 121 + .../3D/Terrain/TerrainInstanceCollision.swift | 18 + .../TerrainInstanceCollisionPublication.swift | 66 + .../3D/Terrain/TerrainInstanceEdit.swift | 121 + .../TerrainInstanceMaterialFilter.swift | 70 + .../Terrain/TerrainInstanceSpatialIndex.swift | 50 + .../3D/Terrain/TerrainLODBalancer.swift | 83 + .../3D/Terrain/TerrainMaterialResolver.swift | 161 ++ .../3D/Terrain/TerrainMaterialUpload.swift | 36 + .../TerrainMaterialUploadMetrics.swift | 11 + .../TerrainMeshPrototype+Validation.swift | 94 + .../3D/Terrain/TerrainMeshPrototype.swift | 254 ++ .../3D/Terrain/TerrainPhysicsInterest.swift | 152 + .../3D/Terrain/TerrainPhysicsReadiness.swift | 98 + .../3D/Terrain/TerrainPlacementAnchor.swift | 123 + .../Terrain/TerrainPreparedMaterialMaps.swift | 137 + .../3D/Terrain/TerrainRegionCache.swift | 376 +++ .../TerrainSourceInstanceCollisions.swift | 156 ++ .../3D/Terrain/TerrainSourceInstances.swift | 170 ++ .../3D/Terrain/TerrainSourceJob.swift | 484 ++++ .../Terrain/TerrainSourceMaterialUpload.swift | 129 + .../3D/Terrain/TerrainSourceMaterials.swift | 137 + .../3D/Terrain/TerrainSourcePublication.swift | 260 ++ .../TerrainSourceQueryPreparation.swift | 61 + .../3D/Terrain/TerrainSourceResidency.swift | 263 ++ .../3D/Terrain/TerrainSourceSave.swift | 235 ++ .../3D/Terrain/TerrainSourceSaveJob.swift | 183 ++ .../3D/Terrain/TerrainSourceSaveQueue.swift | 117 + .../3D/Terrain/TerrainSourceSaveReceipt.swift | 32 + .../3D/Terrain/TerrainSourceVolumes.swift | 94 + .../3D/Terrain/TerrainSurfaceBrush.swift | 57 + .../Terrain/TerrainSurfaceBrushKernel.swift | 114 + .../3D/Terrain/TerrainSurfaceEdit.swift | 37 + .../3D/Terrain/TerrainSurfaceEditKernel.swift | 149 + .../3D/Terrain/TerrainSurfaceQueries.swift | 82 + .../Terrain/TerrainSurfaceStorageRows.swift | 186 ++ .../3D/Terrain/TerrainVolumeCache.swift | 79 + .../3D/Terrain/TerrainVolumeDependency.swift | 25 + .../3D/Terrain/TerrainVolumeField.swift | 310 +++ .../3D/Terrain/TerrainVolumeNormals.swift | 66 + .../Terrain/TerrainVolumePreparedModel.swift | 62 + .../3D/Terrain/TerrainVolumeResources.swift | 110 + .../Terrain/TerrainVolumeSeamGeometry.swift | 135 + .../Terrain/TerrainVolumeTileGeometry.swift | 52 + .../3D/Terrain/TerrainVolumeTilePlan.swift | 62 + .../AdaScripting/AdaScriptNativePlugin.swift | 5 +- .../AdaScriptSystemDependencyValidator.swift | 10 +- .../AnnotatedGravityScriptPlugin.swift | 38 +- Sources/AdaUI/DSL/Button.swift | 6 + Sources/AdaUI/DSL/DropDown.swift | 62 + Sources/CImageDecoder/CImageDecoder.c | 5 + Sources/CImageDecoder/include/CImageDecoder.h | 1 + Sources/CManifold/AdaMeshBoolean.cpp | 281 ++ Sources/CManifold/README.md | 19 + Sources/CManifold/THIRD_PARTY_NOTICES.txt | 120 + Sources/CManifold/Vendor/AUTHORS | 10 + Sources/CManifold/Vendor/LICENSE | 201 ++ Sources/CManifold/Vendor/UPSTREAM.json | 59 + .../Vendor/include/manifold/common.h | 747 +++++ .../Vendor/include/manifold/cross_section.h | 187 ++ .../Vendor/include/manifold/linalg.h | 2454 +++++++++++++++++ .../Vendor/include/manifold/manifold.h | 416 +++ .../CManifold/Vendor/include/manifold/math.h | 527 ++++ .../CManifold/Vendor/include/manifold/mesh.h | 276 ++ .../Vendor/include/manifold/optional_assert.h | 70 + .../Vendor/include/manifold/polygon.h | 61 + .../Vendor/include/manifold/vec_view.h | 149 + Sources/CManifold/Vendor/src/boolean3.cpp | 640 +++++ Sources/CManifold/Vendor/src/boolean3.h | 72 + .../CManifold/Vendor/src/boolean_result.cpp | 979 +++++++ Sources/CManifold/Vendor/src/collider.h | 378 +++ Sources/CManifold/Vendor/src/constructors.cpp | 518 ++++ Sources/CManifold/Vendor/src/csg_tree.cpp | 891 ++++++ Sources/CManifold/Vendor/src/csg_tree.h | 113 + Sources/CManifold/Vendor/src/disjoint_sets.h | 140 + Sources/CManifold/Vendor/src/edge_op.cpp | 947 +++++++ .../CManifold/Vendor/src/execution_impl.cpp | 141 + Sources/CManifold/Vendor/src/execution_impl.h | 160 ++ Sources/CManifold/Vendor/src/face_op.cpp | 461 ++++ Sources/CManifold/Vendor/src/hashtable.h | 197 ++ Sources/CManifold/Vendor/src/impl.cpp | 939 +++++++ Sources/CManifold/Vendor/src/impl.h | 727 +++++ Sources/CManifold/Vendor/src/iters.h | 337 +++ Sources/CManifold/Vendor/src/manifold.cpp | 1139 ++++++++ Sources/CManifold/Vendor/src/mesh_fixes.h | 69 + Sources/CManifold/Vendor/src/minkowski.cpp | 200 ++ Sources/CManifold/Vendor/src/parallel.h | 1221 ++++++++ Sources/CManifold/Vendor/src/polygon.cpp | 1010 +++++++ .../CManifold/Vendor/src/polygon_internal.h | 118 + Sources/CManifold/Vendor/src/properties.cpp | 463 ++++ Sources/CManifold/Vendor/src/quickhull.cpp | 865 ++++++ Sources/CManifold/Vendor/src/quickhull.h | 289 ++ Sources/CManifold/Vendor/src/sdf.cpp | 602 ++++ Sources/CManifold/Vendor/src/shared.h | 363 +++ Sources/CManifold/Vendor/src/smoothing.cpp | 1131 ++++++++ Sources/CManifold/Vendor/src/sort.cpp | 584 ++++ Sources/CManifold/Vendor/src/subdivision.cpp | 805 ++++++ Sources/CManifold/Vendor/src/svd.h | 304 ++ Sources/CManifold/Vendor/src/tree2d.cpp | 61 + Sources/CManifold/Vendor/src/tree2d.h | 85 + Sources/CManifold/Vendor/src/tri_dist.h | 226 ++ Sources/CManifold/Vendor/src/utils.h | 162 ++ Sources/CManifold/Vendor/src/vec.h | 366 +++ Sources/CManifold/include/AdaMeshBoolean.h | 40 + .../Geometry/ScalarBooleanOperation.swift | 13 + Sources/Math/Geometry/ScalarFieldGrid.swift | 61 + .../Math/Geometry/ScalarFieldSurface.swift | 214 ++ Sources/Math/Geometry/ScalarMeshSolid.swift | 98 + .../Geometry/ScalarMeshSolidGeometry.swift | 205 ++ .../Geometry/ScalarMeshSolidHierarchy.swift | 146 + .../Geometry/ScalarMeshSolidValidation.swift | 76 + Sources/Math/Geometry/ScalarSolid.swift | 156 ++ .../Geometry/ScalarSurfaceCollinearity.swift | 47 + Sources/Math/Geometry/ScalarSurfaceFans.swift | 145 + .../Geometry/ScalarSurfaceOrientation.swift | 56 + .../Math/Geometry/ScalarSurfaceTopology.swift | 51 + .../Geometry/ScalarTriangleGeometry.swift | 23 + .../Math/Geometry/TriangleIndexEdges.swift | 14 + .../Geometry/TriangleSurfaceGeometry.swift | 89 + .../Math/Geometry/TriangleSurfaceIndex.swift | 212 ++ .../MeshTerrainAssetTests.swift | 58 + .../MeshTerrainSessionTests.swift | 33 + .../TerrainAssetLimitTests.swift | 38 + .../TerrainAssetSaveTests.swift | 291 ++ .../TerrainAtomicPlatformTests.swift | 58 + .../TerrainManifestAssetTests.swift | 67 + .../TerrainMaterialManifestTests.swift | 122 + .../TerrainMeshPaletteTests.swift | 61 + .../TerrainPrototypeReadBudgetTests.swift | 145 + .../TerrainRegionAssetTests.swift | 70 + .../TerrainRegionInstanceTests.swift | 93 + .../TerrainSurfaceAssetTests.swift | 73 + .../TerrainSurfaceSliceTests.swift | 51 + .../TerrainVolumeBrickAssetTests.swift | 62 + .../TerrainVolumeBrickLoadingTests.swift | 74 + .../TerrainVolumeDocumentTests.swift | 74 + .../TerrainVolumeSaveIntegrityTests.swift | 99 + .../TerrainVolumeSaveReuseTests.swift | 51 + .../TerrainVolumeSaveTests.swift | 185 ++ Tests/AdaECSTests/SystemsGraphTests.swift | 70 + Tests/AdaEngineTests/GoldenFrameSupport.swift | 1 + .../webgpu/pbr-spheres-shadedWireframe.png | Bin 0 -> 230724 bytes .../webgpu/pbr-spheres-wireframe.png | Bin 61584 -> 212346 bytes .../MeshTerrainDrawMaskTests.swift | 77 + .../MeshTerrainFixedStepTests.swift | 57 + .../MeshTerrainImportedAppearanceTests.swift | 139 + .../MeshTerrainMaterialAdoptionTests.swift | 63 + .../MeshTerrainPaintTests.swift | 154 ++ .../MeshTerrainPopulationResidencyTests.swift | 56 + .../MeshTerrainRuntimeTests.swift | 644 +++++ .../MeshTerrainSourceEligibilityTests.swift | 42 + .../MeshTerrainSupersessionTests.swift | 84 + .../Model3DIncrementalExtractionTests.swift | 54 +- .../Render3DDebugViewTests.swift | 73 +- .../Render3DMeshletLODTests.swift | 65 + .../Render3DTerrainHeightBlendTests.swift | 34 + ...ender3DTerrainInstanceBenchmarkTests.swift | 205 ++ .../Render3DTerrainLiveBrushTests.swift | 75 + .../Render3DTerrainMaterialFixture.swift | 298 ++ .../Render3DTerrainMaterialTests.swift | 375 +++ .../Render3DTerrainSurfacePaintTests.swift | 35 + .../Render3DTerrainTextureTests.swift | 121 + .../Render3DTerrainTracingFallbackTests.swift | 33 + .../Render3DTerrainUploadBudgetTests.swift | 66 + .../TerrainAuthoringFootprintTests.swift | 115 + .../TerrainBrushFootprintTests.swift | 171 ++ .../TerrainHeightBrushRuntimeTests.swift | 182 ++ .../TerrainInstanceBrushTests.swift | 260 ++ ...rrainInstanceCollisionLifecycleTests.swift | 130 + ...errainInstanceCollisionRecoveryTests.swift | 81 + .../TerrainInstanceCollisionTests.swift | 61 + .../TerrainInstanceMaterialFilterTests.swift | 76 + .../TerrainInstanceMaterialMaskTests.swift | 83 + ...errainInstanceMaterialThresholdTests.swift | 33 + .../TerrainInstancingBudgetTests.swift | 131 + .../TerrainInstancingPaletteTests.swift | 126 + .../TerrainMeshInstancingTests.swift | 234 ++ .../TerrainMeshPrototypeTests.swift | 146 + .../TerrainNativeCombinedBudgetTests.swift | 66 + .../TerrainPlacementAdmissionTests.swift | 36 + .../TerrainPlacementAnchorEdgeTests.swift | 68 + .../TerrainPlacementAnchorTests.swift | 63 + .../TerrainPlacementHistoryTests.swift | 136 + .../TerrainPlacementStreamingEditTests.swift | 88 + .../TerrainPrototypeImageBudgetTests.swift | 142 + .../TerrainPrototypeImportTests.swift | 88 + .../TerrainPrototypeModelReadTests.swift | 57 + ...rrainPrototypeOtherFormatBudgetTests.swift | 183 ++ .../TerrainPrototypeSidecarBudgetTests.swift | 163 ++ .../TerrainPrototypeValidationTests.swift | 64 + .../AdaEngineTests/TerrainRuntimeTests.swift | 116 + .../TerrainSourceEditSchedulingTests.swift | 203 ++ .../TerrainSourceEditTests.swift | 334 +++ .../TerrainSourceInstanceLifecycleTests.swift | 144 + ...errainSourceInstancePersistenceTests.swift | 71 + .../TerrainSourceInstanceRuntimeTests.swift | 82 + .../TerrainSourceMaterialRuleTests.swift | 246 ++ .../TerrainSourcePersistenceTests.swift | 119 + .../TerrainSourcePhysicsTests.swift | 163 ++ .../TerrainSourceRuntimeTests.swift | 252 ++ .../TerrainSourceSaveLifecycleTests.swift | 350 +++ .../TerrainSourceSaveTests.swift | 133 + .../TerrainSourceSchedulingTests.swift | 211 ++ .../TerrainSourceStreamingTests.swift | 179 ++ .../TerrainSourceSurfaceMaterialTests.swift | 82 + ...TerrainSourceSurfacePersistenceTests.swift | 99 + .../TerrainSourceSurfaceSaveAckTests.swift | 49 + .../TerrainStandaloneSchedulingTests.swift | 26 + .../TerrainSurfaceBrushRuntimeTests.swift | 75 + .../TerrainSurfaceCoverageReuseTests.swift | 56 + .../TerrainSurfaceEditEdgeTests.swift | 78 + .../TerrainSurfaceEditRuntimeTests.swift | 75 + .../TerrainSurfaceGeometryReuseTests.swift | 63 + .../TerrainSurfaceQueryRuntimeTests.swift | 78 + .../TerrainSurfaceQueryStateTests.swift | 66 + .../TerrainThinCollisionTests.swift | 17 + .../TerrainVolumeLiveIntegrityTests.swift | 109 + .../TerrainVolumeModelBudgetTests.swift | 32 + .../TerrainVolumeModelResourceTests.swift | 47 + .../TerrainVolumeOwnershipSeamTests.swift | 70 + .../TerrainVolumePreparedModelTests.swift | 71 + .../TerrainVolumeSourceRuntimeTests.swift | 51 + .../MeshTerrainContentKeyTests.swift | 12 + .../MeshTerrainEvaluatorTests.swift | 147 + .../MeshTerrainPaintProjectionTests.swift | 42 + .../MeshTerrainPinnedPopulationTests.swift | 31 + .../MeshTerrainPlayerCapabilityTests.swift | 17 + .../MeshTerrainPopulationTests.swift | 106 + .../MeshTerrainSurfaceSculptTests.swift | 45 + .../TriangleBooleanTests.swift | 97 + .../TriangleIndexEdgesTests.swift | 13 + .../AdaRenderTests/Texture2DProxyTests.swift | 21 +- .../AdaRenderTests/WGSLReflectionTests.swift | 15 + .../AdaSceneTests/TerrainCameraLODTests.swift | 103 + .../TerrainDirectNativeBudgetTests.swift | 43 + .../TerrainHeightArchiveTests.swift | 56 + .../TerrainHeightResidencyTests.swift | 60 + .../TerrainHeightStorageTests.swift | 152 + .../TerrainInstanceCacheTests.swift | 55 + .../TerrainInstanceStorageTests.swift | 100 + .../TerrainLODBalancerTests.swift | 50 + Tests/AdaSceneTests/TerrainLODTests.swift | 101 + .../TerrainRegionCacheTests.swift | 110 + .../TerrainSurfaceAddressTests.swift | 57 + .../TerrainSurfaceBrushPlanningTests.swift | 100 + .../TerrainSurfaceBrushVisitBudgetTests.swift | 25 + .../TerrainSurfaceCacheTests.swift | 46 + .../TerrainSurfaceCoverageTests.swift | 30 + .../TerrainSurfacePaletteAdmissionTests.swift | 36 + .../TerrainSurfacePruningTests.swift | 30 + .../TerrainSurfaceQueryOrderingTests.swift | 20 + .../TerrainSurfaceStorageBudgetTests.swift | 34 + .../TerrainSurfaceStorageTests.swift | 70 + Tests/AdaSceneTests/TerrainTileTests.swift | 56 + .../TerrainVolumeBaseGeometryTests.swift | 53 + .../TerrainVolumeCacheTests.swift | 106 + .../TerrainVolumeFieldEdgeTests.swift | 75 + .../TerrainVolumeFieldTests.swift | 95 + .../TerrainVolumeNormalTests.swift | 31 + .../TerrainVolumeResourceTests.swift | 80 + .../TerrainVolumeSeamArithmeticTests.swift | 33 + .../AnnotatedGravityScriptPluginTests.swift | 20 +- .../MathTests/ScalarFieldDiagonalTests.swift | 53 + .../ScalarFieldExtractionEdgeTests.swift | 74 + Tests/MathTests/ScalarFieldLatticeTests.swift | 53 + .../ScalarFieldSmallFeatureTests.swift | 30 + Tests/MathTests/ScalarFieldSurfaceTests.swift | 77 + .../MathTests/ScalarFieldThinPlateTests.swift | 28 + .../MathTests/ScalarMeshExtractionTests.swift | 88 + Tests/MathTests/ScalarMeshSolidTests.swift | 86 + Tests/MathTests/ScalarSolidTests.swift | 88 + .../TriangleSurfaceAllocationTests.swift | 24 + .../TriangleSurfaceBoundaryTests.swift | 17 + .../MathTests/TriangleSurfaceQueryTests.swift | 73 + script/test_terrain_documentation.py | 28 + script/validate_terrain_native_io.py | 83 + script/webgpu_validation_package.py | 9 + 531 files changed, 72304 insertions(+), 1113 deletions(-) create mode 100644 Documentation/ArchitectureDecisions/0029-mesh-terrain-triangle-kernel.md create mode 100644 Documentation/Designs/terrain-authoring-v2.md create mode 100644 Documentation/Designs/terrain-mesh-composition.md create mode 100644 Documentation/Plans/2026-11-04-TerrainAuthoringV2.md create mode 100644 Documentation/Plans/2026-11-06-MeshTerrainComposition.md create mode 100644 Documentation/Research/2026-11-06-UnrealMeshTerrain.md create mode 100644 Editor/Samples/MeshTerrainWorkshop/.ada/project.json create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Models/root-tree.glb create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Scenes/Main.ascn create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.aterrain create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.atmesh create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/grass-sprig.vox create mode 100644 Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/loose-stone.vox create mode 100644 Editor/Samples/MeshTerrainWorkshop/README.md create mode 100644 Editor/Samples/MeshTerrainWorkshop/Sources/Main.ada create mode 100644 Editor/Sources/AdaEditor/Assets/Legal/Manifold/AUTHORS create mode 100644 Editor/Sources/AdaEditor/Assets/Legal/Manifold/LICENSE create mode 100644 Editor/Sources/AdaEditor/Assets/Legal/Manifold/README.md create mode 100644 Editor/Sources/AdaEditor/Assets/Legal/Manifold/THIRD_PARTY_NOTICES.txt create mode 100644 Editor/Sources/AdaEditor/Assets/Legal/Manifold/UPSTREAM.json create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainCreation.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainDragSession.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainHistory.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainHistoryQueue.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainPoseQueue.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainSculptStroke.swift create mode 100644 Editor/Sources/AdaEditor/EditorMeshTerrainTransaction.swift create mode 100644 Editor/Sources/AdaEditor/EditorTerrainResourceWriter.swift create mode 100644 Editor/Sources/AdaEditor/EditorTerrainSourceAuthoring.swift create mode 100644 Editor/Sources/AdaEditor/EditorTerrainSourceDescriptor.swift create mode 100644 Editor/Sources/AdaEditor/EditorTerrainTool.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorMeshTerrainJoiningPanel.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrain.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainControls.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainDrag.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainGroupDrag.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainHistory.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainPalette.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainSculpt.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportSourceTerrain.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorTerrainDockPanel.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainBackendTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainControlTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainDragTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainFastBrushTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainHistoryTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainImportTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainPaintTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainPaletteTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainPopulationBrushTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMeshTerrainSculptTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainPickingTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainSourceAuthoringTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainSourceOptionalTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainSourcePickingTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainSourceTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainSourceViewportTests.swift create mode 100644 Sources/AdaAssets/AssetsManager+BoundedRead.swift create mode 100644 Sources/AdaAssets/GLTF/GLTFImportResult+Validation.swift create mode 100644 Sources/AdaAssets/GLTF/NativeGLTFLoader+Budget.swift create mode 100644 Sources/AdaAssets/ModelImportLimits.swift create mode 100644 Sources/AdaAssets/OBJ/NativeOBJLoader+Budget.swift create mode 100644 Sources/AdaAssets/Terrain/AssetsManager+Terrain.swift create mode 100644 Sources/AdaAssets/Terrain/MeshTerrainAuthoringSession.swift create mode 100644 Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset+Compilation.swift create mode 100644 Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainAssetWriter.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainLayerReference.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainManifestAsset.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainMaterialPalette.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainMaterialRule.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainMeshPrototypeReference.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionAsset+Instances.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionAsset+Surface.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionAsset.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionByteBudget.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionInstance.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainRegionSurfaceSample.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainSurfacePaint.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainTextureVariation.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainVolumeAssetWriter.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainVolumeBrickAsset.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainVolumeDocumentAsset.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainVolumeOperation.swift create mode 100644 Sources/AdaAssets/Terrain/TerrainVolumeSave.swift create mode 100644 Sources/AdaAssets/Voxel/VOXLoader+Budget.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/Terrain3DUniforms.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/Wireframe3DResources.swift create mode 100644 Sources/AdaECS/Component/TransientEntity.swift create mode 100644 Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md create mode 100644 Sources/AdaGeometry/MeshTerrainContentKey.swift create mode 100644 Sources/AdaGeometry/MeshTerrainCoverage.swift create mode 100644 Sources/AdaGeometry/MeshTerrainDecoding.swift create mode 100644 Sources/AdaGeometry/MeshTerrainDocument.swift create mode 100644 Sources/AdaGeometry/MeshTerrainEvaluator.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPaint.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPaintBaker.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPaintTopology.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPopulation.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPopulationCells.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPopulationCoverage.swift create mode 100644 Sources/AdaGeometry/MeshTerrainPopulationSampling.swift create mode 100644 Sources/AdaGeometry/MeshTerrainSourceAppearance.swift create mode 100644 Sources/AdaGeometry/MeshTerrainSurfaceAnchor.swift create mode 100644 Sources/AdaGeometry/MeshTerrainSurfaceSculpt.swift create mode 100644 Sources/AdaGeometry/TriangleContactRelaxation.swift create mode 100644 Sources/AdaGeometry/TriangleGeometryAdmission.swift create mode 100644 Sources/AdaGeometry/TrianglePatchClosure.swift create mode 100644 Sources/AdaGeometry/TriangleSheetSculpt.swift create mode 100644 Sources/AdaGeometry/TriangleSurface+Decoding.swift create mode 100644 Sources/AdaGeometry/TriangleSurface.swift create mode 100644 Sources/AdaGeometry/TriangleSurfaceEditing.swift create mode 100644 Sources/AdaGeometry/TriangleSurfaceNormals.swift create mode 100644 Sources/AdaGeometry/TriangleSurfacePrimitives.swift create mode 100644 Sources/AdaPhysics/Physics3D/PhysicsCollisionPreparationMetrics.swift create mode 100644 Sources/AdaPhysics/Physics3D/PhysicsSimulationGate3D.swift create mode 100644 Sources/AdaPhysics/Physics3D/Shape3DResource+Bounds.swift create mode 100644 Sources/AdaRender/Materials/TerrainMaterialBindings.swift create mode 100644 Sources/AdaRender/Materials/TerrainMaterialTextureArrays.swift create mode 100644 Sources/AdaRender/Misc/Mesh3DInstance.swift create mode 100644 Sources/AdaRender/Texture/Texture2D+Subresources.swift create mode 100644 Sources/AdaScene/3D/Components/Mesh3DInstancesComponent.swift create mode 100644 Sources/AdaScene/3D/ModelAsset3D+SidecarBudget.swift create mode 100644 Sources/AdaScene/3D/ModelAsset3D+VoxelBudget.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrain3DSourceModelResource.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainBinding.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainBinding3DSystem.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainBindingLifecycle.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainBindingUpdate.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainContactField.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainDrawMask.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainEdit+Runtime.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainEdit.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainEditTicket.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainMaterialModelAsset.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainMaterialPreparation.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainMeshletLOD.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainPreparedSourceDraws.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainSourceCapture.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainSourceCollision.swift create mode 100644 Sources/AdaScene/3D/Terrain/MeshTerrainSourcePreparation.swift create mode 100644 Sources/AdaScene/3D/Terrain/Terrain3D+Tiles.swift create mode 100644 Sources/AdaScene/3D/Terrain/Terrain3DSource.swift create mode 100644 Sources/AdaScene/3D/Terrain/Terrain3DSourceSystem.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainAuthoringFootprint.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainCameraLOD.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainGenerated3D.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightBrush.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightBrushKernel.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightBrushPlan.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightBrushSampler.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightEdit.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightLOD.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainHeightStorage.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceBrush.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceBrushKernel.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceBrushPlan.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceCollision.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceCollisionPublication.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceEdit.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceMaterialFilter.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainInstanceSpatialIndex.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainLODBalancer.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainMaterialResolver.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainMaterialUpload.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainMaterialUploadMetrics.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainMeshPrototype+Validation.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainMeshPrototype.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainPhysicsInterest.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainPhysicsReadiness.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainPlacementAnchor.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainPreparedMaterialMaps.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainRegionCache.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceInstanceCollisions.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceInstances.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceJob.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceMaterialUpload.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceMaterials.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourcePublication.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceQueryPreparation.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceResidency.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceSave.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceSaveJob.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceSaveQueue.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceSaveReceipt.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSourceVolumes.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceBrush.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceBrushKernel.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceEdit.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceEditKernel.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceQueries.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainSurfaceStorageRows.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeCache.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeDependency.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeField.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeNormals.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumePreparedModel.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeResources.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeSeamGeometry.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeTileGeometry.swift create mode 100644 Sources/AdaScene/3D/Terrain/TerrainVolumeTilePlan.swift create mode 100644 Sources/AdaUI/DSL/DropDown.swift create mode 100644 Sources/CManifold/AdaMeshBoolean.cpp create mode 100644 Sources/CManifold/README.md create mode 100644 Sources/CManifold/THIRD_PARTY_NOTICES.txt create mode 100644 Sources/CManifold/Vendor/AUTHORS create mode 100644 Sources/CManifold/Vendor/LICENSE create mode 100644 Sources/CManifold/Vendor/UPSTREAM.json create mode 100644 Sources/CManifold/Vendor/include/manifold/common.h create mode 100644 Sources/CManifold/Vendor/include/manifold/cross_section.h create mode 100644 Sources/CManifold/Vendor/include/manifold/linalg.h create mode 100644 Sources/CManifold/Vendor/include/manifold/manifold.h create mode 100644 Sources/CManifold/Vendor/include/manifold/math.h create mode 100644 Sources/CManifold/Vendor/include/manifold/mesh.h create mode 100644 Sources/CManifold/Vendor/include/manifold/optional_assert.h create mode 100644 Sources/CManifold/Vendor/include/manifold/polygon.h create mode 100644 Sources/CManifold/Vendor/include/manifold/vec_view.h create mode 100644 Sources/CManifold/Vendor/src/boolean3.cpp create mode 100644 Sources/CManifold/Vendor/src/boolean3.h create mode 100644 Sources/CManifold/Vendor/src/boolean_result.cpp create mode 100644 Sources/CManifold/Vendor/src/collider.h create mode 100644 Sources/CManifold/Vendor/src/constructors.cpp create mode 100644 Sources/CManifold/Vendor/src/csg_tree.cpp create mode 100644 Sources/CManifold/Vendor/src/csg_tree.h create mode 100644 Sources/CManifold/Vendor/src/disjoint_sets.h create mode 100644 Sources/CManifold/Vendor/src/edge_op.cpp create mode 100644 Sources/CManifold/Vendor/src/execution_impl.cpp create mode 100644 Sources/CManifold/Vendor/src/execution_impl.h create mode 100644 Sources/CManifold/Vendor/src/face_op.cpp create mode 100644 Sources/CManifold/Vendor/src/hashtable.h create mode 100644 Sources/CManifold/Vendor/src/impl.cpp create mode 100644 Sources/CManifold/Vendor/src/impl.h create mode 100644 Sources/CManifold/Vendor/src/iters.h create mode 100644 Sources/CManifold/Vendor/src/manifold.cpp create mode 100644 Sources/CManifold/Vendor/src/mesh_fixes.h create mode 100644 Sources/CManifold/Vendor/src/minkowski.cpp create mode 100644 Sources/CManifold/Vendor/src/parallel.h create mode 100644 Sources/CManifold/Vendor/src/polygon.cpp create mode 100644 Sources/CManifold/Vendor/src/polygon_internal.h create mode 100644 Sources/CManifold/Vendor/src/properties.cpp create mode 100644 Sources/CManifold/Vendor/src/quickhull.cpp create mode 100644 Sources/CManifold/Vendor/src/quickhull.h create mode 100644 Sources/CManifold/Vendor/src/sdf.cpp create mode 100644 Sources/CManifold/Vendor/src/shared.h create mode 100644 Sources/CManifold/Vendor/src/smoothing.cpp create mode 100644 Sources/CManifold/Vendor/src/sort.cpp create mode 100644 Sources/CManifold/Vendor/src/subdivision.cpp create mode 100644 Sources/CManifold/Vendor/src/svd.h create mode 100644 Sources/CManifold/Vendor/src/tree2d.cpp create mode 100644 Sources/CManifold/Vendor/src/tree2d.h create mode 100644 Sources/CManifold/Vendor/src/tri_dist.h create mode 100644 Sources/CManifold/Vendor/src/utils.h create mode 100644 Sources/CManifold/Vendor/src/vec.h create mode 100644 Sources/CManifold/include/AdaMeshBoolean.h create mode 100644 Sources/Math/Geometry/ScalarBooleanOperation.swift create mode 100644 Sources/Math/Geometry/ScalarFieldGrid.swift create mode 100644 Sources/Math/Geometry/ScalarFieldSurface.swift create mode 100644 Sources/Math/Geometry/ScalarMeshSolid.swift create mode 100644 Sources/Math/Geometry/ScalarMeshSolidGeometry.swift create mode 100644 Sources/Math/Geometry/ScalarMeshSolidHierarchy.swift create mode 100644 Sources/Math/Geometry/ScalarMeshSolidValidation.swift create mode 100644 Sources/Math/Geometry/ScalarSolid.swift create mode 100644 Sources/Math/Geometry/ScalarSurfaceCollinearity.swift create mode 100644 Sources/Math/Geometry/ScalarSurfaceFans.swift create mode 100644 Sources/Math/Geometry/ScalarSurfaceOrientation.swift create mode 100644 Sources/Math/Geometry/ScalarSurfaceTopology.swift create mode 100644 Sources/Math/Geometry/ScalarTriangleGeometry.swift create mode 100644 Sources/Math/Geometry/TriangleIndexEdges.swift create mode 100644 Sources/Math/Geometry/TriangleSurfaceGeometry.swift create mode 100644 Sources/Math/Geometry/TriangleSurfaceIndex.swift create mode 100644 Tests/AdaAssetsTests/MeshTerrainAssetTests.swift create mode 100644 Tests/AdaAssetsTests/MeshTerrainSessionTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainAssetLimitTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainAssetSaveTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainAtomicPlatformTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainManifestAssetTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainMaterialManifestTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainMeshPaletteTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainPrototypeReadBudgetTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainRegionAssetTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainRegionInstanceTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainSurfaceAssetTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainSurfaceSliceTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeBrickAssetTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeBrickLoadingTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeDocumentTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeSaveIntegrityTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeSaveReuseTests.swift create mode 100644 Tests/AdaAssetsTests/TerrainVolumeSaveTests.swift create mode 100644 Tests/AdaEngineTests/GoldenFrames/webgpu/pbr-spheres-shadedWireframe.png create mode 100644 Tests/AdaEngineTests/MeshTerrainDrawMaskTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainFixedStepTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainImportedAppearanceTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainMaterialAdoptionTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainPaintTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainPopulationResidencyTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainSourceEligibilityTests.swift create mode 100644 Tests/AdaEngineTests/MeshTerrainSupersessionTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainHeightBlendTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainInstanceBenchmarkTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainLiveBrushTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainMaterialFixture.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainMaterialTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainSurfacePaintTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainTextureTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainTracingFallbackTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainUploadBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainAuthoringFootprintTests.swift create mode 100644 Tests/AdaEngineTests/TerrainBrushFootprintTests.swift create mode 100644 Tests/AdaEngineTests/TerrainHeightBrushRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceBrushTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceCollisionLifecycleTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceCollisionRecoveryTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceCollisionTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceMaterialFilterTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceMaterialMaskTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstanceMaterialThresholdTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstancingBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainInstancingPaletteTests.swift create mode 100644 Tests/AdaEngineTests/TerrainMeshInstancingTests.swift create mode 100644 Tests/AdaEngineTests/TerrainMeshPrototypeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainNativeCombinedBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPlacementAdmissionTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPlacementAnchorEdgeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPlacementAnchorTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPlacementHistoryTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPlacementStreamingEditTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeImageBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeImportTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeModelReadTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeOtherFormatBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeSidecarBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainPrototypeValidationTests.swift create mode 100644 Tests/AdaEngineTests/TerrainRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceEditSchedulingTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceEditTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceInstanceLifecycleTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceInstancePersistenceTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceInstanceRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceMaterialRuleTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourcePersistenceTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourcePhysicsTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSaveLifecycleTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSaveTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSchedulingTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceStreamingTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSurfaceMaterialTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSurfacePersistenceTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSourceSurfaceSaveAckTests.swift create mode 100644 Tests/AdaEngineTests/TerrainStandaloneSchedulingTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceBrushRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceCoverageReuseTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceEditEdgeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceEditRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceGeometryReuseTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceQueryRuntimeTests.swift create mode 100644 Tests/AdaEngineTests/TerrainSurfaceQueryStateTests.swift create mode 100644 Tests/AdaEngineTests/TerrainThinCollisionTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumeLiveIntegrityTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumeModelBudgetTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumeModelResourceTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumeOwnershipSeamTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumePreparedModelTests.swift create mode 100644 Tests/AdaEngineTests/TerrainVolumeSourceRuntimeTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainContentKeyTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainEvaluatorTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainPaintProjectionTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainPinnedPopulationTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainPlayerCapabilityTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainPopulationTests.swift create mode 100644 Tests/AdaGeometryTests/MeshTerrainSurfaceSculptTests.swift create mode 100644 Tests/AdaGeometryTests/TriangleBooleanTests.swift create mode 100644 Tests/AdaGeometryTests/TriangleIndexEdgesTests.swift create mode 100644 Tests/AdaSceneTests/TerrainCameraLODTests.swift create mode 100644 Tests/AdaSceneTests/TerrainDirectNativeBudgetTests.swift create mode 100644 Tests/AdaSceneTests/TerrainHeightArchiveTests.swift create mode 100644 Tests/AdaSceneTests/TerrainHeightResidencyTests.swift create mode 100644 Tests/AdaSceneTests/TerrainHeightStorageTests.swift create mode 100644 Tests/AdaSceneTests/TerrainInstanceCacheTests.swift create mode 100644 Tests/AdaSceneTests/TerrainInstanceStorageTests.swift create mode 100644 Tests/AdaSceneTests/TerrainLODBalancerTests.swift create mode 100644 Tests/AdaSceneTests/TerrainLODTests.swift create mode 100644 Tests/AdaSceneTests/TerrainRegionCacheTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceAddressTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceBrushPlanningTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceBrushVisitBudgetTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceCacheTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceCoverageTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfacePaletteAdmissionTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfacePruningTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceQueryOrderingTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceStorageBudgetTests.swift create mode 100644 Tests/AdaSceneTests/TerrainSurfaceStorageTests.swift create mode 100644 Tests/AdaSceneTests/TerrainTileTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeBaseGeometryTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeCacheTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeFieldEdgeTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeFieldTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeNormalTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeResourceTests.swift create mode 100644 Tests/AdaSceneTests/TerrainVolumeSeamArithmeticTests.swift create mode 100644 Tests/MathTests/ScalarFieldDiagonalTests.swift create mode 100644 Tests/MathTests/ScalarFieldExtractionEdgeTests.swift create mode 100644 Tests/MathTests/ScalarFieldLatticeTests.swift create mode 100644 Tests/MathTests/ScalarFieldSmallFeatureTests.swift create mode 100644 Tests/MathTests/ScalarFieldSurfaceTests.swift create mode 100644 Tests/MathTests/ScalarFieldThinPlateTests.swift create mode 100644 Tests/MathTests/ScalarMeshExtractionTests.swift create mode 100644 Tests/MathTests/ScalarMeshSolidTests.swift create mode 100644 Tests/MathTests/ScalarSolidTests.swift create mode 100644 Tests/MathTests/TriangleSurfaceAllocationTests.swift create mode 100644 Tests/MathTests/TriangleSurfaceBoundaryTests.swift create mode 100644 Tests/MathTests/TriangleSurfaceQueryTests.swift create mode 100644 script/test_terrain_documentation.py create mode 100644 script/validate_terrain_native_io.py diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index 2549217ee..7fbb4563e 100644 --- a/.github/workflows/swift-macos.yml +++ b/.github/workflows/swift-macos.yml @@ -29,3 +29,8 @@ jobs: - name: Test run: swift test --parallel + + - name: Test Mesh Terrain authoring kernel + env: + ADAENGINE_HEADLESS: "1" + run: swift test --traits MeshTerrainAuthoring --parallel --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges" diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index d539eea16..aabb5835e 100644 --- a/.github/workflows/swift-windows.yml +++ b/.github/workflows/swift-windows.yml @@ -44,3 +44,10 @@ jobs: swift build -j 2 --product TextureAtlasBuilderTool if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } swift test --skip-build --parallel --num-workers 2 + + - name: Test Mesh Terrain authoring kernel + # Authoring is optional for compiled players; explicitly exercise it in CI. + run: | + swift build -j 2 --traits MeshTerrainAuthoring --product AdaEnginePackageTests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + swift test --traits MeshTerrainAuthoring --skip-build --parallel --num-workers 2 --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges" diff --git a/Documentation/ArchitectureDecisions/0029-mesh-terrain-triangle-kernel.md b/Documentation/ArchitectureDecisions/0029-mesh-terrain-triangle-kernel.md new file mode 100644 index 000000000..0f207989b --- /dev/null +++ b/Documentation/ArchitectureDecisions/0029-mesh-terrain-triangle-kernel.md @@ -0,0 +1,51 @@ +# ADR-0029: A Bounded Triangle Kernel for Mesh Terrain + +Date: 2026-11-06. Status: selected for the implementation feasibility path. +Scope: [approved terrain composition design](../Designs/terrain-mesh-composition.md). +Full integrated feature acceptance remains pending. + +## Decision + +Use unmodified Manifold 3.5.1 (`cc8a7f66d7d5a560da94346258c5b546af27811e`) +behind a narrow C ABI for the supported closed-solid triangle Boolean subproblem. +Keep editable triangle data, regional evaluation, source identity and cancellation +in Ada-owned interfaces. Do not introduce Swift C++ interoperability into clients. +Pin sources and hashes with upstream/embedded third-party license notices. + +An explicit single-valued open ground patch can be closed below its surface using +temporary bottom/side faces with reserved provenance. Clip operand contributions +to section domains when needed, evaluate the closed problem, then remove every +temporary closure face from render/query/collision output. This does not claim +general inside semantics for arbitrary open cave sheets; unsupported input is rejected. + +Existing sampled-density terrain remains compatible and independently named. +Do not silently voxelize an unsupported triangle operation. Compiled players can +consume products independently of the native authoring kernel. + +## Evidence and current limits + +Focused native Windows Swift Testing exercises union, a subtracted tunnel, +explicit ground closure/cap removal, UV/source-face transfer, reflected nonuniform +placement, invalid input, budget rejection, empty-then-union evaluation, section +boundary composition and retained modifier movement. Commands and original logs +are retained by task `mesh-terrain-composition`; these are kernel/graph evidence, +not completed UI, platform, performance or full feature acceptance. + +Input/decode/output sizes and broad-phase candidate/visit work have explicit limits. +Native cancellation uses one execution context per evaluation. Exceptions never +cross the C boundary. The opaque cancellation context remains alive through the +call; only upstream's synchronized Cancel operation is used concurrently. + +Memory estimates for native operation scratch require adversarial profiling; +an input/output budget alone is not proof of exact peak native allocation. +Additional concave/contact/attribute and target-platform fixtures remain required +by the design before the complete feature can be accepted. + +## Alternatives + +A pure Swift robust triangle kernel would avoid C++ packaging but add significant +intersection/attribute-repair work. CGAL provides broader surface-processing +options with different package/license costs. A density-only extension reuses +current code but does not satisfy the triangle-preserving join contract. None of +these is substituted implicitly if this selected route encounters an unsupported +case; substantive limitations return to the approved design boundary. diff --git a/Documentation/Designs/terrain-authoring-v2.md b/Documentation/Designs/terrain-authoring-v2.md new file mode 100644 index 000000000..0a93c9f4f --- /dev/null +++ b/Documentation/Designs/terrain-authoring-v2.md @@ -0,0 +1,337 @@ +# Terrain Authoring V2 + +Status: **Accepted**. +Approval: user approval in the project conversation on 2026-11-04 after reviewing +the document and removing external-reference discussion. +Date: 2026-11-04. +Classification: feature spanning terrain runtime, materials, asset storage and Studio authoring. +Local implementation validation task: `terrain-authoring-v2-implementation`. +Implementation plan: `Documentation/Plans/2026-11-04-TerrainAuthoringV2.md`. + +## Purpose and requirements + +Build a terrain authoring system in which users can shape large landscapes, paint +natural surfaces, scatter meshes, and form real caves and overhangs using mesh union +and subtraction. Tool organization includes brush modes near the viewport, brush +settings, a texture/mesh asset palette, and additional terrain settings in the Inspector. + +The request includes all six capabilities below. Delivering only heightfield stamps +does not satisfy mesh Boolean operations. The user explicitly confirmed in this +chat on 2026-11-04 that caves and overhangs are required. + +| Requirement | Observable result | +| --- | --- | +| Mesh instancing | Paint and erase many copies of grass, rocks or trees using shared assets and spatial batches. | +| Texture flips and detiling | Repeated surfaces vary through deterministic mirroring, rotation and offsets, without a visible chunk grid or incorrect normal lighting. | +| Chunks | Extend terrain through sparse regions; editing and loading operate on bounded subsets, with continuous borders and distance LOD. | +| Automatic textures | Height, slope, curvature, noise and painted masks select and blend materials; manual painting remains available. | +| Many brushes | Multiple real operations, procedural/imported shapes and reusable presets; not merely different names for Raise. | +| Mesh union/subtraction | Drop a supported mesh, preview Add/Subtract, and commit solid terrain changes with matching collision, including caves and overhangs. | + +## Verified baseline + +AdaEngine currently has a single square `Terrain3D` heightfield with 2–257 samples +per axis, a solid-color PBR material, and one generated mesh/static triangle collider. +Any terrain or transform change rebuilds the whole mesh and collider. +Studio has Raise, Lower, Smooth and Flatten, circular falloff, radius/strength, +triangle picking, and one history entry per stroke. Existing scenes retain their +height samples through Codable scene loading and native export. + +Sources inspected: + +- `Sources/AdaScene/3D/Terrain/Terrain3D.swift` and `Terrain3DSystem.swift`. +- `Editor/Sources/AdaEditor/EditorTerrainBrush.swift` and + `UI/Editor/EditorSceneViewportTerrain.swift`. +- `Editor/Documentation/Terrain.md`, `EditorTerrainTests` and + `EditorTerrainInspectorTests`. +- `Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift`: + existing renderer batching requires shared mesh buffers and material identity. +- `Sources/AdaScene/3D/Voxel/VoxelWorldStorage.swift` and `VoxelWorld3D.swift`: + sparse 32-cubed occupancy chunks, dirty halos and budgeted remeshing already exist. + This block-voxel system is not a smooth density field or a mesh Boolean implementation. + +ADR-0027's historical “no terrain” statement predates the current basic terrain. +Its tiled-LOD/splat-material direction remains relevant. ADR-0025 supplies occupancy +chunking precedent. Do not rewrite their historical decisions. Record the new hybrid +terrain decision in a new ADR after approval and cross-link these precedents. + +## Ownership and public compatibility + +- **AdaScene:** terrain component/data access, region revisions, surface queries, + instance placement and terrain runtime systems. Keep authored state independent + of a renderer or an open Studio document. +- **AdaRender / AdaCorePipelines:** terrain material descriptors, texture-array + bindings, shared PBR lighting integration, detiling and terrain render extraction. + General-purpose scalar-field surface extraction belongs in the renderer-neutral + geometry area; do not introduce a dependency from AdaRender back to AdaScene. +- **AdaAssets:** versioned terrain asset loading and ordinary model/texture resources. +- **AdaPhysics integration:** static triangle collision generated from the authoritative + terrain surface, including interiors; decorative instance collision is explicit. +- **Editor:** palette, localized controls, strokes, drag/drop previews, transactions + and project resource handling. Use AdaUI and existing scene history. + +Retain existing `Terrain3D` scene decoding, default appearance and public heightfield +operations. Existing scenes open without conversion prompts or missing-resource errors. +New data uses an optional versioned terrain-asset reference. Editing a legacy patch +in the new system creates its asset through one undoable conversion; the unchanged +legacy format remains readable. Do not silently discard the old payload. +`surfaceHeight(at:)` remains a legacy/top-surface convenience; add a ray query returning +the nearest hit, normal and material for surfaces with multiple heights at one X/Z. +Do not infer cave floors from a single height sample. + +## Sparse data, rendering and streaming + +Use three distinct units: a **region** for persistence/residency, a **surface tile** +for mesh/LOD/collision invalidation, and a **volume brick** for edited 3D density. +Proposed defaults are 256-by-256 base cells per region, 64-by-64 cells per surface +tile and 32-cubed cells per volume brick, with 1-unit base spacing. Region and brick +coordinates are signed, use floor division for negative positions, and are independent +of camera placement. Spacing and volume precision are explicit terrain settings. + +Canonical shared samples have one logical owner; halos are derived. An edit across +a tile/region/brick corner invalidates all dependent geometry, normals, rule caches +and colliders. Unallocated regions are empty. Creating terrain there is an explicit +undoable operation. An untouched distant region must not rebuild during a local stroke. + +Terrain assets contain a versioned manifest plus independently addressable region +payloads, layer/mesh asset references, painted masks, placed-instance records and +volume edits. Persist through the project asset system with staged writes and a +manifest commit so a failed save cannot reference partially written region data. +Collect these resources in native exports. Validate sizes, versions, finite samples, +indices, paths and decoded memory budgets before allocation. Reject unsupported +versions with an actionable error, rather than loading corrupted terrain as empty. + +Residency uses camera distance, an explicit byte budget and hysteresis. Authoring +pins dirty, selected and in-flight transaction regions. Collision residency includes +active physics bodies, rather than relying only on a possibly distant editor camera. +Cancel obsolete loads and remesh jobs. Use immutable Sendable snapshots and revisions; +only publish results if their source revision is still current. GPU resource creation +and world mutation remain on their established isolation boundaries. + +Use tiled heightfield LOD with neighbor differences limited to one level and explicit +edge stitching/morphing. Collision and editing queries use the authoritative detail, +not a distant visual mesh. Volumetric tiles and their seam neighbors initially pin +to their canonical surface resolution; distance culling and streaming still apply. +Adaptive volumetric LOD is outside this version. No per-frame full-array equality scan, +full-world copy or mesh/material recreation is allowed for unchanged terrain. + +## Materials and automatic painting + +Provide up to 32 named terrain layers, addressed through stable asset IDs. A layer +contains albedo, tangent normal, roughness/metallic/AO data, optional blend height, +tint, physical scale, projection and detiling settings. Use a dedicated bounded +terrain texture-array layout instead of adding unbounded samplers to the existing +PBR material. Normalize/import texture dimensions and formats at asset preparation; +report incompatible or missing maps with a usable fallback. Validate the actual +Metal and WebGPU binding limits before finalizing GPU layouts. + +Resolve a bounded set of at most four active material contributions per shading +location; deterministic pruning and interpolation must preserve continuity at tile +borders. Layer count must not multiply the base shader cost by 32. Share the same +PBR lighting, shadows and color-space conventions as other 3D meshes. + +Randomization uses terrain coordinates, layer ID and a saved seed, never camera or +chunk IDs. Optional U/V flips, quarter-turn rotations and offsets apply consistently +to all maps. Transform normal orientation and gradients correctly, including reflection +handedness. Blend neighboring randomized samples to avoid replacing a repetition grid +with hard UV seams. Add low-frequency macro variation. Provide planar projection +for ground and triplanar projection for cliffs, cave walls and ceilings. + +Rules have enabled state, target layer, weight, height/slope ranges, soft transitions, +optional curvature and seeded noise, and an optional painted mask. Height and slope +are measured in terrain-local units relative to its origin/up axis; this makes moving +or rotating the terrain preserve authored biomes. Explain these coordinates in the UI +and guide. Evaluate curvature from derived surface data with neighbor halos. A moisture +mask is artist-authored data, not a claim of hydrology simulation. + +Combine enabled rule weights, prune deterministically and normalize. If all weights +are zero, use the configured fallback layer. Manual paint uses an explicit override +mask; erasing the override reveals automatic rules. Sculpting invalidates derived +rules where surface shape changed without overwriting painted data. Volume interiors +use the same rule/material system with a configurable interior fallback. + +## Mesh instancing + +Register reusable static model assets in the palette. Paint/erase instances with +density, spacing, scale range, yaw, normal alignment, height offset, seed and +height/slope/material filters. Stable instance IDs and saved placement transforms +survive streaming, reload and Undo/Redo. Do not randomly regenerate authored placements +on each frame. After sculpting, surface-anchored placements reproject locally; instances +whose anchor surface disappeared are removed in the same undoable edit. + +Partition placement records into spatial cells and share imported mesh buffers, +materials and textures across all copies. Preserve multi-primitive model structure +and imported child transforms; do not flatten every source model into its first mesh. +Integrate existing frustum/distance culling, mesh LOD and compatible render-pass batching. +Do not promise a single draw for different materials or depth-sorted transparent meshes. +Avoid creating one authored scene entity per blade of grass. Runtime extraction must +consume placement buffers or cell groups without retaining one full ECS entity per instance. + +Decorative instances default to no collision. An opt-in nearby static collision mode +uses source colliders for suitable props and has explicit count/distance budgets; +dynamic gameplay objects use ordinary scene entities. Document this distinction. + +## Brushes and Studio interaction + +Terrain mode exposes tool categories near the viewport, a collapsible texture/mesh/ +brush palette, quick brush settings and advanced Inspector groups. Keep the viewport +usable when the palette is collapsed and at the existing narrow Inspector width. +Preserve camera navigation, selection, Play isolation and accessibility identifiers. +Every label, tooltip, validation error and preset name ships in English and Russian. + +Ship these actual operation families: + +- Surface shaping: Raise, Lower, Smooth, Flatten, Set Height, Ramp, Terrace, Noise, + and bounded local Talus Relaxation with explicit iteration limits. +- Surface painting: layer paint/erase, automatic-rule mask, tint and roughness. +- Instances: scatter and erase, with the placement controls above. +- Region tools: add/remove regions, with a destructive-result preview before committing removal. +- Volume tools: Add/Subtract solid brush, smooth density and imported-mesh Add/Subtract. + +Shapes are separate from operations: soft/hard round, square, line/ridge, crater, +ridged noise, stamps and imported grayscale alpha masks. Ship at least 24 useful +named presets spanning shaping, painting, scattering and volumes. Presets reference +real parameters and have thumbnails; users can save their own and import additional +alpha masks. Bound image dimensions, decoded bytes and iteration/sample budgets. +Procedural masks and bundled original assets avoid depending on unlicensed samples. + +Controls include radius, strength, falloff, spacing, rotation/jitter, seed and applicable +height/slope masks; unsupported controls disappear for the active operation. Flatten +retains stroke-start height by default. Time-based tools remain time-scaled and +movement-based tools use deterministic distance spacing, independent of frame rate. +Tablet pressure is optional when the platform already provides it, with a normal +mouse fallback; adding new tablet platform support is not required by this feature. + +One stroke or committed mesh operation is one history transaction across all touched +regions, masks, volumes and placements. Escape cancels and restores the complete +transaction. Use dirty-data deltas rather than copying the whole terrain for each +pointer event. Chunk-aware bounds and acceleration structures replace rebuilding and +ray-testing every terrain triangle on every hover. + +## True mesh union, subtraction and caves + +Use smooth scalar-density volume bricks for local volumetric edits, initialized from +the heightfield solid. The sign convention is negative inside, positive outside. +Union is `min(terrain, operand)` and subtraction is `max(terrain, -operand)`. +The derived density need not remain an exact signed-distance function after CSG. +Extract a smooth triangle surface rather than rendering occupied voxel cubes. +The existing block-voxel mesher remains available for its original use cases. + +For regions containing volume edits, affected surface tiles become owned by the +volume extractor, including the base surface through those tiles; suppress the +corresponding heightfield triangles. Extend conversion over a halo and construct +shared boundaries from canonical samples. Include explicit seam geometry where +heightfield and volume triangulation differ. The result must have no holes, duplicate +coplanar faces or collision cracks at the ownership boundary. This invariant requires +dedicated geometry and actual rendering/collision tests, not only a density test. + +Keep the heightfield as an editable base and persist volume operations in ordered, +non-destructive form, with spatially indexed density caches. Subsequent base sculpting +re-evaluates relevant cached bricks with those operations; deleting a cut restores +the base. Persist stable source references, transforms, operation order, precision +and material assignment. Invalid source assets produce an error and retain the last +valid saved result until repaired. Baking is explicit and undoable. + +Drop a static mesh asset from the project browser or mesh palette into terrain mode. +Show a movable/rotatable/scalable preview with Add/Subtract and resolution controls, +affected bounds and estimated brick/memory cost before the user applies it. The source +asset remains reusable and the terrain operation appears under the terrain in a +readable hierarchy. Escape cancels without changing either the terrain or source. + +Solid operands must be finite, closed, consistently wound, non-self-intersecting, +manifold triangle meshes; support spheres, boxes and capsules directly as well. +Validate before mutating terrain. Open/non-manifold meshes receive an actionable +message; provide explicit thickness conversion for suitable open stamps only if +implemented and documented separately. Animated/deformed operands require a baked +static mesh. Apply full source/parent transforms, including nonuniform scale and +reflections; reject singular transforms. Solid classification must work for concave +operands. Bound voxelization and validation work and allow cancellation. + +Union may use the terrain's material rules or an assigned operand layer. New cut faces +use the configured interior layer/rules, not undefined source UVs. Precision is an +explicit cell size: features below it may disappear, and a finer choice costs memory +and processing time. This is solid terrain editing at sampled precision, not an exact +CAD triangle Boolean or a promise to retain all source topology/UVs. + +Regenerate visual geometry, ray-query acceleration and triangle collision from the +same committed surface revision. During editing show a pending preview; Play must +wait for coherent collision or report why the operation is still pending. Test actual +movement through a subtracted tunnel and standing on an added ledge. + +## Alternatives and scope boundaries + +- **Heightfield-only mesh stamps:** simpler, but cannot meet the explicitly confirmed + caves/overhangs requirement. +- **Dense global volume:** handles caves uniformly but wastes memory for broad open + landscapes; use sparse local conversion instead. +- **Exact arbitrary triangle CSG:** strong topology guarantees require a substantial + robust geometry kernel; sampled smooth solids fit editable terrain and bounded chunks. +- **Geometry clipmaps immediately:** useful for very large distant surfaces, but do + not solve volumetric ownership; tiled LOD fits incremental meshes and collision first. + +All six requested capabilities belong to this feature. Browser-shell authoring parity, +multiplayer editing, water simulation, hydraulic erosion, runtime deformable physics, +adaptive volumetric LOD and exact CAD CSG are separate work. Do not present those as +implemented. Existing mobile/VR input parity is not established by desktop validation. + +## Platforms, documentation and acceptance evidence + +Keep runtime storage and geometry backend-neutral under Swift 6.2 isolation rules. +Exercise native Studio on Windows and macOS, material rendering on WebGPU and Metal, +and compile/runtime compatibility on supported Linux, Android, Apple mobile/visionOS +and WASM paths as selected by the validation profile. The browser can consume exported +terrain assets without gaining the native authoring UI. Missing host/hardware evidence +remains blocked; Windows results do not establish Metal or mobile interaction. + +Deliver substantial user documentation in the same implementation change: + +- Update `Editor/Documentation/Terrain.md` with the full authoring workflow, + migration, presets, materials, instances, caves, limitations and troubleshooting. + Link it from `Editor/Documentation/EditorGuide.md` and `Editor/README.md`. +- Add `Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md` for runtime configuration, + asset loading, surface queries, collision and export; link it in `AdaEngine.md` Topics. +- Add the editor terrain guide to the maintained offline generator inputs, regenerate + `Editor/Sources/AdaEditor/Assets/Documentation/catalog.json`, and run its `--check`. +- Provide a reproducible example project/scene with automatic grass/rock/snow layers, + manually painted path, shared rock/grass instances, a carved tunnel and an added + overhanging ledge. Build its actual code and test packaged save/reopen/Play/export. + +Acceptance requires evidence for all of the following: + +1. Legacy scenes retain shape, transforms, color, queries and static collision; an + explicit conversion round trip preserves them and supports Undo/Redo. +2. A 1024-by-1024-unit sparse test landscape includes negative coordinates and missing + regions. Cross-corner strokes are continuous. Unrelated chunks do not remesh; a + residency traversal loads/unloads clean data and retains dirty/pinned data. +3. Visual LOD transitions and heightfield/volume boundaries have no visible cracks. + Collision/picking remain accurate across those seams and transformed terrain. +4. Shared-asset placement supports 100,000 decorative instances in a benchmark fixture + without one authored/full-runtime ECS entity per instance. Record CPU/frame time, + draw counts and memory, comparing batching with separate draws on the same hardware. + Use the repository performance harness; no universal frame-rate promise is implied. +5. Golden images on Metal and WebGPU show matching detiling, normal orientation, + continuous chunk boundaries, cliff/ceiling mapping and correctly normalized rules. + Tests cover zero weights, manual override/erase and layer reordering by stable IDs. +6. Every brush operation has a meaningful production-path test. Preset/alpha import, + frame-rate variation, extreme settings and one-transaction multi-region cancellation + are covered. At least 24 shipped presets are accessible in localized mounted UI. +7. Concave mesh union/subtraction creates a cave/tunnel and an overhang across chunk + boundaries. Save/reopen, reordering/removing operations, Undo/Redo, source transforms, + brush edits and Play collision preserve the committed result. Invalid meshes and + canceled/budget-exceeding work leave the original terrain unchanged. +8. Region resources, layers, placements and density edits survive project relocation + and native export; truncated/untrusted assets fail within declared memory limits. +9. Packaged native Studio exercises the palette, preview, narrow Inspector, language + switching, stroke input and Play. Backend/material checks use real GPU pixels, not + compilation or cached WGSL alone. Browser export is served and exercised where applicable. +10. Required lint/analyzers, focused then owning/downstream tests, WebGPU validation, + applicable platform builds, docs/example/offline checks and independent review cover + the final change. Record TDD Red/Green/Refactor through the shared runner. Missing + prerequisites and failed checks cannot be labeled inapplicable or passed. + +Before implementation, enumerate exact task-owned files as each slice is introduced; +preserve unrelated contributor-workflow edits. Run the shared contribution runner at +stage transitions. Independent review uses a separate subagent and includes user-guide +accuracy, applicable third-party license compliance and all six requirements. Implementation and +the permanent implementation plan begin only after the human receives and approves +this document, as required by the accepted contributor workflow. diff --git a/Documentation/Designs/terrain-mesh-composition.md b/Documentation/Designs/terrain-mesh-composition.md new file mode 100644 index 000000000..b513c7fa4 --- /dev/null +++ b/Documentation/Designs/terrain-mesh-composition.md @@ -0,0 +1,758 @@ +# Terrain Mesh Composition, Surface Materials, and Procedural Population + +Status: Approved for implementation; implementation and acceptance pending. +Approval: explicit user approval in this Codex conversation on 2026-11-06. +Date: 2026-11-06. Revision: research-based replacement of the initial short proposal. +Evidence: [Unreal Mesh Terrain research](../Research/2026-11-06-UnrealMeshTerrain.md). +Existing contract: [Terrain Authoring V2](terrain-authoring-v2.md). + +## 1. Decision and intended result + +Ada Studio should support an editable environment in which a participating tree +root, rock, or other static mesh joins the terrain. Moving the object rebuilds +old and new terrain regions and redistributes generated vegetation and props. +The artist can inspect actual triangles using a portable Wireframe view. + +Recommend a shared retained modifier/dependency system with an explicit +**triangle-surface authoring backend for new Mesh Terrain resources**. Keep the +existing heightfield plus local-density backend for compatibility and sampled +solid editing. Both share surface snapshots, material channels, population and +compiled output contracts. They are not silently interchangeable. + +This approved design extends Terrain V2 without silently changing its existing +asset contract. It does not claim to clone Unreal internals. +The source research establishes useful behavior and abstractions; the following +contracts and algorithms are Ada choices. + +The user explicitly confirmed both ground reconstruction and generated grass/rock +redistribution after moving a participating object. Color blending alone does not +fulfill the requested geometry join. Regeneration must preserve manual intent. + +## 2. Goals and scope + +| ID | Required outcome | +| --- | --- | +| G1 | Genuine visible-geometry union/stitching with inspectable shared topology | +| G2 | Retained, selectable, movable operands; old locations reconstruct correctly | +| G3 | Consistent geometry, normals, surface channels and material transition | +| G4 | Deterministic generated grass/rocks respond to surfaces and exclusions | +| G5 | Manual placements, paint, exclusions and pinned overrides persist | +| G6 | Bounded local interactive work; no disappearing last-valid surface | +| G7 | Wireframe and Shaded + Wireframe on Windows/WebGPU and macOS/Metal | +| G8 | Save/reopen, relocation, Undo/Redo, Play and export preserve committed results | +| G9 | Independent authoring detail, runtime LOD and attribute density | +| G10 | Compatible existing terrain assets and preserved unrelated editor work | + +Use distinct product terms: Combine groups geometry; Stitch joins compatible +boundaries; Union/Subtract resolve participating intersections; Conform deforms +terrain toward an object; Blend controls contact shading; Populate generates +instances. Do not hide these different contracts behind one ambiguous Merge flag. + +A full PCG node editor, biological vegetation growth, hydrology, multiplayer +editing, universal non-manifold repair, arbitrary animated operands, lossless +fusion of unrelated shader graphs, gameplay-time execution of the editor graph, +and a Nanite-equivalent renderer are outside the first complete delivery. +Runtime streaming of compiled output is not runtime authoring. + +Native desktop authoring is the initial interaction target. Shared runtime data +remains portable to supported engine platforms. Browser-shell and touch authoring +parity require their own interaction proposal and evidence. + +## 3. Evidence and verified starting point + +Epic's APIs expose a triangle builder, bounded submesh access, interactive sections +and Boolean cut-edge welding. They support the architectural comparison without +establishing exact undocumented algorithms. +[Builder](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FMeshBuilder), +[interactive section](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/AInteractiveSection), +[Boolean](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UBooleanModifier?lang=en-US). + +| Ada owner | Existing foundation | Missing behavior | +| --- | --- | --- | +| `TerrainManifestAsset`, `Terrain3DSource` | Regions, budgets, streaming, heightfield LOD | Tagged mesh backend and graph document | +| `TerrainVolumeOperation`, `TerrainVolumeField` | Stable ordered sampled union/subtract | Scene binding, general modifiers, triangle operations | +| `TerrainHeightStorage` | Bounded sample edits and persistence | Arbitrary surface sheets and editable topology | +| `TerrainSurfaceQueries` | Committed triangle ray/closest queries | Patch-aware attributes and generalized anchors | +| `TerrainMaterialPalette`, `TerrainMaterialResolver` | Stable IDs, bounded rules, top-four contributions | Semantic channels and durable 3D surface paint | +| `TerrainMaterialTextureArrays` | Shared layer PBR resources | Compiled mesh-surface channel atlas | +| `TerrainInstanceBrushKernel`, `TerrainPlacementAnchor` | Persistent brush records, heightfield reprojection | Actual-surface population and ownership overrides | +| `EditorTerrainResourceWriter` | Saved copied operands | Live bindings and edit/reorder/remove transactions | +| `DebugView3D`, GPU backends | Diagnostic enum; Metal line fill | Working WebGPU edges and editor selection | + +The research records the dirty-worktree context. Existing meshlet/LOD resources +can be reused as derived render products; they are not an editable topology kernel +or Nanite. Ongoing terrain-task performance fixes are not assumed finished. + +## 4. Artist workflows and observable semantics + +### 4.1 Place and join + +Place a tree/rock normally, then add Terrain Interaction with a selected target, +operation, participating geometry, optional proxy and contact settings. Preview +bounds, estimated cost and invalid input before mutation. Choose Union for actual +joining or Conform for deformation. Connect existing population rules and inspect +the result in Shaded, Wireframe and Shaded + Wireframe. + +For genuine union, selected visible root/trunk geometry participates in generated +output and is removed from its original draw for that accepted revision. Keep +nonparticipating canopy/leaf geometry separately rendered. A proxy which merely +pushes ground is Conform, not a welded visible tree. A proxy supports a genuine +join only through an explicit compatible attachment loop and generated bridge. + +### 4.2 Move, delete, cancel + +Dragging edits a retained operand rather than applying repeated destructive unions. +Restore the old location by evaluating the base plus remaining operations; update +the new location and affected population. Keep source selection and camera input +usable. One drag is one history transaction; Escape restores source transform, +terrain, overrides and population. Removing/disabling an operand restores its +influence area without deleting the reusable model. + +Duplication creates a new modifier identity. Deleting a target produces clear +binding diagnostics and undoable changes, never a dangling live entity pointer. + +### 4.3 Inspect a join + +Wireframe exposes the actual shared boundary and current triangle density. +Overlay mode shows contact shading against edges. Add local detail where needed; +do not require a global resolution increase. Report internal cracks separately +from intentional exterior boundaries. A skirt cannot stand in for a correct +walkable join or cave boundary. + +## 5. Data model and ownership + +The following are proposed concepts, not existing callable APIs. + +| Record | Required content | +| --- | --- | +| Definition | Stable ID, backend, local frame, channel schema, stage ordering, material palette, section/budget policy, compile profiles | +| Document | Immutable bases, ordered modifiers, population rules, manual overrides, current author revision | +| Base section | Stable ID, geometry reference/hash, transform, boundary/provenance metadata and bounds | +| Modifier | ID, type/algorithm version, order, enablement, target, parameters, dependencies, influence/growth bounds | +| Operand binding | Stable scene entity or standalone source reference, participating submesh selection, relative transform, source fingerprint | +| Surface patch | ID, immutable topology/attributes, BVH, boundary signatures, provenance, channels and memory charge | +| Surface snapshot | Document/geometry/channel revision keys, patch generations, local frame and query capabilities | +| Population rule | ID, weighted prototypes, bounds, seed, density/spacing, filters, exclusions, sampling and ownership policy | +| Placement override | Rule/candidate identity, pin/suppress/transform intent, revision and fallback anchor | +| Compiled section | Product key/profile, render LODs, channels, collision/query products, instance cells and dependencies | + +Persistent IDs describe intent. Temporary ECS entities, GPU resources, preview +tiles and triangle indices do not become durable identities. Use project-relative +validated paths and existing immutable-payload/atomic-manifest conventions. + +### 5.1 Format compatibility + +Introduce a tagged source in a new manifest version: legacy/hybrid or triangle +surface. Continue decoding supported existing manifest and volume documents; +unsupported versions fail without being overwritten. Exact binary/JSON layout +is specified with implementation, including sizes and limits. + +An explicit conversion snapshots an evaluated hybrid surface into mesh bases. +It bakes old evaluation rather than recovering nonexistent source UVs or brush +history. Preserve the original resource, support cancellation and make conversion +one Undo action. Do not force arbitrary mesh sections into a one-height-per-X/Z grid. + +### 5.2 Revisions and cache keys + +Track authoring, geometry, channels, queries, population and compiled products +separately. Material-only changes do not remesh; channel updates invalidate only +declared consumers. Source-content edits invalidate references even if paths stay +the same. Acceptance compares full dependency tuples, not coincident counters. + +Use stable content hashes and canonical ordering including algorithm/schema +versions, ordered modifier membership, transforms, source selection and profile +settings. Do not use process-randomized Swift `Hasher` or timestamps as durable +cache identity. Retain old generations while readers/writers need them. + +## 6. Geometry representation and feasibility gate + +### 6.1 Editable surface + +The new backend retains positions, triangle adjacency, corner UV/normal/tangent +attributes, triangle material/provenance IDs, semantic channels and boundary loops. +Editable data is independent from packed render buffers. Sensitive intersection +work uses an appropriate local frame and double precision, with checked conversion +to GPU floats. Welding geometric positions preserves distinct corner attributes +at intentional seams; coincident sheets are not automatically one surface. + +| Operation | Contract | +| --- | --- | +| Project/conform | Bounded directional deformation and falloff; no fusion promise | +| Union/subtract | Intersection resolution, classification, internal-face removal, boundary assembly and attribute transfer | +| Stitch | Compatible loop matching, subdivision resolution and explicit ambiguity rejection | +| Local remesh | Constrained split/collapse/flip/relax with provenance/attribute transfer | +| Tessellate | Refine locally with conforming transitions to unchanged neighbors | +| Sculpt | Bounded retained displacement/detail in a defined reference frame | +| Paint | Attribute mutation without incidental geometry changes | + +### 6.2 Open terrain versus closed solids + +Open surface patches do not satisfy a closed-solid kernel's input assumptions. +Choose and prove oriented open-surface semantics or a temporary local closure +outside the effect bounds. Temporary caps never enter visible/collision/query +output. The strategy must distinguish cave floor, ceiling and intentional opening; +unsupported ambiguous cases fail while retaining valid terrain. + +Before production integration, evaluate adversarial fixtures: concave operands, +coplanar/tangent contacts, small features, open patches, reflected/nonuniform +transforms, source attributes, seams, cancellation, worst-case work and native/ +WASM delivery. Validate native authoring-kernel packaging separately from compiled +WASM consumption; the editor geometry kernel need not ship inside the player. +Record the selected route in an ADR after evidence. + +Manifold is a candidate for closed-solid subproblems, not a proven open-terrain +kernel. CGAL is a candidate/reference for surface processing with package-specific +licensing and packaging constraints. Neither is installed or selected here. +[Manifold](https://github.com/elalish/manifold), +[CGAL package catalogue](https://doc.cgal.org/latest/Manual/packages.html). +A pure Swift route avoids FFI but requires substantial robustness engineering. + +The current sampled-density route remains explicit. It is not a silent fallback +for a requested topology/attribute-preserving operation. If the feasibility gate +cannot meet G1 and boundary invariants, return to design rather than relabeling +Conform or voxelization as the missing triangle operation. + +### 6.3 Smooth contact and join assets + +Separate geometry smoothing radius, maximum permitted deviation, and material +transition width. Use bounded seam relaxation/remesh or an explicit bridge/blend +surface. Preserve sharp features unless rounding is selected. Do not attribute a +smooth-min SDF algorithm to Epic without evidence. + +A join asset can identify closed participating visible geometry, protected seams, +optional proxy and attachment boundary. Open leaf cards remain outside solids. +Errors identify the failing selection and a remedy; animated participation uses +an explicitly baked static operand in this version. + +## 7. Modifier graph and dependency evaluation + +Default stages are Base, Broad Shape, Detail/Composition, Surface Channels and +Population. Store deterministic suborder/ties. Modifiers declare geometry and +channel read/write sets, source dependencies, finite bounds/growth, halos, +precision/error settings and expected topology change. Worker inputs are resolved +immutable values rather than live scene objects. + +Procedural geometry generation reads an upstream stage and writes downstream. +Reject dependency cycles with an actionable path rather than iterating until an +unbounded graph happens to settle. Reordering validates prerequisites. + +### 7.1 Invalidation and growth + +```text +dirty = union(lastAcceptedInfluence, inFlightInfluences, requestedInfluence) +dirty = expand(dirty, geometryHalo + channelHalo + populationHalo) +``` + +Influence includes smoothing/projection and declared output growth, not only the +model AABB. Propagate expanded output bounds transitively: an operation added on +a newly created ledge must be found even if it lies outside the original base +bounds. Resolve grouping conservatively with finite iterations and a growth budget. + +Cache base snapshots, unchanged graph prefixes, operand acceleration and derived +products separately. Account bytes, pin in-flight inputs and evict by bounded LRU +policy. Coalesce changes rather than scanning/rebuilding the world per mouse event. + +### 7.2 State and acceptance + +```mermaid +stateDiagram-v2 + [*] --> Ready + Ready --> Requested: edit + Requested --> Evaluating: immutable inputs + Evaluating --> Prepared: validated products + Evaluating --> Requested: superseded + Evaluating --> Ready: failure or cancel + Prepared --> Ready: accepted publication + Prepared --> Requested: stale dependency key +``` + +Ready's last-valid products survive failures. Obsolete jobs cannot publish. +Cancellation does not delete shared immutable payloads or files still used by +writers. Keep last-accepted, submitted and requested influences distinct. + +## 8. Interactive editing and history + +Maintain retained authoring input, fast interactive output and compiled products +as related but distinct representations. Reuse unchanged graph prefixes, coalesce +pointer events, bound concurrent jobs and use newest-request-wins per edit. +No full source reload or final collision cooking is required per pointer event. + +A reduced-detail or stage-limited preview must be visibly pending. Do not imply +later modifiers already ran. If a provisional object transform precedes its +terrain update, show an explicit ghost/pending presentation. Keep a coherent +accepted surface visible until replacement resources are usable. + +Publish seam-connected patches together where independent swaps would crack. +The accepted source transform, source participating-geometry draw mask and fused +patch generation switch atomically. Keep provisional drag ghosts separate from +accepted source draws: moving the canopy/trunk ahead of its fused root would +visibly break the attachment even if patch boundaries were otherwise coherent. +Geometry, queries and channels describe the same accepted surface. Population +visibility swaps use a compatible dependency key, even when prepared separately. +Large updates can group publication by connected region with a declared transition +policy; no unnoticed mixing of incompatible boundary generations is allowed. + +One drag captures initial/final graph state and a compatible resource generation. +Undo does not replay nondeterministic input events. Escape restores transforms, +surface, overrides and population. Save atomically publishes a manifest after +payload completion. Do not reload/remesh an entire resource just to create a +history checkpoint; bound retained generations and preserve active readers. + +## 9. Boundaries, authoring detail and runtime LOD + +Adjacent sections share canonical boundary identity and geometry signatures. +Evaluate topology changes with neighboring context and deterministic ownership. +Both sides agree on geometry/subdivision; intentional shading seams may retain +different corner attributes without opening the surface. + +Select crossing triangles even if no vertex is inside the influence box. Test +long triangles, exact boundary/corner contacts and negative coordinates. Distinguish +intentional outer edges from invalid internal cracks. Skirts cannot establish +collision/query continuity at a join. + +| Control | Meaning | +| --- | --- | +| Target edge length | Local authoring detail for surface editing/composition | +| Sampled cell size | Precision of the existing density backend | +| Channel sample/texel density | Detail of paint, masks and semantic data | +| Texture scale | Material repetition density | +| Runtime LOD error | Allowed distant visual simplification | +| Population cell size/density | Scheduling and object distribution | + +Refine locally with constrained transitions and attribute transfer. Runtime LOD +must preserve shared boundaries or supply validated compatible transitions. +Existing meshlet/LOD data is invalidated with topology and reused where eligible. +Wireframe follows displayed LOD, while gameplay queries/collision use appropriate +independent detail and explicit error limits. Coherent provenance does not require +identical triangle counts. + +## 10. Surface channels and durable painting + +### 10.1 Semantic schema + +Channels have stable ID, name, type/range, default, interpolation, ownership, +runtime-export flag and declared consumers. Examples include soil/rock weights, +moisture, biome, vegetation density, exclusion and contact blend. Reordering or +renaming never reinterprets saved identity. GPU packing remains derived. + +Keep semantic channels, layer albedo/normal/ORM/height maps, and optional shading +cache resources distinct. Material layer IDs are categorical; generic masks are +not automatically normalized as if they were a material mixture. + +### 10.2 Paint survives topology changes + +Do not key paint solely by X/Z or a generated triangle index. Persist ordered +authored strokes/detail against source patch provenance, local anchors, side and +radius, with bounded replay/checkpoints. During remesh retain transfer maps; +continuous attributes interpolate appropriately and categorical data follows +declared selection rules. New cut faces use explicit interior/default rules. + +Reprojection is bounded and source/side filtered so floor paint cannot jump to a +ceiling. If correspondence fails, keep intent and expose a recoverable orphan/ +reattachment state instead of discarding it. Paint is independent of render LOD +and atlas packing. Erase removes the selected manual override, revealing rules; +it does not erase unrelated material or population data. + +Paint attachment has an explicit intent frame: base-ground paint remains terrain- +local; source-owned bark/root paint follows the operand-local frame; procedural +contact weights regenerate. Paint on generated bridges/cut faces uses a declared +source or terrain owner and becomes recoverably orphaned if its surface disappears. +Do not drag soil paint with a tree or leave source-owned paint behind through an +unfiltered nearest-surface projection. These ownership rules apply to persisted +stroke coordinates independently of texture projection settings. + +### 10.3 Compiled channel atlas + +Bake accepted channels into section charts with independent coverage for stacked +sheets, gutters, mip policy, distortion/byte limits and retained provenance. +Channel lookup UVs differ from source-material UVs. Categorical IDs use discrete +fetch; interpolate weights rather than averaging IDs. Shared borders/mips must +agree. Repacking cannot move authoritative paint or placement identity. + +Reuse existing texture-array support where suitable. Exact formats, chart +generation and budgets are feasibility outputs, not claimed copies of Epic's +undocumented packing. Separate authoring channel resolution from compiled texels. + +## 11. Material composition + +### 11.1 Bounded layer evaluation + +Extend stable palette IDs, existing top-four contributions, deterministic pruning +and fallback/interior layers. The default order is automatic rules, then procedural +contact contribution, then authored manual override, then deterministic top-four +pruning and normalization. Contact regeneration never overwrites authored paint; +erasing the manual override reveals the current contact/rule result. Painting +another semantic channel does not implicitly alter this material precedence. +Height/slope/noise use the chosen local frame and actual surface +position/normal, including walls and ceilings. + +Mix base color in linear space, normals in a common frame followed by normalization, +and scalar PBR values using documented representation-aware interpolation. Preserve +engine lighting/shadow conventions. Do not promise physically exact mixing of +arbitrary shader graphs. Height-biased texture weights change shading, not geometry; +displacement requires a separate geometry/query/collision contract. + +For automatic weight vector `a`, normalized contact contributions `c`, contact +strength `t`, authored normalized paint `p` and override strength `m`, use: + +```text +b = (1 - t) * normalizeOrFallback(a) + t * normalizeOrFallback(c) +w = (1 - m) * b + m * p +result = normalizeOrFallback(stableTopFour(w)) +``` + +This equation is an Ada layer policy, not a description of Epic's shader. +Multiple contact writers compose in retained modifier order. Preserve intermediate +full-palette weights until final bounded selection where feasible. IDs are sorted +for ties; zero total weight uses the fallback. Height-biased blends operate on +shading weights with a documented width and do not change geometry. Test zero +weights, ties, complete/partial overrides and erase after operand movement. + +### 11.2 Contact appearance and source materials + +A join writes provenance and optional contact weights in a finite band. Assign +supported transition layers with independent geometry/shading widths. Unchanged +bark may retain source UVs; generated bridge/cut faces use explicit local projection +and contact/interior materials. Canopy and nonparticipating surfaces retain their +own materials. Hide original participating draws only when the replacement is ready. + +Unsupported transparency, wind and custom graph semantics remain separate or require +explicit conversion. Do not silently flatten them into terrain. Material transitions +must include roughness/normal behavior, not just a color fade. + +### 11.3 Projection and repetition + +Retain planar mapping for ground and triplanar options for generated cliffs/roots/ +ceilings. Handle normal axis/sign changes and reflection handedness. Choose source- +local bark versus terrain-local soil coordinates intentionally and describe whether +patterns move with an object. Reuse seeded flips, rotations, offsets and macro +variation consistently across each layer's associated maps. Camera/chunk identity +must not randomize texture patterns. + +### 11.4 Optional shading cache + +An RVT-like view-independent cache is a later measured optimization, not a required +correctness mechanism. Eviction cannot lose paint or change collisions. A top-down +capture cannot uniquely address all cave sheets; use charts or explicit multi- +surface addressing. Start with the direct channel route and profile it before +adding a new virtual-texture subsystem. Writers/readers declare dependencies and +invalidate old/new stamp regions. This cache cannot substitute for a genuine join. + +## 12. Procedural population and manual intent + +### 12.1 Inputs and actual-surface sampling + +Rules define weighted prototypes, finite bounds, stable seed, density in stated +units, spacing, scale/yaw, normal alignment, offsets, channel/height/slope filters, +allowed side, exclusion distances and cell scale. Consume a named accepted +upstream surface/channel stage. + +Sample actual patches/triangles and density per surface area where appropriate. +A downward heightfield ray cannot cover walls, ceilings and stacked ledges. +Simplified physics geometry is not the authority for visual placement. + +### 12.2 Determinism and boundaries + +Candidate keys derive from rule, canonical terrain-local cell, candidate lattice +and stable surface/provenance choice, not triangle enumeration or job order. +Use bounded multi-hit/patch selection for multiple sheets. A seed by itself does +not preserve IDs through arbitrary topology changes; define controlled reattachment. + +Resolve spacing across cell edges with sufficient neighbor halos and deterministic +priority. A finite-radius local priority exclusion rule is acceptable initially +even if less dense than globally maximal Poisson sampling. Density is a target +under constraints, not an exact count. Streaming or reversed job order cannot +reshuffle unchanged output. Include maximum spacing/exclusion/prototype extent in halos. + +Default spacing uses Euclidean distance in terrain-local length units within the +same attachment sheet/provenance group. Separate floor/ceiling sheets do not +compete merely because they share X/Z. Cross-sheet overlap is an explicit optional +clearance test using prototype bounds. Exclusions use 3D object-space shapes +transformed to terrain space, expanded by their selected distance, not an implicit +2D footprint or geodesic measure. Nonuniform terrain transforms require documented +unit conversion for world-space clearance; spacing retains the declared local metric. + +### 12.3 Ownership + +| Kind | Persistent authoring data | Regeneration | +| --- | --- | --- | +| Manual | Explicit placement and anchoring policy | Preserve intent; only reattach under selected policy | +| Generated | Rule and stable candidate identity; optional derived cache | Recompute affected cells deterministically | +| Pinned/edited generated | Override plus fallback anchor | Preserve; report unsupported attachment as an orphan | +| Suppressed generated | Tombstone or exclusion | Do not respawn on regeneration | + +Use versioned rows/sidecars for ownership; never infer it from transient entities +or an ID bit pattern. Unpin/restore are explicit. Seed changes affect generated +records while retaining manual intent. Rule deletion previews override consequences. + +### 12.4 Movement and anchors + +Rebuild geometry, derive changed channels, then regenerate affected cells with +spacing/exclusion halos. The old footprint becomes eligible again. Unchanged +cells retain hashes, IDs and GPU resources. Multiple rules may exclude grass +close to roots while introducing litter/small rocks in a wider band. + +General anchors retain position, normal/side, provenance, search radius and last +generation. Reattach by provenance, then bounded compatible closest-surface query; +never snap through a thin wall. Disappeared surfaces remove unmodified derived +candidates but expose recoverable states for pinned/manual intent. + +Reuse shared prototypes, instance buffers and batching/culling. Avoid one authored +or full runtime ECS entity per blade. Decorative collision defaults off; opt-in +nearby props have independent budgets. Persisted CPU-owned rules/intent are the +initial route; GPU-only ephemeral scatter needs separate save/debug/physics semantics. + +## 13. Preview, query, collision and compiled products + +```mermaid +flowchart TD + A[Authoring and source dependencies] --> B[Regional modifier evaluation] + B --> C[Accepted surface and channels] + C --> D[Editor rendering] + C --> E[Surface queries] + C --> F[Population and overrides] + C --> G[Compiled render LODs] + C --> H[Collision preparation] + F --> I[Portable runtime package] + G --> I + H --> I +``` + +Compile profiles select visual error/LOD, channels, collision error and population +products. Persist product dependencies and algorithm versions. Preview actors are +not durable attachment identities. + +Editing may postpone collision cooking. Play waits for compatible committed +products or reports pending work. It cannot pair a new cave opening with an old +closed collider. Simplification preserves clearance and protected seams within +declared tolerance. Test actual traversal and standing on joined ledges. + +Queries follow the accepted intended surface even when the render LOD is coarser. +Provide diagnostics for differences. Navigation, if connected, needs its own +acceptance; collision does not prove navmesh correctness. Do not introduce a new +navigation subsystem as a hidden prerequisite. + +Export consumes compiled output without the editor evaluator by default. Stale +or missing products trigger a rebuild through the existing workflow or an +actionable failure, never silent export of obsolete terrain. + +## 14. Portable Wireframe inspection + +### 14.1 Native editor control + +Use the previous proposal's compact shared dropdown direction: Shaded, Wireframe, +Shaded + Wireframe. Ship English/Russian labels, tooltips, visible selection/focus, +keyboard navigation, Escape/outside dismissal and focus return. The selection is +editor-local, survives navigation/publication and does not change authored cameras, +materials or Play. Omit it in 2D; keep 3D model-preview diagnostics consistent. + +The prior schematic is a proposal, not native rendered evidence. Verify it against +the current toolbar before UI edits. Use Studio theme/fonts and test narrow layouts, +long labels and persistent selection after hover exit. Touch parity is separate. + +### 14.2 Rendering contract + +Draw actual displayed triangle edges, not a grid, box, meshlet border or different +LOD. Cover static, instanced, skinned/morphed and generated terrain paths supported +by the viewport. Report unsupported paths before claiming viewport-wide completion. + +Recommend cached edge-index geometry and a portable line pass. Standard WebGPU +exposes line topology rather than polygon line-fill state. +[WebGPU primitive state](https://www.w3.org/TR/webgpu/#primitive-state). +Reuse source deformation/transforms; generate canonical triangle edges once per +geometry revision/LOD. Normalize nonindexed geometry during preparation if needed. + +Use compatible depth so hidden edges are occluded. Pure Wireframe uses neutral +surfaces/background and edges; overlay retains authored shading. Line topology has +different culling/bias constraints, so define shader/depth/silhouette handling; +do not copy unsupported polygon depth-bias settings. A barycentric triangle route +is an alternative if quality/performance justifies attribute and memory cost. + +Reuse actual per-view visibility and selected meshlet hierarchy branches, including +mixed-detail clusters within one part, rather than only the asset-level LOD number. +Edge ranges correspond to submitted triangle ranges. If diagnostics cannot reuse +that selection, explicitly choose a compatible geometry route for both surface +and edges, label that choice, and do not overlay different-LOD edges while calling +them current displayed topology. + +Cache by resource lifetime with explicit byte charges. Disabled mode performs no +edge preparation/drawing. Animated geometry reuses deformation without per-frame +CPU topology extraction. Diagnose path tracing/upscaling through an explicit +compatible raster route and restore authored settings on return to Shaded. + +## 15. Modules and Swift isolation + +| Owner | Responsibility | +| --- | --- | +| `Math` or reviewed low-level geometry target | Renderer-independent topology kernels and validation | +| `AdaAssets` | Versioned documents/products, validated references, atomic persistence | +| `AdaScene` | Evaluation, patches, streaming, queries, population and committed revisions | +| `AdaRender` | Render/edge resource representation and backend support | +| `AdaCorePipelines` | Material/diagnostic passes, visibility and LOD integration | +| `AdaPhysics` | Collider preparation and supported simplified products | +| `Editor/AdaEditor` | Scene bindings, transactions, UI/status and localization | + +Do not force a dependency-heavy topology library into lightweight `Math`; evaluate +a narrow target in the kernel ADR. Keep the `AdaEngine` facade thin. + +Use value types and immutable `Sendable` snapshots for workers, `@MainActor` for +editor interaction, existing world isolation for publication and structured +cancellable tasks. Caches have explicit synchronization/lifetime. Avoid detached +tasks, unsafe isolation and blocking IO in pointer callbacks. Prepare GPU resources +through their owning API and budget work before world publication. + +## 16. Budgets and measured responsiveness + +Charge source decode, editable topology/attributes, BVHs, operation scratch, +prefix caches, channel atlases/mips, CPU/GPU instances, edge buffers and collision. +Account for simultaneously live old/new generations. Existing mesh budget values +do not cover arbitrarily many new uncharged products. + +For planning only, 100,000 triangles require at most 600,000 directed endpoint +indices before deduplication: about 2.29 MiB at 32 bits each. A mostly closed +manifold often approaches 300,000 endpoint indices after shared-edge deduplication, +but this is an estimate, not an allocation guarantee. A 1024-square RGBA8 ID map +plus RGBA8 weight map costs 8 MiB before mips/gutters, about 10.67 MiB with a full +mip chain. Arbitrary extra channels, CPU copies and live generations add cost. + +Likewise, a direct four-layer, three-axis triplanar path reading base color, +normal and ORM can require 36 texture samples before blend-height, variation or +additional lighting. These arithmetic estimates motivate separate geometry/channel/ +shader budgets; they are not measured costs of the current renderer or UE. + +Bound touched triangles, candidate visits, source validation, remesh iterations, +charts, decoded images, queued jobs and growth. Expensive loops support cancellation. +Failure retains valid output and names the limiting setting with a useful remedy. + +### Proposed targets, not measured results + +On a recorded reference desktop fixture, target pointer/navigation CPU p95 no more +than 16.7 ms, first meaningful local preview p95 no more than 100 ms, and local +release-to-accepted-preview p95 no more than 500 ms. These apply to a declared +bounded edit, not all terrain sizes/hardware. + +The reference edit has a 16-unit influence, at most 100,000 touched triangles, +32 active geometry modifiers and 100,000 decorative instances across the terrain. +Record affected cells/candidates and hardware. Establish feasibility before +promising these targets. Large intentional builds may remain pending/cancellable +while navigation continues; missing measurements cannot be labeled real time. + +Measure warm/cold p50/p95, worker/publication stages, longest main-thread stall, +changed patches/cells, cache hits, peak live bytes, uploads, draw counts and GPU +time. Use the repository harness on quiet hardware and include a mounted native +interaction; a helper benchmark alone does not establish editor responsiveness. + +Expose modifier timing, pending/accepted/error state, bounds, triangle counts, +edge length, channel views, population ownership and generation. Keep ordinary +artist flows free of unnecessary rendering implementation jargon. + +## 17. Failure and compatibility behavior + +| Case | Required behavior | +| --- | --- | +| Missing/changed source | Useful diagnostic; no silently accepted stale dependency | +| Open/invalid solid | Reject Union; offer explicit Conform or repair guidance | +| Non-finite/singular transform | Reject before mutation | +| Reflection/nonuniform scale | Correct normals/winding/collision or explicit unsupported error | +| Degenerate/coplanar/tangent contact | Tested valid output or preserved prior generation | +| Superseded/canceled worker | No late publication or deletion of shared active payloads | +| Budget exhaustion | No partial replacement or loss of authored channels/placements | +| Lost correspondence | Retained intent with recoverable reattachment state | +| Relocation | Portable relative references; outside-root/traversal rejected | +| Unsupported compile profile | Explicit failure or declared compatible fallback | +| Stale runtime products | Rebuild or actionable failure before export/Play | + +A conventional LOD fallback can replace optional meshlet rendering while preserving +the surface. A fallback cannot silently remove caves or required channels to fit +a budget. Readers never rewrite unknown formats as a truncated supported subset. + +## 18. Acceptance fixtures and platform evidence + +Use Swift Testing and production storage/geometry/render/event paths. The current +research task does not report these future checks as passed. + +| Fixture | Evidence required | +| --- | --- | +| Root union on uneven ground | Shared visible boundary, material/normal continuity, preserved canopy | +| Drag across a four-section corner | Old region restored, new join correct, one Undo/Redo and full Escape rollback | +| Slow drag build with accepted tree draw | Source transform/draw mask and fused patch switch together; provisional ghost never detaches accepted canopy | +| Overlapping operands, one removed | Remaining order and authored edits retained | +| Floor/bridge/ceiling at same X/Z | Independent paint, population and anchors | +| Paint ground and roots, then move operand | Ground paint stays terrain-local, source paint follows source, contact regenerates and lost bridge paint remains recoverable | +| Paint over contact, move operand, erase and Undo | Manual override wins; erase reveals current contact/rules; Undo restores intent and compatible generations | +| Cave mouth, long crossing triangles | Correct regional inclusion, seams, picking and clearance | +| Modifier on a newly added ledge | Transitive output-growth dependency included | +| Local remesh/density transition | No T-junctions; transferred attributes; unaffected section hashes retained | +| Mirrored/nonuniform operand | Correct winding, materials, normals and collision | +| Invalid card/open operand | Actionable rejection without history/resource mutation | +| Manual/pinned/suppressed instances | Survive regeneration, seed updates, streaming and save/reopen | +| Reversed/concurrent population jobs | Identical IDs, boundary spacing and ownership | +| Offset simplified collision | Placements follow accepted intended surface | +| Rapid edits and slow worker | Stale geometry/population never publishes | +| Cache eviction/cold reopen | Same result under bounded memory | +| Wireframe for all supported mesh paths | Correct current LOD/deformation, occlusion and Shaded restoration | +| Mixed meshlet hierarchy cut within one part | Edge ranges match actually displayed selected branches, not source or asset-level LOD alone | +| Packaged example, Play and export | Valid dependencies; actual traversal/standing on modified terrain | + +Render real GPU pixels on Windows/WebGPU and macOS/Metal for joins, normals, +ceilings, material/channel boundaries and edge modes. Run applicable WebGPU/Tint +checks for shader/backend changes. Exercise dropdown default, hover, pressed, +focused, selected, open and disabled/error states where applicable in English/ +Russian and narrow/normal layouts. + +Validate supported Linux/mobile/WASM runtime/build paths affected by shared changes. +Unavailable hardware/toolchains remain blocked/unverified, not passed. Record +commands, fixtures, screenshots, failure diffs and latency/resource traces. Native +desktop authoring scope does not waive existing runtime platform compatibility. + +## 19. User documentation and examples + +Update `Editor/Documentation/Terrain.md` with place/join/move/populate/inspect, +join-asset preparation, manual overrides, precision/performance settings, migration +and failure recovery. Link relevant sections from `Editor/Documentation/EditorGuide.md` +and `Editor/README.md`. + +Extend `Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md` for backends, +surface queries, compiled products and runtime limits; maintain Topics navigation +in `AdaEngine.md`. API comments complement, not replace, the user article. + +Ship a reproducible prepared-root/rock fixture with a true union, conform-only +prop, painted floor/ceiling, automatic population and manual overrides. Build the +actual example and exported result. Regenerate offline documentation with the +maintained generator and run its `--check`; do not hand-edit generated catalogs. +Research/design prose does not count as this shipped guide. + +## 20. Subsequent delivery boundaries + +These are design acceptance boundaries, not an approved implementation schedule. + +| Boundary | Reviewable result | Condition | +| --- | --- | --- | +| Geometry feasibility | Adversarial fixtures and kernel comparison | G1/boundaries/attributes and packaging proven; ADR selects route | +| Portable inspection | Native selector and real GPU edges | G7 with LOD/deformation/occlusion evidence | +| Retained authoring | Versioned backend, graph, live bindings and interaction | G1/G2/G6/G8/G10 across save/Undo/Play | +| Surface shading | Durable channels/contact material/remesh transfer | G3/G5/G9 on walls/ceilings and both backends | +| Population | Surface rules, ownership, overrides and local updates | G4/G5 with deterministic movement/boundary fixtures | +| Integrated delivery | Packaged example, docs, export and profiling | All goals and applicable checks covered | + +No slice closes the overall feature while another required result is missing. +After human approval, record exact owned files and commands in the implementation +plan. Coordinate overlapping changes with the active terrain task. Do not weaken +contribution rules or suppress checks to report an unfinished feature complete. + +## 21. Remaining decisions and approval boundary + +Recommend mesh-native authoring, direct channels/materials, persistent CPU-owned +population and a portable edge pass. The remaining choices require evidence: + +1. Select the geometry kernel and open-surface strategy using the feasibility gate. +2. Select channel formats/charts/gutters and budgets using GPU-limit/quality tests. +3. Validate interaction targets on declared hardware; tune section sizing and + scheduling before making real-time claims. +4. Add an RVT-like cache only if profiling shows a benefit over the direct route. + +Public Unreal sources do not settle these Ada-specific choices. Human review +approves behavior and boundaries; feasibility determines implementation. This +document is approved for implementation; completion requires all stated behavior +and acceptance evidence. Independent review follows implementation of the entire +agreed functionality, as explicitly requested by the contributor. diff --git a/Documentation/Plans/2026-11-04-TerrainAuthoringV2.md b/Documentation/Plans/2026-11-04-TerrainAuthoringV2.md new file mode 100644 index 000000000..3e7aa4fba --- /dev/null +++ b/Documentation/Plans/2026-11-04-TerrainAuthoringV2.md @@ -0,0 +1,1625 @@ +# Terrain Authoring V2 Implementation + +Date: 2026-11-04. +Design: [Terrain Authoring V2](../Designs/terrain-authoring-v2.md), approved in the +project conversation after the reference-removal edit. +Status: **In progress**. No milestone below is complete until its production path +and applicable validation have been exercised. + +## Current stage contract (2026-11-05) + +The approved design remains unchanged. The following boundaries consolidate the remaining +work; intermediate fixes do not introduce additional feature requirements. Exact owned files +are registered with the runner before each addition. Stage completion requires its listed +production behavior and checks; the overall task stays open through stage 7. + +| Stage | Concrete deliverable and acceptance | Owned scope | Required checks and current state | +| --- | --- | --- | --- | +| 1. Chunk foundation | Signed sparse storage, canonical shared samples, local mesh/collision updates and legacy compatibility. | AdaScene terrain; its AdaScene/AdaEngine tests; existing physics integration. | Focused storage/geometry/physics regressions and owning suites. Runtime implemented; final cross-platform evidence remains in stage 7. | +| 2. Assets, residency, LOD | Atomic portable region persistence, bounded loading, dirty/history pins, stitched visual LOD and canonical collision/query detail; exported resources reopen after relocation. | AdaAssets terrain/loading; AdaScene residency/save/LOD; native scene/export adapters and tests. | Real-file save/stream/cancel and LOD/physics tests pass locally. Resource collection/scene-reference/export acceptance remains open. | +| 3. Layers and authoring | Stable layer rules, detiling/triplanar PBR, native manual channels, layer/mask/tint/roughness brushes with compact atomic Undo, bounded uploads and retained unchanged geometry. | Terrain material assets/runtime; AdaRender bindings; AdaCorePipelines shader; corresponding CPU/GPU tests. | Implemented locally: owning CPU 469/77, material/texture GPU 22/4, lint 216/0, docs/catalog/example and focused independent review pass. Full platform/runner acceptance remains in stage 7; see boundary evidence below. | +| 4. Instances | Persistent shared-asset scatter/erase, deterministic filters, anchor reprojection, spatial batching; opt-in nearby static collisions and measured 100k-instance behavior. | Terrain instance/model paths; render extraction; physics integration; benchmark/tests. | Implemented locally: owning CPU 481/78, optimized native 100k pixel/performance comparison, lint 227/0, docs and focused independent review pass. Full platform/runner acceptance remains in stage 7. | +| 5. Smooth volumes | Sparse 32-cell density bricks, validated closed mesh/primitive CSG, ordered persistent edits, continuous ownership seams, nearest-surface queries and matching collision for caves/overhangs. | Renderer-neutral geometry; terrain volume assets/runtime/queries; CPU/GPU/physics tests. | In progress: bounded field/mesh solids, native persistence, scoped resources/cache and initial live ownership/collision with explicit seam strips and opt-in committed surface queries are implemented locally. Require concave subtraction and added ledge crossing chunk boundaries, sculpt reevaluation, save/reopen, Undo/reordering and invalid/canceled/budget rejection. | +| 6. Studio tools | Localized viewport tools/palettes, 300px Inspector, 24 meaningful presets, alpha import, mesh drop preview, source migration, history/cancel/Play behavior. | Existing terrain/editor controls, localization, history, project assets; mounted UI and interaction tests. | Not implemented beyond legacy controls. Require rendered native Studio interaction, English/Russian and narrow-layout checks. | +| 7. Delivery and acceptance | Discoverable guides, hybrid-terrain ADR, runnable example, portable export, final required validation and consolidated independent review. | Declared articles/navigation/offline catalog; example and ADR; validation evidence. | Guides currently describe shipped slices only. Full example, final docs/export/platform/GPU/Studio checks and `verify --stage done` remain open. | + +The implementation order from this point is stages 3, 4, 5, 6, then remaining stage 2 +export acceptance and stage 7. Apply focused Red/Green cycles, refactoring only when useful; +run owning/downstream checks at these boundaries and review completed behavior with its +affected interactions. Do not restart the whole audit after every local correction. + +Classify new defects in changed behavior as regressions and missing design behavior as +unmet approved requirements. Both remain in scope. The previously reproduced three scripting +test failures are adjacent baseline issues, recorded separately in local logs; they do not +authorize scripting feature work or a claim that the full package passed. Browser authoring +parity, adaptive volume LOD, hydraulic erosion and exact CAD CSG remain excluded by design. +Missing mandatory platform evidence is a completion blocker, never a passing stage. + +Latest focused evidence: public native-size preflight Red reproduced 6 issues in two cases; +unchanged Green/Refactor pass (1.688s/1.657s). Combined command size refusal passes three +cases (14.012s); the initial final-array assertion compared import order with canonical cell +export order and was corrected to a captured pre-edit payload, with an explicit TDD exception. +Surface-only geometry reuse Red reproduced four GPU-buffer/collider replacements; Green +and Refactor pass while the sculpt replacement control remains active. Scoped SwiftLint +reports zero violations in 201 Swift files. These are local slice results, not stage 7 acceptance. + +Surface brush stage update: all seven operations now execute through the existing atomic +pipeline. Related runtime Red reproduced 21 issues; unchanged Green passes seven cases. +Supplementary production-path checks cover spacing, alpha, original surface filters, layer +pruning and work limits. The owning terrain CPU run passes 245 tests in 36 suites (103.345s), +and the material/texture GPU run passes 19 tests in four suites (91.056s). Independent review +found one mixed-pressure work-budget regression: 13,118,065 mask visits for 3,205 useful +samples. Filtering inactive dabs once before sample evaluation fixes it; fresh unchanged +Green passes both mixed/all-zero cases (.013s), and re-review closes that finding. + +The new live-GPU fixture initially used radius 8 on a four-unit, 129-sample landscape and +correctly exceeded the sample budget. Its corrected radius .25 passes actual paint, Undo +and prepared-work cancellation while retaining mesh buffers. The interrupted fixture cycle +is explained explicitly; it is not claimed as a production fix. A fresh combined GPU Red +then measures 1,747,660 shared image bytes in one update. A private slice/mip upload cursor +now caps transfers at 1 MiB per update, retains the old generation until completion and +reuses completed arrays. Texture2D validates RGBA8 subresources before backend writes; +Metal, WebGPU and headless implementations preserve the existing initial-upload API. +Unchanged combined Green passes both real GPU tests (6.372s). One compile-only Green attempt +placed the cursor field on the wrong state type; correcting that field precedes the passing +run and does not count as behavioral evidence. Broader stage checks and review follow. + +Pacing interaction review identified valid proxy uploads being rejected by inherited +placeholder allocation metadata. A fresh proxy regression reproduces two errors; capturing +the concrete backing once for validation, upload and alpha invalidation passes unchanged +Green. Allocation metadata is now immutable; the actual refactor passes the same test (.003s). +The earlier tracing-height failure disappears after recompiling its unchanged test client +(content SHA256 retained with the compiler log). No production fix is claimed for that +cache/client-rebuild result; its exact test remains in the owning acceptance run. + +Portable tracing previously accepted layered terrain as ordinary PBR and discarded its +layer colors. Real GPU Red reproduces three failures, including incorrect pixels. The +existing unsupported-material/raster fallback now handles terrain bindings explicitly; +unchanged Green preserves the colors and publishes the diagnostic (5.093s). This does not +add terrain shading to the experimental tracer. GPU harness helpers now live in the +curated GPU fixture, so standalone WebGPU validation does not depend on headless test suites. + +Stage 3 local boundary evidence: the final owning terrain plus AdaRender run passes 469 tests +in 77 suites (102.779s); the final real WebGPU material/texture run passes 22 tests in four +suites (114.987s). Pinned SwiftLint reports zero violations in 216 files. Offline regeneration, +27-guide verification and both documentation checks pass. The article's extracted surface-brush +example compiles and constructs its actual request; existing decoder/palette examples execute. +Focused independent review closes the proxy and tracing findings with no remaining blocking +finding in those interactions. These checks establish the Windows implementation boundary, +not Metal, other platforms or whole-feature completion. + +The shared quick runner passes diff/content/secrets/Python lint/security analysis on Windows; +its native SwiftLint adapter is blocked because this host uses the pinned WSL binary. That +binary's actual scoped lint passes independently. An additional pinned Linux tool environment +was installed, but the cross-filesystem Git scan times out before that complete quick run. +Retain both blocked reports honestly. Quick-mode skipped categories do not establish tests, +runtime or platform acceptance. The complete applicable runner/platform profile remains a +stage 7 requirement; it is not waived by advancing implementation to instance collision. + +## Stage 4 local boundary evidence + +Opt-in nearby prop collision now has independent distance/count/geometry/native-preparation +limits, cached flagged candidates, deterministic selection, paced BVH preparation and atomic +publication. Brush painting persists its opt-in flag through native save and exact Undo. +Fixed-step readiness waits for newly required props, including in-region body teleports. +Frozen native physics cycles pass: nearby/reflected-parent selection (two cases, 1.097s), +brush/history/pacing/rejection/readiness (three tests, 1.819s), and review-driven recovery +(three tests, .640s). The latter reproduces and fixes disabled collision during an invalid +configuration, placement-only pose recovery and conservative camera-collector overflow. +Independent review closes all three findings; it does not approve the entire feature. + +Material-layer brush filtering resolves the renderer's canonical automatic rules, masks, +curvature and painted overrides, then interpolates stable-ID weights with a bounded +gesture-local cache. A frozen six-case cycle reproduces nine failures and passes unchanged +Green (3.844s), covering missing-layer atomic rejection as well as automatic/manual fields. +Retained native mask erase/Undo and expensive-rule rejection versus a successful unfiltered +control pass (two tests, 3.294s). Independent review finds one exact-threshold rounding bug; +its actual native erase Red fails one assertion (.441s). Constant-preserving bilinear lerps +pass unchanged Green (.220s), and focused re-review closes that finding. + +The final owning terrain/AdaRender run passes 481 tests in 78 suites (122.674s), after all +filter/recovery fixes. Scoped lint passes 227 Swift files; both documentation checks and +diff checks pass. The shared code-stage content check passes. Its quick review profile +passes diff/content/secrets/Python lint/security analysis but remains blocked on the native +Windows SwiftLint adapter; the pinned WSL lint run passes independently. Quick-mode skipped +test/runtime/platform categories do not establish full acceptance. Final required runner, +analysis and platform checks remain in stage 7, as at the stage 3 boundary. + +The optimized native WebGPU benchmark passes on Windows Swift 6.3.1, NVIDIA RTX 4070, +D3D12 driver 32.0.16.1088. It loads 100000 saved decorative placements from two regions, +requires all placements to pass visibility and produce actual material pixels, and compares +exactly equal images with identical submitted matrices/assets/flags. Thirty warmed frames +are measured at 640 by 480 in albedo view: + +| Path | Runtime entities | Camera draws | CPU ms/frame, process-wide | Completed wall ms/frame | Process working set bytes | Process committed bytes | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Terrain spatial batches | 35 | 1 | 34.375 | 33.00613 | 399245312 | 510283776 | +| One entity per prop, shared assets | 100035 | 1 | 125 | 126.0179567 | 512659456 | 621301760 | + +Both retain 6400000 authored placement bytes. Process CPU includes worker threads; memory +includes the renderer, retained terrain/caches and harness. This is a simple warmed albedo +comparison, not shaded gameplay performance or exclusive placement-allocation accounting. +The measured test passes in 15.089s after the first complete release build (1571.13s). +Raw results and PNGs are retained in task-owned ignored evidence. Independent fixture/article +review reports no blocking finding; execution and full-feature acceptance remain distinct. + +Earlier fixture attempts are excluded: missing normals and an inverted orthographic zoom +did not establish meaningful pixels. A subsequent valid 100k debug comparison exposed an +unequal baseline caused by legacy `Transform(matrix:)` decomposition of scaled bases. +The benchmark now supplies exact authored TRS and asserts matrix equality before submission. +No general Math fix or performance success is claimed for those interrupted/failed attempts. + +## Stage 5 first slice + +Start general scalar-field extraction in renderer-neutral `Math/Geometry`: immutable +finite grids of at most 32 cells per axis, smooth indexed triangles with outward normals, +explicit output limits and canonical shared brick-boundary positions. Own the new grid and +extractor files plus `Tests/MathTests/ScalarFieldSurfaceTests.swift`. Use the exact test file +against the real public Math product for focused Red/Green, then its owning tests. A closed +sphere, exact shared boundaries and malformed/budget failures establish this slice; they +do not establish caves, mesh-solid validation, terrain ownership seams, surface queries, +persistence or collision. Those remain required for stage 5 completion. + +Extraction slice evidence: frozen Red reproduces three missing behaviors; Green passes +three tests (.050s). Removing temporary per-triangle arrays is an actual refactor and +passes unchanged tests (.048s). A second frozen cycle adds signed integer lattice addresses: +Red reproduces five failures, Green passes fractional-scale shared boundaries on all three +axes and addressed-coordinate precision (.047s). The real root Math-filtered owning run +passes 33 tests in 11 suites (.159s). Retained cancellation, extreme-scale normals and exact +solid-contact checks pass with the original extraction/lattice tests (eight tests, .093s). +Independent scoped review finds no blocking source defect. This establishes the standalone +extractor, including per-grid area-weighted normals, rather than terrain volume integration. + +The next geometry slice adds validated sphere/box/capsule scalar operands, full affine +placement and hard union/subtraction. Density is negative inside; transformed values need +not be exact distances under nonuniform scale or shear, while their zero set follows the +full affine transform. Require analytic samples, reflected/nonuniform/sheared placement, +finite/invertible input validation and an extracted primitive tunnel/ledge fixture. Own the +new scalar-solid/operation files and their Math tests; imported concave triangle-solid +validation and persistent terrain operations follow within the same approved stage. + +Primitive geometry evidence: frozen analytic/affine/Boolean tests first record 47 behavioral +issues; the tunnel/ledge fixture additionally exposes rounded contact topology. Canonical +position welding, coincident-face cancellation, coherent sheet winding and independent +contact fans retain a closed indexed mesh. An intermediate fixed-fraction endpoint snap +passes that fixture but independent review identifies erased representable small spheres. +The actual regression records two failed cases before correction. Remove that snap and +interpolate from the closer endpoint using its own density ratio. Cyclic shortest-edge +triangle-area arithmetic preserves narrow representable faces that long-edge subtraction +lost even in Double. Exact collinear subdivisions are conformed without moving coordinates; +contact processing has explicit work/output budgets and cancellation. Unchanged primitive +and small-feature cases plus retained independently thin plate, lattice, normal and cancel +controls pass together: 14 tests in six suites (.333s), log +`.build-tools/contribution/terrain-scalar-final-robust.log`. Earlier failed attempts remain +retained; their intermediate successes do not establish the final precision behavior. + +Next slice: validated closed concave triangle operands with bounded immutable acceleration, +negative-inside nearest-surface sampling, full affine placement, finite/index/degenerate/ +closure/winding/self-intersection rejection and cancellation/work budgets. Own new +`Math/Geometry/ScalarMeshSolid*` files and corresponding Math tests. This is imported-solid +geometry preparation, not yet terrain asset import, persistence or editor drop integration. + +Imported-solid geometry: the first fixture attempt had a missing Transform3D rotation +argument and is a compile prerequisite, not behavioral Red. The corrected frozen tests +record 10 missing-behavior issues. Immutable bounded geometry now validates welded edges +and vertex fans, consistent winding and face intersections with a flat BVH; queries use +nearest-triangle distance and retry ambiguous parity rays instead of guessing. The same +closed concave L prism is checked under reflection/nonuniform scale/shear and duplicate +attribute seams. Unchanged Green passes five tests (.003s); Refactor passes. Retained actual +mesh subtraction extracts a closed L-shaped shaft, and pre-cancelled validation/sampling +throw: seven tests in two suites (.091s), log +`.build-tools/contribution/terrain-mesh-extraction.log`. Independent review finds a 32-bit WASM UInt32-to-Int index trap; use Int(exactly:) before +indexing and retain a full-width malformed-index control. Targeted re-review clears the +fix and article; WASM execution remains outstanding. Full current scalar tests pass 22 tests +in eight suites (.285s); actual root Math-filtered owning validation passes 50 tests in +17 suites (.288s), log `.build-tools/contribution/terrain-scalar-mesh-math-owning.log`. +Scoped WSL lint passes 248 files, and article checks pass two tests. +The terrain article documents these standalone preparation APIs and their integration limits. + +Next persistence slice: a versioned independently loadable 32-cell volume-brick asset +contains 33-cubed finite Float samples and stable optional material tags. Preserve Float +bits, signed brick addresses and sliced-Data decoding; reject malformed versions, counts, +coordinates, tags, nonfinite data and trailing/truncated bytes before allocating arrays. +Own `Sources/AdaAssets/Terrain/TerrainVolumeBrickAsset.swift` and its asset tests. Follow +with bounded ordered operation/source/transform/precision metadata and atomic save/stream +integration. A standalone codec does not establish editable runtime volumes or seams. + +Volume-brick codec evidence: the first thrown-error-only run exposes missing encoding but +does not satisfy the runner assertion requirement. Explicit valid-encoding requirements then +record two behavioral Red issues; unchanged Green passes three tests (.148s). One Green +attempt is blocked by a SwiftPM artifact-index network timeout and is not a source failure. +Retained real AssetsManager loading, signed address limits, header admission and pre-cancelled +encode/decode pass with the frozen codec tests: six tests in two suites (.193s), log +`.build-tools/contribution/terrain-volume-brick-loading.log`. + +Next metadata slice: a bounded versioned volume document stores lattice origin/precision, +ordered unique operation identities, enabled state, primitive/static-model source, complete +affine placement, optional stable material assignment and independently named cached-brick +references. Reject unsafe paths, duplicate identities/addresses/names, unsupported versions, +invalid placement/dimensions and count/byte overflow. Own the new operation/document asset +files, their asset tests and a small Math affine-validation entry point reusing the existing +validated inverse. Atomic publication, cache regeneration and live terrain integration follow; +metadata round trips alone do not establish those behaviors. + +Volume operation metadata: frozen Red records two required-encoding issues; unchanged +Green passes three tests (.056s), preserving identity/order/full affine placement and +checking table counts before malformed children. Record validation delegates primitive +geometry and a shared Math affine-validation entry point, and bounds cached resource names +and signed addresses. Post-Green test layout changes only satisfy SwiftLint; retained logs +preserve the original frozen cycle. Final combined volume-asset checks/review follow. + +Combined volume data validation passes nine tests in three suites (.150s), scoped WSL +SwiftLint passes 254 files and the shared Code quick content check passes. Quick content +is not the complete final platform/runtime profile. Independent read-only persistence review +finds no blocking codec/metadata defect and confirms the article's integration boundaries. + +Next runtime-kernel slice: capture an immutable COW base-height view, prepare validated +primitive/mesh operands in document order, evaluate a bounded negative-inside field with +material provenance, and build canonical signed-address 32-cell density bricks. Truncate +density to a narrow band for sampled extraction and cull operands only by a deterministic +expanded support bound; shared points must evaluate identically across bricks. Enforce +allocation/work/cancellation limits before returning data. Require cave/added-ledge signs, +ordered overlap behavior, base snapshot/re-evaluation, exact shared samples/tags, real mesh +placement and rejection of stale/missing prepared operands. Own TerrainVolume field/kernel +files and nearest AdaScene tests. Sparse retained cache invalidation, resource loading, +publication/seams/query/physics are subsequent mandatory integration, not implied by this kernel. + +Field kernel evidence: frozen Red records five missing-behavior assertions; unchanged +Green passes five tests (.161s), including real immutable heightfield sculpt/re-evaluation, +ordered overlapping edits, placed validated mesh and exact shared brick values/tags with +actual surface extraction. Independent read-only kernel review finds no blocking defect +within this boundary; final Green was not yet available during that review. Supplementary +mesh-brick budget failure/recovery, fractional all-axis seams, disabled references and +pre-cancelled work follow. Post-Green frozen-test formatting changes no expectations. + +Combined current volume data/kernel tests pass 17 tests in five suites (.239s), including +real mesh-brick budget exhaustion/recovery, fractional negative all-axis boundaries and +pre-cancelled work. This is CPU field preparation, not published hybrid terrain. + +Next sparse-cache slice: immutable generations retain only requested/pinned density bricks +within count/byte/work budgets. Reuse unchanged brick identities and regenerate only local +base or relevant ordered-operand/source changes. Base lineage must survive COW/residency +forks; dependencies include authored hidden height coverage and mesh preparation generations. +Failed/cancelled preparation retains the old complete cache. Own the new cache and AdaScene +tests plus narrow field dependency/shared-budget helpers and base lineage/revision access. +Require unchanged reuse, remote-brick stability after local sculpt/op edits, source reload, +eviction/pins and partial-work failure. Resource import, disk publication and ownership seams +remain subsequent requirements; a cache generation is not a live render/physics commit. + +Sparse-cache evidence: initial four-case missing-cache Red is expanded before production +with authored hidden residency, recording five assertions (.002s). Unchanged Green passes +five tests (.257s). Canonical page identities, rather than draw-tile revisions, preserve +unchanged values across COW/residency forks while actual writes/pruning invalidate affected +bricks. Relevant operation order and mesh preparation generations join that key. The byte +charge includes conservative sample and maximum bounded dependency-array staging. Actual +layout/Self-reference cleanup passes frozen Refactor (.311s); scoped WSL lint passes 260 +files. Independent consolidated cache/base-helper review finds no blocking source defect; +its review preceded the final focused Green result. Broader terrain/render/Math owning +validation follows before integrating resource publication and the live hybrid surface. + +Sparse-cache boundary owning validation passes 553 tests in 101 suites (115.584s), filter +`Terrain|AdaRenderTests|MathTests`, log `.build-tools/contribution/terrain-volume-cache-owning.log`. +This includes current legacy/source terrain, rendering ownership, Math, data and cache paths; +GPU golden suites are gated and this is not backend pixel/Metal/WASM acceptance. + +Before hybrid publication, require untouched heightfield base geometry to retain its true +triangular surface, including steep shared boundaries and nonplanar cell diagonals. Add +production field/extractor geometry regressions, then correct base-distance preparation and +consistent tetrahedral X/Z diagonal selection as needed. Own the new AdaScene geometry test, +field implementation and renderer-neutral extractor/grid API files. This is part of the +approved ownership-seam requirement; actual seam meshes/render/collision remain mandatory. + +Base-preservation regressions record two real geometric failures: steep boundary height +error 2, and nonplanar-cell center error 8. Preserve the base's unclipped y-minus-height +field (saturating only at finite Float range); select the heightfield's falling X/Z +tetrahedral orientation. Generic Math extraction keeps its original rising orientation by +default, with a fixed mirrored alternative. Unchanged Green passes both tests (.128s). +Independent read-only correction review finds no blocking defect and identifies the precise +matching condition: same sample grid or exact aligned refinement, not arbitrary coarsening. +The article now states that condition. Retained both-diagonal closure/seam checks and the +combined current volume tests follow; actual ownership seam geometry is still pending. + +Current geometry follow-up passes 24 scalar tests in nine suites (.281s), including +both orientations' closure/normals/shared fractional boundaries; current combined volume +checks pass 24 tests in seven suites (.359s). Scoped WSL lint passes 262 files. + +Next atomic resource slice: manifest version 4 optionally references one immutable volume +document in its asset directory; nil preserves legacy encodings. Save height replacements, +changed independently named bricks and operation metadata before atomically replacing the +manifest. Reuse unchanged generations, support explicit incremental brick removals, enforce +combined encoded staging budgets and stale-baseline checks, retain old referenced resources, +and remove only newly staged files on cancellation/failure. Height-only updates must retain +existing volume references. Own manifest/writer/AssetsManager terrain extensions, new volume +save-plan files, native persistence tests and the existing unknown-version fixture (advance +its future version from 4 to 5). Real temporary-project loading, budget rollback and cancellation +after an actual unpublished brick write establish this slice; live source hydration/render/ +collision/history/export are still subsequent mandatory integration. + +Atomic native save slice implemented: six production filesystem tests reproduce 12 +behavioral Red issues and pass unchanged Green (.321s), including cancellation after +an actual unpublished brick write. A separate unchanged-generation regression reproduces +8 issues across full/incremental saves; canonical signed (z,y,x) cache order passes +unchanged Green and the actual writer-formatting Refactor. Affected volume/manifest/ +height-save/platform suites pass 52 tests in 13 suites (.753s); scoped WSL lint passes +266 files with no violations. Independent slice review is pending; live publication, +scene history, export and Studio acceptance remain mandatory. + +Network failure during validation was infrastructure, not Red. Official Swan +`SWAN_LOCAL_DAWN` resolves the same pinned Dawn 148.0.7778.97 SDK from the previously +SwiftPM-verified cache, without changing the backend or dependency pins. The cached +archive SHA256 is 0257e10caa98696d66e06abd004b98fe48f7cfb1e254c6e78c7f47f1d62150a2; +all 31 native SDK/info members were compared with the archive. Task-local receipts +`terrain-offline-dawn.json` and `terrain-offline-dawn-stable.json` retain provenance. + +Next resource integration slice: load referenced volume documents and independently +requested cached bricks through the originating scoped AssetsManager path, with +preflight memory/count bounds, exact address and palette validation. Prepare closed +static model operands from every selected scene node/primitive and its full inherited +transform; reject deformed/open/invalid geometry and respect import/validation budgets. +Own TerrainRegionCache volume extension, prepared-mesh model bridge and real resource/ +model tests. Keep bulk preload lazy. This slice precedes live ownership seam geometry, +render/collision publication and authoring history; it does not close stage 5. + +Atomic-save review corrections: all three findings (volume-only stale roots, retained +file integrity and retained material references) reproduce together: 3 tests / 7 cases, +19 Red issues (.561s), unchanged Green (.463s). Validate either incremental half against +the root before staging and commit; bounded-read/decode retained bricks individually, +checking addresses and material tags. Independent targeted re-review clears all three. +The final affected profile passes 55 tests in 14 suites (.904s), WSL lint 267/0. +Whole-feature and platform acceptance remain open. + +Scoped lazy volume loading: three filesystem/scoped-project tests (four cases) +reproduce four Red assertions (.167s), then pass unchanged Green (.175s). Metadata +opening does not read bricks; signed subsets validate budgets, uniqueness/declaration, +exact payload addresses and palette tags before returning a complete private array. +The context that opened the cache owns all later loads. Test formatting follows Green; +final affected verification remains pending with the model bridge below. + +Prepared model bridge: frozen two-test Red4 issues (.846s) -> unchanged Green (.151s), +covering split primitives, inherited reflection/rest poses, selected scenes, exact operand +placement, open/deformed rejection and budget admission. Scoped real-OBJ loading has one +actual missing-preparation Red -> unchanged Green (.153s); preceding test-only SPI and +Sendable closure compile failures are prerequisites, not Red. Inherit the original import +limits and scope, narrow import byte caps to the requested preparation allowance, and run +closed-solid validation on the worker. + +Independent review finds full POSITION accessor copies outside per-index staging. The +large-unused-accessor/closed-cube regression reproduces Red1 (.136s), then passes unchanged +Green (.095s) after reserving the entire accessor before flattening, including repeated +scene references. Targeted re-review clears the staging correction and scoped model loading. The current +combined owning/downstream profile passes 574 tests in 107 suites (112.926s), WSL lint +273 files without violations, docs two tests and diff whitespace checks pass. + +Next live volume publication contract: a source-backed terrain with an ordered capsule +cut and elevated box union replaces affected heightfield tile geometry with smooth density +surfaces. Render geometry and real native triangle colliders publish together through the +existing paced source transaction; no duplicate old heightfield plane remains in an owned +tile. First regression uses real saved height/document assets, runtime child mesh buffers, +and native sphere casts onto a tunnel floor and added ledge. Next dedicated checks cover +base re-evaluation, geometry/collision seams, stale/cancel/budget rollback, saved-cache +fallback, nearest queries and source transforms. Own source residency/build/publication, +new hybrid geometry helpers and runtime/geometry tests as introduced. Full 3D materials, +history/density editing/export and Studio acceptance stay mandatory subsequent work. + +First live source publication: frozen real-asset/render-buffer/native-collision regression +reproduces four Red issues (.191s), then passes unchanged Green (1.919s) and a useful +32-bit-safe index/Sendable/formatting Refactor. Earlier axis-name compile failure is not +Red. Retain bounded immutable density caches and tile products in source residency, force +owned tiles to level zero through the existing LOD balancer, replace canonical/display +meshes and use the existing atomic paced render/collision publication. + +Ownership boundary regression on a nonplanar 129-sample terrain reproduces differing +X/Z boundary segments, then passes unchanged Green (1.762s). Extend footprint padding by +at least two height cells; replace pure-base outer density strips with original heightfield +triangles, subdividing their inner edges to match the extracted surface. Original outer +edges/normals remain unchanged. One shared descriptor supplies both render and collision. +Triangle staging and seam point visits have explicit bounds and cancellation. Helper +allocation/format cleanup follows Green; final affected checks and independent review +remain pending. This first live path requires full64cell tiles, brick-aligned exact grid/ +refinement, and has not yet integrated cached-only fallback, volume3D materials, partial/ +outside-base coverage, canonical crossbrick shading, surface queries or volume history. +Those remain mandatory stage5 work, not exclusions or feature completion. + +Consolidated first live review corrections reproduce 5 issues across three frozen tests +(6.504s), then pass unchanged Green (1.587s): canonical inner transition endpoints at +spacing .1/origin100000, terminal unsupported-volume-material diagnostics preserving the +last valid surface, and unchanged native save retaining density/tile generation UUIDs. +Bracket actual canonical axis positions rather than inferring a uniform world increment; +use the failed-generation retention path for the temporary unsupported material case; +carry volume through save acknowledgment. The material guard must disappear when required +3D volume fields are connected; its regression already permits coherent future success. + +Affected centroid re-review finds the split fan still sums Float world positions. A real +finite snapshot near1.2e38 with a midpoint-induced fan reproduces Red1 (.013s), then passes +unchanged Green (.011s) after sharing componentwise Double centroid arithmetic and checking +appended vertices/normals for finiteness. Scoped lint passes281 files with no violations. +Final affected refresh passes580 tests/108 suites (120.716s), with lint281/0, docs2, +shared quick content and diff whitespace checks passing. Independent targeted re-review +clears the fixes and article; this approves the slice, not remaining stages5–7. + +Next surface-query contract: renderer-neutral open-triangle BVH supports two-sided nearest +ray hits and closest points with finite input/index validation, explicit construction byte/ +work bounds, shared query work budgets and cancellation. Source workers prepare/reuse +per-tile indices for explicitly enabled query settings and publish them with the matching +canonical render/collision revision. Ray queries distinguish stacked ledges and cave floors; +editor consumers use terrain-local results under the source transform. Chunk bounds/BVH +visits replace triangle-wide hover scans. Own Math query primitives/tests and source +query settings/preparation/publication/scene tests as introduced. Verify real committed +scene queries, unchanged tile reuse, transforms and rollback, then integrate editor usage +in stage6. Optional query preparation preserves existing runtime memory/performance when +queries are unused. Full platform/CPU/GPU/Studio acceptance remains mandatory. + +Renderer-neutral query index: five frozen production Math tests reproduce five Red +assertions (.008s), then pass unchanged Green (.168s), covering stacked open surfaces, +two-sided rays/shared edges, closest points, empty/invalid input, byte/work limits, +20k-triangle actual BVH traversal under256 visits, shared budgets and cancellation. +A representable outside-edge regression exposes containment expansion (Red2); common +ray-space signed edge tests plus strict closest-point containment pass all six tests +unchanged (.167s), followed by an actual early-return formatting Refactor. Source triangle +IDs survive BVH sorting without copying unused position rows. Existing solid geometry +shares only the triangle record/hierarchy, retaining its original validation/parity paths. + +Fast query cycles use a separate ignored `terrain-query-math-tests` SwiftPM fixture, +unchanged test-byte SHA receipts and the actual public Math product. Its official local +Dawn fallback points to the separately verified stable SDK copy; it does not mutate the +older fast-Math SDK used by native root tests. Root Package.resolved remains unchanged. +Scene publication/query integration is implemented below; complete cross-platform evidence remains pending. + +Scene query integration: two frozen real-source tests reproduce Red2 (2.663s), then pass +unchanged Green (2.827s) and an actual guard/optional-format Refactor. Query settings are +optional/nil by default. Workers capture/reuse per-tile indices; shared construction work +and entry/full-accessor-copy/index staging guards apply before query allocations. Immutable +terrain-local snapshots publish with canonical render/collision, remain usable when held, +retain prior valid output on admission failure and release current acceleration on disable. + +Consolidated independent review confirms BVH recursive sorted/COW buffers exceed512 bytes +per triangle. A separate bug-cycle ledger (`terrain-query-allocation-regression`) retains +actual Red1 (.002s) -> unchanged Green (.001s), without replacing the parent's pending +source-cycle evidence. Derive the charge from four triangle arrays, two node arrays and +allocator/metadata allowance. The parent owns the regression and final review covers it. + +The same review finds Float-rounded cross-tile distances select farther surfaces. Frozen +two-plane ray/closest regression reproduces Red2 (.001s) -> unchanged Green (.002s), carrying +Double preciseDistance through selection while Float.nextUp remains only a conservative +traversal bound. Targeted re-review clears that correction. + +Supplemental post-implementation production coverage (no fabricated Red) passes three +scene tests (10.531s): original runtime checks plus staged sculpt/Undo, old immutable +snapshot retention, distant query UUID reuse, reflected/nonuniform terrain beneath a parent +matching actual native spherecasts, and unchanged save UUID reuse. One test-only throwing +RHS expression was split before execution; that compile failure is not behavioral evidence. +The original fixture was formatted/shared after its completed frozen Refactor. The refreshed +owning Terrain/AdaRender/Math selection passes591 tests in112 suites (100.606s); pinned +WSL SwiftLint passes291 owned Swift files with zero findings. Documentation tests2, +quick content checks and diff checks pass. Targeted independent query review has no +blocking finding. The full feature remains open. + +Next volume shading slice: publish canonical lattice-gradient normals independent of +brick ownership, retain the original pure-heightfield seam normals, and include the +one-sample derivative halo in cache dependencies. Own the volume field/normal/geometry +helpers and focused Scene normal tests. Frozen adjacent-curved-brick coverage must fail +before implementation, then pass unchanged; affected volume cache/runtime suites and +scoped lint follow. Full 3D material atlases remain the next required slice. + +User-directed preview checkpoint (2026-11-06): prioritize a visible example before further +backend expansion. `TerrainPlayground` is a separate production-pipeline executable with a +saved 65-sample hill, subtractive tunnel, additive ledge, four viewpoints and free-flight +controls. It is not the complete Studio authoring UI. Initial native D3D12 launch found +Swift collision validation rejecting thin triangles. The prototype frozen thin test gives +Red1/.002; its first Green still fails native cooking because that initial fixture's area +is below Box3D's existing cooking tolerance. Do not claim unchanged Green for that cycle. +Correct the fixture to a triangle above the native limit but below the old Swift cutoff, +retain that as post-implementation coverage, and disclose the calibration correction in review. +The Swift gate now matches the native normalizer's finite range; native cooking may still +omit sub-tolerance faces, so full exact collision acceptance remains open. The second visible +launch reports ready and displays the actual cave and ledge; the cave-interior and under-ledge +view buttons were exercised through Windows UI automation. Full feature completion and +physics precision acceptance are not established by this preview. + +Preview validation: corrected thin/native physics plus canonical normals pass8 tests in3 +suites (.290s); source documentation tests2 pass. Final example target compilation passes +(134.17s), pinned WSL lint298 files/0 findings, and independent scoped review reports no +remaining preview blocker. Visible shading still has shadow striping under the ledge/in +cave views; this remains an observed rendering issue, not a visually finished result. +The complete quick runner still reports the Windows SwiftLint adapter unavailable and a +heuristic secret finding for the pre-existing public Swan revision in Package.swift. No +suppression, dependency change or complete-profile success is claimed. Initial content +trailing whitespace in that newly owned manifest is corrected separately. Full acceptance +and final task verification remain open. + +Editor-first continuation requested by the user: stage6 begins with a runnable existing +Studio scene and Terrain Source Inspector rather than more standalone-demo controls. +This first slice owns the additive source descriptor/registration, scoped .aterrain path +resolution, edit-world collision suppression and query admission, EN/RU labels, save/reopen +tests and a real project fixture for packaged native Studio. The300px Inspector proposal +uses the existing theme/font and generic controls (filled/empty source states); it was +shown before production UI edits. Acceptance: frozen real-file save/reopen and scope tests, +localized Inspector registration, native packaged scene visible/selectable after reopening. +New sculpt/volume/material controls remain subsequent visible iterations; no stage5/6 or +whole-feature completion is implied. Every iteration is exercised in the running editor. + +Editor source/picking checkpoint: the first Source tests reproduce9 real missing-feature +assertions (.162s). After implementation, two assertions exposed a test-only Windows8.3 +versus canonical temporary-directory alias; canonicalize the fixture root, retaining later +runs as corrected post-implementation coverage, not unchanged Green for that first file. +The initial dependency identity failure and a localized resolver's actor annotation compile +failure are prerequisites, not Red. Private MCP staging adds an explicit AdaEngine package +name so a worktree named terrain-v2 resolves; all runtime dependency source bytes are unchanged. + +Frozen actual-source picking coverage reproduces Red2/.772s and passes unchanged +Green1test/2cases/.219s. Picking uses committed query snapshots on authored owners; +normalized local distances are converted back to the world-ray parameter before comparing +other meshes. Existing picking tests were formatted after their completed Green; no fake +Refactor claim is made. Independent review found an omitted-nil-radius compatibility issue; +a new frozen codec/scene test reproduces Red4/.116s then passes unchanged Green1test/2cases +in.103s. Decode defaults fill required fields while preserving ordinary optional semantics. + +The complete native Windows distribution (terrain-editor-preview-1) passed the packager's +bundled-tool smoke and launched the real copied TerrainStudio project. The terrain resource, +3D cave/ledge and300px Inspector were observed; the user began interacting, so leave that +process/project open and avoid further automated input there. That first staged binary +predates the picking and nil fix; a subsequent distribution must include both before +claiming those interactions in packaged Studio. Editor terrain article now documents source +loading, selection, preview/Play separation and the explicit current authoring limits. +Owning/editor localization/lint/docs checks and targeted re-review follow this checkpoint. + +Next editor authoring slice: use the existing height-edit/save worker transactions on a +private same-directory manifest fork, then publish one portable source-reference change +through ordinary scene history. The previous manifest/resources stay unchanged for exact +Undo/reopen; cancellation/no-op restores the runtime reference and removes only its own +unpublished manifest. Keep the prior committed mesh while work is pending, bound metadata +I/O, serialize gestures and cancel on owner/document/tool lifecycle changes. Initial controls +reuse the existing four height brushes; release commits one asynchronous source gesture. +This edits the saved authoring resource in the edit world, not gameplay-only runtime state. +Own the editor transaction helper, viewport bridge/tool state and tests. Require real source +worker/save/reload coverage for success/cancel/no-op, one-history-edit/Undo/reopen checks, +plus packaged Studio pointer/Escape behavior before calling the source brushes usable. + +## Boundaries and evidence + +All six capabilities remain in scope. Keep legacy heightfields working while adding +new asset, geometry and editing paths. Use one testable behavior per TDD cycle; +record real assertion failures before production fixes. Compile/infrastructure +failures remain blockers. Do not manufacture Red results for APIs that cannot yet compile. + +The initial design-stage task captured concurrent contributor-workflow edits. +Those edits were subsequently committed and revised by other work. Initialize the +implementation task `terrain-authoring-v2-implementation` at the current repository +baseline rather than adopting those unrelated files. Preserve the original design +task and its reports for provenance. Record approval against the accepted design; +the local approval record is an attestation to this chat, not authentication. + +Implementation runs in the isolated `codex/terrain-authoring-v2` worktree at +`.build-tools/worktrees/terrain-v2` in the original checkout. Concurrent edits in +the original checkout must not invalidate this worktree's TDD snapshots. +For fast CPU cycles, a local generated SwiftPM fixture depends on the real engine +package and copies the unchanged terrain test sources, recording their hashes and +original source locations. It does not replace production modules or use mocks. +Full owning/package/backend validation remains required before review. + +Register exact new/changed paths through `scope` before editing each slice. Start +with this plan and the design, then declare the user articles/navigation. Record +source, tool, TDD and runtime evidence under `.build-tools/contribution/`. +Do not modify validation profiles or suppress diagnostics to pass checks. + +## Delivery milestones + +Current implementation evidence (intermediate, not feature acceptance): the focused +native fixture passes 121 tests over tiled legacy rendering/collision, authored +selection, sparse storage, bounded resource loading, the binary region codec and +real-file storage restoration, portable manifest validation and staged persistence. +Persistence tests cancel after an actual region write and verify the old manifest, +cleanup, generation retention, unchanged-resource reuse, reloadable extensionless +paths, concurrent-save rejection and preflight byte limits. Incremental writes retain +unloaded references within changed-payload budgets, validate exact explicit removals +and reject a stale baseline before staging or publishing. A separate read-only +intermediate review found no concrete merge defect; its requested progress-time +replacement and incremental-cancellation regressions now pass. Runtime save requests +capture hidden dirty layouts, preserve unloaded references, adopt committed readers +and release clean dirty pins. Tests exercise revisiting and repeated saves within a +16 KiB page budget, preserving storage/buffer identity, hidden-layout persistence +and stale disk rejection. Save lifecycle tests now cover URI clearing/replacement, +actual pose/native-body movement, unpublished-edit rejection, later edits remaining +dirty, nonzero Undo across region eviction, camera/physics streaming during a save, +and a third queued source continuing residency behind two paused writes. A shared +FIFO bounds owned filesystem snapshots to two, including detached/cancelled tasks +awaiting their final receipts. Post-commit success is retained despite cancellation. +SwiftLint reported zero violations over 70 scoped Swift files before the latest +pin changes. Save snapshots now pin canonical regions through normal residency +admission; the one-page test refuses a second region while an older save and Undo +need the first, preserves the captured save, then saves the later Undo and admits +the second region after release. Independent read-only review found no additional +concrete defect in these save fixes; this is not final feature approval. Twelve existing asset +manager/directory tests also pass in a fixture using the complete engine dependency. +The region cache uses real typed asset loading, immutable resident values, aggregate +height/count limits and independent pin reasons. Its tests cover retaining a loaded +generation after file corruption, explicit eviction and admission failure without +dropping prior resident data and retaining the originating app-world asset scope. +The scope test uses two real resource roots with identical virtual filenames and +different heights, opens under one scope and requests data under the other. +`Terrain3DSource` is registered by `Model3DPlugin` and loads a manifest's declared +set within region/height/mesh budgets. Headless scene tests verify resource loading, +local revision-driven buffer replacement, shared color updates, cleanup, reflected +parent-space collision and preserving a valid generation when replacement fails. +A further real-physics regression moves the parent during a failed replacement, +checks the retained collider's new pose and recovers the source with unchanged +visible mesh buffers. +Local edits during a failed replacement continue to update the retained generation; +a repaired file is adopted only after an explicit source retry. Admission tests +check that height/mesh budget failures publish no partial children and that an empty +source removes a previously loaded generation. +Camera-driven residency now selects nearby regions, including a canonical data and +normal-coverage halo, and stages storage before publishing geometry/collision. +A real-file scene test loads one region from a larger manifest under a one-region +budget, moves to a distant region, releases its previous meshes and preserves the +storage object's identity and query footprint. Dirty region overrides retain edits +across staging. A real-file regression verifies failure under a one-region limit, +raising that limit, moving away and returning to edited data, and changing the +height budget without resetting storage identity or reloading original heights. +A rejected zero-byte limit retains the last valid budget and buffers. CPU staging +tests verify canonical normals across normal-only halo ownership, retaining an +unaffected tile revision while expanding visibility, stale-write rejection after +an intervening edit, and rejection of over-budget candidates without mutation. +Physics-interest planning now includes complete rigid-body bounds, projected swept +motion and upright character capsules outside camera coverage. Shape-local bounds +are cached; initialized rigid bodies use native world bounds. A real-file/physics +test requests a distant wide body's full footprint, replaces it with a character, +and verifies region and collision residency. Another test fails admission, verifies +both rigid and character movement wait, raises the limit and simulates their real +grounded landing, then despawns the source and verifies its hold is released. +`PhysicsSimulationGate3D` has independent owner/reason holds. A physicsSync recheck +runs after body synchronization, verifies source/data/pose and native colliders, +and prevents a character teleported later than preUpdate from moving until its +destination is resident. A second regression releases one of two reasons on the +same owner and verifies movement remains paused until both release. Sweeps include +at least the fixed tick and account for gravity/downward character motion. +Twenty-five existing character/resize/step tests in three suites passed through +the root package with `swift test --scratch-path .build --force-resolved-versions +--jobs 8 --filter CharacterController3D`; the complete package test targets compiled +for this filtered run. The log is `.build-tools/contribution/physics-root-regressions.log`. +Region reads and CPU meshing now run in cancellable owned tasks, with at most two +concurrent source preparations including staged results. Tile GPU/collision +preparation respects a per-frame budget and publishes the whole generation together. +Real-file scene tests verify pending status, incremental preparation without visible +partial children and cancellation when the source changes. A monotonic-camera regression reproduced starvation of a third source and now +verifies FIFO admission ahead of newly requested generations. Further cancellation +and platform checks remain required. Previously reads/meshing were awaited +in `preUpdate`. +The render-only heightfield LOD mesher downsamples canonical geometry and adds +explicit coarse-side vertices for finer neighbors. Tests cover nonlinear shared +positions/normals and every edge mask across six levels, including nondegenerate +upward full-area coverage, partial-tile fallback and invalid inputs. Camera-driven +selection and runtime switching now use active cameras' parent-composed positions, +terrain-local tile distances and a hysteresis band. A headless scene test switches +four tiles between levels 0 and 6, retains buffers within a distance band and checks +a narrow height spike through actual full-detail physics casts and storage queries +while rendering the coarsest visual meshes. These checks do not replace GPU pixels. +Sparse resource user authoring remains outstanding. +Additional policy tests cover hysteresis across both transitions, reflected inverse +transforms, multiple views, partial edges, inactive/no cameras and a zero level cap. +An initially missing source with an active camera retains its error until explicit +retry even after a valid file appears; failure state is keyed independently of +camera movement and retained geometry. +The LOD balancer now propagates refinement to constrain edge neighbors to one level, +preserves full-detail/partial boundaries and produces the mesher's directional masks. +Its tests cover propagation, all-sided seams, partial neighbors, disconnected extreme +coordinates, deterministic ordering and malformed inputs. +Region restoration includes partial tiles and +canonical data-only resources at the positive grid boundary. Three supplemental +Swift Testing cases compile the actual bounded browser-read helper to WASM and +execute exact-size, empty, oversized and pre-cancelled fetch behavior in Node. +Their helper/test/log hashes are retained in +`.build-tools/contribution/terrain-browser-evidence.json`. That fixture uses minimal +enclosing declarations and does not establish complete AdaAssets, browser-shell or +Studio behavior. Full package/backend/platform checks and independent review remain +outstanding. `script/validate_terrain_native_io.py` additionally compiles the actual +region/manifest codecs, bounded reads and writer in a minimal native host fixture. +Its Swift Testing case reproduced an existing-manifest replacement failure on Linux +with Swift 6.3.2 and passed after switching the writer to POSIX rename. Windows uses +MoveFileExW. The probe supplies only contextual asset-protocol/metadata declarations; +it does not replace real filesystem operations or prove the complete AdaAssets +package on Linux. The same test source runs against the complete module in normal +package tests. Sparse resource Studio authoring remains outstanding; none of the remaining feature requirements is waived. + +1. **Chunk foundation and compatibility.** Add stable signed coordinates, bounded + sparse region storage, canonical border samples, revisions and immutable tile + snapshots. Migrate an existing patch without changing its sampled shape. Keep + negative coordinates, corners, malformed input and unchanged writes covered. + Integrate independently rebuildable tile meshes/collision and terrain queries. +2. **Assets, residency and LOD.** Versioned independently addressable region files, + staged persistence, cancellation/pinning and byte budgets; native export dependency + collection. Add stitched heightfield LOD with canonical collision/picking detail. +3. **Layers and rule evaluation.** Stable layer IDs, bounded normalized contributions, + soft elevation/slope/curvature/noise/mask rules and manual override/erase. Implement + bounded GPU texture arrays, coherent mirroring/rotation/gradients/normals, macro + variation and triplanar mapping through shared PBR lighting. +4. **Instance cells.** Deterministic persistent placements and scatter filters, + multi-primitive source transforms, shared render extraction and mesh/material + reuse; spatial culling/LOD and explicit nearby collision budgets. Reproject edited + anchors transactionally. Benchmark 100,000 decorative instances. +5. **Smooth volumes and CSG.** Validated primitive/closed-mesh operands, ordered + union/subtraction, bounded scalar-density sampling and smooth extraction. Ensure + canonical seams with heightfield tiles, surface ray queries, coherent collision, + non-destructive edits and persistence. Cover tunnels/overhangs across borders and + invalid/singular/canceled operations. +6. **Studio tools.** Localized categories, collapsible asset/brush palette, narrow + Inspector, preview/drop operations and complete undo/cancel transactions. Add the + agreed shaping/painting/scattering/volume operations and at least 24 meaningful + editable presets. Retain current navigation, selection and Play behavior. +7. **Documentation, example and final acceptance.** Ship discoverable guides and a + reproducible example. Run applicable package/platform/GPU/export and UI checks, + resolve findings through independent review, and verify the final stage. + +Intermediate independent review identified starvation, orphan children, raw scene +persistence, dirty history bounds and synchronous recovery cooking. Real scene and +physics tests now cover cleanup after nonrecursive owner removal, retaining ordinary +authored children, native scene save/reopen without derived geometry/collision, +and bounded recovery after a missing replacement resource and parent movement. +An integration failure exposed restored entity ID reuse; the decoder now reserves +loaded IDs before new terrain children are spawned. Non-Codable component state is +skipped without formatting large cached descriptors into encoding-error logs. +The standalone dirty queue is bounded and exposes an overflow generation requiring +consumer resynchronization. Failure recovery retains published visual LOD and uses +the same incremental collision preparation as successful replacements. An initial +collision test probe intersected its own waiting body; its corrected probe is offset +from that body. Original failing logs and the corrected Red/Green run are retained. +The re-review also reproduced a cached-URI revert retaining its FIFO position; +a runtime regression now verifies that later sources still publish. Further tests +exercise transient groups with unmarked descendants, safe IDs after reopening, and +consumer reconstruction after dirty-queue overflow. +A further native Scene round trip preserves authored parent/child relationships and +world-space collision while filtering generated child IDs. Runtime, streaming, +physics, scheduling and persistence tests are split into focused files. +This is intermediate review only; complete feature review and full validation remain +outstanding. SwiftLint 0.65.1 was downloaded using the plugin's pinned archive hash +and runs under the existing WSL Swift toolchain; its scoped report passes with zero violations over 57 Swift files after formatting +cleanup. Newly added files and the final feature still require fresh scoped checks. + +The root ECS regression run passed 176 tests in 25 suites after scene serialization +changes (`swift test --scratch-path .build --force-resolved-versions --jobs 8 +--filter AdaECSTests`), logged in `.build-tools/contribution/terrain-ecs-regressions.log`. +Height-edit command/status contracts now stage bounded, revision-checked candidates +and retain compact before/after sample deltas. Runtime tests cover four-tile corner +publication, inverse-delta Undo, invalid/nonfinite/stale/budget rejection and cancelling +prepared work. Further native-query tests reproduced ready status before actual bodies existed. +Terrain now initializes native bodies during serialized publication using the same +initializer as ordinary physics synchronization. Tests query initial terrain, an +edit and Undo without a later physics tick. Additional tests cover page exchange, +empty/unchanged requests, duplicate/missing/excessive/foreign inputs, supersession +and a live write invalidating prepared work. +Storage checks final page counts before allocation and preserves live data during +preparation. Intermediate review reproduced four further defects and they now have behavioral +Red/Green evidence: unbounded final-frame native cooking, cancelling a queued edit, +false collision readiness after an invalid edit/config change, and accepting a +height edit while silently ignoring new residency/memory settings. Native BVHs are +now cached in synchronized immutable Shape3DResource resources and actually cooked +within each tile-preparation frame. Per-physics-world counters are incremented at +the real C mesh factory; tests observe at most one creation per frame under a +one-tile limit, including publication, and no duplicate cooking on later syncs. +The C API borrows const mesh data, with resource/body ARC ownership retaining it +through native shape destruction. Config-conflicting edits fail explicitly while +normal admission applies or rejects the requested settings; queued cancellation +now completes its status. A teardown regression also removes a source during prepared height work, confirms +edit/status cleanup, preserves authored children and queries the absence of native +collision without a later sync. The root package physics/character regression run +passed 36 tests in five suites after the native-cache changes with +`swift test --scratch-path .build --force-resolved-versions --jobs 8 --filter +"CharacterController3D|Physics3D"`; its log is +`.build-tools/contribution/terrain-physics-regressions.log`. +Studio stroke integration and the remaining editing families are still outstanding. + +## Owning paths and tests + +Runtime terrain code belongs under `Sources/AdaScene/3D/Terrain/`; focused data/query +tests go in `Tests/AdaSceneTests/`. Renderer-neutral surface extraction belongs under +AdaRender's geometry boundary with `Tests/AdaRenderTests/`. Shader changes belong in +AdaCorePipelines with actual-pixel tests nested in `Render3DGPUTests`. Existing +`VoxelWorldStorageTests`, `VoxelWorldSystemTests` and instancing tests are precedents, +not substitutes for terrain-specific coverage. + +Studio code belongs alongside `EditorTerrainBrush`, `EditorTerrainPanel`, +`EditorSceneViewportTerrain` and `EditorTerrainComponentDescriptor`; use existing +`EditorTerrainTests`, `EditorTerrainInspectorTests` and localization suites for +regressions. Check nearest instructions when extending other asset/export/UI paths. + +Use Swift 6.2 isolation and Swift Testing. CPU data tests must cover real storage, +meshing, rule and transaction paths. UI tests mount real views; filesystem/export +tests use real temporary resources. Collision tests execute actual physics queries. + +## User documentation + +- Substantially update `Editor/Documentation/Terrain.md`; link it from + `Editor/Documentation/EditorGuide.md` and `Editor/README.md`. +- Add `Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md` and link it in + `Sources/AdaEngine/AdaEngine.docc/AdaEngine.md` Topics. +- Add Terrain to `Editor/scripts/update-offline-documentation.py` inputs and + regenerate `Editor/Sources/AdaEditor/Assets/Documentation/catalog.json`. +- Add a real example scene/project containing material rules, a painted path, + instances, a tunnel and an overhang. Choose the existing example integration + location after inspecting its loader/generator; record exact paths before editing. +- Record a hybrid-terrain ADR without rewriting historical ADR decisions. + +The guides must distinguish completed slices from planned features while work is +in progress. Final articles explain setup, migration, coordinates, precision/cost, +formats, platform limits and a built/executed example using actual APIs. + +The first CPU height-brush slice exercises all nine shaping operations through +real source commands and atomic publication. Tests cover native collision and inverse +deltas, collinear input-event subdivision, set height, ramps, terraces, reproducible +seeded noise, smoothing bounds, conservative local talus and invalid/oversized input. +A shallow immutable storage snapshot supplies each worker; only touched pages copy +on staged writes. Shapes, pressure, rotation/jitter and original height/slope masks +are present in the data contract. Rotated square/alpha support, canonical outer-edge +and normal-only halo slope masks, aggregate union-work preflight, malformed alpha +decoding and valid alpha round trips now pass real regressions. The halo fixture was +corrected from invalid zero streaming radius to a positive radius; the earlier failed +run is not counted as a completed cycle. Alpha decoding checks dimensions/counts +before its bounded pixel loop. Per-dab trigonometry is cached; talus avoids small +neighbor-array allocations. A checked bounds product also prevents 32-bit Int overflow +before work-budget rejection; full WASM engine evidence is still required. Read-only +intermediate review found no additional concrete brush regression before that final +arithmetic/decoder cleanup. Studio controls and named presets are still open. + +The first shared-mesh slice adds `Mesh3DInstancesComponent` with stable placement +IDs, transforms and tints. Extraction retains a single render source per component; +compatible placements enter existing instanced draw lists without per-placement +entities. The initial real test checks uploaded GPU-buffer matrices/colors under a +reflected parent and repeat extraction. Portable tracing expands the placements, +hashes edits and derives tint material packets within its existing triangle limit. +The test fixture now reads actual uploaded GPU bytes rather than the intentionally +empty `currentBuffer.elements` wrapper; the earlier failed fixture run is not counted +as a completed cycle. Selected-LOD GPU candidate bounds and ordinary-versus-empty +shadow digest distinctions now pass dedicated regressions, followed by independent +read-only re-review with no additional concrete issue in those fixes. SwiftLint +reports zero violations over 83 scoped Swift files. Placement assets, streaming, +scatter, nearby collisions, broad lifecycle/LOD/visibility tests and the actual +100k decorative benchmark remain open; four placements do not establish performance. + +## Validation and review + +Run `check_contribution.py run` at Design/Plan transitions. Use the pinned Python +environment, repository toolchain policy and isolated task scratch directories. +Select focused suites first, then owning/downstream suites. Do not delete the shared +`.build`. For Windows execute Swift through the supported developer environment. + +Required evidence includes signed-coordinate/border/migration/transaction tests, +real storage/export round trips, invalid-asset memory bounds, actual collision +queries through tunnels, renderer instancing counters and performance measurements, +and golden frames for layers/detiling/LOD/volume seams on Metal and WebGPU. +Run `script/validate_webgpu.sh` with compatible Swan/Dawn/Swift and fresh Tint +conversion; cached WGSL does not prove the shader. Check native Studio in a complete +staged distribution on Windows/macOS, including English/Russian switching and +300-pixel Inspector behavior. Build and serve relevant WASM exports. Obtain actual +platform evidence for supported configurations selected by the validation profile; +missing hardware/toolchains remain explicitly blocked. + +Build DocC, build/execute examples, regenerate/check offline documentation and run +the localization checker when applicable. Full lint/analyzer and security checks +are selected by the shared runner over exact owned files. Record runtime/platform +recipes that exercise the affected behavior rather than echoing a passing result. + +After full Code-to-Review validation, invoke `adaengine-review` in a separate +subagent context with the final diff, approved design, plan and evidence. The +reviewer checks all six requirements, correctness, performance/compatibility limits +and article/navigation accuracy. Fix findings, rerun affected checks and repeat +review. Only `verify --stage done` with complete evidence closes the feature. + + +The next instancing cycles release removed placement LOD selections, retain +camera-culled live IDs, and avoid full membership sweeps when ordinary sources are +re-extracted. Reordered placements preserve previous poses by ID. Independent +review found and verified fixes for compound owners leaking skin palettes into +static tracing and motion draws. Tracing now filters eligible parts once, transforms +referenced vertices only, bounds aggregate placement/triangle work and material +packets, and skips empty/line/point/cosmetic expansion. Corrected bounded Red +fixtures exposed each cost; an earlier combined stress fixture was interrupted +and is explicitly not accepted as Red evidence. + +`TerrainMeshPrototype` prepares all active static model nodes and primitives, +retaining imported/rest-pose transforms, shared buffers and materials. Real +extraction/draw-list tests cover multi-node/multi-material composition and model +scene selection. Preparation bounds graph/geometry/placement work and rejects +malformed hierarchy, vertex/index storage, nonfinite data, singular transforms +and overflowing transformed placement bounds. Model data validation runs before +glTF GPU upload, including results returned by custom loaders. The general vertex +packer uses POSITION row count, safely ignores surplus attributes and zero-fills +missing entries. A real-file glTF regression and actual uploaded-buffer checks +cover the previously unsafe cardinality path; the Red fixture gates the old writer +on its erroneous allocation recipe to avoid deliberately executing an unsafe copy. +The original extraction regression fixture also needed CameraPlugin, a missing +setup dependency present at the base revision. These changes do not complete +persistent placement resources, scattering, instance collision or backend/performance +validation. The complete focused fixture passes 121 tests in 16 suites; SwiftLint reports +zero violations across 95 owned Swift files. Two documentation tests and the +27-guide offline catalog check pass. Owning model/import/render regressions are +being refreshed after the shared import changes; actual backend pixels, the 100k +benchmark and full feature validation remain open. + +Placement-resource cycles add a bounded version-two region container with compact +64-byte placement rows, stable IDs and lossless reserialization of quantized channels. +Storage and cache enforce an independent decoded-record budget. Sparse residency, +hidden dirty overrides, save baselines and acknowledgements retain placements; +placement-only removal persists. Selected codec (3), storage (8), cache (2), and +source persistence/cache (4) tests pass their recorded Red/Green/Refactor cycles. +Intermediate independent review found and verified positive-boundary resource removal +and normal-only halo fixes; the persistence integration has no remaining concrete +finding. Full focused and owning regressions still need refresh after these cycles. +The earlier owning import/render run passed 129 tests across 29 suites with GPU/perf +skips; it establishes no backend pixel or benchmark evidence. + +The mesh palette persists stable UInt16 IDs and validated relative model references +in version-two manifests. Both native save modes retain the table, and empty +palettes preserve version-one height-only encoding. Two focused tests pass their +recorded cycle; independent intermediate review found no concrete issue. The +unsupported-future-version fixture now uses version three, since two is supported. +SwiftLint passes all 103 scoped Swift files. Source persistence fixtures now provide +real static model assets when testing placement data, preparing the renderer hookup +without changing their preservation assertions. + +The first source-rendering placement cycle passes eight selected real-resource tests. +It resolves static models in the manifest asset scope, groups placements by 64-cell +cell/prototype, shares source buffers/materials, preserves transforms and reuses +unchanged draw children across height edits. Real streaming and failed-replacement +lifecycle checks pass. A wrong cleanup patch anchor caused one compiler-blocked +Green attempt, corrected before the successful cycle; it is not Red evidence. +Intermediate review identified URI-clear cleanup, repeated camera regrouping and +unbounded inactive prototype retention. The next cycle adds explicit regressions +for these, including a 100000-record CPU/runtime fixture; it is not the required +release GPU benchmark. Per-publication grouping diagnostics count actual grouping +closure visits. Draw distance is world-space, matching existing mesh LOD semantics. + +Lifecycle/bounds fixes pass their four-test Red/Green/Refactor cycle and intermediate +independent re-review. Immutable placement cell generations avoid camera-only row +grouping/comparison; active model vertex/index buffers, conservative CPU geometry and +derived clusters are charged once per prototype before draw publication. Inactive +models are released on replacement. Byte totals use checked, saturating arithmetic. +URI clearing removes draw cells. The complete focused fixture now passes 141 tests +in 20 suites (56.972 seconds). This includes the real 100000-record reuse fixture, +not release GPU timing or backend pixels. + +Anchor reprojection passes its one-test initial cycle and three-test edge cycle. +Tests cover real sculpt/draw/save, free/distant preservation, a valid normal-only +neighbor at a shared edge, and 1000 small slope edits. Independent re-review found +no remaining concrete edge issue. Two compiler-blocked fixture/fix attempts are +not Red evidence. Additive general edit aliases and request DTO constructors keep +the existing inverse-height behavior; exact placement restoration/explicit edits +are not consumed by the worker yet and are the next observable Red scenarios. + +The four-test placement/history cycle passes Red/Green/Refactor. Five repeated +Undo/Redo pairs restore exact region bytes. Explicit insert/save/inverse requests +and atomic invalid-cell/global-ID rejection pass, as does new hidden dirty-region +admission. Imported/edited/removed rows maintain a COW ID-to-cell index. Intermediate +review identified unknown declared baselines being mistaken for empty cells, and +full placement exports during local sculpt admission. Dedicated next regressions +exercise save/reload preservation and actual exported-row diagnostics with 100000 +unchanged placements. Export diagnostics count copied rows and do not edit revisions. + +The two streaming-edit fixes pass Red/Green/Refactor: declared unknown baselines +are rejected and a one-sample sculpt exports zero rows from 100000 unchanged +placements (the Red observed 200000). Independent review found save-reader adoption +needs a persistent known-region set, and canonical height-only boundary pages also +need region admission. That metadata is now retained without changing validation; +the next Red selects immediate-save placement Undo and three positive edge/corner +cases under a one-region cap. Full regression refresh follows these fixes. + +Known-region admission fixes pass their recorded Red/Green/Refactor cycle: the +existing immediate-save placement Undo works, three parameterized height boundary +cases reject new canonical resources beyond a one-region limit, unloaded declared +data survive rejected edits and saves, and the 100000-placement local sculpt still +exports zero unrelated rows. A persistent complete-read set survives reader adoption. +Independent re-review found no remaining concrete issue in these fixes. SwiftLint +passes 113 scoped Swift files; documentation tests (2) and 27-guide catalog check pass. +Complete focused/owning checks are being refreshed before the next capability slice. + +After the final placement admission fixes, the complete focused fixture passes +151 tests in 20 suites (70.629 seconds). The refreshed owning import/render +regression selection is running separately; backend pixels, GPU performance, +full authoring footprint promotion, scatter/prop collision, materials, volumes +and native Studio acceptance remain open. + +The refreshed owning import/render selection passes 129 tests in 29 suites. The +initial complete build/test log finished successfully, while the local output helper +failed to print Unicode under Windows cp1252. After fixing that ignored helper, +the same freshly built products were rerun with --skip-build and native exit 0; +no production inputs changed between those runs. GPU/performance gates remain +skipped. Next, source brush gestures plan and hydrate their full bounded footprint +into a private editing view, preserving render visibility and atomic publication. + +The complete authoring-footprint cycle passes its three logical tests (four cases). +The plan is shared between pre-hydration bounds and final kernel evaluation. Private +query snapshots temporarily expose normal-only declarations; draw coverage is restored +before the existing original-revision publication fence. A cross-region ramp loads +region 2 beyond the initial visible/normal set. Missing payloads or a two-region budget +reject the complete gesture, preserving revision and actual GPU buffers. Hidden +height/anchor changes save and undo without exposing extra draw tiles. Independent +intermediate review found no concrete issue; full focused regressions follow. + +Full focused regression after footprint promotion passes 154 tests in 20 suites +(68.870 seconds). Mesh paint/erase request data are added to the existing general +edit DTO; the worker still follows its prior height-only path until the next Green. +The next real-source tests require persisted deterministic spacing/variation, no-op +repainting, event-subdivision invariance, erasing/inverse history, surface filters +and atomic malformed/oversized/missing-model failures. + + +The first runtime scatter cycle passes five real-source tests through Red/Green/Refactor: +paint/spacing/variation, compact save/reload and exact inverse, erase, event-subdivision +invariance, surface filters/alignment, and atomic invalid/oversized/missing-model refusal. +The subsequent two-test work-budget regression cycle passes Green/Refactor after an +observed Red where repeated dabs incorrectly applied beyond the intended membership +search budget. Every per-record bounds test now consumes a visit. Lattice identity +also incorporates physical scale. Its initial regression required only a nonempty +second patch, which was weaker than the review's partial-fill scenario; a strengthened +comparison with an isolated second stroke is included in the next full brush selection. +Independent re-review found no remaining concrete issue in these two fixes. The open +model loading finding is addressed next with small bounded root/dependency/accessor +fixtures. A limits DTO is present; enforcement is not connected until the next Green. + + +Native glTF import-budget checks pass their six-test helper selection together with +all seven scatter tests through Red/Green/Refactor. The first cycle observed six +missing limit errors across root, external buffer/image, invalid-limit and accessor +cases; its positive bounded geometry remains valid. Independent review then found +repeated embedded image copies, empty target metadata, and unchecked sparse index +narrowing on WASM32. Two small additional native assertions reproduced the first +two gaps; both are now charged before conversion. Sparse indices use checked Int +conversion; the native64 regression already rejected by destination bounds, so it +is not evidence of executing the original WASM32 trap. All 13 selected cases pass. + +The three actual model-path tests then observed a late root limit, unnecessary root +reopening, and lost palette import context across cache/fork actor calls. ModelAsset3D +now caps the asset manager's first read and uses provided bytes under bounded native +glTF import. Terrain readers capture default/scoped limits and retain them across +forks and save-reader adoption, installing them during palette model loading. +All 16 selected helper/model/scatter cases pass Green. These checks do not close +image pixel/mipmap/aggregate costs, OBJ/VOX expansion, optional sidecar aggregate +budgets or browser importing; those remain next implementation work. + + +The complete terrain fixture passes 172 tests in 22 suites after pixel-budget +integration (Green: 70.637 seconds) and its first Refactor. Native header checks +precede pixels; importer accounting carries geometry cost into one texture budget. +Initial image Red observed the three expected failures in PNG, TGA and distinct +multi-texture aggregation, while all prior 170 cases passed. A transient GitHub DNS +failure occurred before that valid Red and is recorded only as infrastructure. + +Independent image review found two uncovered cases: nonzero Data slice indices in +PNG headers and underestimated ImageMipmaps conversion/alpha-mask staging. New small +regressions observe an invalid header at safe slice offset 8 (before offset 100 could +trap), and two admitted underfunded model imports: no mipmaps with only 2.5 times the base +pixel payload, and a thin varying-alpha mip chain with only 4 base-image sizes. +The next Green fixes slice offsets and uses conservative staging charges of four +base sizes without mipmaps and six with mipmaps. Positive eight-base-size controls +still require real model creation. The public decoder guide now documents header +inspection, scoped limits and a slice-safe bounded custom decoder example. + + +Both pixel-staging regressions pass Green (0.123 seconds). Slice offsets 8 and 100 +preserve positive decoding and reject the low limit. Actual no-mip and thin varying- +alpha model imports reject insufficient staging allowances, while their larger +positive controls still construct models. The next Refactor retains those tests; +full importer/image/render owning selections and the complete 174-case terrain +fixture refresh remain required. Lint passes 133 scoped Swift files. + + +The refreshed owning import/render/image selection passes 147 tests in 33 suites +(6.260 seconds), including regular custom image-decoder loading and CPU mip/format +checks; GPU/performance gates remain separate. The decoder guide's exact custom +RGBA example and scoped-model snippet compile in an isolated consumer package. +The custom decoder executes slice-100 decoding and the low-budget rejection; the +model snippet is compiled, while real model execution is covered by runtime tests. + +Bounded OBJ/VOX tests pass Red/Green/Refactor: both supplied-root decoders avoid +reopening files, OBJ meters parsing and rejects nan/inf, and a tiny sparse VOX +payload rejects its declared dense-grid footprint under the low limit. The first +OBJ attempt lacked the required resolver fixture setup and crashed before assertions; +it is infrastructure evidence only. Bounded OBJ now selects its built-in safe parser. + +Review then found raw VOX frame expansion beyond the initial input multiplier and +quadratic OBJ copies. Actual diagnostics observed 599940000 copied index bytes for +10000 repeated faces, against a 480000-byte linear bound. The next recorded cycle +mutates stored primitives through immutable source streams and charges actual DTO +record strides before reservation; both tests pass Green/Refactor. A further review +found 49995000 material comparisons for 10000 distinct materials. Indexed lookup +initially still made 51608 comparisons, exceeding the unchanged 40000 bound; one +inout default-slot lookup now passes that bound. Malformed UTF-8 tags and strings +also fail their new assertions before failable decoding is installed. The two-case +metadata Green passes; Refactor and full 180-case/OBJ-VOX owning refresh follow. + +The metadata Refactor passes. The full fixture now selects 182 tests in 22 suites; +two runs failed only the three-second source-queue readiness assertions (different +queue cases). Four isolated moving-camera fairness runs passed in 0.787–1.963 +seconds. Other user-owned Swift builds were running concurrently. Independent +inspection found no concrete queue-blocking path; full-suite validation remains +unresolved. Preserve the deadline and assertions, add failure-stage diagnostics, +and rerun before concluding that this is scheduling load. + +Optional meshlet/LOD resources now share the root/dependency encoded allowance and +a conservative decoded staging allowance, checked before bounded native reads and +parsing. The first three-case Red observed eight missing-budget assertions; Green +and Refactor passed. An intermediate compile failure exposed an inaccessible +resource-root getter and counts only as infrastructure, not behavioral evidence. +Independent review then found optional resources could displace mandatory textures +and rejected parsing retained a decoded reservation. The next Red observed three +failures using small valid/invalid sidecars and a real textured model. Mandatory +material preparation now reserves its footprint first; rejected parsing releases +decoded memory while preserving the encoded-I/O charge. All five selected tests +pass Green in 1.004 seconds. Refactor and owning regressions follow. + +The ignored focused-fixture copier now normalizes source line endings before its +unchanged-content comparison. It still records hashes of the original test bytes; +this avoids rewriting CRLF test copies on every invocation. + +The sidecar fallback Refactor passes. The refreshed owning selection passes 267 +tests in 50 suites (9.709 seconds), including model, material, image, meshlet, +OBJ/VOX and voxel physics behavior. Layout-only cleanup afterward normalizes LF +in the edited model file and removes an empty line before the sidecar-test closing +brace; assertions remain unchanged. Source-queue failures now include update counts +and public stage diagnostics without changing their deadline or expectations. +Lint passes all 144 currently existing scoped Swift files. Two terrain guide checks +also pass; full profile/platform/GPU gates remain open. + +Material schema Red observes unsupported version 3 in both a 32-layer round trip +and actual complete/incremental region saving. The new bounded palette validates +stable IDs, map paths, ranges, weights, fallback/interior/rule references, 32 layers +and 128 rules. Manifest version 3 preserves it through both save modes; versions +1 and 2 keep their prior serialization when no palette is present. The three-case +Green passes. The unknown-version regression advances its future version from +3 to 4 because 3 is now implemented, retaining the same rejection assertion. +This slice stores material definitions; runtime rule evaluation and rendering +remain subsequent implementation work, not inferred from serialization tests. + +The material-schema Refactor passes (3 tests, 0.026 seconds). After layout fixes, +lint passes all 147 scoped Swift files and both terrain documentation checks pass. +The complete focused fixture passes 190 tests in 23 suites (63.373 seconds), +including both source-queue tests with their unchanged three-second deadlines and +readiness/buffer assertions. The earlier full-run failures remain documented above; +no queue implementation or deadline was changed to obtain this result. + +Actual source-tile material-field Red observes missing canonical ID/weight rows in +both layer orders and the pruning/fallback case. The resolver compiles stable IDs +and rules once per job and evaluates with fixed SIMD storage, keeping at most four +normalized contributions. Canonical full-resolution rows are separate from display +LOD vertices; their staging allowance is reserved before snapshots. The two-case +Green/Refactor pass (including both layer-order arguments). An ambiguous Foundation/ +Math acos overload was a compile prerequisite failure, not behavioral Red. + +Independent review found false curvature on planar outer edges and material-row +recomputation on LOD-only updates. A diagnostic counter increments around actual +resolver calls, before the next Red; it does not change selection or caching. +The new four-case Red observes four wrong edge/corner classifications and two LOD +updates each making 16900 evaluations and allocating new ID/weight buffers (ten +failed assertions). The fix uses centered halo stencils or available one-sided +second differences, with neutral curvature when only two samples exist. Source +requests retain immutable canonical descriptors from the same generation; revision- +unchanged LOD changes reuse those fields while rebuilding display topology. The +unchanged four-case tests pass Green in 1.367 seconds, including zero evaluations +and stable actual MeshBuffer identities for both LOD changes. Independent inspection +found both fixes addressed; this is interim review, not overall approval. +Lint passes 150 scoped Swift files and diff whitespace checks pass. GPU material +sampling, texture detiling and painting remain upcoming work. + +The unchanged four-case material-field Refactor passes. The next complete runtime +selection covers the field-cache integration with existing streaming, sculpting, +replacement/failure and persistence cases. Complete-fixture wrapper logs now use +unique timestamps so later runs retain earlier native evidence; the 190-case log +was archived before changing that ignored wrapper. + +The complete source/material-field fixture passes 194 tests in 23 suites (62.837 +seconds). The native timestamped log is +`.build-tools/contribution/terrain-complete-20261105T014217229046Z.log`. +The next rendering prerequisite is actual texture-array allocation: existing +backends recognize the array texture kind but allocate one layer. Pinned native +Tint verifies its hash and fresh SPIR-V-to-WGSL smoke using the existing original +tool cache; no third-party source or engine dependency was changed. + +Native WebGPU array Red runs on NVIDIA GeForce RTX 4070 (D3D12 driver +32.0.16.1088) and observes one allocated slice for a requested 32-slice descriptor. +The safe capacity assertion precedes out-of-range uploads. TextureDescriptor now +exposes arrayLength; Metal and WebGPU allocate that count for 2D arrays. WebGPU +uses explicit array views even for one slice, and the browser binding layout uses +reflected texture dimensions. The unchanged GPU test passes Green in 1.782 seconds: +production ShaderCompiler/fullscreen draw/readback samples distinct first/last +slice colors in a 32-slice array and the single-slice array correctly. This is +native WebGPU pixel evidence, not Metal or browser execution. The decoder guide +now includes a low-level texture-array initialization example. Independent review +found no concrete regression; further material sampling/detiling remains open. + +The texture-array GPU Refactor passes in 2.154 seconds. The next actual source- +material pixel fixture initially omitted Camera3D's render-graph/visibility bundle +and produced an empty frame: its geometry-coverage control failed. Although the +runner accepted an assertion failure, this is fixture prerequisite evidence, not +the intended material Red. The camera now uses the production Camera3D bundle, +then restores its orthographic projection; the coverage control and material +assertions are unchanged. Rerun Red before implementing material rendering. + +That retry still failed coverage because the fixture's Euler helper oriented the +patch around a different axis, leaving it edge-on. The fixture now uses an explicit +X-axis quaternion and requires its transformed up vector to face the camera. +Neither empty-frame attempt counts as intended product Red; no unrelated math +implementation is changed. Retain both logs and rerun with the coverage control. + +The next full-frame attempt exposes a real scheduler integration defect before +pixel assertions: TerrainPhysicsReadinessSystem declares a required ordering edge +to Physics3DSyncSystem even when Physics3DPlugin is absent. Prior focused terrain +tests drove preUpdate rather than the fixed pipeline. Keep physics optional; add +an explicit optional ordering relationship without weakening existing required +dependencies, and test the model-only fixed phases before resuming material Red. +The GPU scheduler-assertion log is infrastructure evidence for the material slice. + + +Optional physics scheduling now uses explicit `afterIfPresent`, preserving mandatory +missing-target diagnostics. Model-only fixed phase Green/Refactor passed. Independent +review exposed stale graph edges after optional target removal; new real execution +coverage reproduced counters 2 instead of 3 in both single/multi executors. Clearing +edges before relinking passes unchanged lifecycle Green/Refactor (0.003s). Read-only +independent review found no further scheduling defects. Scripting exhaustive consumers +preserve optional dependencies; owning suites remain to run. Native texture arrays +passed GPU Green/Refactor; complete terrain runtime previously passed 194 tests/23 +suites in 62.837s before these scheduler/renderer changes. No whole-feature completion +or cross-platform renderer validation is claimed. + + +Actual terrain material Red now passes geometry coverage and fails only the three +expected tint/blend assertions (7.216s). The first renderer Green exposed a binding50 +collision with existing distance-field constants; independent review identified it. +Terrain constants now use unused binding51. The unchanged full-frame GPU test passes +in 3.233s on the RTX4070: red/blue height layers and continuous purple tile-border +blend. This first slice shades layer tints and PBR scalars from immutable per-tile +RGBA8 slot/weight fields, built inside tile publication budgets and retained across +LOD changes. Texture-map arrays, detiling, projection and broader SDF-lit pixels are +still open. Review also found color-only update and palette-removal lifecycle gaps; +add behavioral regressions and fix them after this slice's Refactor. + + +The first tint-field GPU Refactor also passes. New source lifecycle Red reproduces +three expected assertions: stale color, retained terrain bindings after palette +removal, and retained old material identity. Updating existing tile factors on +color edits and explicitly selecting the plain fallback for a palette-free new +manifest passes unchanged Green (0.445s); mesh/field identity controls pass too. +The user article now describes actual tint/PBR-scalar behavior and explicitly marks +texture-map/detiling/projection settings as not rendered yet. + + +Lifecycle Refactor passes (0.381s); independent read-only re-review finds no further +concrete defects in binding51, color propagation, plain reload or LOD field reuse. +Whitespace-only test layout cleanup follows the completed cycle. Additional optional +scripting validator characterization covers absent targets, different schedulers, +registered targets, self-dependency and cycles; it expands coverage of the earlier +optional-dependency Red rather than claiming a manufactured new Red. + + +Broader native owning command `swift test --scratch-path .build --jobs 8 +--force-resolved-versions --filter AdaECSTests|AdaScriptingTests|Terrain` runs 683 tests +in 116 suites (104.357s): all reported issues are three scripting assertions. +Optional validator characterization and terrain tests pass. Isolated skip-build +rerun on the unchanged binary reproduces the same three issues in 0.151s. +Independent inspection finds InputResourceBindingTests' two assertions already +omit mandatory AdaScriptVMExecution read/write access present in task base; the +save-read result-predicate failure is still being traced. Neither result is counted +as a passing owning suite or as a timing-only failure. Latest lint:169 files clean. +Next GPU test adds real PNG-backed layers alongside the existing tint-only control. + + +PNG-backed material GPU Red passes coverage and the tint-only control, failing only +three expected color assertions for textured=true (10.341s). After implementing +bounded raw-image loading, shared prepared mip palettes and array albedo shading, +unchanged GPU Green passes both cases (7.254s). The first Green attempt only exposed +raw Asset protocol/direct-return API compile prerequisites, not behavioral evidence. +Encoded bytes, conservative decoded staging and derived/new/retained array generations +now share the same remaining source mesh budget before allocation. Prepared palettes +survive camera residency, LOD and save adoption, while immutable GPU arrays reuse a +source UUID. Missing/incompatible maps produce bounded warnings and usable fallbacks. +Normal/ORM inputs are prepared but not sampled by the surface shader yet; blend-height, +detiling and projections remain open. Independent review found warnings are lost by +physics status reconstruction; reproduce and fix that after this Refactor. + + +The actual PNG/tint array GPU Refactor passes. Two documentation files contained +mojibake introduced by locale-default Python reads; a reversible CP1252/UTF-8 +repair restores their original typography. All subsequent scripted text reads use +explicit UTF-8. No production Swift/shader files required this repair. The article's +old single-color limit is updated to match the tested source palette renderer. +Next regression covers a missing-map warning followed by real physics synchronization. + + +Missing-map/real-physics Red reproduces exactly the lost-warnings assertion (0.819s), +with ready source/collision controls passing. Forwarding warnings through readiness +status reconstruction passes unchanged Green (0.371s). Independent review also +finds a separate P1: the 8192-entry WebGPU bind-group cache strongly retains old GPU +array/field owners across palette generations, beyond the source lifetime budget. +Reproduce actual GPU allocation release with shadows/contact disabled, then prevent +transient terrain field bindings from entering that long-lived cache. Normal/ORM +surface shading and incremental texture upload scheduling remain open. + + +Warning preservation Refactor passes (0.306s). The new actual WebGPU allocation +lifetime Red reproduces both expected retained objects after three palette reloads, +source removal, eight clean frames and completed readback (4.193s). Render coverage, +reload adoption and source-state cleanup controls pass. Derived terrain field/array +textures now explicitly opt out of long-lived bind-group ownership, preserving +ordinary static-texture caching and avoiding a count-only cache silently exceeding +source generation budgets. This does not recreate meshes/materials on idle frames; +terrain bind groups use the renderer's existing uncached encoding path. + + +The unchanged real WebGPU allocation-lifetime test now passes Green (3.481s): both +obsolete albedo-array and canonical-field GPU owners release after reload/removal +and completed GPU work. Ordinary texture caching retains its prior policy; only +source-budgeted derived terrain textures disable persistent cache ownership. + + +GPU allocation lifetime Refactor passes in 2.689s. Next actual source pixel slice +selects normal/roughness/metallic debug targets with real PNG normal and ORM maps, +including non-unit layer factors, to establish map consumption in shared PBR. + + +Normal/ORM GPU Red passes coverage and fails exactly normal, roughness and metallic +assertions (6.721s). Shared normal and ORM arrays now shade the strongest four +contributions with explicit gradients; tangent normals use the existing PBR +cotangent frame, and linear G/B channels multiply layer factors. Default ORM is +white so absent maps preserve authored factors. R feeds the shared PBR surface AO +path. Unchanged three-case GPU Green passes in 8.092s. AO-lit/SDF-lit pixel coverage +is still open, as are detiling, triplanar projection, blend-height and upload pacing. + + +Normal/ORM Refactor passes (7.223s); independent read-only review finds no additional +concrete shading/binding/lifetime defects in this slice. Common GPU scene setup is +now shared before adding AO coverage; existing assertions and production source +paths remain unchanged. The AO test characterizes the R-channel path already +implemented by the preceding normal/ORM Red/Green, rather than fabricating a new Red. + + +Shared-fixture material GPU suite passes 7 tests/3 suites (17.696s), including actual +AO-dependent indirect-light pixels after replacing only ORM.R. Add non-debug PBR +coverage with an enabled empty SDF field to verify independent terrain/SDF uniform +bindings; this characterizes the prior binding51 fix rather than manufacturing Red. + + +Current full material GPU suite passes 8 tests/3 suites (16.345s), including enabled +empty-SDF/non-debug PBR compatibility. Root CPU/ECS runtime regression passes 381 +tests/51 suites (58.563s) on the unchanged compiled binary. User-doc offline catalog +checks pass 2 tests; latest scoped lint171 files and diff whitespace checks pass. +Full scripting owning run retains its three separately recorded baseline issues. +Next TDD slice adds persisted opt-in stochastic texture controls with full UInt64 +seeds, flips/rotations/offsets and bounded cell/macro settings; shader use follows. + + +Variation metadata Red reproduces 16 assertions (lost full seeds/settings across +input and both save modes, plus invalid ranges accepted). Unchanged two-test Green +passes in 0.039s after adding disabled-default Codable settings and layer validation. +Read-only independent review finds no schema/compatibility defect. Refactor removes +an unused import. The frozen tests retain two NSNumber style violations until this +cycle completes; convert to exact Swift UInt64 values afterward and revalidate +without changing expected values or claiming new behavioral Red. + + +Variation metadata Refactor passes (0.028s). Tests now use exact Swift UInt64 +values instead of Objective-C NSNumber wrappers for style, retaining all expected +values. The next GPU Red includes these native-save tests as controls, plus an +asymmetric repeating map: enabled variation must break the grid, remain invariant +under translating owner and camera together, and reorient a constant tangent normal. + + +Actual variation GPU Red reproduces two enabled-case failures (periodic error zero +and unchanged tangent normals); disabled, local-translation and persisted-seed +controls pass (7.374s). Smooth four-neighbor affine randomization now transforms +albedo/normal/ORM UVs and explicit gradients together; normals return through the +transpose of the same orthogonal transform. Stable layer IDs and both seed halves +feed the hash, independent of chunk/camera IDs. Low-frequency seeded macro color +variation uses local coordinates. Unchanged GPU/native-save Green passes 3 tests/ +3 suites in 5.955s. Review finds a separate accepted-extreme-scale arithmetic hole +(subnormal macro/layer scales can yield non-finite shader coordinates); reproduce +and bound/filter that after this Refactor. It is not treated as completed safety. + + +Stochastic variation Refactor passes 3 tests/3 suites in 4.748s. Next safety Red +uses accepted subnormal macro/layer scales on real GPU output. Macro sampling below +representable resolution should filter to neutral modulation, including zero +strength; unresolved layer UVs should use the coarsest prepared mip, not NaN/black. + + +Extreme-scale Red reproduces macro modulation and layer-color errors with accepted +subnormal scales; the zero-strength control passes (10.234s). Double coordinate +preflight and finite shader fallback now pass all three unchanged cases (11.445s). +Unresolved layer UVs sample actual last mip levels; unresolved macro modulation +uses its neutral filtered value. No non-finite UV/gradient is passed into sampling +or floor/mod/hash conversion for these admitted height tiles. Existing normal +scale paths retain their prior transforms and sampling. + + +Extreme-scale Refactor passes. Independent re-review confirms the tile-bound and +cell-scale guards, and identifies another distinct preflight hole: reciprocal +sample spacing can overflow the field-grid UBO. The next direct production-upload +regression reads actual uniform bytes and requires finite grid values or a clear +typed unsupported-spacing error, with ordinary spacing as a control. + + +Grid-UBO Red reads actual upload bytes and finds non-finite grid addressing for +spacing1e-40; spacing1 remains a control (0.259s). An earlier attempt hit a Swift +Testing key-path/rethrows macro compile issue, fixed in the fixture before valid +Red. Shared reciprocal-spacing preflight now runs before material preparation and +at the actual upload boundary; unsupported spacing reports a typed diagnostic. +Unchanged two-case Green passes in 0.188s. Refactor also checks positive/finite +spacing in the helper and clarifies the local grid-scale name. + + +Actual grid-UBO Refactor passes (0.143s). Independent read-only re-review finds no +further concrete arithmetic issue in the canonical heightfield material path. +Run the combined native material/texture GPU profile after all variation/safety +changes, then refresh owning CPU terrain/ECS validation before the next capability. + + +Combined real native WebGPU material/texture profile passes 13 tests/4 suites in +31.911s after all stochastic and finite-coordinate fixes. It includes32/single-slice +array allocation+pixels, old palette GPU release, albedo/normal/ORM/AO, empty-SDF +non-debug compatibility, variation/motion/normal controls, extreme scales and native +manifest settings. Updated scoped SwiftLint passes172 files; documentation catalog +checks pass2 tests. Refresh CPU terrain/ECS on this compiled snapshot next. + + +Current CPU/ECS snapshot passes386tests/51suites in61.602s after stochastic and +finite-coordinate fixes. Next GPU Red uses a slope4cliff and a one-axis stripe map: +triplanar X-axis projection should remain constant along surface X while planar +mapping repeats. Both modes must preserve the actual geometric normal with a flat +normal map, preventing an axis-biased/faceted normal blend. + + +First triplanar Green fixes projection but fails the flat-normal blue channel. +Inspection finds pre-existing Transform3D.transpose reconstructs its original rows +and returns the input matrix. Terrain's new transform cache now explicitly builds +rows from inverse columns, obtaining the required inverse transpose without changing +unrelated Math APIs. Keep the unchanged actual scene normal assertion and rerun +Green. Review separately identifies varying-normal interpolation under nonuniform +scale; add that regression after this basic projection cycle completes. + + +The corrected explicit inverse-transpose makes unchanged basic triplanar GPU Green +pass (4.936s): projected cliff stripes and flat-map geometric normal controls. +Terrain reuses its unused tangent varying slot, and a pose-keyed immutable binding52 +normal matrix avoids chart-dependent transforms under owner scale. Independent +review still requires curved/ridged varying normals under nonuniform scale: applying +M after interpolation differs from the vertex-normalized geometric baseline. Add +that real GPU regression after this basic Refactor before treating triplanar normals +as complete. No whole-feature or full projection-transform coverage is claimed. + + +Basic triplanar Refactor passes4.791s. Next real GPU regression compares flat maps +against existing planar geometric-normal interpolation on a ridge with ownerScale +(1,4,1), for pure triplanar and mixed planar/triplanar layers. This targets the +reviewed interpolation-order defect rather than the already-tested planar slope. + + +Scaled-ridge Red reproduces2normal RGB mismatches, pure and mixed projection modes +(8.814s). Anchoring Input.ViewNormal and transforming only the local normal-map +perturbation passes unchanged Green9.568s. This preserves the vertex-normalized +interpolation convention under nonuniform scale while avoiding chart selection. +Keep non-flat map/normal-dominance crossover and reflected-pose coverage pending. + + +Scaled-ridge Refactor passes8.552s; independent re-review finds no further concrete +issue in the zero-perturbation baseline fix. Added characterization for a non-flat +map with near-tied3axis normals under scale(2,3,4), followed by reflected X scale. +This exercises the already-implemented projection-independent matrix path without +manufacturing Red. Run all material/texture GPU tests after the expanded coverage. + + +Height-map GPU Red reproduces absent height influence and missing-map warnings +(2issues/10.105s); unchanged Green9.010s and Refactor pass. Decode the fourth input +family under existing encoded/decoded budgets, pack linear height into ORM alpha, +and enable only successfully decoded IDs. Independent review catches discontinuity +in the initial maximum-score threshold formula as a third layer fades. Replace it +with strictly positive bounded multiplicative bias exp2(8*(height-.5)); missing maps +supply exact neutral .5. Equal heights preserve ratios; zero-weight influence tends0. +The three-layer regression initially compared sRGB debug pixels with linear weights. +Its post-fix failures therefore are not valid behavioral evidence. Correct the +fixture color-space conversion and characterize the fixed path without claiming +unchanged-test Green for that cycle; retain both original logs for review. + +Corrected linear-color three-layer regression and original two-layer height/missing +map tests pass together:2tests/2suites14.469s. The independent reviewer confirms +the bounded bias's positive-denominator and continuous zero-weight properties. +Shared GPU fixture is now separate; staging includes both new files. Refresh the +complete texture/material GPU profile before continuing surface-paint persistence. + + +Complete texture/material GPU profile passes18tests/4suites71.512s, including +heightblend, missing maps, detiling, triplanar transforms and GPU lifetime. +Native surface/mask slice: initial test prerequisites exposed that AdaAssets has no +SwiftPM product; use the actual root owning target/private worktree scratch. Valid +ADATRN03 is rejected before production changes. Explicit Swift Testing requirements +record2behavioral Red issues; unchanged Green passes2tests. Add sparse64-byte +surface and8-byte mask sections with bounded table/record validation and exact bits. +Old height/placement-only encodings remain unchanged. Independent review identifies +sliced-Data indexing before application; reconstruct legacy payloads from zero-based +reader bytes. Add typed mixed-section/sliced-Data characterization after Green; +this coverage exercises already-implemented prevention, not a fabricated Red. +Runtime storage/cache/edit/material integration remains pending. + +Surface asset Refactor passes4tests/.017s, including typed mixed sections and +nonzero-index Data. Cache admission Red exposes9expected issues across paint, +mask and combined payloads; legacy Codable control passes. Charge actual decoded +MemoryLayout strides under a separate32MiB default budget, reject before counter +publication, expose statistics and release charges on eviction. Unchanged Green +passes2tests/.138s. Independent cache arithmetic/compatibility review finds no +further concrete issues. Continue unchanged Refactor before storage integration. + + +Cache Refactor passes2tests/.082s. Canonical storage Red7issues/.018s reproduces +lost surface import/export, surface-only invalidation and shared-edge pruning. +COW canonical64cell sparse pages, separately bounded conservative hash/COW charges, +resource export and residency revisions pass unchanged Green3tests/.019s and +Refactor3tests/.017s. Independent review catches P1: pruning used physical-only +coverage and deleted hidden normal-only neighbors. Exact runtime residency regression +Red1issue/.004s reproduces deletion; both callbacks now include normal coverage, +matching existing height-page pruning. Unchanged Green1test/.003s passes. Retain +hidden draws and unchanged row generations; finish Refactor and re-review before +feeding native overrides/masks into canonical material resolution. + + +Normal-only pruning Refactor passes; independent re-review clears the P1. +Native material runtime Red5issues/.576s reproduces ignored masks/paint and silently +accepted missing active layer IDs. Immutable canonical surface lookup plus a throwing +resolver overload merges normalized automatic4 and manual4 contributions in fixed +SIMD storage, prunes deterministically and retains the original nonthrowing resolver. +Actual mask lookup defaults to0. Unchanged Green2tests/.280s passes full/partial +paint, half-weight mask, identical shared-border rows and missing-layer rejection. +Independent review finds no further concrete issues; no per-sample collection +allocation was introduced. Finish Refactor, then refresh broader owning CPU tests. + + +CPU material Refactor passes2tests/.325s. Supplemental negative/grid-limit material +addressing and custom storage-budget/snapshot checks pass in the full root terrain +filter:227tests/32suites63.683s. This is focused terrain ownership coverage, not a +claim that all root tests or the known unrelated scripting failures passed. +Next actual GPU regression adds native layer paint control plus tint/roughness +scenarios. The shared fixture writes real surface records into native assets; +Albedo assertions convert display sRGB to linear, roughness debug stays scalar. + + +Actual native layer/tint/roughness GPU Red reproduces2ignored-channel issues while +layer and outside controls pass(7.862s). Canonical premixed tint/premultiplied +roughness plus a separate mask share an optionalRGBA8vertical atlas at existing +main-pass binding17. Unpainted tiles use one neutral1x2 fallback. Upload disables +binding caching for derived atlases; preflight reserves another64bytes per canonical +vertex only on affected tiles before allocation. Unchanged Green3cases8.042s and +Refactor pass. Independent review finds P2: camera/LOD budget work walks all +unchanged layer-only paint samples despite retained canonical fields. +Instrument actual old preflight-loop visits in SourceBuild/State; regression with +16384native records observes36864visits perLOD change(Red2issues/.777s), while +retained IDs/evaluation-zero and exact edge/corner/negative/partial controls pass. +Cache optional64UInt64 coverage bits per immutable cell, rebuild after sample +pruning, retain with COW UUIDs; increase conservativecellcharge to1024. No authored +record iteration remains in coverage queries. Unchanged Green2tests/.679s passes; +independent re-review clears P2. Finish Refactor then rerun full material/texture GPU +and terrain CPU profiles. Do not infer whole-feature completion from these slices. + + +Coverage-cache Refactor completes; full texture/material GPU passes19tests/4suites +70.580s; full root terrain CPU passes230tests/33suites61.101s; lint189clean, +actual offline catalog regenerated/verified27guides, docs2tests pass, diffcheck clean. +Next native paint admission regression exposes4issues/.066s: data-only active paint +IDs bypass palette validation. Cache now validates active references once on admission +against its immutable manifest ID set, after memory guards and before publication; +cached reuse avoids repeated validation. ValidID and earlier-resident controls pass; +unchanged Green completes. Independent review clears this admission slice. +Review also confirms P1 in existing runtime persistence plumbing: new channels are +absent from dirty override capture, payload reconstruction, save baselines/removal, +acknowledgment and streaming reconstruction. Prepare real source save/stream and +boundary-only payload regressions before changing that path; fullfeature remains WIP. + + +Palette admission Refactor passes. Persistence P1 Red reproduces6issues/.791s in +real save/stream/revisit and boundary-only last-height erasure. Carry surface+mask +arrays through override capture, native reconstruction, save payload/baseline, +removal checks and acknowledgment; explicit empty arrays retain erasure semantics. +Charge decoded override records and subtract from the forked cache surface allowance. +Unchanged Green2tests/3cases/.829s passes; independent review clears the P1, requesting +late-ack coverage. Add actual immutable disk snapshot plus subsequent COW channel +change characterization (paint and mask separately), assert later values stay dirty +and next snapshot retains them while disk contains captured old values. This tests +the already-fixed acknowledgment branch without inventing a separate Red. + +### Editor authoring safe-point correction (2026-11-06) + +The first authoring Green attempt compiled and saved the private-version test, then crashed during cancellation. Symbolizing the native stack identified `QueryState.updateArchetypes` iterating while the asynchronous editor helper directly mutated ECS structure. The helper now requires the owning `AppWorlds`; every grouped ECS snapshot/read/write uses its existing `withWorldAccess` boundary, while filesystem and worker waits leave frames running. Tests now run each explicit `.preUpdate` within `withExclusiveWorldAccess` to exercise that same host contract. Their behavior assertions remain unchanged, but these test bytes differ from the earlier Red: subsequent passes are retained production-path coverage, **not an unchanged Green for that earlier cycle**. No generic ECS synchronization rewrite is included. diff --git a/Documentation/Plans/2026-11-06-MeshTerrainComposition.md b/Documentation/Plans/2026-11-06-MeshTerrainComposition.md new file mode 100644 index 000000000..328695975 --- /dev/null +++ b/Documentation/Plans/2026-11-06-MeshTerrainComposition.md @@ -0,0 +1,209 @@ +# Mesh Terrain Composition Implementation + +Status: In progress. Design approved in the Codex conversation on 2026-11-06. +Design: [Terrain composition](../Designs/terrain-mesh-composition.md). +Implementation branch: `codex/mesh-terrain-composition`. +Workspace: `D:/Development/AdaEngine-mesh-terrain`. + +## Baseline and execution rules + +The implementation workspace contains an isolated copy of the original dirty +worktree at HEAD `fc3aabbfd51679884836da1c71343675f9c8624e`. Initial tracked patch, +untracked paths and file hashes are retained under `.build-tools/mesh-terrain`. +Preserve all initial changes. The separate terrain task's native brush optimizations +must be integrated deliberately; the copied baseline does not certify them. + +Use the approved visual direction for the render selector and present concrete +native-control proposals for newly introduced interaction panels. Use focused +Swift Testing Red/Green cycles and production-path fixtures. Record task-owned +paths in the contribution runner as they are introduced. Reuse one task scratch +per validation package; never delete the user's shared `.build`. + +The contributor explicitly requires the independent reviewer only after all +functionality is implemented. Do not launch implementation-slice reviewers. +Run independent review at the integrated-delivery boundary, fix findings, and +repeat affected checks/review. No partial slice closes the feature. + +## Deliverables and acceptance boundaries + +| Stage | Deliverable | Acceptance and checks | State | +| --- | --- | --- | --- | +| 1 | Geometry-kernel feasibility, adversarial fixtures, kernel ADR | True triangle union/subtraction, shared seam, transformed/concave operands, attributes, valid open-patch closure and no leaked caps; measured resource/work limits | In progress | +| 2 | Versioned mesh bases, retained modifier graph, source bindings and compiled products | Old/new and transitive bounds; stale-worker rejection; atomic transform/draw-mask/surface publication; persistence and Undo/cancel | In progress | +| 3 | Surface channels, contact materials, local detail and paint attachment | Normal/UV/provenance transfer, floor/ceiling separation, manual precedence, remesh/LOD seams and error behavior | In progress | +| 4 | Actual-surface population with manual ownership | Stable candidates, per-sheet spacing, old-footprint refill, pinned/suppressed/manual preservation, bounded local regeneration | In progress | +| 5 | Portable Wireframe and native tools | Real current triangles/meshlet selections, deformation/instances, depth occlusion, Shaded restoration, English/Russian controls and mounted UI evidence | In progress | +| 6 | Packaged example, guides, export, performance and final independent review | Every design goal G1-G10, all applicable validation, final source/asset/runtime evidence; unresolved platform checks remain blocked | In progress | + +## Documentation and example delivery + +Update `Editor/Documentation/Terrain.md`, navigation in +`Editor/Documentation/EditorGuide.md` and `Editor/README.md`. Extend +`Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md` with runtime backends, +queries, compiled products and limitations; maintain DocC Topics navigation. +Regenerate the offline documentation catalog and run its `--check`. + +Provide a reproducible native project/scene and source join assets containing a +tree/root operand, rock union, conform-only example, painted cave floor/ceiling, +generated instances, pins, suppressions and manual placements. Exercise movement, +Undo/Redo, cancellation, save/reopen, relocation, Play collision and actual exported +runtime consumption. Do not substitute source-text assertions for these paths. + +## Validation + +Record exact commands and Red/Green outcomes per slice in the contribution task. +Start with the narrow geometry/asset/surface/population suites, then owning and +downstream targets. Native editor tests run in the Editor package using its real +dependencies. Rendering acceptance uses real WebGPU pixels on Windows and Metal +on macOS, plus shader/Tint validation. Missing host/device/toolchain evidence is +blocked, not passed. Run applicable Linux/mobile/WASM compatibility paths and +served exported content. Profile the declared reference fixture, including p95 +input/preview latency, main-thread stalls, peak bytes, changed patches/cells, +cache reuse, uploads and draw/GPU costs. + +Run `check_contribution.py` at stage transitions. Final review receives the exact +task-owned diff, approved design, this plan, runtime screenshots/fixtures, +performance results and current validation reports. The original workspace is +updated only with task-owned deltas after overlap checks. + +## Implementation checkpoint (2026-11-06, Windows) + +The native feasibility module and retained evaluator now have 16 passing focused +geometry/index/key tests. Manifold is conditionally linked only on desktop +platforms; other players retain compiled-data types and reject unsupported authoring. +Strict-memory-safety geometry validation passes without new unsafe-use warnings. +Unused temporary closure vertices are compacted along with their faces. + +The version-five manifest and bounded `.atmesh` format round-trip through a real +asset file. Compiled products carry canonical SHA-256 input keys and stale products +are rejected. A bounded authoring-session actor groups multiple drag previews into +one Undo step and restores committed geometry on cancel; its focused test passes. +The existing terrain job/publication path loads compiled mesh sections. Its runtime +fixture verifies real mesh buffers, retention after a failed reload and cleanup. +A second runtime fixture verifies a moved join, immutable old queries, restored old +ground, new surface queries and source-pose publication together. + +Portable indexed-line drawing is connected to PBR vertex processing, including CPU +meshlet-cut indices. Windows/WebGPU's `portableWireframePixels` test passes for +Wireframe, Shaded + Wireframe, bounded edge-cache allocation and Shaded restoration. +Rendered images under `.build-tools/mesh-terrain/gpu-images` were inspected. These +sphere/ground frames do not establish deformation, selected-cut, terrain interaction, +Metal, packaged Studio UI or complete feature acceptance. The viewport selector is +implemented using the existing native enum field and both localization catalogs; +its mounted interface is not yet verified. + +Retained evidence is under contribution task `mesh-terrain-composition` and +`.build-tools/mesh-terrain`. All six deliverables above remain open: source binding +and draw masks, full editor interaction/persistence, channels and materials, +retained surface-cell population, detail/seam constraints, package/export/performance, +platform validation and final independent review still require integration. + +### Retained bindings checkpoint (2026-11-07) + +The combined asset/runtime validation package passed its six original focused +checks after the optional authoring trait and draw-mask refactor. Geometry now has +18 passing tests. A reflected source pose retains signed scale; matrix poses that +cannot be represented as TRS are rejected before publication. A regression fixture +exposed Float-collapsed Boolean faces. The C ABI now reimports such closed results +through Manifold's Float-tolerance topology cleanup, retaining provenance rather +than dropping individual faces. The new regression and existing seam tests pass. + +Serialized source identities, bindings and static part selections are connected to +a world-boundary binding system. A real hierarchy fixture captures only the trunk +part, keeps other parts rendered, moves a reflected root without destructive +accumulation, and restores the ground when disabled. Static source collision now +transfers with the accepted terrain generation. The same fixture uses native +physics to verify old-ground contact after moving, fresh authored-body restoration +on detach, and restoration when the terrain owner is removed. Native body handles +are never retained in the runtime backup. + +These tests cover runtime bindings, not Studio controls or complete feature +acceptance. Source/binding deletion, retargeting and other lifecycle interactions, +editor transactions, channels/materials, retained population cells and all other +open stage requirements remain to be completed. No independent reviewer has been +invoked. + +### Mesh coverage and palette checkpoint (2026-11-07) + +An operand that extends outside the union of ground section clipping domains +is rejected before evaluation. The last accepted patch and its queries stay +resident. Existing joins across adjoining sections still pass. Heightfield +and volume saves now reject a Mesh Terrain manifest, including when the caller +supplies an outdated Heightfield baseline for the same destination. + +Mesh Terrain now publishes palette layers through the renderer's PBR material +arrays. The joined source can specify a layer, subtractive faces use the +interior layer by default, and an ordered, bounded transition follows connected +ground edges. Independent triangle fields keep material lookups separate from +query geometry. A Windows runtime fixture checks the actual uploaded layer +and weight data. This does not yet cover imported material transfer, durable +paint, mip generation, native controls, or rendered GPU evidence for this path. + +### Native tree and retained population checkpoint (2026-11-07) + +The Windows viewport joined the opaque trunk of an imported tree and kept the +cutout canopy on the original object. A Move gesture opened the new footprint +and regenerated grass and stones at the old footprint. One Undo restored the +pose, joined geometry and both prototype populations. Of the original +placements, 40 grass and 30 stone identities survived the move. + +CPU population now owns half-open three-dimensional cells. Retained cells +compare the local geometry, clearance dependencies and resolved coverage in a +spacing halo. The test moves a root inside one rebuilt section and compares the +accepted incremental population with a cold evaluation, including unchanged far +placements. The native example and complete packaging checks remain open. + +Original opaque part draws now use retained source-face provenance. Tests +exercise source PBR factors and a real self-contained GLB with three embedded +maps, UV1 selectors and vertex color through live join and compiled reopen. +The canonical joined surface remains authoritative for collision and queries. +This checkpoint does not establish a continuous blend from imported materials +under partially painted source faces, cross-platform rendering or export. + +### Workshop and interaction checkpoint (2026-11-07) + +The [Mesh Terrain Workshop](../../Editor/Samples/MeshTerrainWorkshop/README.md) exercises +joined rock collision, a cave with distinct floor and ceiling paint, source +materials, and grass and stone populations. Studio Play on Windows displayed +the ball landing on the joined rock. The fixed-step integration test verifies +that simulation waits for the triangle collider before the ball starts falling. + +Studio exposes Join, Subtract, Conform, and Detach. Detach restores independent +source drawing and recompiles the ground as one Undo/Redo entry. A spatial +transform submitted to the viewport uses the same transaction as a mouse drag. +Inspector coordinates commit on Enter. The Windows Workshop copy saved the new +coordinate and terrain manifest together, and Edit > Undo restored both. The +focused tests cover accepted and out-of-bounds coordinates. Native Dawn passed +the Wireframe, occlusion, and 32-slice texture array pixel checks. Compact labels +and the remaining platform states still require visual verification. + +The standalone Windows SDK produced a debug WebAssembly player. With the Swift +6.3.2 workaround scoped to AdaScene debug WASI builds, the browser rendered +Workshop terrain, tree, grass and stones. The ball stopped on the joined rock. +The same export rendered both Wireframe and Shaded + Wireframe after setting +the camera's diagnostic mode in private copies of the packaged scene. The +exporter now generates the three shader variants with pinned Tint, and a focused +reflection test verifies the texture dimensions consumed by the WebGPU layout. +The optimized export also rendered the Workshop and its overlay with the +standard compiler pipeline. Remaining platform checks are open. + +A group transform now stages one candidate for joined descendants sharing a +terrain target. The group transform, terrain, and retained source ownership +publish at one world boundary. The focused fixture covers accepted and rejected +moves. The editor regression covers two sources and retained Undo/Redo. The +Windows Inspector also saved a group move and restored its prior manifest via +Undo. A group spanning multiple terrain targets remains outside this slice. + +### Meshlet terrain LOD MVP + +Prepare one coarser hierarchy level from the accepted render sections in the +terrain worker. Reuse AdaEngine's screen-error cut and index ranges in shaded +and edge rendering. Preserve section and material boundaries by simplifying +complete interior triangle fans only. Keep canonical queries and collision +at full resolution. The focused runtime fixture exercises neighboring sections +with palette materials and compares every shared edge at mixed levels. +The Windows WebGPU suite compares the adaptive terrain's shaded and wireframe +pixels with meshes built from the selected coarse indices. The mixed-section +runtime test, the existing meshlet LOD regression, and the wireframe occlusion +check passed. The MVP uses one coarser level and preserves original triangles +where the surface or material field cannot be simplified safely. diff --git a/Documentation/Research/2026-11-06-UnrealMeshTerrain.md b/Documentation/Research/2026-11-06-UnrealMeshTerrain.md new file mode 100644 index 000000000..f696ea8ff --- /dev/null +++ b/Documentation/Research/2026-11-06-UnrealMeshTerrain.md @@ -0,0 +1,406 @@ +# Unreal Engine Mesh Terrain: Technical Research + +Research date: 2026-11-06. Target: public UE 5.8 documentation and APIs. +Companion proposal: [Ada terrain composition design](../Designs/terrain-mesh-composition.md). +Status: research, not an implementation or a claim of feature parity. + +## 1. Findings that change the Ada proposal + +1. The public Unreal APIs describe editable **triangle meshes**, spatially bounded + modifier operations, intermediate caches, and separate interactive output. + Ada's existing heightfield plus scalar-density bricks is a different geometry + strategy. Similar-looking caves do not make the two architectures equivalent. + [Builder](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FMeshBuilder), + [bounded mesh view](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FMeshView?lang=en-US). +2. A natural tree/ground intersection can involve geometric joining, terrain + projection, material blending, and procedural population. Those mechanisms + must be identified separately; a demonstration does not prove they all use + a single automatic mesh-welding operation. +3. Epic exposes explicit cut-edge welding in its Boolean modifier. It also has + different boundary-stitching and mesh-projection tools. There is no evidence + here that Mesh Terrain is implemented as smooth-min SDF fusion. + [Boolean API](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UBooleanModifier?lang=en-US). +4. Mesh Terrain's attribute-channel textures, the material's own texture sets, + and Runtime Virtual Textures are three different resources. Confusing them + hides requirements for caves, painting, and material transitions. +5. Population must sample actual geometry and attributes at an explicit stage. + Ada's current heightfield anchors cannot represent a floor, ledge, wall, and + ceiling independently at the same horizontal coordinates. +6. Interactive authoring and compiled gameplay content are separate concerns. + PCG runtime generation does not establish runtime editing support for every + Mesh Terrain modifier. Missing evidence must not become a product promise. + +## 2. Method, confidence, and limits + +Sources are primary: Epic documentation, generated API references, dated replies +by developers, official issue records, and original tutorial creators describing +their own work. Search results and third-party summaries were discovery leads, +not evidence of engine internals. + +The full licensed Unreal source was not inspected, and Unreal was not launched. +Public declarations establish data types, documented responsibilities, and exposed +settings; they do not reveal complete algorithms, defaults, or synchronization. +Video metadata and published chapter descriptions were inspected where available; +the full recordings were not watched or transcribed. The introduction knowledge +base returned no readable article body. It remains a reading recommendation, not +a technical source silently treated as fully read. + +Classification used throughout: + +| Label | Meaning | +| --- | --- | +| Documented | Directly supported by a readable primary page or public API description | +| Dated statement | A developer's statement at a particular date; not a permanent guarantee | +| Inference | A conclusion from several public facts; exact implementation remains unknown | +| Ada proposal | Our own contract or architectural recommendation | +| Unverified | Requires a source checkout, playback, experiment, or platform execution | + +Documentation can change after this date. All quoted status descriptions are +as-of this research. No roadmap date is used as a delivery commitment. + +## 3. Reading and viewing guide + +### 3.1 Start with the system and practical workflow + +| Resource | Why it matters | Evidence inspected | +| --- | --- | --- | +| [Mesh Terrain overview](https://dev.epicgames.com/documentation/unreal-engine/mesh-terrain-in-unreal-engine) | Vocabulary and feature boundary | Article | +| [Crafting Mesh Terrain](https://dev.epicgames.com/documentation/unreal-engine/crafting-mesh-terrain-in-unreal-engine) | Create/convert, section editing, sculpt/paint, topology and Boolean workflows | Article | +| [Accessing Mesh Terrain](https://dev.epicgames.com/documentation/unreal-engine/accessing-mesh-terrain-in-unreal-engine) | Plugins, mode entry, modifier outliner, evaluation visibility | Article | +| [Mesh Partition Definition](https://dev.epicgames.com/documentation/unreal-engine/mesh-partition-definition-in-unreal-engine) | Reusable configuration and target outputs | Article | +| [Introduction to Mesh Terrain](https://dev.epicgames.com/community/learning/knowledge-base/nK7J/unreal-engine-introduction-to-mesh-terrain) | Original introduction by the feature team | Official link verified; body inaccessible | +| [A Practical Look at Mesh Terrain, Inside Unreal](https://www.youtube.com/watch?v=XlbWtoIk-Zc) | Official extended practical session | Metadata/chapter information; not full playback | +| [Epic's August learning roundup](https://www.unrealengine.com/learning/augusts-epic-learning-content-networked-physics-dynamic-audio-and-more) | Confirms the official session and its subject | Article | + +The Inside Unreal chapter guide points to Landscape comparison around 08:03, +the modifier outliner around 19:02, texture/channel work around 21:33, remeshing +around 31:47, Booleans around 36:00, PCG around 41:02 and 48:44, runtime/partition +discussion around 1:00:32, collision/UVs/Nanite around 1:06:38, and performance/ +caching around 1:18:27. Treat these as navigation aids, not claims extracted +from a fully reviewed transcript. + +### 3.2 Material tutorials and original creator resources + +| Resource | Relevant entry point | Limits | +| --- | --- | --- | +| [Unreal Sensei: Mesh Terrain tutorial](https://www.youtube.com/watch?v=Lhj2LutYNjA) | 23:36 master material; 39:12 painted layers; 52:47 mesh Boolean | Creator's tutorial description/chapters inspected; video not fully reviewed | +| [Sensei Terrain Material](https://www.unrealsensei.com/asset/senseiterrainmaterial) | Creator's resource page for the demonstrated material | No asset downloaded; shader graph and redistribution license not audited | +| [UE 5.8: How To Use Mesh Terrain, Unreal-X-Tutorials](https://www.youtube.com/watch?v=baDPUn994Hw) | Alternative walkthrough of modifiers and channel painting | Original creator's description; independent of Epic | +| [Mesh Terrain Material](https://dev.epicgames.com/documentation/unreal-engine/mesh-terrain-material-in-unreal-engine) | Official binding between channels and shading | Reference documentation | +| [Texturing Material Functions](https://dev.epicgames.com/documentation/unreal-engine/texturing-material-functions-in-unreal-engine) | Projection and height-biased blending techniques | General material tools, not mandatory Mesh Terrain internals | + +The Sensei tutorial advertises auto texturing, triplanar projection, virtual +texture blending, and Nanite displacement. Its resource page describes automatic +slope-based layers. These are features of that creator's material/workflow; +they should not be reclassified as automatic behavior of every Mesh Terrain. +The supplied chapter list has a foliage timestamp inconsistent with its ordering; +this report intentionally does not reproduce that entry as a verified locator. + +## 4. Representation and build architecture + +### 4.1 Terminology + +Epic distinguishes base sections, editor preview sections, and compiled runtime +sections. A definition asset configures construction. Weight channels are editable +surface attributes which are baked into a 2D texture array for compiled output; +modifiers affect geometry and/or channels. This channel array is not the same as +an array of grass/rock albedo textures. +[Overview](https://dev.epicgames.com/documentation/unreal-engine/mesh-terrain-in-unreal-engine). + +The definition supplies channel schema, material, priority ordering, and separate +preview/build configurations. Platform variants choose transformer pipelines; +preview favors iteration and compiled output favors target rendering. Attribute +texel density is configurable. Only declared runtime channels become compiled +channel textures. +[Definition](https://dev.epicgames.com/documentation/unreal-engine/mesh-partition-definition-in-unreal-engine). + +### 4.2 Triangle data and bounded modifications + +`FDynamicMesh3` stores mutable triangle topology with edge adjacency and attribute +overlays. Many operators assume manifold input. UV/normal seams need not be +represented as a single value per geometric vertex. +[Dynamic mesh API](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/GeometryCore/FDynamicMesh3). + +`FMeshData` exposes triangle base provenance, source UV channels passed through +modifiers, and separately generated channel-rendering UVs. `FMeshView` constrains +what a modifier can access by bounds, components, and requested channels, including +tracking triangles touching the boundary. +[Mesh data](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartition/FMeshData?lang=en-US), +[mesh view](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FMeshView?lang=en-US). + +Inference: a useful editable terrain abstraction includes topology, corner +attributes, provenance, and regional ownership. An array of positions/indices +prepared only for GPU drawing is not sufficient to provide that contract. + +### 4.3 Evaluation, dependencies, and caches + +`UModifierComponent` is documented as editor-only. Its public interface includes +dependency collection, code/cache versioning, priority, background preparation, +and distinct last-applied and last-submitted bounds. `FModifierGroup` holds ordered +descriptors, per-instance data, cache keys, and async-ready groups. +[Modifier](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UModifierComponent?lang=en-US), +[group](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FModifierGroup). + +`FMeshBuilder` asynchronously produces a dynamic mesh and optional spatial tree; +it supports cancellation, local LRU caching, and optional derived-data-cache use. +`FModifierGraphCache` separately accounts for merged bases and cached modifier +views. The editor component exposes queues for changed bounds/modifiers and +distinct preview-build/transform contexts. +[Builder](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FMeshBuilder), +[graph cache](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FModifierGraphCache?lang=en-US), +[editor component](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UMeshPartitionEditorComponent?lang=en-US). + +`FBaseGrowth` explicitly expands base influence for modifier grouping. This matters +when later effects act on geometry created outside the original base bounds. +[Base growth](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FBaseGrowth?lang=en-US). + +Inference: prefix caching, batched regional invalidation, immutable worker inputs, +and stale-result rejection are suitable Ada design choices. The public API does +not disclose every cache boundary, task dependency, or eviction threshold. + +### 4.4 Interaction is its own product + +`AInteractiveSection` is a transient editor region expressly optimized for +interactive response. It contains base meshes, base/preview tasks, active +modifiers, and outdated-build state. A base filter can exclude later modifiers. +That is stronger evidence than assuming an ordinary final build becomes +interactive merely because it runs off-thread. +[Interactive section](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/AInteractiveSection). + +The user workflow requires a compiled-section build for gameplay/cooked output. +Large sections increase the scope of a local rebuild. Local remesh and tessellation +are authoring tools distinct from runtime LOD. +[Crafting](https://dev.epicgames.com/documentation/unreal-engine/crafting-mesh-terrain-in-unreal-engine). + +## 5. What "the meshes grow together" can mean + +| Mechanism | Geometry effect | Expected result | Typical failure if confused | +| --- | --- | --- | --- | +| Append/combine | Adds disconnected topology without intersection resolution | Several surfaces in one container | Interior overlap and visible intersection remain | +| Stitch/weld | Joins compatible boundary topology | Shared seam | Cannot resolve arbitrary intersecting solid interiors | +| Boolean union/subtract | Yes | Resolved local solid boundary | Thin/open foliage is not automatically a valid solid | +| Mesh projection/conform | Usually moves existing surface | Terrain follows a prop's shape | Prop and terrain can remain separate surfaces | +| Material transition/RVT | No | Similar color, roughness and normals at intersection | No new walkable surface or geometric connection | +| Procedural population | Changes placed-instance set | Grass/rocks adapt around the prop | Does not itself join terrain to the prop | + +Stitch in Epic's tooling can join touching boundary edges. Boolean Union removes +overlapping interior surfaces; Subtract produces capped local boundaries; Trim +leaves an open hole. The Boolean API has explicit welding of newly cut edges, +seam-aware simplification settings, winding tests, channel transfer, and separate +operator/inclusion bounds. +[Crafting](https://dev.epicgames.com/documentation/unreal-engine/crafting-mesh-terrain-in-unreal-engine), +[Boolean modes](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UE__MeshPartition__EBooleanOpera-?lang=en-US), +[Boolean settings](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UBooleanModifier?lang=en-US). + +`UMeshProjectModifier` provides directional projection, bounds, blend modes, +intersection smoothing, and channel writes. A tree can therefore appear embedded +through deformation and shading without welding every leaf and branch into the +terrain. This is a possible mechanism, not proof of which operation a particular +unreviewed demo used. +[Projection API](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/UMeshProjectModifier?lang=en-US). + +For Ada, the confirmed user requirement is stronger than color blending: at least +one explicit geometry-join path must produce a shared boundary that Wireframe and +collision can inspect. A closed root/base proxy is a practical participating +operand while the canopy remains independently rendered. + +## 6. Local detail and output rendering + +Remesh settings expose target edge length, channel-controlled density, smoothing, +projection back to the input, UV resampling, and boundary/seam constraints. +The local modifier currently documents an inclusion limitation: affected triangles +must have their vertices inside its influence box. This is a useful edge case for +Ada's own regional-selection tests, not a behavior to copy. +[Remesh base](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/URemeshModifierBase?lang=en-US), +[local remesh](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/URemeshModifier?lang=en-US). + +The static-mesh transformer exposes LODs, skirts, streaming, optional Nanite and +fallback settings. Collision has its own transformer and simplification controls. +Consequently, Mesh Terrain is not synonymous with Nanite, and a coherent visual/ +collision generation need not have identical triangle counts. +[Static output](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FStaticMeshTransformer?lang=en-US), +[collision output](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartitionEditor/FCollisionTransformer?lang=en-US). + +Ada must distinguish authoring edge length, scalar-volume cell size, channel texel +size, material repetition scale, and runtime screen-error LOD. A single generic +"resolution" slider would conceal important costs and failure modes. + +## 7. Material architecture and breakdown + +### 7.1 Four layers of data + +```mermaid +flowchart LR + A[Geometry and topology] --> B[Surface attributes] + B --> C[Compiled channel atlas] + C --> D[Terrain PBR material] + E[Layer color normal ORM textures] --> D + D --> F[Optional shading cache] + B --> G[Population rules] +``` + +This diagram is a conceptual decomposition for comparison, not a recovered Epic +render graph. + +The official material nodes expose the channel resource, samples by name/index, +an inspector, and normalized/unit-space section coordinates. Channel values can +drive material-attribute blending. The page does not specify a default triplanar +shader, exact PBR mixing formula, or lossless imported-material merging. +[Material reference](https://dev.epicgames.com/documentation/unreal-engine/mesh-terrain-material-in-unreal-engine). + +In a July 13 developer reply, Etienne Carrier described one automatically +unwrapped UV channel and UV editing/multiple channels as future work. The current +API separately exposes source-UV storage. These are different evidence levels: +data-structure capacity does not prove every UI workflow supports it. Exact +current import/Boolean UV behavior needs an experiment. +[Dated UV reply](https://forums.unrealengine.com/t/knowledge-base-introduction-to-mesh-terrain/2707493?page=3), +[source/channel UV fields](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/MeshPartition/FMeshData?lang=en-US). + +### 7.2 Why a good material makes the demo look seamless + +Our interpretation separates five effects: + +- Geometry/normal continuity removes the visible crease or hole. +- A contact mask controls where soil changes into bark or rock. +- Material attributes mix in a common coordinate/normal convention. +- Projection handles stretched cliff/root UVs and cut faces. +- Small procedural detail distracts from repetition and reinforces contact. + +For Ada these become explicit settings and testable shader behavior, not a promise +that importing any two materials can combine their arbitrary graph semantics. +World-aligned texturing and height-biased blends are available general techniques; +their coordinate choice affects moving-object texture stability. +[Texturing functions](https://dev.epicgames.com/documentation/unreal-engine/texturing-material-functions-in-unreal-engine). + +### 7.3 Runtime Virtual Texturing: useful, but not geometry + +RVT is a shading cache generated on demand. Writers and readers explicitly +participate. Epic documents an orthographic capture along the volume's negative Z +axis and recommends compatible world-space normal conventions for blending. +[RVT reference](https://dev.epicgames.com/documentation/unreal-engine/runtime-virtual-texturing-in-unreal-engine). + +Mesh Terrain RVT setup requires assets, volumes, and an RVT transformer in both +preview and build pipelines. The quick start illustrates attribute sharing and +compositing with additional scene primitives. +[Mesh Terrain RVT setup](https://dev.epicgames.com/documentation/unreal-engine/runtime-virtual-textures-and-mesh-terrain-in-unreal-engine), +[RVT quick start](https://dev.epicgames.com/documentation/unreal-engine/runtimevirtual-texturing-quick-start-in-unreal-engine?application_version=5.8). + +Inference: a single top-down cache cannot uniquely address every independent +floor/ceiling sheet sharing horizontal coordinates. It can accelerate appropriate +surface shading but should not be Ada's authoritative cave-paint storage. Geometry, +paint and placement must remain correct with the cache disabled or evicted. + +## 8. Procedural filling after moving an object + +Mesh Partition Query retrieves geometry and channels; ToPoint samples the surface. +Write preserves point count and original-position correspondence while allowing +destination positions to change. +Query/write priority boundaries are explicit to avoid feedback. Patch/mesh-projection +instance spawners can also create terrain modifiers. +[Mesh Terrain PCG integration](https://dev.epicgames.com/documentation/unreal-engine/pcg-and-mesh-terrain-in-unreal-engine). + +PCG generation modes provide spatial partitioning, hierarchical coarse-to-fine +execution, and runtime cell generation/cleanup around sources. The generic World +Ray Hit Query instead samples the physics world; it must not be mistaken for +geometry-based surface input. +[Generation modes](https://dev.epicgames.com/documentation/unreal-engine/using-pcg-generation-modes-in-unreal-engine), +[node reference](https://dev.epicgames.com/documentation/unreal-engine/procedural-content-generation-framework-node-reference-in-unreal-engine). + +UE 5.8's PCG release notes describe non-destructive manual edits, exclusions and +restoration. The GPU-processing documentation separately describes GPU-resident +procedural instances with persistence and feature limitations. These support +distinguishing saved authoring intent from optional ephemeral runtime scatter. +[5.8 release notes](https://dev.epicgames.com/documentation/unreal-engine/unreal-engine-5-8-release-notes), +[PCG GPU processing](https://dev.epicgames.com/documentation/unreal-engine/using-pcg-with-gpu-processing-in-unreal-engine). + +Ada proposal: evaluate terrain, then authoritative surface channels, then generated +population, while keeping manual overrides separate. Use old/new object bounds, +spacing halos and deterministic cell ownership. Seeds alone do not preserve point +identity when triangle enumeration changes; identities need spatial/provenance +contracts independent of transient generated actors. + +An official issue records painted foliage becoming invalid when editor terrain +sections are replaced and their identities differ from runtime sections. Another +records missing navigation output from the partition navigation builder. Both +were unresolved when inspected; target-fix fields are not promises. +[Foliage identity issue UE-398404](https://issues.unrealengine.com/issue/UE-398404), +[navigation issue UE-391361](https://issues.unrealengine.com/issue/UE-391361). + +## 9. Ada source comparison + +Repository inspected: `D:/Development/AdaEngine`, HEAD +`fc3aabbfd51679884836da1c71343675f9c8624e`, with substantial unrelated and +in-progress worktree changes. These observations concern the working files on +the research date, not a clean release or runtime certification. + +| Area | Inspected implementation | Consequence | +| --- | --- | --- | +| Base terrain | `Terrain3DSource`, `TerrainHeightStorage` | Heightfield region/tile/LOD infrastructure is reusable | +| Volumes | `TerrainVolumeOperation`, `TerrainVolumeDocumentAsset`, `TerrainVolumeField` | Retained sampled union/subtract with one document cell size; no direct scene-entity binding | +| Extraction | `Math/Geometry/ScalarFieldSurface.swift` | Marching-tetrahedra surface extraction; not a triangle Boolean kernel | +| Operand validation | `ScalarMeshSolid.swift` | Closed/manifold solid validation; no arbitrary open-card union | +| Authoring | `EditorTerrainResourceWriter.swift` | Adds an operation with copied placement; moving the original entity is not a retained dependency | +| Materials | `TerrainMaterialPalette`, `TerrainLayerReference`, `TerrainMaterialResolver` | Stable IDs, bounded palette/rules, planar/triplanar options, top-four contributions | +| GPU material resources | `TerrainMaterialBindings`, `TerrainMaterialTextureArrays` | Existing layer arrays and per-surface ID/weight fields; extend rather than duplicate | +| Placement | `TerrainInstanceBrushKernel`, `TerrainPlacementAnchor` | Samples/reprojects the base heightfield, not arbitrary surface strata | +| Surface queries | `TerrainSurfaceQueries` | Revisioned actual triangle ray/closest queries already available | +| Instance persistence | `TerrainRegionInstance` | Authored rows exist; generated-rule ownership and override semantics need extension | +| Rendering | `DebugView3D`, `Main3DRenderNode`, `WGPURenderCommandEncoder` | Wireframe exists as a diagnostic enum, but WebGPU polygon-fill call is empty | +| Cluster rendering | `Mesh.Part`, `MeshletGeometry`, `MeshletLODGeometry`, ADR-0028 | Existing optional hierarchy is not Nanite or an editable topology kernel | + +These are source observations. Tests were read selectively, but no engine/GPU +tests were run for this research. The active terrain task's fixes and performance +results must be verified in that task before reusing them as acceptance evidence. + +## 10. Architecture options for Ada + +| Strategy | Strengths | Main limitation for this request | +| --- | --- | --- | +| Extend heightfield + local density only | Reuses current caves, budgets and persistence | Does not provide direct triangle stitching, source-corner attributes, or local mesh remesh | +| New triangle-surface authoring backend | Fits imported mesh composition and local topology operations | Robust intersections, open patch boundaries and attribute transfer are substantial work | +| Separate static props + shading/population only | Cheap and useful for many environments | Cannot satisfy the explicitly requested genuine geometry-join path | +| Dense global scalar volume | Uniform 3D editing | Unnecessary memory/resolution cost for broad open terrain | + +Recommendation: add a shared modifier/dependency/attribute framework and an explicit +triangle-surface backend for new Mesh Terrain assets, retaining the current hybrid +backend for compatibility and sampled solid tools. Do not pretend this backend is +already implemented or silently convert every old terrain. The companion design +defines proof gates before committing a production geometry kernel. + +Original implementation candidates need separate evaluation. Manifold documents +solid-input Boolean processing and rejects invalid manifold input; it is not a +general open-terrain patch solution. CGAL documents richer surface/corefinement +facilities and package-specific licenses. Neither is selected or installed here. +[Manifold](https://github.com/elalish/manifold), +[CGAL polygon processing](https://doc.cgal.org/latest/Polygon_mesh_processing/index.html), +[CGAL package/license catalogue](https://doc.cgal.org/latest/Manual/packages.html). + +## 11. Wireframe portability + +WebGPU's standard primitive state exposes point, line and triangle topologies; +it does not provide Metal-style polygon line-fill state. Ada should use a portable +edge pass or a validated barycentric route, with actual LOD/deformation inputs. +Line topology has different culling/bias rules, so hidden-edge behavior needs an +explicit depth surface and shader/depth policy rather than a blind state copy. +[WebGPU primitive state](https://www.w3.org/TR/webgpu/#primitive-state). + +## 12. What remains genuinely unknown + +- Epic's complete Boolean/remesh algorithms, tolerance policy, threading, and + worst-case behavior without inspecting the applicable licensed source. +- Current importer/material support for every source UV configuration, especially + after Boolean/remesh and across editor/build paths. +- Exact channel texture formats, packing, atlas layout, gutters, filter policy, + and which choices vary by build configuration. +- Whether a given filmed tree example uses projection, Boolean, RVT, a custom + master material, PCG, PVE, or several together; the referenced clip was not supplied. +- Achieved interactive latency, runtime memory and frame costs on Ada's target + hardware. No general UE performance claim is imported as an Ada benchmark. +- Gameplay-time availability of the full authoring modifier pipeline. Compiled + consumption and PCG runtime generation are supported concepts; that stronger + deformation claim is not established by this research. The documented + `UModifierComponent` itself is explicitly editor-only; possible runtime + equivalents or future variants were not established. + +These limits do not prevent a concrete design. They prevent calling an Ada choice +an established Unreal internal or declaring parity without measured evidence. diff --git a/Editor/Documentation/AgentGuides/scenes.md b/Editor/Documentation/AgentGuides/scenes.md index 2e04b34a9..730bf5eff 100644 --- a/Editor/Documentation/AgentGuides/scenes.md +++ b/Editor/Documentation/AgentGuides/scenes.md @@ -29,9 +29,7 @@ Reusable nested scenes through `SceneInstance` already exist. and Move/Scale/Rotate tools. Scene edits have undo/redo. The native viewport's Terrain tool creates height-field ground and sculpts it with Raise/Lower/Smooth/Flatten brushes, radius/strength controls and a surface -ring. Brush controls and Create Terrain live in the contextual Inspector, share -the active viewport's state and clear with its action owner. The viewport retains -its full height. A stroke commits one document-history edit; Escape restores its original +ring. Brush controls and Create Terrain live in a dock below the viewport. Its Sculpt, Materials, Mesh Instancing and Height Generator tabs share the active viewport's state and clear with its action owner. Source strokes publish coherent worker batches while dragging and save one resource URI version on release. The blue surface ring invalidates captured annotations when hover or published geometry changes. A stroke commits one document-history edit; Escape restores its original heights. `Terrain3DSystem` generates meshes and, with `Physics3DPlugin`, matching static triangle colliders. Height samples persist in `AdaScene.Terrain3D` through scene loading and native export. Imported-mesh sculpting, texture painting and diff --git a/Editor/Documentation/EditorGuide.md b/Editor/Documentation/EditorGuide.md index 11a0c7c6f..c00e37760 100644 --- a/Editor/Documentation/EditorGuide.md +++ b/Editor/Documentation/EditorGuide.md @@ -80,6 +80,14 @@ and stay at their defaults for other formats: Voxel materials are not imported yet: colors come from the palette and the model uses one diffuse material. The model tools of the AI agent work with glTF only. +## Shape terrain + +Open a scene and choose **Terrain** in the viewport toolbar. In the dock, choose +**Mesh Terrain** to join a static model to the ground or **Heightfield** to sculpt +height samples with Raise, Lower, Smooth or Flatten. Inspect the triangles with +the 3D viewport's **Wireframe** mode. See the [Terrain guide](Terrain.md) for a +worked join, heightfield tools, saved versions, and collision. + ## Build and run Choose **Build > Build Project** to build the project. **Build > Run** runs the active scene inside the editor when a scene is selected; otherwise it runs the selected target. **Build > Stop** stops the current operation. Build Output and Problems show progress and diagnostics. diff --git a/Editor/Documentation/Terrain.md b/Editor/Documentation/Terrain.md index 2b4a13d2c..4cdf78557 100644 --- a/Editor/Documentation/Terrain.md +++ b/Editor/Documentation/Terrain.md @@ -1,46 +1,164 @@ -# Terrain sculpting - -Open a scene in the native Studio workbench and click **Terrain** in the viewport -toolbar. Studio switches to 3D and opens the brush controls in the **Inspector**. -The viewport keeps its full height. Modes use two rows of buttons; radius and -strength each have a full-width slider. **Create Terrain** in the Inspector adds -a centred 64-unit square with 65 × 65 height samples and selects it. - -Choose **Raise Ground**, **Lower Ground**, **Smooth Ground** or **Flatten Ground**, -set the radius and strength, then hold the left mouse button and drag over the -terrain. The blue ring follows the surface. Holding still continues the brush; -moving quickly resamples the stroke. Flatten uses the height where the stroke -started. Strength is applied with elapsed time. Right-drag still rotates the -camera. Selecting a regular transform tool leaves terrain mode. - -Each completed stroke is one scene-history edit. Escape cancels the current -stroke. Save, reopen, Undo and Redo use the ordinary scene document path. Terrain -samples live in the `AdaScene.Terrain3D` component; the generated mesh is transient. -The Inspector exposes the patch size, ground colour and collision toggle below -the contextual brush settings. Brush settings follow the active scene, disappear -in Play or when leaving terrain mode, and return when terrain mode is enabled. - -`Model3DPlugin` installs `Terrain3DSystem`, which rebuilds the mesh when the height -field changes. When `Physics3DPlugin` is present, the same triangle grid creates a -static collider. Collision includes the authored entity and parent transforms; -the editing viewport omits physics. Removing the component removes its generated -mesh and collider. Native scene loading and native AdaScript export retain the -Codable component. - -The tool edits authored height fields, not arbitrary imported meshes. A height -field has one height per X/Z point, so it cannot make caves or overhangs. It is a -single patch with a maximum resolution of 257 × 257; texture painting, terrain -streaming and browser-shell authoring are separate features. The shared native -workbench includes touch input, but phone and VR interaction are not established -by the desktop checks. - -Coverage: `EditorTerrainTests` checks geometry winding, brush falloff and modes, -triangle picking, malformed data, scene round trips and stroke transactions. - -Windows validation (2026-10-28): all 14 terrain and localization tests passed, -including the mounted 300-pixel Inspector, live English/Russian switching, mode -and creation actions, Play/tool visibility, and viewport ownership changes. -The updated Standalone package passed its bundled-tool startup checks. Earlier -packaged D3D12 validation rendered the authored terrain; scene Play created its -static collider with `PhysicsBody3DInitialized`. These checks do not establish -browser, phone or VR interaction parity. +# Terrain + +Open a native Studio scene and select **Terrain** in the viewport toolbar. The +terrain dock opens **below the viewport**. It includes **Sculpt**, **Materials**, +**Joining**, **Mesh Instancing**, and **Height Generator**. The Inspector keeps +ordinary entity components. **Mesh Instancing** also has its own viewport toolbar +button. Play uses a separate runtime and does not show the authoring dock. + +## Create and join Mesh Terrain + +Set **Terrain Type** to **Mesh Terrain** and choose **Create Terrain**. This +creates a saved 128-unit surface from a 64 × 64 grid of cells. Choose **Heightfield** +to create ground for the heightfield workflow described below. + +For a first join, add a static cube to the scene and position it so it crosses the +ground surface. In **Joining**, select the cube, choose the **Target Terrain**, set +**Operation** to **Join**, and choose **Join selected mesh**. A closed mesh with +consistent face winding works as a solid. Joining replaces the overlapping faces +with a connected surface. The cube stays in the hierarchy and remains selectable. +A self-contained GLB can contribute its opaque trunk and roots while its +cutout leaves keep their own draw. Joined source triangles keep the original +color, roughness, normal maps and texture coordinates. Keep the model in +project Assets so the joined surface can load its textures when you reopen the +scene. + +Use **Contact width** to control the transition around a joined object. Choose +**Subtract** to cut a closed shape out of the terrain, or **Conform** to project +the existing ground toward a static mesh. Conform keeps the source visible and +works with an open surface. **Projection distance** limits the reach of the +projection above and below the ground; **Projection strength** controls how far +the affected vertices move. Choose **Apply operation** to rebuild. + +Select the cube and use the viewport's Move tool. While the editor prepares a new +surface, the previous terrain remains visible. Releasing saves the accepted shape +and position in one Undo step. Escape cancels an in-progress move. Undo and Redo +restore the saved surface with the cube's corresponding position. The original +model asset stays available for reuse. Keep the entire joined mesh within the +terrain's boundary. A move past the edge is rejected and the last accepted +position stays in the scene. + +To place a joined object precisely, edit its Transform in the Inspector and +press Enter. The terrain and the object's new transform are saved together. +If the new position crosses the terrain boundary, Studio keeps the last valid +position and shows why the edit was rejected. + +To move a group of joined objects, select their parent in the hierarchy. All +joined objects in the group must share one Mesh Terrain target. Release the +Move handle or press Enter in the Inspector to save the group and the rebuilt +terrain in one Undo step. + +Select the bound object and choose **Detach from terrain** in Joining or in the +Inspector to make it independent again. The ground and its procedural population +rebuild without that object. Undo restores the original binding and surface. + +Use the render selector in the 3D viewport to choose **Wireframe** or **Shaded + +Wireframe**. Wireframe shows triangle edges; the combined mode keeps the shaded +surface visible under the edges. Choose **Shaded** to return to the usual view. +These modes are for inspection in the viewport and do not change Play or the +scene's authored materials. + +Zoom out in Wireframe to inspect Mesh Terrain's render LOD. Eligible regions +switch to a coarser meshlet level as they occupy fewer pixels. Adjacent terrain +sections keep the same shared edge, even when one section shows finer triangles. +Regions with varying material paint retain their original triangles. + +## Create and generate heightfield ground + +With **Terrain Type** set to **Heightfield**, **Create Terrain** creates a saved +128-unit terrain with 129 × 129 height samples under an Environment parent. The +resource lives in project Assets. In **Height +Generator**, set **Height Amplitude**, **Noise Frequency**, and the seed, then choose +**Generate Heightmap**. Five deterministic noise octaves create hills and valleys; +**New Seed** changes their arrangement. Generating on an existing source keeps its +other saved channels and creates one undoable resource version. + +**Add region** expands a partial region before adding an adjacent 256-cell region. +The runtime uses 64-cell surface chunks, independent persistence regions, distance +LOD, and bounded streaming. Old heightfield Terrain components remain readable. + +## Sculpt in the viewport + +Choose **Raise**, **Lower**, **Smooth**, or **Flatten**. On Mesh Terrain, Smooth +follows connected triangles on the selected surface. Flatten uses the plane at +the start of the stroke. The Heightfield workflow also offers presets for ramps, +terraces, noise, erosion and brush shapes. Radius and strength are shared +controls. Hold the left button and drag over visible terrain. A blue +ring follows its surface. Source strokes publish worker batches **while the button +is held**. Releasing finishes the stroke and saves one resource version; Escape +cancels it. Each complete stroke is one ordinary scene Undo action. Undo, Redo, +Save and reopening the scene retain the selected resource version. + +Terrain authoring uses coherent asynchronous worker publication. Large volumetric +regions can take longer to prepare than a simple heightfield. The previous valid +mesh stays visible while a batch is prepared, and camera navigation remains usable. +Height tools edit the base heightfield; volume tools address additional strata. + +## Materials and texture variation + +In **Materials**, choose a card to paint its layer. **Add Material…** opens the +system file picker and imports a PNG or JPEG as a new layer, with a thumbnail. +Imported textures are copied into the terrain resource directory. Terrain layers +have stable IDs; choosing another card does not renumber saved paint. + +**Automatic Grass / Rock / Snow** initializes height and slope rules. Grass covers +lower gentle ground, rock covers steeper surfaces, and snow covers higher ground. +Existing layers are retained when applying variation settings again. Painted +material overrides these rules; **Erase material paint** reveals them again. + +On Mesh Terrain, select its entity in the hierarchy before painting. Each stroke +follows the connected side under the brush. Paint on the ground stays in place; +paint on a joined rock or root follows the source when it moves. If that part of +the surface disappears, the stroke stays saved and can apply again when a +matching surface returns. Erasing reveals the current contact and automatic +layers. Paint and erase each create one Undo step and leave the mesh shape alone. + +New texture layers use deterministic flips, quarter-turn rotations, random offsets, +macro variation and triplanar projection. Terrain-local coordinates keep variation +stable across chunk boundaries and camera movement. Volume material fields evaluate +actual surface height and normal, including multiple strata at the same X/Z. + +## Mesh instancing and solids + +Select the separate **Mesh Instancing** button, then **Add Mesh…** to import a +self-contained GLB or VOX. Choose its palette entry, density and brush radius. +**Paint meshes** and **Erase meshes** edit persistent instance records. Copies share +mesh buffers rather than becoming thousands of authored hierarchy entities. +Decorative instances do not create per-instance physics bodies by default. + +On Mesh Terrain, **Paint meshes** records where to generate the selected +prototype. **Erase meshes** clears its generated coverage without deleting +manual placements or pinned overrides. A pin that loses its supporting surface +is retained in the resource for recovery. If a joined object moves, the ground +it uncovers can grow the painted vegetation again. The area around its new +position clears according to the rule's exclusion distance. + +**Add solid** and **Subtract solid** use a sphere, box, capsule or a registered +palette mesh as an ordered volume operation. Position it with the brush and release +to apply. Caves and overhangs are real generated surfaces, with matching runtime +collision. Mesh operands must be supported closed static solids. Self-contained +models avoid silently omitting external model dependencies. Invalid resources keep +the last valid terrain generation. + +## Saved sources and compatibility + +An entity's **Terrain Source** component references a project `.aterrain` resource, +for example `@res://Terrain/playground.aterrain`. Keep its immutable region, mesh +document, model, texture and volume files together. The editing runtime resolves absolute paths; +scene references stay portable. Missing or outside-project terrain roots are rejected. + +Strokes operate on private same-directory manifest versions and publish their URI +through scene history. Earlier resources remain exact for Undo; unpublished manifests +are removed only after active filesystem writers finish. Shared payloads are never +deleted by gesture cancellation. Intermediate versions can therefore accumulate. + +The edit viewport omits source collision cooking; authored collision settings and +Play retain runtime physics. A nil streaming radius retains full-declared-set +residency, while newly added generic Source components use the engine's camera +streaming default. The old Terrain component still supports its four legacy brushes. +Advanced presets and saved palettes belong to Terrain Source resources. + +Native Windows is exercised during development. Other platform acceptance and full +portable export validation remain separate checks; local editor tests do not prove +those platforms. Three-dimensional manual paint storage and volumetric curvature +rules are not yet equivalent to the heightfield paint channels. diff --git a/Editor/Package.swift b/Editor/Package.swift index bd418027c..0590cc824 100644 --- a/Editor/Package.swift +++ b/Editor/Package.swift @@ -87,7 +87,7 @@ let package = Package( ProcessInfo.processInfo.environment["ADAEDITOR_SWIFTTERM_PACKAGE_PATH"].map { .package(name: "SwiftTerm", path: $0) } ?? .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", revision: "caa1bdd3f1396c5b0ed66121f299d1c8b42bc94c"), .package(url: "https://github.com/apple/swift-crypto.git", from: "4.0.0"), - .package(name: "AdaEngine", path: adaEngineLocalPath), + .package(name: "AdaEngine", path: adaEngineLocalPath, traits: ["default", "MeshTerrainAuthoring"]), .package(name: "AdaDebugging", path: "Debugging"), .package(name: "AdaPlayerConnect", path: "PlayerConnect"), adaMCPPackage, diff --git a/Editor/README.md b/Editor/README.md index 8e0fb1111..0678f1817 100644 --- a/Editor/README.md +++ b/Editor/README.md @@ -2,6 +2,9 @@ AdaEditor is a standalone SwiftPM package for the AdaEngine editor. It contains the `AdaEditor` executable target, UI resources, and tests for editor logic. +The [Asset Store guide](Documentation/EditorGuide.md#asset-store) explains searching +the in-editor catalog and adding bundles directly to an open project. + ## Structure - `Package.swift` — the editor package manifest. @@ -175,6 +178,9 @@ private environment variables. No credentials belong in the public repository. For explicit joints, vertex weights and animation authored by an interpreted AdaScript model recipe, see [Procedural rigging](Documentation/ProceduralRigging.md). +The [terrain guide](Documentation/Terrain.md) covers Mesh Terrain joins, moving +joined sources, Wireframe, heightfield sculpting, collision, and Undo/Redo. + Open **Help > AdaEngine Documentation**, or use the **Documentation** button on the welcome screen or editor toolbar. The native SwiftUI reader includes full-text guide search, section navigation, Back/Forward history, and copyable code examples. On macOS it opens a reusable window; on iPadOS it opens an in-app reader with a Done button. The bundled library contains the editor guide and substantive Markdown articles from the AdaEngine and AdaScripting DocC catalogs. It works without internet or a source checkout. Generated API reference pages and external websites are not bundled; external links are labeled **Internet**. diff --git a/Editor/Samples/MeshTerrainWorkshop/.ada/project.json b/Editor/Samples/MeshTerrainWorkshop/.ada/project.json new file mode 100644 index 000000000..530f9fcd4 --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/.ada/project.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 3, + "project": { + "name": "MeshTerrainWorkshop", + "displayName": "Mesh Terrain Workshop" + }, + "paths": { + "sources": "Sources", + "assets": "Assets", + "resourceRoots": [ + "Assets" + ] + }, + "build": { + "system": "adascript" + }, + "runtime": { + "entry": { + "scene": "Assets/Scenes/Main.ascn" + }, + "moduleName": "MeshTerrainWorkshop", + "plugins": { + "preset": "game3d", + "presetVersion": 1, + "enable": [], + "disable": [] + } + }, + "editor": { + "startupScene": "Assets/Scenes/Main.ascn" + } +} diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Models/root-tree.glb b/Editor/Samples/MeshTerrainWorkshop/Assets/Models/root-tree.glb new file mode 100644 index 0000000000000000000000000000000000000000..b82e10da9518243df7babb8bfbfc36fc80772729 GIT binary patch literal 5304 zcmb7G3wTUt8a{%ANQi{QT}&c~$z<*~IsczTf~X`m7p-b5lVm2DN+#)Kf{Lt5t@60k zqPAMJ-K7^*($cNk%sKUBd2~g!tb6r>rP~X2Y1OjLzW+Zn47I59c)#y_^ZnoZp6~z8 zpHop)G%7?8gzY^9p{SD}j4jO1Yf#wA%ItM@HSRivby9<(wBF&cyC*p9(-c;-A>rEP?-V9L&C{!yEHkm9sokeRlXyKVP!743ggIQ-b8%#Q*R-@G!nLxD?@-1eQ zTCG!?v=)=zU@)>p_fm=W+^>DNmTQ0Ykh;4q=Rw|moe|sPUtc&fV_ZhTuPW&8R^itE znFHIWRcQ@My~@IxV>H}qg+^sEDDfj}faN|9Yj{8|U;Eh>sBaR%XZO6%_o3nH|Nlth zk?z$})7?bgYN-Z0c1K4c$5vNo$Fw&nD(o)1+vcfpE3Ar)a$B~m!s)Wd=i2KkfLsQQZd!;u5;F4?iE^<8aos8VgQ6;_iTyT$_f z4hQFDyKHk;FbO)n#i+gYF5G`DFZQ6TQT%phjo!@q`Jm)lC=C{_v6%5F!OL~`(0(Vm z)}+2;^}U|_SF6qH-_SSX9WB?IS-oK8_u|&xx!S_t%%Ha2>$x=s3}tZJv}ju+#?E&T z`)4I+Ky(^C|J_+_tFQ-;lJCrBFSWDUUgSGHc~WYvt3ts_R@*#wx6@YTTjjD<<3v|v z+T2qxueGJ_T)W3sRpl%zsHv~0bm4CT8~RdPoqc3YRgHU;t&ETUBpmYWViE(t71@1b zk9?=A+>Sp9YD|2!zg}P^cElYheKV#q8yeYI*s5wPZ6oVFH4X<>t8kp~NN!Cz6PcS) z_z*;v*VLC**$bWJ_Hu>QOXz-sMbPuRp z+sM0$1Hiu#wQgs%)8m}#zqNThZf9w|hutb_~SJ`zc8mLCqIwB!A04R zjLgq3$eOHHSTSd;gHHTK#_N7-$9O~0q`MYGuWl8@Fa{Si1{cJn1x5fe8XsrsU1+dx z9;)#ij0eHGG8}bW9QS;`2Qt-soyWK3zMtx9>fL2*0cys_$B&5+gv^{g;n>Ho(|xUr zWqfi75$g)LuhU&7+xDy?EUtMzgkBglKau%5T^&94@erB$p29y;b1SSkRqS^zWlD-@4S3%Vo)6QZ|2MG*AVF8b>yJ>QV_4y>`9=k9@Fzo zb13)m3?ysO8p7h%eW##noXGrrtr2unzZnwqKbamv4`v_dKFi;c^BwmW)GkX5ic3NH z(0j9v98_Nl;+1NmW0(fYbjy1M?!UNtwfMx2511ahEyK$6a9gq~69;YSjhcfPTn`}%}a&6=yzxXl)> z3e*|%)qif*-+%SgOiz$GR0N=r1AzWGj?*E@y&(jie|`9JK)rjyoI$Shys&Jer4P%X3k1^KbkOJgU? zEWfi+LEdbRm0A9yC82WlrUShGfLHV6xQF8?%TK#zmGdH36P6$Pda=AMtBSUE+(?ep zjV2$iZYAGbwGs8MGTKMkhfbUGoVa=AL-ca)_hjezII?o%WV%T|P_~EsjT|V_(84|S z^3Tfoq?dY+_rm0v@{}20i)DLni2d>!OwC(g!)s(z4pm$tw*7@~F5+$+3wlvVPoD*^_jDob3Ci_*O%u zyzZohUJF}DwhvXvyPugwFYL-~-nDD0*z|oiJGI`SW4~aZ{KG{cVWyAT+HXzJ>GYY+QqgCF$SP&E zTs)|j6mJL-vvy3EFTdY~x)zV9QDlpe&(Av4;}&sli;EBW;{vVzdzH*U#TX~tx4-2Ktp1Bast#)oAxBIt< z+^#Qia2qo2gvj!m7H0Exue#@QJEO;+xjlW(%k6=*Bix>S=nP33siqNqe&F_G>s4;& zsDI)1qmI|O?RWeLw=JEUx$R=v;=AA0#O=`qzWCb3AMyKM>4&)u?C+wt1NRX^J6sV$ zBrsA4!4)ZV1$GtMY%X zU|;Y@0s8^_fxkPjKd?Xeqk#i}1Hc~x90(i;{vN=1U_AJH0tW#Hfj<_g04l&A2TTAa za3u*)(Mw1Ke{bMm;9&6g0VV;Hz~2`*1ULlz{eVM(L&4u4I1D%p`~!f=z+~_b1P%ud z2Y)iA5HJOp0)7Qh1yn&r0x%Vr%9W%-MWUbv|6rg7r~!WxPz%(8e+W0TjR*}!_+gkE z5gJVJJ7I1_XfVU?jJXk^!2&-Vb0b2D6@C|}5TRfM?3Xh8R=~FhzCT*=O|Xvpf)JG+ z9LKj7kLz?>`F8`pLGgXM72iH2hKLq<-338?=8DO*BDNRA6?4t&F>x}>-RO#PZ~^=<+J;qe~Q4F%=SRw z-DjN+MZZ~R7mm*x9U0yy96v?ojL9m1jbi^hg#rg&ZnMFZ=VWCRO2UOaWL4g| zwz=vhZ7XgR{qp|yVK-8@uDJHHE$Jd1n}4S5aH6+u?_ak4vOl8W`WN4CxFR3UIX_E! z{-14G3GcMUdm~yt6iR!Zdbix%D}7VuW2+*<+V9Cp+p{X7eS2Z;x92)FCH>b8J|QPN MFKcsV$y~Vq1GP!TX#fBK literal 0 HcmV?d00001 diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Scenes/Main.ascn b/Editor/Samples/MeshTerrainWorkshop/Assets/Scenes/Main.ascn new file mode 100644 index 000000000..e8f4e80c2 --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/Assets/Scenes/Main.ascn @@ -0,0 +1,207 @@ +{ + "format": "ada.scene", + "schemaVersion": 1, + "engineVersion": "1.0.0", + "scene": { + "id": "mesh-terrain-workshop", + "name": "Mesh Terrain Workshop" + }, + "entities": [ + { + "id": "Camera", + "name": "Camera", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 11, + -19 + ], + "rotation": [ + 0.258819, + 0, + 0, + 0.965926 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaRender.Camera": { + "projection": "perspective", + "isActive": true, + "renderOrder": 0, + "backgroundColor": { + "red": 0.05, + "green": 0.07, + "blue": 0.1, + "alpha": 1 + } + }, + "AdaCorePipelines.Environment3D": {} + } + }, + { + "id": "Sun", + "name": "Sun", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 4, + -3 + ], + "rotation": [ + 0.2661003137748674, + -0.4155384462484667, + -0.1285411045311107, + 0.8602299734049316 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.DirectionalLightComponent": { + "radiance": [ + 1, + 1, + 1 + ], + "intensity": 3, + "castShadows": true, + "shadowDistance": 30, + "shadowBias": 0.0008, + "shadowSlopeBias": 0.003 + } + } + }, + { + "id": "Terrain", + "name": "Mesh Terrain", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.MeshTerrain3DIdentity": { + "id": "D17A8658-AD33-4C26-A314-FD40C37203F9" + }, + "AdaScene.Terrain3DSource": { + "source": "@res://Terrain/Workshop.aterrain", + "generatesCollision": true + } + } + }, + { + "id": "RootTree", + "name": "Join and move this tree", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 3, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Model3DSource": { + "source": "@res://Models/root-tree.glb" + } + } + }, + { + "id": "PlayBall", + "name": "Play: drops onto the joined rock", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + -2.5, + 5.0, + 1.7 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Mesh3DComponent": { + "mesh": "Sphere", + "size": 0.8, + "color": { + "red": 1, + "green": 0.18, + "blue": 0.04, + "alpha": 1 + }, + "metallic": 0.0, + "roughness": 0.55 + }, + "AdaPhysics.PhysicsBody3DComponent": { + "mode": { + "dynamic": {} + }, + "shapes": [ + { + "fixture": { + "sphere": { + "_0": { + "radius": 0.4, + "center": { + "x": 0, + "y": 0, + "z": 0 + } + } + } + } + } + ] + } + } + } + ], + "editor": { + "selectedEntity": "Terrain", + "expandedEntities": [] + } +} diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.aterrain b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.aterrain new file mode 100644 index 000000000..06c7b8e40 --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.aterrain @@ -0,0 +1 @@ +{"heightRegions":[],"materials":{"fallbackLayerID":7,"interiorLayerID":21,"layers":[{"id":7,"metallic":0,"name":"Grass","projection":"planar","roughness":0.92,"scale":1,"tint":{"w":1,"x":0.24,"y":0.44,"z":0.18},"variation":{"cellScale":2,"flipU":true,"flipV":true,"isEnabled":false,"macroScale":32,"macroStrength":0.1,"quarterTurns":true,"randomOffset":true,"seed":0}},{"id":21,"metallic":0,"name":"Granite","projection":"triplanar","roughness":0.63,"scale":1,"tint":{"w":1,"x":0.53,"y":0.34,"z":0.23},"variation":{"cellScale":2,"flipU":true,"flipV":true,"isEnabled":false,"macroScale":32,"macroStrength":0.1,"quarterTurns":true,"randomOffset":true,"seed":0}},{"id":31,"metallic":0,"name":"Cave ochre","projection":"triplanar","roughness":0.89,"scale":1,"tint":{"w":1,"x":0.66,"y":0.28,"z":0.12},"variation":{"cellScale":2,"flipU":true,"flipV":true,"isEnabled":false,"macroScale":32,"macroStrength":0.1,"quarterTurns":true,"randomOffset":true,"seed":0}},{"id":32,"metallic":0,"name":"Cave slate","projection":"triplanar","roughness":0.8,"scale":1,"tint":{"w":1,"x":0.17,"y":0.25,"z":0.58},"variation":{"cellScale":2,"flipU":true,"flipV":true,"isEnabled":false,"macroScale":32,"macroStrength":0.1,"quarterTurns":true,"randomOffset":true,"seed":0}}],"rules":[]},"meshDocumentFileName":"Workshop.atmesh","meshPrototypes":[{"assetPath":"grass-sprig.vox","id":1},{"assetPath":"loose-stone.vox","id":2}],"originX":0,"originZ":0,"spacing":1,"version":5} \ No newline at end of file diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.atmesh b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.atmesh new file mode 100644 index 000000000..5e8c7c3f2 --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/Workshop.atmesh @@ -0,0 +1 @@ +{"compiled":{"inputKey":"6jnc7ZiFDplxbu3J9rVr+Cmrfcdb9ozJc1kKRxflhOU=","patches":[{"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","paint":{"samples":[{"corner":2599,"layers":[{"id":31,"weight":0.047811497}]},{"corner":2600,"layers":[{"id":31,"weight":0.026529232}]},{"corner":2606,"layers":[{"id":31,"weight":0.047811497}]},{"corner":2607,"layers":[{"id":31,"weight":0.047811497}]},{"corner":2613,"layers":[{"id":31,"weight":0.047811497}]},{"corner":2617,"layers":[{"id":31,"weight":0.047811497}]},{"corner":2626,"layers":[{"id":32,"weight":0.047811497}]},{"corner":2634,"layers":[{"id":32,"weight":0.047811497}]},{"corner":2637,"layers":[{"id":32,"weight":0.047811497}]},{"corner":2642,"layers":[{"id":32,"weight":0.047811497}]},{"corner":2644,"layers":[{"id":32,"weight":0.026529232}]},{"corner":2645,"layers":[{"id":32,"weight":0.047811497}]}]},"surface":{"faceIDs":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,323,324,324,325,326,326,327,327,328,328,329,329,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,368,369,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,403,403,404,405,405,406,407,407,407,408,408,408,409,409,409,409,410,411,412,413,414,414,414,414,415,415,415,415,416,416,417,418,418,418,419,419,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,454,455,458,459,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,494,494,495,495,495,496,497,497,498,498,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,0,0,0,0,1,1,1,2,2,2,2,3,3,3,6,7,8,8,8,8,9,9,9,10,10,10,11,11,11,11,0,1,2,3,4,5,5,5,5,5,5,6,6,6,6,6,6,6,6,7,8,8,8,9,9,9,9,9,10,11,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,4,5,8,8,9,9,9,10,10,10,10,10,10,11,11,11,11,11],"faceSources":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4],"indices":[0,1,2,1,3,2,2,3,4,3,5,4,4,5,6,5,7,6,7,8,6,7,9,8,9,10,8,9,11,10,10,11,12,11,13,12,12,13,14,13,15,14,14,15,16,15,17,16,16,17,18,17,19,18,18,19,20,19,21,20,20,21,22,21,23,22,23,24,22,23,25,24,25,26,24,25,27,26,27,28,26,27,29,28,28,29,30,29,31,30,30,31,32,31,33,32,32,33,34,33,35,34,34,35,36,35,37,36,36,37,38,37,39,38,38,39,40,39,41,40,1,42,3,3,42,43,3,43,5,43,44,5,5,44,7,44,45,7,7,45,9,45,46,9,9,46,11,46,47,11,11,47,13,13,47,48,13,48,15,48,49,15,15,49,17,17,49,50,17,50,19,19,50,51,19,51,21,51,52,21,21,52,23,23,52,53,23,53,25,53,54,25,25,54,27,54,55,27,27,55,29,55,56,29,29,56,31,56,57,31,31,57,33,33,57,58,33,58,35,35,58,59,35,59,37,37,59,60,37,60,39,39,60,61,39,61,41,41,61,62,43,42,63,43,63,64,43,64,44,44,64,65,44,65,45,65,66,45,45,66,46,46,66,67,46,67,47,67,68,47,47,68,48,48,68,69,48,69,49,69,70,49,49,70,50,70,71,50,50,71,51,51,71,72,51,72,52,72,73,52,52,73,53,53,73,74,53,74,54,54,74,75,54,75,55,75,76,55,55,76,56,56,76,77,56,77,57,77,78,57,57,78,58,58,78,79,58,79,59,59,79,80,59,80,60,80,81,60,61,60,81,61,81,82,61,82,62,62,82,83,64,63,84,64,84,85,64,85,65,85,86,65,65,86,66,86,87,66,66,87,67,87,88,67,67,88,68,88,89,68,68,89,69,69,89,90,69,90,70,90,91,70,70,91,71,91,92,71,71,92,72,92,93,72,72,93,73,93,94,73,73,94,74,74,94,95,74,95,75,95,96,75,75,96,76,96,97,76,76,97,77,97,98,77,77,98,78,98,99,78,78,99,79,79,99,100,79,100,80,100,101,80,80,101,81,101,102,81,82,81,102,82,102,103,82,103,83,83,103,104,85,84,105,85,105,106,85,106,86,86,106,107,86,107,87,87,107,108,87,108,88,88,108,109,88,109,89,109,110,89,89,110,90,110,111,90,111,91,90,111,112,91,91,112,92,92,112,113,92,113,93,93,113,114,93,114,94,114,115,94,94,115,95,95,115,116,95,116,96,96,116,117,96,117,97,97,117,118,97,118,98,98,118,119,98,119,99,119,120,99,99,120,100,100,120,121,100,121,101,101,121,122,101,122,102,102,122,123,103,102,123,103,123,124,103,124,104,104,124,125,106,105,126,106,126,127,106,127,107,127,128,107,107,128,108,128,129,108,108,129,109,129,130,109,109,130,110,130,131,110,110,131,111,131,132,111,111,132,112,132,133,112,112,133,113,133,134,113,113,134,114,134,135,114,114,135,115,135,136,115,115,136,116,136,137,116,116,137,117,137,138,117,117,138,118,138,139,118,118,139,119,139,140,119,119,140,120,140,141,120,141,121,120,141,142,121,121,142,122,142,143,122,122,143,123,143,144,123,124,123,144,124,144,145,124,145,125,145,146,125,127,126,147,127,147,148,127,148,128,148,149,128,128,149,129,149,150,129,129,150,130,150,151,130,130,151,131,151,152,131,131,152,132,132,152,153,132,153,133,153,154,133,154,134,133,154,155,134,155,135,134,155,156,135,156,136,135,156,157,136,136,157,137,137,157,158,137,158,138,158,159,138,138,159,139,159,160,139,139,160,140,160,161,140,140,161,141,161,162,141,141,162,142,162,163,142,163,143,142,163,164,143,164,144,143,164,165,144,165,145,144,165,166,145,145,166,146,146,166,167,147,168,148,148,168,169,148,169,149,149,169,170,149,170,150,150,170,171,150,171,151,151,171,172,151,172,152,172,173,152,152,173,153,153,173,174,153,174,154,154,174,175,154,175,155,175,176,155,155,176,156,156,176,177,156,177,157,177,178,157,157,178,158,158,178,179,158,179,159,159,179,180,159,180,160,160,180,181,160,181,161,161,181,182,161,182,162,182,183,162,162,183,163,163,183,184,163,184,164,164,184,185,164,185,165,185,186,165,165,186,166,166,186,187,166,187,167,167,187,188,168,189,169,169,189,190,169,190,170,190,191,170,190,192,191,170,191,193,170,193,171,171,193,194,171,194,195,171,195,172,172,195,196,172,196,197,172,197,198,172,198,173,173,198,199,173,199,200,200,199,201,173,200,174,174,200,202,174,202,175,202,203,175,175,203,176,203,204,176,176,204,177,204,205,177,177,205,178,205,206,178,178,206,179,206,207,179,179,207,180,207,208,180,180,208,181,208,209,181,182,181,209,182,209,210,182,210,183,210,211,183,183,211,184,184,211,212,184,212,185,212,213,185,185,213,186,213,214,186,186,214,187,214,215,187,187,215,188,215,216,188,189,217,190,190,217,218,190,218,192,218,219,192,200,201,220,221,220,222,200,220,221,202,200,221,202,221,223,202,223,203,203,223,224,203,224,204,204,224,225,204,225,205,225,226,205,205,226,206,226,227,206,227,207,206,227,228,207,207,228,208,208,228,229,208,229,209,209,229,230,210,209,230,210,230,231,210,231,211,211,231,232,211,232,212,212,232,233,212,233,213,213,233,234,213,234,214,214,234,235,214,235,215,215,235,236,215,236,216,216,236,237,217,238,218,238,239,218,218,239,219,239,240,219,239,241,240,239,242,241,242,243,244,242,245,243,245,246,243,245,247,246,245,248,247,248,249,247,248,250,249,248,251,250,221,252,253,221,222,252,221,253,254,254,255,251,254,253,255,248,254,251,223,221,254,223,254,256,223,256,224,256,257,224,224,257,258,225,224,258,257,259,258,225,258,260,257,261,259,257,262,261,225,263,264,225,260,263,225,264,265,225,265,226,226,265,266,226,266,267,226,267,227,227,267,268,227,268,269,270,269,268,270,271,269,227,269,228,269,272,228,228,272,229,272,273,229,229,273,230,273,274,230,230,274,231,275,231,274,275,232,231,275,276,232,232,276,233,276,277,233,233,277,234,277,278,234,234,278,235,278,279,235,235,279,236,279,280,236,236,280,237,280,281,237,238,282,239,239,282,283,239,283,242,283,284,242,242,284,245,284,285,245,245,285,248,285,286,248,248,286,254,286,287,254,256,254,287,256,287,288,256,288,257,288,289,257,257,290,262,257,289,290,290,289,291,271,292,269,269,292,293,294,293,292,269,293,272,293,295,272,272,295,273,295,296,273,273,296,274,296,297,274,275,274,297,275,297,298,275,298,276,298,299,276,276,299,277,277,299,300,277,300,278,300,301,278,278,301,279,301,302,279,279,302,280,302,303,280,280,303,281,303,304,281,282,305,283,283,305,306,283,306,284,306,307,284,307,285,284,307,308,285,308,286,285,308,309,286,309,287,286,309,310,287,287,310,288,288,310,311,288,311,289,289,311,312,289,313,291,289,312,313,313,312,314,314,315,316,312,315,314,316,315,317,317,315,318,315,319,318,318,319,320,294,321,293,320,319,321,293,321,319,293,319,322,293,322,295,322,323,295,323,296,295,323,324,296,324,297,296,324,325,297,298,297,325,298,325,326,298,326,299,326,327,299,299,327,300,300,327,328,300,328,301,301,328,329,301,329,302,329,330,302,302,330,303,303,330,331,303,331,304,331,332,304,305,333,306,306,333,334,306,334,307,334,335,307,307,335,308,335,336,308,308,336,309,336,337,309,309,337,310,337,338,310,310,338,311,311,338,339,311,339,312,339,340,312,312,340,315,315,340,341,315,341,319,319,341,342,319,342,322,342,343,322,322,343,323,323,343,344,323,344,324,344,345,324,324,345,325,345,346,325,325,346,326,346,347,326,326,347,327,347,348,327,327,348,328,328,348,349,329,328,349,329,349,350,329,350,330,330,350,351,330,351,331,331,351,352,331,352,332,332,352,353,334,333,354,334,354,355,334,355,335,335,355,356,335,356,336,336,356,357,336,357,337,337,357,358,337,358,338,358,359,338,338,359,339,339,359,360,339,360,340,360,361,340,340,361,341,341,361,362,341,362,342,342,362,363,342,363,343,363,364,343,343,364,344,344,364,365,344,365,345,345,365,366,345,366,346,346,366,367,346,367,347,367,368,347,347,368,348,368,369,348,348,369,349,349,369,370,349,370,350,350,370,371,350,371,351,351,371,372,352,351,372,352,372,373,352,373,353,353,373,374,355,354,375,355,375,376,355,376,356,376,377,356,356,377,357,377,378,357,357,378,358,378,379,358,358,379,359,379,380,359,359,380,360,380,381,360,360,381,361,381,382,361,361,382,362,382,383,362,362,383,363,383,384,363,363,384,364,384,385,364,364,385,365,385,386,365,365,386,366,386,387,366,366,387,367,387,388,367,367,388,368,388,389,368,389,369,368,389,390,369,390,370,369,390,391,370,391,371,370,391,392,371,371,392,372,392,393,372,373,372,393,373,393,394,373,394,374,394,395,374,376,375,396,376,396,397,376,397,377,397,398,377,377,398,378,398,399,378,378,399,379,399,400,379,379,400,380,400,401,380,380,401,381,401,402,381,402,382,381,402,403,382,382,403,383,403,404,383,383,404,384,404,405,384,384,405,385,405,406,385,385,406,386,386,406,407,386,407,387,407,408,387,387,408,388,408,409,388,388,409,389,409,410,389,389,410,390,410,411,390,390,411,391,391,411,412,391,412,392,412,413,392,392,413,393,413,414,393,394,393,414,394,414,415,394,415,395,395,415,416,397,396,417,397,417,418,397,418,398,398,418,419,398,419,399,419,420,399,399,420,400,400,420,421,400,421,401,421,422,401,401,422,402,402,422,423,402,423,403,423,424,403,403,424,404,424,425,404,404,425,405,405,425,426,405,426,406,426,427,406,406,427,407,407,427,428,407,428,408,408,428,429,408,429,409,429,430,409,409,430,410,410,430,431,410,431,411,431,432,411,411,432,412,412,432,433,412,433,413,413,433,434,413,434,414,434,435,414,415,414,435,415,435,436,415,436,416,416,436,437,418,417,438,418,438,439,418,439,419,439,440,419,419,440,420,440,441,420,420,441,421,441,442,421,421,442,422,442,443,422,422,443,423,423,443,444,423,444,424,444,445,424,445,425,424,445,446,425,446,426,425,446,447,426,447,427,426,447,448,427,427,448,428,428,448,449,428,449,429,449,450,429,429,450,430,450,451,430,430,451,431,451,452,431,431,452,432,452,453,432,453,433,432,453,454,433,454,434,433,454,455,434,455,435,434,455,456,435,456,436,435,456,457,436,436,457,437,437,457,458,438,459,439,459,460,439,439,460,440,460,461,440,440,461,441,461,462,441,441,462,442,442,462,463,442,463,443,463,464,443,443,464,444,464,465,444,444,465,445,465,466,445,445,466,446,466,467,446,446,467,447,467,468,447,447,468,448,468,469,448,448,469,449,469,470,449,449,470,450,450,470,471,450,471,451,451,471,472,451,472,452,452,472,473,452,473,453,473,474,453,453,474,454,474,475,454,454,475,455,475,476,455,455,476,456,476,477,456,456,477,457,477,478,457,457,478,458,478,479,458,480,481,482,480,482,483,483,482,484,484,482,485,486,487,488,488,487,481,481,487,482,489,490,491,490,492,491,492,493,491,493,494,491,495,496,497,496,489,497,489,491,497,487,497,491,487,491,482,498,499,497,499,500,497,500,501,497,501,495,497,486,502,487,502,498,487,498,497,487,485,482,503,504,503,482,504,482,491,504,505,506,504,491,505,507,505,491,507,491,494,508,509,510,508,510,511,512,513,514,512,515,513,508,514,509,508,516,517,508,517,514,514,517,518,512,519,520,514,518,519,512,514,519,511,521,522,511,523,521,511,513,523,515,524,525,515,525,526,513,527,523,513,526,527,515,526,513,511,510,513,508,528,516,512,520,529,512,529,515,508,511,528,511,530,528,511,522,530,515,529,531,515,531,524,509,513,510,509,514,513,532,533,534,533,535,534,532,534,536,532,536,537,538,539,540,538,541,539,538,540,542,542,540,543,532,537,544,532,545,546,532,544,545,544,539,547,544,548,545,544,547,548,547,539,541,549,550,551,551,550,552,549,553,550,549,554,553,555,556,557,558,559,556,555,557,560,555,558,556,549,561,554,549,562,563,549,563,561,561,564,557,561,563,565,561,565,564,564,560,557,532,551,533,532,549,551,532,566,549,549,566,562,532,567,568,532,568,569,532,569,566,533,570,535,571,540,572,571,543,540,571,572,573,573,572,574,574,572,575,533,551,570,551,552,570,556,575,572,576,575,556,576,556,559],"normals":[{"x":-0.36861345,"y":0.09257122,"z":-0.924962},{"x":-0.9699091,"y":0.24272716,"z":-0.018972985},{"x":-0.007157838,"y":0.193986,"z":-0.9809782},{"x":0.007316938,"y":0.9997081,"z":-0.02302706},{"x":-0.0061861924,"y":0.19464974,"z":-0.9808533},{"x":0.012282813,"y":0.99809283,"z":0.0604966},{"x":-0.002134527,"y":0.19604823,"z":-0.98059195},{"x":0.011111917,"y":0.9934842,"z":0.11342705},{"x":0.0030434588,"y":0.19749665,"z":-0.9802989},{"x":0.004483693,"y":0.99387974,"z":0.11037689},{"x":0.006752642,"y":0.19826487,"z":-0.98012525},{"x":-0.004395379,"y":0.9985986,"z":0.052740972},{"x":0.0070871124,"y":0.1979593,"z":-0.9801846},{"x":-0.011123269,"y":0.9994379,"z":-0.03162559},{"x":0.0038899153,"y":0.19673973,"z":-0.980448},{"x":-0.012270317,"y":0.9949234,"z":-0.09988465},{"x":-0.001185336,"y":0.19522566,"z":-0.98075765},{"x":-0.007361995,"y":0.9929307,"z":-0.118467234},{"x":-0.0056115924,"y":0.1941644,"z":-0.980953},{"x":0.0011808563,"y":0.99689317,"z":-0.07875715},{"x":-0.007255141,"y":0.19406754,"z":-0.9809614},{"x":0.009195104,"y":0.99995756,"z":0.0005214635},{"x":-0.0053251307,"y":0.1949833,"z":-0.9807921},{"x":0.012600863,"y":0.99675304,"z":0.07952763},{"x":-0.0007230306,"y":0.19647245,"z":-0.9805091},{"x":0.009743305,"y":0.99289453,"z":0.118597984},{"x":0.0043075476,"y":0.19779973,"z":-0.980233},{"x":0.0021026186,"y":0.99505323,"z":0.09932097},{"x":0.007222716,"y":0.1982918,"z":-0.9801164},{"x":-0.00663253,"y":0.9995091,"z":0.030620832},{"x":0.006522266,"y":0.1976968,"z":-0.98024154},{"x":-0.012072414,"y":0.99848586,"z":-0.053668898},{"x":0.0025877971,"y":0.19632265,"z":-0.9805359},{"x":-0.011472162,"y":0.99378073,"z":-0.110762246},{"x":-0.0025666524,"y":0.19486216,"z":-0.9808273},{"x":-0.0052330373,"y":0.9935695,"z":-0.113103345},{"x":-0.006400366,"y":0.19403066,"z":-0.9809746},{"x":0.0036181104,"y":0.9982157,"z":-0.059602197},{"x":-0.007075142,"y":0.19422774,"z":-0.9809309},{"x":0.010713147,"y":0.9996534,"z":0.024048286},{"x":0.83185744,"y":0.20756985,"z":-0.51471156},{"x":0.9698318,"y":0.24303956,"z":0.01892223},{"x":-0.9702485,"y":0.2415781,"z":-0.016056785},{"x":0.07119419,"y":0.9973418,"z":-0.0155137},{"x":0.06768165,"y":0.9962587,"z":0.05373947},{"x":0.03060804,"y":0.9948944,"z":0.09616826},{"x":-0.021582017,"y":0.99561703,"z":0.090999864},{"x":-0.06317987,"y":0.9971738,"z":0.040653802},{"x":-0.07335869,"y":0.9968541,"z":-0.03000501},{"x":-0.04706588,"y":0.99521595,"z":-0.08561571},{"x":0.0024690733,"y":0.9951076,"z":-0.09876633},{"x":0.050855402,"y":0.9967169,"z":-0.06300151},{"x":0.07403306,"y":0.997247,"z":0.0041985116},{"x":0.060345158,"y":0.99577284,"z":0.06924542},{"x":0.016796684,"y":0.9948623,"z":0.09983453},{"x":-0.035077676,"y":0.9960942,"z":0.08103046},{"x":-0.069647044,"y":0.99733114,"z":0.021907337},{"x":-0.06953707,"y":0.99641687,"z":-0.048146777},{"x":-0.034921907,"y":0.9949461,"z":-0.09414215},{"x":0.016934406,"y":0.9954727,"z":-0.09352695},{"x":0.060500924,"y":0.9970839,"z":-0.046511404},{"x":0.074000046,"y":0.99697566,"z":0.023740672},{"x":0.96962464,"y":0.24409887,"z":0.015614789},{"x":-0.9705609,"y":0.24068694,"z":-0.009015824},{"x":0.11606962,"y":0.9932331,"z":-0.00399852},{"x":0.10522892,"y":0.99389964,"z":0.03302138},{"x":0.0422349,"y":0.9976552,"z":0.053855255},{"x":-0.042134766,"y":0.9979657,"z":0.047846112},{"x":-0.105213076,"y":0.9942867,"z":0.018003333},{"x":-0.11607297,"y":0.9930276,"z":-0.020573629},{"x":-0.069681935,"y":0.9963612,"z":-0.04907948},{"x":0.011713278,"y":0.99851155,"z":-0.053268492},{"x":0.08719955,"y":0.99571395,"z":-0.030823112},{"x":0.119091496,"y":0.9928603,"z":0.0067538484},{"x":0.09230576,"y":0.99488735,"z":0.04097342},{"x":0.01952399,"y":0.99829394,"z":0.055028252},{"x":-0.063191876,"y":0.99713486,"z":0.041579053},{"x":-0.11409319,"y":0.9934416,"z":0.0075264033},{"x":-0.108614355,"y":0.9936278,"z":-0.03011391},{"x":-0.049384627,"y":0.99737227,"z":-0.053006437},{"x":0.034796093,"y":0.9981672,"z":-0.04951313},{"x":0.10138213,"y":0.994619,"z":-0.02132375},{"x":0.1175652,"y":0.99291545,"z":0.017244898},{"x":0.9695506,"y":0.24475451,"z":0.0081806965},{"x":-0.97070765,"y":0.24026363,"z":0.00031307887},{"x":0.13076887,"y":0.99137664,"z":0.008478074},{"x":0.11546561,"y":0.99330413,"z":0.0038294303},{"x":0.042839877,"y":0.9990782,"z":-0.0027524915},{"x":-0.051699128,"y":0.9986308,"z":-0.007986795},{"x":-0.11984613,"y":0.9927498,"z":-0.0092032505},{"x":-0.1286673,"y":0.99167037,"z":-0.005891945},{"x":-0.07416445,"y":0.99724597,"z":0.00033592348},{"x":0.017922312,"y":0.9998186,"z":0.006446756},{"x":0.100801855,"y":0.994863,"z":0.009310251},{"x":0.13322178,"y":0.99105746,"z":0.007555189},{"x":0.1002905,"y":0.994956,"z":0.002093634},{"x":0.017135864,"y":0.99984324,"z":-0.004458691},{"x":-0.07481603,"y":0.9971587,"z":-0.00878441},{"x":-0.12886482,"y":0.9916239,"z":-0.008712984},{"x":-0.11950631,"y":0.99282384,"z":-0.0043705753},{"x":-0.05097009,"y":0.9986979,"z":0.0021646214},{"x":0.04358823,"y":0.9990203,"z":0.007653651},{"x":0.11585014,"y":0.9932233,"z":0.009288585},{"x":0.13061969,"y":0.9914122,"z":0.006345592},{"x":0.96956927,"y":0.24481301,"z":-0.0014259416},{"x":-0.9706253,"y":0.24040668,"z":0.009550283},{"x":0.11187692,"y":0.99354464,"z":0.018780496},{"x":0.0958657,"y":0.9950445,"z":-0.026386056},{"x":0.03218654,"y":0.99776,"z":-0.05864282},{"x":-0.047692813,"y":0.9969566,"z":-0.06166825},{"x":-0.10356822,"y":0.9940401,"z":-0.03402965},{"x":-0.10817721,"y":0.9940781,"z":0.010320865},{"x":-0.059258267,"y":0.9970049,"z":0.04969541},{"x":0.019408487,"y":0.99773425,"z":0.06441842},{"x":0.088241056,"y":0.9949898,"z":0.046998885},{"x":0.11316183,"y":0.99355584,"z":0.0064175697},{"x":0.08223211,"y":0.9959123,"z":-0.03737005},{"x":0.010236102,"y":0.9979766,"z":-0.06275261},{"x":-0.066888265,"y":0.9961422,"z":-0.056803446},{"x":-0.11051022,"y":0.9936154,"z":-0.02271528},{"x":-0.09956201,"y":0.99477625,"z":0.022530703},{"x":-0.039174844,"y":0.99761784,"z":0.05678098},{"x":0.04092509,"y":0.9971896,"z":0.062754504},{"x":0.100395724,"y":0.994241,"z":0.037490617},{"x":0.11011427,"y":0.99389964,"z":-0.0061955014},{"x":0.96965325,"y":0.24425273,"z":-0.010638814},{"x":-0.9703655,"y":0.24108836,"z":0.016346619},{"x":0.06382983,"y":0.9976655,"z":0.024274163},{"x":0.051223118,"y":0.99744195,"z":-0.049857147},{"x":0.013171823,"y":0.9950011,"z":-0.098991655},{"x":-0.03127949,"y":0.99458885,"z":-0.09906878},{"x":-0.060339577,"y":0.99691844,"z":-0.05012746},{"x":-0.059435636,"y":0.9979447,"z":0.023953246},{"x":-0.028906321,"y":0.9958743,"z":0.08601661},{"x":0.015823686,"y":0.99431807,"z":0.10526726},{"x":0.05274139,"y":0.99597156,"z":0.07251926},{"x":0.063579895,"y":0.9979703,"z":0.0036056908},{"x":0.04270327,"y":0.99682754,"z":-0.067166224},{"x":0.000700424,"y":0.99454004,"z":-0.10435346},{"x":-0.041574616,"y":0.99507874,"z":-0.08994364},{"x":-0.06336742,"y":0.9975116,"z":-0.030905694},{"x":-0.05360876,"y":0.99760544,"z":0.043697495},{"x":-0.017191747,"y":0.9951962,"z":0.096379325},{"x":0.027609633,"y":0.9944776,"z":0.10125195},{"x":0.058824256,"y":0.99669313,"z":0.05605845},{"x":0.06085726,"y":0.9979981,"z":-0.017209895},{"x":0.969818,"y":0.24323592,"z":-0.017013473},{"x":-0.97005314,"y":0.2421531,"z":0.0189425},{"x":-0.001305289,"y":0.9997249,"z":0.023417216},{"x":-0.0069939536,"y":0.9981543,"z":-0.060324866},{"x":-0.009169894,"y":0.9934897,"z":-0.11355246},{"x":-0.006829007,"y":0.99382675,"z":-0.11073336},{"x":-0.0011004101,"y":0.9985853,"z":-0.053162303},{"x":0.0052118576,"y":0.9994947,"z":0.031356636},{"x":0.008897443,"y":0.9949574,"z":0.09990334},{"x":0.008173086,"y":0.99289006,"z":0.11875424},{"x":0.0034238044,"y":0.9968545,"z":0.07917937},{"x":-0.0030672841,"y":0.9999953,"z":-0.0001739316},{"x":-0.008020941,"y":0.9968077,"z":-0.07943635},{"x":-0.008967385,"y":0.99287796,"z":-0.11879783},{"x":-0.0055058855,"y":0.9950008,"z":-0.09971534},{"x":0.00071105553,"y":0.9995185,"z":-0.031021515},{"x":0.006593626,"y":0.9985476,"z":0.0534718},{"x":0.009160069,"y":0.9937936,"z":0.11086186},{"x":0.0072089504,"y":0.9935182,"z":0.11344449},{"x":0.0016898484,"y":0.99819535,"z":0.060026567},{"x":-0.0047091325,"y":0.9997067,"z":-0.02375784},{"x":0.97007954,"y":0.2420498,"z":-0.01890944},{"x":-0.96979946,"y":0.24333726,"z":0.0166144},{"x":-0.0660967,"y":0.9976788,"z":0.016378662},{"x":-0.0633199,"y":0.99647903,"z":-0.05495603},{"x":-0.029138599,"y":0.99467933,"z":-0.09881269},{"x":0.019406524,"y":0.9954031,"z":-0.093787305},{"x":0.05845652,"y":0.99739677,"z":-0.042219482},{"x":0.06843628,"y":0.9971896,"z":0.030487131},{"x":0.044365976,"y":0.9951434,"z":0.08787083},{"x":-0.0015861434,"y":0.9948174,"z":0.101665705},{"x":-0.0468284,"y":0.996776,"z":0.06515169},{"x":-0.06888056,"y":0.9976173,"z":-0.003906331},{"x":-0.056602463,"y":0.9958722,"z":-0.07095595},{"x":-0.01632689,"y":0.9945828,"z":-0.102657594},{"x":0.03202492,"y":0.9959838,"z":-0.08361066},{"x":0.064610615,"y":0.9976465,"z":-0.02295401},{"x":0.06500729,"y":0.99667174,"z":0.04918822},{"x":0.033134595,"y":0.99476135,"z":0.09670583},{"x":-0.01506827,"y":0.9952325,"z":0.09635968},{"x":-0.055921324,"y":0.9972694,"z":0.048234396},{"x":-0.0689889,"y":0.9973279,"z":-0.024032515},{"x":0.9703926,"y":0.2410096,"z":-0.015895752},{"x":-0.969643,"y":0.2443241,"z":0.009907291},{"x":-0.11320966,"y":0.99355805,"z":0.005101918},{"x":-0.09303633,"y":0.9948502,"z":-0.040215407},{"x":-0.10291899,"y":0.9940635,"z":-0.035292026},{"x":-0.084549084,"y":0.9951021,"z":-0.051217698},{"x":-0.03850864,"y":0.99690837,"z":-0.06848984},{"x":-0.026378201,"y":0.9973872,"z":-0.06725336},{"x":0.028996035,"y":0.9975786,"z":-0.06321506},{"x":0.03538167,"y":0.9974028,"z":-0.062736414},{"x":0.045138713,"y":0.997737,"z":-0.04983364},{"x":0.050296545,"y":0.9976199,"z":-0.04716741},{"x":0.102494255,"y":0.9945267,"z":-0.020287454},{"x":0.061329264,"y":0.9972462,"z":-0.041697726},{"x":0.11340921,"y":0.9933121,"z":0.021666426},{"x":0.066050895,"y":0.99671406,"z":0.046887312},{"x":-0.014338295,"y":0.9990387,"z":0.04142628},{"x":-0.08227767,"y":0.9963867,"z":0.02107087},{"x":-0.113797896,"y":0.9934539,"z":-0.009968426},{"x":-0.090297416,"y":0.994937,"z":-0.04412201},{"x":-0.01948662,"y":0.99802095,"z":-0.059787184},{"x":0.06128888,"y":0.997074,"z":-0.045685157},{"x":0.11125091,"y":0.9937522,"z":-0.0089324135},{"x":0.10620137,"y":0.99382627,"z":0.03210447},{"x":0.048589233,"y":0.99716824,"z":0.0573981},{"x":-0.03352611,"y":0.99797064,"z":0.054135535},{"x":-0.09872672,"y":0.99482805,"z":0.023879094},{"x":-0.114840195,"y":0.9932203,"z":-0.018030997},{"x":0.9706412,"y":0.24037102,"z":-0.008800666},{"x":-0.9695649,"y":0.24483383,"z":0.000554091},{"x":-0.130828,"y":0.99137735,"z":-0.0074194423},{"x":-0.115777,"y":0.9932535,"z":-0.006565883},{"x":0.06844786,"y":0.99764305,"z":-0.004821301},{"x":0.119799264,"y":0.9927749,"z":0.006793989},{"x":0.074231125,"y":0.9972322,"z":0.0042097564},{"x":0.1240323,"y":0.992269,"z":0.0042727385},{"x":0.050762426,"y":0.99841523,"z":-0.024293847},{"x":-0.03206168,"y":0.9974708,"z":-0.06343601},{"x":-0.09835156,"y":0.9932119,"z":-0.062104736},{"x":-0.111292236,"y":0.9935497,"z":-0.021748636},{"x":-0.10028026,"y":0.9949417,"z":-0.005903106},{"x":-0.017547589,"y":0.99984556,"z":-0.0009951454},{"x":0.07456807,"y":0.997207,"z":0.004228862},{"x":0.12889788,"y":0.9916309,"z":0.007309981},{"x":0.11979926,"y":0.9927749,"z":0.0067939865},{"x":0.05138854,"y":0.9986745,"z":0.0029143223},{"x":-0.043259513,"y":0.99906087,"z":-0.002453311},{"x":-0.115777,"y":0.9932535,"z":-0.0065658805},{"x":-0.130828,"y":0.99137735,"z":-0.007419441},{"x":0.9707038,"y":0.2402788,"z":0.000543783},{"x":-0.9695527,"y":0.24471886,"z":-0.008959856},{"x":-0.11484018,"y":0.9932203,"z":-0.01803101},{"x":-0.10950129,"y":0.9939757,"z":0.004668466},{"x":-0.10300106,"y":0.9945482,"z":0.016269702},{"x":-0.0987267,"y":0.99482805,"z":0.023879103},{"x":-0.08489003,"y":0.99624014,"z":0.017302133},{"x":-0.10300106,"y":0.9945482,"z":0.016269702},{"x":-0.033526108,"y":0.99797064,"z":0.054135546},{"x":-0.03597059,"y":0.998552,"z":0.040000677},{"x":-0.01230876,"y":0.99906814,"z":0.041368373},{"x":0.048589233,"y":0.99716824,"z":0.057398118},{"x":0.04418626,"y":0.99807173,"z":0.04359439},{"x":0.04930283,"y":0.99782354,"z":0.043789376},{"x":0.06643945,"y":0.99678516,"z":0.044779193},{"x":0.07314527,"y":0.9969861,"z":0.025856178},{"x":0.07238747,"y":0.99655133,"z":0.040564574},{"x":0.10620134,"y":0.99382627,"z":0.032104492},{"x":0.07124667,"y":0.99654555,"z":0.04267156},{"x":0.10218019,"y":0.994713,"z":-0.010258932},{"x":0.015091054,"y":0.9988221,"z":-0.04611627},{"x":-0.07866728,"y":0.98553383,"z":-0.1501152},{"x":-0.15707389,"y":0.9870167,"z":-0.03355399},{"x":0.033104233,"y":0.9774042,"z":-0.20877083},{"x":-0.118920356,"y":0.9920386,"z":0.041441526},{"x":-0.08059985,"y":0.99431294,"z":-0.06960938},{"x":0.086363785,"y":0.9847909,"z":-0.15075862},{"x":-0.053510327,"y":0.9910237,"z":-0.12250969},{"x":-0.07484004,"y":0.97599417,"z":-0.20453468},{"x":-0.09843097,"y":0.9652642,"z":-0.24202564},{"x":-0.077017866,"y":0.9826636,"z":-0.16864358},{"x":-0.012770339,"y":0.9999184,"z":-0.00039785483},{"x":-0.08746592,"y":0.9961675,"z":-0.00039406057},{"x":-0.00732898,"y":0.99134487,"z":0.13107908},{"x":-0.10208602,"y":0.99437505,"z":-0.028225048},{"x":-0.08403984,"y":0.9958878,"z":0.0338353},{"x":-0.010985985,"y":0.9982519,"z":0.058072645},{"x":0.06835603,"y":0.99625236,"z":0.052996993},{"x":0.113409184,"y":0.9933121,"z":0.021666432},{"x":0.10249424,"y":0.9945267,"z":-0.02028746},{"x":0.040697087,"y":0.9977982,"z":-0.0523691},{"x":-0.041623298,"y":0.9974271,"z":-0.058366854},{"x":-0.102918975,"y":0.9940635,"z":-0.03529204},{"x":-0.11320962,"y":0.99355805,"z":0.0051019154},{"x":0.97053826,"y":0.24074921,"z":0.009762333},{"x":-0.9696372,"y":0.2440177,"z":-0.016094144},{"x":-0.06898889,"y":0.9973279,"z":-0.024032474},{"x":-0.055921316,"y":0.9972694,"z":0.048234418},{"x":-0.015068265,"y":0.9952325,"z":0.09635968},{"x":0.033134595,"y":0.99476135,"z":0.09670581},{"x":0.065007284,"y":0.99667174,"z":0.04918819},{"x":0.0581053,"y":0.99810517,"z":-0.020244118},{"x":-0.017321208,"y":0.99734664,"z":-0.07070819},{"x":-0.13615966,"y":0.98675585,"z":-0.088167116},{"x":-0.17293563,"y":0.9791592,"z":-0.10649231},{"x":-0.012770735,"y":0.9983938,"z":-0.055197716},{"x":-0.029152427,"y":0.9993737,"z":0.020061491},{"x":0.039982677,"y":0.9960872,"z":-0.078814395},{"x":-0.046104267,"y":0.99678266,"z":0.06556446},{"x":-0.0015861384,"y":0.9948174,"z":0.10166569},{"x":0.04436598,"y":0.9951434,"z":0.08787081},{"x":-0.01593038,"y":0.9838033,"z":-0.17854229},{"x":0.054580342,"y":0.93899935,"z":-0.3395603},{"x":0.0176983,"y":0.9234965,"z":-0.38319847},{"x":0.0550953,"y":0.9778718,"z":-0.20182},{"x":-0.0633199,"y":0.99647903,"z":-0.054956},{"x":-0.06609669,"y":0.9976788,"z":0.01637869},{"x":0.97021747,"y":0.24167304,"z":0.016500778},{"x":-0.9698567,"y":0.24293602,"z":-0.018978681},{"x":-0.0047091437,"y":0.9997067,"z":-0.023757873},{"x":0.0016898348,"y":0.99819535,"z":0.06002654},{"x":0.007208939,"y":0.9935182,"z":0.1134445},{"x":0.009160066,"y":0.9937936,"z":0.110861875},{"x":0.006593637,"y":0.9985476,"z":0.053471833},{"x":-0.0016623422,"y":0.999587,"z":-0.028690763},{"x":-0.027207054,"y":0.9972263,"z":-0.06927819},{"x":-0.1575687,"y":0.9854296,"z":-0.06403607},{"x":-0.0054263365,"y":0.9979941,"z":0.06307396},{"x":-0.008020144,"y":0.9979766,"z":-0.06307422},{"x":0.038943633,"y":0.997437,"z":0.060023654},{"x":-0.005731066,"y":0.9999681,"z":-0.0055594374},{"x":-0.023388634,"y":0.99813485,"z":0.0563901},{"x":0.018179337,"y":0.99955845,"z":-0.02350538},{"x":-0.04184339,"y":0.99370545,"z":0.10391612},{"x":0.10993596,"y":0.9881065,"z":0.10751626},{"x":0.0008485839,"y":0.99999595,"z":0.002715625},{"x":0.0034237942,"y":0.9968545,"z":0.07917936},{"x":0.00817308,"y":0.99289006,"z":0.118754245},{"x":-0.158663,"y":0.9873172,"z":-0.0055464255},{"x":-0.23665969,"y":0.9313799,"z":-0.27662915},{"x":0.006865342,"y":0.940324,"z":-0.3402112},{"x":0.16202843,"y":0.94960254,"z":-0.2683314},{"x":0.22667249,"y":0.9694235,"z":-0.094009206},{"x":-0.0069939587,"y":0.9981543,"z":-0.060324896},{"x":-0.0013053017,"y":0.9997249,"z":0.023417192},{"x":0.9698824,"y":0.24283181,"z":0.018995589},{"x":-0.97018623,"y":0.24176987,"z":-0.016910931},{"x":0.060857236,"y":0.9979981,"z":-0.017209895},{"x":0.05882424,"y":0.99669313,"z":0.05605846},{"x":0.027609628,"y":0.9944776,"z":0.10125197},{"x":-0.017191734,"y":0.9951962,"z":0.09637935},{"x":-0.05360873,"y":0.99760544,"z":0.04369751},{"x":-0.06361888,"y":0.99750525,"z":-0.03059484},{"x":-0.042954236,"y":0.99523425,"z":-0.08754264},{"x":0.00012273031,"y":0.9951161,"z":-0.09871141},{"x":0.04440506,"y":0.9970497,"z":-0.06261104},{"x":0.06357988,"y":0.9979703,"z":0.0036056908},{"x":0.05274137,"y":0.99597156,"z":0.072519265},{"x":0.015823688,"y":0.99431807,"z":0.10526728},{"x":-0.27877763,"y":0.95718014,"z":0.07803376},{"x":-0.2951892,"y":0.95532686,"z":0.014630053},{"x":-0.009078277,"y":0.9999304,"z":-0.0075418362},{"x":0.21832298,"y":0.9753555,"z":-0.031885434},{"x":0.21238954,"y":0.9735522,"z":-0.08418335},{"x":0.05122309,"y":0.99744195,"z":-0.049857147},{"x":0.06382982,"y":0.9976655,"z":0.024274165},{"x":0.9696509,"y":0.24393347,"z":0.016539527},{"x":-0.97051436,"y":0.24081506,"z":-0.010489081},{"x":0.11011427,"y":0.99389964,"z":-0.0061954963},{"x":0.10039573,"y":0.994241,"z":0.03749061},{"x":0.04092509,"y":0.9971896,"z":0.06275449},{"x":-0.03917484,"y":0.99761784,"z":0.05678096},{"x":-0.09956201,"y":0.99477625,"z":0.022530695},{"x":-0.11051022,"y":0.9936154,"z":-0.022715276},{"x":-0.06688827,"y":0.9961422,"z":-0.056803446},{"x":0.010236102,"y":0.9979766,"z":-0.062752604},{"x":0.0822321,"y":0.9959123,"z":-0.03737003},{"x":0.11316183,"y":0.99355584,"z":0.006417571},{"x":0.088241056,"y":0.9949898,"z":0.046998877},{"x":0.019408489,"y":0.99773425,"z":0.0644184},{"x":-0.31424615,"y":0.94833,"z":0.043813664},{"x":-0.232859,"y":0.94600606,"z":0.22549792},{"x":-0.035318486,"y":0.9598365,"z":0.27832815},{"x":0.1653468,"y":0.96524286,"z":0.20240226},{"x":0.15313382,"y":0.98804665,"z":0.017715957},{"x":0.0958657,"y":0.9950445,"z":-0.026386043},{"x":0.11187691,"y":0.99354464,"z":0.018780498},{"x":0.96955556,"y":0.24467851,"z":0.009719979},{"x":-0.97069806,"y":0.24029844,"z":-0.0013996388},{"x":0.13061969,"y":0.9914123,"z":0.006345606},{"x":0.11585013,"y":0.9932233,"z":0.009288562},{"x":0.04358822,"y":0.9990203,"z":0.0076536024},{"x":-0.050970085,"y":0.9986979,"z":0.00216457},{"x":-0.11950629,"y":0.99282384,"z":-0.004370605},{"x":-0.1288648,"y":0.9916239,"z":-0.008712983},{"x":-0.07481602,"y":0.9971587,"z":-0.008784371},{"x":0.017135873,"y":0.99984324,"z":-0.004458636},{"x":0.100290485,"y":0.994956,"z":0.00209367},{"x":0.13322176,"y":0.99105746,"z":0.007555193},{"x":0.10080184,"y":0.994863,"z":0.009310223},{"x":0.017922312,"y":0.9998186,"z":0.0064467103},{"x":-0.16270341,"y":0.9802306,"z":0.11258557},{"x":-0.10603945,"y":0.94836736,"z":0.29892302},{"x":-0.10455159,"y":0.9644535,"z":0.24268991},{"x":0.009493552,"y":0.9895479,"z":0.14389193},{"x":0.04283987,"y":0.9990782,"z":-0.002752438},{"x":0.1154656,"y":0.99330413,"z":0.0038294627},{"x":0.13076887,"y":0.99137664,"z":0.0084780725},{"x":0.96956104,"y":0.2448496,"z":0.0003190477},{"x":-0.9706556,"y":0.24033974,"z":0.00803313},{"x":0.11756521,"y":0.99291545,"z":0.017244866},{"x":0.101382114,"y":0.994619,"z":-0.02132377},{"x":0.034796067,"y":0.9981672,"z":-0.049513124},{"x":-0.04938465,"y":0.99737227,"z":-0.053006414},{"x":-0.10861438,"y":0.9936278,"z":-0.030113874},{"x":-0.114093184,"y":0.9934416,"z":0.007526437},{"x":-0.06319185,"y":0.99713486,"z":0.04157907},{"x":0.019524014,"y":0.99829394,"z":0.055028234},{"x":0.09230578,"y":0.99488735,"z":0.04097339},{"x":0.11909149,"y":0.9928603,"z":0.0067538163},{"x":0.08719953,"y":0.99571395,"z":-0.030823128},{"x":0.011713263,"y":0.99851155,"z":-0.05326848},{"x":-0.069681965,"y":0.9963612,"z":-0.04907946},{"x":-0.116072975,"y":0.9930276,"z":-0.020573592},{"x":-0.10521307,"y":0.9942867,"z":0.01800336},{"x":-0.042134754,"y":0.9979657,"z":0.047846116},{"x":0.04223492,"y":0.9976552,"z":0.05385523},{"x":0.10522894,"y":0.99389964,"z":0.03302134},{"x":0.116069615,"y":0.9932331,"z":-0.0039985473},{"x":0.9696334,"y":0.24439172,"z":-0.0091546},{"x":-0.9704191,"y":0.24093398,"z":0.015412347},{"x":0.07400006,"y":0.99697566,"z":0.02374072},{"x":0.06050096,"y":0.9970839,"z":-0.04651138},{"x":0.01693444,"y":0.9954727,"z":-0.09352697},{"x":-0.034921903,"y":0.9949461,"z":-0.09414218},{"x":-0.06953708,"y":0.9964168,"z":-0.048146825},{"x":-0.069647074,"y":0.99733114,"z":0.021907305},{"x":-0.03507771,"y":0.9960942,"z":0.081030466},{"x":0.016796663,"y":0.9948623,"z":0.09983456},{"x":0.06034516,"y":0.99577284,"z":0.06924546},{"x":0.07403309,"y":0.997247,"z":0.004198554},{"x":0.050855435,"y":0.99671686,"z":-0.063001476},{"x":0.0024691024,"y":0.9951076,"z":-0.09876635},{"x":-0.047065873,"y":0.99521595,"z":-0.08561576},{"x":-0.073358715,"y":0.9968541,"z":-0.030005058},{"x":-0.06317992,"y":0.9971738,"z":0.040653773},{"x":-0.021582052,"y":0.99561703,"z":0.09099988},{"x":0.030608024,"y":0.9948944,"z":0.096168295},{"x":0.06768165,"y":0.9962586,"z":0.053739518},{"x":0.071194224,"y":0.9973418,"z":-0.015513668},{"x":0.96978176,"y":0.24343704,"z":-0.016180338},{"x":-0.9701064,"y":0.24194762,"z":0.018837217},{"x":0.010713204,"y":0.9996534,"z":0.024048286},{"x":0.003618154,"y":0.9982157,"z":-0.059602182},{"x":-0.005233025,"y":0.9935695,"z":-0.11310332},{"x":-0.011472188,"y":0.99378073,"z":-0.11076223},{"x":-0.012072466,"y":0.99848586,"z":-0.053668894},{"x":-0.0066325795,"y":0.9995091,"z":0.030620826},{"x":0.0021025953,"y":0.99505323,"z":0.09932095},{"x":0.009743316,"y":0.99289453,"z":0.11859797},{"x":0.012600907,"y":0.99675304,"z":0.079527624},{"x":0.009195155,"y":0.99995756,"z":0.0005214699},{"x":0.0011808925,"y":0.99689317,"z":-0.07875712},{"x":-0.0073619927,"y":0.9929307,"z":-0.11846722},{"x":-0.01227035,"y":0.9949234,"z":-0.099884644},{"x":-0.011123322,"y":0.9994379,"z":-0.03162559},{"x":-0.0043954263,"y":0.9985986,"z":0.052740958},{"x":0.0044836774,"y":0.99387974,"z":0.11037687},{"x":0.01111194,"y":0.9934842,"z":0.113427036},{"x":0.012282864,"y":0.99809283,"z":0.060496595},{"x":0.0073169894,"y":0.9997081,"z":-0.023027055},{"x":0.9700272,"y":0.24225739,"z":-0.018936265},{"x":-0.8272533,"y":0.20720714,"z":0.52222335},{"x":-0.0072136708,"y":0.19803077,"z":0.98016924},{"x":-0.006539139,"y":0.1982377,"z":0.98013216},{"x":-0.00259988,"y":0.19738479,"z":0.98032266},{"x":0.0025823393,"y":0.19590883,"z":0.9806187},{"x":0.0064185136,"y":0.19455206,"z":0.9808712},{"x":0.0070655504,"y":0.19397707,"z":0.98098063},{"x":0.004234751,"y":0.19445698,"z":0.98090196},{"x":-0.0007204112,"y":0.19576108,"z":0.9806514},{"x":-0.0053872047,"y":0.19725633,"z":0.98033714},{"x":-0.007409512,"y":0.19819689,"z":0.98013425},{"x":-0.005725345,"y":0.19810037,"z":0.980165},{"x":-0.0011961978,"y":0.19701445,"z":0.9803999},{"x":0.0038652734,"y":0.19549356,"z":0.9806974},{"x":0.006955991,"y":0.1942969,"z":0.9809181},{"x":0.0066075227,"y":0.19400409,"z":0.9809785},{"x":0.003001175,"y":0.19475275,"z":0.9808478},{"x":-0.0021359953,"y":0.19618331,"z":0.98056495},{"x":-0.006279846,"y":0.1975967,"z":0.9802633},{"x":-0.0073163593,"y":0.19828223,"z":0.9801177},{"x":0.3709832,"y":0.09232905,"z":0.9240383},{"x":-0.02770436,"y":0.029570535,"z":-0.9991787},{"x":0.0012347224,"y":0.006790848,"z":-0.9999762},{"x":0.44641164,"y":0.5042417,"z":-0.73922724},{"x":-0.00858531,"y":0.023787808,"z":-0.99968016},{"x":0.017548649,"y":0.007504768,"z":-0.99981785},{"x":0.58145934,"y":0.012099385,"z":-0.81348556},{"x":-0.6287579,"y":-0.012967416,"z":-0.77749306},{"x":-0.37949702,"y":0.81315637,"z":-0.4413148},{"x":-0.04110461,"y":-0.00015470032,"z":-0.99915487},{"x":0.008106902,"y":0.014254177,"z":0.9998656},{"x":-0.03877538,"y":0.046370078,"z":0.9981715},{"x":0.4336204,"y":0.7526088,"z":0.49553353},{"x":-0.0066472343,"y":0.029676408,"z":0.99953747},{"x":0.069549836,"y":-0.0014463317,"z":0.9975774},{"x":0.7447018,"y":-0.009171785,"z":0.6673344},{"x":-0.90820295,"y":0.013714011,"z":0.41830543},{"x":-0.04448264,"y":0.020910714,"z":0.9987913},{"x":-0.6556799,"y":0.5328474,"z":0.53493696},{"x":-0.99995863,"y":0.008993192,"z":0.001353552},{"x":-0.99818885,"y":0.041854646,"z":-0.043210708},{"x":-0.99948764,"y":0.029663472,"z":-0.012024733},{"x":-0.9998854,"y":0.0118071465,"z":0.009481229},{"x":-0.9983515,"y":0.0007589113,"z":-0.057390694},{"x":0.9991209,"y":0.030768065,"z":-0.028474033},{"x":0.99928373,"y":0.037697803,"z":-0.0033033022},{"x":0.9975166,"y":0.0678755,"z":-0.01880523},{"x":0.79205054,"y":0.6058426,"z":-0.07490449},{"x":0.9965906,"y":0.0012658217,"z":0.082496025},{"x":0.39405522,"y":0.78811044,"z":0.47286627},{"x":-0.65094453,"y":0.65094453,"z":0.39056674},{"x":-0.30485538,"y":-0.60971075,"z":0.7316529},{"x":0.23287322,"y":-0.93149287,"z":0.27944788},{"x":0.30485538,"y":0.60971075,"z":-0.7316529},{"x":-0.39405522,"y":-0.78811044,"z":-0.47286627},{"x":-0.23287322,"y":0.93149287,"z":-0.27944788},{"x":0.65094453,"y":-0.65094453,"z":-0.39056674},{"x":0.44314766,"y":0.8862953,"z":0.13453923},{"x":0.2655555,"y":0.9294616,"z":0.2560887},{"x":0.26670775,"y":0.93754756,"z":0.22331949},{"x":0.22826344,"y":0.8760788,"z":-0.42471373},{"x":0.37613252,"y":0.75226504,"z":-0.5409451},{"x":0.37104982,"y":-0.92481565,"z":0.083893634},{"x":0.38805625,"y":-0.91771144,"z":0.08495938},{"x":0.10918242,"y":-0.9918873,"z":0.06510646},{"x":0.5960436,"y":-0.75993407,"z":-0.25929183},{"x":0.5825394,"y":-0.7690621,"z":-0.26304275},{"x":0.35180974,"y":-0.8826668,"z":-0.3116555},{"x":0.258639,"y":-0.9525156,"z":-0.16068575},{"x":0.7498953,"y":0.56728005,"z":0.34036806},{"x":0.8361611,"y":-0.47032118,"z":-0.28219274},{"x":0.4334779,"y":-0.8950621,"z":0.10469417},{"x":0.6492112,"y":-0.7123449,"z":-0.26662657},{"x":0.5321186,"y":0.33862093,"z":0.7760062},{"x":-0.7825589,"y":0.24899603,"z":0.5706159},{"x":-0.25747314,"y":0.34920442,"z":0.9009794},{"x":-0.76809555,"y":0.22761829,"z":0.5985141},{"x":-0.15300019,"y":0.24568558,"z":0.9571988},{"x":0.053892378,"y":0.034295134,"z":0.99795765},{"x":-0.22907092,"y":-0.118815415,"z":0.96613115},{"x":0.02569582,"y":0.016351871,"z":0.99953604},{"x":-0.5402606,"y":-0.023256548,"z":0.84117633},{"x":-0.18223864,"y":-0.11597007,"z":0.9763914},{"x":-0.3742912,"y":-0.124467224,"z":0.91892004},{"x":-0.41958353,"y":-0.12529531,"z":0.89902765},{"x":0.051692296,"y":0.003785458,"z":0.9986559},{"x":0.43570432,"y":-0.40898043,"z":0.8018084},{"x":0.50934476,"y":-0.4345393,"z":0.7427944},{"x":0.065165244,"y":-0.25437543,"z":0.9649076},{"x":0.16338466,"y":-0.30425978,"z":0.9384729},{"x":0.31763,"y":0.20212819,"z":-0.92642075},{"x":-0.14824761,"y":0.42158702,"z":-0.89458764},{"x":-0.45899722,"y":0.5841782,"z":-0.6693709},{"x":-0.47371292,"y":0.5462128,"z":-0.69083124},{"x":-0.19049075,"y":-0.026580557,"z":-0.9813291},{"x":-0.19443847,"y":-0.12373356,"z":-0.9730795},{"x":-0.4421926,"y":-0.25597718,"z":-0.859617},{"x":-0.56519604,"y":0.034278747,"z":-0.82424414},{"x":-0.22179386,"y":-0.14114153,"z":-0.9648246},{"x":-0.5285373,"y":-0.20350474,"z":-0.82415664},{"x":-0.5556636,"y":-0.18596478,"z":-0.8103426},{"x":-0.41987523,"y":-0.26719332,"z":-0.8673595},{"x":-0.1813024,"y":-0.14039381,"z":-0.9733545},{"x":0.7246966,"y":-0.16975324,"z":-0.66783136},{"x":0.6318312,"y":-0.19898471,"z":-0.74912906},{"x":-0.00027881717,"y":-0.28573865,"z":-0.95830756},{"x":0.18473612,"y":-0.27737716,"z":-0.94283324},{"x":0.9063042,"y":-0.16902693,"z":-0.38735333},{"x":0.50934476,"y":-0.4345393,"z":0.7427944},{"x":0.62158436,"y":-0.44090995,"z":0.6474807},{"x":0.95175153,"y":-0.28222883,"z":-0.120482385},{"x":-0.81712615,"y":0.23055156,"z":0.5283473},{"x":-0.5415505,"y":-0.17489105,"z":0.822275},{"x":-0.92539036,"y":-0.15158518,"z":-0.34738266},{"x":-0.925252,"y":-0.34572288,"z":0.15615499},{"x":-0.8789531,"y":-0.34507814,"z":-0.3291847},{"x":-0.70041484,"y":-0.28545478,"z":-0.6541672},{"x":-0.58940196,"y":-0.2079969,"z":-0.780604}],"positions":[{"x":-15,"y":-0.10992316,"z":-12},{"x":-15,"y":0.017355446,"z":-10.8},{"x":-13.5,"y":-0.027083233,"z":-12},{"x":-13.5,"y":0.004276092,"z":-10.8},{"x":-12,"y":0.0692005,"z":-12},{"x":-12,"y":-0.010925865,"z":-10.8},{"x":-10.5,"y":0.1311339,"z":-12},{"x":-10.5,"y":-0.020704348,"z":-10.8},{"x":-9,"y":0.12797393,"z":-12},{"x":-9,"y":-0.02020543,"z":-10.8},{"x":-7.5,"y":0.061289173,"z":-12},{"x":-7.5,"y":-0.009676768,"z":-10.8},{"x":-6,"y":-0.035818834,"z":-12},{"x":-6,"y":0.0056553306,"z":-10.8},{"x":-4.5,"y":-0.11514678,"z":-12},{"x":-4.5,"y":0.018180188,"z":-10.8},{"x":-3,"y":-0.13731718,"z":-12},{"x":-3,"y":0.021680608,"z":-10.8},{"x":-1.5,"y":-0.09132491,"z":-12},{"x":-1.5,"y":0.014419023,"z":-10.8},{"x":0,"y":0,"z":-12},{"x":0,"y":-0,"z":-10.8},{"x":1.5,"y":0.09132491,"z":-12},{"x":1.5,"y":-0.014419023,"z":-10.8},{"x":3,"y":0.13731718,"z":-12},{"x":3,"y":-0.021680608,"z":-10.8},{"x":4.5,"y":0.11514678,"z":-12},{"x":4.5,"y":-0.018180188,"z":-10.8},{"x":6,"y":0.035818834,"z":-12},{"x":6,"y":-0.0056553306,"z":-10.8},{"x":7.5,"y":-0.061289173,"z":-12},{"x":7.5,"y":0.009676768,"z":-10.8},{"x":9,"y":-0.12797393,"z":-12},{"x":9,"y":0.02020543,"z":-10.8},{"x":10.5,"y":-0.1311339,"z":-12},{"x":10.5,"y":0.020704348,"z":-10.8},{"x":12,"y":-0.0692005,"z":-12},{"x":12,"y":0.010925865,"z":-10.8},{"x":13.5,"y":0.027083233,"z":-12},{"x":13.5,"y":-0.004276092,"z":-10.8},{"x":15,"y":0.10992316,"z":-12},{"x":15,"y":-0.017355446,"z":-10.8},{"x":-15,"y":0.1401147,"z":-9.6},{"x":-13.5,"y":0.034521922,"z":-9.6},{"x":-12,"y":-0.08820713,"z":-9.6},{"x":-10.5,"y":-0.16715118,"z":-9.6},{"x":-9,"y":-0.1631233,"z":-9.6},{"x":-7.5,"y":-0.07812288,"z":-9.6},{"x":-6,"y":0.045656845,"z":-9.6},{"x":-4.5,"y":0.14677303,"z":-9.6},{"x":-3,"y":0.17503276,"z":-9.6},{"x":-1.5,"y":0.116408244,"z":-9.6},{"x":0,"y":-0,"z":-9.6},{"x":1.5,"y":-0.116408244,"z":-9.6},{"x":3,"y":-0.17503276,"z":-9.6},{"x":4.5,"y":-0.14677303,"z":-9.6},{"x":6,"y":-0.045656845,"z":-9.6},{"x":7.5,"y":0.07812288,"z":-9.6},{"x":9,"y":0.1631233,"z":-9.6},{"x":10.5,"y":0.16715118,"z":-9.6},{"x":12,"y":0.08820713,"z":-9.6},{"x":13.5,"y":-0.034521922,"z":-9.6},{"x":15,"y":-0.1401147,"z":-9.6},{"x":-15,"y":0.22638805,"z":-8.4},{"x":-13.5,"y":0.05577824,"z":-8.4},{"x":-12,"y":-0.14251927,"z":-8.4},{"x":-10.5,"y":-0.27007183,"z":-8.4},{"x":-9,"y":-0.26356384,"z":-8.4},{"x":-7.5,"y":-0.12622578,"z":-8.4},{"x":-6,"y":0.07376931,"z":-8.4},{"x":-4.5,"y":0.23714615,"z":-8.4},{"x":-3,"y":0.28280637,"z":-8.4},{"x":-1.5,"y":0.18808474,"z":-8.4},{"x":0,"y":-0,"z":-8.4},{"x":1.5,"y":-0.18808474,"z":-8.4},{"x":3,"y":-0.28280637,"z":-8.4},{"x":4.5,"y":-0.23714615,"z":-8.4},{"x":6,"y":-0.07376931,"z":-8.4},{"x":7.5,"y":0.12622578,"z":-8.4},{"x":9,"y":0.26356384,"z":-8.4},{"x":10.5,"y":0.27007183,"z":-8.4},{"x":12,"y":0.14251927,"z":-8.4},{"x":13.5,"y":-0.05577824,"z":-8.4},{"x":15,"y":-0.22638805,"z":-8.4},{"x":-15,"y":0.2537098,"z":-7.2},{"x":-13.5,"y":0.06250986,"z":-7.2},{"x":-12,"y":-0.15971926,"z":-7.2},{"x":-10.5,"y":-0.30266556,"z":-7.2},{"x":-9,"y":-0.29537216,"z":-7.2},{"x":-7.5,"y":-0.14145939,"z":-7.2},{"x":-6,"y":0.08267219,"z":-7.2},{"x":-4.5,"y":0.26576623,"z":-7.2},{"x":-3,"y":0.31693697,"z":-7.2},{"x":-1.5,"y":0.21078382,"z":-7.2},{"x":0,"y":-0,"z":-7.2},{"x":1.5,"y":-0.21078382,"z":-7.2},{"x":3,"y":-0.31693697,"z":-7.2},{"x":4.5,"y":-0.26576623,"z":-7.2},{"x":6,"y":-0.08267219,"z":-7.2},{"x":7.5,"y":0.14145939,"z":-7.2},{"x":9,"y":0.29537216,"z":-7.2},{"x":10.5,"y":0.30266556,"z":-7.2},{"x":12,"y":0.15971926,"z":-7.2},{"x":13.5,"y":-0.06250986,"z":-7.2},{"x":15,"y":-0.2537098,"z":-7.2},{"x":-15,"y":0.2149654,"z":-6},{"x":-13.5,"y":0.052963886,"z":-6},{"x":-12,"y":-0.1353283,"z":-6},{"x":-10.5,"y":-0.25644505,"z":-6},{"x":-9,"y":-0.25026542,"z":-6},{"x":-7.5,"y":-0.11985692,"z":-6},{"x":-6,"y":0.0700472,"z":-6},{"x":-4.5,"y":0.22518069,"z":-6},{"x":-3,"y":0.26853707,"z":-6},{"x":-1.5,"y":0.17859472,"z":-6},{"x":0,"y":-0,"z":-6},{"x":1.5,"y":-0.17859472,"z":-6},{"x":3,"y":-0.26853707,"z":-6},{"x":4.5,"y":-0.22518069,"z":-6},{"x":6,"y":-0.0700472,"z":-6},{"x":7.5,"y":0.11985692,"z":-6},{"x":9,"y":0.25026542,"z":-6},{"x":10.5,"y":0.25644505,"z":-6},{"x":12,"y":0.1353283,"z":-6},{"x":13.5,"y":-0.052963886,"z":-6},{"x":15,"y":-0.2149654,"z":-6},{"x":-15,"y":0.12024387,"z":-4.7999997},{"x":-13.5,"y":0.029626083,"z":-4.7999997},{"x":-12,"y":-0.07569776,"z":-4.7999997},{"x":-10.5,"y":-0.14344609,"z":-4.7999997},{"x":-9,"y":-0.13998944,"z":-4.7999997},{"x":-7.5,"y":-0.067043625,"z":-4.7999997},{"x":-6,"y":0.039181873,"z":-4.7999997},{"x":-4.5,"y":0.12595794,"z":-4.7999997},{"x":-3,"y":0.15020993,"z":-4.7999997},{"x":-1.5,"y":0.099899426,"z":-4.7999997},{"x":0,"y":-0,"z":-4.7999997},{"x":1.5,"y":-0.099899426,"z":-4.7999997},{"x":3,"y":-0.15020993,"z":-4.7999997},{"x":4.5,"y":-0.12595794,"z":-4.7999997},{"x":6,"y":-0.039181873,"z":-4.7999997},{"x":7.5,"y":0.067043625,"z":-4.7999997},{"x":9,"y":0.13998944,"z":-4.7999997},{"x":10.5,"y":0.14344609,"z":-4.7999997},{"x":12,"y":0.07569776,"z":-4.7999997},{"x":13.5,"y":-0.029626083,"z":-4.7999997},{"x":15,"y":-0.12024387,"z":-4.7999997},{"x":-15,"y":-0.005789125,"z":-3.6000004},{"x":-13.5,"y":-0.0014263437,"z":-3.6000004},{"x":-12,"y":0.003644458,"z":-3.6000004},{"x":-10.5,"y":0.0069061923,"z":-3.6000004},{"x":-9,"y":0.0067397724,"z":-3.6000004},{"x":-7.5,"y":0.0032278062,"z":-3.6000004},{"x":-6,"y":-0.0018864059,"z":-3.6000004},{"x":-4.5,"y":-0.006064228,"z":-3.6000004},{"x":-3,"y":-0.007231836,"z":-3.6000004},{"x":-1.5,"y":-0.004809644,"z":-3.6000004},{"x":0,"y":0,"z":-3.6000004},{"x":1.5,"y":0.004809644,"z":-3.6000004},{"x":3,"y":0.007231836,"z":-3.6000004},{"x":4.5,"y":0.006064228,"z":-3.6000004},{"x":6,"y":0.0018864059,"z":-3.6000004},{"x":7.5,"y":-0.0032278062,"z":-3.6000004},{"x":9,"y":-0.0067397724,"z":-3.6000004},{"x":10.5,"y":-0.0069061923,"z":-3.6000004},{"x":12,"y":-0.003644458,"z":-3.6000004},{"x":13.5,"y":0.0014263437,"z":-3.6000004},{"x":15,"y":0.005789125,"z":-3.6000004},{"x":-15,"y":-0.13031477,"z":-2.3999996},{"x":-13.5,"y":-0.032107383,"z":-2.3999996},{"x":-12,"y":0.08203774,"z":-2.3999996},{"x":-10.5,"y":0.15546027,"z":-2.3999996},{"x":-9,"y":0.1517141,"z":-2.3999996},{"x":-7.5,"y":0.07265879,"z":-2.3999996},{"x":-6,"y":-0.042463508,"z":-2.3999996},{"x":-4.5,"y":-0.1365074,"z":-2.3999996},{"x":-3,"y":-0.1627906,"z":-2.3999996},{"x":-1.5,"y":-0.1082664,"z":-2.3999996},{"x":0,"y":0,"z":-2.3999996},{"x":1.5,"y":0.1082664,"z":-2.3999996},{"x":3,"y":0.1627906,"z":-2.3999996},{"x":4.5,"y":0.1365074,"z":-2.3999996},{"x":6,"y":0.042463508,"z":-2.3999996},{"x":7.5,"y":-0.07265879,"z":-2.3999996},{"x":9,"y":-0.1517141,"z":-2.3999996},{"x":10.5,"y":-0.15546027,"z":-2.3999996},{"x":12,"y":-0.08203774,"z":-2.3999996},{"x":13.5,"y":0.032107383,"z":-2.3999996},{"x":15,"y":0.13031477,"z":-2.3999996},{"x":-15,"y":-0.22090623,"z":-1.2000008},{"x":-13.5,"y":-0.05442761,"z":-1.2000008},{"x":-12,"y":0.12481065,"z":-1.5},{"x":-12,"y":0.13906826,"z":-1.2000008},{"x":-11.625001,"y":0.14316626,"z":-1.5},{"x":-10.5,"y":0.23651427,"z":-1.5},{"x":-10.125001,"y":0.23557773,"z":-1.5},{"x":-9.116441,"y":0.2313079,"z":-1.5},{"x":-9,"y":0.23081493,"z":-1.5},{"x":-8.625001,"y":0.21105114,"z":-1.5},{"x":-8.5,"y":0.19988337,"z":-1.5},{"x":-7.5,"y":0.12316931,"z":-1.2000008},{"x":-8.5,"y":0.21251099,"z":-1.2000008},{"x":-6,"y":-0.07198304,"z":-1.2000008},{"x":-4.5,"y":-0.23140384,"z":-1.2000008},{"x":-3,"y":-0.27595842,"z":-1.2000008},{"x":-1.5,"y":-0.1835304,"z":-1.2000008},{"x":0,"y":0,"z":-1.2000008},{"x":1.5,"y":0.1835304,"z":-1.2000008},{"x":3,"y":0.27595842,"z":-1.2000008},{"x":4.5,"y":0.23140384,"z":-1.2000008},{"x":6,"y":0.07198304,"z":-1.2000008},{"x":7.5,"y":-0.12316931,"z":-1.2000008},{"x":9,"y":-0.25718182,"z":-1.2000008},{"x":10.5,"y":-0.26353222,"z":-1.2000008},{"x":12,"y":-0.13906826,"z":-1.2000008},{"x":13.5,"y":0.05442761,"z":-1.2000008},{"x":15,"y":0.22090623,"z":-1.2000008},{"x":-15,"y":-0.25397372,"z":0},{"x":-13.5,"y":-0.062574886,"z":0},{"x":-12,"y":0.1598854,"z":0},{"x":-8.5,"y":0.23817605,"z":-0.40000024},{"x":-7.5,"y":0.14160654,"z":0},{"x":-8.5,"y":0.2443218,"z":0},{"x":-6,"y":-0.08275819,"z":0},{"x":-4.4791493,"y":-0.24640104,"z":0.016564334},{"x":-2.9554975,"y":-0.2659615,"z":-0.025014149},{"x":-1.4346846,"y":-0.18096784,"z":-0.05412858},{"x":-0.0019116977,"y":0.00043835476,"z":-0.00032304612},{"x":1.5,"y":0.2110031,"z":0},{"x":3,"y":0.31726667,"z":0},{"x":4.5,"y":0.2660427,"z":0},{"x":6,"y":0.08275819,"z":0},{"x":7.5,"y":-0.14160654,"z":0},{"x":9,"y":-0.29567942,"z":0},{"x":10.5,"y":-0.30298042,"z":0},{"x":12,"y":-0.1598854,"z":0},{"x":13.5,"y":0.062574886,"z":0},{"x":15,"y":0.25397372,"z":0},{"x":-15,"y":-0.22090623,"z":1.2000008},{"x":-13.5,"y":-0.05442761,"z":1.2000008},{"x":-12,"y":0.15220423,"z":0.44278002},{"x":-12,"y":0.14427255,"z":0.9},{"x":-12,"y":0.13906826,"z":1.2000008},{"x":-11.624999,"y":0.18004638,"z":0.9},{"x":-12,"y":0.14427255,"z":0.9},{"x":-10.5,"y":0.26353222,"z":1.2000008},{"x":-10.5,"y":0.2733943,"z":0.9},{"x":-10.124999,"y":0.27156904,"z":0.9},{"x":-9,"y":0.25718182,"z":1.2000008},{"x":-9.093598,"y":0.2672025,"z":0.9},{"x":-9,"y":0.26680624,"z":0.9},{"x":-8.624999,"y":0.22828795,"z":0.9},{"x":-8.5,"y":0.22904117,"z":0.47630885},{"x":-8.5,"y":0.21865673,"z":0.8000005},{"x":-7.5,"y":0.12316931,"z":1.2000008},{"x":-8.5,"y":0.2171203,"z":0.9},{"x":-6,"y":-0.07198304,"z":1.2000008},{"x":-4.525356,"y":-0.19724837,"z":1.2334838},{"x":-3.9766972,"y":-0.14503625,"z":0.41859898},{"x":-4.024417,"y":-0.13242756,"z":0.79522896},{"x":-3.5106533,"y":-0.16060202,"z":0.38021868},{"x":-4.0139065,"y":-0.12894593,"z":1.0660932},{"x":-4.020327,"y":-0.15251167,"z":1.2877443},{"x":-3.264903,"y":-0.1753981,"z":0.3893209},{"x":-2.908183,"y":-0.2127876,"z":0.38446197},{"x":-1.9906086,"y":-0.11072375,"z":0.3559024},{"x":-1.4726975,"y":-0.0771559,"z":0.37536135},{"x":-0.9922741,"y":-0.004326731,"z":0.37422365},{"x":-0.9241685,"y":-0.021644853,"z":0.7682619},{"x":-0.0060014734,"y":0.0013604009,"z":1.1938804},{"x":-0.96931934,"y":-0.0039390847,"z":1.1800985},{"x":-0.89653647,"y":-0.08788279,"z":1.2707614},{"x":1.5,"y":0.1835304,"z":1.2000008},{"x":3,"y":0.27595842,"z":1.2000008},{"x":4.5,"y":0.23140384,"z":1.2000008},{"x":6,"y":0.07198304,"z":1.2000008},{"x":7.5,"y":-0.12316931,"z":1.2000008},{"x":9,"y":-0.25718182,"z":1.2000008},{"x":10.5,"y":-0.26353222,"z":1.2000008},{"x":12,"y":-0.13906826,"z":1.2000008},{"x":13.5,"y":0.05442761,"z":1.2000008},{"x":15,"y":0.22090623,"z":1.2000008},{"x":-15,"y":-0.13031468,"z":2.4000006},{"x":-13.5,"y":-0.03210736,"z":2.4000006},{"x":-12,"y":0.08203768,"z":2.4000006},{"x":-10.5,"y":0.15546016,"z":2.4000006},{"x":-9,"y":0.151714,"z":2.4000006},{"x":-7.5,"y":0.07265874,"z":2.4000006},{"x":-6,"y":-0.042463478,"z":2.4000006},{"x":-4.55787,"y":-0.117113546,"z":2.4517088},{"x":-4.0524535,"y":-0.0654246,"z":2.0180767},{"x":-4.0304093,"y":-0.025263913,"z":2.4396768},{"x":-0.91532063,"y":0.024293728,"z":1.9794412},{"x":-0.01087132,"y":0.0027036336,"z":2.4013472},{"x":-0.9505664,"y":0.043036535,"z":2.4343812},{"x":1.5,"y":0.108266324,"z":2.4000006},{"x":3,"y":0.16279048,"z":2.4000006},{"x":4.5,"y":0.13650732,"z":2.4000006},{"x":6,"y":0.042463478,"z":2.4000006},{"x":7.5,"y":-0.07265874,"z":2.4000006},{"x":9,"y":-0.151714,"z":2.4000006},{"x":10.5,"y":-0.15546016,"z":2.4000006},{"x":12,"y":-0.08203768,"z":2.4000006},{"x":13.5,"y":0.03210736,"z":2.4000006},{"x":15,"y":0.13031468,"z":2.4000006},{"x":-15,"y":-0.0057892287,"z":3.5999994},{"x":-13.5,"y":-0.0014263694,"z":3.5999994},{"x":-12,"y":0.0036445234,"z":3.5999994},{"x":-10.5,"y":0.0069063166,"z":3.5999994},{"x":-9,"y":0.0067398935,"z":3.5999994},{"x":-7.5,"y":0.0032278642,"z":3.5999994},{"x":-6,"y":-0.0018864397,"z":3.5999994},{"x":-4.508303,"y":-0.0042885076,"z":3.6012187},{"x":-4.0421176,"y":0.040591605,"z":2.9760735},{"x":-3.7463143,"y":0.03351056,"z":3.0545623},{"x":-3.0569293,"y":0.0049792905,"z":3.6037655},{"x":-3.0904775,"y":0.041512974,"z":3.018437},{"x":-2.906853,"y":0.0026125833,"z":3.0233598},{"x":-2.1938813,"y":0.04684385,"z":3.0530188},{"x":-1.5086693,"y":0.010553749,"z":3.5784354},{"x":-1.4847513,"y":0.06882416,"z":3.0308514},{"x":-0.99739516,"y":0.10594039,"z":2.969687},{"x":0,"y":0,"z":3.5999994},{"x":1.5,"y":0.0048097307,"z":3.5999994},{"x":3,"y":0.007231966,"z":3.5999994},{"x":4.5,"y":0.0060643367,"z":3.5999994},{"x":6,"y":0.765283,"z":3.5999994},{"x":7.5,"y":0.7645158,"z":3.5999994},{"x":9,"y":0.76398903,"z":3.5999994},{"x":10.5,"y":-0.0069063166,"z":3.5999994},{"x":12,"y":-0.0036445234,"z":3.5999994},{"x":13.5,"y":0.0014263694,"z":3.5999994},{"x":15,"y":0.0057892287,"z":3.5999994},{"x":-15,"y":0.120243825,"z":4.799999},{"x":-13.5,"y":0.029626071,"z":4.799999},{"x":-12,"y":-0.07569773,"z":4.799999},{"x":-10.5,"y":-0.14344604,"z":4.799999},{"x":-9,"y":-0.13998938,"z":4.799999},{"x":-7.5,"y":-0.0670436,"z":4.799999},{"x":-6,"y":0.03918186,"z":4.799999},{"x":-4.5,"y":0.12595789,"z":4.799999},{"x":-3,"y":0.15020987,"z":4.799999},{"x":-1.5,"y":0.09989939,"z":4.799999},{"x":0,"y":-0,"z":4.799999},{"x":1.5,"y":-0.09989939,"z":4.799999},{"x":3,"y":-0.15020987,"z":4.799999},{"x":4.5,"y":-0.12595789,"z":4.799999},{"x":6,"y":0.7591227,"z":4.799999},{"x":7.5,"y":0.77505654,"z":4.799999},{"x":9,"y":0.7859984,"z":4.799999},{"x":10.5,"y":0.14344604,"z":4.799999},{"x":12,"y":0.07569773,"z":4.799999},{"x":13.5,"y":-0.029626071,"z":4.799999},{"x":15,"y":-0.120243825,"z":4.799999},{"x":-15,"y":0.2149654,"z":6},{"x":-13.5,"y":0.052963886,"z":6},{"x":-12,"y":-0.1353283,"z":6},{"x":-10.5,"y":-0.25644505,"z":6},{"x":-9,"y":-0.25026542,"z":6},{"x":-7.5,"y":-0.11985692,"z":6},{"x":-6,"y":0.0700472,"z":6},{"x":-4.5,"y":0.22518069,"z":6},{"x":-3,"y":0.26853707,"z":6},{"x":-1.5,"y":0.17859472,"z":6},{"x":0,"y":-0,"z":6},{"x":1.5,"y":-0.17859472,"z":6},{"x":3,"y":-0.26853707,"z":6},{"x":4.5,"y":-0.22518069,"z":6},{"x":6,"y":0.75449294,"z":6},{"x":7.5,"y":0.78297853,"z":6},{"x":9,"y":0.8025398,"z":6},{"x":10.5,"y":0.25644505,"z":6},{"x":12,"y":0.1353283,"z":6},{"x":13.5,"y":-0.052963886,"z":6},{"x":15,"y":-0.2149654,"z":6},{"x":-15,"y":0.2537098,"z":7.200001},{"x":-13.5,"y":0.06250986,"z":7.200001},{"x":-12,"y":-0.15971926,"z":7.200001},{"x":-10.5,"y":-0.30266556,"z":7.200001},{"x":-9,"y":-0.29537216,"z":7.200001},{"x":-7.5,"y":-0.14145939,"z":7.200001},{"x":-6,"y":0.08267219,"z":7.200001},{"x":-4.5,"y":0.26576626,"z":7.200001},{"x":-3,"y":0.31693697,"z":7.200001},{"x":-1.5,"y":0.21078382,"z":7.200001},{"x":0,"y":-0,"z":7.200001},{"x":1.5,"y":-0.21078382,"z":7.200001},{"x":3,"y":-0.31693697,"z":7.200001},{"x":4.5,"y":-0.26576626,"z":7.200001},{"x":6,"y":-0.08267219,"z":7.200001},{"x":7.5,"y":0.14145939,"z":7.200001},{"x":9,"y":0.29537216,"z":7.200001},{"x":10.5,"y":0.30266556,"z":7.200001},{"x":12,"y":0.15971926,"z":7.200001},{"x":13.5,"y":-0.06250986,"z":7.200001},{"x":15,"y":-0.2537098,"z":7.200001},{"x":-15,"y":0.22638796,"z":8.400002},{"x":-13.5,"y":0.055778217,"z":8.400002},{"x":-12,"y":-0.1425192,"z":8.400002},{"x":-10.5,"y":-0.27007172,"z":8.400002},{"x":-9,"y":-0.26356372,"z":8.400002},{"x":-7.5,"y":-0.12622572,"z":8.400002},{"x":-6,"y":0.07376928,"z":8.400002},{"x":-4.5,"y":0.23714606,"z":8.400002},{"x":-3,"y":0.28280625,"z":8.400002},{"x":-1.5,"y":0.18808466,"z":8.400002},{"x":0,"y":-0,"z":8.400002},{"x":1.5,"y":-0.18808466,"z":8.400002},{"x":3,"y":-0.28280625,"z":8.400002},{"x":4.5,"y":-0.23714606,"z":8.400002},{"x":6,"y":-0.07376928,"z":8.400002},{"x":7.5,"y":0.12622572,"z":8.400002},{"x":9,"y":0.26356372,"z":8.400002},{"x":10.5,"y":0.27007172,"z":8.400002},{"x":12,"y":0.1425192,"z":8.400002},{"x":13.5,"y":-0.055778217,"z":8.400002},{"x":15,"y":-0.22638796,"z":8.400002},{"x":-15,"y":0.14011486,"z":9.599998},{"x":-13.5,"y":0.034521963,"z":9.599998},{"x":-12,"y":-0.088207245,"z":9.599998},{"x":-10.5,"y":-0.16715139,"z":9.599998},{"x":-9,"y":-0.1631235,"z":9.599998},{"x":-7.5,"y":-0.07812297,"z":9.599998},{"x":-6,"y":0.0456569,"z":9.599998},{"x":-4.5,"y":0.1467732,"z":9.599998},{"x":-3,"y":0.17503297,"z":9.599998},{"x":-1.5,"y":0.116408385,"z":9.599998},{"x":0,"y":-0,"z":9.599998},{"x":1.5,"y":-0.116408385,"z":9.599998},{"x":3,"y":-0.17503297,"z":9.599998},{"x":4.5,"y":-0.1467732,"z":9.599998},{"x":6,"y":-0.0456569,"z":9.599998},{"x":7.5,"y":0.07812297,"z":9.599998},{"x":9,"y":0.1631235,"z":9.599998},{"x":10.5,"y":0.16715139,"z":9.599998},{"x":12,"y":0.088207245,"z":9.599998},{"x":13.5,"y":-0.034521963,"z":9.599998},{"x":15,"y":-0.14011486,"z":9.599998},{"x":-15,"y":0.01735555,"z":10.799999},{"x":-13.5,"y":0.004276118,"z":10.799999},{"x":-12,"y":-0.010925931,"z":10.799999},{"x":-10.5,"y":-0.020704472,"z":10.799999},{"x":-9,"y":-0.020205552,"z":10.799999},{"x":-7.5,"y":-0.009676826,"z":10.799999},{"x":-6,"y":0.0056553646,"z":10.799999},{"x":-4.5,"y":0.018180298,"z":10.799999},{"x":-3,"y":0.021680739,"z":10.799999},{"x":-1.5,"y":0.01441911,"z":10.799999},{"x":0,"y":-0,"z":10.799999},{"x":1.5,"y":-0.01441911,"z":10.799999},{"x":3,"y":-0.021680739,"z":10.799999},{"x":4.5,"y":-0.018180298,"z":10.799999},{"x":6,"y":-0.0056553646,"z":10.799999},{"x":7.5,"y":0.009676826,"z":10.799999},{"x":9,"y":0.020205552,"z":10.799999},{"x":10.5,"y":0.020704472,"z":10.799999},{"x":12,"y":0.010925931,"z":10.799999},{"x":13.5,"y":-0.004276118,"z":10.799999},{"x":15,"y":-0.01735555,"z":10.799999},{"x":-15,"y":-0.10992316,"z":12},{"x":-13.5,"y":-0.027083233,"z":12},{"x":-12,"y":0.0692005,"z":12},{"x":-10.5,"y":0.1311339,"z":12},{"x":-9,"y":0.12797393,"z":12},{"x":-7.5,"y":0.061289173,"z":12},{"x":-6,"y":-0.035818834,"z":12},{"x":-4.5,"y":-0.11514678,"z":12},{"x":-3,"y":-0.13731718,"z":12},{"x":-1.5,"y":-0.09132491,"z":12},{"x":0,"y":0,"z":12},{"x":1.5,"y":0.09132491,"z":12},{"x":3,"y":0.13731718,"z":12},{"x":4.5,"y":0.11514678,"z":12},{"x":6,"y":0.035818834,"z":12},{"x":7.5,"y":-0.061289173,"z":12},{"x":9,"y":-0.12797393,"z":12},{"x":10.5,"y":-0.1311339,"z":12},{"x":12,"y":-0.0692005,"z":12},{"x":13.5,"y":0.027083233,"z":12},{"x":15,"y":0.10992316,"z":12},{"x":-2.908183,"y":-0.2127876,"z":0.38446197},{"x":-3.264903,"y":-0.1753981,"z":0.3893209},{"x":-1,"y":2.1,"z":0.4000001},{"x":-1.9906086,"y":-0.11072375,"z":0.3559024},{"x":-1.4726975,"y":-0.0771559,"z":0.37536135},{"x":-0.9922741,"y":-0.004326731,"z":0.37422365},{"x":-3.9766972,"y":-0.14503625,"z":0.41859898},{"x":-4,"y":2.1,"z":0.4000001},{"x":-3.5106533,"y":-0.16060202,"z":0.38021868},{"x":-3.0904775,"y":0.041512974,"z":3.018437},{"x":-2.906853,"y":0.0026125833,"z":3.0233598},{"x":-1,"y":2.1,"z":3},{"x":-2.1938813,"y":0.04684385,"z":3.0530188},{"x":-1.4847513,"y":0.06882416,"z":3.0308514},{"x":-0.99739516,"y":0.10594039,"z":2.969687},{"x":-4.0421176,"y":0.040591605,"z":2.9760735},{"x":-3.7463143,"y":0.03351056,"z":3.0545623},{"x":-4,"y":2.1,"z":3},{"x":-4.0139065,"y":-0.12894593,"z":1.0660932},{"x":-4.020327,"y":-0.15251167,"z":1.2877443},{"x":-4.0524535,"y":-0.0654246,"z":2.0180767},{"x":-4.0304093,"y":-0.025263913,"z":2.4396768},{"x":-4.024417,"y":-0.13242756,"z":0.79522896},{"x":-0.9241685,"y":-0.021644853,"z":0.7682619},{"x":-0.96931934,"y":-0.0039390847,"z":1.1800985},{"x":-0.91532063,"y":0.024293728,"z":1.9794412},{"x":-0.89653647,"y":-0.08788279,"z":1.2707614},{"x":-0.9505664,"y":0.043036535,"z":2.4343812},{"x":-10,"y":-4,"z":-3},{"x":-4,"y":-4,"z":-3},{"x":-4,"y":-1,"z":-3},{"x":-10,"y":-1,"z":-3},{"x":-10,"y":-4,"z":2},{"x":-4,"y":-1,"z":2},{"x":-4,"y":-4,"z":2},{"x":-10,"y":-1,"z":2},{"x":-10,"y":-4,"z":-1.5},{"x":-8.5,"y":-4,"z":-1.5},{"x":-8.5,"y":-4,"z":-1.3690908},{"x":-8.5,"y":-4,"z":0.9},{"x":-10,"y":-4,"z":0.9},{"x":-9.9,"y":-1,"z":-1.5},{"x":-10,"y":-1,"z":-1.5},{"x":-8.5,"y":-1,"z":-1.5},{"x":-10,"y":-1,"z":0.9},{"x":-9.9,"y":-1,"z":0.9},{"x":-8.5,"y":-1,"z":0.9},{"x":-8.5,"y":-1,"z":-0.059999995},{"x":-10,"y":-3.1,"z":-1.5},{"x":-10,"y":-1.66,"z":0.9},{"x":-10,"y":-1.1571429,"z":-1.5},{"x":-10,"y":-1.1571429,"z":0.9},{"x":-12,"y":-4.3,"z":-1.5},{"x":-8.5,"y":-4.3,"z":-1.5},{"x":-10,"y":-4,"z":-1.5},{"x":-8.5,"y":-4,"z":-1.5},{"x":-10,"y":-3.1,"z":-1.5},{"x":-10,"y":-1.1571429,"z":-1.5},{"x":-9,"y":0.23081493,"z":-1.5},{"x":-9.9,"y":-1,"z":-1.5},{"x":-8.5,"y":-1,"z":-1.5},{"x":-9.116441,"y":0.2313079,"z":-1.5},{"x":-8.625001,"y":0.21105114,"z":-1.5},{"x":-8.5,"y":0.19988337,"z":-1.5},{"x":-10,"y":-1,"z":-1.5},{"x":-11.625001,"y":0.14316626,"z":-1.5},{"x":-12,"y":0.12481065,"z":-1.5},{"x":-10.125001,"y":0.23557773,"z":-1.5},{"x":-10.5,"y":0.23651427,"z":-1.5},{"x":-12,"y":-4.3,"z":0.9},{"x":-10,"y":-4,"z":0.9},{"x":-8.5,"y":-4.3,"z":0.9},{"x":-8.5,"y":-4,"z":0.9},{"x":-10,"y":-1.66,"z":0.9},{"x":-10,"y":-1.1571429,"z":0.9},{"x":-9,"y":0.26680624,"z":0.9},{"x":-8.5,"y":-1,"z":0.9},{"x":-9.9,"y":-1,"z":0.9},{"x":-8.624999,"y":0.22828795,"z":0.9},{"x":-8.5,"y":0.2171203,"z":0.9},{"x":-9.093598,"y":0.2672025,"z":0.9},{"x":-10,"y":-1,"z":0.9},{"x":-12,"y":0.14427255,"z":0.9},{"x":-11.624999,"y":0.18004638,"z":0.9},{"x":-10.124999,"y":0.27156904,"z":0.9},{"x":-10.5,"y":0.2733943,"z":0.9},{"x":-12,"y":0.15220423,"z":0.44278002},{"x":-12,"y":0.12481065,"z":-1.5},{"x":-12,"y":0.13906826,"z":-1.2000008},{"x":-12,"y":0.1598854,"z":0},{"x":-8.5,"y":-4,"z":-1.3690908},{"x":-8.5,"y":0.21251099,"z":-1.2000008},{"x":-8.5,"y":-1,"z":-0.059999995},{"x":-8.5,"y":0.23817605,"z":-0.40000024},{"x":-8.5,"y":0.2443218,"z":0},{"x":-8.5,"y":0.22904117,"z":0.47630885},{"x":-8.5,"y":0.21865673,"z":0.8000005}],"textureCoordinates":[{"x":-15,"y":-12},{"x":-15,"y":-10.8},{"x":-13.5,"y":-12},{"x":-13.5,"y":-10.8},{"x":-12,"y":-12},{"x":-12,"y":-10.8},{"x":-10.5,"y":-12},{"x":-10.5,"y":-10.8},{"x":-9,"y":-12},{"x":-9,"y":-10.8},{"x":-7.5,"y":-12},{"x":-7.5,"y":-10.8},{"x":-6,"y":-12},{"x":-6,"y":-10.8},{"x":-4.5,"y":-12},{"x":-4.5,"y":-10.8},{"x":-3,"y":-12},{"x":-3,"y":-10.8},{"x":-1.5,"y":-12},{"x":-1.5,"y":-10.8},{"x":0,"y":-12},{"x":0,"y":-10.8},{"x":1.5,"y":-12},{"x":1.5,"y":-10.8},{"x":3,"y":-12},{"x":3,"y":-10.8},{"x":4.5,"y":-12},{"x":4.5,"y":-10.8},{"x":6,"y":-12},{"x":6,"y":-10.8},{"x":7.5,"y":-12},{"x":7.5,"y":-10.8},{"x":9,"y":-12},{"x":9,"y":-10.8},{"x":10.5,"y":-12},{"x":10.5,"y":-10.8},{"x":12,"y":-12},{"x":12,"y":-10.8},{"x":13.5,"y":-12},{"x":13.5,"y":-10.8},{"x":15,"y":-12},{"x":15,"y":-10.8},{"x":-15,"y":-9.6},{"x":-13.5,"y":-9.6},{"x":-12,"y":-9.6},{"x":-10.5,"y":-9.6},{"x":-9,"y":-9.6},{"x":-7.5,"y":-9.6},{"x":-6,"y":-9.6},{"x":-4.5,"y":-9.6},{"x":-3,"y":-9.6},{"x":-1.5,"y":-9.6},{"x":0,"y":-9.6},{"x":1.5,"y":-9.6},{"x":3,"y":-9.6},{"x":4.5,"y":-9.6},{"x":6,"y":-9.6},{"x":7.5,"y":-9.6},{"x":9,"y":-9.6},{"x":10.5,"y":-9.6},{"x":12,"y":-9.6},{"x":13.5,"y":-9.6},{"x":15,"y":-9.6},{"x":-15,"y":-8.4},{"x":-13.5,"y":-8.4},{"x":-12,"y":-8.4},{"x":-10.5,"y":-8.4},{"x":-9,"y":-8.4},{"x":-7.5,"y":-8.4},{"x":-6,"y":-8.4},{"x":-4.5,"y":-8.4},{"x":-3,"y":-8.4},{"x":-1.5,"y":-8.4},{"x":0,"y":-8.4},{"x":1.5,"y":-8.4},{"x":3,"y":-8.4},{"x":4.5,"y":-8.4},{"x":6,"y":-8.4},{"x":7.5,"y":-8.4},{"x":9,"y":-8.4},{"x":10.5,"y":-8.4},{"x":12,"y":-8.4},{"x":13.5,"y":-8.4},{"x":15,"y":-8.4},{"x":-15,"y":-7.2},{"x":-13.5,"y":-7.2},{"x":-12,"y":-7.2},{"x":-10.5,"y":-7.2},{"x":-9,"y":-7.2},{"x":-7.5,"y":-7.2},{"x":-6,"y":-7.2},{"x":-4.5,"y":-7.2},{"x":-3,"y":-7.2},{"x":-1.5,"y":-7.2},{"x":0,"y":-7.2},{"x":1.5,"y":-7.2},{"x":3,"y":-7.2},{"x":4.5,"y":-7.2},{"x":6,"y":-7.2},{"x":7.5,"y":-7.2},{"x":9,"y":-7.2},{"x":10.5,"y":-7.2},{"x":12,"y":-7.2},{"x":13.5,"y":-7.2},{"x":15,"y":-7.2},{"x":-15,"y":-6},{"x":-13.5,"y":-6},{"x":-12,"y":-6},{"x":-10.5,"y":-6},{"x":-9,"y":-6},{"x":-7.5,"y":-6},{"x":-6,"y":-6},{"x":-4.5,"y":-6},{"x":-3,"y":-6},{"x":-1.5,"y":-6},{"x":0,"y":-6},{"x":1.5,"y":-6},{"x":3,"y":-6},{"x":4.5,"y":-6},{"x":6,"y":-6},{"x":7.5,"y":-6},{"x":9,"y":-6},{"x":10.5,"y":-6},{"x":12,"y":-6},{"x":13.5,"y":-6},{"x":15,"y":-6},{"x":-15,"y":-4.7999997},{"x":-13.5,"y":-4.7999997},{"x":-12,"y":-4.7999997},{"x":-10.5,"y":-4.7999997},{"x":-9,"y":-4.7999997},{"x":-7.5,"y":-4.7999997},{"x":-6,"y":-4.7999997},{"x":-4.5,"y":-4.7999997},{"x":-3,"y":-4.7999997},{"x":-1.5,"y":-4.7999997},{"x":0,"y":-4.7999997},{"x":1.5,"y":-4.7999997},{"x":3,"y":-4.7999997},{"x":4.5,"y":-4.7999997},{"x":6,"y":-4.7999997},{"x":7.5,"y":-4.7999997},{"x":9,"y":-4.7999997},{"x":10.5,"y":-4.7999997},{"x":12,"y":-4.7999997},{"x":13.5,"y":-4.7999997},{"x":15,"y":-4.7999997},{"x":-15,"y":-3.6000004},{"x":-13.5,"y":-3.6000004},{"x":-12,"y":-3.6000004},{"x":-10.5,"y":-3.6000004},{"x":-9,"y":-3.6000004},{"x":-7.5,"y":-3.6000004},{"x":-6,"y":-3.6000004},{"x":-4.5,"y":-3.6000004},{"x":-3,"y":-3.6000004},{"x":-1.5,"y":-3.6000004},{"x":0,"y":-3.6000004},{"x":1.5,"y":-3.6000004},{"x":3,"y":-3.6000004},{"x":4.5,"y":-3.6000004},{"x":6,"y":-3.6000004},{"x":7.5,"y":-3.6000004},{"x":9,"y":-3.6000004},{"x":10.5,"y":-3.6000004},{"x":12,"y":-3.6000004},{"x":13.5,"y":-3.6000004},{"x":15,"y":-3.6000004},{"x":-15,"y":-2.3999996},{"x":-13.5,"y":-2.3999996},{"x":-12,"y":-2.3999996},{"x":-10.5,"y":-2.3999996},{"x":-9,"y":-2.3999996},{"x":-7.5,"y":-2.3999996},{"x":-6,"y":-2.3999996},{"x":-4.5,"y":-2.3999996},{"x":-3,"y":-2.3999996},{"x":-1.5,"y":-2.3999996},{"x":0,"y":-2.3999996},{"x":1.5,"y":-2.3999996},{"x":3,"y":-2.3999996},{"x":4.5,"y":-2.3999996},{"x":6,"y":-2.3999996},{"x":7.5,"y":-2.3999996},{"x":9,"y":-2.3999996},{"x":10.5,"y":-2.3999996},{"x":12,"y":-2.3999996},{"x":13.5,"y":-2.3999996},{"x":15,"y":-2.3999996},{"x":-15,"y":-1.2000008},{"x":-13.5,"y":-1.2000008},{"x":-12,"y":-1.5},{"x":-12,"y":-1.2000008},{"x":-11.625001,"y":-1.5},{"x":-10.5,"y":-1.5},{"x":-10.125001,"y":-1.5},{"x":-9.116441,"y":-1.5},{"x":-9,"y":-1.5},{"x":-8.625001,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-7.5,"y":-1.2000008},{"x":-8.5,"y":-1.2000008},{"x":-6,"y":-1.2000008},{"x":-4.5,"y":-1.2000008},{"x":-3,"y":-1.2000008},{"x":-1.5,"y":-1.2000008},{"x":0,"y":-1.2000008},{"x":1.5,"y":-1.2000008},{"x":3,"y":-1.2000008},{"x":4.5,"y":-1.2000008},{"x":6,"y":-1.2000008},{"x":7.5,"y":-1.2000008},{"x":9,"y":-1.2000008},{"x":10.5,"y":-1.2000008},{"x":12,"y":-1.2000008},{"x":13.5,"y":-1.2000008},{"x":15,"y":-1.2000008},{"x":-15,"y":0},{"x":-13.5,"y":0},{"x":-12,"y":0},{"x":-8.5,"y":-0.40000024},{"x":-7.5,"y":0},{"x":-8.5,"y":0},{"x":-6,"y":0},{"x":-4.5,"y":0},{"x":-3,"y":0},{"x":-1.5,"y":0},{"x":0,"y":0},{"x":1.5,"y":0},{"x":3,"y":0},{"x":4.5,"y":0},{"x":6,"y":0},{"x":7.5,"y":0},{"x":9,"y":0},{"x":10.5,"y":0},{"x":12,"y":0},{"x":13.5,"y":0},{"x":15,"y":0},{"x":-15,"y":1.2000008},{"x":-13.5,"y":1.2000008},{"x":-12,"y":0.44278002},{"x":-12,"y":0.9},{"x":-12,"y":1.2000008},{"x":-11.624999,"y":0.9},{"x":-12,"y":0.9},{"x":-10.5,"y":1.2000008},{"x":-10.5,"y":0.9},{"x":-10.124999,"y":0.9},{"x":-9,"y":1.2000008},{"x":-9.093598,"y":0.9},{"x":-9,"y":0.9},{"x":-8.624999,"y":0.9},{"x":-8.5,"y":0.47630885},{"x":-8.5,"y":0.8000005},{"x":-7.5,"y":1.2000008},{"x":-8.5,"y":0.9},{"x":-6,"y":1.2000008},{"x":-4.5,"y":1.2000008},{"x":-4,"y":0.4000001},{"x":-4,"y":0.8000005},{"x":-3.4999998,"y":0.4000001},{"x":-4,"y":1.0271835},{"x":-4,"y":1.2000008},{"x":-3.316857,"y":0.4000001},{"x":-3,"y":0.4000001},{"x":-1.9999998,"y":0.4000001},{"x":-1.5,"y":0.4000001},{"x":-1,"y":0.4000001},{"x":-1,"y":0.8000005},{"x":0,"y":1.2000008},{"x":-1,"y":1.1360906},{"x":-1,"y":1.2000008},{"x":1.5,"y":1.2000008},{"x":3,"y":1.2000008},{"x":4.5,"y":1.2000008},{"x":6,"y":1.2000008},{"x":7.5,"y":1.2000008},{"x":9,"y":1.2000008},{"x":10.5,"y":1.2000008},{"x":12,"y":1.2000008},{"x":13.5,"y":1.2000008},{"x":15,"y":1.2000008},{"x":-15,"y":2.4000006},{"x":-13.5,"y":2.4000006},{"x":-12,"y":2.4000006},{"x":-10.5,"y":2.4000006},{"x":-9,"y":2.4000006},{"x":-7.5,"y":2.4000006},{"x":-6,"y":2.4000006},{"x":-4.5,"y":2.4000006},{"x":-4,"y":2.0000007},{"x":-4,"y":2.4000006},{"x":-1,"y":2.0000007},{"x":0,"y":2.4000006},{"x":-1,"y":2.4000006},{"x":1.5,"y":2.4000006},{"x":3,"y":2.4000006},{"x":4.5,"y":2.4000006},{"x":6,"y":2.4000006},{"x":7.5,"y":2.4000006},{"x":9,"y":2.4000006},{"x":10.5,"y":2.4000006},{"x":12,"y":2.4000006},{"x":13.5,"y":2.4000006},{"x":15,"y":2.4000006},{"x":-15,"y":3.5999994},{"x":-13.5,"y":3.5999994},{"x":-12,"y":3.5999994},{"x":-10.5,"y":3.5999994},{"x":-9,"y":3.5999994},{"x":-7.5,"y":3.5999994},{"x":-6,"y":3.5999994},{"x":-4.5,"y":3.5999994},{"x":-4,"y":3},{"x":-3.75,"y":3},{"x":-3,"y":3.5999994},{"x":-3.1144407,"y":3},{"x":-3,"y":3},{"x":-2.25,"y":3},{"x":-1.5,"y":3.5999994},{"x":-1.5,"y":3},{"x":-1,"y":3},{"x":0,"y":3.5999994},{"x":1.5,"y":3.5999994},{"x":3,"y":3.5999994},{"x":4.5,"y":3.5999994},{"x":6,"y":3.5999994},{"x":7.5,"y":3.5999994},{"x":9,"y":3.5999994},{"x":10.5,"y":3.5999994},{"x":12,"y":3.5999994},{"x":13.5,"y":3.5999994},{"x":15,"y":3.5999994},{"x":-15,"y":4.799999},{"x":-13.5,"y":4.799999},{"x":-12,"y":4.799999},{"x":-10.5,"y":4.799999},{"x":-9,"y":4.799999},{"x":-7.5,"y":4.799999},{"x":-6,"y":4.799999},{"x":-4.5,"y":4.799999},{"x":-3,"y":4.799999},{"x":-1.5,"y":4.799999},{"x":0,"y":4.799999},{"x":1.5,"y":4.799999},{"x":3,"y":4.799999},{"x":4.5,"y":4.799999},{"x":6,"y":4.799999},{"x":7.5,"y":4.799999},{"x":9,"y":4.799999},{"x":10.5,"y":4.799999},{"x":12,"y":4.799999},{"x":13.5,"y":4.799999},{"x":15,"y":4.799999},{"x":-15,"y":6},{"x":-13.5,"y":6},{"x":-12,"y":6},{"x":-10.5,"y":6},{"x":-9,"y":6},{"x":-7.5,"y":6},{"x":-6,"y":6},{"x":-4.5,"y":6},{"x":-3,"y":6},{"x":-1.5,"y":6},{"x":0,"y":6},{"x":1.5,"y":6},{"x":3,"y":6},{"x":4.5,"y":6},{"x":6,"y":6},{"x":7.5,"y":6},{"x":9,"y":6},{"x":10.5,"y":6},{"x":12,"y":6},{"x":13.5,"y":6},{"x":15,"y":6},{"x":-15,"y":7.200001},{"x":-13.5,"y":7.200001},{"x":-12,"y":7.200001},{"x":-10.5,"y":7.200001},{"x":-9,"y":7.200001},{"x":-7.5,"y":7.200001},{"x":-6,"y":7.200001},{"x":-4.5,"y":7.200001},{"x":-3,"y":7.200001},{"x":-1.5,"y":7.200001},{"x":0,"y":7.200001},{"x":1.5,"y":7.200001},{"x":3,"y":7.200001},{"x":4.5,"y":7.200001},{"x":6,"y":7.200001},{"x":7.5,"y":7.200001},{"x":9,"y":7.200001},{"x":10.5,"y":7.200001},{"x":12,"y":7.200001},{"x":13.5,"y":7.200001},{"x":15,"y":7.200001},{"x":-15,"y":8.400002},{"x":-13.5,"y":8.400002},{"x":-12,"y":8.400002},{"x":-10.5,"y":8.400002},{"x":-9,"y":8.400002},{"x":-7.5,"y":8.400002},{"x":-6,"y":8.400002},{"x":-4.5,"y":8.400002},{"x":-3,"y":8.400002},{"x":-1.5,"y":8.400002},{"x":0,"y":8.400002},{"x":1.5,"y":8.400002},{"x":3,"y":8.400002},{"x":4.5,"y":8.400002},{"x":6,"y":8.400002},{"x":7.5,"y":8.400002},{"x":9,"y":8.400002},{"x":10.5,"y":8.400002},{"x":12,"y":8.400002},{"x":13.5,"y":8.400002},{"x":15,"y":8.400002},{"x":-15,"y":9.599998},{"x":-13.5,"y":9.599998},{"x":-12,"y":9.599998},{"x":-10.5,"y":9.599998},{"x":-9,"y":9.599998},{"x":-7.5,"y":9.599998},{"x":-6,"y":9.599998},{"x":-4.5,"y":9.599998},{"x":-3,"y":9.599998},{"x":-1.5,"y":9.599998},{"x":0,"y":9.599998},{"x":1.5,"y":9.599998},{"x":3,"y":9.599998},{"x":4.5,"y":9.599998},{"x":6,"y":9.599998},{"x":7.5,"y":9.599998},{"x":9,"y":9.599998},{"x":10.5,"y":9.599998},{"x":12,"y":9.599998},{"x":13.5,"y":9.599998},{"x":15,"y":9.599998},{"x":-15,"y":10.799999},{"x":-13.5,"y":10.799999},{"x":-12,"y":10.799999},{"x":-10.5,"y":10.799999},{"x":-9,"y":10.799999},{"x":-7.5,"y":10.799999},{"x":-6,"y":10.799999},{"x":-4.5,"y":10.799999},{"x":-3,"y":10.799999},{"x":-1.5,"y":10.799999},{"x":0,"y":10.799999},{"x":1.5,"y":10.799999},{"x":3,"y":10.799999},{"x":4.5,"y":10.799999},{"x":6,"y":10.799999},{"x":7.5,"y":10.799999},{"x":9,"y":10.799999},{"x":10.5,"y":10.799999},{"x":12,"y":10.799999},{"x":13.5,"y":10.799999},{"x":15,"y":10.799999},{"x":-15,"y":12},{"x":-13.5,"y":12},{"x":-12,"y":12},{"x":-10.5,"y":12},{"x":-9,"y":12},{"x":-7.5,"y":12},{"x":-6,"y":12},{"x":-4.5,"y":12},{"x":-3,"y":12},{"x":-1.5,"y":12},{"x":0,"y":12},{"x":1.5,"y":12},{"x":3,"y":12},{"x":4.5,"y":12},{"x":6,"y":12},{"x":7.5,"y":12},{"x":9,"y":12},{"x":10.5,"y":12},{"x":12,"y":12},{"x":13.5,"y":12},{"x":15,"y":12},{"x":-3,"y":0.4000001},{"x":-3.316857,"y":0.4000001},{"x":-1,"y":0.4000001},{"x":-1.9999998,"y":0.4000001},{"x":-1.5,"y":0.4000001},{"x":-1,"y":0.4000001},{"x":-4,"y":0.4000001},{"x":-4,"y":0.4000001},{"x":-3.4999998,"y":0.4000001},{"x":-3.1144407,"y":3},{"x":-3,"y":3},{"x":-1,"y":3},{"x":-2.25,"y":3},{"x":-1.5,"y":3},{"x":-1,"y":3},{"x":-4,"y":3},{"x":-3.75,"y":3},{"x":-4,"y":3},{"x":-4,"y":1.0271835},{"x":-4,"y":1.2000008},{"x":-4,"y":2.0000007},{"x":-4,"y":2.4000006},{"x":-4,"y":0.8000005},{"x":-1,"y":0.8000005},{"x":-1,"y":1.1360906},{"x":-1,"y":2.0000007},{"x":-1,"y":1.2000008},{"x":-1,"y":2.4000006},{"x":-10,"y":-3},{"x":-4,"y":-3},{"x":-4,"y":-3},{"x":-10,"y":-3},{"x":-10,"y":2},{"x":-4,"y":2},{"x":-4,"y":2},{"x":-10,"y":2},{"x":-10,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-8.5,"y":-1.3690908},{"x":-8.5,"y":0.9},{"x":-10,"y":0.9},{"x":-9.9,"y":-1.5},{"x":-10,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-10,"y":0.9},{"x":-9.9,"y":0.9},{"x":-8.5,"y":0.9},{"x":-8.5,"y":-0.059999995},{"x":-10,"y":-1.5},{"x":-10,"y":0.9},{"x":-10,"y":-1.5},{"x":-10,"y":0.9},{"x":-12,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-10,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-10,"y":-1.5},{"x":-10,"y":-1.5},{"x":-9,"y":-1.5},{"x":-9.9,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-9.116441,"y":-1.5},{"x":-8.625001,"y":-1.5},{"x":-8.5,"y":-1.5},{"x":-10,"y":-1.5},{"x":-11.625001,"y":-1.5},{"x":-12,"y":-1.5},{"x":-10.125001,"y":-1.5},{"x":-10.5,"y":-1.5},{"x":-12,"y":0.9},{"x":-10,"y":0.9},{"x":-8.5,"y":0.9},{"x":-8.5,"y":0.9},{"x":-10,"y":0.9},{"x":-10,"y":0.9},{"x":-9,"y":0.9},{"x":-8.5,"y":0.9},{"x":-9.9,"y":0.9},{"x":-8.624999,"y":0.9},{"x":-8.5,"y":0.9},{"x":-9.093598,"y":0.9},{"x":-10,"y":0.9},{"x":-12,"y":0.9},{"x":-11.624999,"y":0.9},{"x":-10.124999,"y":0.9},{"x":-10.5,"y":0.9},{"x":-12,"y":0.44278002},{"x":-12,"y":-1.5},{"x":-12,"y":-1.2000008},{"x":-12,"y":-1.110223e-16},{"x":-8.5,"y":-1.3690908},{"x":-8.5,"y":-1.2000008},{"x":-8.5,"y":-0.059999995},{"x":-8.5,"y":-0.40000024},{"x":-8.5,"y":-1.110223e-16},{"x":-8.5,"y":0.47630885},{"x":-8.5,"y":0.8000005}]}}],"placements":[{"id":11,"normal":{"x":0,"y":1,"z":0},"ownership":"manual","prototypeID":2,"sourceID":0,"transform":{"w":{"w":1,"x":9,"y":-0.18687001,"z":-2},"x":{"w":0,"x":1.7,"y":0,"z":0},"y":{"w":0,"x":0,"y":1.7,"z":0},"z":{"w":0,"x":0,"y":0,"z":1.7}}},{"id":95120272969886708,"normal":{"x":0.11822809,"y":0.99298644,"z":-0},"ownership":"pinned","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":0.7197571,"y":-0.08569654,"z":-5.505005},"x":{"w":0,"x":-0.4035381,"y":0.048046503,"z":-0.8628031},"y":{"w":0,"x":0.11275642,"y":0.9470305,"z":-7.105763e-09},"z":{"w":0,"x":0.85675186,"y":-0.10200756,"z":-0.4063883}}},{"id":234724683911514383,"normal":{"x":-0.06148598,"y":0.9978465,"z":-0.02284459},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.7643728,"y":0.20602997,"z":-0.92878175},"x":{"w":0,"x":-0.052561294,"y":-0.029360034,"z":-1.1409718},"y":{"w":0,"x":-0.07025136,"y":1.1400985,"z":-0.02610129},"z":{"w":0,"x":1.1391853,"y":0.068953015,"z":-0.054253343}}},{"id":264890868816738666,"normal":{"x":0.3937299,"y":0.91913885,"z":-0.012669878},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.0736065,"y":0.75763595,"z":5.029837},"x":{"w":0,"x":-0.46529734,"y":0.21330893,"z":1.0149302},"y":{"w":0,"x":0.4475526,"y":1.0447847,"z":-0.014401858},"z":{"w":0,"x":-0.9355645,"y":0.39371315,"z":-0.511659}}},{"id":364310581690127558,"normal":{"x":-0.03619137,"y":0.9956515,"z":0.085839},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.7831444,"y":0.07085235,"z":2.9533465},"x":{"w":0,"x":0.9544283,"y":0.0645931,"z":-0.3468137},"y":{"w":0,"x":-0.036826126,"y":1.0131141,"z":0.08734453},"z":{"w":0,"x":0.35085022,"y":-0.069375515,"z":0.9526156}}},{"id":462572004798091611,"normal":{"x":-0.03625433,"y":0.99738276,"z":-0.06255591},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.8787689,"y":0.16875425,"z":-1.6551056},"x":{"w":0,"x":-0.9740564,"y":-0.06916546,"z":-0.5382494},"y":{"w":0,"x":-0.040424507,"y":1.1121073,"z":-0.069751434},"z":{"w":0,"x":0.5411674,"y":-0.04141911,"z":-0.9740146}}},{"id":554178759713883711,"normal":{"x":0.00027918897,"y":0.79497856,"z":-0.6066375},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.558431,"y":0.12334083,"z":2.7602477},"x":{"w":0,"x":-0.3846979,"y":0.5981827,"z":0.78372175},"y":{"w":0,"x":0.00029546957,"y":0.84133935,"z":-0.64201474},"z":{"w":0,"x":-0.985922,"y":-0.23315334,"z":-0.3059936}}},{"id":628930031237679401,"normal":{"x":0.00041945747,"y":0.8201378,"z":-0.57216597},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.6992874,"y":0.39186773,"z":3.0652618},"x":{"w":0,"x":-0.13836624,"y":0.57391864,"z":0.8225484},"y":{"w":0,"x":0.00042470216,"y":0.8303722,"z":-0.57930607},"z":{"w":0,"x":-1.0029798,"y":-0.078823455,"z":-0.11372012}}},{"id":663126617676173338,"normal":{"x":-0.08652916,"y":0.99528587,"z":0.04380359},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.5284066,"y":0.10701077,"z":-5.6520014},"x":{"w":0,"x":0.24863665,"y":0.05767505,"z":-0.8193127},"y":{"w":0,"x":-0.07425492,"y":0.8541037,"z":0.03759001},"z":{"w":0,"x":0.8179768,"y":0.060003266,"z":0.25245512}}},{"id":671867330878164149,"normal":{"x":0.03339474,"y":0.995659,"z":-0.08687882},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.594048,"y":-0.056570366,"z":-4.2672844},"x":{"w":0,"x":-0.49951953,"y":-0.06963378,"z":-0.990032},"y":{"w":0,"x":0.037104726,"y":1.1062717,"z":-0.096530624},"z":{"w":0,"x":0.991784,"y":-0.076459534,"z":-0.4950257}}},{"id":765112102258358009,"normal":{"x":-0.028794011,"y":0.9961858,"z":0.08237023},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.0516105,"y":-0.20778833,"z":5.632914},"x":{"w":0,"x":-0.70763814,"y":-0.073905766,"z":0.6464488},"y":{"w":0,"x":-0.027679862,"y":0.9576396,"z":0.07918302},"z":{"w":0,"x":-0.6500707,"y":0.03967447,"z":-0.7070671}}},{"id":786681073420872359,"normal":{"x":-0.13064343,"y":0.87433076,"z":0.46741632},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.9322114,"y":0.18240589,"z":6.96471},"x":{"w":0,"x":0.79532325,"y":-0.10741706,"z":0.423224},"y":{"w":0,"x":-0.11853298,"y":0.7932817,"z":0.42408758},"z":{"w":0,"x":-0.42024624,"y":-0.42703852,"z":0.6813423}}},{"id":899958993870735377,"normal":{"x":0.06220801,"y":0.99221826,"z":0.10785669},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":5.3927765,"y":0.04171014,"z":2.7571564},"x":{"w":0,"x":-1.1695734,"y":0.08132067,"z":-0.07353291},"y":{"w":0,"x":0.0730758,"y":1.1655596,"z":0.12669936},"z":{"w":0,"x":0.08173168,"y":0.12157198,"z":-1.1655309}}},{"id":949117660909107763,"normal":{"x":-0.01557292,"y":0.82004184,"z":0.57209164},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.13737,"y":0.46114096,"z":6.424233},"x":{"w":0,"x":0.46526805,"y":0.5082335,"z":-0.71584195},"y":{"w":0,"x":-0.015472975,"y":0.8147789,"z":0.56841993},"z":{"w":0,"x":0.8777765,"y":-0.25502822,"z":0.38945404}}},{"id":1110189126001383030,"normal":{"x":0.033449702,"y":0.9972982,"z":0.06540222},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.9925454,"y":-0.120246366,"z":4.8583536},"x":{"w":0,"x":-0.7089947,"y":0.054465055,"z":-0.46790832},"y":{"w":0,"x":0.028473113,"y":0.8489218,"z":0.055671766},"z":{"w":0,"x":0.4702063,"y":0.030718425,"z":-0.708901}}},{"id":1382742969428925793,"normal":{"x":-0.011499831,"y":0.88183075,"z":0.4714258},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.6554656,"y":0.7193886,"z":6.122741},"x":{"w":0,"x":0.3547921,"y":0.42508432,"z":-0.78649145},"y":{"w":0,"x":-0.011061013,"y":0.84818316,"z":0.4534379},"z":{"w":0,"x":0.8939481,"y":-0.15821366,"z":0.31775492}}},{"id":1406004729132185948,"normal":{"x":0.00027918897,"y":0.79497856,"z":-0.6066375},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.226699,"y":0.6443568,"z":3.4428694},"x":{"w":0,"x":-1.1373534,"y":0.05344179,"z":0.06951027},"y":{"w":0,"x":0.00031847984,"y":0.90685433,"z":-0.69200826},"z":{"w":0,"x":-0.08767898,"y":-0.6899417,"z":-0.9041864}}},{"id":1567591612000042446,"normal":{"x":-0.007293155,"y":0.9998053,"z":-0.018337576},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.466844,"y":0.77761763,"z":4.5551066},"x":{"w":0,"x":0.026761232,"y":0.016807022,"z":0.9057127},"y":{"w":0,"x":-0.0066095227,"y":0.9060873,"z":-0.016618682},"z":{"w":0,"x":-0.90584457,"y":-0.0061147665,"z":0.026878558}}},{"id":1651595186770579054,"normal":{"x":0.0023373952,"y":0.9983276,"z":0.057762355},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.694062,"y":-0.0054695024,"z":-3.5691092},"x":{"w":0,"x":0.72744995,"y":-0.024629796,"z":0.3962489},"y":{"w":0,"x":0.0019370824,"y":0.82735,"z":0.04786974},"z":{"w":0,"x":-0.39700893,"y":-0.041093033,"z":0.726291}}},{"id":1792201079189045036,"normal":{"x":0.06237909,"y":0.9949463,"z":-0.07868079},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.411225,"y":0.11473171,"z":-1.9529309},"x":{"w":0,"x":0.66154534,"y":0.01291708,"z":0.6878222},"y":{"w":0,"x":0.05953556,"y":0.94959193,"z":-0.07509415},"z":{"w":0,"x":-0.6853625,"y":0.09495664,"z":0.65739626}}},{"id":1879656300313756330,"normal":{"x":-0.061381873,"y":0.99615693,"z":0.062479027},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":-1.5089417,"y":-0.14907147,"z":-1.758194},"x":{"w":0,"x":0.8382419,"y":0.082884364,"z":-0.49797493},"y":{"w":0,"x":-0.060063288,"y":0.9747579,"z":0.061136875},"z":{"w":0,"x":0.50123966,"y":-0.021805903,"z":0.840108}}},{"id":2055651244789160122,"normal":{"x":-0.09491473,"y":0.99494797,"z":-0.0327074},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.725444,"y":-0.26190868,"z":0.59516555},"x":{"w":0,"x":-1.1436057,"y":-0.10235747,"z":0.20498934},"y":{"w":0,"x":-0.11070215,"y":1.1604402,"z":-0.038147703},"z":{"w":0,"x":-0.20060587,"y":-0.05686088,"z":-1.1475433}}},{"id":2059379161970839171,"normal":{"x":-0.057408903,"y":0.992363,"z":-0.10917832},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.787118,"y":-0.04800981,"z":-4.242474},"x":{"w":0,"x":-0.85528743,"y":0.0019512449,"z":0.4674688},"y":{"w":0,"x":-0.05595668,"y":0.96726,"z":-0.10641653},"z":{"w":0,"x":-0.46411178,"y":-0.12021573,"z":-0.84864366}}},{"id":2193739537057438310,"normal":{"x":-0.08267944,"y":0.9964263,"z":-0.017285615},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.4511795,"y":-0.19033457,"z":0.8698457},"x":{"w":0,"x":-0.9305856,"y":-0.08312353,"z":-0.340524},"y":{"w":0,"x":-0.08221745,"y":0.99085844,"z":-0.017189026},"z":{"w":0,"x":0.3407439,"y":0.012068591,"z":-0.9341326}}},{"id":2309942658922947572,"normal":{"x":-0.0021576537,"y":0.99223965,"z":-0.124321334},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.690791,"y":0.1219694,"z":4.6252775},"x":{"w":0,"x":0.9779514,"y":-0.04329362,"z":-0.36251},"y":{"w":0,"x":-0.0022523215,"y":1.0357751,"z":-0.12977605},"z":{"w":0,"x":0.36507913,"y":0.12236239,"z":0.9702687}}},{"id":2394782242392535417,"normal":{"x":-0.070623666,"y":0.99691254,"z":-0.034317244},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.8836212,"y":0.28756678,"z":-0.62327576},"x":{"w":0,"x":0.11003931,"y":-0.030023638,"z":-1.0986408},"y":{"w":0,"x":-0.07800709,"y":1.1011355,"z":-0.037904963},"z":{"w":0,"x":1.096279,"y":0.081366286,"z":0.107579134}}},{"id":2404699078100177282,"normal":{"x":0.029672397,"y":0.99896795,"z":-0.034388},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.50431,"y":0.28836438,"z":-0.40445417},"x":{"w":0,"x":-0.5742958,"y":-0.0034904538,"z":-0.5969404},"y":{"w":0,"x":0.024579166,"y":0.82749635,"z":-0.028485341},"z":{"w":0,"x":0.59644437,"y":-0.037461538,"z":-0.57359964}}},{"id":2415405417569574506,"normal":{"x":0.052251607,"y":0.991425,"z":0.1197759},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.349931,"y":-0.104166225,"z":-2.50998},"x":{"w":0,"x":-0.9629937,"y":0.035423476,"z":0.12688902},"y":{"w":0,"x":0.050786633,"y":0.9636286,"z":0.11641776},"z":{"w":0,"x":-0.121558055,"y":0.121973574,"z":-0.9565869}}},{"id":2566791130923976235,"normal":{"x":0.117978215,"y":0.99088776,"z":0.06498182},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.4596162,"y":0.16332051,"z":-5.8404074},"x":{"w":0,"x":-0.8457612,"y":0.047425684,"z":0.812348},"y":{"w":0,"x":0.13846594,"y":1.1629622,"z":0.07626636},"z":{"w":0,"x":-0.80186397,"y":0.15079848,"z":-0.8436495}}},{"id":2614652191226931743,"normal":{"x":0.3911757,"y":0.9131762,"z":-0.11441518},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.271835,"y":0.16435815,"z":4.1868286},"x":{"w":0,"x":-0.21002829,"y":-0.022986954,"z":-0.90153337},"y":{"w":0,"x":0.36221316,"y":0.84556496,"z":-0.10594392},"z":{"w":0,"x":0.8258888,"y":-0.37668833,"z":-0.18280087}}},{"id":2646468855806367734,"normal":{"x":-0.016087873,"y":0.9950448,"z":0.09811733},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.2279096,"y":-0.20206535,"z":5.3632545},"x":{"w":0,"x":-0.13681337,"y":-0.095543236,"z":0.9465072},"y":{"w":0,"x":-0.015462129,"y":0.95634234,"z":0.09430103},"z":{"w":0,"x":-0.95119154,"y":-0.0018035233,"z":-0.13767253}}},{"id":3005993551451688000,"normal":{"x":0.03335988,"y":0.99461967,"z":-0.0980754},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.459961,"y":-0.20080204,"z":-5.496765},"x":{"w":0,"x":-0.18280043,"y":0.0908861,"z":0.8595315},"y":{"w":0,"x":0.029471539,"y":0.87868935,"z":-0.086643994},"z":{"w":0,"x":-0.8638207,"y":0.010745642,"z":-0.18484887}}},{"id":3049476003073827825,"normal":{"x":0.029597634,"y":0.99645096,"z":-0.078799784},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.476896,"y":0.2248013,"z":-1.2921703},"x":{"w":0,"x":-0.38695854,"y":-0.053256743,"z":-0.818794},"y":{"w":0,"x":0.026850728,"y":0.9039721,"z":-0.07148652},"z":{"w":0,"x":0.82008475,"y":-0.054726616,"z":-0.3840089}}},{"id":3232370097606158726,"normal":{"x":-0.13879952,"y":0.99006104,"z":-0.022666354},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.8511794,"y":0.10526773,"z":-0.64538294},"x":{"w":0,"x":0.6156416,"y":0.10393266,"z":0.76981544},"y":{"w":0,"x":-0.13757494,"y":0.98132604,"z":-0.022466382},"z":{"w":0,"x":-0.7645201,"y":-0.09289568,"z":0.6239485}}},{"id":3241159700811860875,"normal":{"x":-0.010621532,"y":0.999905,"z":-0.008783104},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":6.711029,"y":0.76477176,"z":4.583252},"x":{"w":0,"x":1.0221179,"y":0.011983706,"z":0.12821329},"y":{"w":0,"x":-0.010942277,"y":1.0300997,"z":-0.009048332},"z":{"w":0,"x":-0.12830636,"y":0.0076155453,"z":1.022148}}},{"id":3306617403286076641,"normal":{"x":0.06253747,"y":0.9974725,"z":-0.033728763},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.536177,"y":0.05688139,"z":-2.8336017},"x":{"w":0,"x":0.6138491,"y":-0.020962227,"z":0.5182318},"y":{"w":0,"x":0.050256714,"y":0.80159456,"z":-0.027105303},"z":{"w":0,"x":-0.51621497,"y":0.053113274,"z":0.6136084}}},{"id":3327238691131227032,"normal":{"x":-0.0031945626,"y":0.9962991,"z":0.08589484},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.4197676,"y":-0.081748344,"z":-2.7046},"x":{"w":0,"x":0.5395653,"y":0.092286296,"z":-1.0503665},"y":{"w":0,"x":-0.0037837895,"y":1.1800647,"z":0.101738},"z":{"w":0,"x":1.0544062,"y":-0.042990427,"z":0.53786325}}},{"id":3334857114653078980,"normal":{"x":-0.048490264,"y":0.99711543,"z":0.05839064},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.231801,"y":0.09064682,"z":-4.5953417},"x":{"w":0,"x":-0.22003444,"y":0.055365987,"z":-1.1281911},"y":{"w":0,"x":-0.055801656,"y":1.147461,"z":0.06719482},"z":{"w":0,"x":1.1281698,"y":0.06755425,"z":-0.21671508}}},{"id":3360373667893266255,"normal":{"x":0.033449717,"y":0.9972982,"z":0.06540222},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":-1.5293579,"y":0.16984595,"z":-5.8515778},"x":{"w":0,"x":-0.543518,"y":-0.021901965,"z":0.6119566},"y":{"w":0,"x":0.027387582,"y":0.8165566,"z":0.05354929},"z":{"w":0,"x":-0.6117356,"y":0.056017045,"z":-0.54131687}}},{"id":3386846398446043823,"normal":{"x":0.11822809,"y":0.99298644,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.1754683,"y":-0.13995495,"z":-5.8802443},"x":{"w":0,"x":1.1339537,"y":-0.13501209,"z":-0.013795907},"y":{"w":0,"x":0.13502194,"y":1.1340364,"z":6.647584e-11},"z":{"w":0,"x":0.013699147,"y":-0.0016310636,"z":1.141963}}},{"id":3411685558799347063,"normal":{"x":-0.048520293,"y":0.99125546,"z":-0.12271246},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.031101,"y":-0.105320565,"z":2.5950253},"x":{"w":0,"x":-0.66727686,"y":-0.15106314,"z":-0.9564285},"y":{"w":0,"x":-0.057056926,"y":1.1656566,"z":-0.14430244},"z":{"w":0,"x":0.9666023,"y":-0.035476997,"z":-0.6687715}}},{"id":3437019906518827545,"normal":{"x":0.029558918,"y":0.9951475,"z":0.09384901},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":4.2118073,"y":0.2271256,"z":1.3361359},"x":{"w":0,"x":-0.3072213,"y":-0.0761418,"z":0.9041485},"y":{"w":0,"x":0.028315945,"y":0.953301,"z":0.08990261},"z":{"w":0,"x":-0.9069071,"y":0.05555807,"z":-0.30347982}}},{"id":3484023327492963758,"normal":{"x":0.0023373952,"y":0.9983276,"z":0.057762355},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":7.5398865,"y":-0.060259156,"z":-2.615921},"x":{"w":0,"x":-0.6511668,"y":0.0529358,"z":-0.8885586},"y":{"w":0,"x":0.0025778783,"y":1.1010412,"z":0.06370527},"z":{"w":0,"x":0.8901303,"y":0.035536017,"z":-0.6502016}}},{"id":3746762475976214644,"normal":{"x":-0.0016088247,"y":0.9963029,"z":-0.085895166},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.5155857,"y":0.03708722,"z":-3.244633},"x":{"w":0,"x":1.0480653,"y":0.0035076023,"z":0.021054512},"y":{"w":0,"x":-0.0016865027,"y":1.0444069,"z":-0.09004242},"z":{"w":0,"x":-0.021277957,"y":0.08998987,"z":1.0441961}}},{"id":3772236811064485042,"normal":{"x":0.06237909,"y":0.9949463,"z":-0.07868079},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":4.5478973,"y":0.21349637,"z":-1.3884735},"x":{"w":0,"x":-0.5650135,"y":-0.025663504,"z":-0.77247375},"y":{"w":0,"x":0.059721738,"y":0.95256156,"z":-0.07532898},"z":{"w":0,"x":0.7705891,"y":-0.09264192,"z":-0.5605573}}},{"id":3931172755185422077,"normal":{"x":-0.01898656,"y":0.99979794,"z":-0.0066003236},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.734174,"y":0.767884,"z":5.9165115},"x":{"w":0,"x":-1.0484924,"y":-0.017370375,"z":0.38489002},"y":{"w":0,"x":-0.021208748,"y":1.1168143,"z":-0.0073728245},"z":{"w":0,"x":-0.38469762,"y":-0.014228128,"z":-1.0486102}}},{"id":3966480748890281796,"normal":{"x":0.08894184,"y":0.99552464,"z":-0.031937737},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.455468,"y":-0.2145025,"z":1.0139091},"x":{"w":0,"x":0.8496873,"y":-0.08400604,"z":-0.25228247},"y":{"w":0,"x":0.07918682,"y":0.8863368,"z":-0.02843485},"z":{"w":0,"x":0.2538364,"y":0.004698621,"z":0.85335636}}},{"id":4015087303937030101,"normal":{"x":-0.12014734,"y":0.992496,"z":0.022722097},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.4112091,"y":0.17875613,"z":0.93904114},"x":{"w":0,"x":-0.19086349,"y":-0.04676642,"z":1.0335203},"y":{"w":0,"x":-0.12639935,"y":1.0441418,"z":0.023904469},"z":{"w":0,"x":-1.0268275,"y":-0.119837895,"z":-0.19505014}}},{"id":4076258268796436760,"normal":{"x":-0.0031942755,"y":0.99620956,"z":-0.08692686},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.8235488,"y":-0.01663847,"z":-3.8209453},"x":{"w":0,"x":-0.833302,"y":0.031117829,"z":0.38724133},"y":{"w":0,"x":-0.0029368526,"y":0.9159259,"z":-0.0799215},"z":{"w":0,"x":-0.38847855,"y":-0.07367328,"z":-0.83004403}}},{"id":4121725455044080659,"normal":{"x":0.004217304,"y":0.9961511,"z":-0.08755159},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.050669,"y":-0.20022655,"z":1.8504715},"x":{"w":0,"x":0.30181518,"y":-0.08339646,"z":-0.9343363},"y":{"w":0,"x":0.0041557713,"y":0.9816166,"z":-0.08627415},"z":{"w":0,"x":0.93804157,"y":0.022484018,"z":0.30100524}}},{"id":4282813152735818872,"normal":{"x":0.0023373952,"y":0.9983276,"z":0.057762355},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":8.207687,"y":-0.027956173,"z":-3.2012482},"x":{"w":0,"x":-1.0567031,"y":0.02844572,"z":-0.44887742},"y":{"w":0,"x":0.0026843639,"y":1.1465224,"z":0.06633677},"z":{"w":0,"x":0.44976985,"y":0.05998845,"z":-1.0550023}}},{"id":4443540095137263116,"normal":{"x":-0.0031946201,"y":0.9962991,"z":0.08589484},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.9475884,"y":0.041800078,"z":3.1504009},"x":{"w":0,"x":-0.4353426,"y":-0.06603136,"z":0.74971014},"y":{"w":0,"x":-0.0027775709,"y":0.86623526,"z":0.074681535},"z":{"w":0,"x":-0.7526073,"y":0.034998644,"z":-0.4339424}}},{"id":4582682018222651853,"normal":{"x":0.004231384,"y":0.99947685,"z":0.032064527},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.167453,"y":-0.25922257,"z":-1.1584874},"x":{"w":0,"x":-0.6596162,"y":-0.012072617,"z":0.46335903},"y":{"w":0,"x":0.0034112956,"y":0.8057674,"z":0.025850074},"z":{"w":0,"x":-0.46350378,"y":0.023110934,"z":-0.6592201}}},{"id":4630822834338664659,"normal":{"x":-0.0022942792,"y":0.9955931,"z":0.0937508},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.382688,"y":0.14487757,"z":-4.8180723},"x":{"w":0,"x":-0.110777944,"y":0.10546494,"z":-1.1227032},"y":{"w":0,"x":-0.0025995886,"y":1.1280807,"z":0.106226616},"z":{"w":0,"x":1.1276429,"y":0.0129613215,"z":-0.110047825}}},{"id":4879197868318855407,"normal":{"x":0.0034075074,"y":0.99942297,"z":-0.03379472},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.2384014,"y":0.029147847,"z":-2.7697504},"x":{"w":0,"x":-0.24383426,"y":0.035138328,"z":1.0145723},"y":{"w":0,"x":0.0035576185,"y":1.0434506,"z":-0.03528348},"z":{"w":0,"x":-1.0151743,"y":-0.004783148,"z":-0.24381329}}},{"id":4904215211621293681,"normal":{"x":-0.0016086801,"y":0.9962134,"z":0.0869272},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.5522217,"y":0.015935285,"z":-3.8387103},"x":{"w":0,"x":1.0241117,"y":0.036667418,"z":-0.40126804},"y":{"w":0,"x":-0.0017703968,"y":1.0963622,"z":0.09566595},"z":{"w":0,"x":0.40293595,"y":-0.088377655,"z":1.0202928}}},{"id":4961182118666246256,"normal":{"x":0.00486465,"y":0.9994483,"z":0.032855343},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.175007,"y":-0.28788885,"z":-0.41096103},"x":{"w":0,"x":0.67168707,"y":-0.03355311,"z":0.9212223},"y":{"w":0,"x":0.005548559,"y":1.1399581,"z":0.03747439},"z":{"w":0,"x":-0.92181647,"y":-0.017587082,"z":0.67147976}}},{"id":4996839322536682682,"normal":{"x":-0.057735115,"y":0.9980019,"z":-0.025669705},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.218395,"y":-0.089194685,"z":-4.986478},"x":{"w":0,"x":-0.64826006,"y":-0.062062215,"z":-0.9548547},"y":{"w":0,"x":-0.06672941,"y":1.1534762,"z":-0.029668678},"z":{"w":0,"x":0.9545399,"y":0.038488004,"z":-0.650548}}},{"id":5131255535348154796,"normal":{"x":-0.44943485,"y":0.88795537,"z":0.09769155},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.0617747,"y":0.22623639,"z":4.183245},"x":{"w":0,"x":0.21455821,"y":0.22347373,"z":-1.0441507},"y":{"w":0,"x":-0.48949757,"y":0.9671079,"z":0.10639983},"z":{"w":0,"x":0.94899076,"y":0.44831717,"z":0.29095492}}},{"id":5144340803789185751,"normal":{"x":0.003403624,"y":0.998284,"z":0.05845907},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.9278345,"y":0.02897396,"z":-4.1165853},"x":{"w":0,"x":-0.84255373,"y":0.042553384,"z":-0.67761296},"y":{"w":0,"x":0.0036829466,"y":1.0802093,"z":0.06325658},"z":{"w":0,"x":0.6789378,"y":0.046948567,"z":-0.8412528}}},{"id":5199155223429921003,"normal":{"x":0.317942,"y":0.87331545,"z":0.36909756},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.300164,"y":0.42973596,"z":6.6235223},"x":{"w":0,"x":0.50591147,"y":0.10218529,"z":-0.6775727},"y":{"w":0,"x":0.27080986,"y":0.7438541,"z":0.3143821},"z":{"w":0,"x":0.62945104,"y":-0.40215948,"z":0.40933126}}},{"id":5306240815124141698,"normal":{"x":-0.0016087089,"y":0.9962134,"z":0.0869272},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.3145447,"y":-0.033567227,"z":4.054886},"x":{"w":0,"x":-0.7475996,"y":-0.078648694,"z":0.88750374},"y":{"w":0,"x":-0.0018710581,"y":1.1586752,"z":0.10110323},"z":{"w":0,"x":-0.8909798,"y":0.063558996,"z":-0.7448952}}},{"id":5333956731807639317,"normal":{"x":0.12890203,"y":0.99078,"z":0.04170405},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.9926686,"y":-0.048596505,"z":-1.403559},"x":{"w":0,"x":0.9369193,"y":-0.11587616,"z":-0.14298414},"y":{"w":0,"x":0.12307879,"y":0.94602084,"z":0.039820045},"z":{"w":0,"x":0.13683333,"y":-0.057504278,"z":0.94321746}}},{"id":5404576842344074154,"normal":{"x":-0.004858609,"y":0.99924725,"z":-0.038488075},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.283203,"y":0.28858203,"z":6.861725},"x":{"w":0,"x":0.80116385,"y":0.003200673,"z":-0.018038768},"y":{"w":0,"x":-0.003893559,"y":0.80077004,"z":-0.030843316},"z":{"w":0,"x":0.017902,"y":0.030922899,"z":0.8005763}}},{"id":5407017221204399694,"normal":{"x":0.0023239958,"y":0.9926046,"z":0.12137014},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.962698,"y":0.028768977,"z":-3.8896883},"x":{"w":0,"x":0.97837,"y":0.026178652,"z":-0.23283137},"y":{"w":0,"x":0.002338018,"y":0.9985935,"z":0.12210244},"z":{"w":0,"x":0.2342868,"y":-0.11928601,"z":0.9710736}}},{"id":5408299543589571425,"normal":{"x":-0.002169747,"y":0.99781895,"z":0.06597434},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.569075,"y":0.04051253,"z":-4.282018},"x":{"w":0,"x":1.0458903,"y":0.027221277,"z":-0.37730718},"y":{"w":0,"x":-0.0024131928,"y":1.109774,"z":0.07337664},"z":{"w":0,"x":0.37828013,"y":-0.06818326,"z":1.0436683}}},{"id":5453859435289942594,"normal":{"x":0.06620194,"y":0.9940292,"z":0.08673662},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.3386075,"y":-0.033272486,"z":4.159617},"x":{"w":0,"x":-0.34762952,"y":-0.049557976,"z":0.8332792},"y":{"w":0,"x":0.059862684,"y":0.8988446,"z":0.07843103},"z":{"w":0,"x":-0.8326024,"y":0.085316904,"z":-0.34227306}}},{"id":5476675009760289800,"normal":{"x":-0.06148598,"y":0.9978465,"z":-0.02284459},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.7026672,"y":0.20743793,"z":-0.7012024},"x":{"w":0,"x":1.1593231,"y":0.070728935,"z":-0.030882431},"y":{"w":0,"x":-0.071439885,"y":1.1593869,"z":-0.026542878},"z":{"w":0,"x":0.029200153,"y":0.028383095,"z":1.1611754}}},{"id":5482683669127398523,"normal":{"x":0.01741687,"y":0.9939929,"z":-0.108049594},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.913504,"y":0.080555536,"z":-3.0092633},"x":{"w":0,"x":0.9366963,"y":-0.078402124,"z":-0.5702644},"y":{"w":0,"x":0.019148644,"y":1.0928266,"z":-0.11879307},"z":{"w":0,"x":0.57531005,"y":0.09127744,"z":0.9324351}}},{"id":5532464745909881613,"normal":{"x":-0.07064687,"y":0.99724007,"z":0.022830708},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.1939065,"y":0.24931303,"z":0.473841},"x":{"w":0,"x":0.1687662,"y":-0.0094384765,"z":0.9344969},"y":{"w":0,"x":-0.067090556,"y":0.9470398,"z":0.021681434},"z":{"w":0,"x":-0.9321332,"y":-0.069872335,"z":0.16763362}}},{"id":5568724841896522898,"normal":{"x":0.03335988,"y":0.99461967,"z":-0.0980754},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.8322325,"y":-0.23462607,"z":-5.713161},"x":{"w":0,"x":-0.2176016,"y":-0.098152675,"z":-1.0694195},"y":{"w":0,"x":0.0365537,"y":1.0898429,"z":-0.10746498},"z":{"w":0,"x":1.073292,"y":-0.057017073,"z":-0.21315649}}},{"id":5675300392451389842,"normal":{"x":0.03343169,"y":0.99676114,"z":-0.07314075},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.7432022,"y":0.045205228,"z":3.9434633},"x":{"w":0,"x":0.07158254,"y":0.07465233,"z":1.0500803},"y":{"w":0,"x":0.035275828,"y":1.051744,"z":-0.0771753},"z":{"w":0,"x":-1.0521394,"y":0.04034156,"z":0.06885496}}},{"id":5873688302835403746,"normal":{"x":-0.08236074,"y":0.99258536,"z":-0.0893923},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.064994,"y":-0.16591983,"z":1.7633079},"x":{"w":0,"x":-0.9499827,"y":-0.07107762,"z":0.08603294},"y":{"w":0,"x":-0.07877929,"y":0.9494228,"z":-0.08550508},"z":{"w":0,"x":-0.07904124,"y":-0.092006885,"z":-0.94879305}}},{"id":5931565621737323728,"normal":{"x":0.06620196,"y":0.9940292,"z":0.08673661},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.90551627,"y":0.01860263,"z":-4.322054},"x":{"w":0,"x":-0.40616086,"y":0.10648102,"z":-0.9103031},"y":{"w":0,"x":0.06636582,"y":0.99648964,"z":0.08695129},"z":{"w":0,"x":0.91410375,"y":-0.025034841,"z":-0.41078502}}},{"id":5969504417151934198,"normal":{"x":-0.12121325,"y":0.99067986,"z":-0.062135503},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.84077555,"y":0.09586745,"z":-1.3116791},"x":{"w":0,"x":0.577675,"y":0.12555037,"z":0.87483585},"y":{"w":0,"x":-0.12798244,"y":1.0460045,"z":-0.06560548},"z":{"w":0,"x":-0.8744834,"y":-0.070147574,"z":0.5875093}}},{"id":5973132573365490647,"normal":{"x":0.01746491,"y":0.9967357,"z":0.07882231},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.792109,"y":0.15189981,"z":2.3622067},"x":{"w":0,"x":0.76277906,"y":-0.06068644,"z":0.5983899},"y":{"w":0,"x":0.01696512,"y":0.9682123,"z":0.07656667},"z":{"w":0,"x":-0.6012201,"y":-0.049673185,"z":0.7613491}}},{"id":6016292586000995025,"normal":{"x":-0.12014734,"y":0.992496,"z":0.022722097},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.23696147,"y":0.03113102,"z":1.1782186},"x":{"w":0,"x":-0.8852038,"y":-0.10621182,"z":-0.041372836},"y":{"w":0,"x":-0.10723299,"y":0.88581496,"z":0.020279752},"z":{"w":0,"x":0.038649015,"y":0.025084523,"z":-0.89132226}}},{"id":6086577981691600262,"normal":{"x":-0.03625433,"y":0.99738276,"z":-0.06255591},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.9123334,"y":0.16811892,"z":-1.6846874},"x":{"w":0,"x":-0.9504044,"y":-0.046522412,"z":-0.19093926},"y":{"w":0,"x":-0.03518521,"y":0.9679705,"z":-0.060711164},"z":{"w":0,"x":0.19334976,"y":-0.052531037,"z":-0.9496036}}},{"id":6115942786029977274,"normal":{"x":-0.12144657,"y":0.99258685,"z":0.0047027553},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.4142789,"y":-0.17603445,"z":-0.5684243},"x":{"w":0,"x":0.7224189,"y":0.084975146,"z":0.7208736},"y":{"w":0,"x":-0.12437271,"y":1.0165023,"z":0.0048160623},"z":{"w":0,"x":-0.71513003,"y":-0.09094498,"z":0.7273835}}},{"id":6382912636778867337,"normal":{"x":-0.1214479,"y":0.99259764,"z":-0.0005562174},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.12533867,"y":0.015871994,"z":-0.24284805},"x":{"w":0,"x":-0.77224994,"y":-0.09424533,"z":0.4322795},"y":{"w":0,"x":-0.108089834,"y":0.8834218,"z":-0.0004950408},"z":{"w":0,"x":-0.4290272,"y":-0.052928977,"z":-0.7779794}}},{"id":6392155858055133168,"normal":{"x":0.06255418,"y":0.99773973,"z":0.024544036},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.8471775,"y":0.12141093,"z":2.1288497},"x":{"w":0,"x":-0.29451126,"y":-0.008990708,"z":1.1160879},"y":{"w":0,"x":0.07220795,"y":1.1517174,"z":0.028331831},"z":{"w":0,"x":-1.1137859,"y":0.07704446,"z":-0.2932832}}},{"id":6467961738570720681,"normal":{"x":-0.0032064707,"y":0.9999949,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.0489349,"y":0.0033633963,"z":3.627945},"x":{"w":0,"x":0.08099191,"y":0.00025969942,"z":1.1702045},"y":{"w":0,"x":-0.0037612026,"y":1.172998,"z":-1.3655561e-10},"z":{"w":0,"x":-1.1701984,"y":-0.0037522265,"z":0.080992326}}},{"id":6601061506374349038,"normal":{"x":0.0024878464,"y":0.9961568,"z":0.08755208},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.829442,"y":-0.17446949,"z":-2.1646607},"x":{"w":0,"x":-0.76745915,"y":-0.052357905,"z":0.61752975},"y":{"w":0,"x":0.0024541242,"y":0.982656,"z":0.08636549},"z":{"w":0,"x":-0.6197405,"y":0.06872895,"z":-0.7643795}}},{"id":6753654986346384639,"normal":{"x":-0.0021580113,"y":0.99242204,"z":0.12285687},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.876611,"y":-0.006141741,"z":-3.5995603},"x":{"w":0,"x":-0.14698635,"y":-0.11824332,"z":0.9525726},"y":{"w":0,"x":-0.0020955815,"y":0.96371335,"z":0.11930288},"z":{"w":0,"x":-0.959881,"y":0.016002629,"z":-0.14612769}}},{"id":6851780835688215646,"normal":{"x":-0.06146576,"y":0.99751836,"z":0.034338094},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.6997986,"y":0.288254,"z":0.30545044},"x":{"w":0,"x":0.8053182,"y":0.06662797,"z":-0.49400327},"y":{"w":0,"x":-0.05821479,"y":0.94475883,"z":0.032521926},"z":{"w":0,"x":0.49506524,"y":0.0027111955,"z":0.807415}}},{"id":6917464196798311755,"normal":{"x":0.004231275,"y":0.99945116,"z":-0.03285544},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.701681,"y":-0.275799,"z":0.72403854},"x":{"w":0,"x":0.8146536,"y":-0.0032982011,"z":0.004584715},"y":{"w":0,"x":0.0034471066,"y":0.81422603,"z":-0.026766447},"z":{"w":0,"x":-0.004473835,"y":0.026785204,"z":0.8142204}}},{"id":6958726515315893108,"normal":{"x":0.06253747,"y":0.9974725,"z":-0.033728763},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.7377663,"y":0.11916171,"z":-2.4721208},"x":{"w":0,"x":-1.040448,"y":0.08225053,"z":0.5032996},"y":{"w":0,"x":0.072462745,"y":1.1557808,"z":-0.039081834},"z":{"w":0,"x":-0.50480175,"y":-0.003617938,"z":-1.042962}}},{"id":7129230664687023114,"normal":{"x":-0.061381873,"y":0.99615693,"z":0.06247904},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.0797424,"y":0.1761278,"z":1.8875885},"x":{"w":0,"x":0.9303338,"y":0.033260647,"z":0.38369355},"y":{"w":0,"x":-0.061805416,"y":1.0030307,"z":0.06291016},"z":{"w":0,"x":-0.3801409,"y":-0.08167819,"z":0.92880005}}},{"id":7134633061319323456,"normal":{"x":0.044921227,"y":0.99459064,"z":-0.093656406},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.51128,"y":0.23820636,"z":5.811723},"x":{"w":0,"x":1.1820436,"y":-0.06217156,"z":-0.093281455},"y":{"w":0,"x":0.0533371,"y":1.1809245,"z":-0.11120267},"z":{"w":0,"x":0.09859963,"y":0.10651562,"z":1.1784422}}},{"id":7143058827116097762,"normal":{"x":0.06631057,"y":0.9956603,"z":-0.06529481},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.3763964,"y":0.14321421,"z":5.58602},"x":{"w":0,"x":0.5887285,"y":-0.08026124,"z":-0.6259916},"y":{"w":0,"x":0.057231393,"y":0.85933536,"z":-0.0563547},"z":{"w":0,"x":0.6285156,"y":-0.0030689433,"z":0.59149575}}},{"id":7174659285793128737,"normal":{"x":0.06631057,"y":0.9956603,"z":-0.06529481},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.81122553,"y":0.07693814,"z":5.1493597},"x":{"w":0,"x":-0.94510955,"y":0.091246516,"z":0.43157762},"y":{"w":0,"x":0.06916091,"y":1.0384585,"z":-0.06810149},"z":{"w":0,"x":-0.43566266,"y":-0.03309259,"z":-0.9470586}}},{"id":7317978721210505232,"normal":{"x":-0.06146576,"y":0.99751836,"z":0.034338094},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.8514663,"y":0.20791876,"z":1.1206517},"x":{"w":0,"x":-1.0735394,"y":-0.08005927,"z":0.40406075},"y":{"w":0,"x":-0.07067657,"y":1.1469991,"z":0.039483752},"z":{"w":0,"x":-0.40580705,"y":0.012027394,"z":-1.0757961}}},{"id":7388536908491676592,"normal":{"x":-0.0021580113,"y":0.99242204,"z":0.12285687},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.8705435,"y":-0.013536494,"z":-3.5223677},"x":{"w":0,"x":-0.84600604,"y":-0.026126582,"z":0.19618687},"y":{"w":0,"x":-0.0018749862,"y":0.8622646,"z":0.10674402},"z":{"w":0,"x":-0.19791001,"y":0.10351428,"z":-0.8396514}}},{"id":7482425891155135909,"normal":{"x":0.0024873698,"y":0.995966,"z":-0.089696765},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.623786,"y":-0.16921045,"z":2.2230234},"x":{"w":0,"x":0.57698804,"y":0.080647215,"z":0.9114829},"y":{"w":0,"x":0.0026907537,"y":1.0774033,"z":-0.09703101},"z":{"w":0,"x":-0.9150398,"y":0.054021157,"z":0.57445985}}},{"id":7501766153600278580,"normal":{"x":0.01744207,"y":0.9954311,"z":-0.093875736},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":3.0703888,"y":0.16734113,"z":-2.3386688},"x":{"w":0,"x":0.80016536,"y":-0.07451626,"z":-0.64147854},"y":{"w":0,"x":0.017934924,"y":1.0235587,"z":-0.09652835},"z":{"w":0,"x":0.6455429,"y":0.06392739,"z":0.7978091}}},{"id":7507659111794703209,"normal":{"x":-0.0016088247,"y":0.9963029,"z":-0.085895166},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.5566102,"y":0.034149338,"z":-3.260748},"x":{"w":0,"x":0.3233113,"y":-0.069224894,"z":-0.80899906},"y":{"w":0,"x":-0.0014060456,"y":0.8707263,"z":-0.075068735},"z":{"w":0,"x":0.8119541,"y":0.02907242,"z":0.32200465}}},{"id":7686835658013460062,"normal":{"x":-0.0106218625,"y":0.99993616,"z":0.0038579102},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.028579,"y":0.75941956,"z":4.8017464},"x":{"w":0,"x":-0.41180474,"y":-0.007555858,"z":0.8246025},"y":{"w":0,"x":-0.0097906245,"y":0.9216838,"z":0.003556001},"z":{"w":0,"x":-0.82457894,"y":-0.0071701086,"z":-0.41185868}}},{"id":7757715829268399993,"normal":{"x":-0.06123137,"y":0.99371445,"z":-0.09371384},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.953604,"y":0.21060474,"z":-1.8626777},"x":{"w":0,"x":-0.5256611,"y":0.045249745,"z":0.8232751},"y":{"w":0,"x":-0.059873797,"y":0.97168255,"z":-0.091636084},"z":{"w":0,"x":-0.82234097,"y":-0.09967199,"z":-0.51958627}}},{"id":7901371199605660933,"normal":{"x":0.01746491,"y":0.9967357,"z":0.07882231},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.3103395,"y":0.19253215,"z":1.7335707},"x":{"w":0,"x":-0.51137686,"y":-0.049198795,"z":0.7354435},"y":{"w":0,"x":0.015667915,"y":0.89418024,"z":0.07071217},"z":{"w":0,"x":-0.73692065,"y":0.053152356,"z":-0.5088484}}},{"id":7923164676572280662,"normal":{"x":0.066452384,"y":0.9977896,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.46717033,"y":0.031113353,"z":4.5506244},"x":{"w":0,"x":0.8810569,"y":-0.05867803,"z":0.25048295},"y":{"w":0,"x":0.06099322,"y":0.91581976,"z":8.54813e-10},"z":{"w":0,"x":-0.2499293,"y":0.01664519,"z":0.88300866}}},{"id":7952479049380363240,"normal":{"x":0.10564181,"y":0.99399024,"z":-0.028692227},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":4.847931,"y":0.21857058,"z":-0.36354065},"x":{"w":0,"x":-0.6643773,"y":0.04813973,"z":-0.7784546},"y":{"w":0,"x":0.1082355,"y":1.0183944,"z":-0.029396672},"z":{"w":0,"x":0.7723951,"y":-0.10129982,"z":-0.66547006}}},{"id":8048702852484375382,"normal":{"x":0.117978215,"y":0.99088776,"z":0.064981826},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.4277139,"y":-0.12108171,"z":5.2542415},"x":{"w":0,"x":-0.3341262,"y":-0.024797829,"z":0.9847596},"y":{"w":0,"x":0.122720405,"y":1.030717,"z":0.0675938},"z":{"w":0,"x":-0.9773977,"y":0.1378923,"z":-0.32815593}}},{"id":8388491218624194167,"normal":{"x":0.066452384,"y":0.9977896,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.0155415265,"y":-0.0010350593,"z":5.0685153},"x":{"w":0,"x":-0.79286194,"y":0.052804276,"z":-0.47872177},"y":{"w":0,"x":0.061646637,"y":0.92563087,"z":0},"z":{"w":0,"x":0.47766355,"y":-0.0318122,"z":-0.7946183}}},{"id":8479178507877664781,"normal":{"x":0.044921227,"y":0.99459064,"z":-0.093656406},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.113198,"y":0.16473895,"z":5.3202343},"x":{"w":0,"x":-0.5470755,"y":0.12156404,"z":1.0285592},"y":{"w":0,"x":0.052617367,"y":1.1649895,"z":-0.10970214},"z":{"w":0,"x":-1.0343806,"y":-0.0050329925,"z":-0.54957706}}},{"id":8518557008992606757,"normal":{"x":-0.048369806,"y":0.99463844,"z":0.091403924},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.791624,"y":0.1493372,"z":-5.01199},"x":{"w":0,"x":0.19026484,"y":0.09775831,"z":-0.9630998},"y":{"w":0,"x":-0.04772015,"y":0.98127955,"z":0.09017629},"z":{"w":0,"x":0.9668716,"y":0.029193994,"z":0.19397326}}},{"id":8544775542655531210,"normal":{"x":0.121237844,"y":0.9922102,"z":0.028640846},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.951989,"y":0.19644909,"z":0.49765652},"x":{"w":0,"x":-0.7191973,"y":0.07591399,"z":0.41448843},"y":{"w":0,"x":0.10105801,"y":0.8270584,"z":0.023873622},"z":{"w":0,"x":-0.4090854,"y":0.070850104,"z":-0.72279847}}},{"id":8743514383706044212,"normal":{"x":0.07639614,"y":0.99541277,"z":0.057593707},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.180595,"y":-0.019491458,"z":-2.8952296},"x":{"w":0,"x":-0.43010515,"y":0.072004326,"z":-0.67395645},"y":{"w":0,"x":0.061326277,"y":0.7990582,"z":0.046232805},"z":{"w":0,"x":0.6750119,"y":-0.026716316,"z":-0.43363306}}},{"id":8850896281288043949,"normal":{"x":0.06645241,"y":0.9977896,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.43545532,"y":0.029001158,"z":-4.8327594},"x":{"w":0,"x":0.44152418,"y":-0.029405346,"z":0.7946009},"y":{"w":0,"x":0.060438782,"y":0.9074944,"z":0},"z":{"w":0,"x":-0.7928445,"y":0.05280314,"z":0.44250238}}},{"id":8888344324077843044,"normal":{"x":0.1289759,"y":0.99134785,"z":0.024386799},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.3292284,"y":0.028685616,"z":1.2188747},"x":{"w":0,"x":-0.4839691,"y":0.079379655,"z":-0.6672666},"y":{"w":0,"x":0.10680667,"y":0.8209484,"z":0.020195039},"z":{"w":0,"x":0.6634291,"y":-0.07425886,"z":-0.49001974}}},{"id":8901509044156194760,"normal":{"x":0.118186176,"y":0.9926344,"z":-0.026626658},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-1.0127739,"y":0.13330956,"z":6.4744034},"x":{"w":0,"x":-0.898596,"y":0.112098716,"z":0.19046427},"y":{"w":0,"x":0.10936644,"y":0.91855836,"z":-0.024639621},"z":{"w":0,"x":-0.1920462,"y":-0.0014163647,"z":-0.90522575}}},{"id":9106284951925644996,"normal":{"x":0.034109235,"y":0.9988265,"z":0.034383126},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.4378371,"y":0.28590003,"z":0.47684866},"x":{"w":0,"x":-0.82440704,"y":0.05650239,"z":-0.8235492},"y":{"w":0,"x":0.039793544,"y":1.1652812,"z":0.04011308},"z":{"w":0,"x":0.8245255,"y":0.00025505363,"z":-0.82536685}}},{"id":9136034351442388783,"normal":{"x":-0.12014734,"y":0.992496,"z":0.022722097},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.1658134,"y":0.14685598,"z":1.0348574},"x":{"w":0,"x":0.26765054,"y":0.007146046,"z":1.1031146},"y":{"w":0,"x":-0.13638443,"y":1.1266248,"z":0.025792833},"z":{"w":0,"x":-1.0946745,"y":-0.13861787,"z":0.26650062}}},{"id":9312239655361542277,"normal":{"x":0.0024873698,"y":0.995966,"z":-0.089696765},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.372025,"y":-0.20225997,"z":1.8768005},"x":{"w":0,"x":0.11012234,"y":-0.0940507,"z":-1.041257},"y":{"w":0,"x":0.002614924,"y":1.0470387,"z":-0.09429637},"z":{"w":0,"x":1.0454926,"y":0.0072876243,"z":0.109912015}}},{"id":9618223119026222502,"normal":{"x":0.00011014569,"y":0.99278116,"z":0.119939744},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.69872,"y":-0.01670382,"z":-3.5181663},"x":{"w":0,"x":-1.1772076,"y":0.018280294,"z":-0.15023099},"y":{"w":0,"x":0.0001307314,"y":1.1783276,"z":0.14235596},"z":{"w":0,"x":0.15133902,"y":0.14117743,"z":-1.1687114}}},{"id":9624431911603049355,"normal":{"x":0.08894184,"y":0.99552464,"z":-0.031937737},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":7.93425,"y":-0.1716713,"z":0.8974762},"x":{"w":0,"x":-1.032246,"y":0.10676823,"z":0.4533991},"y":{"w":0,"x":0.1007245,"y":1.1274078,"z":-0.03616872},"z":{"w":0,"x":-0.4547799,"y":0.007358549,"z":-1.0371225}}},{"id":10053571058584562504,"normal":{"x":0.08013038,"y":0.9923942,"z":0.09344958},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":11.014709,"y":0.19189827,"z":-5.75589},"x":{"w":0,"x":-1.0325003,"y":0.06991882,"z":0.1428322},"y":{"w":0,"x":0.08371023,"y":1.0367298,"z":0.097624466},"z":{"w":0,"x":-0.13521197,"y":0.10793191,"z":-1.03025}}},{"id":10324351260341824835,"normal":{"x":-0.0021580113,"y":0.99242204,"z":0.12285687},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":11.551697,"y":-0.04787391,"z":-3.250595},"x":{"w":0,"x":-0.20197633,"y":0.13343911,"z":-1.0814517},"y":{"w":0,"x":-0.0023915353,"y":1.099816,"z":0.1361517},"z":{"w":0,"x":1.0896504,"y":0.027147971,"z":-0.20015778}}},{"id":10585522169474554174,"normal":{"x":0.12899931,"y":0.9915277,"z":-0.015234176},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.41875,"y":-0.12960754,"z":0.09295659},"x":{"w":0,"x":0.7600061,"y":-0.09072234,"z":0.5308163},"y":{"w":0,"x":0.12015687,"y":0.92356193,"z":-0.01418993},"z":{"w":0,"x":-0.524937,"y":0.080053,"z":0.7652702}}},{"id":10600154983221444583,"normal":{"x":0.03335988,"y":0.99461967,"z":-0.0980754},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.7977204,"y":-0.16913411,"z":-5.0607214},"x":{"w":0,"x":0.87450486,"y":-0.09735415,"z":-0.68984646},"y":{"w":0,"x":0.03729935,"y":1.1120741,"z":-0.109657094},"z":{"w":0,"x":0.69568294,"y":0.062754214,"z":0.87304753}}},{"id":10671355654678616451,"normal":{"x":0.121282786,"y":0.99257797,"z":-0.008912641},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.244844,"y":0.17186333,"z":-0.3527154},"x":{"w":0,"x":0.7979168,"y":-0.10238729,"z":-0.5445967},"y":{"w":0,"x":0.117821716,"y":0.96425253,"z":-0.008658296},"z":{"w":0,"x":0.5414672,"y":-0.05893866,"z":0.8044124}}},{"id":10719402248464686976,"normal":{"x":-0.028752863,"y":0.9947621,"z":-0.09808944},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":3.6217651,"y":-0.21299689,"z":-5.6190033},"x":{"w":0,"x":0.07279617,"y":0.10196652,"z":1.0127424},"y":{"w":0,"x":-0.029341212,"y":1.0151174,"z":-0.1000966},"z":{"w":0,"x":-1.0174397,"y":-0.021978704,"z":0.0753467}}},{"id":11012430682543819626,"normal":{"x":0.029677605,"y":0.99914336,"z":0.028840976},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.0969477,"y":0.26348826,"z":0.50323886},"x":{"w":0,"x":0.4446403,"y":-0.037212644,"z":0.831626},"y":{"w":0,"x":0.028008673,"y":0.9429561,"z":0.027219092},"z":{"w":0,"x":-0.8319868,"y":0.011856809,"z":0.4453638}}},{"id":11061716874540267413,"normal":{"x":-0.03619137,"y":0.9956515,"z":0.085839},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.6541252,"y":0.14392166,"z":2.4730337},"x":{"w":0,"x":-1.1516999,"y":-0.04928397,"z":0.08606873},"y":{"w":0,"x":-0.04183587,"y":1.1509358,"z":0.099226676},"z":{"w":0,"x":-0.08992495,"y":0.095745824,"z":-1.1484754}}},{"id":11123601313559906740,"normal":{"x":-0.03407451,"y":0.9988482,"z":0.033782337},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.5906544,"y":-0.2871669,"z":6.9155464},"x":{"w":0,"x":-0.67781365,"y":0.000989724,"z":-0.71293896},"y":{"w":0,"x":-0.03351994,"y":0.9825916,"z":0.03323252},"z":{"w":0,"x":0.7121513,"y":0.047191177,"z":-0.67699915}}},{"id":11144095154606067968,"normal":{"x":-0.06146576,"y":0.99751836,"z":0.034338094},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.8153687,"y":0.27302325,"z":0.954774},"x":{"w":0,"x":-0.55793375,"y":-0.06285084,"z":0.8271002},"y":{"w":0,"x":-0.06144532,"y":0.9971867,"z":0.034326673},"z":{"w":0,"x":-0.82720584,"y":-0.03167996,"z":-0.56041235}}},{"id":11222948170222868408,"normal":{"x":0.06560112,"y":0.8547583,"z":-0.5148638},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.061722,"y":0.08681457,"z":2.481495},"x":{"w":0,"x":0.018870797,"y":0.5972017,"z":0.99385715},"y":{"w":0,"x":0.07607352,"y":0.9912093,"z":-0.5970551},"z":{"w":0,"x":-1.1569852,"y":0.07491403,"z":-0.023047151}}},{"id":11286177971909030538,"normal":{"x":-0.0016010712,"y":0.99150133,"z":-0.13008651},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.6156464,"y":-0.005583607,"z":-3.6929474},"x":{"w":0,"x":-0.577069,"y":0.07623327,"z":0.58814186},"y":{"w":0,"x":-0.0013248639,"y":0.8204522,"z":-0.1076446},"z":{"w":0,"x":-0.5930603,"y":-0.076010555,"z":-0.57204264}}},{"id":11497973097935105676,"normal":{"x":-0.048693217,"y":0.99478763,"z":0.089590624},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.651657,"y":-0.2036777,"z":-1.7821808},"x":{"w":0,"x":-0.95366275,"y":-0.08450726,"z":0.42002022},"y":{"w":0,"x":-0.050907847,"y":1.0400319,"z":0.09366531},"z":{"w":0,"x":-0.425402,"y":0.06498709,"z":-0.9528069}}},{"id":11581656704912725761,"normal":{"x":0.05956621,"y":0.99340653,"z":0.097955786},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.3497782,"y":-0.19014728,"z":5.6004148},"x":{"w":0,"x":-0.13676026,"y":-0.085550025,"z":0.9507579},"y":{"w":0,"x":0.057442423,"y":0.9579874,"z":0.09446326},"z":{"w":0,"x":-0.9528693,"y":0.070029505,"z":-0.13076262}}},{"id":11654961138256470117,"normal":{"x":0.06645241,"y":0.9977896,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.292176,"y":-0.01945881,"z":-4.3431664},"x":{"w":0,"x":-0.029646413,"y":0.001974435,"z":0.9613843},"y":{"w":0,"x":0.0639168,"y":0.95971733,"z":3.5831456e-09},"z":{"w":0,"x":-0.95925933,"y":0.06388629,"z":-0.02971209}}},{"id":11770786811117233200,"normal":{"x":0.00077180483,"y":0.99150234,"z":0.13008665},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.0669413,"y":-0.021969734,"z":3.8162405},"x":{"w":0,"x":-0.537963,"y":0.09364882,"z":-0.7105865},"y":{"w":0,"x":0.0006916632,"y":0.8885473,"z":0.1165788},"z":{"w":0,"x":0.71673065,"y":0.06943337,"z":-0.5334638}}},{"id":11847577867596375237,"normal":{"x":0.12900916,"y":0.99160343,"z":-0.00890389},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.7537413,"y":-0.02180587,"z":-0.7240151},"x":{"w":0,"x":-0.7944265,"y":0.09929063,"z":-0.4527647},"y":{"w":0,"x":0.11865814,"y":0.91204226,"z":-0.008189489},"z":{"w":0,"x":0.4480789,"y":-0.065484285,"z":-0.8005654}}},{"id":11878859136281885926,"normal":{"x":-0.016028553,"y":0.99137527,"z":-0.13006997},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.6924117,"y":-0.09560992,"z":-4.469172},"x":{"w":0,"x":0.5859061,"y":0.11133775,"z":0.7763995},"y":{"w":0,"x":-0.015692264,"y":0.9705752,"z":-0.12734099},"z":{"w":0,"x":-0.78418493,"y":0.06376422,"z":0.5826373}}},{"id":11904164239714197153,"normal":{"x":-0.1214479,"y":0.99259764,"z":-0.0005562174},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":0.09906006,"y":0.01219048,"z":-1.0748596},"x":{"w":0,"x":0.2638331,"y":0.031786185,"z":-0.8828979},"y":{"w":0,"x":-0.1119778,"y":0.91519827,"z":-0.00051284727},"z":{"w":0,"x":0.87634474,"y":0.10737284,"z":0.2657405}}},{"id":11958499526726458717,"normal":{"x":0.00027918897,"y":0.79497856,"z":-0.6066375},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":8.959656,"y":0.58292425,"z":3.3627014},"x":{"w":0,"x":1.1423814,"y":0.18917453,"z":0.2484328},"y":{"w":0,"x":0.0003306572,"y":0.94148463,"z":-0.7184343},"z":{"w":0,"x":-0.3122591,"y":0.6930808,"z":0.908116}}},{"id":11986514245922902213,"normal":{"x":0.066452384,"y":0.9977896,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.53502405,"y":-0.035632383,"z":5.564475},"x":{"w":0,"x":-0.4993326,"y":0.033255346,"z":-0.7235546},"y":{"w":0,"x":0.058461867,"y":0.8778112,"z":0},"z":{"w":0,"x":0.72195524,"y":-0.048081927,"z":-0.5004388}}},{"id":12020118259628586699,"normal":{"x":-0.5466018,"y":0.8369141,"z":0.028305518},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.9239955,"y":0.039263893,"z":6.368808},"x":{"w":0,"x":0.37787026,"y":0.21080299,"z":1.0641224},"y":{"w":0,"x":-0.6278978,"y":0.96138835,"z":0.032515373},"z":{"w":0,"x":-0.88461226,"y":-0.5923471,"z":0.43147022}}},{"id":12030260455644956093,"normal":{"x":-0.010621532,"y":0.999905,"z":-0.008783104},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.4606233,"y":0.76572055,"z":3.7847712},"x":{"w":0,"x":0.821585,"y":0.010901917,"z":0.2475653},"y":{"w":0,"x":-0.009114793,"y":0.8580614,"z":-0.007537158},"z":{"w":0,"x":-0.24763753,"y":0.004586543,"z":0.8216227}}},{"id":12047061391077086541,"normal":{"x":0.11822809,"y":0.99298644,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.41529113,"y":0.04944587,"z":5.9975896},"x":{"w":0,"x":0.38652718,"y":-0.046021134,"z":-0.92421955},"y":{"w":0,"x":0.11856474,"y":0.99581397,"z":3.735898e-09},"z":{"w":0,"x":0.91773754,"y":-0.10926872,"z":0.38925722}}},{"id":12070102671655222185,"normal":{"x":-0.094951384,"y":0.9953322,"z":0.017266635},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.839554,"y":-0.17443581,"z":-0.043558404},"x":{"w":0,"x":-0.9667028,"y":-0.09287614,"z":0.0378092},"y":{"w":0,"x":-0.09228227,"y":0.96735317,"z":0.016781267},"z":{"w":0,"x":-0.039236374,"y":0.013101668,"z":-0.9710091}}},{"id":12150019850043282006,"normal":{"x":-0.50689954,"y":0.85907346,"z":0.07103301},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.576371,"y":-0.09683835,"z":4.9928207},"x":{"w":0,"x":-0.6776993,"y":-0.45721903,"z":0.69347},"y":{"w":0,"x":-0.5434063,"y":0.9209437,"z":0.076148786},"z":{"w":0,"x":-0.6282193,"y":-0.30338058,"z":-0.8139576}}},{"id":12191103888102579059,"normal":{"x":0.044921227,"y":0.99459064,"z":-0.093656406},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.73381,"y":0.16788372,"z":5.1716614},"x":{"w":0,"x":0.37518907,"y":-0.099420495,"z":-0.87584776},"y":{"w":0,"x":0.043034453,"y":0.9528161,"z":-0.08972266},"z":{"w":0,"x":0.88042134,"y":-0.0042053056,"z":0.37762558}}},{"id":12213623745628730510,"normal":{"x":0.0005114408,"y":0.9999867,"z":0.0051334817},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.03807,"y":0.76444155,"z":3.7601132},"x":{"w":0,"x":-0.8343762,"y":0.00047827454,"z":-0.010038825},"y":{"w":0,"x":0.000426765,"y":0.8344256,"z":0.0042835656},"z":{"w":0,"x":0.010041147,"y":0.0042781206,"z":-0.83436537}}},{"id":12423136668519823831,"normal":{"x":-0.50689954,"y":0.85907346,"z":0.07103301},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":4.737732,"y":-0.064743124,"z":5.7561493},"x":{"w":0,"x":-0.86468923,"y":-0.45372182,"z":-0.68320626},"y":{"w":0,"x":-0.604109,"y":1.02382,"z":0.08465517},"z":{"w":0,"x":0.5546951,"y":0.40773842,"z":-0.97282296}}},{"id":12505707260982300884,"normal":{"x":0.00077194633,"y":0.9917019,"z":-0.12855652},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.245663,"y":0.07922572,"z":-3.043155},"x":{"w":0,"x":-0.94638103,"y":0.041578468,"z":0.31505904},"y":{"w":0,"x":0.00077064073,"y":0.9900285,"z":-0.12833959},"z":{"w":0,"x":-0.3177898,"y":-0.12142025,"z":-0.93856}}},{"id":12519427218624189587,"normal":{"x":-0.12008082,"y":0.99276286,"z":-0.001588687},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.35009396,"y":0.045887705,"z":2.1033256},"x":{"w":0,"x":-0.054773696,"y":-0.004883413,"z":1.0884417},"y":{"w":0,"x":-0.13086767,"y":1.0819426,"z":-0.0017314071},"z":{"w":0,"x":-1.0805568,"y":-0.130788,"z":-0.054963693}}},{"id":12535335269539011193,"normal":{"x":-0.036024448,"y":0.99105847,"z":-0.1284731},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":2.2374268,"y":0.10335053,"z":-2.644699},"x":{"w":0,"x":0.78212726,"y":0.05220388,"z":0.18339552},"y":{"w":0,"x":-0.02900096,"y":0.79783726,"z":-0.103425406},"z":{"w":0,"x":-0.18846248,"y":0.09387559,"z":0.7770144}}},{"id":12614480218963424916,"normal":{"x":0.45435217,"y":0.8840736,"z":-0.10944389},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.257127,"y":0.11675814,"z":3.590666},"x":{"w":0,"x":0.95441973,"y":-0.5408322,"z":-0.40653598},"y":{"w":0,"x":0.5315508,"y":1.034286,"z":-0.12803942},"z":{"w":0,"x":0.41859853,"y":-0.08025509,"z":1.0895056}}},{"id":12641945224730632620,"normal":{"x":0.10212567,"y":0.99426,"z":0.031897165},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":8.362991,"y":-0.22339316,"z":-0.21369934},"x":{"w":0,"x":-0.8186059,"y":0.09406692,"z":-0.31119725},"y":{"w":0,"x":0.08995224,"y":0.87574375,"z":0.02809501},"z":{"w":0,"x":0.3124115,"y":-0.0056700227,"z":-0.8235136}}},{"id":12677446537644682667,"normal":{"x":0.14792497,"y":0.9889587,"z":0.008880142},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.442794,"y":0.013190849,"z":0.37149864},"x":{"w":0,"x":0.8919572,"y":-0.1369206,"z":0.3903166},"y":{"w":0,"x":0.14543982,"y":0.97234416,"z":0.0087309545},"z":{"w":0,"x":-0.3872229,"y":0.04981687,"z":0.90236294}}},{"id":12730415926885948354,"normal":{"x":-0.003206413,"y":0.9999949,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":-1.2857208,"y":-0.004122573,"z":-3.7018738},"x":{"w":0,"x":-0.546616,"y":-0.0017526856,"z":0.9944201},"y":{"w":0,"x":-0.0036384854,"y":1.1347468,"z":-1.3210258e-10},"z":{"w":0,"x":-0.994415,"y":-0.0031885218,"z":-0.5466189}}},{"id":12998728714861939358,"normal":{"x":0.0663106,"y":0.9956603,"z":-0.0652948},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.48866865,"y":-0.04954258,"z":-5.0591884},"x":{"w":0,"x":-0.9085392,"y":0.013389892,"z":-0.718495},"y":{"w":0,"x":0.07681325,"y":1.1533588,"z":-0.07563658},"z":{"w":0,"x":0.7145027,"y":-0.10696672,"z":-0.9054843}}},{"id":13030887303061472467,"normal":{"x":0.0007738607,"y":0.9941434,"z":0.10806596},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.4465265,"y":0.0922128,"z":2.807867},"x":{"w":0,"x":0.63399833,"y":-0.10862764,"z":0.9947704},"y":{"w":0,"x":0.0009167272,"y":1.1776812,"z":0.128017},"z":{"w":0,"x":-1.0006834,"y":-0.06774382,"z":0.6303693}}},{"id":13165685773189169943,"normal":{"x":0.052428912,"y":0.99478984,"z":-0.08743195},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":8.326965,"y":-0.13102348,"z":2.2318268},"x":{"w":0,"x":-0.23821194,"y":0.09589303,"z":0.9482144},"y":{"w":0,"x":0.051504593,"y":0.9772518,"z":-0.08589053},"z":{"w":0,"x":-0.9516582,"y":0.028886518,"z":-0.24199839}}},{"id":13217190812011182453,"normal":{"x":0.3937299,"y":0.91913885,"z":-0.012669878},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":9.022125,"y":0.7896767,"z":5.75441},"x":{"w":0,"x":-0.6621555,"y":0.27859834,"z":-0.36621082},"y":{"w":0,"x":0.31747848,"y":0.7411345,"z":-0.010216172},"z":{"w":0,"x":0.3330688,"y":-0.15257758,"z":-0.7183054}}},{"id":13375886585845454060,"normal":{"x":0.045021445,"y":0.99680954,"z":-0.065907605},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.549169,"y":0.08891876,"z":4.691996},"x":{"w":0,"x":-0.82665676,"y":0.09382773,"z":0.8543947},"y":{"w":0,"x":0.05368994,"y":1.1887367,"z":-0.07859755},"z":{"w":0,"x":-0.85785276,"y":-0.016016886,"z":-0.82824355}}},{"id":13376647632305700624,"normal":{"x":0.052584063,"y":0.997733,"z":0.041996717},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":7.9564514,"y":-0.12089986,"z":-1.8254395},"x":{"w":0,"x":-0.5550098,"y":0.0016198514,"z":0.65644395},"y":{"w":0,"x":0.04520264,"y":0.8576774,"z":0.03610148},"z":{"w":0,"x":-0.65488774,"y":0.05782708,"z":-0.55383676}}},{"id":13551357464975787028,"normal":{"x":0.12900916,"y":0.99160343,"z":-0.00890389},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":6.13089,"y":0.057540216,"z":-0.9119873},"x":{"w":0,"x":1.0308502,"y":-0.13800123,"z":-0.4327739},"y":{"w":0,"x":0.14532812,"y":1.1170356,"z":-0.010030184},"z":{"w":0,"x":0.43036887,"y":-0.046653222,"z":1.0399979}}},{"id":13582459266265188091,"normal":{"x":-0.0022942792,"y":0.9955931,"z":0.0937508},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.405052,"y":0.21283877,"z":-5.5392437},"x":{"w":0,"x":-1.0952278,"y":0.001047406,"z":-0.037925526},"y":{"w":0,"x":-0.002514266,"y":1.0910554,"z":0.10274009},"z":{"w":0,"x":0.03785659,"y":0.102765515,"z":-1.0903989}}},{"id":13651276036702641170,"normal":{"x":-0.016028553,"y":0.99137527,"z":-0.13006997},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":3.3252869,"y":-0.11740794,"z":-4.5900726},"x":{"w":0,"x":-0.8462698,"y":-0.049724113,"z":-0.27470425},"y":{"w":0,"x":-0.014283479,"y":0.8834413,"z":-0.115908876},"z":{"w":0,"x":0.27880263,"y":-0.105671175,"z":-0.83976793}}},{"id":13684629974968942527,"normal":{"x":0.001985411,"y":0.794977,"z":-0.6066363},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.237132,"y":0.2604606,"z":2.940918},"x":{"w":0,"x":0.83831364,"y":0.31029812,"z":0.40937915},"y":{"w":0,"x":0.0019520388,"y":0.7816067,"z":-0.59643364},"z":{"w":0,"x":-0.51368517,"y":0.5093643,"z":0.665824}}},{"id":13715289067101464913,"normal":{"x":-0.07199032,"y":0.99740535,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":-0.1007843,"y":-0.007274369,"z":-2.7539215},"x":{"w":0,"x":-0.57327217,"y":-0.04137741,"z":-0.93311524},"y":{"w":0,"x":-0.07889618,"y":1.093084,"z":0},"z":{"w":0,"x":0.93069416,"y":0.06717527,"z":-0.57476354}}},{"id":13836336170938418832,"normal":{"x":0.0764558,"y":0.99619085,"z":-0.041931815},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.7460756,"y":-0.034807652,"z":1.9245863},"x":{"w":0,"x":0.9667027,"y":-0.07868933,"z":-0.10682951},"y":{"w":0,"x":0.074602954,"y":0.97204894,"z":-0.040915627},"z":{"w":0,"x":0.10972216,"y":0.032367866,"z":0.9690367}}},{"id":13898711065492966552,"normal":{"x":0.06220801,"y":0.99221826,"z":0.10785669},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.13067,"y":0.036835834,"z":2.953171},"x":{"w":0,"x":0.6140456,"y":-0.098288625,"z":0.55003726},"y":{"w":0,"x":0.05164591,"y":0.8237526,"z":0.089544035},"z":{"w":0,"x":-0.55635816,"y":-0.0320122,"z":0.61538166}}},{"id":13924919120609585234,"normal":{"x":0.09484832,"y":0.99528617,"z":-0.020229978},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.214112,"y":0.22523737,"z":6.7387614},"x":{"w":0,"x":0.015000133,"y":-0.019006824,"z":-0.8647806},"y":{"w":0,"x":0.08205514,"y":0.86104137,"z":-0.017501345},"z":{"w":0,"x":0.86108863,"y":-0.08171953,"z":0.016732173}}},{"id":14143275550433836302,"normal":{"x":-0.016087873,"y":0.9950448,"z":0.09811733},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":3.60997,"y":-0.15124914,"z":4.910553},"x":{"w":0,"x":-0.14984667,"y":0.0818504,"z":-0.85464543},"y":{"w":0,"x":-0.014021136,"y":0.8672159,"z":0.08551264},"z":{"w":0,"x":0.8584415,"y":0.028451974,"z":-0.14778733}}},{"id":14230324854543547780,"normal":{"x":0.06620196,"y":0.9940292,"z":0.08673661},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":-0.75193787,"y":0.04196124,"z":-4.70697},"x":{"w":0,"x":-0.81841594,"y":0.06084495,"z":-0.07264427},"y":{"w":0,"x":0.054542705,"y":0.81896424,"z":0.07146086},"z":{"w":0,"x":0.07748801,"y":0.06617743,"z":-0.8175574}}},{"id":14570852869534098161,"normal":{"x":-0.01898656,"y":0.99979794,"z":-0.0066003236},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":7.4443054,"y":0.7804858,"z":5.7826233},"x":{"w":0,"x":0.23996373,"y":-0.0018113799,"z":-0.96466476},"y":{"w":0,"x":-0.018873861,"y":0.9938635,"z":-0.006561146},"z":{"w":0,"x":0.96448183,"y":0.019899502,"z":0.23988086}}},{"id":14656888401253097172,"normal":{"x":-0.03619137,"y":0.9956515,"z":0.085839},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.8103638,"y":0.0822061,"z":2.83313},"x":{"w":0,"x":-0.77687234,"y":0.017081754,"z":-0.5256765},"y":{"w":0,"x":-0.033953562,"y":0.9340879,"z":0.08053136},"z":{"w":0,"x":0.5248568,"y":0.0857109,"z":-0.7728759}}},{"id":14718649945083518949,"normal":{"x":0.105681114,"y":0.99435997,"z":0.008928642},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":5.6183167,"y":0.12013075,"z":0.35559082},"x":{"w":0,"x":-0.06539866,"y":0.014621597,"z":-0.854298},"y":{"w":0,"x":0.0905605,"y":0.8520892,"z":0.007651155},"z":{"w":0,"x":0.8496103,"y":-0.08969924,"z":-0.066575065}}},{"id":14751172137323531424,"normal":{"x":-0.0016014221,"y":0.9917009,"z":0.12855639},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.4367428,"y":0.035376802,"z":3.37587},"x":{"w":0,"x":-0.26298392,"y":-0.14586297,"z":1.1219301},"y":{"w":0,"x":-0.0018601145,"y":1.1518953,"z":0.14932276},"z":{"w":0,"x":-1.1313708,"y":0.032011576,"z":-0.261035}}},{"id":15035277316039575643,"normal":{"x":-0.0825992,"y":0.99545926,"z":0.047309678},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.861099,"y":-0.11950344,"z":-1.8541814},"x":{"w":0,"x":-0.45394668,"y":-0.07014719,"z":0.68343395},"y":{"w":0,"x":-0.06801632,"y":0.8197111,"z":0.03895716},"z":{"w":0,"x":-0.68364936,"y":-0.034975026,"z":-0.4576795}}},{"id":15141264676356332061,"normal":{"x":0.0027835742,"y":0.999411,"z":-0.03420343},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":5.423349,"y":-0.0036312556,"z":-3.8081539},"x":{"w":0,"x":0.557923,"y":-0.021186875,"z":-0.57366693},"y":{"w":0,"x":0.002228288,"y":0.8000413,"z":-0.027380284},"z":{"w":0,"x":0.5740537,"y":0.017486036,"z":0.5576534}}},{"id":15312337249018848095,"normal":{"x":0.00011014569,"y":0.99278116,"z":0.119939744},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.206166,"y":-0.11755468,"z":-2.6829379},"x":{"w":0,"x":-1.0398582,"y":-0.00095565733,"z":0.008865238},"y":{"w":0,"x":0.00011454012,"y":1.0323896,"z":0.124724925},"z":{"w":0,"x":-0.008915863,"y":0.12472131,"z":-1.0323515}}},{"id":15367259572745846369,"normal":{"x":0.01744207,"y":0.9954311,"z":-0.093875736},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.7137578,"y":0.1614447,"z":-2.2816553},"x":{"w":0,"x":-0.07434272,"y":-0.07961078,"z":-0.8579825},"y":{"w":0,"x":0.015085106,"y":0.8609176,"z":-0.08119023},"z":{"w":0,"x":0.86153597,"y":-0.021943968,"z":-0.07261448}}},{"id":15375075865290289993,"normal":{"x":0.00041945747,"y":0.8201378,"z":-0.57216597},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":6.855789,"y":0.6866607,"z":3.4879303},"x":{"w":0,"x":-0.56594276,"y":-0.33578128,"z":-0.48172086},"y":{"w":0,"x":0.0003420779,"y":0.66884995,"z":-0.46662062},"z":{"w":0,"x":0.5872001,"y":-0.32401526,"z":-0.46401018}}},{"id":15448020577883524639,"normal":{"x":0.45703715,"y":0.889298,"z":-0.016310746},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.709709,"y":0.40868825,"z":4.1147013},"x":{"w":0,"x":-0.7657319,"y":0.37949085,"z":-0.76559657},"y":{"w":0,"x":0.5243985,"y":1.0203689,"z":-0.018714743},"z":{"w":0,"x":0.6746537,"y":-0.36239576,"z":-0.8544053}}},{"id":15898349081190199278,"normal":{"x":0.002768893,"y":0.9941399,"z":-0.108065575},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.789388,"y":0.052722584,"z":-3.163356},"x":{"w":0,"x":-0.60131437,"y":0.07438476,"z":0.6688891},"y":{"w":0,"x":0.0024989503,"y":0.89722145,"z":-0.09753029},"z":{"w":0,"x":-0.6730078,"y":-0.063129306,"z":-0.59799653}}},{"id":15954617581670846202,"normal":{"x":-0.0021576537,"y":0.99223965,"z":-0.124321334},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.885646,"y":0.02803194,"z":3.8721573},"x":{"w":0,"x":-0.74284136,"y":-0.056379482,"z":-0.43708634},"y":{"w":0,"x":-0.0018636395,"y":0.85703117,"z":-0.10738057},"z":{"w":0,"x":0.4407036,"y":-0.091407955,"z":-0.7371983}}},{"id":16044471731358169620,"normal":{"x":0.117978215,"y":0.99088776,"z":0.064981826},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":1.2504425,"y":-0.1378203,"z":5.8313293},"x":{"w":0,"x":-0.4275583,"y":0.005562953,"z":0.6914288},"y":{"w":0,"x":0.09591206,"y":0.8055563,"z":0.052827895},"z":{"w":0,"x":-0.6847668,"y":0.10935705,"z":-0.4243186}}},{"id":16211096757397483218,"normal":{"x":0.003403624,"y":0.998284,"z":0.05845907},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":6.707077,"y":0.013075779,"z":-3.8322449},"x":{"w":0,"x":1.0305655,"y":-0.013373689,"z":0.16837563},"y":{"w":0,"x":0.0035544569,"y":1.0425234,"z":0.061049704},"z":{"w":0,"x":-0.16886853,"y":-0.059672814,"z":1.0288427}}},{"id":16218036424309167697,"normal":{"x":0.00011008587,"y":0.992242,"z":0.1243216},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.014877,"y":0.06129741,"z":-4.143921},"x":{"w":0,"x":0.79553574,"y":0.046806466,"z":-0.37427858},"y":{"w":0,"x":9.692276e-05,"y":0.873597,"z":0.10945615},"z":{"w":0,"x":0.37719396,"y":-0.09894348,"z":0.7893588}}},{"id":16259832098110324835,"normal":{"x":-0.013038513,"y":0.99982,"z":-0.013782028},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.411863,"y":0.7933804,"z":5.8919353},"x":{"w":0,"x":-0.5021115,"y":0.0030419477,"z":0.69570214},"y":{"w":0,"x":-0.011186761,"y":0.85782385,"z":-0.011824681},"z":{"w":0,"x":-0.69561887,"y":-0.015991038,"z":-0.5019815}}},{"id":16371428607270913292,"normal":{"x":-0.01611092,"y":0.9964696,"z":-0.08239369},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.1936026,"y":-0.2081695,"z":-5.734355},"x":{"w":0,"x":0.32425016,"y":-0.07548412,"z":-0.97630775},"y":{"w":0,"x":-0.01661858,"y":1.0278683,"z":-0.08498992},"z":{"w":0,"x":0.9790804,"y":0.04244539,"z":0.32188934}}},{"id":16801080327717613556,"normal":{"x":0.12899931,"y":0.9915277,"z":-0.015234176},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":7.032295,"y":-0.06723799,"z":0.8799219},"x":{"w":0,"x":0.52966774,"y":-0.08218703,"z":-0.8641063},"y":{"w":0,"x":0.13117284,"y":1.0082343,"z":-0.015490857},"z":{"w":0,"x":0.8580374,"y":-0.10340007,"z":0.5357823}}},{"id":16980931805788377201,"normal":{"x":0.01746491,"y":0.9967357,"z":0.07882231},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":3.9349518,"y":0.15454285,"z":2.2971344},"x":{"w":0,"x":-0.549966,"y":-0.056582514,"z":0.8373634},"y":{"w":0,"x":0.01752455,"y":1.0001394,"z":0.079091474},"z":{"w":0,"x":-0.83908993,"y":0.057974055,"z":-0.54718244}}},{"id":17081526057585681269,"normal":{"x":0.0034075074,"y":0.99942297,"z":-0.03379472},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.522093,"y":0.007222143,"z":-3.3895624},"x":{"w":0,"x":0.9236205,"y":-0.014117395,"z":-0.32437032},"y":{"w":0,"x":0.0033360366,"y":0.97846025,"z":-0.033085883},"z":{"w":0,"x":0.3246602,"y":0.030108204,"z":0.9231357}}},{"id":17081928559373656989,"normal":{"x":-0.0022873927,"y":0.9926047,"z":0.12137015},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":9.019974,"y":0.12496613,"z":-4.676758},"x":{"w":0,"x":0.93892187,"y":0.046750523,"z":-0.36464572},"y":{"w":0,"x":-0.0023064439,"y":1.0008715,"z":0.12238097},"z":{"w":0,"x":0.36762318,"y":-0.11312299,"z":0.93208516}}},{"id":17085085686073349052,"normal":{"x":0.004231384,"y":0.99947685,"z":0.032064527},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.391482,"y":-0.28305557,"z":-0.44515708},"x":{"w":0,"x":0.92813486,"y":0.018616423,"z":-0.70276964},"y":{"w":0,"x":0.0049267304,"y":1.1637216,"z":0.03733372},"z":{"w":0,"x":0.70299894,"y":-0.032733895,"z":0.92757046}}},{"id":17103844693651706743,"normal":{"x":-0.004102433,"y":0.99579567,"z":0.091510266},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":9.600837,"y":0.25159755,"z":-5.9875603},"x":{"w":0,"x":-0.55732125,"y":0.0566385,"z":-0.6413134},"y":{"w":0,"x":-0.0034933297,"y":0.84794664,"z":0.077923425},"z":{"w":0,"x":0.6438001,"y":0.05363155,"z":-0.5547458}}},{"id":17130228746280289238,"normal":{"x":-0.08264746,"y":0.9960408,"z":0.032743327},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.744526,"y":-0.16506784,"z":-1.0539448},"x":{"w":0,"x":-0.114708796,"y":-0.039297555,"z":0.9058816},"y":{"w":0,"x":-0.075536504,"y":0.910342,"z":0.029926108},"z":{"w":0,"x":-0.9035817,"y":-0.071112856,"z":-0.1175025}}},{"id":17143021226482083446,"normal":{"x":0.0048647746,"y":0.999474,"z":-0.032064434},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":9.146149,"y":-0.27147508,"z":0.77664185},"x":{"w":0,"x":0.9944176,"y":0.016189866,"z":0.65552276},"y":{"w":0,"x":0.005794678,"y":1.1905235,"z":-0.038193554},"z":{"w":0,"x":-0.655697,"y":0.03507441,"z":0.99381566}}},{"id":17175801894661857103,"normal":{"x":0.0007737368,"y":0.99400204,"z":-0.109358646},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":3.9302273,"y":-0.0028841011,"z":-3.6853664},"x":{"w":0,"x":-0.73109335,"y":-0.0934861,"z":-0.8549032},"y":{"w":0,"x":0.0008733605,"y":1.1219887,"z":-0.12343954},"z":{"w":0,"x":0.85999906,"y":-0.080612846,"z":-0.726636}}},{"id":17200813642783658533,"normal":{"x":-0.1388396,"y":0.9903141,"z":-0.0012401054},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.0883164,"y":0.1535743,"z":0.23017375},"x":{"w":0,"x":-0.28851813,"y":-0.039056733,"z":1.1122432},"y":{"w":0,"x":-0.15962647,"y":1.1385826,"z":-0.001425771},"z":{"w":0,"x":-1.1014216,"y":-0.15478118,"z":-0.29114622}}},{"id":17228398124695370306,"normal":{"x":0.10536006,"y":0.9913392,"z":0.07839555},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.9126673,"y":0.16978294,"z":1.4246126},"x":{"w":0,"x":0.9400058,"y":-0.051081456,"z":-0.6173821},"y":{"w":0,"x":0.11861226,"y":1.11603,"z":0.08825614},"z":{"w":0,"x":0.6080305,"y":-0.13873969,"z":0.93724656}}},{"id":17238587383699644781,"normal":{"x":-0.07199032,"y":0.99740535,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.34238172,"y":-0.02471229,"z":-3.0390356},"x":{"w":0,"x":0.11976004,"y":0.008643993,"z":-1.1503602},"y":{"w":0,"x":-0.0832647,"y":1.1536086,"z":0},"z":{"w":0,"x":1.1473755,"y":0.0828148,"z":0.12007159}}},{"id":17309064131023178382,"normal":{"x":0.0024873698,"y":0.995966,"z":-0.089696765},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.010709,"y":-0.22574833,"z":1.6059734},"x":{"w":0,"x":0.20292181,"y":-0.07434905,"z":-0.8199224},"y":{"w":0,"x":0.0021091036,"y":0.8445051,"z":-0.076056175},"z":{"w":0,"x":0.82328373,"y":0.016161982,"z":0.20228818}}},{"id":17356206825442468566,"normal":{"x":-0.39778733,"y":0.78591454,"z":-0.47339576},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":4.7267423,"y":0.0692176,"z":3.514316},"x":{"w":0,"x":0.45356035,"y":0.5317013,"z":0.50159156},"y":{"w":0,"x":-0.34219357,"y":0.67607725,"z":-0.40723523},"z":{"w":0,"x":-0.6459133,"y":0.0151867755,"z":0.5679638}}},{"id":17380011495226103276,"normal":{"x":-0.1388396,"y":0.9903141,"z":-0.0012401054},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":0.429245,"y":0.0614749,"z":0.4703827},"x":{"w":0,"x":0.3121475,"y":0.04264178,"z":-0.8948252},"y":{"w":0,"x":-0.13171238,"y":0.939477,"z":-0.0011764516},"z":{"w":0,"x":0.88610524,"y":0.12462429,"z":0.31504446}}},{"id":17466428768888585008,"normal":{"x":0.45435217,"y":0.8840736,"z":-0.10944389},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":10.368118,"y":0.0277439,"z":3.3323975},"x":{"w":0,"x":-0.69918305,"y":0.28394878,"z":-0.60893047},"y":{"w":0,"x":0.4405764,"y":0.8572689,"z":-0.1061256},"z":{"w":0,"x":0.5072629,"y":-0.35319018,"z":-0.747142}}},{"id":17484290461955824681,"normal":{"x":-0.070572115,"y":0.99654204,"z":0.043858875},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.862286,"y":0.02393993,"z":-4.8467445},"x":{"w":0,"x":-0.6284241,"y":-0.07945091,"z":0.7940683},"y":{"w":0,"x":-0.071684524,"y":1.0122502,"z":0.044550214},"z":{"w":0,"x":-0.7948071,"y":-0.028477103,"z":-0.6318581}}},{"id":17528541720388132112,"normal":{"x":-0.070623666,"y":0.99691254,"z":-0.034317244},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.857312,"y":0.27704474,"z":-0.87479603},"x":{"w":0,"x":-0.050826345,"y":-0.042425618,"z":-1.127861},"y":{"w":0,"x":-0.0797908,"y":1.1263143,"z":-0.03877171},"z":{"w":0,"x":1.1258347,"y":0.07790946,"z":-0.05366567}}},{"id":17555436584939358846,"normal":{"x":0.117978215,"y":0.99088776,"z":0.06498182},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.43993756,"y":0.03405929,"z":-5.720628},"x":{"w":0,"x":-0.69640386,"y":0.033685878,"z":0.75069547},"y":{"w":0,"x":0.12087193,"y":1.0151918,"z":0.06657566},"z":{"w":0,"x":-0.74166596,"y":0.13381928,"z":-0.6940322}}},{"id":17628467688433143181,"normal":{"x":-0.13064343,"y":0.87433076,"z":0.46741632},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":6.895157,"y":0.26596558,"z":6.79805},"x":{"w":0,"x":-0.6944925,"y":-0.5314389,"z":0.79997736},"y":{"w":0,"x":-0.15483925,"y":1.0362611,"z":0.5539841},"z":{"w":0,"x":-0.9478481,"y":0.2201053,"z":-0.67664504}}},{"id":17739936460587277215,"normal":{"x":0.045064066,"y":0.99775296,"z":0.049580444},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":11.273212,"y":0.13910128,"z":-5.415344},"x":{"w":0,"x":0.32201794,"y":0.02657242,"z":-0.82742584},"y":{"w":0,"x":0.040029354,"y":0.88628054,"z":0.044041146},"z":{"w":0,"x":0.8268841,"y":-0.053252965,"z":0.32009685}}},{"id":17826860729500306772,"normal":{"x":-0.099610776,"y":0.8160623,"z":0.5693154},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":5.292389,"y":0.067512915,"z":6.860916},"x":{"w":0,"x":-0.77550286,"y":0.2502101,"z":-0.4943402},"y":{"w":0,"x":-0.094938084,"y":0.7777814,"z":0.5426092},"z":{"w":0,"x":0.5458609,"y":0.49074724,"z":-0.607935}}},{"id":18020434025062617339,"normal":{"x":0.10212567,"y":0.99426,"z":0.031897165},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.745327,"y":-0.2485437,"z":-0.6538676},"x":{"w":0,"x":0.78158784,"y":-0.065618925,"z":-0.45702842},"y":{"w":0,"x":0.09270741,"y":0.9025671,"z":0.028955536},"z":{"w":0,"x":0.45231205,"y":-0.07160477,"z":0.7838029}}},{"id":18056105261107488177,"normal":{"x":0.00011008587,"y":0.992242,"z":0.1243216},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.191941,"y":0.05155205,"z":-4.0662975},"x":{"w":0,"x":-0.7284275,"y":-0.048823316,"z":0.39031616},"y":{"w":0,"x":9.113637e-05,"y":0.8214284,"z":0.10291976},"z":{"w":0,"x":-0.39335787,"y":0.09060225,"z":-0.72277105}}},{"id":18123768933927619765,"normal":{"x":-0.048693217,"y":0.99478763,"z":0.089590624},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":10.994015,"y":-0.173884,"z":-1.926927},"x":{"w":0,"x":-1.1199588,"y":-0.065907314,"z":0.12310876},"y":{"w":0,"x":-0.054956656,"y":1.1227479,"z":0.10111472},"z":{"w":0,"x":-0.12837175,"y":0.094343245,"z":-1.1173303}}},{"id":18135699354902212852,"normal":{"x":-0.1214479,"y":0.99259764,"z":-0.0005562174},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":0.117907286,"y":0.014594102,"z":-0.90069467},"x":{"w":0,"x":-0.39959577,"y":-0.048384845,"z":0.90501946},"y":{"w":0,"x":-0.120293394,"y":0.983162,"z":-0.00055092806},"z":{"w":0,"x":-0.89829326,"y":-0.110134974,"z":-0.40251407}}},{"id":18220266175511755013,"normal":{"x":0.10216573,"y":0.99465,"z":-0.0152821485},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":8.635975,"y":-0.25461662,"z":0.23898607},"x":{"w":0,"x":0.6328838,"y":-0.052731488,"z":0.7989495},"y":{"w":0,"x":0.10427127,"y":1.0151489,"z":-0.0155970985},"z":{"w":0,"x":-0.7938692,"y":0.091297075,"z":0.6348853}}},{"id":18291691493994383040,"normal":{"x":-0.12144792,"y":0.9925978,"z":0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":-0.43937582,"y":-0.053759214,"z":-1.5338246},"x":{"w":0,"x":0.57446676,"y":0.07028808,"z":0.815628},"y":{"w":0,"x":-0.121460155,"y":0.9926978,"z":-3.7256656e-09},"z":{"w":0,"x":-0.8095905,"y":-0.099056326,"z":0.57875085}}},{"id":18342532252161559755,"normal":{"x":-0.0016010712,"y":0.99150133,"z":-0.13008651},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":2.784309,"y":-0.07118836,"z":-4.1950536},"x":{"w":0,"x":-1.0946463,"y":0.04826777,"z":0.38136286},"y":{"w":0,"x":-0.0018575297,"y":1.1503202,"z":-0.1509238},"z":{"w":0,"x":-0.38440078,"y":-0.1430093,"z":-1.085266}}},{"id":18395768325119080768,"normal":{"x":-0.0016088247,"y":0.9963029,"z":-0.085895166},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":1.5106645,"y":0.09689788,"z":-2.5320635},"x":{"w":0,"x":-0.29870474,"y":-0.09415023,"z":-1.086459},"y":{"w":0,"x":-0.0018190992,"y":1.1265194,"z":-0.09712165},"z":{"w":0,"x":1.0905293,"y":-0.02390937,"z":-0.29775184}}},{"id":18433374447346200941,"normal":{"x":-0.1255593,"y":0.9917581,"z":-0.02550911},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":1,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","sourceID":0,"transform":{"w":{"w":1,"x":6.752758,"y":0.032188155,"z":-5.730411},"x":{"w":0,"x":0.3732105,"y":0.06987958,"z":0.8798261},"y":{"w":0,"x":-0.12031853,"y":0.9503627,"z":-0.024444373},"z":{"w":0,"x":-0.8743573,"y":-0.10095009,"z":0.37890851}}}]},"document":{"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8106","modifiers":[{"contactWidth":2.4,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8101","materialLayer":21,"operation":{"union":{}},"order":0,"rounding":{"maximumDeviation":0.13,"radius":1.3},"sourceID":1,"surface":{"faceIDs":[0,1,2,3,4,5,6,7,8,9,10,11],"faceSources":[0,0,0,0,0,0,0,0,0,0,0,0],"indices":[0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,0,4,7,0,7,3,1,2,6,1,6,5],"normals":[],"positions":[{"x":-1.5,"y":-1,"z":-1.3},{"x":1.5,"y":-1,"z":-1.3},{"x":1.5,"y":2.1,"z":-1.3},{"x":-1.5,"y":2.1,"z":-1.3},{"x":-1.5,"y":-1,"z":1.3},{"x":1.5,"y":-1,"z":1.3},{"x":1.5,"y":2.1,"z":1.3},{"x":-1.5,"y":2.1,"z":1.3}],"textureCoordinates":[]},"transform":{"w":{"w":1,"x":-2.5,"y":0,"z":1.7},"x":{"w":0,"x":1,"y":0,"z":0},"y":{"w":0,"x":0,"y":1,"z":0},"z":{"w":0,"x":0,"y":0,"z":1}}},{"contactWidth":1,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8102","operation":{"conform":{"direction":{"x":0,"y":1,"z":0},"distance":2,"strength":0.85}},"order":1,"sourceID":2,"surface":{"faceIDs":[],"faceSources":[],"indices":[0,4,1,1,4,5,1,5,2,2,5,6,2,6,3,3,6,7,4,8,5,5,8,9,5,9,6,6,9,10,6,10,7,7,10,11,8,12,9,9,12,13,9,13,10,10,13,14,10,14,11,11,14,15],"normals":[{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0},{"x":0,"y":1,"z":0}],"positions":[{"x":-2,"y":0,"z":-2},{"x":-0.6666666,"y":0,"z":-2},{"x":0.66666675,"y":0,"z":-2},{"x":2,"y":0,"z":-2},{"x":-2,"y":0,"z":-0.6666666},{"x":-0.6666666,"y":0,"z":-0.6666666},{"x":0.66666675,"y":0,"z":-0.6666666},{"x":2,"y":0,"z":-0.6666666},{"x":-2,"y":0,"z":0.66666675},{"x":-0.6666666,"y":0,"z":0.66666675},{"x":0.66666675,"y":0,"z":0.66666675},{"x":2,"y":0,"z":0.66666675},{"x":-2,"y":0,"z":2},{"x":-0.6666666,"y":0,"z":2},{"x":0.66666675,"y":0,"z":2},{"x":2,"y":0,"z":2}],"textureCoordinates":[{"x":-2,"y":-2},{"x":-0.6666666,"y":-2},{"x":0.66666675,"y":-2},{"x":2,"y":-2},{"x":-2,"y":-0.6666666},{"x":-0.6666666,"y":-0.6666666},{"x":0.66666675,"y":-0.6666666},{"x":2,"y":-0.6666666},{"x":-2,"y":0.66666675},{"x":-0.6666666,"y":0.66666675},{"x":0.66666675,"y":0.66666675},{"x":2,"y":0.66666675},{"x":-2,"y":2},{"x":-0.6666666,"y":2},{"x":0.66666675,"y":2},{"x":2,"y":2}]},"transform":{"w":{"w":1,"x":7,"y":0.9,"z":4.5},"x":{"w":0,"x":1,"y":0,"z":0},"y":{"w":0,"x":0,"y":1,"z":0},"z":{"w":0,"x":0,"y":0,"z":1}}},{"contactWidth":1,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8103","operation":{"subtract":{}},"order":2,"sourceID":3,"surface":{"faceIDs":[0,1,2,3,4,5,6,7,8,9,10,11],"faceSources":[0,0,0,0,0,0,0,0,0,0,0,0],"indices":[0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,0,4,7,0,7,3,1,2,6,1,6,5],"normals":[],"positions":[{"x":-10,"y":-4,"z":-3},{"x":-4,"y":-4,"z":-3},{"x":-4,"y":-1,"z":-3},{"x":-10,"y":-1,"z":-3},{"x":-10,"y":-4,"z":2},{"x":-4,"y":-4,"z":2},{"x":-4,"y":-1,"z":2},{"x":-10,"y":-1,"z":2}],"textureCoordinates":[]},"transform":{"w":{"w":1,"x":0,"y":0,"z":0},"x":{"w":0,"x":1,"y":0,"z":0},"y":{"w":0,"x":0,"y":1,"z":0},"z":{"w":0,"x":0,"y":0,"z":1}}},{"contactWidth":1,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8104","operation":{"subtract":{}},"order":3,"sourceID":4,"surface":{"faceIDs":[0,1,2,3,4,5,6,7,8,9,10,11],"faceSources":[0,0,0,0,0,0,0,0,0,0,0,0],"indices":[0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,0,4,7,0,7,3,1,2,6,1,6,5],"normals":[],"positions":[{"x":-12,"y":-4.3,"z":-1.5},{"x":-8.5,"y":-4.3,"z":-1.5},{"x":-8.5,"y":1.2,"z":-1.5},{"x":-12,"y":1.2,"z":-1.5},{"x":-12,"y":-4.3,"z":0.9},{"x":-8.5,"y":-4.3,"z":0.9},{"x":-8.5,"y":1.2,"z":0.9},{"x":-12,"y":1.2,"z":0.9}],"textureCoordinates":[]},"transform":{"w":{"w":1,"x":0,"y":0,"z":0},"x":{"w":0,"x":1,"y":0,"z":0},"y":{"w":0,"x":0,"y":1,"z":0},"z":{"w":0,"x":0,"y":0,"z":1}}}],"paint":[{"anchor":{"faceID":4,"frame":{"source":{"_0":"273A3815-7E65-486D-9B9D-E1D3EC2D8103"}},"normal":{"x":0,"y":1,"z":0},"point":{"x":-6.5,"y":-4,"z":-0.4},"section":"273A3815-7E65-486D-9B9D-E1D3EC2D8105"},"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8107","operation":{"paint":{"layer":31}},"radius":4,"strength":0.98},{"anchor":{"faceID":7,"frame":{"source":{"_0":"273A3815-7E65-486D-9B9D-E1D3EC2D8103"}},"normal":{"x":0,"y":-1,"z":0},"point":{"x":-6.5,"y":-1,"z":-0.4},"section":"273A3815-7E65-486D-9B9D-E1D3EC2D8105"},"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8108","operation":{"paint":{"layer":32}},"radius":4,"strength":0.98}],"sections":[{"closure":{"ground":{"bottomHeight":-6}},"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","surface":{"faceIDs":[],"faceSources":[],"indices":[0,21,1,1,21,22,1,22,2,2,22,23,2,23,3,3,23,24,3,24,4,4,24,25,4,25,5,5,25,26,5,26,6,6,26,27,6,27,7,7,27,28,7,28,8,8,28,29,8,29,9,9,29,30,9,30,10,10,30,31,10,31,11,11,31,32,11,32,12,12,32,33,12,33,13,13,33,34,13,34,14,14,34,35,14,35,15,15,35,36,15,36,16,16,36,37,16,37,17,17,37,38,17,38,18,18,38,39,18,39,19,19,39,40,19,40,20,20,40,41,21,42,22,22,42,43,22,43,23,23,43,44,23,44,24,24,44,45,24,45,25,25,45,46,25,46,26,26,46,47,26,47,27,27,47,48,27,48,28,28,48,49,28,49,29,29,49,50,29,50,30,30,50,51,30,51,31,31,51,52,31,52,32,32,52,53,32,53,33,33,53,54,33,54,34,34,54,55,34,55,35,35,55,56,35,56,36,36,56,57,36,57,37,37,57,58,37,58,38,38,58,59,38,59,39,39,59,60,39,60,40,40,60,61,40,61,41,41,61,62,42,63,43,43,63,64,43,64,44,44,64,65,44,65,45,45,65,66,45,66,46,46,66,67,46,67,47,47,67,68,47,68,48,48,68,69,48,69,49,49,69,70,49,70,50,50,70,71,50,71,51,51,71,72,51,72,52,52,72,73,52,73,53,53,73,74,53,74,54,54,74,75,54,75,55,55,75,76,55,76,56,56,76,77,56,77,57,57,77,78,57,78,58,58,78,79,58,79,59,59,79,80,59,80,60,60,80,81,60,81,61,61,81,82,61,82,62,62,82,83,63,84,64,64,84,85,64,85,65,65,85,86,65,86,66,66,86,87,66,87,67,67,87,88,67,88,68,68,88,89,68,89,69,69,89,90,69,90,70,70,90,91,70,91,71,71,91,92,71,92,72,72,92,93,72,93,73,73,93,94,73,94,74,74,94,95,74,95,75,75,95,96,75,96,76,76,96,97,76,97,77,77,97,98,77,98,78,78,98,99,78,99,79,79,99,100,79,100,80,80,100,101,80,101,81,81,101,102,81,102,82,82,102,103,82,103,83,83,103,104,84,105,85,85,105,106,85,106,86,86,106,107,86,107,87,87,107,108,87,108,88,88,108,109,88,109,89,89,109,110,89,110,90,90,110,111,90,111,91,91,111,112,91,112,92,92,112,113,92,113,93,93,113,114,93,114,94,94,114,115,94,115,95,95,115,116,95,116,96,96,116,117,96,117,97,97,117,118,97,118,98,98,118,119,98,119,99,99,119,120,99,120,100,100,120,121,100,121,101,101,121,122,101,122,102,102,122,123,102,123,103,103,123,124,103,124,104,104,124,125,105,126,106,106,126,127,106,127,107,107,127,128,107,128,108,108,128,129,108,129,109,109,129,130,109,130,110,110,130,131,110,131,111,111,131,132,111,132,112,112,132,133,112,133,113,113,133,134,113,134,114,114,134,135,114,135,115,115,135,136,115,136,116,116,136,137,116,137,117,117,137,138,117,138,118,118,138,139,118,139,119,119,139,140,119,140,120,120,140,141,120,141,121,121,141,142,121,142,122,122,142,143,122,143,123,123,143,144,123,144,124,124,144,145,124,145,125,125,145,146,126,147,127,127,147,148,127,148,128,128,148,149,128,149,129,129,149,150,129,150,130,130,150,151,130,151,131,131,151,152,131,152,132,132,152,153,132,153,133,133,153,154,133,154,134,134,154,155,134,155,135,135,155,156,135,156,136,136,156,157,136,157,137,137,157,158,137,158,138,138,158,159,138,159,139,139,159,160,139,160,140,140,160,161,140,161,141,141,161,162,141,162,142,142,162,163,142,163,143,143,163,164,143,164,144,144,164,165,144,165,145,145,165,166,145,166,146,146,166,167,147,168,148,148,168,169,148,169,149,149,169,170,149,170,150,150,170,171,150,171,151,151,171,172,151,172,152,152,172,173,152,173,153,153,173,174,153,174,154,154,174,175,154,175,155,155,175,176,155,176,156,156,176,177,156,177,157,157,177,178,157,178,158,158,178,179,158,179,159,159,179,180,159,180,160,160,180,181,160,181,161,161,181,182,161,182,162,162,182,183,162,183,163,163,183,184,163,184,164,164,184,185,164,185,165,165,185,186,165,186,166,166,186,187,166,187,167,167,187,188,168,189,169,169,189,190,169,190,170,170,190,191,170,191,171,171,191,192,171,192,172,172,192,193,172,193,173,173,193,194,173,194,174,174,194,195,174,195,175,175,195,196,175,196,176,176,196,197,176,197,177,177,197,198,177,198,178,178,198,199,178,199,179,179,199,200,179,200,180,180,200,201,180,201,181,181,201,202,181,202,182,182,202,203,182,203,183,183,203,204,183,204,184,184,204,205,184,205,185,185,205,206,185,206,186,186,206,207,186,207,187,187,207,208,187,208,188,188,208,209,189,210,190,190,210,211,190,211,191,191,211,212,191,212,192,192,212,213,192,213,193,193,213,214,193,214,194,194,214,215,194,215,195,195,215,216,195,216,196,196,216,217,196,217,197,197,217,218,197,218,198,198,218,219,198,219,199,199,219,220,199,220,200,200,220,221,200,221,201,201,221,222,201,222,202,202,222,223,202,223,203,203,223,224,203,224,204,204,224,225,204,225,205,205,225,226,205,226,206,206,226,227,206,227,207,207,227,228,207,228,208,208,228,229,208,229,209,209,229,230,210,231,211,211,231,232,211,232,212,212,232,233,212,233,213,213,233,234,213,234,214,214,234,235,214,235,215,215,235,236,215,236,216,216,236,237,216,237,217,217,237,238,217,238,218,218,238,239,218,239,219,219,239,240,219,240,220,220,240,241,220,241,221,221,241,242,221,242,222,222,242,243,222,243,223,223,243,244,223,244,224,224,244,245,224,245,225,225,245,246,225,246,226,226,246,247,226,247,227,227,247,248,227,248,228,228,248,249,228,249,229,229,249,250,229,250,230,230,250,251,231,252,232,232,252,253,232,253,233,233,253,254,233,254,234,234,254,255,234,255,235,235,255,256,235,256,236,236,256,257,236,257,237,237,257,258,237,258,238,238,258,259,238,259,239,239,259,260,239,260,240,240,260,261,240,261,241,241,261,262,241,262,242,242,262,263,242,263,243,243,263,264,243,264,244,244,264,265,244,265,245,245,265,266,245,266,246,246,266,267,246,267,247,247,267,268,247,268,248,248,268,269,248,269,249,249,269,270,249,270,250,250,270,271,250,271,251,251,271,272,252,273,253,253,273,274,253,274,254,254,274,275,254,275,255,255,275,276,255,276,256,256,276,277,256,277,257,257,277,278,257,278,258,258,278,279,258,279,259,259,279,280,259,280,260,260,280,281,260,281,261,261,281,282,261,282,262,262,282,283,262,283,263,263,283,284,263,284,264,264,284,285,264,285,265,265,285,286,265,286,266,266,286,287,266,287,267,267,287,288,267,288,268,268,288,289,268,289,269,269,289,290,269,290,270,270,290,291,270,291,271,271,291,292,271,292,272,272,292,293,273,294,274,274,294,295,274,295,275,275,295,296,275,296,276,276,296,297,276,297,277,277,297,298,277,298,278,278,298,299,278,299,279,279,299,300,279,300,280,280,300,301,280,301,281,281,301,302,281,302,282,282,302,303,282,303,283,283,303,304,283,304,284,284,304,305,284,305,285,285,305,306,285,306,286,286,306,307,286,307,287,287,307,308,287,308,288,288,308,309,288,309,289,289,309,310,289,310,290,290,310,311,290,311,291,291,311,312,291,312,292,292,312,313,292,313,293,293,313,314,294,315,295,295,315,316,295,316,296,296,316,317,296,317,297,297,317,318,297,318,298,298,318,319,298,319,299,299,319,320,299,320,300,300,320,321,300,321,301,301,321,322,301,322,302,302,322,323,302,323,303,303,323,324,303,324,304,304,324,325,304,325,305,305,325,326,305,326,306,306,326,327,306,327,307,307,327,328,307,328,308,308,328,329,308,329,309,309,329,330,309,330,310,310,330,331,310,331,311,311,331,332,311,332,312,312,332,333,312,333,313,313,333,334,313,334,314,314,334,335,315,336,316,316,336,337,316,337,317,317,337,338,317,338,318,318,338,339,318,339,319,319,339,340,319,340,320,320,340,341,320,341,321,321,341,342,321,342,322,322,342,343,322,343,323,323,343,344,323,344,324,324,344,345,324,345,325,325,345,346,325,346,326,326,346,347,326,347,327,327,347,348,327,348,328,328,348,349,328,349,329,329,349,350,329,350,330,330,350,351,330,351,331,331,351,352,331,352,332,332,352,353,332,353,333,333,353,354,333,354,334,334,354,355,334,355,335,335,355,356,336,357,337,337,357,358,337,358,338,338,358,359,338,359,339,339,359,360,339,360,340,340,360,361,340,361,341,341,361,362,341,362,342,342,362,363,342,363,343,343,363,364,343,364,344,344,364,365,344,365,345,345,365,366,345,366,346,346,366,367,346,367,347,347,367,368,347,368,348,348,368,369,348,369,349,349,369,370,349,370,350,350,370,371,350,371,351,351,371,372,351,372,352,352,372,373,352,373,353,353,373,374,353,374,354,354,374,375,354,375,355,355,375,376,355,376,356,356,376,377,357,378,358,358,378,379,358,379,359,359,379,380,359,380,360,360,380,381,360,381,361,361,381,382,361,382,362,362,382,383,362,383,363,363,383,384,363,384,364,364,384,385,364,385,365,365,385,386,365,386,366,366,386,387,366,387,367,367,387,388,367,388,368,368,388,389,368,389,369,369,389,390,369,390,370,370,390,391,370,391,371,371,391,392,371,392,372,372,392,393,372,393,373,373,393,394,373,394,374,374,394,395,374,395,375,375,395,396,375,396,376,376,396,397,376,397,377,377,397,398,378,399,379,379,399,400,379,400,380,380,400,401,380,401,381,381,401,402,381,402,382,382,402,403,382,403,383,383,403,404,383,404,384,384,404,405,384,405,385,385,405,406,385,406,386,386,406,407,386,407,387,387,407,408,387,408,388,388,408,409,388,409,389,389,409,410,389,410,390,390,410,411,390,411,391,391,411,412,391,412,392,392,412,413,392,413,393,393,413,414,393,414,394,394,414,415,394,415,395,395,415,416,395,416,396,396,416,417,396,417,397,397,417,418,397,418,398,398,418,419,399,420,400,400,420,421,400,421,401,401,421,422,401,422,402,402,422,423,402,423,403,403,423,424,403,424,404,404,424,425,404,425,405,405,425,426,405,426,406,406,426,427,406,427,407,407,427,428,407,428,408,408,428,429,408,429,409,409,429,430,409,430,410,410,430,431,410,431,411,411,431,432,411,432,412,412,432,433,412,433,413,413,433,434,413,434,414,414,434,435,414,435,415,415,435,436,415,436,416,416,436,437,416,437,417,417,437,438,417,438,418,418,438,439,418,439,419,419,439,440],"normals":[],"positions":[{"x":-15,"y":-0.10992316,"z":-12},{"x":-13.5,"y":-0.027083233,"z":-12},{"x":-12,"y":0.0692005,"z":-12},{"x":-10.5,"y":0.1311339,"z":-12},{"x":-9,"y":0.12797393,"z":-12},{"x":-7.5,"y":0.061289173,"z":-12},{"x":-6,"y":-0.035818834,"z":-12},{"x":-4.5,"y":-0.11514678,"z":-12},{"x":-3,"y":-0.13731718,"z":-12},{"x":-1.5,"y":-0.09132491,"z":-12},{"x":0,"y":0,"z":-12},{"x":1.5,"y":0.09132491,"z":-12},{"x":3,"y":0.13731718,"z":-12},{"x":4.5,"y":0.11514678,"z":-12},{"x":6,"y":0.035818834,"z":-12},{"x":7.5,"y":-0.061289173,"z":-12},{"x":9,"y":-0.12797393,"z":-12},{"x":10.5,"y":-0.1311339,"z":-12},{"x":12,"y":-0.0692005,"z":-12},{"x":13.5,"y":0.027083233,"z":-12},{"x":15,"y":0.10992316,"z":-12},{"x":-15,"y":0.017355446,"z":-10.8},{"x":-13.5,"y":0.004276092,"z":-10.8},{"x":-12,"y":-0.010925865,"z":-10.8},{"x":-10.5,"y":-0.020704348,"z":-10.8},{"x":-9,"y":-0.02020543,"z":-10.8},{"x":-7.5,"y":-0.009676768,"z":-10.8},{"x":-6,"y":0.0056553306,"z":-10.8},{"x":-4.5,"y":0.018180188,"z":-10.8},{"x":-3,"y":0.021680608,"z":-10.8},{"x":-1.5,"y":0.014419023,"z":-10.8},{"x":0,"y":-0,"z":-10.8},{"x":1.5,"y":-0.014419023,"z":-10.8},{"x":3,"y":-0.021680608,"z":-10.8},{"x":4.5,"y":-0.018180188,"z":-10.8},{"x":6,"y":-0.0056553306,"z":-10.8},{"x":7.5,"y":0.009676768,"z":-10.8},{"x":9,"y":0.02020543,"z":-10.8},{"x":10.5,"y":0.020704348,"z":-10.8},{"x":12,"y":0.010925865,"z":-10.8},{"x":13.5,"y":-0.004276092,"z":-10.8},{"x":15,"y":-0.017355446,"z":-10.8},{"x":-15,"y":0.1401147,"z":-9.6},{"x":-13.5,"y":0.034521922,"z":-9.6},{"x":-12,"y":-0.08820713,"z":-9.6},{"x":-10.5,"y":-0.16715118,"z":-9.6},{"x":-9,"y":-0.1631233,"z":-9.6},{"x":-7.5,"y":-0.07812288,"z":-9.6},{"x":-6,"y":0.045656845,"z":-9.6},{"x":-4.5,"y":0.14677303,"z":-9.6},{"x":-3,"y":0.17503276,"z":-9.6},{"x":-1.5,"y":0.116408244,"z":-9.6},{"x":0,"y":-0,"z":-9.6},{"x":1.5,"y":-0.116408244,"z":-9.6},{"x":3,"y":-0.17503276,"z":-9.6},{"x":4.5,"y":-0.14677303,"z":-9.6},{"x":6,"y":-0.045656845,"z":-9.6},{"x":7.5,"y":0.07812288,"z":-9.6},{"x":9,"y":0.1631233,"z":-9.6},{"x":10.5,"y":0.16715118,"z":-9.6},{"x":12,"y":0.08820713,"z":-9.6},{"x":13.5,"y":-0.034521922,"z":-9.6},{"x":15,"y":-0.1401147,"z":-9.6},{"x":-15,"y":0.22638805,"z":-8.4},{"x":-13.5,"y":0.05577824,"z":-8.4},{"x":-12,"y":-0.14251927,"z":-8.4},{"x":-10.5,"y":-0.27007183,"z":-8.4},{"x":-9,"y":-0.26356384,"z":-8.4},{"x":-7.5,"y":-0.12622578,"z":-8.4},{"x":-6,"y":0.07376931,"z":-8.4},{"x":-4.5,"y":0.23714615,"z":-8.4},{"x":-3,"y":0.28280637,"z":-8.4},{"x":-1.5,"y":0.18808474,"z":-8.4},{"x":0,"y":-0,"z":-8.4},{"x":1.5,"y":-0.18808474,"z":-8.4},{"x":3,"y":-0.28280637,"z":-8.4},{"x":4.5,"y":-0.23714615,"z":-8.4},{"x":6,"y":-0.07376931,"z":-8.4},{"x":7.5,"y":0.12622578,"z":-8.4},{"x":9,"y":0.26356384,"z":-8.4},{"x":10.5,"y":0.27007183,"z":-8.4},{"x":12,"y":0.14251927,"z":-8.4},{"x":13.5,"y":-0.05577824,"z":-8.4},{"x":15,"y":-0.22638805,"z":-8.4},{"x":-15,"y":0.2537098,"z":-7.2},{"x":-13.5,"y":0.06250986,"z":-7.2},{"x":-12,"y":-0.15971926,"z":-7.2},{"x":-10.5,"y":-0.30266556,"z":-7.2},{"x":-9,"y":-0.29537216,"z":-7.2},{"x":-7.5,"y":-0.14145939,"z":-7.2},{"x":-6,"y":0.08267219,"z":-7.2},{"x":-4.5,"y":0.26576623,"z":-7.2},{"x":-3,"y":0.31693697,"z":-7.2},{"x":-1.5,"y":0.21078382,"z":-7.2},{"x":0,"y":-0,"z":-7.2},{"x":1.5,"y":-0.21078382,"z":-7.2},{"x":3,"y":-0.31693697,"z":-7.2},{"x":4.5,"y":-0.26576623,"z":-7.2},{"x":6,"y":-0.08267219,"z":-7.2},{"x":7.5,"y":0.14145939,"z":-7.2},{"x":9,"y":0.29537216,"z":-7.2},{"x":10.5,"y":0.30266556,"z":-7.2},{"x":12,"y":0.15971926,"z":-7.2},{"x":13.5,"y":-0.06250986,"z":-7.2},{"x":15,"y":-0.2537098,"z":-7.2},{"x":-15,"y":0.2149654,"z":-6},{"x":-13.5,"y":0.052963886,"z":-6},{"x":-12,"y":-0.1353283,"z":-6},{"x":-10.5,"y":-0.25644505,"z":-6},{"x":-9,"y":-0.25026542,"z":-6},{"x":-7.5,"y":-0.11985692,"z":-6},{"x":-6,"y":0.0700472,"z":-6},{"x":-4.5,"y":0.22518069,"z":-6},{"x":-3,"y":0.26853707,"z":-6},{"x":-1.5,"y":0.17859472,"z":-6},{"x":0,"y":-0,"z":-6},{"x":1.5,"y":-0.17859472,"z":-6},{"x":3,"y":-0.26853707,"z":-6},{"x":4.5,"y":-0.22518069,"z":-6},{"x":6,"y":-0.0700472,"z":-6},{"x":7.5,"y":0.11985692,"z":-6},{"x":9,"y":0.25026542,"z":-6},{"x":10.5,"y":0.25644505,"z":-6},{"x":12,"y":0.1353283,"z":-6},{"x":13.5,"y":-0.052963886,"z":-6},{"x":15,"y":-0.2149654,"z":-6},{"x":-15,"y":0.12024387,"z":-4.7999997},{"x":-13.5,"y":0.029626083,"z":-4.7999997},{"x":-12,"y":-0.07569776,"z":-4.7999997},{"x":-10.5,"y":-0.14344609,"z":-4.7999997},{"x":-9,"y":-0.13998944,"z":-4.7999997},{"x":-7.5,"y":-0.067043625,"z":-4.7999997},{"x":-6,"y":0.039181873,"z":-4.7999997},{"x":-4.5,"y":0.12595794,"z":-4.7999997},{"x":-3,"y":0.15020993,"z":-4.7999997},{"x":-1.5,"y":0.099899426,"z":-4.7999997},{"x":0,"y":-0,"z":-4.7999997},{"x":1.5,"y":-0.099899426,"z":-4.7999997},{"x":3,"y":-0.15020993,"z":-4.7999997},{"x":4.5,"y":-0.12595794,"z":-4.7999997},{"x":6,"y":-0.039181873,"z":-4.7999997},{"x":7.5,"y":0.067043625,"z":-4.7999997},{"x":9,"y":0.13998944,"z":-4.7999997},{"x":10.5,"y":0.14344609,"z":-4.7999997},{"x":12,"y":0.07569776,"z":-4.7999997},{"x":13.5,"y":-0.029626083,"z":-4.7999997},{"x":15,"y":-0.12024387,"z":-4.7999997},{"x":-15,"y":-0.005789125,"z":-3.6000004},{"x":-13.5,"y":-0.0014263437,"z":-3.6000004},{"x":-12,"y":0.003644458,"z":-3.6000004},{"x":-10.5,"y":0.0069061923,"z":-3.6000004},{"x":-9,"y":0.0067397724,"z":-3.6000004},{"x":-7.5,"y":0.0032278062,"z":-3.6000004},{"x":-6,"y":-0.0018864059,"z":-3.6000004},{"x":-4.5,"y":-0.006064228,"z":-3.6000004},{"x":-3,"y":-0.007231836,"z":-3.6000004},{"x":-1.5,"y":-0.004809644,"z":-3.6000004},{"x":0,"y":0,"z":-3.6000004},{"x":1.5,"y":0.004809644,"z":-3.6000004},{"x":3,"y":0.007231836,"z":-3.6000004},{"x":4.5,"y":0.006064228,"z":-3.6000004},{"x":6,"y":0.0018864059,"z":-3.6000004},{"x":7.5,"y":-0.0032278062,"z":-3.6000004},{"x":9,"y":-0.0067397724,"z":-3.6000004},{"x":10.5,"y":-0.0069061923,"z":-3.6000004},{"x":12,"y":-0.003644458,"z":-3.6000004},{"x":13.5,"y":0.0014263437,"z":-3.6000004},{"x":15,"y":0.005789125,"z":-3.6000004},{"x":-15,"y":-0.13031477,"z":-2.3999996},{"x":-13.5,"y":-0.032107383,"z":-2.3999996},{"x":-12,"y":0.08203774,"z":-2.3999996},{"x":-10.5,"y":0.15546027,"z":-2.3999996},{"x":-9,"y":0.1517141,"z":-2.3999996},{"x":-7.5,"y":0.07265879,"z":-2.3999996},{"x":-6,"y":-0.042463508,"z":-2.3999996},{"x":-4.5,"y":-0.1365074,"z":-2.3999996},{"x":-3,"y":-0.1627906,"z":-2.3999996},{"x":-1.5,"y":-0.1082664,"z":-2.3999996},{"x":0,"y":0,"z":-2.3999996},{"x":1.5,"y":0.1082664,"z":-2.3999996},{"x":3,"y":0.1627906,"z":-2.3999996},{"x":4.5,"y":0.1365074,"z":-2.3999996},{"x":6,"y":0.042463508,"z":-2.3999996},{"x":7.5,"y":-0.07265879,"z":-2.3999996},{"x":9,"y":-0.1517141,"z":-2.3999996},{"x":10.5,"y":-0.15546027,"z":-2.3999996},{"x":12,"y":-0.08203774,"z":-2.3999996},{"x":13.5,"y":0.032107383,"z":-2.3999996},{"x":15,"y":0.13031477,"z":-2.3999996},{"x":-15,"y":-0.22090623,"z":-1.2000008},{"x":-13.5,"y":-0.05442761,"z":-1.2000008},{"x":-12,"y":0.13906826,"z":-1.2000008},{"x":-10.5,"y":0.26353222,"z":-1.2000008},{"x":-9,"y":0.25718182,"z":-1.2000008},{"x":-7.5,"y":0.12316931,"z":-1.2000008},{"x":-6,"y":-0.07198304,"z":-1.2000008},{"x":-4.5,"y":-0.23140384,"z":-1.2000008},{"x":-3,"y":-0.27595842,"z":-1.2000008},{"x":-1.5,"y":-0.1835304,"z":-1.2000008},{"x":0,"y":0,"z":-1.2000008},{"x":1.5,"y":0.1835304,"z":-1.2000008},{"x":3,"y":0.27595842,"z":-1.2000008},{"x":4.5,"y":0.23140384,"z":-1.2000008},{"x":6,"y":0.07198304,"z":-1.2000008},{"x":7.5,"y":-0.12316931,"z":-1.2000008},{"x":9,"y":-0.25718182,"z":-1.2000008},{"x":10.5,"y":-0.26353222,"z":-1.2000008},{"x":12,"y":-0.13906826,"z":-1.2000008},{"x":13.5,"y":0.05442761,"z":-1.2000008},{"x":15,"y":0.22090623,"z":-1.2000008},{"x":-15,"y":-0.25397372,"z":0},{"x":-13.5,"y":-0.062574886,"z":0},{"x":-12,"y":0.1598854,"z":0},{"x":-10.5,"y":0.30298042,"z":0},{"x":-9,"y":0.29567942,"z":0},{"x":-7.5,"y":0.14160654,"z":0},{"x":-6,"y":-0.08275819,"z":0},{"x":-4.5,"y":-0.2660427,"z":0},{"x":-3,"y":-0.31726667,"z":0},{"x":-1.5,"y":-0.2110031,"z":0},{"x":0,"y":0,"z":0},{"x":1.5,"y":0.2110031,"z":0},{"x":3,"y":0.31726667,"z":0},{"x":4.5,"y":0.2660427,"z":0},{"x":6,"y":0.08275819,"z":0},{"x":7.5,"y":-0.14160654,"z":0},{"x":9,"y":-0.29567942,"z":0},{"x":10.5,"y":-0.30298042,"z":0},{"x":12,"y":-0.1598854,"z":0},{"x":13.5,"y":0.062574886,"z":0},{"x":15,"y":0.25397372,"z":0},{"x":-15,"y":-0.22090623,"z":1.2000008},{"x":-13.5,"y":-0.05442761,"z":1.2000008},{"x":-12,"y":0.13906826,"z":1.2000008},{"x":-10.5,"y":0.26353222,"z":1.2000008},{"x":-9,"y":0.25718182,"z":1.2000008},{"x":-7.5,"y":0.12316931,"z":1.2000008},{"x":-6,"y":-0.07198304,"z":1.2000008},{"x":-4.5,"y":-0.23140384,"z":1.2000008},{"x":-3,"y":-0.27595842,"z":1.2000008},{"x":-1.5,"y":-0.1835304,"z":1.2000008},{"x":0,"y":0,"z":1.2000008},{"x":1.5,"y":0.1835304,"z":1.2000008},{"x":3,"y":0.27595842,"z":1.2000008},{"x":4.5,"y":0.23140384,"z":1.2000008},{"x":6,"y":0.07198304,"z":1.2000008},{"x":7.5,"y":-0.12316931,"z":1.2000008},{"x":9,"y":-0.25718182,"z":1.2000008},{"x":10.5,"y":-0.26353222,"z":1.2000008},{"x":12,"y":-0.13906826,"z":1.2000008},{"x":13.5,"y":0.05442761,"z":1.2000008},{"x":15,"y":0.22090623,"z":1.2000008},{"x":-15,"y":-0.13031468,"z":2.4000006},{"x":-13.5,"y":-0.03210736,"z":2.4000006},{"x":-12,"y":0.08203768,"z":2.4000006},{"x":-10.5,"y":0.15546016,"z":2.4000006},{"x":-9,"y":0.151714,"z":2.4000006},{"x":-7.5,"y":0.07265874,"z":2.4000006},{"x":-6,"y":-0.042463478,"z":2.4000006},{"x":-4.5,"y":-0.13650732,"z":2.4000006},{"x":-3,"y":-0.16279048,"z":2.4000006},{"x":-1.5,"y":-0.108266324,"z":2.4000006},{"x":0,"y":0,"z":2.4000006},{"x":1.5,"y":0.108266324,"z":2.4000006},{"x":3,"y":0.16279048,"z":2.4000006},{"x":4.5,"y":0.13650732,"z":2.4000006},{"x":6,"y":0.042463478,"z":2.4000006},{"x":7.5,"y":-0.07265874,"z":2.4000006},{"x":9,"y":-0.151714,"z":2.4000006},{"x":10.5,"y":-0.15546016,"z":2.4000006},{"x":12,"y":-0.08203768,"z":2.4000006},{"x":13.5,"y":0.03210736,"z":2.4000006},{"x":15,"y":0.13031468,"z":2.4000006},{"x":-15,"y":-0.0057892287,"z":3.5999994},{"x":-13.5,"y":-0.0014263694,"z":3.5999994},{"x":-12,"y":0.0036445234,"z":3.5999994},{"x":-10.5,"y":0.0069063166,"z":3.5999994},{"x":-9,"y":0.0067398935,"z":3.5999994},{"x":-7.5,"y":0.0032278642,"z":3.5999994},{"x":-6,"y":-0.0018864397,"z":3.5999994},{"x":-4.5,"y":-0.0060643367,"z":3.5999994},{"x":-3,"y":-0.007231966,"z":3.5999994},{"x":-1.5,"y":-0.0048097307,"z":3.5999994},{"x":0,"y":0,"z":3.5999994},{"x":1.5,"y":0.0048097307,"z":3.5999994},{"x":3,"y":0.007231966,"z":3.5999994},{"x":4.5,"y":0.0060643367,"z":3.5999994},{"x":6,"y":0.0018864397,"z":3.5999994},{"x":7.5,"y":-0.0032278642,"z":3.5999994},{"x":9,"y":-0.0067398935,"z":3.5999994},{"x":10.5,"y":-0.0069063166,"z":3.5999994},{"x":12,"y":-0.0036445234,"z":3.5999994},{"x":13.5,"y":0.0014263694,"z":3.5999994},{"x":15,"y":0.0057892287,"z":3.5999994},{"x":-15,"y":0.120243825,"z":4.799999},{"x":-13.5,"y":0.029626071,"z":4.799999},{"x":-12,"y":-0.07569773,"z":4.799999},{"x":-10.5,"y":-0.14344604,"z":4.799999},{"x":-9,"y":-0.13998938,"z":4.799999},{"x":-7.5,"y":-0.0670436,"z":4.799999},{"x":-6,"y":0.03918186,"z":4.799999},{"x":-4.5,"y":0.12595789,"z":4.799999},{"x":-3,"y":0.15020987,"z":4.799999},{"x":-1.5,"y":0.09989939,"z":4.799999},{"x":0,"y":-0,"z":4.799999},{"x":1.5,"y":-0.09989939,"z":4.799999},{"x":3,"y":-0.15020987,"z":4.799999},{"x":4.5,"y":-0.12595789,"z":4.799999},{"x":6,"y":-0.03918186,"z":4.799999},{"x":7.5,"y":0.0670436,"z":4.799999},{"x":9,"y":0.13998938,"z":4.799999},{"x":10.5,"y":0.14344604,"z":4.799999},{"x":12,"y":0.07569773,"z":4.799999},{"x":13.5,"y":-0.029626071,"z":4.799999},{"x":15,"y":-0.120243825,"z":4.799999},{"x":-15,"y":0.2149654,"z":6},{"x":-13.5,"y":0.052963886,"z":6},{"x":-12,"y":-0.1353283,"z":6},{"x":-10.5,"y":-0.25644505,"z":6},{"x":-9,"y":-0.25026542,"z":6},{"x":-7.5,"y":-0.11985692,"z":6},{"x":-6,"y":0.0700472,"z":6},{"x":-4.5,"y":0.22518069,"z":6},{"x":-3,"y":0.26853707,"z":6},{"x":-1.5,"y":0.17859472,"z":6},{"x":0,"y":-0,"z":6},{"x":1.5,"y":-0.17859472,"z":6},{"x":3,"y":-0.26853707,"z":6},{"x":4.5,"y":-0.22518069,"z":6},{"x":6,"y":-0.0700472,"z":6},{"x":7.5,"y":0.11985692,"z":6},{"x":9,"y":0.25026542,"z":6},{"x":10.5,"y":0.25644505,"z":6},{"x":12,"y":0.1353283,"z":6},{"x":13.5,"y":-0.052963886,"z":6},{"x":15,"y":-0.2149654,"z":6},{"x":-15,"y":0.2537098,"z":7.200001},{"x":-13.5,"y":0.06250986,"z":7.200001},{"x":-12,"y":-0.15971926,"z":7.200001},{"x":-10.5,"y":-0.30266556,"z":7.200001},{"x":-9,"y":-0.29537216,"z":7.200001},{"x":-7.5,"y":-0.14145939,"z":7.200001},{"x":-6,"y":0.08267219,"z":7.200001},{"x":-4.5,"y":0.26576626,"z":7.200001},{"x":-3,"y":0.31693697,"z":7.200001},{"x":-1.5,"y":0.21078382,"z":7.200001},{"x":0,"y":-0,"z":7.200001},{"x":1.5,"y":-0.21078382,"z":7.200001},{"x":3,"y":-0.31693697,"z":7.200001},{"x":4.5,"y":-0.26576626,"z":7.200001},{"x":6,"y":-0.08267219,"z":7.200001},{"x":7.5,"y":0.14145939,"z":7.200001},{"x":9,"y":0.29537216,"z":7.200001},{"x":10.5,"y":0.30266556,"z":7.200001},{"x":12,"y":0.15971926,"z":7.200001},{"x":13.5,"y":-0.06250986,"z":7.200001},{"x":15,"y":-0.2537098,"z":7.200001},{"x":-15,"y":0.22638796,"z":8.400002},{"x":-13.5,"y":0.055778217,"z":8.400002},{"x":-12,"y":-0.1425192,"z":8.400002},{"x":-10.5,"y":-0.27007172,"z":8.400002},{"x":-9,"y":-0.26356372,"z":8.400002},{"x":-7.5,"y":-0.12622572,"z":8.400002},{"x":-6,"y":0.07376928,"z":8.400002},{"x":-4.5,"y":0.23714606,"z":8.400002},{"x":-3,"y":0.28280625,"z":8.400002},{"x":-1.5,"y":0.18808466,"z":8.400002},{"x":0,"y":-0,"z":8.400002},{"x":1.5,"y":-0.18808466,"z":8.400002},{"x":3,"y":-0.28280625,"z":8.400002},{"x":4.5,"y":-0.23714606,"z":8.400002},{"x":6,"y":-0.07376928,"z":8.400002},{"x":7.5,"y":0.12622572,"z":8.400002},{"x":9,"y":0.26356372,"z":8.400002},{"x":10.5,"y":0.27007172,"z":8.400002},{"x":12,"y":0.1425192,"z":8.400002},{"x":13.5,"y":-0.055778217,"z":8.400002},{"x":15,"y":-0.22638796,"z":8.400002},{"x":-15,"y":0.14011486,"z":9.599998},{"x":-13.5,"y":0.034521963,"z":9.599998},{"x":-12,"y":-0.088207245,"z":9.599998},{"x":-10.5,"y":-0.16715139,"z":9.599998},{"x":-9,"y":-0.1631235,"z":9.599998},{"x":-7.5,"y":-0.07812297,"z":9.599998},{"x":-6,"y":0.0456569,"z":9.599998},{"x":-4.5,"y":0.1467732,"z":9.599998},{"x":-3,"y":0.17503297,"z":9.599998},{"x":-1.5,"y":0.116408385,"z":9.599998},{"x":0,"y":-0,"z":9.599998},{"x":1.5,"y":-0.116408385,"z":9.599998},{"x":3,"y":-0.17503297,"z":9.599998},{"x":4.5,"y":-0.1467732,"z":9.599998},{"x":6,"y":-0.0456569,"z":9.599998},{"x":7.5,"y":0.07812297,"z":9.599998},{"x":9,"y":0.1631235,"z":9.599998},{"x":10.5,"y":0.16715139,"z":9.599998},{"x":12,"y":0.088207245,"z":9.599998},{"x":13.5,"y":-0.034521963,"z":9.599998},{"x":15,"y":-0.14011486,"z":9.599998},{"x":-15,"y":0.01735555,"z":10.799999},{"x":-13.5,"y":0.004276118,"z":10.799999},{"x":-12,"y":-0.010925931,"z":10.799999},{"x":-10.5,"y":-0.020704472,"z":10.799999},{"x":-9,"y":-0.020205552,"z":10.799999},{"x":-7.5,"y":-0.009676826,"z":10.799999},{"x":-6,"y":0.0056553646,"z":10.799999},{"x":-4.5,"y":0.018180298,"z":10.799999},{"x":-3,"y":0.021680739,"z":10.799999},{"x":-1.5,"y":0.01441911,"z":10.799999},{"x":0,"y":-0,"z":10.799999},{"x":1.5,"y":-0.01441911,"z":10.799999},{"x":3,"y":-0.021680739,"z":10.799999},{"x":4.5,"y":-0.018180298,"z":10.799999},{"x":6,"y":-0.0056553646,"z":10.799999},{"x":7.5,"y":0.009676826,"z":10.799999},{"x":9,"y":0.020205552,"z":10.799999},{"x":10.5,"y":0.020704472,"z":10.799999},{"x":12,"y":0.010925931,"z":10.799999},{"x":13.5,"y":-0.004276118,"z":10.799999},{"x":15,"y":-0.01735555,"z":10.799999},{"x":-15,"y":-0.10992316,"z":12},{"x":-13.5,"y":-0.027083233,"z":12},{"x":-12,"y":0.0692005,"z":12},{"x":-10.5,"y":0.1311339,"z":12},{"x":-9,"y":0.12797393,"z":12},{"x":-7.5,"y":0.061289173,"z":12},{"x":-6,"y":-0.035818834,"z":12},{"x":-4.5,"y":-0.11514678,"z":12},{"x":-3,"y":-0.13731718,"z":12},{"x":-1.5,"y":-0.09132491,"z":12},{"x":0,"y":0,"z":12},{"x":1.5,"y":0.09132491,"z":12},{"x":3,"y":0.13731718,"z":12},{"x":4.5,"y":0.11514678,"z":12},{"x":6,"y":0.035818834,"z":12},{"x":7.5,"y":-0.061289173,"z":12},{"x":9,"y":-0.12797393,"z":12},{"x":10.5,"y":-0.1311339,"z":12},{"x":12,"y":-0.0692005,"z":12},{"x":13.5,"y":0.027083233,"z":12},{"x":15,"y":0.10992316,"z":12}],"textureCoordinates":[{"x":-15,"y":-12},{"x":-13.5,"y":-12},{"x":-12,"y":-12},{"x":-10.5,"y":-12},{"x":-9,"y":-12},{"x":-7.5,"y":-12},{"x":-6,"y":-12},{"x":-4.5,"y":-12},{"x":-3,"y":-12},{"x":-1.5,"y":-12},{"x":0,"y":-12},{"x":1.5,"y":-12},{"x":3,"y":-12},{"x":4.5,"y":-12},{"x":6,"y":-12},{"x":7.5,"y":-12},{"x":9,"y":-12},{"x":10.5,"y":-12},{"x":12,"y":-12},{"x":13.5,"y":-12},{"x":15,"y":-12},{"x":-15,"y":-10.8},{"x":-13.5,"y":-10.8},{"x":-12,"y":-10.8},{"x":-10.5,"y":-10.8},{"x":-9,"y":-10.8},{"x":-7.5,"y":-10.8},{"x":-6,"y":-10.8},{"x":-4.5,"y":-10.8},{"x":-3,"y":-10.8},{"x":-1.5,"y":-10.8},{"x":0,"y":-10.8},{"x":1.5,"y":-10.8},{"x":3,"y":-10.8},{"x":4.5,"y":-10.8},{"x":6,"y":-10.8},{"x":7.5,"y":-10.8},{"x":9,"y":-10.8},{"x":10.5,"y":-10.8},{"x":12,"y":-10.8},{"x":13.5,"y":-10.8},{"x":15,"y":-10.8},{"x":-15,"y":-9.6},{"x":-13.5,"y":-9.6},{"x":-12,"y":-9.6},{"x":-10.5,"y":-9.6},{"x":-9,"y":-9.6},{"x":-7.5,"y":-9.6},{"x":-6,"y":-9.6},{"x":-4.5,"y":-9.6},{"x":-3,"y":-9.6},{"x":-1.5,"y":-9.6},{"x":0,"y":-9.6},{"x":1.5,"y":-9.6},{"x":3,"y":-9.6},{"x":4.5,"y":-9.6},{"x":6,"y":-9.6},{"x":7.5,"y":-9.6},{"x":9,"y":-9.6},{"x":10.5,"y":-9.6},{"x":12,"y":-9.6},{"x":13.5,"y":-9.6},{"x":15,"y":-9.6},{"x":-15,"y":-8.4},{"x":-13.5,"y":-8.4},{"x":-12,"y":-8.4},{"x":-10.5,"y":-8.4},{"x":-9,"y":-8.4},{"x":-7.5,"y":-8.4},{"x":-6,"y":-8.4},{"x":-4.5,"y":-8.4},{"x":-3,"y":-8.4},{"x":-1.5,"y":-8.4},{"x":0,"y":-8.4},{"x":1.5,"y":-8.4},{"x":3,"y":-8.4},{"x":4.5,"y":-8.4},{"x":6,"y":-8.4},{"x":7.5,"y":-8.4},{"x":9,"y":-8.4},{"x":10.5,"y":-8.4},{"x":12,"y":-8.4},{"x":13.5,"y":-8.4},{"x":15,"y":-8.4},{"x":-15,"y":-7.2},{"x":-13.5,"y":-7.2},{"x":-12,"y":-7.2},{"x":-10.5,"y":-7.2},{"x":-9,"y":-7.2},{"x":-7.5,"y":-7.2},{"x":-6,"y":-7.2},{"x":-4.5,"y":-7.2},{"x":-3,"y":-7.2},{"x":-1.5,"y":-7.2},{"x":0,"y":-7.2},{"x":1.5,"y":-7.2},{"x":3,"y":-7.2},{"x":4.5,"y":-7.2},{"x":6,"y":-7.2},{"x":7.5,"y":-7.2},{"x":9,"y":-7.2},{"x":10.5,"y":-7.2},{"x":12,"y":-7.2},{"x":13.5,"y":-7.2},{"x":15,"y":-7.2},{"x":-15,"y":-6},{"x":-13.5,"y":-6},{"x":-12,"y":-6},{"x":-10.5,"y":-6},{"x":-9,"y":-6},{"x":-7.5,"y":-6},{"x":-6,"y":-6},{"x":-4.5,"y":-6},{"x":-3,"y":-6},{"x":-1.5,"y":-6},{"x":0,"y":-6},{"x":1.5,"y":-6},{"x":3,"y":-6},{"x":4.5,"y":-6},{"x":6,"y":-6},{"x":7.5,"y":-6},{"x":9,"y":-6},{"x":10.5,"y":-6},{"x":12,"y":-6},{"x":13.5,"y":-6},{"x":15,"y":-6},{"x":-15,"y":-4.7999997},{"x":-13.5,"y":-4.7999997},{"x":-12,"y":-4.7999997},{"x":-10.5,"y":-4.7999997},{"x":-9,"y":-4.7999997},{"x":-7.5,"y":-4.7999997},{"x":-6,"y":-4.7999997},{"x":-4.5,"y":-4.7999997},{"x":-3,"y":-4.7999997},{"x":-1.5,"y":-4.7999997},{"x":0,"y":-4.7999997},{"x":1.5,"y":-4.7999997},{"x":3,"y":-4.7999997},{"x":4.5,"y":-4.7999997},{"x":6,"y":-4.7999997},{"x":7.5,"y":-4.7999997},{"x":9,"y":-4.7999997},{"x":10.5,"y":-4.7999997},{"x":12,"y":-4.7999997},{"x":13.5,"y":-4.7999997},{"x":15,"y":-4.7999997},{"x":-15,"y":-3.6000004},{"x":-13.5,"y":-3.6000004},{"x":-12,"y":-3.6000004},{"x":-10.5,"y":-3.6000004},{"x":-9,"y":-3.6000004},{"x":-7.5,"y":-3.6000004},{"x":-6,"y":-3.6000004},{"x":-4.5,"y":-3.6000004},{"x":-3,"y":-3.6000004},{"x":-1.5,"y":-3.6000004},{"x":0,"y":-3.6000004},{"x":1.5,"y":-3.6000004},{"x":3,"y":-3.6000004},{"x":4.5,"y":-3.6000004},{"x":6,"y":-3.6000004},{"x":7.5,"y":-3.6000004},{"x":9,"y":-3.6000004},{"x":10.5,"y":-3.6000004},{"x":12,"y":-3.6000004},{"x":13.5,"y":-3.6000004},{"x":15,"y":-3.6000004},{"x":-15,"y":-2.3999996},{"x":-13.5,"y":-2.3999996},{"x":-12,"y":-2.3999996},{"x":-10.5,"y":-2.3999996},{"x":-9,"y":-2.3999996},{"x":-7.5,"y":-2.3999996},{"x":-6,"y":-2.3999996},{"x":-4.5,"y":-2.3999996},{"x":-3,"y":-2.3999996},{"x":-1.5,"y":-2.3999996},{"x":0,"y":-2.3999996},{"x":1.5,"y":-2.3999996},{"x":3,"y":-2.3999996},{"x":4.5,"y":-2.3999996},{"x":6,"y":-2.3999996},{"x":7.5,"y":-2.3999996},{"x":9,"y":-2.3999996},{"x":10.5,"y":-2.3999996},{"x":12,"y":-2.3999996},{"x":13.5,"y":-2.3999996},{"x":15,"y":-2.3999996},{"x":-15,"y":-1.2000008},{"x":-13.5,"y":-1.2000008},{"x":-12,"y":-1.2000008},{"x":-10.5,"y":-1.2000008},{"x":-9,"y":-1.2000008},{"x":-7.5,"y":-1.2000008},{"x":-6,"y":-1.2000008},{"x":-4.5,"y":-1.2000008},{"x":-3,"y":-1.2000008},{"x":-1.5,"y":-1.2000008},{"x":0,"y":-1.2000008},{"x":1.5,"y":-1.2000008},{"x":3,"y":-1.2000008},{"x":4.5,"y":-1.2000008},{"x":6,"y":-1.2000008},{"x":7.5,"y":-1.2000008},{"x":9,"y":-1.2000008},{"x":10.5,"y":-1.2000008},{"x":12,"y":-1.2000008},{"x":13.5,"y":-1.2000008},{"x":15,"y":-1.2000008},{"x":-15,"y":0},{"x":-13.5,"y":0},{"x":-12,"y":0},{"x":-10.5,"y":0},{"x":-9,"y":0},{"x":-7.5,"y":0},{"x":-6,"y":0},{"x":-4.5,"y":0},{"x":-3,"y":0},{"x":-1.5,"y":0},{"x":0,"y":0},{"x":1.5,"y":0},{"x":3,"y":0},{"x":4.5,"y":0},{"x":6,"y":0},{"x":7.5,"y":0},{"x":9,"y":0},{"x":10.5,"y":0},{"x":12,"y":0},{"x":13.5,"y":0},{"x":15,"y":0},{"x":-15,"y":1.2000008},{"x":-13.5,"y":1.2000008},{"x":-12,"y":1.2000008},{"x":-10.5,"y":1.2000008},{"x":-9,"y":1.2000008},{"x":-7.5,"y":1.2000008},{"x":-6,"y":1.2000008},{"x":-4.5,"y":1.2000008},{"x":-3,"y":1.2000008},{"x":-1.5,"y":1.2000008},{"x":0,"y":1.2000008},{"x":1.5,"y":1.2000008},{"x":3,"y":1.2000008},{"x":4.5,"y":1.2000008},{"x":6,"y":1.2000008},{"x":7.5,"y":1.2000008},{"x":9,"y":1.2000008},{"x":10.5,"y":1.2000008},{"x":12,"y":1.2000008},{"x":13.5,"y":1.2000008},{"x":15,"y":1.2000008},{"x":-15,"y":2.4000006},{"x":-13.5,"y":2.4000006},{"x":-12,"y":2.4000006},{"x":-10.5,"y":2.4000006},{"x":-9,"y":2.4000006},{"x":-7.5,"y":2.4000006},{"x":-6,"y":2.4000006},{"x":-4.5,"y":2.4000006},{"x":-3,"y":2.4000006},{"x":-1.5,"y":2.4000006},{"x":0,"y":2.4000006},{"x":1.5,"y":2.4000006},{"x":3,"y":2.4000006},{"x":4.5,"y":2.4000006},{"x":6,"y":2.4000006},{"x":7.5,"y":2.4000006},{"x":9,"y":2.4000006},{"x":10.5,"y":2.4000006},{"x":12,"y":2.4000006},{"x":13.5,"y":2.4000006},{"x":15,"y":2.4000006},{"x":-15,"y":3.5999994},{"x":-13.5,"y":3.5999994},{"x":-12,"y":3.5999994},{"x":-10.5,"y":3.5999994},{"x":-9,"y":3.5999994},{"x":-7.5,"y":3.5999994},{"x":-6,"y":3.5999994},{"x":-4.5,"y":3.5999994},{"x":-3,"y":3.5999994},{"x":-1.5,"y":3.5999994},{"x":0,"y":3.5999994},{"x":1.5,"y":3.5999994},{"x":3,"y":3.5999994},{"x":4.5,"y":3.5999994},{"x":6,"y":3.5999994},{"x":7.5,"y":3.5999994},{"x":9,"y":3.5999994},{"x":10.5,"y":3.5999994},{"x":12,"y":3.5999994},{"x":13.5,"y":3.5999994},{"x":15,"y":3.5999994},{"x":-15,"y":4.799999},{"x":-13.5,"y":4.799999},{"x":-12,"y":4.799999},{"x":-10.5,"y":4.799999},{"x":-9,"y":4.799999},{"x":-7.5,"y":4.799999},{"x":-6,"y":4.799999},{"x":-4.5,"y":4.799999},{"x":-3,"y":4.799999},{"x":-1.5,"y":4.799999},{"x":0,"y":4.799999},{"x":1.5,"y":4.799999},{"x":3,"y":4.799999},{"x":4.5,"y":4.799999},{"x":6,"y":4.799999},{"x":7.5,"y":4.799999},{"x":9,"y":4.799999},{"x":10.5,"y":4.799999},{"x":12,"y":4.799999},{"x":13.5,"y":4.799999},{"x":15,"y":4.799999},{"x":-15,"y":6},{"x":-13.5,"y":6},{"x":-12,"y":6},{"x":-10.5,"y":6},{"x":-9,"y":6},{"x":-7.5,"y":6},{"x":-6,"y":6},{"x":-4.5,"y":6},{"x":-3,"y":6},{"x":-1.5,"y":6},{"x":0,"y":6},{"x":1.5,"y":6},{"x":3,"y":6},{"x":4.5,"y":6},{"x":6,"y":6},{"x":7.5,"y":6},{"x":9,"y":6},{"x":10.5,"y":6},{"x":12,"y":6},{"x":13.5,"y":6},{"x":15,"y":6},{"x":-15,"y":7.200001},{"x":-13.5,"y":7.200001},{"x":-12,"y":7.200001},{"x":-10.5,"y":7.200001},{"x":-9,"y":7.200001},{"x":-7.5,"y":7.200001},{"x":-6,"y":7.200001},{"x":-4.5,"y":7.200001},{"x":-3,"y":7.200001},{"x":-1.5,"y":7.200001},{"x":0,"y":7.200001},{"x":1.5,"y":7.200001},{"x":3,"y":7.200001},{"x":4.5,"y":7.200001},{"x":6,"y":7.200001},{"x":7.5,"y":7.200001},{"x":9,"y":7.200001},{"x":10.5,"y":7.200001},{"x":12,"y":7.200001},{"x":13.5,"y":7.200001},{"x":15,"y":7.200001},{"x":-15,"y":8.400002},{"x":-13.5,"y":8.400002},{"x":-12,"y":8.400002},{"x":-10.5,"y":8.400002},{"x":-9,"y":8.400002},{"x":-7.5,"y":8.400002},{"x":-6,"y":8.400002},{"x":-4.5,"y":8.400002},{"x":-3,"y":8.400002},{"x":-1.5,"y":8.400002},{"x":0,"y":8.400002},{"x":1.5,"y":8.400002},{"x":3,"y":8.400002},{"x":4.5,"y":8.400002},{"x":6,"y":8.400002},{"x":7.5,"y":8.400002},{"x":9,"y":8.400002},{"x":10.5,"y":8.400002},{"x":12,"y":8.400002},{"x":13.5,"y":8.400002},{"x":15,"y":8.400002},{"x":-15,"y":9.599998},{"x":-13.5,"y":9.599998},{"x":-12,"y":9.599998},{"x":-10.5,"y":9.599998},{"x":-9,"y":9.599998},{"x":-7.5,"y":9.599998},{"x":-6,"y":9.599998},{"x":-4.5,"y":9.599998},{"x":-3,"y":9.599998},{"x":-1.5,"y":9.599998},{"x":0,"y":9.599998},{"x":1.5,"y":9.599998},{"x":3,"y":9.599998},{"x":4.5,"y":9.599998},{"x":6,"y":9.599998},{"x":7.5,"y":9.599998},{"x":9,"y":9.599998},{"x":10.5,"y":9.599998},{"x":12,"y":9.599998},{"x":13.5,"y":9.599998},{"x":15,"y":9.599998},{"x":-15,"y":10.799999},{"x":-13.5,"y":10.799999},{"x":-12,"y":10.799999},{"x":-10.5,"y":10.799999},{"x":-9,"y":10.799999},{"x":-7.5,"y":10.799999},{"x":-6,"y":10.799999},{"x":-4.5,"y":10.799999},{"x":-3,"y":10.799999},{"x":-1.5,"y":10.799999},{"x":0,"y":10.799999},{"x":1.5,"y":10.799999},{"x":3,"y":10.799999},{"x":4.5,"y":10.799999},{"x":6,"y":10.799999},{"x":7.5,"y":10.799999},{"x":9,"y":10.799999},{"x":10.5,"y":10.799999},{"x":12,"y":10.799999},{"x":13.5,"y":10.799999},{"x":15,"y":10.799999},{"x":-15,"y":12},{"x":-13.5,"y":12},{"x":-12,"y":12},{"x":-10.5,"y":12},{"x":-9,"y":12},{"x":-7.5,"y":12},{"x":-6,"y":12},{"x":-4.5,"y":12},{"x":-3,"y":12},{"x":-1.5,"y":12},{"x":0,"y":12},{"x":1.5,"y":12},{"x":3,"y":12},{"x":4.5,"y":12},{"x":6,"y":12},{"x":7.5,"y":12},{"x":9,"y":12},{"x":10.5,"y":12},{"x":12,"y":12},{"x":13.5,"y":12},{"x":15,"y":12}]}}],"version":1},"manualPlacements":[{"id":11,"normal":{"x":0,"y":1,"z":0},"ownership":"manual","prototypeID":2,"sourceID":0,"transform":{"w":{"w":1,"x":9,"y":-0.18687001,"z":-2},"x":{"w":0,"x":1.7,"y":0,"z":0},"y":{"w":0,"x":0,"y":1.7,"z":0},"z":{"w":0,"x":0,"y":0,"z":1.7}}}],"populationOverrides":[{"candidateID":171148080807394718,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","suppressed":true},{"candidateID":95120272969886708,"pinned":{"id":95120272969886708,"normal":{"x":0.11822809,"y":0.99298644,"z":-0},"ownership":"generated","patchID":"273A3815-7E65-486D-9B9D-E1D3EC2D8105","prototypeID":2,"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","sourceID":0,"transform":{"w":{"w":1,"x":0.7197571,"y":-0.08569654,"z":-5.505005},"x":{"w":0,"x":-0.4035381,"y":0.048046503,"z":-0.8628031},"y":{"w":0,"x":0.11275642,"y":0.9470305,"z":-7.105763e-09},"z":{"w":0,"x":0.85675186,"y":-0.10200756,"z":-0.4063883}}},"ruleID":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","suppressed":false}],"populationRules":[{"bounds":{"maximum":{"x":12,"y":1.5,"z":7},"minimum":{"x":-2,"y":-0.75,"z":-6}},"cellSize":6,"density":1.7,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D8109","maximumScale":1.2,"maximumUpDot":1,"minimumScale":0.8,"minimumUpDot":0.2,"offset":0,"prototypeID":1,"seed":218,"sourceExclusionDistance":0.9,"spacing":0.65},{"bounds":{"maximum":{"x":12,"y":1.5,"z":7},"minimum":{"x":-2,"y":-0.75,"z":-6}},"cellSize":6,"density":0.5,"enabled":true,"id":"273A3815-7E65-486D-9B9D-E1D3EC2D810A","maximumScale":1.2,"maximumUpDot":1,"minimumScale":0.8,"minimumUpDot":0.2,"offset":0,"prototypeID":2,"seed":327,"sourceExclusionDistance":1.3,"spacing":0.8}],"version":1} \ No newline at end of file diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/grass-sprig.vox b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/grass-sprig.vox new file mode 100644 index 0000000000000000000000000000000000000000..c0138e73e7139de61078fb772304433f23ce4ab2 GIT binary patch literal 1148 zcmWIZk5HJ#z`)?^=;;Tf7&2HG7=k^cTzNnoAOLc~m>oz*L`Hd9fCPbn7l;`dfd~wk zfFv^zvj8zG5VJ9YM3}&k8K{a0h(R<9W01R(BiKNo{-_l?|3PMrg3%Bd4S|st0szUd B3CsWh literal 0 HcmV?d00001 diff --git a/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/loose-stone.vox b/Editor/Samples/MeshTerrainWorkshop/Assets/Terrain/loose-stone.vox new file mode 100644 index 0000000000000000000000000000000000000000..8c976aeab68806e50d090040ed13afd95c64ab0e GIT binary patch literal 1168 zcmeHD+X;X$5X)Wt^velcrGiMo2PlYk2RG0)Tvv1I7B(f9yK6}y^RSdBfX;`Wf;|JK z5bN9qf+HNMi0#w_UrXh!a<6RYDUNR)=dr2$^ASCdEncs6 Mswsh#z~2(M0W$px0{{R3 literal 0 HcmV?d00001 diff --git a/Editor/Samples/MeshTerrainWorkshop/README.md b/Editor/Samples/MeshTerrainWorkshop/README.md new file mode 100644 index 000000000..4e57ef6f5 --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/README.md @@ -0,0 +1,32 @@ +# Mesh Terrain Workshop + +Open this directory as an Ada Studio project. It contains a ready-to-run terrain +with a joined rock, a shaped platform, a cave with different materials on its +floor and ceiling, and two populations made from small VOX models. + +1. Open the terrain tools, choose **Joining**, and select **Join and move this + tree** in the hierarchy. Select **Mesh Terrain** as the target and choose + **Join selected mesh**. The trunk joins the ground; the canopy keeps its own + material. +2. Drag the tree along the red X handle. Grass and stones move out of the new + footprint and grow back where the tree used to be. Use Undo and Redo to + restore both the tree and the ground. +3. Switch the viewport from **Shaded** to **Wireframe** or **Shaded + Wireframe** + to see the triangles. Zoom out to inspect the coarser triangles where the + terrain can simplify. Use the cave entrance on the left to inspect the painted + floor and ceiling separately. +4. Save and reopen the project to keep the move. Copy the whole project if you + want a clean copy for further edits; the joined terrain references the + original model under Assets for its bark texture. +5. Press **Play** in the scene viewport to see the orange ball drop and stop on + the joined rock. Stop Play to return to editing. + +The example includes a manually placed rock, a pinned procedural stone, and a +suppressed grass candidate in the authored terrain file. Mesh Terrain compiles +them with the ground so runtime scenes load the same placement decisions. + +Validate the project from the engine checkout: + +```text +adastudio validate --project Editor/Samples/MeshTerrainWorkshop +``` diff --git a/Editor/Samples/MeshTerrainWorkshop/Sources/Main.ada b/Editor/Samples/MeshTerrainWorkshop/Sources/Main.ada new file mode 100644 index 000000000..883bda19b --- /dev/null +++ b/Editor/Samples/MeshTerrainWorkshop/Sources/Main.ada @@ -0,0 +1 @@ +// The workshop uses the native components saved in Main.ascn. diff --git a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json index b0fef0dab..61092a654 100644 --- a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json +++ b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json @@ -119,6 +119,23 @@ "level": 0, "links": [] }, + { + "kind": "heading", + "text": "Shape terrain", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Open a scene and choose **Terrain** in the viewport toolbar. In the dock, choose\n**Mesh Terrain** to join a static model to the ground or **Heightfield** to sculpt\nheight samples with Raise, Lower, Smooth or Flatten. Inspect the triangles with\nthe 3D viewport's **Wireframe** mode. See the Terrain guide for a\nworked join, heightfield tools, saved versions, and collision.", + "level": 0, + "links": [ + { + "title": "Terrain guide", + "destination": "EditorTerrain" + } + ] + }, { "kind": "heading", "text": "Build and run", @@ -298,7 +315,7 @@ }, { "kind": "text", - "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Authoring skeletal animation\n• Animation IK and pose modifiers\n• Movie Maker\n• visionOS Windowed Mode", + "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Authoring skeletal animation\n• Animation IK and pose modifiers\n• Movie Maker\n• Terrain\n• visionOS Windowed Mode", "level": 0, "links": [ { @@ -329,6 +346,10 @@ "title": "Movie Maker", "destination": "MovieMaker" }, + { + "title": "Terrain", + "destination": "Terrain" + }, { "title": "visionOS Windowed Mode", "destination": "VisionOSWindowed" @@ -2935,370 +2956,1418 @@ ] }, { - "id": "AnimationAuthoring", - "title": "Authoring skeletal animation", - "section": "AdaEngine", - "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md", + "id": "EditorTerrain", + "title": "Terrain", + "section": "Editor", + "source": "Editor/Documentation/Terrain.md", "blocks": [ { "kind": "text", - "text": "Copy an imported animation or capture a rest/current pose into a project-owned\n`.animlib` resource. Source GLB/glTF bytes remain unchanged. Swift code, Studio's\nAnimator, the editor agent and project Tools share the same data and edit commands.", + "text": "Open a native Studio scene and select **Terrain** in the viewport toolbar. The\nterrain dock opens **below the viewport**. It includes **Sculpt**, **Materials**,\n**Joining**, **Mesh Instancing**, and **Height Generator**. The Inspector keeps\nordinary entity components. **Mesh Instancing** also has its own viewport toolbar\nbutton. Play uses a separate runtime and does not show the authoring dock.", "level": 0, "links": [] }, + { + "kind": "heading", + "text": "Create and join Mesh Terrain", + "level": 2, + "links": [] + }, { "kind": "text", - "text": "Opening a GLB/glTF or `.animlib` in Studio reveals the bottom **Animator**. A\nresizable **Model** panel shows the imported node hierarchy, and the right Inspector\nfollows bone, track, key and graph selection. The timeline places all bones on a\nshared seconds ruler with a draggable playhead, clip/duration/loop controls, key\ninsertion and grouped editing. Graph uses connected pose cards on a canvas.\nThese surfaces share the large center viewport's player and camera. Asset editing\ndoes not require an open scene. See the Studio animation-authoring guide for the\ncomplete pointer and keyboard workflow.", + "text": "Set **Terrain Type** to **Mesh Terrain** and choose **Create Terrain**. This\ncreates a saved 128-unit surface from a 64 × 64 grid of cells. Choose **Heightfield**\nto create ground for the heightfield workflow described below.", "level": 0, "links": [] }, { - "kind": "heading", - "text": "Create a library", - "level": 2, + "kind": "text", + "text": "For a first join, add a static cube to the scene and position it so it crosses the\nground surface. In **Joining**, select the cube, choose the **Target Terrain**, set\n**Operation** to **Join**, and choose **Join selected mesh**. A closed mesh with\nconsistent face winding works as a solid. Joining replaces the overlapping faces\nwith a connected surface. The cube stays in the hierarchy and remains selectable.\nA self-contained GLB can contribute its opaque trunk and roots while its\ncutout leaves keep their own draw. Joined source triangles keep the original\ncolor, roughness, normal maps and texture coordinates. Keep the model in\nproject Assets so the joined surface can load its textures when you reopen the\nscene.", + "level": 0, "links": [] }, { "kind": "text", - "text": "A binding snapshots the complete hierarchy: stable node IDs, parents, names, rest\nmatrices/TRS and morph target widths. Tracks target hierarchy nodes, including\nnon-joint nodes, rather than offsets in a skin palette. Changed rigs fail binding\nexplicitly; guessing by duplicate bone names or reordered indices is not remapping.", + "text": "Use **Contact width** to control the transition around a joined object. Choose\n**Subtract** to cut a closed shape out of the terrain, or **Conform** to project\nthe existing ground toward a static mesh. Conform keeps the source visible and\nworks with an open surface. **Projection distance** limits the reach of the\nprojection above and below the ground; **Projection strength** controls how far\nthe affected vertices move. Choose **Apply operation** to rebuild.", "level": 0, "links": [] }, { - "kind": "code", - "text": "import AdaAnimation\nimport Foundation\n\nlet rig = try SkeletalRig(nodes: [\n .init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: \"Root\")\n])\nvar library = AnimationLibrary(id: \"hero\", binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet clip = try library.poseClip(id: \"wave\", name: \"Wave\", duration: 1)\ntry library.apply([.init(id: \"create-wave\", kind: .createClip, clip: clip)])\n// This known one-bone rig has one rotation track in the captured pose clip.\nif let rotation = clip.tracks.first(where: { $0.channel == .rotation }), var key = rotation.keys.first {\n key.value = [0, 0, 0.70710677, 0.70710677]\n try library.apply([.init(id: \"rotate-root\", kind: .putKey, clipID: clip.id,\n trackID: rotation.id, key: key, replaceExisting: true)])\n}\nlet reopened = try AnimationLibrary.decode(library.encodedData())\nlet player = try reopened.sample(clipID: \"wave\", at: 0.5)", - "language": "swift", + "kind": "text", + "text": "Select the cube and use the viewport's Move tool. While the editor prepares a new\nsurface, the previous terrain remains visible. Releasing saves the accepted shape\nand position in one Undo step. Escape cancels an in-progress move. Undo and Redo\nrestore the saved surface with the cube's corresponding position. The original\nmodel asset stays available for reuse. Keep the entire joined mesh within the\nterrain's boundary. A move past the edge is rejected and the last accepted\nposition stays in the scene.", + "level": 0, "links": [] }, { "kind": "text", - "text": "Use `library.extract(clip, rig: rig)` to copy a runtime clip from the same source rig.\nTranslation, normalized XYZW quaternion rotation, scale and morph-weight tracks\nretain their linear/step/cubic-spline values and derivatives. Explicit clip duration\npreserves a hold after the last key. `poseClip(name:duration:from:)` can capture a\ncompatible player's local pose/morphs. Matrix-authored static nodes retain matrices.", + "text": "To place a joined object precisely, edit its Transform in the Inspector and\npress Enter. The terrain and the object's new transform are saved together.\nIf the new position crosses the terrain boundary, Studio keeps the last valid\nposition and shows why the edit was rejected.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "To move a group of joined objects, select their parent in the hierarchy. All\njoined objects in the group must share one Mesh Terrain target. Release the\nMove handle or press Enter in the Inspector to save the group and the rebuilt\nterrain in one Undo step.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Select the bound object and choose **Detach from terrain** in Joining or in the\nInspector to make it independent again. The ground and its procedural population\nrebuild without that object. Undo restores the original binding and surface.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Use the render selector in the 3D viewport to choose **Wireframe** or **Shaded +\nWireframe**. Wireframe shows triangle edges; the combined mode keeps the shaded\nsurface visible under the edges. Choose **Shaded** to return to the usual view.\nThese modes are for inspection in the viewport and do not change Play or the\nscene's authored materials.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Zoom out in Wireframe to inspect Mesh Terrain's render LOD. Eligible regions\nswitch to a coarser meshlet level as they occupy fewer pixels. Adjacent terrain\nsections keep the same shared edge, even when one section shows finer triangles.\nRegions with varying material paint retain their original triangles.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Edit keys and play clips", + "text": "Create and generate heightfield ground", "level": 2, "links": [] }, { "kind": "text", - "text": "`AnimationEdit` supports `clip.create/duplicate/remove/configure`,\n`track.create/remove/configure` and `key.put/remove`. IDs are scoped to the library,\nclip and track respectively. Duplicated clips own independent value data.", + "text": "With **Terrain Type** set to **Heightfield**, **Create Terrain** creates a saved\n128-unit terrain with 129 × 129 height samples under an Environment parent. The\nresource lives in project Assets. In **Height\nGenerator**, set **Height Amplitude**, **Noise Frequency**, and the seed, then choose\n**Generate Heightmap**. Five deterministic noise octaves create hills and valleys;\n**New Seed** changes their arrangement. Generating on an existing source keeps its\nother saved channels and creates one undoable resource version.", "level": 0, "links": [] }, { "kind": "text", - "text": "Batches are atomic. Each operation leaves a valid document; an invalid operation\nreports its ID and preserves the original library. Updating/moving an existing key\nID requires `replaceExisting:true`. Another key at the same time remains a conflict\nand must be removed explicitly. Keys use finite seconds in strictly increasing\norder within duration, typed finite values, and normalized quaternion rotations.\nCubic incoming/outgoing tangents are component derivatives per second.", + "text": "**Add region** expands a partial region before adding an adjacent 256-cell region.\nThe runtime uses 64-cell surface chunks, independent persistence regions, distance\nLOD, and bounded streaming. Old heightfield Terrain components remain readable.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Native temporal curves", + "text": "Sculpt in the viewport", "level": 2, "links": [] }, { "kind": "text", - "text": "Key `curve` metadata adds easing to the outgoing segment. Families are linear,\nquadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic and\nbounce; directions are `in`, `out`, `inOut` and `outIn`. Back exposes overshoot;\nelastic exposes amplitude and period. Overshoot is retained rather than clamped.", + "text": "Choose **Raise**, **Lower**, **Smooth**, or **Flatten**. On Mesh Terrain, Smooth\nfollows connected triangles on the selected surface. Flatten uses the plane at\nthe start of the stroke. The Heightfield workflow also offers presets for ramps,\nterraces, noise, erosion and brush shapes. Radius and strength are shared\ncontrols. Hold the left button and drag over visible terrain. A blue\nring follows its surface. Source strokes publish worker batches **while the button\nis held**. Releasing finishes the stroke and saves one resource version; Escape\ncancels it. Each complete stroke is one ordinary scene Undo action. Undo, Redo,\nSave and reopening the scene retain the selected resource version.", "level": 0, "links": [] }, { - "kind": "code", - "text": "import AdaAnimation\n\nlet pose = SkeletalJointPose()\nlet rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix)])\nvar library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet track = AnimationLibraryTrack(id: \"move\", nodeID: \"node-0\", channel: .translation, keys: [\n .init(id: \"start\", time: 0, value: [0, 0, 0]),\n .init(id: \"end\", time: 1, value: [1, 0, 0])\n])\ntry library.apply([.init(kind: .createClip, clip: .init(id: \"move\", name: \"Move\", duration: 1, tracks: [track]))])\nvar key = track.keys[0]\nkey.curve = .init(easing: .init(family: .quadratic, direction: .in))\ntry library.apply([.init(kind: .putKey, clipID: \"move\", trackID: \"move\", key: key, replaceExisting: true)])\nlet player = try library.sample(clipID: \"move\", at: 0.5) // translation.x is 0.25\nlet reopened = try AnimationLibrary.decode(library.encodedData())", - "language": "swift", + "kind": "text", + "text": "Terrain authoring uses coherent asynchronous worker publication. Large volumetric\nregions can take longer to prepare than a simple heightfield. The previous valid\nmesh stays visible while a batch is prepared, and camera navigation remains usable.\nHeight tools edit the base heightfield; volume tools address additional strata.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Materials and texture variation", + "level": 2, "links": [] }, { "kind": "text", - "text": "For translation, scale and morph weights, `bezier` interpolation accepts per-component\n`curve.incoming` and `curve.outgoing` arrays of `AnimationBezierHandle`. Each handle\nstores a time offset in seconds and a value offset relative to its key. Missing\nhandles default to one third of the straight segment. They are distinct from\nHermite's `incoming`/`outgoing` derivatives on the key itself. Sampling first inverts\nmonotone Bézier time, applies easing to that parameter and then evaluates the value.\nReversing temporal curves, invalid dimensions, non-finite values and unsafe Float\nranges fail before replacing the document. Flat and crossing temporal controls are\nsupported when their full time polynomial remains monotone.", + "text": "In **Materials**, choose a card to paint its layer. **Add Material…** opens the\nsystem file picker and imports a PNG or JPEG as a new layer, with a thumbnail.\nImported textures are copied into the terrain resource directory. Terrain layers\nhave stable IDs; choosing another card does not renumber saved paint.", "level": 0, "links": [] }, { "kind": "text", - "text": "Quaternion linear tracks use shortest-path slerp with eased progress. Imported cubic\nquaternion tracks retain their normalized component interpolation; easing remaps\ntheir segment parameter. Arbitrary scalar Bézier handles are rejected on rotations.\nUnedited linear/step/imported cubic tracks retain their existing evaluator.", + "text": "**Automatic Grass / Rock / Snow** initializes height and slope rules. Grass covers\nlower gentle ground, rock covers steeper surfaces, and snow covers higher ground.\nExisting layers are retained when applying variation settings again. Painted\nmaterial overrides these rules; **Erase material paint** reveals them again.", "level": 0, "links": [] }, { "kind": "text", - "text": "Edits and extraction promote native curve data to `.animlib` schema **2**. Legacy\nlibraries without that data remain schema 1; old readers must reject schema 2 rather\nthan silently remove its curves. A library permits at most 500,000 compiled native\nscalar segments in addition to its existing key/byte limits. A sparse easing edit\nonly compiles the changed segments; Bézier tracks compile every scalar segment.\nThe same key payload goes through `key.put` from native Inspector, agents and Tools,\nincluding resource revisions, atomic rejection, history and explicit Save.", + "text": "On Mesh Terrain, select its entity in the hierarchy before painting. Each stroke\nfollows the connected side under the brush. Paint on the ground stays in place;\npaint on a joined rock or root follows the source when it moves. If that part of\nthe surface disappears, the stroke stays saved and can apply again when a\nmatching surface returns. Erasing reveals the current contact and automatic\nlayers. Paint and erase each create one Undo step and leave the mesh shape alone.", "level": 0, "links": [] }, { "kind": "text", - "text": "Load `AnimationLibraryResource` through AssetsManager or decode its JSON, then\nprovide libraries to `Model3DSource.instantiate(_:libraries:in:under:)`. The source's\n`animationLibraries` references and loaded libraries must match in count/order.\nDistinct library IDs, compatible bindings, imported/authored name collisions and\nthe complete graph validate before runtime entities are allocated.", + "text": "New texture layers use deterministic flips, quarter-turn rotations, random offsets,\nmacro variation and triplanar projection. Terrain-local coordinates keep variation\nstable across chunk boundaries and camera movement. Volume material fields evaluate\nactual surface height and normal, including multiple strata at the same X/Z.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Mesh instancing and solids", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Select the separate **Mesh Instancing** button, then **Add Mesh…** to import a\nself-contained GLB or VOX. Choose its palette entry, density and brush radius.\n**Paint meshes** and **Erase meshes** edit persistent instance records. Copies share\nmesh buffers rather than becoming thousands of authored hierarchy entities.\nDecorative instances do not create per-instance physics bodies by default.", "level": 0, "links": [] }, { "kind": "text", - "text": "Use `library.runtimeName(for: clipID)` for a single animation or an existing\n`AnimationGraph` clip reference. Display names may repeat; qualified names encode\nlibrary/clip identity. Native compiled tracks retain the existing mixer, crossfade,\nrepeat/one-shot clock and skin-palette behavior. Each player owns mutable poses.", + "text": "On Mesh Terrain, **Paint meshes** records where to generate the selected\nprototype. **Erase meshes** clears its generated coverage without deleting\nmanual placements or pinned overrides. A pin that loses its supporting surface\nis retained in the resource for recovery. If a joined object moves, the ground\nit uncovers can grow the painted vegetation again. The area around its new\nposition clears according to the rule's exclusion distance.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "**Add solid** and **Subtract solid** use a sphere, box, capsule or a registered\npalette mesh as an ordered volume operation. Position it with the brush and release\nto apply. Caves and overhangs are real generated surfaces, with matching runtime\ncollision. Mesh operands must be supported closed static solids. Self-contained\nmodels avoid silently omitting external model dependencies. Invalid resources keep\nthe last valid terrain generation.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Studio and automation", + "text": "Saved sources and compatibility", "level": 2, "links": [] }, { "kind": "text", - "text": "Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract a copy or\ncreate a rest-pose clip, select bone/node tracks and edit keys. The model viewport\nshows the animated mesh and bones. **Save and Bind** explicitly saves the resource\nand performs a separate undoable scene transaction. The graph picker includes\nbound saved clips. Inspector edits easing and per-component Bézier offsets; Curves\nplots the production evaluator. Additional tangent modes, canvas handle dragging,\nstate machines/blend spaces, paths and native 2D spline deformation remain later work.\nProcedural 3D IK is described below.", + "text": "An entity's **Terrain Source** component references a project `.aterrain` resource,\nfor example `@res://Terrain/playground.aterrain`. Keep its immutable region, mesh\ndocument, model, texture and volume files together. The editing runtime resolves absolute paths;\nscene references stay portable. Missing or outside-project terrain roots are rejected.", "level": 0, "links": [] }, { "kind": "text", - "text": "The editor agent calls `editor.animation.` with a `requestJSON` string\ncontaining `AnimationAuthoringRequest`. Studio API 2 project Tools use\n`editor.animations.submit(requestMap)` and `poll(jobID)`. All clients share the same\nproject drafts/history. Inspect first for stable IDs and `revision`; writes require\n`expectedRevision`. Binding also needs an open scene revision and saved resource.\nDrafts require explicit Save. Reload/Discard recover external-file conflicts.", + "text": "Strokes operate on private same-directory manifest versions and publish their URI\nthrough scene history. Earlier resources remain exact for Undo; unpublished manifests\nare removed only after active filesystem writers finish. Shared payloads are never\ndeleted by gesture cancellation. Intermediate versions can therefore accumulate.", "level": 0, "links": [] }, { "kind": "text", - "text": "Requests support 64 KiB, responses 1 MiB, pages 256 items and batches 256 operations.\nLibraries/model imports support 32 MiB; a library allows 16,384 nodes, 256 clips,\n4,096 tracks per clip, 100,000 keys and 1,024 morph targets per node. IDs are limited\nto 256 UTF-8 bytes, names to 1,024, source paths to 4,096 and fingerprints to 256.\nLarge morph-key pages may need smaller limits. CPU samples and preview\nacknowledgements do not establish pixels: verify the viewport and visible Play.", + "text": "The edit viewport omits source collision cooking; authored collision settings and\nPlay retain runtime physics. A nil streaming radius retains full-declared-set\nresidency, while newly added generic Source components use the engine's camera\nstreaming default. The old Terrain component still supports its four legacy brushes.\nAdvanced presets and saved palettes belong to Terrain Source resources.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native Windows is exercised during development. Other platform acceptance and full\nportable export validation remain separate checks; local editor tests do not prove\nthose platforms. Three-dimensional manual paint storage and volumetric curvature\nrules are not yet equivalent to the heightfield paint channels.", + "level": 0, + "links": [] + } + ] + }, + { + "id": "Terrain", + "title": "Terrain", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/3D/Terrain.md", + "blocks": [ + { + "kind": "text", + "text": "Create editable ground with `Terrain3D`. Its heights describe one square surface\nin the entity's local X/Z plane. `Model3DPlugin` generates the visible geometry;\n`Physics3DPlugin` adds matching static triangle collision.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Save and bind from Swift", + "text": "Load Mesh Terrain", "level": 2, "links": [] }, { "kind": "text", - "text": "Given a model compatible with the library's complete rig and an existing owner in\nits world, explicitly save the resource and instantiate an authored clip:", + "text": "Mesh Terrain uses a `Terrain3DSource` whose `.aterrain` manifest references an\n`.atmesh` document. Keep the manifest, its document, and its relative resources\ntogether. The document retains the source sections and ordered modifiers. Its\ncompiled product contains the triangles and placement records that the runtime\ncan use without the desktop authoring kernel.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "With `MainSchedulerPlugin`, `TransformPlugin`, `RenderWorldPlugin`, and\n`Model3DPlugin` installed, pass the resolved manifest path to the source:", "level": 0, "links": [] }, { "kind": "code", - "text": "import AdaEngine\nimport Foundation\n\nfunc saveAndBind(_ library: AnimationLibrary, resourceURL: URL, resourceReference: String,\n model: ModelAsset3D, world: World, owner: Entity, clipID: String) throws {\n try FileManager.default.createDirectory(at: resourceURL.deletingLastPathComponent(), withIntermediateDirectories: true)\n try library.encodedData().write(to: resourceURL, options: .atomic)\n let reopened = try AnimationLibrary.decode(Data(contentsOf: resourceURL))\n let source = Model3DSource(source: reopened.binding.sourceAsset,\n animation: reopened.runtimeName(for: clipID), autoplay: true,\n animationLibraries: [resourceReference])\n _ = try source.instantiate(model, libraries: [reopened], in: world, under: owner)\n}", + "text": "let terrain = world.spawn(\"Mesh Terrain\") {\n Transform()\n Terrain3DSource(source: manifestPath, generatesCollision: false, queries: .init())\n}", "language": "swift", "links": [] }, { "kind": "text", - "text": "Use a project-owned resource URL and its portable `@res://Animations/Hero.animlib`\nreference. For an imported model, derive the binding from\n`GLTFAnimationAdapter.makeRig(nodes:meshes:)` using that model's native import data,\nthen extract its compatible clip. The synthetic Root rig above is only for a model\nwith that same rig; incompatible models fail explicitly. Studio Tools can follow\nthe complete bone-key, sample, Save and Bind workflow in the AnimationHelper example\nlinked from the Studio guide. Native Tools bound expanded request conversion to\n8,192 values and 64 KiB of UTF-8 strings, and restricted imports preflight repeated\ndecoded accessor work before allocation.", + "text": "Enable `generatesCollision` and install `Physics3DPlugin` if you need terrain\ncollision. When the source is ready, read `MeshTerrain3DSourceSurface` on the\nauthored entity. The optional `queries` value supports terrain-local `raycast`\nand `closest` queries against the accepted triangles, including surfaces that do\nnot fit a single height at each X/Z position. Convert world coordinates into the\nterrain's local coordinates before querying. A retained query value continues to\ndescribe its original surface after a newer version is published.", "level": 0, "links": [] }, { "kind": "text", - "text": "Native hosts can construct a preview with `ModelAsset3D(gltfAt:loader:)`, supplying\n`NativeGLTFLoader(resourceRoot:maximumTotalBytes:)` for the full model import. Studio's\nlibrary viewport uses this scoped loader; it does not change the global resolver.", + "text": "When building authoring tools on desktop, enable the `MeshTerrainAuthoring`\npackage trait and check `TriangleMeshBoolean.isAvailable` before compiling a\ndocument. `MeshTerrainDocumentAsset.compiling()` produces an immutable asset\nwith an input key covering its geometry and population rules. Recompile changed\ninputs before distributing them; the runtime rejects a missing or mismatched\ncompiled product and retains the last valid surface after a failed update.", "level": 0, "links": [] }, { "kind": "text", - "text": "Studio's shared request envelope also carries `graphInspect`, `graphApply`,\n`graphUndo`, `graphRedo`, `graphPreview` and `graphBind`. These address an open\nmodel/library workspace and use its graph revision rather than the clip-library\nrevision. `graphApply` replaces a detached `AnimationGraph` in the same canvas\nhistory. Binding validates the complete destination rig and saved libraries,\nthen commits one scene-history edit. See the Studio guide and AnimationHelper\nexample for request maps, separate permissions, budgets and explicit scene Save.", + "text": "A Mesh Terrain manifest can use the shared `TerrainMaterialPalette`. Set a join\nmodifier's `materialLayer` to an existing palette ID. The operand uses that\nlayer and `contactWidth` controls how far its material blends along connected\nground, independently of optional geometry rounding. Subtraction uses the\ninterior layer by default. The renderer stores material fields per triangle;\npalette changes do not change the geometry used by queries or collision.", "level": 0, "links": [] }, { - "kind": "heading", - "text": "Procedural IK after animation", - "level": 2, + "kind": "text", + "text": "The source prepares one optional coarser meshlet level for eligible display\nsections within its geometry budget. The renderer selects it by projected error,\nusing a two-pixel threshold. Simplification preserves section boundaries and\nretains full detail where triangle material fields vary. Shaded and Wireframe\nuse the same selected index ranges. Set `maximumLOD` to zero to keep full\ndetail. Terrain queries and collision use the full accepted surface.", + "level": 0, "links": [] }, { "kind": "text", - "text": "See Animation IK and pose modifiers for ordered two-bone/FABRIK constraints, model-space targets,\ncurrent scene marker bindings, `.animmod` resources and the shared Tool/agent API.", + "text": "An opaque join can retain per-part source materials and vertex attributes in the\ntriangle document. Portable source maps are read from their self-contained\nglTF or GLB resource under `@res://`; keep that resource when moving or packaging\nthe terrain. The display uses source UV sets on retained source faces and terrain\nprojection for the palette and cut interior. Transparent or custom shader parts\nstay on the original model.", "level": 0, - "links": [ - { - "title": "Animation IK and pose modifiers", - "destination": "AnimationIK" - } - ] - } - ] - }, - { - "id": "AnimationIK", - "title": "Animation IK and pose modifiers", - "section": "AdaEngine", - "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md", - "blocks": [ + "links": [] + }, { "kind": "text", - "text": "Constrain a skeletal chain after its clip, Animation Graph and visual LOD pose have been mixed.", + "text": "Author material strokes in `MeshTerrainDocument.paint`. Capture a\n`MeshTerrainSurfaceAnchor` from the selected triangle hit: ground anchors are\nsection-local and joined-source anchors are operand-local. Compilation\nreprojects each stroke onto a compatible connected side and stores its material\ncoverage alongside the compiled patch. The player loads that coverage without\nreplaying brushes. Missing surfaces leave recoverable IDs in\n`MeshTerrainCompiledProduct.paintOrphans`; the authored strokes stay in the\ndocument. Erase removes authored coverage so the current rule and contact\nmaterials can show through.", "level": 0, "links": [] }, { "kind": "text", - "text": "IK belongs to each playback instance. Editing a target does not rewrite the imported GLB or\nbake corrections into an animation library. An ordered stack supports two-bone IK and FABRIK.", + "text": "A population rule can also retain `MeshTerrainPopulationCoverage` stamps.\nThese stamps limit where the selected prototype is generated and can erase\nprevious coverage. Their anchors preserve the chosen side and source.\nCompilation re-evaluates source clearances after a join moves and includes the\nresulting placements in the same accepted generation. The player groups the\nrecords into three-dimensional cells using the rule's cell size and shares\nprototype geometry across those cells. Authoring reuses previously accepted\ncells when the surface, masks and source clearance in their spacing halo\nstay the same. Compiled products retain unsupported pins as recoverable\n`orphanedPins`.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Use Mesh Terrain authoring to publish new `.atmesh` generations.\n`AssetsManager.saveTerrainRegions` edits height and volume sources and rejects\nMesh Terrain manifests, including an outdated heightfield manifest aimed at\nthe same destination.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Solve an editable chain", + "text": "Create a heightfield", "level": 2, "links": [] }, { "kind": "text", - "text": "This complete example constructs two unit segments and moves their endpoint. For an imported\nmodel, use its full rig binding; skin-palette indices are not stable bone IDs.", + "text": "Install `MainSchedulerPlugin`, `TransformPlugin`, `RenderWorldPlugin` and\n`Model3DPlugin` in your app. Add `Physics3DPlugin` when the ground must block\nphysics bodies. Create the entity in its main world:", "level": 0, "links": [] }, { "kind": "code", - "text": "import AdaAnimation\nimport Math\n\nlet poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]),\n SkeletalJointPose(translation: [1, 0, 0])]\nlet rig = try SkeletalRig(nodes: poses.indices.map { index in\n .init(parentIndex: index == 0 ? nil : index - 1,\n restPose: poses[index], restMatrix: poses[index].matrix)\n})\nlet binding = AnimationRigBinding(rig: rig, sourceAsset: \"Assets/Robot.glb\")\nvar player = try SkeletalAnimationPlayer(rig: rig, clips: [])\nlet arm = AnimationPoseModifier(id: \"arm\", kind: .twoBone,\n joints: [\"node-0\", \"node-1\", \"node-2\"], target: [1, 1, 0], pole: [0, 0, 1])\ntry player.setPoseModifiers([arm], binding: binding)\nassert((player.globalTransforms[2].origin - Vector3(1, 1, 0)).length < 0.0002)\n\n// Transient scene input does not change the saved stack or advance the clip clock.\nplayer.finalizePoseModifiers(inputs: [\"arm\": .init(target: [0, 2, 0])])\nassert(player.basePoses == poses)", + "text": "import AdaEngine\n\n@MainActor\nfunc createGround(in world: World) {\n var terrain = Terrain3D(resolution: 129, size: 128)\n terrain.heights[10 * terrain.resolution + 10] = 3\n terrain.color = [0.28, 0.43, 0.18, 1]\n world.spawn(\"Ground\") {\n Transform()\n terrain\n }\n}", "language": "swift", "links": [] }, { "kind": "text", - "text": "`setPoseModifiers` validates before replacing the stack. Chains are contiguous and have\neditable TRS and nonzero segments. Two-bone IK requires exactly three joints. FABRIK accepts\n1–64 segments, 1–64 iterations (default 16) and a positive tolerance. Its automatic tolerance\nis `max(1e-5, length * 1e-4)`. Influence is in `0...1`; coordinates must be finite. A stack has\nat most 32 modifiers and 32,768 compiled segment-iterations. Neither solver stretches bones.", + "text": "`resolution` is the number of height samples along each axis; there is one fewer\ncell. Heights are row-major along Z. The example creates a 128-unit square centered\non the origin, with one raised sample. Keep heights finite and preserve the\n`resolution * resolution` array length. `validate()` rejects malformed data.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Separate base and final poses", + "text": "Tile geometry and updates", "level": 2, "links": [] }, { "kind": "text", - "text": "`basePoses` contains animation/graph/LOD output. `poses`, local/global transforms, scene\nbones and skin palettes contain the final constrained result. Repeated finalization starts\nfrom the same base pose, without accumulating corrections. Crossfade sources and LOD\nendpoints also contain base poses. Moving targets and paused clips still finalize active IK;\ntarget movement does not emit animation markers or advance playback clocks.", + "text": "The runtime divides the patch into tiles of at most 64 × 64 cells. The example\nabove produces four tiles. Tiles are generated children of the authored entity,\nmarked by `TerrainTile3DComponent`; save the `Terrain3D` component rather than\nthese transient meshes or colliders. Generated entities carry `TransientEntity`;\nWorld/Scene serialization omits them and their descendants, and terrain regenerates\nthem when the scene initializes. Authored parent/child relationships and local\ntransforms survive the round trip; generated child IDs are omitted from saved\nrelationships. The raw ECS hierarchy is otherwise unchanged: even\nwhen its owner is removed nonrecursively, terrain cleans its generated mesh and\ncollision children while ordinary authored children remain.", "level": 0, "links": [] }, { "kind": "text", - "text": "Modifiers execute in saved order, each observing the previous influenced result. Influence\nblends local rotations once. Local translation, scale and the end joint's authored local\nrotation remain intact; its world orientation is not independently locked. Rigid transforms\nand positive uniform scale are supported. An unsupported participating hierarchy skips that\nmodifier; unrelated nonuniform branches do not disable valid chains.", + "text": "Changing heights replaces the affected tile meshes and their collision. The\nnormal's neighbor samples also invalidate adjoining tiles when needed, preserving\ncontinuous lighting at their borders. An edit at a shared corner can update four\ntiles. Changing the ground color retains the geometry and updates the shared\nmaterial; moving the terrain retains meshes and updates world-space collision.\nParent rotation, scale and reflected transforms are included in collision.", "level": 0, "links": [] }, { "kind": "text", - "text": "Read `modifierDiagnostics` for each stable modifier ID. `solverError` measures the\nfull-strength solution; `reachError` measures the published endpoint after influence.\nUnreachable targets and iteration exhaustion can produce valid approximate results. Missing\ntargets skip their modifier instead of holding an old solved frame. CPU samples do not prove\nrendered output.", + "text": "For custom tools, `meshTiles(cellCount:)` returns CPU mesh descriptors with stable\n`TerrainTileCoordinate` addresses. `meshTile(at:cellCount:)` builds one tile directly.\nPositions and UVs remain in the parent terrain's coordinates. Partial edge tiles\npreserve the original cell count; cell counts outside `1...256` throw a validation\nerror. Runtime tile size uses `Terrain3D.defaultTileCellCount`.", "level": 0, "links": [] }, { "kind": "heading", - "text": "Resources and scene bindings", + "text": "Queries and persistence", "level": 2, "links": [] }, { "kind": "text", - "text": "`AnimationModifierStack` stores a versioned full-rig snapshot and an ordered list. Bounded\nJSON uses `.animmod`; `AnimationModifierResource` supplies normal asset codecs. Unknown\nversions and changed rigs are rejected. `Model3DSource.animationModifiers` loads the stack\nbefore replacing a live model. Scene overrides remain in `Model3DSource.modifierBindings`.", + "text": "`surfaceHeight(at:)` takes a terrain-local X/Z position as a `Vector2`. It returns\nthe height on the same triangles used by rendering and collision, or `nil` outside\nthe patch. Transform a world point into terrain-local coordinates before querying.", "level": 0, "links": [] }, { "kind": "text", - "text": "An `AnimationIKTarget3D` is a model-space point or a stable scene entity ID with a local\noffset. Studio assigns `AnimationSceneIdentity3D` to authored entities. Programmatic scenes\nassign a shared namespace to the model owner and its ordinary target markers. Namespaces\nisolate loaded scene instances; runtime Entity.ID values are never persisted as targets.", + "text": "The Codable component retains heights, size, color and collision settings through\nscene loading and native export. Set `generatesCollision` to `false` to remove\ngenerated colliders. Removing the component removes generated tile geometry and\ncollision. Geometry is rebuilt from authored data after reopening a scene.", "level": 0, "links": [] }, + { + "kind": "heading", + "text": "Sparse height data", + "level": 2, + "links": [] + }, { "kind": "text", - "text": "Owners and marker offsets resolve from one current local-transform snapshot before applying\nany stack. A marker beneath any animated rig is rejected, including another instance.", + "text": "Terrain manifests can retain a `TerrainMaterialPalette` with up to 32 named\n`TerrainLayerReference` entries and 128 automatic `TerrainMaterialRule` entries.\nEach layer has a stable `UInt16` ID: changing its display order preserves rule\nreferences. Choose existing IDs for the fallback surface and volume interior.\nTexture paths are relative to the manifest directory. Albedo, normal, packed\nocclusion/roughness/metallic and blend-height maps are optional; scale is measured\nin terrain-local units per repetition. Height and curvature ranges use local units,\nand slope ranges use degrees from the terrain's up axis.", "level": 0, "links": [] }, { - "kind": "heading", - "text": "Studio, agents and developer Tools", - "level": 2, + "kind": "code", + "text": "let palette = TerrainMaterialPalette(\n layers: [\n TerrainLayerReference(id: 7, name: \"Grass\", albedoPath: \"Textures/grass.png\", scale: 2),\n TerrainLayerReference(id: 12, name: \"Rock\", albedoPath: \"Textures/rock.png\", projection: .triplanar)\n ],\n fallbackLayerID: 7,\n interiorLayerID: 12,\n rules: [TerrainMaterialRule(layerID: 12, slope: TerrainMaterialRange(minimum: 40, maximum: 90, transition: 8))]\n)\nlet layout = TerrainManifestAsset(materials: palette)\ntry layout.validate()", + "language": "swift", "links": [] }, { "kind": "text", - "text": "Open a GLB/glTF or `.animmod`. **Modifiers** is in the resizable Model panel. Select a\ncontiguous chain, then add **Two-bone IK** or **FABRIK**. The Inspector owns chain, target,\npole, influence and solver settings. Blue target and purple pole handles preview a drag;\nrelease creates one Undo entry and Escape cancels. Save explicitly before binding to a scene.", + "text": "`validate()` rejects duplicate IDs, missing rule/fallback references, invalid\nranges and escaping texture paths. Both native region-save modes preserve this\npalette. A manifest containing materials uses version 3; height-only and model-\npalette manifests keep versions 1 and 2 respectively.", "level": 0, "links": [] }, { "kind": "text", - "text": "Agents call `editor.animation.modifierInspect/Create/Apply/Sample/Save/Undo/Redo/Preview/Bind/Reload/Discard`\n(each suffix is a separate action, for example `editor.animation.modifierApply`). API 2 Tools\nsubmit the same `AnimationAuthoringRequest` through `editor.animations.submit` and poll\nasynchronously. Edits use the inspected `expectedRevision`; binding also requires a current\n`sceneRevision` and a saved compatible stack. Standard animation read/write, preview and\ndocument-write permissions apply. See Authoring skeletal animation for job limits and transport.", + "text": "Source rendering resolves rules into four canonical layer IDs and weights per\nsample, then interpolates those fields independently of visual LOD. The strongest\nfour contributions shade layer tints, roughness and metallic values through the\nshared PBR lighting pipeline. `Terrain3DSource.color` multiplies the palette tint;\nuse `.one` to preserve its authored colors. Color-only edits retain tile meshes\nand material fields. Removing the palette on reload restores the ordinary source\nmaterial. Albedo maps load relative to the manifest into shared mipmapped arrays;\nlayer scale controls their repetitions. Preparation caps normalized dimensions at\n256 pixels, bounds encoded/decoded/staging memory before allocation, and reports\nmissing or incompatible images in `Terrain3DSourceStatus.warnings` while preserving\na usable fallback. Tangent-space normal maps share the existing PBR normal frame.\nPacked ORM maps use linear R/G/B for occlusion, roughness and metallic; G/B multiply\nthe layer factors. Missing maps preserve those factors. Triplanar projection samples\nterrain-local ZY/XZ/XY planes and blends by the local surface normal, reducing\nstretching on cliffs. Its normal-map blend preserves the geometric baseline,\nincluding ridges and mixed planar/triplanar layers under nonuniform owner scale.\nOptional `blendHeightPath` maps use linear red-channel heights from black (low)\nto white (high). Their bounded influence favors raised details in material\ntransitions while preserving equal-height proportions and fading layers smoothly\nto zero. Height samples share all UV flips, rotations, mip filtering and projection\nwith the other maps. Missing or invalid height maps warn and remain neutral;\nwithout height maps, the ordinary automatic-rule blend stays unchanged.", "level": 0, - "links": [ - { - "title": "Authoring skeletal animation", - "destination": "AnimationAuthoring" - } - ] + "links": [] }, { "kind": "text", - "text": "This article describes 3D skeletal IK. Native 2D weighted deformation, continuous-turn angles,\nspline-chain authoring and typed timeline bindings for modifier parameters remain separate\nimplementation stages. A 3D numerical solve does not establish those workflows.", + "text": "`TerrainLayerReference.variation` stores opt-in `TerrainTextureVariation` controls:\na full `UInt64` seed, U/V flips, quarter-turns, offsets, variation cell size and\nlow-frequency macro strength/scale. Missing settings default to disabled. Cell\nsize accepts 0.25...64 texture repetitions and macro strength accepts 0...1;\nmacro scale is positive terrain-local units. Both native save modes preserve these\nsettings. Enable variation to randomize flips, quarter-turns and offsets from the\nterrain-local coordinates, stable layer ID and seed. Neighbor samples blend\nsmoothly; the same transforms apply to all maps, gradients and tangent normals.\nMoving the owner and camera together preserves the pattern. Macro variation adds\nseeded low-frequency albedo modulation; disabled variation preserves the ordinary\nsingle-sample texture path. Scales below the shader coordinate range use filtered\nfallbacks: unresolved layer sampling reads the coarsest prepared mip, while\nunresolved macro modulation averages to its neutral value. Material-field uploads\nrequire a finite reciprocal sample spacing; an unsupported spacing reports a load\nerror before publication and retains the previous working generation.", "level": 0, "links": [] - } - ] - }, - { - "id": "ImageDecoders", - "title": "Loading images and adding formats", - "section": "AdaEngine", - "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/ImageDecoders.md", - "blocks": [ + }, { "kind": "text", - "text": "Load PNG, JPEG (`jpg` or `jpeg`), BMP and TGA images as CPU pixels using\n`Image`, or load a GPU texture through `Texture2D`:", + "text": "`TerrainHeightStorage` is a separate data API for sparse regions. It uses signed\nregion coordinates, 256 cells per region and 64 cells per tile. Allocate a surface\nexplicitly with `allocateRegion(at:)`, then edit its canonical samples with\n`setHeight(_:at:)`. Missing surfaces return `nil` from height queries. The default\n64 MiB budget covers allocated height pages; mesh snapshots and GPU resources need\nseparate budgets. Flat regions do not allocate height pages until a nonzero edit.", "level": 0, "links": [] }, { - "kind": "code", - "text": "import AdaEngine\n\nlet image = try await AssetsManager.load(Image.self, at: \"@res://Textures/ground.tga\")\nlet texture = try await AssetsManager.load(Texture2D.self, at: \"@res://Textures/ground.tga\")", - "language": "swift", + "kind": "text", + "text": "Use `snapshot(of:)` to obtain immutable tile geometry and `tileRevision(at:)` to\ndetect affected tiles. Shared border samples have one owner. Normals sample across\nborders, so an edit can invalidate adjacent tiles. `takeDirtyTiles(limit:)` drains\na bounded batch from a single-consumer queue; independent consumers should inspect\nthe storage and tile revisions. The queue holds at most 65,536 distinct pending\naddresses. Removed-tile history can exceed that bound even when no terrain remains.\nCheck `dirtyOverflowGeneration` before and after draining: if it changed since your\nlast successful synchronization, discard your derived tile set and rebuild it from\n`tileCoordinates`. An overflow discards earlier queue entries; height data remain\nunchanged. Source-managed runtime data use revisions rather than this FIFO. `init(importing:)` preserves a legacy patch's\npositions, sample spacing and partial edge tiles.", + "level": 0, "links": [] }, { "kind": "text", - "text": "`Image(contentsOf:)`, `Image.decode(from:)` and `AssetsManager` use the same\ndecoder registry. File extensions are case insensitive and act as hints;\nthe encoded bytes identify the format. Built-in decoders produce RGBA8 pixels.\nTGA decoding includes indexed colors, RLE, alpha and top- or bottom-origin rows.", + "text": "`TerrainRegionAsset` stores independently loadable height pages in `.atregion`\nfiles. Version 1 starts with the eight-byte signature `ADATRN01`, signed little-endian\n32-bit region X/Z coordinates, and two unsigned 32-bit counts. Up to 16 four-byte\ntile records contain local X/Z and cell counts. Up to 16 page records contain local\nX/Z, two zero reserved bytes and 4,096 little-endian R32F samples. Pages own samples\nby integer grid address rather than duplicating neighbors' borders. The positive\ngrid boundary permits data-only pages. Decoding rejects unsupported versions,\ninvalid dimensions or addresses, duplicate records, nonfinite heights and mismatched\nbyte lengths. `encodedData()` validates in-memory records before encoding.", "level": 0, "links": [] }, { - "kind": "heading", - "text": "Registering another raster format", - "level": 2, + "kind": "text", + "text": "Region assets with artist-authored surface channels use `ADATRN03`. Sparse\n`surface` records retain up to four stable layer IDs and normalized weights,\nan explicit `overrideStrength`, tint and roughness overrides. `ruleMasks` store\nnonzero values by mask ID; absence means zero. Values use little-endian Float32\nwithout requantization on save. These optional sections coexist with heights and\nplacements; files without surface channels retain their earlier encoding. The\ncodec rejects duplicate coordinates/IDs, invalid weights, reserved bytes, unbounded\ncounts and payloads above 8 MiB before allocating record arrays. These data types\nprovide persistence. A material-palette source resolves saved rule masks and layer\npaint into its full-resolution fields. Manual weights blend with the automatic\nresult by `overrideStrength`; zero override reveals the rules. Active painted IDs\nmust exist in the palette or publication fails with a diagnostic. `tintStrength`\nmixes white toward the saved RGB tint, then multiplies the resolved layer color.\n`roughnessStrength` blends the resolved roughness toward the saved value. These\ncanonical fields retain their full resolution across visual LOD changes; unpainted\ntiles share a neutral fallback. Runtime edits and brushes use the atomic authoring path\ndescribed below; native Studio controls remain in development.\n`TerrainRegionCache.Limits.maximumResidentSurfaceBytes` separately bounds decoded\npaint/mask records (32 MiB by default); it does not consume the height-page or\nplacement allowances. Canonical `TerrainHeightStorage` has its own\n`surfaceMemoryBudgetBytes` allowance (32 MiB by default), conservatively charging\nsparse hash tables and COW staging. `statistics.residentSurfaceBytes` reports the cache charge,\nand eviction releases that allowance. A rejected load retains the resident generation.\nCanonical pages also retain exact tint/roughness coverage metadata, so moving the\ncamera or changing LOD does not repeatedly walk unchanged layer-paint records.", + "level": 0, "links": [] }, { "kind": "text", - "text": "Implement `ImageDecoder` when another file format should produce an ordinary\n`Image`. This example defines a tiny custom format: four signature bytes,\none byte each for width and height, then RGBA8 pixels in row-major order.", + "text": "Shared palette images initialize over multiple updates, submitting at most 1 MiB\nper source per update. Array slices and mip levels stay private until the complete\npalette is ready; a replacement retains the previously published surface meanwhile.\nTile meshes, canonical material fields and collision preparation use the separate\n`maximumTileUpdatesPerFrame` allowance. Completed arrays are shared across tiles\nand retained across idle frames and visual LOD changes.", "level": 0, "links": [] }, { - "kind": "code", - "text": "import AdaEngine\nimport Foundation\n\nstruct RawRGBAImageDecoder: ImageDecoder {\n let supportedExtensions = [\"rawrgba\"]\n\n func canDecode(_ data: Data) -> Bool {\n data.starts(with: [65, 68, 65, 73]) // \"ADAI\"\n }\n\n func decode(_ data: Data) throws -> Image {\n let bytes = Array(data)\n guard canDecode(data), bytes.count >= 6 else {\n throw ImageDecodingError.invalidData\n }\n let width = Int(bytes[4])\n let height = Int(bytes[5])\n guard width > 0, height > 0, bytes.count == 6 + width * height * 4 else {\n throw ImageDecodingError.invalidData\n }\n return Image(width: width, height: height, data: Data(bytes.dropFirst(6)))\n }\n}\n\nImage.registerDecoder(RawRGBAImageDecoder())\nlet image = try await AssetsManager.load(Image.self, at: \"@res://Sprites/player.rawrgba\")", - "language": "swift", + "kind": "text", + "text": "Terrain layer shading uses the raster PBR pipeline. When a view requests the\nexperimental portable path tracer, layered terrain selects the existing raster\nfallback for that view and records the reason in `Render3DPathTracingStatistics`.\nIts layer colors, maps and manual fields remain visible. Ordinary PBR meshes and\nterrain without a layer palette keep their existing tracing behavior.", + "level": 0, "links": [] }, { "kind": "text", - "text": "Register before loading assets or building the editor's project tree. The editor\nclassifies image files using `Image.extensions()`, which reflects the registry.\nExtensions are returned lowercase and without duplicates.", + "text": "For a region-file round trip, enumerate `resourceCoordinates` and call\n`regionAsset(at:)` for each address, then encode its returned asset. This list\nincludes canonical data-only regions that `regionCoordinates` omits. Keep them\nwhen saving: a shared border may be stored there. Restore a set of loaded\n`TerrainRegionAsset` values with `TerrainHeightStorage(regions:spacing:origin:)`,\nusing the original spacing and origin. Restore validates every payload, rejects\nduplicate region addresses and checks the total resident height-page budget.\nIt preserves partial tiles and queues their initial geometry generation.\nCapturing individual regions during concurrent edits does not form an atomic\nwhole-terrain save; stop edits while collecting that set.", "level": 0, "links": [] }, { "kind": "text", - "text": "Registry access is synchronized. Decoder callbacks execute outside the lock\nand may run concurrently, so implementations must protect their own mutable state.\nNewer registrations take precedence among matching decoders. Registering the\nsame concrete decoder type replaces its previous instance; already cached assets\nare unaffected. A decoder's error propagates once it recognizes the input.", + "text": "`TerrainManifestAsset` describes spacing, `originX`, `originZ` and a table of\n`TerrainRegionReference` values in a portable `.aterrain` JSON resource. Each entry\ncontains signed X/Z coordinates and `fileName`, a basename beside the manifest.\nVersion 1 accepts at most 16,384 entries and 4 MiB of encoded input. File names must\nstart with an ASCII letter or digit, use letters, digits, dots, hyphens or underscores,\nend in `.atregion` and fit within 128 bytes. Paths, URI references and percent escapes\nare rejected. Region addresses and file names must be unique; filename comparisons\nignore case for portability. Unknown versions and invalid spacing/origins throw.\nThe decoder checks the table count before constructing its reference array.", "level": 0, "links": [] }, { "kind": "text", - "text": "Registration adds decoding only; it does not add an image encoder. WebP, GIF,\nTIFF, HDR/EXR and KTX2 are not built-in raster decoders.", + "text": "Native tools can persist a complete immutable region set with\n`AssetsManager.saveTerrainRegions(_:manifest:at:mode:maximumStagedBytes:onProgress:)`.\nPass the previous layout manifest and all region assets. Changed regions receive\nnew, uniquely named files beside the destination `.aterrain`. Byte-identical regions\nretain their previous filenames and references after a bounded comparison; symbolic\nlinks are not reused. Capture the whole region set while edits are stopped.\nAn omitted `.aterrain` extension is appended and included in the returned asset's\nreload path. The default 128 MiB staging budget includes\nall encoded regions and the manifest. Validation and budget checks happen before\ncreating files. Progress reports completed region writes; cancellation is checked\nbetween writes and immediately before the manifest commit.\nProgress callbacks are asynchronous and may await a UI actor update. Only one save\nto a given manifest can run in a process. An overlapping call throws\n`TerrainSaveError.saveInProgress`; retry after the active operation completes.\nThe reservation is released on success, failure and cancellation.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For streamed terrain, pass `mode: .update(removing: [])` and only the changed\nregion assets. Untouched manifest references remain present without reading their\npayloads; the staging budget covers the supplied payloads and resulting manifest.\nPass exact prior `TerrainRegionReference` values in `removing` to delete regions.\nDuplicate, mismatched or simultaneously replaced removals throw\n`TerrainSaveError.invalidRemoval`. The omitted-mode default replaces the complete\nset, so callers must explicitly choose incremental mode for a partial snapshot.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Incremental saves require an existing destination matching the supplied baseline.\nThe writer checks that generation before staging and again before publication;\na mismatch throws `TerrainSaveError.staleManifest` and discards unpublished files.\nThe in-process reservation serializes engine saves to the same path. This is not a\ncross-process lock: independent tools must coordinate their own simultaneous writes.\nRetain the returned manifest as the baseline for the next save.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The writer flushes each file, stages the manifest beside its destination and\nreplaces it without deleting the previous manifest first. A failed or cancelled\noperation removes its completed unpublished files and leaves the previous manifest\nreadable. A successful commit returns its published generation even if cancellation\narrives afterward. Previous generation files stay available to existing readers;\nautomatic collection of those files is not yet provided. Use the returned manifest\nor load the destination with `AssetsManager.loadFresh` to observe the new version.\nSaving through this API is native-only; WASM consumers can load exported resources.\nThe commit uses the native same-directory rename operation on Windows and POSIX\nhosts. A focused Linux filesystem test exercises the actual codecs and writer;\ncomplete package, renderer and Studio platform validation remain separate checks.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainRegionCache.open(manifestAt:limits:)` opens an immutable manifest generation\nand loads individual regions with `loadRegion(at:)`. A coordinate absent from the\nmanifest returns `nil`; a declared resource that is missing, malformed or has the\nwrong coordinate throws. Loaded values remain resident until explicit eviction,\neven if the underlying file is later modified. This keeps a running generation\nconsistent. Evict and reload to observe a changed file.\nThe cache captures its originating app-world asset scope. A request made from\nanother world retains that scope rather than resolving the same virtual filename\nunder the requesting world's resource directory.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Cache limits default to 64 MiB of decoded Float height pages, 128 resident regions\nand four concurrent loads. Region count bounds metadata separately; encoded input\nand decoder temporaries are bounded by the resource format and load count. Returned\nvalues retained by callers, mesh snapshots and GPU resources have separate lifetimes\nand budgets. Failed admission preserves existing resident values. Use `statistics`\nto inspect resident regions, height bytes and active loads.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Pin a region with `.dirty`, `.selected`, `.transaction` or `.physics` to prevent\n`evictRegion(at:)` from releasing it. Each reason must be unpinned independently.\nThe cache provides explicit residency controls. `Terrain3DSource` uses it to stage\ncamera and physics region residency.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For render-only heightfield LOD, call `snapshot.meshLOD(level:finerEdges:)` with a\nlevel from 0 through 6. A full 64-cell tile reduces from 4,225 vertices at level 0\nto 1,089 at level 1 without stitched edges. Samples retain their canonical positions,\nnormals and UVs. Continue using the full snapshot for collision and editing queries.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Adjacent levels must differ by at most one. On the coarse tile, mark each boundary\nwith a finer neighbor using `TerrainLODEdges.minX`, `.maxX`, `.minZ` or `.maxZ`.\nThe coarse mesh explicitly includes the finer boundary samples. Center fans handle\ncorners, preserving complete upward-facing coverage without degenerate triangles\nor unmatched border vertices. Partial tiles retain level 0, and their adjoining\ntiles must also stay at full detail. This API builds geometry independently of the\nruntime camera policy.\n`TerrainLODBalancer.select(proposed:fullDetailTiles:partialTiles:)` refines proposed\nlevels until edge neighbors differ by at most one, then returns each tile's level\nand finer-neighbor mask. Requested detail is never coarsened. Full-detail pins stay\nat level 0; partial tiles also force adjoining tiles to level 0. Disconnected tiles\nretain their own proposals, and the result does not depend on dictionary ordering.\nInputs are limited to 65,536 tiles and validated grid coordinates before traversal.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "To render a saved manifest, attach `Terrain3DSource` to an entity in a world with\n`Model3DPlugin`:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "world.spawn(\"Landscape\") {\n Transform()\n Terrain3DSource(source: \"@res://Terrain/world.aterrain\")\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "The system loads nearby manifest regions within explicit limits, prepares tile\ngeometry and creates generated children. Defaults allow 64 MiB of canonical\nheight pages, 128 regions and 128 MiB of conservatively estimated mesh data.\n`streamingRadius` defaults to 384 terrain-local X/Z units around active cameras.\nSet it to `nil` to load the complete declared set within limits. With no usable\nactive cameras or physical interests, the conservative fallback also requests the\ncomplete set.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Residency includes neighboring declared resources for canonical border heights\nand normal coverage, so the region count includes this halo. Halo topology does\nnot create visible meshes or make height queries report an unloaded surface.\nChanging camera regions stages a replacement, retains unaffected tile generations\nand releases distant generated meshes while preserving the runtime storage object.\nThe manifest generation and its originating asset scope remain stable across moves.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Local edits retain region overrides across residency changes. Those data remain\npinned within the same height/count budgets; if the requested set cannot fit, the\nprevious valid surface remains available and status reports the admission error.\nNew geometry and collision are prepared before committing the staged storage.\nA revision check prevents staging from discarding an intervening author edit.\nChanging the region or height budget stages against those same live edits instead\nof reloading the original files. An accepted height-budget change updates the\nexisting storage object; a rejected change retains its last valid budget and data.\nStaging may retain both old and new page generations, each within the configured\npage budget; metadata, CPU mesh descriptors and GPU allocations have separate costs.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`Terrain3DSourceSurface.storage` exposes the loaded height storage for local edits\nand queries. The system watches tile revisions and retains unaffected mesh buffers.\nSource-owned storage uses revision tracking instead of the standalone storage's\nsingle-consumer dirty FIFO.\n`Terrain3DSourceStatus` reports readiness, generated tile count and load/build errors.\nA failed source replacement keeps the last working geometry and collision. Its\nretained collision continues to follow valid parent/owner transform changes using\nthe same incremental preparation budget. Admission failure preserves the published\nvisual LOD and mesh buffers while recovery updates changed samples and collision. Change\nthe source or increment `reloadGeneration` to retry a failed load. Local storage\nedits update the retained generation without silently adopting a repaired file;\nthe error remains visible until a successful explicit retry. Camera/transform\nupdates also preserve that failed request, including when no generation has loaded\nyet; they do not repeatedly read the same missing resource. Emptying the path\nclears generated geometry, and removing the component also clears transient state,\nincluding a pending height request and its completion status. Ordinary authored\nchildren remain; only generated terrain children are removed.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For a bounded height change, attach `Terrain3DHeightEdit` to the loaded source owner:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "@MainActor\nfunc raiseGround(_ owner: Entity) {\n guard let surface = owner.components[Terrain3DSourceSurface.self] else { return }\n let edit = Terrain3DHeightEdit(\n storage: surface.storage,\n updates: [.init(point: .init(x: 64, z: 64), height: 5)]\n )\n owner.components[Terrain3DHeightEdit.self] = edit\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "The request captures the storage identity and revision. It accepts at most 65,536\nunique sample addresses on resident surfaces. Nonfinite heights, duplicate/missing\nsamples, insufficient page budgets and stale requests fail without changing live\nsamples or mesh buffers. Observe `Terrain3DHeightEditStatus` and match its `id` to\nyour request. Invalid edits report a separate error while preserving the working\nsource's readiness. Residency, collision, LOD and memory settings must already be\npublished before capturing a height edit. Changing those settings rejects the edit\nand lets the normal source pipeline admit the new settings or report their error;\nresubmit after it finishes. Color and the per-frame upload budget do not invalidate\nresidency admission. An applied result contains a compact `TerrainEditDelta`.\nFor each Undo command submit `Terrain3DEdit(storage: storage, undoing: delta)` at\nthe current revision. The result is itself reversible for Redo. This restores exact\nplacement channels together with heights; `reversedUpdates` contains heights only.\nAn unchanged request produces an empty delta rather than a new data revision.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Preparation copies metadata once and copies touched Float pages on write. It checks\nthe final page count before allocating; a batch can free one complete page and\nallocate another within the same budget. The live storage object stays unchanged\nwhile the replacement tile meshes and collision resources are prepared. Removing\nthe request before publication cancels its prepared work and preserves the live\ngeneration. Direct `setHeight` remains an immediate low-level data API; use edit\nrequests when preparing a coherent scene replacement. A ready result exposes its\nnative collision immediately, including Undo, without waiting for a later physics\ntick. `physicsWorld3D.collisionPreparationMetrics.snapshot` exposes cumulative\nsuccessful native BVH creations and their triangle count. Compare snapshots around\nan update to inspect real collision preparation rather than generated-child counts.\nRuntime height changes are not automatically written to asset files; save\nresource data separately through the terrain persistence API. Scene files retain\nthe source reference.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "With `Physics3DPlugin`, colliders use full-detail triangles and include the owner's\nparent transforms, rotation and reflected scale. Geometry and collision replacements\nare prepared before world changes are published. Changing color retains geometry;\nchanging transforms retains visible mesh buffers and rebuilds world-space colliders.\nThe runtime selects stitched render levels from the nearest active camera's distance\nto each tile in terrain-local X/Z. `lodDistance` defaults to 128 units; subsequent\nboundaries double. `maximumLOD` defaults to 6, and setting it to 0 keeps full detail.\nA 15% hysteresis band avoids buffer replacement from small boundary oscillations.\nCamera parent transforms are included. Multiple cameras retain the detail required\nby the nearest one; with no active camera or more than 16 active cameras, the\nconservative fallback is full detail. Partial tiles and their neighbors remain at\nfull detail. Collision and queries always use canonical samples, including narrow\nfeatures that disappear from a distant visual mesh.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "This initial runtime retains canonical CPU descriptors for resident collision and\npose updates, and its mesh budget uses a conservative full-tile bound. LOD reduces\ndraw geometry, while camera and physics residency control the retained region set.\nDynamic and kinematic bodies request terrain around their complete collision bounds;\ncharacter controllers request their capsules, including requested height changes.\nThe margin `physicsPadding` defaults to 64 terrain-local units. Swept interests\ninclude at least a quarter-second of motion (or the fixed tick if longer), including\nrigid-body gravity and the character's possible downward motion. Rotating bodies\nreceive conservative rotation coverage. Sleeping rigid bodies retain their ground. More than 64 distinct\nphysical interests conservatively request all declared terrain instead of dropping\nactors from residency planning.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`Physics3DPlugin` installs `PhysicsSimulationGate3D`. If collision-enabled terrain\ncannot admit its requested data while physical actors are present, rigid simulation\nand character movement wait. The source status reports the error; successful\npublication resumes movement. Removing a source or despawning its owner releases\nits hold. Other providers' hold reasons remain independent. Ray queries may still\ninspect the last valid collision generation while movement waits.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "After body synchronization, terrain rechecks physical coverage before each fixed\nstep. A character teleported by a script after `preUpdate` waits until the new region\nis resident. The check also verifies source/data revisions, owner transforms and\nnative collider initialization. It resumes after coherent publication; independent\nowner/reason holds remain active until their own provider releases them.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Region reads and CPU meshing run in owned background tasks. At most two sources\nprepare concurrently, including completed CPU jobs awaiting GPU publication.\nWaiting sources enter a FIFO queue so repeated camera updates cannot indefinitely\noccupy both preparation slots ahead of another terrain.\n`maximumTileUpdatesPerFrame` defaults to 4 and accepts 1 through 64; it limits tile\nmesh uploads and actual native collision tree preparation in each frame. Prepared\nimmutable collision data are reused when the completed generation activates its\nbodies, so final publication does not recook all tiles. Prepared children stay hidden\nuntil the complete replacement can publish together. `Terrain3DSourceStatus` reports\n`isPreparing`, `preparedTileCount` and `pendingTileCount` while the last valid\ngeneration remains available. Changing the source or removing its owner cancels\npending work. Revision checks reject results superseded by local edits. Camera\nmotion can finish an already prepared generation before requesting its next view,\nso continuous navigation does not repeatedly discard useful work.\nNative Studio exposes source sculpting, material import/painting, mesh instancing and seeded height generation in a terrain dock below the viewport. Height, surface and placement strokes publish asynchronous batches while dragging and save one private resource version through ordinary scene history. A visible surface ring follows the brush; Escape restores the previous resource. Attaching `Terrain3D` continues to use the compatible legacy patch described above.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Terrain resource loaders can bound encoded input before decoding by declaring\n`static var maximumEncodedBytes: Int?` on their `Asset` type. The default is `nil`.\nNative loads check the file size and read bounded chunks, including a check for\nfiles that grow while being read. Browser loads consume the response stream and\nreject a chunk that would exceed the limit. The limit also applies during hot reload;\ndecoders still need their own validation of counts, coordinates and numeric values.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Runtime height brushes", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainHeightBrushSettings` selects raise, lower, smooth, flatten, set height,\nramp, terrace, seeded noise or bounded local talus relaxation. Supply settings and\nterrain-local `TerrainBrushPoint` events to `TerrainHeightBrushStroke`, then attach\n`Terrain3DHeightEdit(storage:brush:)`. Completion and inverse Undo use the same\nheight-edit status and delta as explicit sample updates.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Raise, lower, smooth, flatten and talus sample time at fixed intervals, scaling\nby elapsed seconds and optional pressure. Stamp operations use deterministic path\ndistance spacing. Flatten defaults to the surface height at stroke start. Set height\nrequires `targetHeight`; ramps interpolate the first/last input heights. Terraces\nuse `terraceStep` and an optional height offset. Noise uses the seed and canonical\nsample coordinates. Talus has explicit iteration and angle controls.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The worker reads an immutable input generation and publishes one complete delta;\ninvalid settings or excessive work retain the previous surface. At most 4096 input\npoints/dabs and 65536 affected samples are accepted, with a separate sample-visit\nbudget. Work preflight counts both dab supports and scans over their union, including\nlocal stencil reads. Rotated square/alpha/ridge bounds follow the rotated support.\nSlope masks share the renderer's one-sided and normal-halo derivatives. Brush updates\nplan and load their complete declared footprint into a private editing view before\napplication, including normal-only and unloaded neighboring regions. The original\nvisible tile set is restored before publication. Missing payloads or insufficient\nresidency reject the whole gesture without changing visible geometry.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Shapes include soft/hard round, square, ridge, a signed crater mask, ridged noise and\na bounded grayscale alpha. `TerrainBrushAlpha` accepts up to 1024 × 1024 bytes and\nvalidates dimensions/counts before decoding pixels. Rotation/jitter and height/slope\nranges belong to settings. Studio brush controls, named presets and imported-image\nhandling are not connected to this resource path yet.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Runtime surface brushes", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainSurfaceBrushSettings` selects `paintLayer`, `eraseLayer`, `paintMask`,\n`paintTint`, `eraseTint`, `paintRoughness` or `eraseRoughness`. Submit its stroke to\nthe loaded source owner, using the source's `Terrain3DSourceSurface.storage` and\nterrain-local input points. The target layer must exist in its material palette.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "func paintTerrainPath(owner: Entity, storage: TerrainHeightStorage, points: [TerrainBrushPoint]) {\n var settings = TerrainSurfaceBrushSettings(operation: .paintLayer, radius: 4, strength: 0.3)\n settings.layerID = 12 // A stable layer ID declared in this source's palette.\n settings.shape = .softRound\n settings.spacing = 0.25\n owner.components[Terrain3DEdit.self] = .init(\n storage: storage,\n brush: TerrainSurfaceBrushStroke(settings: settings, points: points)\n )\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Strength is coverage per distance-spaced dab, between zero and one. Rotation,\nseeded jitter, pressure, procedural shapes, grayscale alpha, height and slope masks\nuse the sculpt brush conventions. Event duration does not multiply surface opacity.\nOverlapping dabs accumulate coverage; layer painting retains the strongest four\nstable IDs and normalizes their weights. Erasing the layer override reveals the\nautomatic rules while retaining separately painted tint and roughness.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For `paintMask`, set `maskID` and `maskValue`; a target value of zero erases that\nautomatic-rule mask. Tint and roughness brushes affect only their own channels.\nThe original surface supplies height/slope filters throughout the gesture.\nPlanning bounds dabs, canonical samples and work before hydration, and the complete\nresult must fit resident storage and native region limits before publication.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "One gesture publishes one revision and compact `TerrainEditDelta` changes. Read\n`Terrain3DEditStatus` and create `Terrain3DEdit(storage:undoing:)` from an applied\ndelta for exact Undo. Removing the request before publication cancels it. Direct\n`TerrainSurfaceUpdate` and `TerrainRuleMaskUpdate` arrays can also accompany height\nupdates in one `Terrain3DEdit`. Material-only changes retain unchanged mesh buffers\nand collision; sculpted geometry and LOD transitions continue to rebuild as needed.\nUse `Terrain3DSave` to persist the committed channels with heights and placements.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Saving runtime edits", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "After a height edit has published, attach `Terrain3DSave(storage:)` to its source\nowner. `Terrain3DSaveStatus` reports `pending`, `saved`, `failed` or `cancelled`, plus\nthe saved revision or error. The default 128 MiB encoded staging budget can be\nreduced. An optional asynchronous `onProgress` callback receives completed and\ntotal region writes. Native writable projects support this operation; browser\nsaving remains unavailable.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The operation captures changed resident data and hidden dirty regions, restoring\neach hidden region's authored tile layout. It uses incremental persistence, so\nunloaded references survive without loading their payloads. Successful publication\nadopts a new immutable reader and releases only data matching the committed\nsnapshot from dirty residency. Later edits remain unsaved. Saving itself preserves\nthe surface object and visible mesh buffers; normal edits and streaming can continue\npublishing their own changes while the immutable snapshot writes.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "At most two filesystem snapshots run per source system, including cancelled tasks\nwaiting for their final result. Additional requests wait in FIFO order. If an\nuncaptured queued request must yield to residency or geometry work, it fails busy;\nretry after publication. A request captured before its current heights are published\nalso fails busy. Changing the source URI/reload generation or removing the request\ncancels an unpublished save. Pose, camera, physics and newer height changes allow\nthe existing snapshot to finish. A commit that already succeeded reports `saved`;\nits status includes the captured revision, which may precede later edits.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "A stale disk manifest fails explicitly while retaining local data and geometry.\nSaving a scene persists its terrain URI; save terrain edits separately before\nclosing or exporting that scene.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "An in-flight transaction pins its canonical regions through normal residency\nadmission. Camera or physics requests that cannot fit those pages plus their new\nregions fail without evicting the transaction's data. Saving the captured snapshot\nreleases its transaction pins, while newer unsaved data remain dirty. A one-page\nworking set can therefore refuse a second nonzero region until the newer Undo is\nalso saved or its data are otherwise released.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Shared mesh placements", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`Mesh3DInstancesComponent` is the initial shared-mesh runtime path for spatial cells.\nIt takes one mesh/material table and an array of `Mesh3DInstance` records with stable\nIDs, local transforms and tint. The owner/parent transform applies to every record.\nExtraction keeps one render source per component and compatible materials share\ninstanced draws. It does not create an authored scene entity for each placement.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainMeshPrototype` prepares a selected scene from a `ModelAsset3D`. It retains\nall active mesh nodes, every primitive and the shared material table. Imported\nparent/rest-pose transforms are composed after each placement, beneath the terrain\nowner. Mesh buffers and materials are reused.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "let prototype = try TerrainMeshPrototype(model: importedModel)\nlet batches = try prototype.makeBatches(instances: placements)\nfor batch in batches {\n let child = world.spawn(\"Terrain model batch\") {\n Transform()\n batch\n }\n terrain.addChild(child)\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Run this setup after the renderer is ready, in the scene's normal world-mutation\ncontext. Keep placement IDs stable when moving or reordering records. Update the\nexisting batch components when placements change; preparing them is not a per-frame\noperation. This runtime example does not save placement records into a terrain asset.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Preparation accepts static indexed triangle geometry with standard position,\nnormal, UV, tangent and color streams. Animation, skinning/joint streams and morph\ntargets require ordinary model entities. Invalid hierarchies, missing materials,\nnonfinite/noninvertible transforms, invalid vertex/index data and transformed-bound\noverflow throw `TerrainMeshPrototypeError`. The glTF import path also validates\nattribute cardinality and indexed vertex references before GPU upload.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "A model is limited to 4096 nodes, 64 draw parts and 256 materials. Geometry\npreflight allows 1048576 vertices and 6291456 indices across its unique active\nmeshes. A cell allows at most 65536 placements and 262144 placement/part pairs.\n`maximumExpandedBytes` covers expanded CPU placement records, with a 64 MiB\ndefault and 128 MiB hard cap; shared source assets and renderer pass buffers have\nadditional costs. Tints use normalized RGBA values. Negative scales are supported\nwhen the resulting transform and mesh bounds remain finite and invertible.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The forward/shadow lists consume placement transforms. Portable tracing has a\n131072-placement/input-triangle work budget and a 262144-material-packet cap;\nline, point, empty and cosmetic-only parts do not expand. It transforms referenced\nvertices only. These limits do not establish the decorative-instance benchmark.\nRegion assets now preserve stable placement records in a versioned binary section.\nRecords retain compact rotation, tint and attachment-normal channels without repeated\nquantization on save. Height-only files retain their original encoding. Residency\nand saves preserve records in hidden dirty regions, including placement-only resource\nremoval. Decoded placement rows have an independent 16 MiB default storage/cache\nbudget; expanded draw records and renderer buffers have separate costs. A manifest's `meshPrototypes` table assigns stable `UInt16` IDs to static model\npaths relative to the manifest directory. It also selects an optional imported\nscene and world-space draw distance. Nonempty palettes use manifest version two;\nempty palettes keep version one. Both save modes preserve model references.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Source loading creates one shared batch per imported mesh node, prototype and\n64-cell placement group. Region streaming removes inactive draw cells. Existing\ncells retain their entities and shared buffers through unrelated height edits.\nA failed source replacement preserves the previous valid generation.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For example, save an unanchored decorative placement in a native writable project:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "let layout = TerrainManifestAsset(meshPrototypes: [\n .init(id: 17, assetPath: \"Models/rock.glb\", maximumDistance: 500),\n])\nlet rock = try TerrainRegionInstance(\n id: 1, prototypeID: 17, localPosition: [10, 0, 10], flags: []\n)\nlet region = TerrainRegionAsset(\n x: 0, z: 0, tiles: [], pages: [], instances: [rock]\n)\ntry await AssetsManager.saveTerrainRegions(\n [region], manifest: layout, at: terrainURL.path\n)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "The model must exist at `Models/rock.glb` relative to `terrainURL`. Attach\n`Terrain3DSource(source: terrainURL.path)` to the terrain owner after saving.\nPlacement position and vertical offset use base-cell units; X/Z include the\nregion's 256-cell offset and terrain origin, while model scale remains a model\ntransform. Sculpt commands now move nearby anchored placements with the edited surface and\nalign their orientation when requested. Unanchored/distant rows remain unchanged.\nAttachment queries include canonical halo coverage without exposing hidden draw\ntiles. Local yaw is recovered against a canonical normal frame, so many small\nslope edits do not lose tilt through repeated quaternion quantization. A gesture\nvisits at most 262144 nearby records. Its delta retains previous/current placement\nrows for exact combined Undo/Redo, preserving quantized channels across repeated\nhistory steps. `Terrain3DEdit`, `Terrain3DEditStatus` and `TerrainEditDelta` are general\naliases for the original height-edit spellings.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Explicit placement gestures use `Terrain3DEdit(storage:instances:)` with\n`TerrainInstanceCellChange` entries. Each entry addresses one 64-cell spatial group\nand includes its expected previous rows and desired rows. The worker validates\nunique cells, record membership, stable IDs, stale input, decoded budgets and the\n65536-record limit per region before publication. A gesture permits at most 65536\ncells and 262144 previous/desired row visits. Saving includes these placement edits.\nDeclared regions must have a complete loaded baseline before explicit cell replacement;\nan unloaded declaration is not interpreted as an empty cell. Newly addressed regions,\nincluding canonical height pages across an outer edge/corner, pass region admission\nbefore publication. Loaded baselines survive adoption of a new save reader.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native Studio's separate Mesh Instancing toolbar button opens its terrain dock. Import a self-contained GLB or VOX, select its palette entry, then paint or erase shared instances. The runtime paint/erase request path below authors the same saved placements. Actual backend pixels and the 100k-instance benchmark remain required\nbefore the feature is complete.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Runtime mesh brushes", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainInstanceBrushSettings` selects paint or erase and a stable mesh palette ID.\nSubmit a `TerrainInstanceBrushStroke` through `Terrain3DEdit(storage:brush:)` on the\nterrain source owner. The general edit/status/delta names alias the existing height\nrequest types; saves and exact inverse Undo share the same atomic publication path.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "var settings = TerrainInstanceBrushSettings(prototypeID: 17, radius: 8, density: 2)\nsettings.minimumDistance = 1.5\nsettings.scaleRange = 0.8...1.2\nsettings.heightRange = 0...100\nsettings.slopeRange = 0...35\nsettings.seed = 42\nterrain.components[Terrain3DEdit.self] = .init(\n storage: surface.storage,\n brush: TerrainInstanceBrushStroke(settings: settings, points: gesturePoints)\n)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Density is the requested placements per square terrain-local unit before spacing and\nsurface masks. Minimum distance applies to placements of the selected prototype,\nincluding existing nearby rows. A jittered deterministic lattice provides stable\ncandidate IDs and random scale/yaw; changing its physical scale selects a distinct\ncandidate lattice. Repeating the same paint request leaves accepted records\nunchanged, and subdividing a straight input segment preserves its result. Terrain\nowner transforms apply after local authoring, as for sculpt brushes.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The brush supports the existing soft/hard round, square, ridge, crater, noise and\nalpha masks, rotation/jitter, pressure, tint, height offset and height/slope filters.\nPainted records anchor to the sampled surface and can align their up axis with the\nsurface normal. Sculpt edits reproject those anchors. Erase removes covered records\nfrom the selected prototype, or every prototype with `eraseAllPrototypes`; inverse\nhistory restores exact row order, IDs and compact transforms.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Set `materialLayerID` to a registered stable layer ID and `minimumMaterialWeight`\nto a value in 0...1 to restrict paint or erase to that layer's contribution. The\ndefault threshold is .5; zero accepts any positive contribution. Leave the ID\nunset to disable this filter. The brush resolves automatic height/slope/curvature,\nnoise, rule masks and painted overrides at canonical samples, then interpolates\nthe selected layer's weights. Missing layer IDs reject the gesture without\nchanging placements or geometry. This uses layer weights, not texture color.\nEach gesture caches at most 65536 canonical filter samples and charges sample\nand rule evaluations to its existing work budget. Unfiltered brushes do not\nallocate this material cache.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Planning loads the complete declared gesture footprint into a private editing\nview, including unloaded height/placement regions and a normal halo. Extra query\ntiles do not become visible render tiles. Unknown declared payloads, missing models,\ninvalid settings, cancellation or exceeded budgets retain the published generation.\nThere are at most 262144 candidate lattice cells and 1048576 counted work visits\nper planning/evaluation stage;\nbounds searches, mask evaluations and spacing comparisons all count. Placement and\nresident resource limits also apply. Native Studio brush controls remain pending.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Nearby placement collision", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Decorative placements have no collision. Set `TerrainInstanceFlags.collision` on\nexplicit records, or `settings.requestsCollision = true` before painting, to request\nstatic triangle collision near active cameras and physical bodies. The flag is saved\nwith each placement and follows exact placement Undo/Redo.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`Terrain3DSource.instanceCollision` controls this mode. Its defaults are a horizontal\ndistance of 64 terrain-local units, at most 128 colliders, a conservative 32 MiB\ngeometry/native-tree allowance, and four native preparations per update. Count is\nlimited to 1024 and per-update preparations to 64. Selection uses transformed model\nbounds and stable placement IDs to break equal-distance ties. Count limits can omit\nnearby placements; use suitable limits for gameplay obstacles. Setting the count to\nzero disables placement collision. `generatesCollision = false` disables both the\nbase surface and placement collision.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The placement preparation budget is separate from the height tile update budget.\nAll selected shapes are prepared before their generation publishes. Cancelled or\nrejected preparations retain the last published collision; unchanged selections\nreuse their entities and native trees. Camera/body movement releases distant props\nand prepares newly selected ones. Full parent transforms, including reflections,\napply to collision as well as rendering. The physics readiness gate waits when\nmovement requests selected prop collision that has not published yet.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`Terrain3DSourceStatus.instanceColliderCount` reports the published count, including\nwhile a replacement is pending. Use the physics world's existing preparation metrics\nto measure actual native BVH work. Selection visits at most 262144 flagged candidates;\ndecorative-only cells retain no collision candidate rows. Exceeding candidate or\ngeometry budgets reports an error rather than partially publishing collision.\nIf more than 16 active cameras exceed the bounded camera collector, collision\nselection conservatively considers all loaded flagged props, within the same\ncount, memory and work limits.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Measuring large placement scenes", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`Render3DGPUTests.Benchmarks.terrainHundredThousandInstances` loads two native\nregions containing 100000 decorative placements, renders spatial batches and\nthen compares one entity per placement using the same shared assets. It verifies\nidentical pixels and records actual camera draw counts, runtime entity count,\nplacement storage and frame wall time. Windows also records process CPU time, working set\nand committed bytes. Process CPU includes worker threads. These figures include the renderer, caches and test\nharness; they are not exclusive placement allocations.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Run an optimized build with `ADAENGINE_PERF=1`, the applicable golden backend\nenabled, and `swift test -c release -Xswiftc -enable-testing --filter\nterrainHundredThousandInstances`. Use `ADAENGINE_PERF_FRAMES` to select the measured\nframe count and `ADAENGINE_TERRAIN_PERF_OUTPUT` for a JSON result file. Run on a\nquiet machine and compare results on the same hardware. Timing includes a final\nreadback to wait for submitted GPU work; it is not a per-pass GPU timestamp or\na universal frame-rate promise. This fixture uses warmed albedo view rather than\nfull shaded gameplay; keep that distinction when reporting its results.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Solid geometry preparation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "The renderer-neutral Math module provides bounded scalar geometry for tools that prepare\nsolid operations. `ScalarSolid` evaluates spheres, boxes and capsules under a finite,\ninvertible affine transform, including reflection, nonuniform scale and shear.\n`ScalarMeshSolid` validates a closed static triangle mesh, welds exact duplicate positions\nat attribute seams and evaluates concave membership with nearest-surface distance.\nOpen or nonmanifold edges/vertices, inconsistent winding, degenerate faces and intersecting\nfaces are errors. Mesh preparation accepts at most 65,536 triangles and 196,608 source\nvertices, with explicit validation and query work budgets. Cancellation and exhausted\nbudgets throw; callers must retain their previous valid terrain result.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Both fields are negative inside. `ScalarBooleanOperation.union` takes the minimum;\n`subtract` takes the maximum of the base and negated operand. Transformed analytic values\npreserve their surface but need not be exact distances after nonuniform scale/shear.\n`ScalarFieldGrid` holds at most 32 cells per axis, with signed integer lattice offsets for\nshared boundaries. `ScalarFieldSurface.extract` returns indexed smooth triangles and\narea-weighted normals, with explicit triangle and contact-processing bounds. Neighboring\ngrids must select the same tetrahedral diagonal orientation. Precision\nis sampled: features not captured by the chosen grid can disappear. Representable sampled\nthin faces are retained, rather than removed by a fixed endpoint-snapping tolerance.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For standalone picking and proximity tools, `TriangleSurfaceIndex` builds an immutable\nBVH over open or closed indexed triangles. `raycast` returns the nearest two-sided hit;\nits distance uses the normalized ray direction, and its normal retains geometric winding.\n`closest(to:)` returns the nearest point within the supplied distance. Hits retain the\noriginal triangle-list index through BVH sorting. Exact shared edges remain hittable;\nrepresentable points outside an edge stay outside. Empty indices return no hit.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Construction validates referenced finite vertices, nondegenerate triangles and index\nrepresentability, with explicit triangle, conservative byte and work allowances. Unused\nposition-accessor rows are not copied into Double geometry. Query overloads accept a\nshared `TriangleSurfaceQueryBudget` across chunks; exceeding the allowance throws rather\nthan returning a partially searched answer. Cancellation also throws. This Math API does\nnot itself attach acceleration to a scene or transform local hits into world space.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Enable `Terrain3DSource(queries: .init())` to prepare acceleration for published source\ntiles. `Terrain3DSourceSurface.queries` then exposes immutable `TerrainSurfaceQueries`\nwith `raycast`, `closest(to:)`, revision, byte charge and per-tile generation diagnostics.\nThe default `queries: nil` avoids this construction and memory cost. Settings independently\nbound the conservative current-generation index/staging bytes and shared construction work.\nIndices build in workers, reuse geometry-equal tiles and publish with the same canonical\nmesh and collision revision. Budget failure keeps the previous valid snapshot; disabling\nqueries drops the current acceleration while retaining render buffers.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The query snapshot follows published geometry, including cave floors, ceilings and stacked\nledges. Holding a snapshot retains its surface while the editable base changes; a completed\nstaged edit or Undo publishes a replacement. Unchanged save acknowledgment retains the\nindices. Positions, normals and distances use terrain-local space. Transform world rays\ninto that space and returned positions back through the terrain hierarchy; transform\nnormals with the inverse transpose. Closest-point distance uses the local metric under\nnonuniform scale. When comparing ray hits from different transformed entities, reconstruct\nthe world ray parameter from each returned world position.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TriangleSurfaceHit.preciseDistance` retains the Double metric for ordering across tile\nindices. `distance` provides the Float value for normal application use. The scene layer\ncompares the precise metric so distinguishable close surfaces cannot be reordered by Float\nrounding. One query work allowance covers tile bounds and all visited BVH nodes/triangles.\nThese native CPU checks do not establish backend pixels or browser interaction behavior.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainVolumeBrickAsset` stores canonical 33-cubed density samples and stable optional\nmaterial IDs in an independently loadable `.atvolbrick` resource. Its versioned little-endian\nencoding preserves Float bits and rejects malformed sizes, counts, coordinates and samples.\n`TerrainVolumeDocumentAsset` stores the explicit lattice origin/cell size, ordered solid\noperations and cached-brick references in `.atvolume` metadata. Operations retain UUIDs,\nenabled state, full affine placement, a primitive or portable static-model source, and an\noptional material layer. Validation rejects unsafe paths, duplicate identities/cache\naddresses/names, invalid operands and oversized tables before resource staging. Metadata\nalone does not publish a cache or commit a multi-resource save.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native multi-resource saves use `AssetsManager.saveTerrainRegions(..., volume:)` with\n`TerrainVolumeSave(document:bricks:mode:)`. The manifest uses version 4 when it references\na volume document; height-only manifests retain their previous encoding. The default\n`.replaceAll` writes a complete cache; `.update(removing:)` replaces supplied addresses\nand explicitly removes referenced bricks while retaining the others. Pass the current\npublished document and manifest as the update baseline. Unchanged payloads and canonical\ncache tables retain their filenames, regardless of supplied brick order.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Height resources, changed bricks and document metadata stage before the manifest is\natomically replaced. The combined encoded-byte budget covers both height and volume data.\nCancellation or failure removes newly staged files and keeps the previous manifest and\nits resources readable. Old committed generations remain on disk; this API does not\nperform resource garbage collection. Saving heights without a `volume` argument preserves\nand checks the referenced volume resources. These native writes require a writable local\nasset directory; browser asset loading uses the separate typed asynchronous read path.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainRegionCache.loadVolumeResources()` opens the manifest's operation metadata in\nthe project context that opened the cache. A height-only manifest returns `nil`. The\nreturned `TerrainVolumeResources` keeps that context when another project requests\n`loadBricks(at:maximumBytes:maximumBricks:)`. Supply signed brick coordinates from its\ndocument; duplicate or undeclared requests and count/byte overruns fail before payload\nreads. Unrequested bricks stay unloaded, including unavailable distant files. Requested\npayload addresses and material tags must match the document and manifest palette.\nLoading returns the complete requested array or throws; it does not install scene geometry.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Use `TerrainVolumeResources.loadPreparedMesh(operationID:maximumBytes:maximumValidationWork:)`\nfor an enabled static-model operation. It retains the cache's model import limits and project\ncontext. Its per-load encoded/decoded import caps also respect `maximumBytes`. The model\nbridge selects the operation's scene (or the model default), visits all active nodes and\nprimitives, honors rest poses and inherited affine transforms, and corrects reflected-node\nwinding before applying the complete operation placement. Open, deformed or otherwise\ninvalid solids throw. Programmatic models use `TerrainVolumePreparedMesh(operation:model:)`.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Preparation reserves retained source geometry, conservative per-index validation storage\nand complete position-accessor copies, including unused imported rows and repeated scene\nreferences. An otherwise valid operand can exceed the explicit byte or validation-work\nallowance. Keep its last committed terrain generation when preparation fails; these loading\nAPIs do not publish replacement geometry on their own.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainVolumeField` captures an immutable COW heightfield base and applies enabled\noperations in document order. Prepare static mesh operands with `TerrainVolumePreparedMesh`\nso their source identity and complete placement can be checked. The field builds canonical\n32-cell bricks with explicit allocation/work budgets and cancellation, and can extract their\nstandalone surfaces. Base density retains the heightfield's `y - height` distances;\noperand contributions use a two-cell narrow band and deterministic expanded support.\nTerrain extraction selects the heightfield's falling X/Z cell diagonal. Exact unchanged-base\nmatching requires the same sample grid or an exact aligned refinement; coarser or unaligned\ngrids still resample detail. Brick addresses do not change shared sample values. Mesh sampling charges a\nconservative configurable query allowance. A failed build returns no partial brick.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`TerrainVolumeCache.preparing` returns a complete immutable sparse generation for requested\nand pinned bricks. Unchanged local base pages and relevant operations retain their runtime\nbrick generation IDs; a local sculpt or source reload rebuilds affected bricks. Hidden\nauthored regions keep their canonical page dependencies when draw residency changes.\nCount, conservative byte and shared evaluation-work limits apply before the replacement\nis returned. A failed preparation leaves the previous cache available. Its byte statistics\ncover one generation, including bounded sample/dependency staging; retained older generations\nand render/physics resources need their own admission budgets.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "`Terrain3DSource` consumes enabled operations for full 64-cell height tiles. Its initial\nhybrid path requires a brick-aligned lattice origin and the same height sample spacing or\nan exact aligned refinement, within the existing density/geometry work and memory bounds.\nAffected tiles retain full visual detail, while the existing LOD balancer refines their\nneighbors. A halo keeps edits away from ownership boundaries. Explicit one-cell transition\nstrips preserve the adjacent heightfield edges and conform their inner edges to the density\ntessellation. Transition interpolation brackets actual canonical coordinates, which can\nhave uneven Float increments far from the origin. The same committed mesh descriptor supplies rendering and triangle collision;\nthe old heightfield mesh is replaced in owned tiles. Source preparation retains prior valid\ngeometry until the existing paced publication transaction is complete.\nVolume source tiles interpolate central lattice gradients for consistent normals at shared\nbrick positions. Pure-heightfield transition edges keep their original normals. Derivative\nsampling shares a per-tile work budget and its one-sample halo participates in cache dependencies.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Open `Editor/Samples/MeshTerrainWorkshop` in Ada Studio to explore the joined rock,\npainted cave and procedural grass and stones. Use Play to inspect collision on the joined rock.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native Studio's mesh tab exposes primitive or registered closed-mesh union/subtraction. Volume materials evaluate actual vertex height and normal; three-corner material fields use a separate triangle atlas while textures retain terrain-local coordinates for triplanar projection and detiling. Native collision cooking has its\nown small-area precision limit; render geometry below that limit may not become collision faces.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "This initial source integration is still in development: partial height tiles, volume-only\ncoverage outside the heightfield and cached-only/baked fallback remain incomplete. Three-dimensional manual paint storage and volumetric curvature rules are not yet equivalent to heightfield paint. Palette-bearing volume sources use an independent three-corner material field per triangle; no single X/Z field is reused for separate strata. `Terrain3DSourceSurface.storage`\ncontinues to represent the editable heightfield base; its height query does not describe\nmultiple surfaces in a volume column; enable the source queries above for committed surface\npicking and proximity. Complete portable export and platform acceptance remain open.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Current limits", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "A heightfield has one height per X/Z position, so it cannot represent caves or\noverhangs. The legacy `Terrain3D` patch supports at most 257 × 257 samples.\n`Terrain3DSource` supports sparse resource tiles within its configured residency\nand mesh budgets, with camera-driven residency. Source palettes render rule-selected\ntints and albedo maps; the legacy patch keeps its single ground color. Material\npainting controls in native Studio, instance controls and volume editing remain\nin development.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The tile data and headless runtime tests exercise CPU geometry and real physics\nqueries. They do not establish Metal/WebGPU pixel agreement or native mobile,\nbrowser-shell and VR authoring parity; those need separate backend/runtime checks.", + "level": 0, + "links": [] + } + ] + }, + { + "id": "AnimationAuthoring", + "title": "Authoring skeletal animation", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md", + "blocks": [ + { + "kind": "text", + "text": "Copy an imported animation or capture a rest/current pose into a project-owned\n`.animlib` resource. Source GLB/glTF bytes remain unchanged. Swift code, Studio's\nAnimator, the editor agent and project Tools share the same data and edit commands.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Opening a GLB/glTF or `.animlib` in Studio reveals the bottom **Animator**. A\nresizable **Model** panel shows the imported node hierarchy, and the right Inspector\nfollows bone, track, key and graph selection. The timeline places all bones on a\nshared seconds ruler with a draggable playhead, clip/duration/loop controls, key\ninsertion and grouped editing. Graph uses connected pose cards on a canvas.\nThese surfaces share the large center viewport's player and camera. Asset editing\ndoes not require an open scene. See the Studio animation-authoring guide for the\ncomplete pointer and keyboard workflow.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Create a library", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "A binding snapshots the complete hierarchy: stable node IDs, parents, names, rest\nmatrices/TRS and morph target widths. Tracks target hierarchy nodes, including\nnon-joint nodes, rather than offsets in a skin palette. Changed rigs fail binding\nexplicitly; guessing by duplicate bone names or reordered indices is not remapping.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\nimport Foundation\n\nlet rig = try SkeletalRig(nodes: [\n .init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: \"Root\")\n])\nvar library = AnimationLibrary(id: \"hero\", binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet clip = try library.poseClip(id: \"wave\", name: \"Wave\", duration: 1)\ntry library.apply([.init(id: \"create-wave\", kind: .createClip, clip: clip)])\n// This known one-bone rig has one rotation track in the captured pose clip.\nif let rotation = clip.tracks.first(where: { $0.channel == .rotation }), var key = rotation.keys.first {\n key.value = [0, 0, 0.70710677, 0.70710677]\n try library.apply([.init(id: \"rotate-root\", kind: .putKey, clipID: clip.id,\n trackID: rotation.id, key: key, replaceExisting: true)])\n}\nlet reopened = try AnimationLibrary.decode(library.encodedData())\nlet player = try reopened.sample(clipID: \"wave\", at: 0.5)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Use `library.extract(clip, rig: rig)` to copy a runtime clip from the same source rig.\nTranslation, normalized XYZW quaternion rotation, scale and morph-weight tracks\nretain their linear/step/cubic-spline values and derivatives. Explicit clip duration\npreserves a hold after the last key. `poseClip(name:duration:from:)` can capture a\ncompatible player's local pose/morphs. Matrix-authored static nodes retain matrices.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Edit keys and play clips", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`AnimationEdit` supports `clip.create/duplicate/remove/configure`,\n`track.create/remove/configure` and `key.put/remove`. IDs are scoped to the library,\nclip and track respectively. Duplicated clips own independent value data.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Batches are atomic. Each operation leaves a valid document; an invalid operation\nreports its ID and preserves the original library. Updating/moving an existing key\nID requires `replaceExisting:true`. Another key at the same time remains a conflict\nand must be removed explicitly. Keys use finite seconds in strictly increasing\norder within duration, typed finite values, and normalized quaternion rotations.\nCubic incoming/outgoing tangents are component derivatives per second.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Native temporal curves", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Key `curve` metadata adds easing to the outgoing segment. Families are linear,\nquadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic and\nbounce; directions are `in`, `out`, `inOut` and `outIn`. Back exposes overshoot;\nelastic exposes amplitude and period. Overshoot is retained rather than clamped.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\n\nlet pose = SkeletalJointPose()\nlet rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix)])\nvar library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet track = AnimationLibraryTrack(id: \"move\", nodeID: \"node-0\", channel: .translation, keys: [\n .init(id: \"start\", time: 0, value: [0, 0, 0]),\n .init(id: \"end\", time: 1, value: [1, 0, 0])\n])\ntry library.apply([.init(kind: .createClip, clip: .init(id: \"move\", name: \"Move\", duration: 1, tracks: [track]))])\nvar key = track.keys[0]\nkey.curve = .init(easing: .init(family: .quadratic, direction: .in))\ntry library.apply([.init(kind: .putKey, clipID: \"move\", trackID: \"move\", key: key, replaceExisting: true)])\nlet player = try library.sample(clipID: \"move\", at: 0.5) // translation.x is 0.25\nlet reopened = try AnimationLibrary.decode(library.encodedData())", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "For translation, scale and morph weights, `bezier` interpolation accepts per-component\n`curve.incoming` and `curve.outgoing` arrays of `AnimationBezierHandle`. Each handle\nstores a time offset in seconds and a value offset relative to its key. Missing\nhandles default to one third of the straight segment. They are distinct from\nHermite's `incoming`/`outgoing` derivatives on the key itself. Sampling first inverts\nmonotone Bézier time, applies easing to that parameter and then evaluates the value.\nReversing temporal curves, invalid dimensions, non-finite values and unsafe Float\nranges fail before replacing the document. Flat and crossing temporal controls are\nsupported when their full time polynomial remains monotone.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Quaternion linear tracks use shortest-path slerp with eased progress. Imported cubic\nquaternion tracks retain their normalized component interpolation; easing remaps\ntheir segment parameter. Arbitrary scalar Bézier handles are rejected on rotations.\nUnedited linear/step/imported cubic tracks retain their existing evaluator.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Edits and extraction promote native curve data to `.animlib` schema **2**. Legacy\nlibraries without that data remain schema 1; old readers must reject schema 2 rather\nthan silently remove its curves. A library permits at most 500,000 compiled native\nscalar segments in addition to its existing key/byte limits. A sparse easing edit\nonly compiles the changed segments; Bézier tracks compile every scalar segment.\nThe same key payload goes through `key.put` from native Inspector, agents and Tools,\nincluding resource revisions, atomic rejection, history and explicit Save.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Load `AnimationLibraryResource` through AssetsManager or decode its JSON, then\nprovide libraries to `Model3DSource.instantiate(_:libraries:in:under:)`. The source's\n`animationLibraries` references and loaded libraries must match in count/order.\nDistinct library IDs, compatible bindings, imported/authored name collisions and\nthe complete graph validate before runtime entities are allocated.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Use `library.runtimeName(for: clipID)` for a single animation or an existing\n`AnimationGraph` clip reference. Display names may repeat; qualified names encode\nlibrary/clip identity. Native compiled tracks retain the existing mixer, crossfade,\nrepeat/one-shot clock and skin-palette behavior. Each player owns mutable poses.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Studio and automation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract a copy or\ncreate a rest-pose clip, select bone/node tracks and edit keys. The model viewport\nshows the animated mesh and bones. **Save and Bind** explicitly saves the resource\nand performs a separate undoable scene transaction. The graph picker includes\nbound saved clips. Inspector edits easing and per-component Bézier offsets; Curves\nplots the production evaluator. Additional tangent modes, canvas handle dragging,\nstate machines/blend spaces, paths and native 2D spline deformation remain later work.\nProcedural 3D IK is described below.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The editor agent calls `editor.animation.` with a `requestJSON` string\ncontaining `AnimationAuthoringRequest`. Studio API 2 project Tools use\n`editor.animations.submit(requestMap)` and `poll(jobID)`. All clients share the same\nproject drafts/history. Inspect first for stable IDs and `revision`; writes require\n`expectedRevision`. Binding also needs an open scene revision and saved resource.\nDrafts require explicit Save. Reload/Discard recover external-file conflicts.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Requests support 64 KiB, responses 1 MiB, pages 256 items and batches 256 operations.\nLibraries/model imports support 32 MiB; a library allows 16,384 nodes, 256 clips,\n4,096 tracks per clip, 100,000 keys and 1,024 morph targets per node. IDs are limited\nto 256 UTF-8 bytes, names to 1,024, source paths to 4,096 and fingerprints to 256.\nLarge morph-key pages may need smaller limits. CPU samples and preview\nacknowledgements do not establish pixels: verify the viewport and visible Play.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Save and bind from Swift", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Given a model compatible with the library's complete rig and an existing owner in\nits world, explicitly save the resource and instantiate an authored clip:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaEngine\nimport Foundation\n\nfunc saveAndBind(_ library: AnimationLibrary, resourceURL: URL, resourceReference: String,\n model: ModelAsset3D, world: World, owner: Entity, clipID: String) throws {\n try FileManager.default.createDirectory(at: resourceURL.deletingLastPathComponent(), withIntermediateDirectories: true)\n try library.encodedData().write(to: resourceURL, options: .atomic)\n let reopened = try AnimationLibrary.decode(Data(contentsOf: resourceURL))\n let source = Model3DSource(source: reopened.binding.sourceAsset,\n animation: reopened.runtimeName(for: clipID), autoplay: true,\n animationLibraries: [resourceReference])\n _ = try source.instantiate(model, libraries: [reopened], in: world, under: owner)\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Use a project-owned resource URL and its portable `@res://Animations/Hero.animlib`\nreference. For an imported model, derive the binding from\n`GLTFAnimationAdapter.makeRig(nodes:meshes:)` using that model's native import data,\nthen extract its compatible clip. The synthetic Root rig above is only for a model\nwith that same rig; incompatible models fail explicitly. Studio Tools can follow\nthe complete bone-key, sample, Save and Bind workflow in the AnimationHelper example\nlinked from the Studio guide. Native Tools bound expanded request conversion to\n8,192 values and 64 KiB of UTF-8 strings, and restricted imports preflight repeated\ndecoded accessor work before allocation.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native hosts can construct a preview with `ModelAsset3D(gltfAt:loader:)`, supplying\n`NativeGLTFLoader(resourceRoot:maximumTotalBytes:)` for the full model import. Studio's\nlibrary viewport uses this scoped loader; it does not change the global resolver.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Studio's shared request envelope also carries `graphInspect`, `graphApply`,\n`graphUndo`, `graphRedo`, `graphPreview` and `graphBind`. These address an open\nmodel/library workspace and use its graph revision rather than the clip-library\nrevision. `graphApply` replaces a detached `AnimationGraph` in the same canvas\nhistory. Binding validates the complete destination rig and saved libraries,\nthen commits one scene-history edit. See the Studio guide and AnimationHelper\nexample for request maps, separate permissions, budgets and explicit scene Save.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Procedural IK after animation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "See Animation IK and pose modifiers for ordered two-bone/FABRIK constraints, model-space targets,\ncurrent scene marker bindings, `.animmod` resources and the shared Tool/agent API.", + "level": 0, + "links": [ + { + "title": "Animation IK and pose modifiers", + "destination": "AnimationIK" + } + ] + } + ] + }, + { + "id": "AnimationIK", + "title": "Animation IK and pose modifiers", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md", + "blocks": [ + { + "kind": "text", + "text": "Constrain a skeletal chain after its clip, Animation Graph and visual LOD pose have been mixed.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "IK belongs to each playback instance. Editing a target does not rewrite the imported GLB or\nbake corrections into an animation library. An ordered stack supports two-bone IK and FABRIK.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Solve an editable chain", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "This complete example constructs two unit segments and moves their endpoint. For an imported\nmodel, use its full rig binding; skin-palette indices are not stable bone IDs.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\nimport Math\n\nlet poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]),\n SkeletalJointPose(translation: [1, 0, 0])]\nlet rig = try SkeletalRig(nodes: poses.indices.map { index in\n .init(parentIndex: index == 0 ? nil : index - 1,\n restPose: poses[index], restMatrix: poses[index].matrix)\n})\nlet binding = AnimationRigBinding(rig: rig, sourceAsset: \"Assets/Robot.glb\")\nvar player = try SkeletalAnimationPlayer(rig: rig, clips: [])\nlet arm = AnimationPoseModifier(id: \"arm\", kind: .twoBone,\n joints: [\"node-0\", \"node-1\", \"node-2\"], target: [1, 1, 0], pole: [0, 0, 1])\ntry player.setPoseModifiers([arm], binding: binding)\nassert((player.globalTransforms[2].origin - Vector3(1, 1, 0)).length < 0.0002)\n\n// Transient scene input does not change the saved stack or advance the clip clock.\nplayer.finalizePoseModifiers(inputs: [\"arm\": .init(target: [0, 2, 0])])\nassert(player.basePoses == poses)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "`setPoseModifiers` validates before replacing the stack. Chains are contiguous and have\neditable TRS and nonzero segments. Two-bone IK requires exactly three joints. FABRIK accepts\n1–64 segments, 1–64 iterations (default 16) and a positive tolerance. Its automatic tolerance\nis `max(1e-5, length * 1e-4)`. Influence is in `0...1`; coordinates must be finite. A stack has\nat most 32 modifiers and 32,768 compiled segment-iterations. Neither solver stretches bones.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Separate base and final poses", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`basePoses` contains animation/graph/LOD output. `poses`, local/global transforms, scene\nbones and skin palettes contain the final constrained result. Repeated finalization starts\nfrom the same base pose, without accumulating corrections. Crossfade sources and LOD\nendpoints also contain base poses. Moving targets and paused clips still finalize active IK;\ntarget movement does not emit animation markers or advance playback clocks.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Modifiers execute in saved order, each observing the previous influenced result. Influence\nblends local rotations once. Local translation, scale and the end joint's authored local\nrotation remain intact; its world orientation is not independently locked. Rigid transforms\nand positive uniform scale are supported. An unsupported participating hierarchy skips that\nmodifier; unrelated nonuniform branches do not disable valid chains.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Read `modifierDiagnostics` for each stable modifier ID. `solverError` measures the\nfull-strength solution; `reachError` measures the published endpoint after influence.\nUnreachable targets and iteration exhaustion can produce valid approximate results. Missing\ntargets skip their modifier instead of holding an old solved frame. CPU samples do not prove\nrendered output.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Resources and scene bindings", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`AnimationModifierStack` stores a versioned full-rig snapshot and an ordered list. Bounded\nJSON uses `.animmod`; `AnimationModifierResource` supplies normal asset codecs. Unknown\nversions and changed rigs are rejected. `Model3DSource.animationModifiers` loads the stack\nbefore replacing a live model. Scene overrides remain in `Model3DSource.modifierBindings`.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "An `AnimationIKTarget3D` is a model-space point or a stable scene entity ID with a local\noffset. Studio assigns `AnimationSceneIdentity3D` to authored entities. Programmatic scenes\nassign a shared namespace to the model owner and its ordinary target markers. Namespaces\nisolate loaded scene instances; runtime Entity.ID values are never persisted as targets.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Owners and marker offsets resolve from one current local-transform snapshot before applying\nany stack. A marker beneath any animated rig is rejected, including another instance.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Studio, agents and developer Tools", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Open a GLB/glTF or `.animmod`. **Modifiers** is in the resizable Model panel. Select a\ncontiguous chain, then add **Two-bone IK** or **FABRIK**. The Inspector owns chain, target,\npole, influence and solver settings. Blue target and purple pole handles preview a drag;\nrelease creates one Undo entry and Escape cancels. Save explicitly before binding to a scene.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Agents call `editor.animation.modifierInspect/Create/Apply/Sample/Save/Undo/Redo/Preview/Bind/Reload/Discard`\n(each suffix is a separate action, for example `editor.animation.modifierApply`). API 2 Tools\nsubmit the same `AnimationAuthoringRequest` through `editor.animations.submit` and poll\nasynchronously. Edits use the inspected `expectedRevision`; binding also requires a current\n`sceneRevision` and a saved compatible stack. Standard animation read/write, preview and\ndocument-write permissions apply. See Authoring skeletal animation for job limits and transport.", + "level": 0, + "links": [ + { + "title": "Authoring skeletal animation", + "destination": "AnimationAuthoring" + } + ] + }, + { + "kind": "text", + "text": "This article describes 3D skeletal IK. Native 2D weighted deformation, continuous-turn angles,\nspline-chain authoring and typed timeline bindings for modifier parameters remain separate\nimplementation stages. A 3D numerical solve does not establish those workflows.", + "level": 0, + "links": [] + } + ] + }, + { + "id": "ImageDecoders", + "title": "Loading images and adding formats", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/ImageDecoders.md", + "blocks": [ + { + "kind": "text", + "text": "Load PNG, JPEG (`jpg` or `jpeg`), BMP and TGA images as CPU pixels using\n`Image`, or load a GPU texture through `Texture2D`:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaEngine\n\nlet image = try await AssetsManager.load(Image.self, at: \"@res://Textures/ground.tga\")\nlet texture = try await AssetsManager.load(Texture2D.self, at: \"@res://Textures/ground.tga\")", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "`Image(contentsOf:)`, `Image.decode(from:)` and `AssetsManager` use the same\ndecoder registry. File extensions are case insensitive and act as hints;\nthe encoded bytes identify the format. Built-in decoders produce RGBA8 pixels.\nTGA decoding includes indexed colors, RLE, alpha and top- or bottom-origin rows.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Registering another raster format", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Implement `ImageDecoder` when another file format should produce an ordinary\n`Image`. This example defines a tiny custom format: four signature bytes,\none byte each for width and height, then RGBA8 pixels in row-major order.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaEngine\nimport Foundation\n\nstruct RawRGBAImageDecoder: ImageDecoder {\n let supportedExtensions = [\"rawrgba\"]\n\n func canDecode(_ data: Data) -> Bool {\n data.starts(with: [65, 68, 65, 73]) // \"ADAI\"\n }\n\n func decodedRGBAByteCount(_ data: Data) throws -> Int {\n guard canDecode(data), data.count >= 6 else {\n throw ImageDecodingError.invalidData\n }\n let header = Array(data.prefix(6)) // A fixed-size header, including for Data slices.\n let width = Int(header[4])\n let height = Int(header[5])\n let count = width * height * 4 // One-byte dimensions bound this multiplication.\n guard width > 0, height > 0, data.count == 6 + count else {\n throw ImageDecodingError.invalidData\n }\n return count\n }\n\n func decode(_ data: Data) throws -> Image {\n _ = try decodedRGBAByteCount(data)\n let header = Array(data.prefix(6))\n return Image(width: Int(header[4]), height: Int(header[5]), data: Data(data.dropFirst(6)))\n }\n}\n\nImage.registerDecoder(RawRGBAImageDecoder())\nlet image = try await AssetsManager.load(Image.self, at: \"@res://Sprites/player.rawrgba\")", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Register before loading assets or building the editor's project tree. The editor\nclassifies image files using `Image.extensions()`, which reflects the registry.\nExtensions are returned lowercase and without duplicates.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Registry access is synchronized. Decoder callbacks execute outside the lock\nand may run concurrently, so implementations must protect their own mutable state.\nNewer registrations take precedence among matching decoders. Registering the\nsame concrete decoder type replaces its previous instance; already cached assets\nare unaffected. A decoder's error propagates once it recognizes the input.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Registration adds decoding only; it does not add an image encoder. WebP, GIF,\nTIFF, HDR/EXR and KTX2 are not built-in raster decoders.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Texture arrays", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Once the renderer is configured, `TextureDescriptor` can initialize a 2D texture\narray. Set `textureType` to `.texture2DArray`, set `arrayLength`, and provide a\n`TextureSubresource` for every slice and mip level. Images in each mip level need\nmatching dimensions and formats. Slice numbers start at zero. Sample the result\nwith a 2D-array shader binding, including when the array contains one slice.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "let slices = [\n Image(width: 2, height: 2, color: .red, format: .rgba8),\n Image(width: 2, height: 2, color: .blue, format: .rgba8)\n]\nvar descriptor = TextureDescriptor(\n width: 2, height: 2, pixelFormat: .rgba8,\n textureUsage: [.read], textureType: .texture2DArray, mipmapLevel: 1\n)\ndescriptor.arrayLength = slices.count\ndescriptor.subresources = slices.enumerated().map {\n TextureSubresource(image: $0.element, mipLevel: 0, slice: $0.offset)\n}\nlet arrayTexture = Texture2D(descriptor: descriptor)", + "language": "swift", + "links": [] + }, + { + "kind": "heading", + "text": "Bounded model and image imports", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Model pipelines can install `ModelImportLimits` for one structured resource\nrequest. The bounded native paths accept glTF/GLB, OBJ and static VOX data. Terrain palette readers retain their limits across actor calls,\nreader forks and save adoption. The root/dependency byte limit applies before\nresource reads; decoded geometry and texture preparation share a conservative\nbudget. Pixel checks happen before decoding, mip conversion and GPU texture creation.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Optional `.meshlets` and `.meshletlod` files share the encoded allowance. Their\nparsed records and staging copies use the remaining decoded allowance after\nmandatory model textures. If a derived file is invalid or exceeds these limits,\nthe model keeps its original geometry and materials and reports an import warning.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaEngine\n\nlet limits = ModelImportLimits(\n maximumEncodedBytes: 16 * 1024 * 1024,\n maximumDecodedBytes: 64 * 1024 * 1024\n)\nlet model = try await ModelImportContext.$limits.withValue(limits) {\n try await AssetsManager.loadFresh(ModelAsset3D.self, at: \"@res://Models/rock.glb\")\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "`decodedRGBAByteCount(_:)` must inspect only bounded header data and return the\nRGBA8 pixel size without allocating pixel buffers. Check dimensions and arithmetic\nbefore multiplying them. Built-in PNG, JPEG, BMP and TGA decoders provide this\ninspection. The custom decoder above opts in too. The default implementation\nrejects bounded inspection explicitly; ordinary image loading keeps its existing\nbehavior when no import limits are installed. Header readers must honor `Data`\nslices whose `startIndex` is nonzero.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Bounded OBJ loading uses the built-in parser and meters text/token expansion before\nallocation. Repeated faces and distinct materials use shared buffers and indexed\nprimitive selection; nonfinite coordinates reject before GPU preparation. Bounded\nVOX loading admits raw records, dense grids and conservative meshing work before\nallocation. It requires a static, acyclic visible hierarchy with bounded depth;\nstatic subtree merging is unavailable under these limits. Ordinary imports without\nlimits preserve the existing loader/options path. Conservative estimates can reject\nan asset that would fit after geometry reduction; raise the scoped limit when needed.", "level": 0, "links": [] }, diff --git a/Editor/Sources/AdaEditor/Assets/Legal/Manifold/AUTHORS b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/AUTHORS new file mode 100644 index 000000000..a3e3c587f --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/AUTHORS @@ -0,0 +1,10 @@ +# This is the list of Manifold's significant contributors. +# +# This does not necessarily list everyone who has contributed code, +# especially since many employees of one corporation may be contributing. +# To see the full list of contributors, see the revision history in +# source control. +Emmett Lalish +Chun Kit LAM +Geoff deRosenroll +Google LLC diff --git a/Editor/Sources/AdaEditor/Assets/Legal/Manifold/LICENSE b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Editor/Sources/AdaEditor/Assets/Legal/Manifold/README.md b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/README.md new file mode 100644 index 000000000..1b2228126 --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/README.md @@ -0,0 +1,10 @@ +# Manifold notices + +Ada Studio enables the optional `MeshTerrainAuthoring` package trait on desktop. +The triangle kernel is Manifold 3.5.1, unmodified selected 3D sources from +https://github.com/elalish/manifold at cc8a7f66d7d5a560da94346258c5b546af27811e. + +`LICENSE`, `AUTHORS` and `THIRD_PARTY_NOTICES.txt` preserve upstream and embedded +notices. `UPSTREAM.json` records the source revision and original file hashes. +The wrapper and its build selection live in `Sources/CManifold` in AdaEngine. +Default compiled-player consumers do not enable this authoring dependency. diff --git a/Editor/Sources/AdaEditor/Assets/Legal/Manifold/THIRD_PARTY_NOTICES.txt b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/THIRD_PARTY_NOTICES.txt new file mode 100644 index 000000000..1553f30eb --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,120 @@ +include/manifold/linalg.h +========================= +Copyright 2026 The Manifold Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Based on linalg.h - 2.2 - Single-header public domain linear algebra library + +The intent of this library is to provide the bulk of the functionality +you need to write programs that frequently use small, fixed-size vectors +and matrices, in domains such as computational geometry or computer +graphics. It strives for terse, readable source code. + +The original author of this software is Sterling Orsten, and its permanent +home is . If you find this software +useful, an acknowledgement in your source text and/or product documentation +is appreciated, but not required. + +The author acknowledges significant insights and contributions by: + Stan Melax + Dimitri Diakopoulos + +Some features are deprecated. Define LINALG_FORWARD_COMPATIBLE to remove +them. + + +include/manifold/math.h +======================= +Copyright 2026 The Manifold Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Deterministic trigonometric helpers. + +Adapted from FreeBSD msun implementations via musl libc sources: +- https://git.musl-libc.org/cgit/musl/plain/src/math/__sin.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/__cos.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/__tan.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/__rem_pio2.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/e_acos.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/atan.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/atan2.c +- https://git.musl-libc.org/cgit/musl/plain/src/math/tan.c + +Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved. +Developed at SunPro/SunSoft, a Sun Microsystems, Inc. business. +Permission to use, copy, modify, and distribute this software is freely +granted, provided that this notice is preserved. + +src/disjoint_sets.h +=================== +from https://github.com/wjakob/dset, changed to add connected component +computation + +Copyright (c) 2015 Wenzel Jakob + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + + +src/svd.h +========= +MIT License + +Copyright (c) 2019 wi-re +Copyright 2023 The Manifold Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Modified from https://github.com/wi-re/tbtSVD, removing CUDA dependence and +approximate inverse square roots. diff --git a/Editor/Sources/AdaEditor/Assets/Legal/Manifold/UPSTREAM.json b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/UPSTREAM.json new file mode 100644 index 000000000..a86e4d191 --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Legal/Manifold/UPSTREAM.json @@ -0,0 +1,59 @@ +{ + "repository": "https://github.com/elalish/manifold", + "tag": "v3.5.1", + "revision": "cc8a7f66d7d5a560da94346258c5b546af27811e", + "features": { + "parallel": false, + "crossSection": false, + "iostream": false + }, + "files": { + "AUTHORS": "9e6e21c75344afd433233c5162511fed9891726f45274c535adfedbb39e43bc8", + "LICENSE": "1eb85fc97224598dad1852b5d6483bbcf0aa8608790dcc657a5a2a761ae9c8c6", + "include/manifold/common.h": "6a9de1f074b630cf2a40c50440746536bea126e2e6c7741efe35fb8ff0428d7a", + "include/manifold/cross_section.h": "cbabd391b9057deb1d72906834919b5f3fd8382fff085f6f793f3729be5747b7", + "include/manifold/linalg.h": "af7f88bbf8bc565ca256eb771b8ed447de2bed3c7679b6a42a6c2d4c0c18e201", + "include/manifold/manifold.h": "f7a646cd8d72a5ab0a690da0ad13a2f70191358178d3486e9b04e07fe7d26a8c", + "include/manifold/math.h": "0b2e7c506b4a8baba0c091281a70c01851ad45d26e19719929bd175501d74801", + "include/manifold/mesh.h": "60c3c607d3534681870f2e2f5afffd638673fb6f1720ef2e6b667cb224750cce", + "include/manifold/optional_assert.h": "336175509d9c347a7b68d614cb9a3b7d4c9a4a7a1c5f35c4935143e7e83ae22b", + "include/manifold/polygon.h": "c7bc74867b58fb7d0031cc093edbec2f3548fea0a96318f9eaffc8d7d5ff79c1", + "include/manifold/vec_view.h": "8a76d7d3a95e7f9e953d51cad4c02d753b00191b432f7f18e7f3ac5f5e7cdaa1", + "src/boolean3.cpp": "c629543eb56fd32711e85fbe8142ad523991d02a22f98403a83ba92d97309cb6", + "src/boolean3.h": "d643fc7868cd9dd0f187cde5cc33c0a57f845401050fb66c16ae3abd1bf9b1fb", + "src/boolean_result.cpp": "3f38aae61e932b397554ab689803a13f01a8edf5bb3587983f9b55d07980de1a", + "src/collider.h": "630b3897c70dce8ad3857dafd681159ebbde40e31607da019fc762a2b297a3e1", + "src/constructors.cpp": "0fbbeebea29e2ac5d55af9a7ad9440f0eec5d589441b4e0def8f37edbdd8fe52", + "src/csg_tree.cpp": "a30eb82bc19dc4a9c2d0e4620fc0f8086a1a6112970d59057464304371e80706", + "src/csg_tree.h": "6357cf203dd3044ba333f9e0e78644b4f4b796d9a9c775e131034717cef07c94", + "src/disjoint_sets.h": "1ab97032bbf063698197aa6a4214211b422709e7905a0277a5a20fe713cfdf6f", + "src/edge_op.cpp": "8ee7d9c6f0cd387b6b397929f92dc25d790d740366ac2b822ec9d23cd004fb78", + "src/execution_impl.cpp": "1c0ba62bd4de72df114fd78bf4189112ac9696e0abc71859e0cda0c11c6d9945", + "src/execution_impl.h": "c8a8e994df251a26b27beca8568a40fc1bf9828dc4bfdbb4c1410df80738032d", + "src/face_op.cpp": "798bc18ec84b92fa377a0ad3b6bc80d78705852b8102c680a180df824d923e34", + "src/hashtable.h": "420c13bdd3053c40c09791fa4c46180e4dcecdbe8e8438a5a7f55b418fa86e8b", + "src/impl.cpp": "a504f5fc7ecfe4e920da58a2c14f3699e9fa1a3aebec705eac93cc7a21d44dd0", + "src/impl.h": "814308612cb58795fc9312bd5ad530fea89ecbdd76db174597ee41231a86304b", + "src/iters.h": "cd95b66261851bf92d4e5ebc15ebdd6388c4f94809a69aeef153a18f38e34882", + "src/manifold.cpp": "3aaa22740b7611e09ce1adf3a0913ac01dcc66990b5a409c876f2cc3ffce77c5", + "src/mesh_fixes.h": "bfd3a301c49b69d69aeed2d771805310aa383bc7c35721d3ef966c52a8e0074e", + "src/minkowski.cpp": "ac3512609f251552803d520d4193338cf43cc41f5c686159e05705afe57ed13a", + "src/parallel.h": "8d06c29a6c3227d0ce68df68f97591bbe1f50a0d70d95a1469c299f927ccc028", + "src/polygon.cpp": "09ba6faded6695fe042f0f39faa01ebe9d374622416f5b46de7b5e01f6a53f9a", + "src/polygon_internal.h": "f245afafb5adc718021bb53be8b46c0e72e96119a370fd93645a12084eda2dff", + "src/properties.cpp": "ff450cba096ff322fea3ba35cdda8df937a17e9ae0a56d6b341b923ca4d47529", + "src/quickhull.cpp": "a26f50012c433a83ad0d6fe2388132a007a426071a115cc5bfeca9c1c4272bea", + "src/quickhull.h": "1c778818e2d41e6a3d61617fefd9a97bff0a62662f8b83238b3f17a1bbe10e03", + "src/sdf.cpp": "4ea4d520af79edb6d365af105435e8c60fcef870cbd0b518b034bd629b17c4c3", + "src/shared.h": "b90c83e62d2ffd00af1ce09b05ddadd6265aaa14f4cfd71d6282e023627858d4", + "src/smoothing.cpp": "9387554bb36f34a46e7696be57aa1d959df4a63d49aae6d4577d7173fbe51289", + "src/sort.cpp": "7c20ad45ed710768333e8cff1a827d4c393f63ccc672bb439efe2678e4f05661", + "src/subdivision.cpp": "81ed2c73b4a623aa30a02a78e6aa3dd228e17946cfdb85d380d6ac6d3a6b05d8", + "src/svd.h": "b9ecc9d47c7bfbe1c3f50d35929762b8f9d52b2266cba00c8d4c1bdac275bc39", + "src/tree2d.cpp": "817a0417e81bff42d63e9c387a30c00bd78395a20d5c8016c526e5fc2c9c7d70", + "src/tree2d.h": "745c9caec99f4b34694f0f947a14660b0b6ba9968e8569f022f473ba2d1a7c50", + "src/tri_dist.h": "e3f43d70975e3af77765ccc03d8d74a2aec311c38316ec0776e6c05bc4ecfc88", + "src/utils.h": "7a155944b719b4473f7475126d17175b3115268337d735a002e2e196b63d974b", + "src/vec.h": "d21e7da8ef87306d14767a2baa5d21237e485fbb5e44d5b6b568149b11f775de" + } +} \ No newline at end of file diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index 4edeb6b1a..2eda4ff78 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -2881,5 +2881,163 @@ "in": "in", "out": "out", "inOut": "inOut", - "outIn": "outIn" + "outIn": "outIn", + "Add to Project": "Add to Project", + "Added to Project": "Added to Project", + "All categories": "All categories", + "Animations": "Animations", + "Asset Store": "Asset Store", + "Asset details": "Asset details", + "Asset preview": "Asset preview", + "Assets and the project workspace must be on the same filesystem.": "Assets and the project workspace must be on the same filesystem.", + "Could not add the asset. Check your connection and project settings, then try again.": "Could not add the asset. Check your connection and project settings, then try again.", + "Could not load the Asset Store. Please try again.": "Could not load the Asset Store. Please try again.", + "Could not register asset resources. Check the selected executable target and Assets folder in Project Settings.": "Could not register asset resources. Check the selected executable target and Assets folder in Project Settings.", + "Downloading asset…": "Downloading asset…", + "Highest rated": "Highest rated", + "Importing asset…": "Importing asset…", + "License: {0}": "License: {0}", + "Load more": "Load more", + "Loading asset details…": "Loading asset details…", + "Loading assets…": "Loading assets…", + "Materials": "Materials", + "Newest": "Newest", + "No assets found. Try another search or category.": "No assets found. Try another search or category.", + "No description provided.": "No description provided.", + "Open a writable project to add assets.": "Open a writable project to add assets.", + "Popular": "Popular", + "Rating: {0}": "Rating: {0}", + "Save Package.swift before adding this asset.": "Save Package.swift before adding this asset.", + "Scripts": "Scripts", + "Search assets": "Search assets", + "Show in Project": "Show in Project", + "Textures": "Textures", + "The asset archive is damaged or incomplete.": "The asset archive is damaged or incomplete.", + "The asset contains unsafe file paths or the project folder is unavailable.": "The asset contains unsafe file paths or the project folder is unavailable.", + "The asset exceeds the import size or file count limits.": "The asset exceeds the import size or file count limits.", + "This ZIP format is not supported. Use an unencrypted ZIP with stored or deflated files.": "This ZIP format is not supported. Use an unencrypted ZIP with stored or deflated files.", + "This asset cannot be added: it must be available, free and include a license.": "This asset cannot be added: it must be available, free and include a license.", + "This asset is unavailable. Please try again.": "This asset is unavailable. Please try again.", + "Tools": "Tools", + "UI": "UI", + "{0} downloads": "{0} downloads", + "{0} of {1} bytes": "{0} of {1} bytes", + "Load a project font from a .ttf or .otf resource.": "Load a project font from a .ttf or .otf resource.", + "Terrain Source": "Terrain Source", + "Terrain File": "Terrain File", + "A saved terrain with chunks, caves and overhangs.": "A saved terrain with chunks, caves and overhangs.", + "Open a project scene before choosing a terrain file.": "Open a project scene before choosing a terrain file.", + "Choose an existing .aterrain file from project Assets.": "Choose an existing .aterrain file from project Assets.", + "Terrain files must be inside project Assets.": "Terrain files must be inside project Assets.", + "Preparing terrain stroke…": "Preparing terrain stroke…", + "Applying terrain stroke…": "Applying terrain stroke…", + "Saving terrain stroke…": "Saving terrain stroke…", + "Release to apply the terrain stroke.": "Release to apply the terrain stroke.", + "Terrain stroke could not be saved. Try again.": "Terrain stroke could not be saved. Try again.", + "Sculpt": "Sculpt", + "Mesh Instancing": "Mesh Instancing", + "Height Generator": "Height Generator", + "Brush Settings": "Brush Settings", + "Brush Preset": "Brush Preset", + "Add region": "Add region", + "Add Material…": "Add Material…", + "Automatic Grass / Rock / Snow": "Automatic Grass / Rock / Snow", + "Erase material paint": "Erase material paint", + "Height and slope choose automatic layers. Paint overrides them.": "Height and slope choose automatic layers. Paint overrides them.", + "Paint meshes": "Paint meshes", + "Erase meshes": "Erase meshes", + "Add Mesh…": "Add Mesh…", + "Mesh Density": "Mesh Density", + "Import a self-contained GLB or VOX model. Copies share mesh buffers.": "Import a self-contained GLB or VOX model. Copies share mesh buffers.", + "Add solid": "Add solid", + "Subtract solid": "Subtract solid", + "Box": "Box", + "Capsule": "Capsule", + "Palette mesh": "Palette mesh", + "Height Amplitude": "Height Amplitude", + "Noise Frequency": "Noise Frequency", + "Five noise octaves generate hills and valleys.": "Five noise octaves generate hills and valleys.", + "New Seed": "New Seed", + "Generate Heightmap": "Generate Heightmap", + "Drag to draw. Escape cancels. Undo restores the previous surface.": "Drag to draw. Escape cancels. Undo restores the previous surface.", + "Drawing terrain…": "Drawing terrain…", + "Choose a GLB or VOX file inside Assets.": "Choose a GLB or VOX file inside Assets.", + "Choose a PNG or JPEG file inside Assets.": "Choose a PNG or JPEG file inside Assets.", + "Sculpt terrain": "Sculpt terrain", + "Paint material": "Paint material", + "Soft hill": "Soft hill", + "Small hill": "Small hill", + "Mountain": "Mountain", + "Hard mound": "Hard mound", + "Square mound": "Square mound", + "Rock ridge": "Rock ridge", + "Soft valley": "Soft valley", + "Deep valley": "Deep valley", + "Round pit": "Round pit", + "Square pit": "Square pit", + "Narrow trench": "Narrow trench", + "Crater": "Crater", + "Gentle smooth": "Gentle smooth", + "Strong smooth": "Strong smooth", + "Round plateau": "Round plateau", + "Square plateau": "Square plateau", + "Set elevation": "Set elevation", + "Ramp": "Ramp", + "Fine terraces": "Fine terraces", + "Broad terraces": "Broad terraces", + "Fine noise": "Fine noise", + "Rock noise": "Rock noise", + "Gentle erosion": "Gentle erosion", + "Rock erosion": "Rock erosion", + "Convert to Terrain Source": "Convert to Terrain Source", + "Reduce texture repetition": "Reduce texture repetition", + "Seed: {0}": "Seed: {0}", + "Shaded": "Shaded", + "Wireframe": "Wireframe", + "Shaded + Wireframe": "Shaded + Wireframe", + "Wireframe overlay": "Overlay", + "Press Enter to apply the transform.": "Press Enter to apply the transform.", + "Enter a finite number for each edited coordinate.": "Enter a finite number for each edited coordinate.", + "Show bones": "Show bones", + "Hide bones": "Hide bones", + "Mesh Terrain": "Mesh Terrain", + "Terrain Type": "Terrain Type", + "Heightfield": "Heightfield", + "Joining": "Joining", + "Selected object": "Selected object", + "Select a static mesh in the scene.": "Select a static mesh in the scene.", + "The object stays in the hierarchy. Move it to update the terrain and surrounding objects.": "The object stays in the hierarchy. Move it to update the terrain and surrounding objects.", + "Target Terrain": "Target Terrain", + "Operation": "Operation", + "Join": "Join", + "Join selected mesh": "Join selected mesh", + "Detach from terrain": "Detach from terrain", + "Apply operation": "Apply operation", + "Conform": "Conform", + "Contact width": "Contact width", + "Projection distance": "Projection distance", + "Projection strength": "Projection strength", + "No Mesh Terrain is ready.": "No Mesh Terrain is ready.", + "Escape cancels. Undo restores the whole change.": "Escape cancels. Undo restores the whole change.", + "Preparing mesh terrain…": "Preparing mesh terrain…", + "Saving mesh terrain…": "Saving mesh terrain…", + "Updating mesh terrain…": "Updating mesh terrain…", + "Mesh Terrain could not be updated.": "Mesh Terrain could not be updated.", + "Moving joined mesh…": "Moving joined mesh…", + "This group must have a single Mesh Terrain target.": "This group must have a single Mesh Terrain target.", + "Too many terrain sources are waiting for a pose update.": "Too many terrain sources are waiting for a pose update.", + "Mesh Terrain could not be restored. The last valid surface is still visible.": "Mesh Terrain could not be restored. The last valid surface is still visible.", + "Mesh Terrain restoration was cancelled.": "Mesh Terrain restoration was cancelled.", + "The selected Mesh Terrain is unavailable. Choose another terrain.": "The selected Mesh Terrain is unavailable. Choose another terrain.", + "Terrain is not ready. Select a terrain and wait for it to load.": "Terrain is not ready. Select a terrain and wait for it to load.", + "Terrain is busy. Wait for the current change to finish.": "Terrain is busy. Wait for the current change to finish.", + "Terrain changed while the edit was being prepared. Try again.": "Terrain changed while the edit was being prepared. Try again.", + "Terrain update timed out. The last valid surface is still visible.": "Terrain update timed out. The last valid surface is still visible.", + "The terrain resource is too large to edit.": "The terrain resource is too large to edit.", + "Restoring Mesh Terrain…": "Restoring Mesh Terrain…", + "Choose a Heightfield for this tool.": "Choose a Heightfield for this tool.", + "Choose Sculpt to edit the shape of Mesh Terrain.": "Choose Sculpt to edit the shape of Mesh Terrain.", + "Drawing mesh terrain…": "Drawing mesh terrain…", + "Split this brush stroke into smaller strokes.": "Split this brush stroke into smaller strokes.", + "Move the whole joined mesh inside the terrain boundary.": "Move the whole joined mesh inside the terrain boundary." } diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index 5774c70ba..f0b1bb2fd 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -2626,7 +2626,7 @@ "Preview could not compile. The last valid material is kept.": "Не удалось собрать предпросмотр. Сохранён последний рабочий материал.", "The renderer could not prepare this graph material.": "Не удалось подготовить материал графа для рендера.", "Compiling graph…": "Сборка графа…", - "Sphere": "Сфера", + "Sphere": "Шар", "Plane": "Плоскость", "Dissolve Shader Graph": "Граф растворения", "UV Scroll Shader Graph": "Граф прокрутки UV", @@ -2881,5 +2881,163 @@ "in": "Разгон", "out": "Замедление", "inOut": "Разгон и замедление", - "outIn": "Замедление и разгон" + "outIn": "Замедление и разгон", + "Add to Project": "Добавить в проект", + "Added to Project": "Добавлено в проект", + "All categories": "Все категории", + "Animations": "Анимации", + "Asset Store": "Магазин ассетов", + "Asset details": "Описание ассета", + "Asset preview": "Изображение ассета", + "Assets and the project workspace must be on the same filesystem.": "Папка ассетов и проект должны находиться на одном диске.", + "Could not add the asset. Check your connection and project settings, then try again.": "Не удалось добавить ассет. Проверь подключение и настройки проекта, затем попробуй ещё раз.", + "Could not load the Asset Store. Please try again.": "Не удалось открыть магазин ассетов. Попробуй ещё раз.", + "Could not register asset resources. Check the selected executable target and Assets folder in Project Settings.": "Не удалось подключить файлы ассета. Проверь выбранную цель запуска и папку ассетов в настройках проекта.", + "Downloading asset…": "Скачиваем ассет…", + "Highest rated": "По оценке", + "Importing asset…": "Добавляем ассет…", + "License: {0}": "Лицензия: {0}", + "Load more": "Показать ещё", + "Loading asset details…": "Загружаем описание ассета…", + "Loading assets…": "Загружаем ассеты…", + "Materials": "Материалы", + "Newest": "Новые", + "No assets found. Try another search or category.": "Ассеты не найдены. Попробуй другой запрос или категорию.", + "No description provided.": "Описания пока нет.", + "Open a writable project to add assets.": "Открой проект, в который можно добавлять файлы.", + "Popular": "Популярные", + "Rating: {0}": "Оценка: {0}", + "Save Package.swift before adding this asset.": "Сначала сохрани Package.swift, затем добавь ассет.", + "Scripts": "Скрипты", + "Search assets": "Поиск ассетов", + "Show in Project": "Показать в проекте", + "Textures": "Текстуры", + "The asset archive is damaged or incomplete.": "Архив ассета повреждён или скачался не полностью.", + "The asset contains unsafe file paths or the project folder is unavailable.": "В архиве есть недопустимые пути или папка проекта недоступна.", + "The asset exceeds the import size or file count limits.": "Ассет слишком большой или содержит слишком много файлов.", + "This ZIP format is not supported. Use an unencrypted ZIP with stored or deflated files.": "Этот ZIP не поддерживается. Нужен обычный ZIP без пароля, со сжатием Deflate или без сжатия.", + "This asset cannot be added: it must be available, free and include a license.": "Можно добавить только доступный бесплатный ассет с указанной лицензией.", + "This asset is unavailable. Please try again.": "Этот ассет недоступен. Попробуй ещё раз.", + "Tools": "Инструменты", + "UI": "Интерфейс", + "{0} downloads": "Скачиваний: {0}", + "{0} of {1} bytes": "{0} из {1} байт", + "Load a project font from a .ttf or .otf resource.": "Выбрать шрифт проекта из файла .ttf или .otf.", + "Terrain Source": "Terrain из файла", + "Terrain File": "Файл terrain", + "A saved terrain with chunks, caves and overhangs.": "Сохранённый terrain с чанками, пещерами и нависаниями.", + "Open a project scene before choosing a terrain file.": "Открой сцену проекта, прежде чем выбирать файл terrain.", + "Choose an existing .aterrain file from project Assets.": "Выбери существующий файл .aterrain в папке Assets проекта.", + "Terrain files must be inside project Assets.": "Файлы terrain должны находиться в папке Assets проекта.", + "Preparing terrain stroke…": "Подготавливаем мазок…", + "Applying terrain stroke…": "Изменяем поверхность…", + "Saving terrain stroke…": "Сохраняем поверхность…", + "Release to apply the terrain stroke.": "Отпустите кнопку, чтобы применить мазок.", + "Terrain stroke could not be saved. Try again.": "Не удалось сохранить мазок. Попробуйте ещё раз.", + "Sculpt": "Рельеф", + "Mesh Instancing": "Инстансинг мешей", + "Height Generator": "Генератор высот", + "Brush Settings": "Настройки кисти", + "Brush Preset": "Готовая кисть", + "Add region": "Добавить участок", + "Add Material…": "Добавить материал…", + "Automatic Grass / Rock / Snow": "Автоматически: трава, камень, снег", + "Erase material paint": "Стереть окраску", + "Height and slope choose automatic layers. Paint overrides them.": "Высота и наклон выбирают материал. Кисть заменяет этот выбор.", + "Paint meshes": "Рисовать мешами", + "Erase meshes": "Стереть меши", + "Add Mesh…": "Добавить меш…", + "Mesh Density": "Плотность мешей", + "Import a self-contained GLB or VOX model. Copies share mesh buffers.": "Добавьте GLB или VOX. Экземпляры используют общую геометрию.", + "Add solid": "Добавить объём", + "Subtract solid": "Вычесть объём", + "Box": "Куб", + "Capsule": "Капсула", + "Palette mesh": "Меш из палитры", + "Height Amplitude": "Высота холмов", + "Noise Frequency": "Частота шума", + "Five noise octaves generate hills and valleys.": "Пять слоёв шума создают холмы и долины.", + "New Seed": "Другой seed", + "Generate Heightmap": "Создать карту высот", + "Drag to draw. Escape cancels. Undo restores the previous surface.": "Рисуйте перетаскиванием. Escape отменяет мазок. Undo возвращает прежнюю поверхность.", + "Drawing terrain…": "Рисуем поверхность…", + "Choose a GLB or VOX file inside Assets.": "Выберите GLB или VOX в Assets.", + "Choose a PNG or JPEG file inside Assets.": "Выберите PNG или JPEG в Assets.", + "Sculpt terrain": "Изменять рельеф", + "Paint material": "Рисовать материалом", + "Soft hill": "Мягкий холм", + "Small hill": "Небольшой холм", + "Mountain": "Гора", + "Hard mound": "Крутой холм", + "Square mound": "Квадратный холм", + "Rock ridge": "Каменный хребет", + "Soft valley": "Пологая долина", + "Deep valley": "Глубокая долина", + "Round pit": "Круглая яма", + "Square pit": "Квадратная яма", + "Narrow trench": "Узкая траншея", + "Crater": "Кратер", + "Gentle smooth": "Мягкое сглаживание", + "Strong smooth": "Сильное сглаживание", + "Round plateau": "Круглое плато", + "Square plateau": "Квадратное плато", + "Set elevation": "Задать высоту", + "Ramp": "Склон", + "Fine terraces": "Мелкие террасы", + "Broad terraces": "Широкие террасы", + "Fine noise": "Мелкий шум", + "Rock noise": "Каменистый шум", + "Gentle erosion": "Мягкая эрозия", + "Rock erosion": "Каменная осыпь", + "Convert to Terrain Source": "Преобразовать в Terrain Source", + "Reduce texture repetition": "Уменьшить повторение текстур", + "Seed: {0}": "Seed: {0}", + "Shaded": "Обычный", + "Wireframe": "Каркас", + "Shaded + Wireframe": "Материалы + рёбра", + "Wireframe overlay": "Рёбра +", + "Press Enter to apply the transform.": "Нажмите Enter, чтобы применить изменение.", + "Enter a finite number for each edited coordinate.": "Введите обычное число для каждой изменённой координаты.", + "Show bones": "Показать кости", + "Hide bones": "Скрыть кости", + "Mesh Terrain": "Земля из мешей", + "Terrain Type": "Тип земли", + "Heightfield": "Карта высот", + "Joining": "Сращивание", + "Selected object": "Выбранный объект", + "Select a static mesh in the scene.": "Выбери неподвижный меш в сцене.", + "The object stays in the hierarchy. Move it to update the terrain and surrounding objects.": "Объект остаётся в иерархии. Перемещай его, чтобы перестроить землю и окружение.", + "Target Terrain": "Земля для сращивания", + "Operation": "Операция", + "Join": "Объединение", + "Join selected mesh": "Срастить выбранный меш", + "Detach from terrain": "Отсоединить от земли", + "Apply operation": "Применить операцию", + "Conform": "Подогнать поверхность", + "Contact width": "Ширина перехода", + "Projection distance": "Дальность проекции", + "Projection strength": "Сила проекции", + "No Mesh Terrain is ready.": "Готовой земли из мешей пока нет.", + "Escape cancels. Undo restores the whole change.": "Escape — отменить. Undo — восстановить всё изменение.", + "Preparing mesh terrain…": "Подготавливаем землю из мешей…", + "Saving mesh terrain…": "Сохраняем землю из мешей…", + "Updating mesh terrain…": "Перестраиваем землю из мешей…", + "Mesh Terrain could not be updated.": "Не удалось обновить землю из мешей.", + "Moving joined mesh…": "Перемещаем соединённый меш…", + "This group must have a single Mesh Terrain target.": "Все соединённые объекты группы должны относиться к одному участку Mesh Terrain.", + "Too many terrain sources are waiting for a pose update.": "Слишком много объектов ждут обновления положения.", + "Mesh Terrain could not be restored. The last valid surface is still visible.": "Не удалось восстановить землю. Показана последняя рабочая поверхность.", + "Mesh Terrain restoration was cancelled.": "Восстановление земли отменено.", + "The selected Mesh Terrain is unavailable. Choose another terrain.": "Выбранная земля из мешей недоступна. Выберите другую.", + "Terrain is not ready. Select a terrain and wait for it to load.": "Земля ещё не готова. Выберите её и дождитесь загрузки.", + "Terrain is busy. Wait for the current change to finish.": "Земля обновляется. Дождитесь завершения.", + "Terrain changed while the edit was being prepared. Try again.": "Земля изменилась во время подготовки. Попробуйте ещё раз.", + "Terrain update timed out. The last valid surface is still visible.": "Не дождались обновления земли. Последняя рабочая поверхность остаётся видимой.", + "The terrain resource is too large to edit.": "Ресурс земли слишком большой для редактирования.", + "Restoring Mesh Terrain…": "Восстанавливаем землю из мешей…", + "Choose a Heightfield for this tool.": "Выберите землю типа Heightfield для этого инструмента.", + "Choose Sculpt to edit the shape of Mesh Terrain.": "Выберите вкладку «Рельеф», чтобы изменить форму земли из мешей.", + "Drawing mesh terrain…": "Меняем форму земли из мешей…", + "Split this brush stroke into smaller strokes.": "Разделите мазок на несколько более коротких.", + "Move the whole joined mesh inside the terrain boundary.": "Переместите соединённый меш целиком внутрь границ земли." } diff --git a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift index 98886aff4..f1b611f4a 100644 --- a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift +++ b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift @@ -25,6 +25,7 @@ enum EditorBuiltInComponentType { static let mesh2D = String(reflecting: Mesh2D.self) static let mesh3D = String(reflecting: Mesh3DComponent.self) static let model3DSource = String(reflecting: Model3DSource.self) + static let terrain3DSource = String(reflecting: Terrain3DSource.self) static let audioSource = String(reflecting: AudioSource.self) static let physicsBody3D = String(reflecting: PhysicsBody3DComponent.self) static let directionalLight3D = String(reflecting: DirectionalLightComponent.self) @@ -101,7 +102,9 @@ struct EditorComponentField: Equatable, Identifiable, Sendable { } func displayValue(in payload: EditorComponentPayload) -> String { - if coding == .shaderGraphValue { return EditorShaderGraphFields.display(self, in: payload) } + if coding == .shaderGraphValue { + return EditorShaderGraphFields.display(self, in: payload) + } if coding == .spriteAnchorPreset { return EditorSpriteLayoutFields.anchorPreset(in: payload) } @@ -283,6 +286,7 @@ enum EditorComponentRegistry { mesh2DDescriptor, mesh3DDescriptor, terrainDescriptor, + terrainSourceDescriptor, model3DSourceDescriptor, spineSourceDescriptor, audioSourceDescriptor, @@ -303,7 +307,9 @@ enum EditorComponentRegistry { @MainActor static func registerBuiltIns() { - guard !hasRegisteredBuiltIns else { return } + guard !hasRegisteredBuiltIns else { + return + } DisplayLayout.registerRuntimeType() RuntimeTypeRegistry.registerComponent(ParticleEmitter.self, names: ["ParticleEmitter"]) RuntimeTypeRegistry.registerComponent(ParticleEmitter3D.self, names: ["ParticleEmitter3D"]) @@ -355,6 +361,9 @@ enum EditorComponentRegistry { RuntimeTypeRegistry.registerComponent(Mesh2D.self, names: ["Mesh2D"]) RuntimeTypeRegistry.registerComponent(Mesh3DComponent.self, names: ["Mesh3DComponent"]) RuntimeTypeRegistry.registerComponent(Terrain3D.self, names: ["Terrain3D"]) + RuntimeTypeRegistry.registerComponent(Terrain3DSource.self, names: ["Terrain3DSource"]) + RuntimeTypeRegistry.registerComponent(MeshTerrain3DIdentity.self, names: ["MeshTerrain3DIdentity"]) + RuntimeTypeRegistry.registerComponent(MeshTerrain3DBinding.self, names: ["MeshTerrain3DBinding"]) RuntimeTypeRegistry.registerComponent(PhysicsBody3DComponent.self, names: ["PhysicsBody3DComponent"]) RuntimeTypeRegistry.registerComponent(DirectionalLightComponent.self, names: ["DirectionalLightComponent"]) RuntimeTypeRegistry.registerComponent(PointLightComponent.self, names: ["PointLightComponent"]) diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainCreation.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainCreation.swift new file mode 100644 index 000000000..dddaed3cf --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainCreation.swift @@ -0,0 +1,113 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +/// New resources remain private until the caller publishes one scene/history change. +enum EditorMeshTerrainCreation { + struct File: Sendable { + let url: URL + let digest: Data + let count: Int + } + struct Receipt: Sendable { let files: [File] } + + /// Changes resource references while sharing the unchanged compiled document with history. + @concurrent + static func forkResources(_ command: EditorTerrainStrokeCommand, from original: URL) async throws -> (URL, Receipt) { + let cache = try await TerrainRegionCache.open(manifestAt: original.path) + let previous = await cache.manifest + guard previous.meshDocumentFileName != nil else { throw Failure.invalidDestination } + let update = try EditorTerrainResourceWriter.planManifestUpdate(command, manifest: previous) + let directory = original.deletingLastPathComponent() + let file = directory.appendingPathComponent("edit-\(UUID().uuidString.lowercased()).aterrain") + var created: [File] = [] + do { + if let payload = update.payload { created.append(try stage(payload.data, at: directory.appendingPathComponent(payload.name))) } + created.append(try stage(update.manifest.encodedData(), at: file)) + try Task.checkCancellation() + return (file, .init(files: created)) + } catch { + await discard(.init(files: created)) + throw error + } + } + + @concurrent + static func fork(_ asset: MeshTerrainDocumentAsset, from original: URL) async throws -> (URL, Receipt) { + _ = try asset.acceptedCompiledProduct() + let cache = try await TerrainRegionCache.open(manifestAt: original.path) + let previous = await cache.manifest + guard previous.meshDocumentFileName != nil else { throw Failure.invalidDestination } + let name = "mesh-\(UUID().uuidString.lowercased()).atmesh" + let file = original.deletingLastPathComponent().appendingPathComponent("edit-\(UUID().uuidString.lowercased()).aterrain") + let manifest = TerrainManifestAsset(spacing: previous.spacing, originX: previous.originX, originZ: previous.originZ, + heightRegions: [], meshPrototypes: previous.meshPrototypes, materials: previous.materials, meshDocumentFileName: name) + var created: [File] = [] + do { + created.append(try stage(asset.encodedData(), at: file.deletingLastPathComponent().appendingPathComponent(name))) + created.append(try stage(manifest.encodedData(), at: file)) + try Task.checkCancellation() + return (file, Receipt(files: created)) + } catch { + await discard(Receipt(files: created)) + throw error + } + } + + @concurrent + static func create(at file: URL, noise: EditorTerrainNoise) async throws -> Receipt { + try Task.checkCancellation() + guard file.isFileURL, file.pathExtension.lowercased() == "aterrain" else { throw Failure.invalidDestination } + var surface = try TriangleSurface.ground(size: [128, 128], cells: 64) + for index in surface.positions.indices { + if index.isMultiple(of: 256) { try Task.checkCancellation() } + let point = surface.positions[index] + surface.positions[index].y = noise.height(x: point.x, z: point.z) + } + surface.normals = [] + let bottom = (surface.positions.map(\.y).min() ?? 0) - 32 + let asset = try await MeshTerrainDocumentAsset(document: .init(sections: [.init(surface: surface, closure: .ground(bottomHeight: bottom))])).compiling() + _ = try asset.acceptedCompiledProduct() + let name = "mesh-\(UUID().uuidString.lowercased()).atmesh" + let manifest = TerrainManifestAsset(heightRegions: [], meshDocumentFileName: name) + var created: [File] = [] + do { + created.append(try stage(asset.encodedData(), at: file.deletingLastPathComponent().appendingPathComponent(name))) + created.append(try stage(manifest.encodedData(), at: file)) + try Task.checkCancellation() + return Receipt(files: created) + } catch { + await discard(Receipt(files: created)) + throw error + } + } + + /// Preserve an external replacement instead of removing a file the user has since changed. + @concurrent + static func discard(_ receipt: Receipt) async { + for file in receipt.files.reversed() { + guard let handle = try? FileHandle(forReadingFrom: file.url) else { continue } + let data = try? handle.read(upToCount: file.count + 1) + try? handle.close() + if let data, data.count == file.count, MeshTerrainContentKey.digest(data) == file.digest { + try? FileManager.default.removeItem(at: file.url) + } + } + } + + private static func stage(_ data: Data, at destination: URL) throws -> File { + let temporary = destination.deletingLastPathComponent().appendingPathComponent(".ada-mesh-\(UUID().uuidString).tmp") + try data.write(to: temporary, options: .withoutOverwriting) + defer { try? FileManager.default.removeItem(at: temporary) } + let handle = try FileHandle(forWritingTo: temporary) + do { try handle.synchronize(); try handle.close() } + catch { try? handle.close(); throw error } + try Task.checkCancellation() + // A same-directory move publishes a complete, newly named immutable file; + // Foundation refuses an existing destination. No old generation is replaced. + try FileManager.default.moveItem(at: temporary, to: destination) + return File(url: destination, digest: MeshTerrainContentKey.digest(data), count: data.count) + } + + enum Failure: Error { case invalidDestination } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainDragSession.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainDragSession.swift new file mode 100644 index 000000000..e2dbf0558 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainDragSession.swift @@ -0,0 +1,58 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Retains the pre-gesture generation before the first provisional world pose. +@MainActor +final class EditorMeshTerrainDragSession { + struct Snapshot: Sendable { + let source: Terrain3DSource + let asset: MeshTerrainDocumentAsset + let ownership: MeshTerrain3DOwnership + let binding: MeshTerrain3DBinding + let identity: MeshTerrain3DIdentity + let draws: Set + let collisions: Set + } + + let sourceEditorID: String + let targetEditorID: String + let source: Entity + let owner: Entity + let initialPose: Transform + let initialPayload: EditorComponentPayload + let initialContent: String + let generation: UUID + let captured: Task + let ready: Task + + init(sourceEditorID: String, targetEditorID: String, source: Entity, owner: Entity, initialPose: Transform, + initialPayload: EditorComponentPayload, initialContent: String, generation: UUID, application: AppWorlds) { + self.sourceEditorID = sourceEditorID; self.targetEditorID = targetEditorID; self.source = source; self.owner = owner + self.initialPose = initialPose; self.initialPayload = initialPayload; self.initialContent = initialContent; self.generation = generation + let captured = Task { @MainActor in + try await application.withWorldAccess { + guard application.main.getEntityByID(source.id) === source, application.main.getEntityByID(owner.id) === owner, + let settings = owner.components[Terrain3DSource.self], let asset = owner.components[MeshTerrain3DSourceSurface.self]?.asset, + let binding = source.components[MeshTerrain3DBinding.self], binding.enabled, + binding.target == owner.components[MeshTerrain3DIdentity.self]?.id, + let identity = source.components[MeshTerrain3DIdentity.self], source.components[Transform.self] == initialPose, + source.components[MeshTerrain3DProposedPose.self] == nil, owner.components[MeshTerrain3DEdit.self] == nil else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: application.main) + let parts = try MeshTerrainSourceCapture.capture(source, parts: binding.parts) + let possibleCollisions = try MeshTerrainSourceCapture.collisionSources(source, parts: binding.parts, in: application.main) + return .init(source: settings, asset: asset, ownership: ownership, binding: binding, identity: identity, + draws: Set(parts.drawReplacements.map(\.entity)).intersection(ownership.draws.map(\.entity)), + collisions: Set(possibleCollisions).intersection(ownership.collisions)) + } + } + self.captured = captured + self.ready = Task { _ = try await captured.value } + } + + func bindingUpdate(_ snapshot: Snapshot) -> MeshTerrainBindingUpdate { + .init(entity: source.id, modifier: snapshot.binding.modifier, identity: snapshot.identity, binding: snapshot.binding, + draws: snapshot.draws, collisions: snapshot.collisions) + } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainHistory.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainHistory.swift new file mode 100644 index 000000000..448143d3f --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainHistory.swift @@ -0,0 +1,188 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Restores existing immutable versions without replacing their resident scene. +@MainActor +enum EditorMeshTerrainHistory { + struct Source { + let editorID: String + let pose: Transform + let previousBinding: MeshTerrain3DBinding? + let binding: MeshTerrain3DBinding? + let identity: MeshTerrain3DIdentity? + } + + struct Plan { + let ownerEditorID: String + let target: UUID + let uri: String + let sources: [Source] + + static func make(previous: EditorSceneModel, next: EditorSceneModel) throws -> Self? { + guard previous.entities.map(\.id) == next.entities.map(\.id) else { return nil } + let terrainKey = EditorBuiltInComponentType.terrain3DSource + let identityKey = String(reflecting: MeshTerrain3DIdentity.self) + let bindingKey = String(reflecting: MeshTerrain3DBinding.self) + let owners = previous.entities.indices.filter { + previous.entities[$0].components[terrainKey]?["source"] != next.entities[$0].components[terrainKey]?["source"] + } + guard owners.count == 1, let ownerIndex = owners.first, + let before = previous.entities[ownerIndex].components[terrainKey], + let after = next.entities[ownerIndex].components[terrainKey], + let uri = after["source"]?.stringValue, + let identity = try decode(MeshTerrain3DIdentity.self, previous.entities[ownerIndex].components[identityKey]) else { return nil } + var normalized = next + normalized.editor = previous.editor + normalized.entities[ownerIndex].components[terrainKey] = before + guard normalized.entities[ownerIndex] == previous.entities[ownerIndex] else { return nil } + var sources: [Source] = [] + var joinedDescendants: Set = [] + for index in previous.entities.indices where index != ownerIndex && previous.entities[index] != next.entities[index] { + let old = previous.entities[index], new = next.entities[index] + let previousBinding = try decode(MeshTerrain3DBinding.self, old.components[bindingKey]) + let binding = try decode(MeshTerrain3DBinding.self, new.components[bindingKey]) + let unbound = previousBinding == nil && binding == nil + let transformChanged = old.components[EditorBuiltInComponentType.transform] != new.components[EditorBuiltInComponentType.transform] + if unbound, !transformChanged || old.components[identityKey] != new.components[identityKey] { return nil } + if transformChanged { + let subtree = Set(next.subtreeEntityIDs(rootedAt: new.id).dropFirst()) + let descendants = try next.entities.filter { entity in + guard subtree.contains(entity.id), let child = try decode(MeshTerrain3DBinding.self, entity.components[bindingKey]) else { return false } + return child.enabled && child.target == identity.id + } + if unbound, descendants.isEmpty { return nil } + joinedDescendants.formUnion(descendants.map(\.id)) + } + guard previousBinding.map({ $0.target == identity.id }) ?? true, binding.map({ $0.target == identity.id }) ?? true, + previousBinding?.modifier == nil || binding?.modifier == nil || previousBinding?.modifier == binding?.modifier else { return nil } + for key in [EditorBuiltInComponentType.transform, identityKey, bindingKey] { + normalized.entities[index].components[key] = old.components[key] + } + guard normalized.entities[index] == old else { return nil } + sources.append(.init(editorID: new.id, pose: try pose(in: new), + previousBinding: previousBinding, binding: binding, identity: try decode(MeshTerrain3DIdentity.self, new.components[identityKey]))) + } + joinedDescendants.subtract(sources.map(\.editorID)) + for entity in next.entities where joinedDescendants.contains(entity.id) { + let binding = try decode(MeshTerrain3DBinding.self, entity.components[bindingKey]) + sources.append(.init(editorID: entity.id, pose: try pose(in: entity), previousBinding: binding, binding: binding, + identity: try decode(MeshTerrain3DIdentity.self, entity.components[identityKey]))) + } + guard normalized == previous, sources.count <= 512 else { return nil } + return .init(ownerEditorID: previous.entities[ownerIndex].id, target: identity.id, uri: uri, sources: sources) + } + + private static func pose(in entity: EditorSceneEntity) throws -> Transform { + try entity.components[EditorBuiltInComponentType.transform].flatMap { + try EditorComponentPayloadDecoder.decode(Transform.self, payload: $0) as? Transform + } ?? .init() + } + + private static func decode(_ type: Value.Type, _ payload: EditorComponentPayload?) throws -> Value? { + try payload.map { try JSONDecoder().decode(type, from: JSONEncoder().encode($0)) } + } + } + + @concurrent + static func prepare(_ file: URL) async throws -> MeshTerrainDocumentAsset { + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + let manifest = await cache.manifest + guard let name = manifest.meshDocumentFileName else { throw EditorTerrainSourceAuthoring.Failure.unavailable } + let asset = try await AssetsManager.loadFresh(MeshTerrainDocumentAsset.self, at: file.deletingLastPathComponent().appendingPathComponent(name).path) + _ = try asset.acceptedCompiledProduct() + try Task.checkCancellation() + return asset + } + + static func apply(_ plan: Plan, candidate: MeshTerrainDocumentAsset, file: URL, owner: Entity, + roots: [String: Entity], application: AppWorlds, isCurrent: () -> Bool) async throws { + var request: MeshTerrain3DEdit? + do { + try await application.withWorldAccess { + guard isCurrent(), application.main.getEntityByID(owner.id) === owner, + owner.components[MeshTerrain3DIdentity.self]?.id == plan.target, + let surface = owner.components[MeshTerrain3DSourceSurface.self], let inputKey = surface.asset.compiled?.inputKey, + surface.asset.document.id == candidate.document.id, owner.components[MeshTerrain3DEdit.self] == nil else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + if owner.components[Terrain3DSource.self]?.source == file.path, inputKey == candidate.compiled?.inputKey, + plan.sources.allSatisfy({ change in + guard let root = roots[change.editorID], application.main.getEntityByID(root.id) === root else { return false } + return root.components[Transform.self] == change.pose && root.components[MeshTerrain3DBinding.self] == change.binding + && root.components[MeshTerrain3DIdentity.self] == change.identity && root.components[MeshTerrain3DProposedPose.self] == nil + && (change.binding?.enabled != true || root.components[MeshTerrain3DBindingStatus.self]?.phase == .applied) + }) { return } + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: application.main) + var draws = Dictionary(uniqueKeysWithValues: ownership.draws.map { ($0.entity, $0) }) + var collisions = Set(ownership.collisions) + var poses: [MeshTerrainSourceTransform] = [], updates: [MeshTerrainBindingUpdate] = [] + for change in plan.sources { + guard let root = roots[change.editorID], application.main.getEntityByID(root.id) === root else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let residentBinding = root.components[MeshTerrain3DBinding.self] + guard residentBinding == change.previousBinding || residentBinding == change.binding else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + poses.append(.init(entity: root.id, pose: change.pose)) + if change.previousBinding == nil, change.binding == nil { continue } + if let old = residentBinding { + let previous = try MeshTerrainSourceCapture.capture(root, parts: old.parts) + for draw in previous.drawReplacements { draws.removeValue(forKey: draw.entity) } + collisions.subtract(try MeshTerrainSourceCapture.collisionSources(root, parts: old.parts, in: application.main)) + } + var selected: [MeshTerrainDrawReplacement] = [], selectedCollisions: Set = [] + if let binding = change.binding, binding.enabled, binding.operation == .union { + selected = try MeshTerrainSourceCapture.capture(root, parts: binding.parts).drawReplacements + for draw in selected { draws[draw.entity] = draw } + if owner.components[Terrain3DSource.self]?.generatesCollision == true, application.main.physicsWorld3D != nil { + selectedCollisions = Set(try MeshTerrainSourceCapture.collisionSources(root, parts: binding.parts, in: application.main)) + collisions.formUnion(selectedCollisions) + } + } + guard let modifier = change.binding?.modifier ?? change.previousBinding?.modifier else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + updates.append(.init(entity: root.id, modifier: modifier, identity: change.identity, binding: change.binding, + draws: Set(selected.map(\.entity)), collisions: selectedCollisions)) + } + let edit = MeshTerrain3DEdit(expectedInputKey: inputKey, candidate: candidate, sourceTransforms: poses, + drawReplacements: draws.values.sorted { $0.entity < $1.entity }, collisionReplacements: collisions.sorted(), + adoptionManifest: file.path, bindingUpdates: updates) + request = edit + owner.components[MeshTerrain3DEdit.self] = edit + } + guard let submitted = request else { return } + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + let done = try await application.withWorldAccess { + guard isCurrent(), application.main.getEntityByID(owner.id) === owner else { throw CancellationError() } + guard let status = owner.components[MeshTerrain3DEditStatus.self], status.id == submitted.id else { return false } + switch status.phase { + case .pending: return false + case .failed: throw EditorTerrainSourceAuthoring.Failure.failed(status.error ?? "Mesh Terrain could not be updated.") + case .cancelled: throw CancellationError() + case .applied: + guard owner.components[Terrain3DSource.self]?.source == file.path, + owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == candidate.compiled?.inputKey else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + return true + } + } + if done { return } + try await Task.sleep(for: .milliseconds(10)) + } + throw EditorTerrainSourceAuthoring.Failure.timedOut + } catch { + await Task { @MainActor in + try? await application.withWorldAccess { + if application.main.getEntityByID(owner.id) === owner, owner.components[MeshTerrain3DEdit.self]?.id == request?.id { + owner.components.remove(MeshTerrain3DEdit.self) + } + } + }.value + throw error + } + } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainHistoryQueue.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainHistoryQueue.swift new file mode 100644 index 000000000..dcd44b3e2 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainHistoryQueue.swift @@ -0,0 +1,46 @@ +/// One active history restore and at most one pending restore. A new request +/// replaces the pending value and cancels the active worker before it is drained. +@MainActor +final class EditorMeshTerrainHistoryQueue { + struct Intent { + let apply: @MainActor () async throws -> Void + let failed: @MainActor (String) -> Void + let finished: @MainActor () -> Void + } + + private var pending: Intent? + private var active: Task? + private var worker: Task? + var isRunning: Bool { worker != nil } + + func submit(_ intent: Intent) -> Task { + pending = intent + active?.cancel() + if let worker { return worker } + let task = Task { @MainActor [weak self] in + guard let self else { return } + var latest: Intent? + defer { + self.worker = nil + latest?.finished() + } + while let next = self.pending { + self.pending = nil + latest = next + let operation = Task { @MainActor in try await next.apply() } + self.active = operation + do { try await operation.value } + catch is CancellationError {} + catch { if self.pending == nil { next.failed(error.localizedDescription) } } + self.active = nil + } + } + worker = task + return task + } + + func cancel() { + pending = nil + active?.cancel() + } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainPoseQueue.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainPoseQueue.swift new file mode 100644 index 000000000..191f88424 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainPoseQueue.swift @@ -0,0 +1,75 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// One bounded mailbox for UI poses. World mutation always occurs between updates; +/// newer pointer samples replace older samples for the same retained source. +@MainActor +final class EditorMeshTerrainPoseQueue { + private struct Intent { + let pose: Transform + let source: Entity + let prepared: Task? + let ticket: MeshTerrainEditTicket + } + private var pending: [Entity.ID: Intent] = [:] + private var tickets: [Entity.ID: MeshTerrainEditTicket] = [:] + private var worker: Task? + private var generation = UUID() + + @discardableResult + func propose(_ pose: Transform, for source: Entity, application: AppWorlds, prepared: Task? = nil) -> Bool { + tickets = tickets.filter { $0.value.isPending } + guard tickets[source.id] != nil || tickets.count < 512 else { return false } + tickets[source.id]?.supersede() + let ticket = MeshTerrainEditTicket() + tickets[source.id] = ticket + pending[source.id] = .init(pose: pose, source: source, prepared: prepared, ticket: ticket) + guard worker == nil else { return true } + let generation = generation + worker = Task { @MainActor [weak self] in + guard let self else { return } + defer { if self.generation == generation { self.worker = nil } } + while !self.pending.isEmpty { + do { + let batch = self.pending + self.pending.removeAll(keepingCapacity: true) + var accepted: [Entity.ID: Intent] = [:] + for (id, intent) in batch { + do { try await intent.prepared?.value; accepted[id] = intent } + catch { + if self.tickets[id] === intent.ticket { self.discard(id) } + } + } + try await application.withWorldAccess { + guard self.generation == generation else { throw CancellationError() } + for id in accepted.keys.sorted() { + guard let intent = accepted[id], self.tickets[id] === intent.ticket, intent.ticket.isPending else { continue } + guard application.main.getEntityByID(id) === intent.source else { self.discard(id); continue } + if intent.source.components[MeshTerrain3DBinding.self]?.enabled == true { + intent.source.components[MeshTerrain3DProposedPose.self] = .init(intent.pose, ticket: intent.ticket) + } else { + intent.source.components[Transform.self] = intent.pose + self.discard(id) + } + } + } + } catch { return } + } + } + return true + } + + func discard(_ source: Entity.ID) { + pending.removeValue(forKey: source) + tickets.removeValue(forKey: source)?.supersede() + } + + func cancel() { + generation = UUID() + pending.removeAll(keepingCapacity: false) + for ticket in tickets.values { ticket.supersede() } + tickets.removeAll(keepingCapacity: false) + worker?.cancel() + worker = nil + } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainSculptStroke.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainSculptStroke.swift new file mode 100644 index 000000000..2b1b51a3b --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainSculptStroke.swift @@ -0,0 +1,257 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +/// Captures one terrain generation and coalesces stationary pointer samples. +/// A worker always evaluates a snapshot of the retained stamps, never the last preview's vertices. +@MainActor +final class EditorMeshTerrainSculptStroke { + enum Tool { + case shape(EditorTerrainBrushMode) + case material(MeshTerrainPaintStamp.Operation) + case population(Population) + } + + struct Population: Sendable { + let prototypeID: UInt16 + let density: Float + let seed: UInt64 + let erases: Bool + } + + private struct PaintStamp { + let id = UUID() + let anchor: MeshTerrainSurfaceAnchor + var mix: Float + } + struct Hit { + let editorID: String + let owner: Entity + let source: Terrain3DSource + let surface: MeshTerrain3DSourceSurface + let matrix: Transform3D + let sample: TerrainSurfaceQueryHit + } + + private struct Stamp { + let id = UUID() + let center: Vector3 + let normal: Vector3 + let target: SurfaceTarget? + var displacement: Vector3 + var mix: Float + } + + private struct SurfaceTarget: Equatable { + let sectionID: UUID + let sourceID: UInt32 + } + + let hit: Hit + let ownership: MeshTerrain3DOwnership + private let radius: Float + private let strength: Float + private let tool: Tool + private let sourceIDs: [UInt32] + private let firstOrder: Int + private var stamps: [Stamp] = [] + private let newPopulationRule = UUID() + private let populationRuleIndex: Int? + private let maximumSurfaceStamps: Int + private var paint: [PaintStamp] = [] + private(set) var revision: UInt64 = 0 + private(set) var exceededLimit = false + var radiusForSampling: Float { radius } + var previewQueries: TerrainSurfaceQueries? + + init(hit: Hit, ownership: MeshTerrain3DOwnership, radius: Float, strength: Float, tool: Tool) throws { + guard radius.isFinite, radius > 0, strength.isFinite, strength > 0 else { throw TriangleGeometryError.invalidInput } + if case .shape = tool { + let modifiers = hit.surface.asset.document.modifiers + let limit = min(64, 512 - modifiers.count), lastOrder = modifiers.map(\.order).max() ?? -1 + guard limit > 0, lastOrder <= Int.max - limit else { throw TriangleGeometryError.budgetExceeded } + let occupied = Set(modifiers.map(\.sourceID)) + sourceIDs = Array((UInt32(1)...UInt32(513)).lazy.filter { !occupied.contains($0) }.prefix(limit)) + firstOrder = lastOrder + 1 + populationRuleIndex = nil; maximumSurfaceStamps = 0 + } else { + sourceIDs = []; firstOrder = 0 + if case let .population(settings) = tool { + let rules = hit.surface.asset.populationRules + let index = rules.firstIndex { $0.prototypeID == settings.prototypeID && $0.coverage != nil } + guard index != nil || rules.count < 128, settings.density.isFinite, (0...1).contains(settings.density) else { + throw TriangleGeometryError.budgetExceeded + } + populationRuleIndex = index + maximumSurfaceStamps = MeshTerrainPopulationCoverage.maximumStamps - (index.flatMap { rules[$0].coverage?.stamps.count } ?? 0) + } else { + populationRuleIndex = nil + maximumSurfaceStamps = 4_096 - hit.surface.asset.document.paint.count + } + guard maximumSurfaceStamps > 0 else { throw TriangleGeometryError.budgetExceeded } + } + self.hit = hit; self.ownership = ownership; self.radius = radius; self.strength = strength; self.tool = tool + previewQueries = hit.surface.queries + } + + func append(_ sample: TerrainSurfaceQueryHit, duration: Float) { + guard !exceededLimit, duration.isFinite, duration > 0 else { return } + let amount = strength * min(duration, 0.05) + guard case let .shape(mode) = tool else { + appendPaint(sample, amount: min(1, amount)) + return + } + let target: SurfaceTarget? + let displacement: Vector3 + switch mode { + case .raise, .lower: + target = nil + displacement = sample.normal * (mode == .lower ? -amount : amount) + case .smooth, .flatten: + guard let current = surfaceTarget(sample) else { return } + target = current + displacement = .zero + } + let mix = min(1, amount) + let tolerance = max(0.01, radius * 0.1) + if let last = stamps.indices.last, stamps[last].target == target, (stamps[last].center - sample.position).squaredLength < tolerance * tolerance { + let combined = stamps[last].displacement + displacement + guard combined.squaredLength <= 4_096 else { exceededLimit = true; return } + stamps[last].displacement = combined + stamps[last].mix += (1 - stamps[last].mix) * mix + } else { + guard stamps.count < sourceIDs.count, displacement.squaredLength <= 4_096 else { exceededLimit = true; return } + stamps.append(.init(center: sample.position, normal: sample.normal, target: target, displacement: displacement, mix: mix)) + } + revision &+= 1 + } + + func candidate() -> MeshTerrainDocumentAsset { + let initial = hit.surface.asset + if case let .population(settings) = tool { return populationCandidate(initial, settings: settings) } + var document = initial.document + if case let .material(operation) = tool { + document.paint += paint.map { .init(id: $0.id, anchor: $0.anchor, radius: radius, strength: $0.mix, operation: operation) } + return .init(document: document, populationRules: initial.populationRules, manualPlacements: initial.manualPlacements, + populationOverrides: initial.populationOverrides) + } + guard case let .shape(mode) = tool else { return initial } + let operand = TriangleSurface(positions: [], indices: []) + for (index, stamp) in stamps.enumerated() { + let operation: MeshTerrainModifierOperation + switch mode { + case .raise, .lower: + operation = .sculpt(center: stamp.center, radius: radius, displacement: stamp.displacement) + case .smooth, .flatten: + guard let target = stamp.target else { continue } + let normal = mode == .flatten ? hit.sample.normal : stamp.normal + let action: MeshTerrainSurfaceSculpt.Operation = mode == .flatten ? .flatten(point: hit.sample.position) : .smooth + operation = .surfaceSculpt(.init(sectionID: target.sectionID, center: stamp.center, normal: normal, sourceID: target.sourceID, + radius: radius, strength: stamp.mix, operation: action)) + } + document.modifiers.append(.init(id: stamp.id, sourceID: sourceIDs[index], operation: operation, surface: operand, order: firstOrder + index)) + } + return .init(document: document, populationRules: initial.populationRules, manualPlacements: initial.manualPlacements, + populationOverrides: initial.populationOverrides) + } + + private func populationCandidate(_ initial: MeshTerrainDocumentAsset, settings: Population) -> MeshTerrainDocumentAsset { + guard !paint.isEmpty else { return initial } + var rules = initial.populationRules + let index: Int + if let populationRuleIndex { index = populationRuleIndex } + else { + if settings.erases { return initial } + index = rules.count + // Brush coverage derives the actual bounded sample domain at compile time. + let possible = MeshTerrainBounds(minimum: Vector3(-1_000_000), maximum: Vector3(1_000_000)) + rules.append(.init(id: newPopulationRule, prototypeID: settings.prototypeID, seed: settings.seed, bounds: possible, + density: 1, spacing: 0.8)) + } + let operation: MeshTerrainPopulationCoverage.Stamp.Operation = settings.erases ? .erase : .include(density: settings.density) + let authored = rules[index].coverage?.stamps ?? [] + rules[index].coverage = .init(stamps: authored + paint.map { .init(anchor: $0.anchor, radius: radius, operation: operation) }) + return .init(document: initial.document, populationRules: rules, manualPlacements: initial.manualPlacements, + populationOverrides: initial.populationOverrides) + } + + private func appendPaint(_ sample: TerrainSurfaceQueryHit, amount: Float) { + let document = hit.surface.asset.document + guard let patch = patch(for: sample), + let anchor = try? MeshTerrainSurfaceAnchor.capture(section: patch.id, surface: patch.surface, hit: sample.surface, document: document) else { return } + let tolerance = max(0.01, radius * 0.1) + if let last = paint.indices.last, paint[last].anchor.frame == anchor.frame, paint[last].anchor.section == anchor.section, + paint[last].anchor.normal.dot(anchor.normal) >= 0.5, + (paint[last].anchor.point - anchor.point).squaredLength < tolerance * tolerance { + if case .population = tool { return } + if case .material(.erase) = tool { paint[last].mix = min(1, paint[last].mix + amount) } + else { paint[last].mix += (1 - paint[last].mix) * amount } + } else { + guard paint.count < min(64, maximumSurfaceStamps) else { exceededLimit = true; return } + paint.append(.init(anchor: anchor, mix: amount)) + } + revision &+= 1 + } + + private func patch(for sample: TerrainSurfaceQueryHit) -> MeshTerrainCompiledPatch? { + guard let patches = hit.surface.asset.compiled?.patches, sample.coordinate.z == 0, + patches.indices.contains(sample.coordinate.x) else { return nil } + return patches[sample.coordinate.x] + } + + private func surfaceTarget(_ sample: TerrainSurfaceQueryHit) -> SurfaceTarget? { + guard let patch = patch(for: sample) else { return nil } + let face = sample.surface.triangleIndex + guard (0.. Bool) async throws -> MeshTerrain3DSourceSurface { + let owner = hit.owner, original = hit.source + var requestID: UUID? + do { + let resident = try await application.withWorldAccess { () throws -> MeshTerrain3DSourceSurface? in + guard isCurrent(), application.main.getEntityByID(owner.id) === owner, + owner.components[Terrain3DSource.self] == original, + let surface = owner.components[MeshTerrain3DSourceSurface.self], let key = surface.asset.compiled?.inputKey, + owner.components[MeshTerrain3DEdit.self] == nil else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + if candidate.compiled?.inputKey == key { return surface } + let request = MeshTerrain3DEdit(expectedInputKey: key, candidate: candidate) + requestID = request.id + owner.components[MeshTerrain3DEdit.self] = request + return nil + } + if let resident { return resident } + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + if let applied = try await application.withWorldAccess({ () throws -> MeshTerrain3DSourceSurface? in + guard isCurrent(), application.main.getEntityByID(owner.id) === owner else { throw CancellationError() } + guard let status = owner.components[MeshTerrain3DEditStatus.self], status.id == requestID else { return nil } + switch status.phase { + case .pending: return nil + case .failed: throw EditorTerrainSourceAuthoring.Failure.failed(status.error ?? "Mesh Terrain could not be updated.") + case .cancelled: throw CancellationError() + case .applied: + guard let surface = owner.components[MeshTerrain3DSourceSurface.self] else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + return surface + } + }) { return applied } + try await Task.sleep(for: .milliseconds(10)) + } + throw EditorTerrainSourceAuthoring.Failure.timedOut + } catch { + if let requestID { + await Task { @MainActor in + try? await application.withWorldAccess { + if application.main.getEntityByID(owner.id) === owner, owner.components[MeshTerrain3DEdit.self]?.id == requestID { + owner.components.remove(MeshTerrain3DEdit.self) + } + } + }.value + } + throw error + } + } +} diff --git a/Editor/Sources/AdaEditor/EditorMeshTerrainTransaction.swift b/Editor/Sources/AdaEditor/EditorMeshTerrainTransaction.swift new file mode 100644 index 000000000..a86afa1ea --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMeshTerrainTransaction.swift @@ -0,0 +1,114 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Prepares immutable resources off the UI actor; publication and scene history share a safe point. +@MainActor +enum EditorMeshTerrainTransaction { + struct Input { + let owner: Entity + let original: Terrain3DSource + let previous: MeshTerrainDocumentAsset + let candidate: MeshTerrainDocumentAsset + let previousOwnership: MeshTerrain3DOwnership + let draws: [MeshTerrainDrawReplacement] + let collisions: [Entity.ID] + let bindings: [MeshTerrainBindingUpdate] + let previousBindings: [MeshTerrainBindingUpdate] + var sourceTransforms: [MeshTerrainSourceTransform] = [] + var rollbackAsset: MeshTerrainDocumentAsset? + var rollbackTransforms: [MeshTerrainSourceTransform] = [] + var resourceCommand: EditorTerrainStrokeCommand? + } + + static func run(_ input: Input, application: AppWorlds, isCurrent: () -> Bool, + onProgress: (String) -> Void, commit: (URL) throws -> Void) async throws { + onProgress("Preparing mesh terrain…") + let compiled = (try? input.candidate.acceptedCompiledProduct()) != nil ? input.candidate : try await input.candidate.compiling() + try Task.checkCancellation() + guard isCurrent() else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + onProgress("Saving mesh terrain…") + let original = URL(fileURLWithPath: input.original.source) + let (file, receipt): (URL, EditorMeshTerrainCreation.Receipt) + if let command = input.resourceCommand { + (file, receipt) = try await EditorMeshTerrainCreation.forkResources(command, from: original) + } else { + (file, receipt) = try await EditorMeshTerrainCreation.fork(compiled, from: original) + } + var request: MeshTerrain3DEdit? + do { + try await application.withWorldAccess { + guard isCurrent(), application.main.getEntityByID(input.owner.id) === input.owner, + input.owner.components[Terrain3DSource.self] == input.original, + input.owner.components[MeshTerrain3DEdit.self] == nil else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + let value = MeshTerrain3DEdit(expectedInputKey: try input.previous.authoringKey(), candidate: compiled, + sourceTransforms: input.sourceTransforms, + drawReplacements: input.draws, collisionReplacements: input.collisions, adoptionManifest: file.path, + bindingUpdates: input.bindings) + request = value + input.owner.components[MeshTerrain3DEdit.self] = value + } + onProgress("Updating mesh terrain…") + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while true { + let committed = try await application.withWorldAccess { + guard isCurrent(), application.main.getEntityByID(input.owner.id) === input.owner else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + guard let status = input.owner.components[MeshTerrain3DEditStatus.self], status.id == request?.id else { return false } + switch status.phase { + case .pending: return false + case .failed: throw EditorTerrainSourceAuthoring.Failure.failed(status.error ?? "Mesh Terrain could not be updated.") + case .cancelled: throw CancellationError() + case .applied: + guard input.owner.components[Terrain3DSource.self]?.source == file.path else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + try commit(file) + return true + } + } + if committed { return } + guard ContinuousClock.now < deadline else { throw EditorTerrainSourceAuthoring.Failure.timedOut } + try await Task.sleep(for: .milliseconds(10)) + } + } catch { + // A cancellation must still reach a world safe point and undo a generation that + // completed just before Escape. Never remove a resource still referenced by a live owner. + let removable = await Task { @MainActor in + do { + let rollback = try await application.withWorldAccess { () throws -> MeshTerrain3DEdit? in + guard application.main.getEntityByID(input.owner.id) === input.owner else { return nil } + if input.owner.components[MeshTerrain3DEdit.self]?.id == request?.id { input.owner.components.remove(MeshTerrain3DEdit.self) } + let currentPath = input.owner.components[Terrain3DSource.self]?.source + guard currentPath == file.path || (input.rollbackAsset != nil && currentPath == input.original.source) else { return nil } + guard try input.owner.components[MeshTerrain3DSourceSurface.self]?.asset.authoringKey() == compiled.authoringKey(), + input.owner.components[MeshTerrain3DEdit.self] == nil else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + let rollback = MeshTerrain3DEdit(expectedInputKey: try compiled.authoringKey(), candidate: input.rollbackAsset ?? input.previous, + sourceTransforms: input.rollbackTransforms, + drawReplacements: input.previousOwnership.draws, collisionReplacements: input.previousOwnership.collisions, + adoptionManifest: input.original.source, bindingUpdates: input.previousBindings) + input.owner.components[MeshTerrain3DEdit.self] = rollback + return rollback + } + if let rollback { + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + let done = try await application.withWorldAccess { + guard application.main.getEntityByID(input.owner.id) === input.owner else { return true } + guard let status = input.owner.components[MeshTerrain3DEditStatus.self], status.id == rollback.id else { return false } + return status.phase != .pending + } + if done { break } + try await Task.sleep(for: .milliseconds(10)) + } + } + return try await application.withWorldAccess { + application.main.getEntityByID(input.owner.id) !== input.owner || input.owner.components[Terrain3DSource.self]?.source != file.path + } + } catch { return false } + }.value + if removable { await EditorMeshTerrainCreation.discard(receipt) } + throw error + } + } +} diff --git a/Editor/Sources/AdaEditor/EditorModelAssetImporter.swift b/Editor/Sources/AdaEditor/EditorModelAssetImporter.swift index 35df4c638..6acd1a354 100644 --- a/Editor/Sources/AdaEditor/EditorModelAssetImporter.swift +++ b/Editor/Sources/AdaEditor/EditorModelAssetImporter.swift @@ -16,7 +16,7 @@ enum EditorModelAssetImporter { return target } let document = try JSONDecoder().decode(Document.self, from: jsonData(at: source)) - let uris = ((document.buffers ?? []) + (document.images ?? [])).compactMap(\.uri).filter { !$0.hasPrefix("data:") } + let uris = externalURIs(in: document) guard !uris.isEmpty else { let target = uniqueURL(source.lastPathComponent, in: destination, fileManager: fileManager) try fileManager.copyItem(at: source, to: target) @@ -50,6 +50,21 @@ enum EditorModelAssetImporter { return target.appendingPathComponent(source.lastPathComponent) } + /// Terrain prototypes copy a single immutable file, so external URIs are not portable. + static func requireSelfContainedGLB(_ data: Data) throws { + let count = try jsonChunkLength(data.prefix(20)) + guard count <= data.count - 20 else { throw Failure(message: "Invalid GLB JSON chunk.") } + let json = data.dropFirst(20).prefix(count) + let document = try JSONDecoder().decode(Document.self, from: json) + guard externalURIs(in: document).isEmpty else { + throw Failure(message: "Terrain meshes must use a self-contained GLB or VOX file.") + } + } + + private static func externalURIs(in document: Document) -> [String] { + ((document.buffers ?? []) + (document.images ?? [])).compactMap(\.uri).filter { !$0.hasPrefix("data:") } + } + private static func uniqueURL(_ name: String, in destination: URL, fileManager: FileManager) -> URL { let input = URL(fileURLWithPath: name) var candidate = destination.appendingPathComponent(name) @@ -68,14 +83,21 @@ enum EditorModelAssetImporter { } let file = try FileHandle(forReadingFrom: url) defer { try? file.close() } - guard let header = try file.read(upToCount: 20), header.count == 20 else { throw Failure(message: "Invalid GLB header.") } + let count = try jsonChunkLength(file.read(upToCount: 20) ?? Data()) + guard let json = try file.read(upToCount: count), json.count == count else { throw Failure(message: "Invalid GLB JSON chunk.") } + return json + } + + private static func jsonChunkLength(_ header: Data) throws -> Int { + guard header.count == 20 else { throw Failure(message: "Invalid GLB header.") } func uint32(_ offset: Int) -> UInt32 { - (0..<4).reduce(UInt32(0)) { $0 | UInt32(header[offset + $1]) << (8 * $1) } + (0..<4).reduce(UInt32(0)) { $0 | UInt32(header[header.startIndex + offset + $1]) << (8 * $1) } } - let count = Int(uint32(12)) - guard uint32(0) == 0x46546C67, uint32(4) == 2, uint32(16) == 0x4E4F534A, count > 0, count <= 16 * 1024 * 1024, - let json = try file.read(upToCount: count), json.count == count else { throw Failure(message: "Invalid GLB JSON chunk.") } - return json + let count = uint32(12) + guard uint32(0) == 0x46546C67, uint32(4) == 2, uint32(16) == 0x4E4F534A, (1...16 * 1_024 * 1_024).contains(count) else { + throw Failure(message: "Invalid GLB JSON chunk.") + } + return Int(count) } private struct Failure: LocalizedError { diff --git a/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift b/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift index 3d51f9798..7aed97d28 100644 --- a/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift +++ b/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift @@ -47,8 +47,14 @@ enum EditorSceneFileLoader { registerEditorSceneComponents(loadsScriptableObjects: loadsScriptableObjects) let sceneModel = try EditorSceneModel.decode(from: content) let sourceURL = sourceURL?.resolvingSymlinksInPath().standardizedFileURL - return load(model: sceneModel, into: world, loadsScriptableObjects: loadsScriptableObjects, loadsPhysicsBodies: loadsPhysicsBodies, - sourceURL: sourceURL, resourceRootURL: resourceRootURL) + return load( + model: sceneModel, + into: world, + loadsScriptableObjects: loadsScriptableObjects, + loadsPhysicsBodies: loadsPhysicsBodies, + sourceURL: sourceURL, + resourceRootURL: resourceRootURL + ) } catch { return .emptyWithWarnings([error.localizedDescription]) } @@ -134,6 +140,9 @@ enum EditorSceneFileLoader { } resolvedPayload["imageBasedLightingSource"] = .string(url.path) } + if typeName == EditorBuiltInComponentType.terrain3DSource, let reference = payload["source"]?.stringValue, !reference.isEmpty { + resolvedPayload["source"] = .string(try EditorTerrainResource.resolve(reference, sourceURL: sourceURL, resourceRootURL: resourceRootURL).path) + } if typeName == EditorBuiltInComponentType.model3DSource, let reference = payload["source"]?.stringValue, !reference.isEmpty { resolvedPayload["source"] = .string(try EditorModelResource.resolve(reference, sourceURL: sourceURL, resourceRootURL: resourceRootURL).path) if let path = payload["animationModifiers"]?.stringValue, !path.isEmpty { @@ -216,9 +225,18 @@ enum EditorSceneFileLoader { var assets = world.getResource(EditorSceneAssetCache.self) ?? EditorSceneAssetCache() assets.beginLoad() return try await ShaderGraphResourceScope.$rootPath.withValue(resourceRootURL?.path) { - let state = Instantiation(model, world: world, loadsScriptableObjects: false, loadsPhysicsBodies: false, - sourceURL: sourceURL, resourceRootURL: resourceRootURL, parentEntity: nil, - idPrefix: "", ancestry: sourceURL.map { Set([$0]) } ?? [], assets: assets) + let state = Instantiation( + model, + world: world, + loadsScriptableObjects: false, + loadsPhysicsBodies: false, + sourceURL: sourceURL, + resourceRootURL: resourceRootURL, + parentEntity: nil, + idPrefix: "", + ancestry: sourceURL.map { Set([$0]) } ?? [], + assets: assets + ) defer { state.assets.finishLoad() world.insertResource(state.assets) @@ -256,9 +274,18 @@ enum EditorSceneFileLoader { ancestry: Set, assets: inout EditorSceneAssetCache ) -> EditorSceneRuntimeLoadResult { - let state = Instantiation(sceneModel, world: world, loadsScriptableObjects: loadsScriptableObjects, - loadsPhysicsBodies: loadsPhysicsBodies, sourceURL: sourceURL, resourceRootURL: resourceRootURL, - parentEntity: parentEntity, idPrefix: idPrefix, ancestry: ancestry, assets: assets) + let state = Instantiation( + sceneModel, + world: world, + loadsScriptableObjects: loadsScriptableObjects, + loadsPhysicsBodies: loadsPhysicsBodies, + sourceURL: sourceURL, + resourceRootURL: resourceRootURL, + parentEntity: parentEntity, + idPrefix: idPrefix, + ancestry: ancestry, + assets: assets + ) defer { assets = state.assets } for entity in sceneModel.entities { state.instantiateEntity(entity) } return state.finish() @@ -283,9 +310,18 @@ enum EditorSceneFileLoader { var runtimeEntityIDsByEditorID: [String: Entity.ID] = [:] var editorIDsByRuntimeEntityID: [Entity.ID: String] = [:] - init(_ sceneModel: EditorSceneModel, world: World, loadsScriptableObjects: Bool, loadsPhysicsBodies: Bool, - sourceURL: URL?, resourceRootURL: URL?, parentEntity: Entity?, idPrefix: String, - ancestry: Set, assets: EditorSceneAssetCache) { + init( + _ sceneModel: EditorSceneModel, + world: World, + loadsScriptableObjects: Bool, + loadsPhysicsBodies: Bool, + sourceURL: URL?, + resourceRootURL: URL?, + parentEntity: Entity?, + idPrefix: String, + ancestry: Set, + assets: EditorSceneAssetCache + ) { self.sceneModel = sceneModel self.world = world self.loadsScriptableObjects = loadsScriptableObjects @@ -359,7 +395,22 @@ enum EditorSceneFileLoader { _ = try ui.resolveView(runtime: runtime) } } - EditorSceneFileLoader.insertComponent(component, into: entity, in: world) + if !loadsPhysicsBodies, var terrain = component as? Terrain3DSource { + // Preview-only admission must never rewrite the scene's Play settings. + terrain.generatesCollision = false + terrain.queries = terrain.queries ?? .init() + EditorSceneFileLoader.insertComponent(terrain, into: entity, in: world) + } else { + EditorSceneFileLoader.insertComponent(component, into: entity, in: world) + if let model = component as? Model3DSource, let root = resourceRootURL?.resolvingSymlinksInPath().standardizedFileURL { + let path = URL(fileURLWithPath: model.source).resolvingSymlinksInPath().standardizedFileURL.path + if path.hasPrefix(root.path + "/") { + entity.components[MeshTerrain3DSourceModelResource.self] = .init( + resolvedPath: model.source, resourcePath: "@res://" + path.dropFirst(root.path.count + 1) + ) + } + } + } } else { warnings.append("Component is not decodable: \(componentName)") } @@ -556,17 +607,23 @@ enum EditorSceneFileLoader { let root = resourceRootURL ?? sourceURL?.deletingLastPathComponent().deletingLastPathComponent() let url: URL if reference.hasPrefix("@res://") { - guard let root else { return nil } + guard let root else { + return nil + } url = root.appendingPathComponent(String(reference.dropFirst("@res://".count))) } else { - guard let sourceURL else { return nil } + guard let sourceURL else { + return nil + } url = sourceURL.deletingLastPathComponent().appendingPathComponent(reference) } let resolved = url.resolvingSymlinksInPath().standardizedFileURL guard resolved.pathExtension == fileExtension else { return nil } if reference.hasPrefix("@res://") || fileExtension == "particles" || fileExtension == "animlib" || fileExtension == "animmod", let root { let rootPath = root.resolvingSymlinksInPath().standardizedFileURL.path - guard resolved.path.hasPrefix(rootPath + "/") else { return nil } + guard resolved.path.hasPrefix(rootPath + "/") else { + return nil + } } return resolved } diff --git a/Editor/Sources/AdaEditor/EditorSceneModel+Hierarchy.swift b/Editor/Sources/AdaEditor/EditorSceneModel+Hierarchy.swift index 161f8b7d4..8c3436a57 100644 --- a/Editor/Sources/AdaEditor/EditorSceneModel+Hierarchy.swift +++ b/Editor/Sources/AdaEditor/EditorSceneModel+Hierarchy.swift @@ -151,7 +151,7 @@ extension EditorSceneModel { } extension EditorSceneModel { - private func subtreeEntityIDs(rootedAt rootID: String) -> [String] { + func subtreeEntityIDs(rootedAt rootID: String) -> [String] { guard entities.contains(where: { $0.id == rootID }) else { return [] } diff --git a/Editor/Sources/AdaEditor/EditorTerrainResourceWriter.swift b/Editor/Sources/AdaEditor/EditorTerrainResourceWriter.swift new file mode 100644 index 000000000..ec9ad4aed --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainResourceWriter.swift @@ -0,0 +1,134 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Writes only private manifests and immutable payloads. The scene publishes their URI after runtime validation. +enum EditorTerrainResourceWriter { + @concurrent + static func apply(_ command: EditorTerrainStrokeCommand, to file: URL, manifest: TerrainManifestAsset) async throws { + try Task.checkCancellation() + switch command { + case let .volume(operation): + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + let previous = try await cache.loadVolumeResources()?.document + let document = TerrainVolumeDocumentAsset( + cellSize: previous?.cellSize ?? manifest.spacing, + origin: previous?.origin ?? [manifest.originX, 0, manifest.originZ], + operations: (previous?.operations ?? []) + [operation] + ) + _ = try await AssetsManager.saveTerrainRegions([], manifest: manifest, at: file.path, mode: .update(removing: []), volume: .init(document: document, bricks: [])) + case .materials, .mesh, .texture: + let update = try planManifestUpdate(command, manifest: manifest) + if let payload = update.payload { + try payload.data.write(to: file.deletingLastPathComponent().appendingPathComponent(payload.name), options: .withoutOverwriting) + } + try update.manifest.encodedData().write(to: file, options: .atomic) + case let .region(coordinate): + guard manifest.heightRegions.count <= 128 else { throw Failure.modelTooLarge } + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + var regions: [TerrainRegionAsset] = [] + for reference in manifest.heightRegions { + if let region = try await cache.loadRegion(at: .init(x: Int(reference.x), z: Int(reference.z))) { regions.append(region) } + } + let storage = try TerrainHeightStorage(regions: regions, spacing: manifest.spacing, origin: [manifest.originX, manifest.originZ]) + var target = coordinate + while try storage.regionAsset(at: target)?.tiles.count == 16 { target = .init(x: target.x + 1, z: target.z) } + _ = try storage.allocateRegion(at: target) + guard let region = try storage.regionAsset(at: target) else { throw Failure.emptyRegion } + _ = try await AssetsManager.saveTerrainRegions([region], manifest: manifest, at: file.path, mode: .update(removing: [])) + case let .generate(settings): try await EditorTerrainNoise.generate(settings, at: file, manifest: manifest) + default: throw Failure.unsupported + } + } + + struct ManifestUpdate: Sendable { + struct Payload: Sendable { + let name: String + let data: Data + } + let manifest: TerrainManifestAsset + let payload: Payload? + } + + static func planManifestUpdate(_ command: EditorTerrainStrokeCommand, manifest: TerrainManifestAsset) throws -> ManifestUpdate { + try Task.checkCancellation() + switch command { + case let .materials(palette): + return .init(manifest: replacing(manifest, materials: palette), payload: nil) + case let .mesh(source): + guard ["glb", "vox"].contains(source.pathExtension.lowercased()) else { throw Failure.selfContainedModel } + let metadata = try source.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) + guard metadata.isRegularFile == true, metadata.isSymbolicLink != true, + let count = metadata.fileSize, count > 0, count <= 32 * 1_024 * 1_024 else { throw Failure.modelTooLarge } + let used = Set(manifest.meshPrototypes.map(\.id)) + guard let id = (UInt16(0)...UInt16(255)).first(where: { !used.contains($0) }) else { throw Failure.paletteFull } + let name = "mesh-\(UUID().uuidString.lowercased()).\(source.pathExtension.lowercased())" + let data = try boundedData(source, count: count, maximum: 32 * 1_024 * 1_024) + if source.pathExtension.lowercased() == "glb" { try EditorModelAssetImporter.requireSelfContainedGLB(data) } + let next = replacing(manifest, prototypes: manifest.meshPrototypes + [.init(id: id, assetPath: name)]) + return .init(manifest: next, payload: .init(name: name, data: data)) + case let .texture(source, layerID): + guard ["png", "jpg", "jpeg"].contains(source.pathExtension.lowercased()) else { throw Failure.unsupported } + let palette = manifest.materials ?? .init(layers: [], fallbackLayerID: layerID) + guard palette.layers.count < 32 else { throw Failure.paletteFull } + let metadata = try source.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) + guard metadata.isRegularFile == true, metadata.isSymbolicLink != true, + let count = metadata.fileSize, count > 0, count <= 16 * 1_024 * 1_024 else { throw Failure.modelTooLarge } + let name = "texture-\(UUID().uuidString.lowercased()).\(source.pathExtension.lowercased())" + let data = try boundedData(source, count: count, maximum: 16 * 1_024 * 1_024) + var layers = palette.layers.map { layer in + TerrainLayerReference( + id: layer.id, + name: layer.name, + albedoPath: layer.id == layerID ? name : layer.albedoPath, + normalPath: layer.normalPath, + ormPath: layer.ormPath, + blendHeightPath: layer.blendHeightPath, + scale: layer.scale, + tint: layer.tint, + roughness: layer.roughness, + metallic: layer.metallic, + projection: layer.projection, + variation: layer.variation + ) + } + if !layers.contains(where: { $0.id == layerID }) { + layers.append(.init( + id: layerID, + name: source.deletingPathExtension().lastPathComponent, + albedoPath: name, + scale: 4, + projection: .triplanar, + variation: .init(isEnabled: true) + )) + } + let next = replacing(manifest, materials: .init(layers: layers, fallbackLayerID: palette.fallbackLayerID, + interiorLayerID: palette.interiorLayerID, rules: palette.rules)) + return .init(manifest: next, payload: .init(name: name, data: data)) + default: throw Failure.unsupported + } + } + + private static func boundedData(_ source: URL, count: Int, maximum: Int) throws -> Data { + // Bound the read even if an external file grows after the metadata check. + let handle = try FileHandle(forReadingFrom: source) + defer { try? handle.close() } + let data = try handle.read(upToCount: maximum + 1) ?? Data() + guard data.count == count, data.count <= maximum else { throw Failure.modelTooLarge } + return data + } + + private static func replacing(_ source: TerrainManifestAsset, materials: TerrainMaterialPalette? = nil, prototypes: [TerrainMeshPrototypeReference]? = nil) -> TerrainManifestAsset { + .init( + spacing: source.spacing, + originX: source.originX, + originZ: source.originZ, + heightRegions: source.heightRegions, + meshPrototypes: prototypes ?? source.meshPrototypes, + materials: materials ?? source.materials, + volumeFileName: source.volumeFileName, + meshDocumentFileName: source.meshDocumentFileName + ) + } + + enum Failure: Error { case selfContainedModel, modelTooLarge, paletteFull, emptyRegion, unsupported } +} diff --git a/Editor/Sources/AdaEditor/EditorTerrainSourceAuthoring.swift b/Editor/Sources/AdaEditor/EditorTerrainSourceAuthoring.swift new file mode 100644 index 000000000..95d98b454 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainSourceAuthoring.swift @@ -0,0 +1,275 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Runs worker edits on a private resource; the caller commits its URI through scene history. +@MainActor +enum EditorTerrainSourceAuthoring { + enum Phase: Sendable { + case preparing, applying, saving + case writing(completed: Int, total: Int) + } + enum Failure: LocalizedError, Sendable { + case unavailable, busy, sourceChanged, timedOut, manifestTooLarge + case failed(String) + + nonisolated var errorDescription: String? { + switch self { + case .unavailable: "Terrain is not ready. Select a terrain and wait for it to load." + case .busy: "Terrain is busy. Wait for the current change to finish." + case .sourceChanged: "Terrain changed while the edit was being prepared. Try again." + case .timedOut: "Terrain update timed out. The last valid surface is still visible." + case .manifestTooLarge: "The terrain resource is too large to edit." + case let .failed(message): message + } + } + } + + static func applyHeightStroke( + to owner: Entity, + application: AppWorlds, + expectedStorage: TerrainHeightStorage, + expectedRevision: UInt64, + brush: TerrainHeightBrushStroke, + command: EditorTerrainStrokeCommand? = nil, + assetsRoot: URL, + isCurrent: () -> Bool = { true }, + onProgress: @escaping @MainActor @Sendable (Phase) -> Void = { _ in }, + commit: (URL) throws -> Void = { _ in }, + nextCommand: (() -> (command: EditorTerrainStrokeCommand?, finished: Bool))? = nil, + onPublished: (Terrain3DSourceSurface) -> Void = { _ in } + ) async throws -> URL? { + try Task.checkCancellation() + let (original, loadedSurface) = try await application.withWorldAccess { + guard owner.world === application.main, + let original = owner.components[Terrain3DSource.self], + let surface = owner.components[Terrain3DSourceSurface.self], + surface.storage === expectedStorage, surface.manifest != nil else { throw Failure.unavailable } + guard expectedStorage.revision == expectedRevision, isCurrent() else { throw Failure.sourceChanged } + guard owner.components[Terrain3DHeightEdit.self] == nil, owner.components[Terrain3DSave.self] == nil else { throw Failure.busy } + return (original, surface) + } + guard let manifest = loadedSurface.manifest else { throw Failure.unavailable } + // A completed save can immediately schedule residency/LOD publication. Retain the + // captured surface and wait for that coherent generation instead of dropping input. + let _: Bool = try await wait(application: application, owner: owner, source: original, isCurrent: isCurrent) { + guard owner.components[Terrain3DSourceSurface.self]?.storage === expectedStorage, + expectedStorage.revision == expectedRevision else { throw Failure.sourceChanged } + guard let status = owner.components[Terrain3DSourceStatus.self] else { + return nil + } + if let error = status.error { throw Failure.failed(error) } + return status.isReady && !status.isPreparing ? true : nil + } + let url = try EditorTerrainResource.resolve( + original.source, + sourceURL: assetsRoot.appendingPathComponent("_editor.ascn"), + resourceRootURL: assetsRoot + ) + let fork = try await EditorTerrainAssetFork.make(from: url, expected: manifest) + let operation = command ?? .height(brush) + var prepared = original + prepared.source = fork.path + var editID: UUID? + var saveRequest: Terrain3DSave? + do { + onProgress(.preparing) + if operation.changesResource { + try await EditorTerrainResourceWriter.apply(operation, to: fork, manifest: manifest) + } + try await application.withWorldAccess { + try check(owner: owner, source: original, isCurrent: isCurrent) + guard expectedStorage.revision == expectedRevision else { throw Failure.sourceChanged } + owner.components.remove(Terrain3DSourceStatus.self) + owner.components[Terrain3DSource.self] = prepared + } + var surface: Terrain3DSourceSurface = try await wait(application: application, owner: owner, source: prepared, isCurrent: isCurrent) { + guard let status = owner.components[Terrain3DSourceStatus.self] else { + return nil + } + if let error = status.error { throw Failure.failed(error) } + return status.isReady && !status.isPreparing ? owner.components[Terrain3DSourceSurface.self] : nil + } + if operation.changesResource { + try await EditorTerrainAssetFork.verify(url, expected: manifest) + try await application.withWorldAccess { + try check(owner: owner, source: prepared, isCurrent: isCurrent) + try commit(fork) + } + return fork + } + var changed = false + var once = false + while true { + try Task.checkCancellation() + let input = nextCommand?() ?? (command: once ? nil : operation, finished: true) + guard let batch = input.command else { + if input.finished { break } + try await Task.sleep(for: .milliseconds(16)) + continue + } + once = true + onProgress(.applying) + let edit = try await application.withWorldAccess { + try check(owner: owner, source: prepared, isCurrent: isCurrent) + let edit: Terrain3DHeightEdit + switch batch { + case let .height(stroke): edit = .init(storage: surface.storage, brush: stroke) + case let .surface(stroke): edit = .init(storage: surface.storage, brush: stroke) + case let .instances(stroke): edit = .init(storage: surface.storage, brush: stroke) + default: throw Failure.unavailable + } + owner.components[Terrain3DHeightEdit.self] = edit + return edit + } + editID = edit.id + let delta: TerrainHeightDelta = try await wait(application: application, owner: owner, source: prepared, isCurrent: isCurrent) { + guard let status = owner.components[Terrain3DHeightEditStatus.self], status.id == edit.id else { + return nil + } + switch status.phase { + case .pending: return nil + case .applied: + guard let delta = status.delta else { throw Failure.unavailable } + return delta + case .cancelled: throw CancellationError() + case .failed: throw Failure.failed(status.error ?? "Terrain edit failed") + } + } + try await application.withWorldAccess { owner.components.remove(Terrain3DHeightEdit.self) } + editID = nil + changed = changed || !delta.changes.isEmpty || !delta.instanceChanges.isEmpty || !delta.surfaceChanges.isEmpty || !delta.maskChanges.isEmpty + surface = try await application.withWorldAccess { + guard let value = owner.components[Terrain3DSourceSurface.self] else { throw Failure.unavailable } + return value + } + onPublished(surface) + if nextCommand == nil { break } + } + guard changed else { + await cleanup(owner: owner, application: application, original: original, prepared: prepared, fork: fork, editID: nil, save: nil) + return nil + } + onProgress(.saving) + let save = Terrain3DSave(storage: surface.storage, onProgress: { completed, total in + await onProgress(.writing(completed: completed, total: total)) + }) + saveRequest = save + try await application.withWorldAccess { + try check(owner: owner, source: prepared, isCurrent: isCurrent) + owner.components[Terrain3DSave.self] = save + } + let _: Bool = try await wait(application: application, owner: owner, source: prepared, isCurrent: isCurrent) { + guard let status = owner.components[Terrain3DSaveStatus.self], status.id == save.id else { + return nil + } + switch status.phase { + case .pending: return nil + case .saved: return true + case .cancelled: throw CancellationError() + case .failed: throw Failure.failed(status.error ?? "Terrain save failed") + } + } + try await EditorTerrainAssetFork.verify(url, expected: manifest) + try await application.withWorldAccess { + try check(owner: owner, source: prepared, isCurrent: isCurrent) + if owner.components[Terrain3DSave.self]?.id == save.id { owner.components.remove(Terrain3DSave.self) } + try commit(fork) + } + return fork + } catch { + await cleanup(owner: owner, application: application, original: original, prepared: prepared, fork: fork, editID: editID, save: saveRequest) + throw error + } + } + + private static func cleanup( + owner: Entity, + application: AppWorlds, + original: Terrain3DSource, + prepared: Terrain3DSource, + fork: URL, + editID: UUID?, + save: Terrain3DSave? + ) async { + // Cancellation cannot skip the safe point. This scoped task owns only the unpublished + // fork; it waits for actual filesystem completion before deleting that destination. + await Task { @MainActor in + let canDelete = try? await application.withWorldAccess { + guard application.main.getEntityByID(owner.id) === owner else { + return true + } + if let editID, owner.components[Terrain3DHeightEdit.self]?.id == editID { owner.components.remove(Terrain3DHeightEdit.self) } + if let save, owner.components[Terrain3DSave.self]?.id == save.id { owner.components.remove(Terrain3DSave.self) } + if owner.components[Terrain3DSource.self] == prepared { + owner.components[Terrain3DSource.self] = original + owner.components.remove(Terrain3DSourceStatus.self) + } + return owner.components[Terrain3DSource.self]?.source != fork.path + } + guard canDelete == true else { + return + } + if let save, save.hasStartedWriting { + while save.completion == nil { try? await Task.sleep(for: .milliseconds(10)) } + } + try? FileManager.default.removeItem(at: fork) + }.value + } + + private static func check(owner: Entity, source: Terrain3DSource, isCurrent: () -> Bool) throws { + try Task.checkCancellation() + guard owner.world?.getEntityByID(owner.id) === owner, isCurrent(), owner.components[Terrain3DSource.self] == source else { throw Failure.sourceChanged } + } + + private static func wait( + application: AppWorlds, + owner: Entity, + source: Terrain3DSource, + isCurrent: () -> Bool, + read: () throws -> Value? + ) async throws -> Value { + let clock = ContinuousClock(), deadline = ContinuousClock().now.advanced(by: .seconds(30)) + while true { + if let value = try await application.withWorldAccess({ + try check(owner: owner, source: source, isCurrent: isCurrent) + return try read() + }) { + return value + } + guard clock.now < deadline else { throw Failure.timedOut } + try await Task.sleep(for: .milliseconds(10)) + } + } +} + +private enum EditorTerrainAssetFork { + @concurrent + static func make(from source: URL, expected: TerrainManifestAsset) async throws -> URL { + let data = try checkedData(from: source, expected: expected) + let destination = source.deletingLastPathComponent().appendingPathComponent("edit-\(UUID().uuidString.lowercased()).aterrain") + try data.write(to: destination, options: .withoutOverwriting) + return destination + } + + @concurrent + static func verify(_ source: URL, expected: TerrainManifestAsset) async throws { + _ = try checkedData(from: source, expected: expected) + } + + private static func checkedData(from source: URL, expected: TerrainManifestAsset) throws -> Data { + try Task.checkCancellation() + let maximum = TerrainManifestAsset.maximumEncodedBytes ?? 4 * 1_024 * 1_024 + let handle = try FileHandle(forReadingFrom: source) + defer { try? handle.close() } + var data = Data() + while let bytes = try handle.read(upToCount: min(65_536, maximum - data.count + 1)), !bytes.isEmpty { + guard bytes.count <= maximum - data.count else { throw EditorTerrainSourceAuthoring.Failure.manifestTooLarge } + data.append(bytes) + try Task.checkCancellation() + } + let canonical = try TerrainManifestAsset(data: data).encodedData() + let baseline = try expected.encodedData() + guard canonical == baseline else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + return baseline + } +} diff --git a/Editor/Sources/AdaEditor/EditorTerrainSourceDescriptor.swift b/Editor/Sources/AdaEditor/EditorTerrainSourceDescriptor.swift new file mode 100644 index 000000000..3871c3ce3 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainSourceDescriptor.swift @@ -0,0 +1,65 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// The scene stores a portable reference; runtime budgets retain their engine defaults. +enum EditorTerrainSourcePayload { + static func encode(_ source: Terrain3DSource) -> EditorComponentPayload { + (try? JSONDecoder().decode(EditorComponentPayload.self, from: JSONEncoder().encode(source))) ?? [:] + } + + static func decode(_ payload: EditorComponentPayload) throws -> Terrain3DSource { + // Missing optional fields keep Codable semantics; defaults fill required fields only. + var complete = encode(Terrain3DSource(streamingRadius: nil)) + complete.merge(payload) { _, authored in authored } + return try JSONDecoder().decode(Terrain3DSource.self, from: JSONEncoder().encode(complete)) + } +} + +extension EditorComponentRegistry { + static let terrainSourceDescriptor: EditorComponentDescriptor = { + var color = EditorComponentField(key: "color", label: "Ground Color", kind: .vector4) + color.coding = .vectorObject + return EditorComponentDescriptor( + typeName: EditorBuiltInComponentType.terrain3DSource, + displayName: "Terrain Source", + category: "3D", + description: "A saved terrain with chunks, caves and overhangs.", + requiredComponentTypeNames: [EditorBuiltInComponentType.transform, EditorBuiltInComponentType.visibility], + fields: [ + EditorComponentField(key: "source", label: "Terrain File", kind: .assetReference), + color, + EditorComponentField(key: "generatesCollision", label: "Ground Collision", kind: .bool), + ], + makeDefaultPayload: { EditorTerrainSourcePayload.encode(Terrain3DSource()) }, + decode: { try EditorTerrainSourcePayload.decode($0) } + ) + }() +} + +@MainActor +enum EditorTerrainResource { + static func resolve(_ reference: String, sourceURL: URL?, resourceRootURL: URL?) throws -> URL { + guard let root = resourceRootURL ?? sourceURL?.deletingLastPathComponent() else { + throw AssetError.message(L10n.text("Open a project scene before choosing a terrain file.")) + } + let url: URL + if reference.hasPrefix("@res://") { + url = root.appendingPathComponent(String(reference.dropFirst(7))) + } else if let sourceURL { + url = URL(fileURLWithPath: reference, relativeTo: sourceURL.deletingLastPathComponent()) + } else { + throw AssetError.message(L10n.text("Choose an existing .aterrain file from project Assets.")) + } + let resolved = url.resolvingSymlinksInPath().standardizedFileURL + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL + guard resolved.path.hasPrefix(canonicalRoot.path + "/") else { + throw AssetError.message(L10n.text("Terrain files must be inside project Assets.")) + } + var isDirectory: ObjCBool = false + guard resolved.pathExtension.lowercased() == "aterrain", + FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw AssetError.message(L10n.text("Choose an existing .aterrain file from project Assets.")) + } + return resolved + } +} diff --git a/Editor/Sources/AdaEditor/EditorTerrainTool.swift b/Editor/Sources/AdaEditor/EditorTerrainTool.swift new file mode 100644 index 000000000..0e37c0fbc --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainTool.swift @@ -0,0 +1,81 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +enum EditorTerrainTool: String, CaseIterable { + case sculpt = "Sculpt terrain" + case paint = "Paint material" + case erasePaint = "Erase material paint" + case scatter = "Paint meshes" + case eraseMeshes = "Erase meshes" + case add = "Add solid" + case subtract = "Subtract solid" +} + +struct EditorTerrainBrushPreset { + let name: String + let operation: TerrainHeightBrushOperation + let shape: TerrainBrushShape + let radius: Float + let strength: Float + var step: Float = 1 + + static let all: [Self] = [ + .init(name: "Soft hill", operation: .raise, shape: .softRound, radius: 8, strength: 3), + .init(name: "Small hill", operation: .raise, shape: .softRound, radius: 3, strength: 2), + .init(name: "Mountain", operation: .raise, shape: .softRound, radius: 16, strength: 10), + .init(name: "Hard mound", operation: .raise, shape: .hardRound, radius: 5, strength: 4), + .init(name: "Square mound", operation: .raise, shape: .square, radius: 6, strength: 4), + .init(name: "Rock ridge", operation: .raise, shape: .ridge, radius: 8, strength: 6), + .init(name: "Soft valley", operation: .lower, shape: .softRound, radius: 8, strength: 4), + .init(name: "Deep valley", operation: .lower, shape: .softRound, radius: 12, strength: 10), + .init(name: "Round pit", operation: .lower, shape: .hardRound, radius: 4, strength: 4), + .init(name: "Square pit", operation: .lower, shape: .square, radius: 5, strength: 4), + .init(name: "Narrow trench", operation: .lower, shape: .ridge, radius: 5, strength: 5), + .init(name: "Crater", operation: .raise, shape: .crater, radius: 10, strength: 4), + .init(name: "Gentle smooth", operation: .smooth, shape: .softRound, radius: 6, strength: 2), + .init(name: "Strong smooth", operation: .smooth, shape: .hardRound, radius: 10, strength: 10), + .init(name: "Round plateau", operation: .flatten, shape: .softRound, radius: 8, strength: 6), + .init(name: "Square plateau", operation: .flatten, shape: .square, radius: 8, strength: 6), + .init(name: "Set elevation", operation: .setHeight, shape: .hardRound, radius: 5, strength: 1), + .init(name: "Ramp", operation: .ramp, shape: .softRound, radius: 5, strength: 1), + .init(name: "Fine terraces", operation: .terrace, shape: .softRound, radius: 8, strength: 1, step: 0.5), + .init(name: "Broad terraces", operation: .terrace, shape: .hardRound, radius: 12, strength: 1, step: 2), + .init(name: "Fine noise", operation: .noise, shape: .softRound, radius: 6, strength: 0.5), + .init(name: "Rock noise", operation: .noise, shape: .ridgedNoise, radius: 10, strength: 2), + .init(name: "Gentle erosion", operation: .talus, shape: .softRound, radius: 8, strength: 1), + .init(name: "Rock erosion", operation: .talus, shape: .hardRound, radius: 12, strength: 6), + ] +} + +enum EditorTerrainStrokeCommand: Sendable { + case height(TerrainHeightBrushStroke) + case surface(TerrainSurfaceBrushStroke) + case instances(TerrainInstanceBrushStroke) + case volume(TerrainVolumeOperation) + case materials(TerrainMaterialPalette) + case mesh(URL) + case texture(URL, layerID: UInt16) + case region(TerrainRegionCoordinate) + case generate(EditorTerrainNoise) + + var changesResource: Bool { + switch self { + case .height, .surface, .instances: false + case .volume, .materials, .mesh, .texture, .region, .generate: true + } + } +} + +@MainActor +struct EditorTerrainSourceActions { + let create: () -> Void + let materials: () -> Void + let variation: () -> Void + let mesh: () -> Void + let texture: () -> Void + let extend: () -> Void + let generate: () -> Void + var join: (() -> Void)? = nil + var detach: (() -> Void)? = nil + var cancel: (() -> Void)? = nil +} diff --git a/Editor/Sources/AdaEditor/Tooling/EditorCLI.swift b/Editor/Sources/AdaEditor/Tooling/EditorCLI.swift index 34bc8f9f1..586813e48 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorCLI.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorCLI.swift @@ -301,6 +301,10 @@ enum EditorCLI { if invocation.options["target"] == "web", let web = EditorBuildExportConfiguration.load().swiftExecutable(for: .web) { return web } + #if os(Windows) + // An empty override lets the WSL worker select its installed Linux Swift. + if invocation.options["target"] == "web" { return "" } + #endif return await SwiftToolchainLocator.locate().swiftExecutablePath } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift index 4b1c32f01..1c1a7992f 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift @@ -6,35 +6,55 @@ struct EditorEnumField: View { var accessibilityID = "AdaEditor.Enum.Toggle" var labels: [String: String] = [:] var localizesValues = true + var compact = false @Environment(\.theme) private var theme var body: some View { - HStack(spacing: 6) { - Text(label(selection.wrappedValue)) - .font(.system(size: 12)) - .lineLimit(1) - Spacer() - Text("\u{E5CF}") - .environment(\.font, AdaEditorMaterialSymbolFont.font(size: 16)) - } - .padding(.horizontal, 8) - .frame(height: 30) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(theme.editorColors.text) - .background(RoundedRectangleShape(cornerRadius: 6).fill(theme.editorColors.surfaceElevated)) - .overlay { RoundedRectangleShape(cornerRadius: 6).stroke(theme.editorColors.border.opacity(0.6), lineWidth: 1) } - .contextMenu(opensOnPrimaryAction: true) { - ForEach(cases, id: \.self) { value in - ContextMenuOption(label(value), isSelected: selection.wrappedValue == value) { - selection.wrappedValue = value - } + DropDown(selection: selection, options: cases.map { .init(label: label($0), value: $0) }, identifier: accessibilityID) { + HStack(spacing: compact ? 2 : 6) { + let value = selection.wrappedValue + Text(label(value)).font(.system(size: 12)).lineLimit(1) + Spacer(minLength: 0) + Text("\u{E5CF}").environment(\.font, AdaEditorMaterialSymbolFont.font(size: 16)) } + .padding(.horizontal, compact ? 4 : 8) + .frame(height: fieldHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(theme.editorColors.text) } - .accessibilityIdentifier(accessibilityID) + .buttonStyle(EditorFieldButtonStyle(theme: theme)) } private func label(_ value: String) -> String { let title = labels[value] ?? value return localizesValues ? L10n.tr(title) : title } + + private var fieldHeight: Float { + #if IOS || VISIONOS || ANDROID + 44 + #else + 30 + #endif + } +} + +struct EditorFieldButtonStyle: ButtonStyle { + let theme: Theme + @Environment(\.isEnabled) private var isEnabled + func makeBody(configuration: Configuration) -> some View { + let enabled = isEnabled && configuration.state.isEnabled + let focused = configuration.state.contains(.focused) + let feedback: Color = !enabled ? .clear : configuration.isPressed ? theme.editorColors.text.opacity(0.16) + : configuration.isHighlighted ? theme.editorColors.text.opacity(0.10) : focused ? theme.editorColors.blue.opacity(0.12) : .clear + return configuration.label + .opacity(enabled ? 1 : 0.55) + .background { + RoundedRectangleShape(cornerRadius: 6).fill(theme.editorColors.surfaceElevated) + .overlay { RoundedRectangleShape(cornerRadius: 6).fill(feedback) } + } + .overlay { + RoundedRectangleShape(cornerRadius: 6).stroke(focused ? theme.editorColors.blue : theme.editorColors.border.opacity(0.6), lineWidth: 1) + } + } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar.swift index 117d6d5e7..dae8f9a41 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar.swift @@ -23,17 +23,6 @@ struct EditorInspectorSidebar: View { if showsTitle { adaEditorInspectorTitle(theme: theme) } ScrollView(showsIndicators: true) { VStack(alignment: .leading, spacing: 0) { - if !viewModel.isInspectingPlayMode, viewModel.runtimeSelection == nil, - let terrain = viewModel.terrainEditing, terrain.isEnabled { - inspectorSection("Terrain Brush") { - EditorTerrainPanel( - state: terrain, - onCreate: viewModel.createTerrainRequested, - onChange: viewModel.terrainSettingsChanged - ) - } - .accessibilityIdentifier("AdaEditor.Inspector.Terrain") - } if let runtimeSelection = viewModel.runtimeSelection { inspectorSection(L10n.text("PLAY MODE · \(runtimeSelection.name.uppercased())")) { Text(L10n.text("Entity #\(runtimeSelection.id)")) @@ -73,7 +62,14 @@ struct EditorInspectorSidebar: View { } inspectorSection("COMPONENTS") { ForEach(selectedEntity.components, id: \.typeName) { component in - componentEditor(component) + if component.typeName == String(reflecting: MeshTerrain3DBinding.self) { + if let state = viewModel.terrainEditing { + EditorMeshTerrainDetachButton(state: state, accessibilityID: "AdaEditor.Inspector.DetachTerrain", + action: viewModel.detachTerrainRequested) + } + } else if component.typeName != String(reflecting: MeshTerrain3DIdentity.self) { + componentEditor(component) + } } addComponentPicker } @@ -160,6 +156,11 @@ struct EditorInspectorSidebar: View { } if !isCollapsed { + if viewModel.requiresTerrainTransformSubmission(component.typeName) { + Text(L10n.tr(viewModel.terrainEditing?.sourceFailure ?? "Press Enter to apply the transform.")) + .font(.system(size: 11)) + .foregroundColor(viewModel.terrainEditing?.sourceFailure == nil ? theme.editorColors.muted : .red) + } ForEach(EditorInspectorFieldLayout.rows(fields: fields.map(\.field), collapsed: collapsedFieldGroups[component.typeName] ?? [])) { row in if let index = row.fieldIndex { componentFieldRow(fields[index]) @@ -225,14 +226,17 @@ struct EditorInspectorSidebar: View { } else if field.typeName == EditorBuiltInComponentType.spineSource, ["animation", "skin"].contains(field.field.key) { spineChoiceField(field) } else { + let submitsTerrainTransform = viewModel.requiresTerrainTransformSubmission(field.typeName) fieldControl( fieldID: "\(field.typeName).\(field.field.key)", value: field.value, kind: field.field.kind, isEditable: field.field.isEditable, scalarBinding: viewModel.componentFieldBinding(typeName: field.typeName, field: field.field), - axisBinding: { viewModel.componentVectorAxisBinding(typeName: field.typeName, field: field.field, axisIndex: $0) } + axisBinding: { viewModel.componentVectorAxisBinding(typeName: field.typeName, field: field.field, axisIndex: $0) }, + onVectorSubmit: submitsTerrainTransform ? { viewModel.submitTerrainTransform(field.field) } : nil ) + .disabled(submitsTerrainTransform && viewModel.terrainEditing?.isSourceBusy == true) } } } @@ -251,10 +255,11 @@ struct EditorInspectorSidebar: View { kind: EditorComponentFieldKind, isEditable: Bool, scalarBinding: Binding, - axisBinding: @escaping (Int) -> Binding + axisBinding: @escaping (Int) -> Binding, + onVectorSubmit: (() -> Void)? = nil ) -> some View { if let axes = axisLabels(for: kind) { - vectorField(fieldID: fieldID, axes: axes, value: value, isEditable: isEditable, axisBinding: axisBinding) + vectorField(fieldID: fieldID, axes: axes, value: value, isEditable: isEditable, axisBinding: axisBinding, onSubmit: onVectorSubmit) } else if case .bool = kind, isEditable { boolField(text: scalarBinding) .accessibilityIdentifier("AdaEditor.Inspector.Bool.\(fieldID)") @@ -279,7 +284,8 @@ struct EditorInspectorSidebar: View { axes: [String], value: String, isEditable: Bool, - axisBinding: @escaping (Int) -> Binding + axisBinding: @escaping (Int) -> Binding, + onSubmit: (() -> Void)? ) -> some View { HStack(spacing: 4) { ForEach(Array(axes.indices), id: \.self) { index in @@ -287,7 +293,8 @@ struct EditorInspectorSidebar: View { label: axes[index], value: vectorDisplayValue(from: value, index: index), isEditable: isEditable, - text: axisBinding(index) + text: axisBinding(index), + onSubmit: onSubmit ) .accessibilityIdentifier("AdaEditor.Inspector.Axis.\(fieldID).\(axes[index])") } @@ -296,7 +303,7 @@ struct EditorInspectorSidebar: View { .frame(maxWidth: .infinity) } - private func vectorAxisField(label: String, value: String, isEditable: Bool, text: Binding) -> some View { + private func vectorAxisField(label: String, value: String, isEditable: Bool, text: Binding, onSubmit: (() -> Void)?) -> some View { HStack(spacing: 0) { Text(L10n.tr(label)) .font(.system(size: 10, weight: .bold)) @@ -304,7 +311,7 @@ struct EditorInspectorSidebar: View { .frame(width: 24, height: 32) .background(RoundedRectangleShape(cornerRadius: 5).fill(axisColor(for: label))) if isEditable { - TextField("", text: text) + TextField("", text: text, onSubmit: onSubmit) .font(.system(size: 11, weight: .bold)) .foregroundColor(theme.editorColors.text) .multilineTextAlignment(.leading) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebarViewModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebarViewModel.swift index 2539acf28..78ae025c4 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebarViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebarViewModel.swift @@ -34,6 +34,7 @@ final class EditorInspectorSidebarViewModel { var addableScriptableObjects: [EditorScriptableObjectDescriptor] = [] var gizmo: EditorGizmo? var hasExplicitGizmo: Bool + var hasJoinedDescendant = false } struct ComponentSection: Equatable { @@ -84,6 +85,7 @@ final class EditorInspectorSidebarViewModel { var isInspectingPlayMode = false private(set) var terrainEditing: EditorTerrainEditingState? @ObservationIgnored private var createTerrainAction: (() -> Void)? + @ObservationIgnored private var detachTerrainAction: ((String) -> Void)? @ObservationIgnored private var terrainSettingsChangedAction: (() -> Void)? var isComponentPickerPresented = false var componentSearchText = "" @@ -187,6 +189,7 @@ final class EditorInspectorSidebarViewModel { if sceneViewportActionOwner != ObjectIdentifier(owner) { terrainEditing = nil createTerrainAction = nil + detachTerrainAction = nil terrainSettingsChangedAction = nil } sceneViewportActionOwner = ObjectIdentifier(owner) @@ -204,16 +207,22 @@ final class EditorInspectorSidebarViewModel { owner: AnyObject, state: EditorTerrainEditingState, create: @escaping () -> Void, + detach: ((String) -> Void)? = nil, settingsChanged: @escaping () -> Void ) { guard sceneViewportActionOwner == ObjectIdentifier(owner) else { return } if terrainEditing !== state { terrainEditing = state } createTerrainAction = create + detachTerrainAction = detach terrainSettingsChangedAction = settingsChanged } func createTerrainRequested() { createTerrainAction?() } + func detachTerrainRequested() { + if let id = selectedEntity?.editorID { detachTerrainAction?(id) } + } + func terrainSettingsChanged() { terrainSettingsChangedAction?() } func clearSceneViewportActions(owner: AnyObject) { @@ -224,6 +233,7 @@ final class EditorInspectorSidebarViewModel { sceneViewportActionOwner = nil terrainEditing = nil createTerrainAction = nil + detachTerrainAction = nil terrainSettingsChangedAction = nil applyGizmoChange = nil addEntity = nil @@ -395,6 +405,33 @@ final class EditorInspectorSidebarViewModel { componentFieldBinding(typeName: EditorBuiltInComponentType.transform, field: field.field) } + func requiresTerrainTransformSubmission(_ typeName: String) -> Bool { + guard typeName == EditorBuiltInComponentType.transform, let selectedEntity else { return false } + return selectedEntity.hasJoinedDescendant || selectedEntity.components.contains { + $0.typeName == String(reflecting: MeshTerrain3DBinding.self) + } + } + + func submitTerrainTransform(_ field: EditorComponentField) { + let typeName = EditorBuiltInComponentType.transform + guard requiresTerrainTransformSubmission(typeName), terrainEditing?.isSourceBusy != true else { return } + var values = vectorComponents(from: componentFieldValue(typeName: typeName, field: field), count: field.kind.vectorComponentCount) + let drafts = values.indices.map { VectorAxisDraftKey(typeName: typeName, fieldKey: field.key, axisIndex: $0) } + guard drafts.contains(where: { vectorAxisDrafts[$0] != nil }) else { return } + for (index, key) in drafts.enumerated() { + guard let draft = vectorAxisDrafts[key] else { continue } + let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard let number = Float(value), number.isFinite else { + terrainEditing?.sourceFailure = "Enter a finite number for each edited coordinate." + return + } + values[index] = value + } + for key in drafts { vectorAxisDrafts[key] = nil } + terrainEditing?.sourceFailure = nil + updateComponentField?(typeName, field, values.joined(separator: ", ")) + } + func componentVectorAxisBinding(typeName: String, field: EditorComponentField, axisIndex: Int) -> Binding { Binding( get: { @@ -460,6 +497,10 @@ final class EditorInspectorSidebarViewModel { } let draftKey = VectorAxisDraftKey(typeName: typeName, fieldKey: field.key, axisIndex: axisIndex) + if requiresTerrainTransformSubmission(typeName) { + if terrainEditing?.isSourceBusy != true { vectorAxisDrafts[draftKey] = value } + return + } let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedValue.isEmpty || Double(trimmedValue) == nil { vectorAxisDrafts[draftKey] = value diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorMeshPicking.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorMeshPicking.swift index 322ef3d4d..127ca7fea 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorMeshPicking.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorMeshPicking.swift @@ -14,6 +14,7 @@ enum EditorMeshPicking { let mesh: Mesh? let skin: [Transform3D] let usesBounds: Bool + var isTerrain = false } static func pick(ray: Ray, world: World, authoredEntities: [String: Entity.ID], editorIDs: [Entity.ID: String], cameraID: Entity.ID?) -> String? { @@ -23,6 +24,31 @@ enum EditorMeshPicking { guard entity.id != cameraID, let editorID = editorIDs[entity.id], let matrix = EditorPicking.worldMatrix(for: entity, requiringVisible: true), let localRay = EditorPicking.localRay(ray, matrix: matrix) else { continue } + if entity.components[Terrain3DSource.self] != nil { + if let queries = entity.components[MeshTerrain3DSourceSurface.self]?.queries ?? entity.components[Terrain3DSourceSurface.self]?.queries, + let distance = terrainDistance(ray: localRay, queries: queries) { + candidates.append(Candidate(editorID: editorID, ray: localRay, minimumDistance: distance, mesh: nil, skin: [], usesBounds: true, isTerrain: true)) + } + // Generated chunks have no editor IDs; the authored source owns their query snapshot. + continue + } + if let terrain = entity.components[Terrain3D.self] { + // The authored terrain owns the surface; its generated tile children have no editor IDs. + if let hit = EditorTerrainBrush.hit(ray: localRay, terrain: terrain) { + candidates.append( + Candidate( + editorID: editorID, + ray: localRay, + minimumDistance: hit.distance, + mesh: nil, + skin: [], + usesBounds: true, + isTerrain: true + ) + ) + } + continue + } if let component = entity.components[Mesh3DComponent.self] { let mesh = component.mesh let skin = entity.components[SkinnedMesh3DComponent.self]?.matrices ?? [] @@ -41,8 +67,16 @@ enum EditorMeshPicking { // An unbounded skin still needs a triangle test; it cannot be rejected using rest-pose bounds. let minimumDistance = bounds.flatMap { EditorPicking.rayAABBIntersectionDistance(ray: localRay, aabb: $0) } guard bounds == nil || minimumDistance != nil else { continue } - candidates.append(Candidate(editorID: editorID, ray: localRay, minimumDistance: minimumDistance ?? 0, - mesh: mesh, skin: skin, usesBounds: (isMorphed && bounds != nil) || !hasTriangles)) + candidates.append( + Candidate( + editorID: editorID, + ray: localRay, + minimumDistance: minimumDistance ?? 0, + mesh: mesh, + skin: skin, + usesBounds: (isMorphed && bounds != nil) || !hasTriangles + ) + ) } else { // Imported roots and transform-only generated groups have no selectable surface. // Keep the small bounds fallback for authored cameras, lights and empty entities. @@ -53,6 +87,7 @@ enum EditorMeshPicking { } candidates.sort { $0.minimumDistance < $1.minimumDistance } var selectedID: String? + var selectedIsTerrain = false var nearest = Float.greatestFiniteMagnitude for candidate in candidates { if candidate.minimumDistance > nearest { break } @@ -63,14 +98,34 @@ enum EditorMeshPicking { // Morph deltas currently live in a GPU texture. Preserve conservative picking for those poses. distance = candidate.minimumDistance } - if let distance, distance < nearest { + // The retained source owns selection at coincident joined triangles, + // so the artist can pick and move it after its draw is replaced. + if let distance, distance < nearest || (distance == nearest && selectedIsTerrain && !candidate.isTerrain) { nearest = distance selectedID = candidate.editorID + selectedIsTerrain = candidate.isTerrain } } return selectedID } + private static func terrainDistance(ray: Ray, queries: TerrainSurfaceQueries) -> Float? { + guard let hit = try? queries.raycast(ray) else { + return nil + } + let direction = ray.direction + let length = (Double(direction.x) * Double(direction.x) + Double(direction.y) * Double(direction.y) + Double(direction.z) * Double(direction.z)).squareRoot() + guard length.isFinite, length > 0 else { + return nil + } + // Source queries normalize local rays; other candidates retain the original world-ray parameter. + let distance = hit.preciseDistance / length + guard distance.isFinite, distance >= 0, distance <= Double(Float.greatestFiniteMagnitude) else { + return nil + } + return Float(distance) + } + private static func intersectionDistance(ray: Ray, mesh: Mesh, skin: [Transform3D], maximumDistance: Float) -> Float? { var nearest = maximumDistance var hit = false @@ -121,14 +176,20 @@ enum EditorMeshPicking { let cross = ray.direction.cross(secondEdge) let determinant = edge.dot(cross) // A relative tolerance works for both metre-sized and very small imported geometry. - guard determinant.isFinite, abs(determinant) > Float.ulpOfOne * Math.sqrt(edge.squaredLength * cross.squaredLength) else { return nil } + guard determinant.isFinite, abs(determinant) > Float.ulpOfOne * Math.sqrt(edge.squaredLength * cross.squaredLength) else { + return nil + } let inverse = 1 / determinant let offset = ray.origin - a let u = offset.dot(cross) * inverse - guard u >= 0, u <= 1 else { return nil } + guard u >= 0, u <= 1 else { + return nil + } let q = offset.cross(edge) let v = ray.direction.dot(q) * inverse - guard v >= 0, u + v <= 1 else { return nil } + guard v >= 0, u + v <= 1 else { + return nil + } let distance = secondEdge.dot(q) * inverse return distance.isFinite && distance >= 0 ? distance : nil } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorMeshTerrainJoiningPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorMeshTerrainJoiningPanel.swift new file mode 100644 index 000000000..f4e784a01 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorMeshTerrainJoiningPanel.swift @@ -0,0 +1,134 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Uses the same fields/buttons as inspectors; the owning dock supplies horizontal scrolling. +struct EditorMeshTerrainJoiningPanel: View { + let state: EditorTerrainEditingState + let onChange: () -> Void + @Environment(\.theme) private var theme + + var body: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.text("Selected object")).font(.system(size: 11, weight: .semibold)) + Text(state.meshSourceName ?? L10n.text("Select a static mesh in the scene.")).font(.system(size: 12)).lineLimit(2) + Text(L10n.text("The object stays in the hierarchy. Move it to update the terrain and surrounding objects.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted).lineLimit(4) + }.frame(width: 220) + VStack(alignment: .leading, spacing: 8) { + Text(L10n.text("Target Terrain")).font(.system(size: 11, weight: .semibold)) + DropDown(selection: Binding(get: { state.meshTargetEditorID ?? state.meshTargets.first?.id ?? "" }, + set: { if !state.isSourceBusy { state.meshTargetEditorID = $0; onChange() } }), + options: state.meshTargets.map { .init(label: $0.name, value: $0.id) }, identifier: "AdaEditor.Terrain.JoinTarget") { + HStack(spacing: 6) { + Text(targetName).font(.system(size: 12)).lineLimit(1) + Spacer() + Text("\u{E5CF}").environment(\.font, AdaEditorMaterialSymbolFont.font(size: 16)) + }.padding(.horizontal, 8).frame(height: controlHeight) + } + .buttonStyle(EditorFieldButtonStyle(theme: theme)) + .disabled(state.isSourceBusy || state.meshTargets.isEmpty) + Text(L10n.text("Operation")).font(.system(size: 11, weight: .semibold)) + EditorEnumField(cases: ["Join", "Subtract", "Conform"], + selection: Binding(get: { state.meshOperation }, set: { if !state.isSourceBusy { state.meshOperation = $0; onChange() } }), + accessibilityID: "AdaEditor.Terrain.JoinOperation").disabled(state.isSourceBusy) + if state.meshOperation == "Conform" { + setting("Projection distance", property: \.meshConformDistance, in: 0.1...16, id: "ConformDistance") + setting("Projection strength", property: \.meshConformStrength, in: 0...1, id: "ConformStrength") + } else if state.meshOperation == "Join" { + setting("Contact width", property: \.meshContactWidth, in: 0...8, id: "JoinContactWidth") + } + }.frame(width: 240) + VStack(alignment: .leading, spacing: 10) { + Button(action: { + // Observable changes schedule visual reconciliation. Reject a queued click + // against the live state as well, including the interval before that frame. + guard !state.isSourceBusy, state.meshSourceName != nil, !state.meshTargets.isEmpty else { return } + state.sourceActions?.join?() + }) { + Text(state.meshOperation == "Join" ? L10n.text("Join selected mesh") : L10n.text("Apply operation")).font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 10).frame(height: controlHeight).frame(maxWidth: .infinity) + } + .buttonStyle(EditorFieldButtonStyle(theme: theme)) + .disabled(state.isSourceBusy || state.meshSourceName == nil || state.meshTargets.isEmpty) + .accessibilityIdentifier("AdaEditor.Terrain.JoinSelected") + if state.meshHasBinding { + EditorMeshTerrainDetachButton(state: state, accessibilityID: "AdaEditor.Terrain.DetachSelected") { + if state.meshHasBinding { state.sourceActions?.detach?() } + } + } + if state.isSourceBusy { + Button(action: { state.sourceActions?.cancel?() }) { + Text(L10n.text("Cancel")).font(.system(size: 11)).padding(.horizontal, 10) + .frame(height: controlHeight).frame(maxWidth: .infinity) + }.buttonStyle(EditorFieldButtonStyle(theme: theme)).accessibilityIdentifier("AdaEditor.Terrain.Cancel") + } + if let status = state.sourceStatus { + Text(L10n.tr(status)).font(.system(size: 11)).lineLimit(2) + } + if let failure = state.sourceFailure { + Text(L10n.tr(failure)).font(.system(size: 10)).lineLimit(4) + } + Text(L10n.text("Escape cancels. Undo restores the whole change.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted).lineLimit(2) + }.frame(width: 240) + } + } + + private var targetName: String { + state.meshTargets.first { $0.id == state.meshTargetEditorID }?.name ?? state.meshTargets.first?.name ?? L10n.text("No Mesh Terrain is ready.") + } + + private func setting(_ title: String, property: ReferenceWritableKeyPath, + in range: ClosedRange, id: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.tr(title)) + Spacer() + Text(String(format: "%.2f", state[keyPath: property])) + }.font(.system(size: 10)) + Slider(value: Binding(get: { Double(state[keyPath: property]) }, set: { + guard !state.isSourceBusy else { return } + state[keyPath: property] = Float($0); onChange() + }), in: range) + .disabled(state.isSourceBusy) + .accessibilityIdentifier("AdaEditor.Terrain.\(id)") + } + } + + private var controlHeight: Float { + #if IOS || VISIONOS || ANDROID + 44 + #else + 30 + #endif + } +} + +struct EditorMeshTerrainDetachButton: View { + let state: EditorTerrainEditingState + let accessibilityID: String + let action: () -> Void + @Environment(\.theme) private var theme + + var body: some View { + Button(action: { + guard !state.isSourceBusy else { return } + action() + }) { + Text(L10n.text("Detach from terrain")).font(.system(size: 11)) + .padding(.horizontal, 10).frame(height: height).frame(maxWidth: .infinity) + } + .buttonStyle(EditorFieldButtonStyle(theme: theme)) + .disabled(state.isSourceBusy) + .accessibilityIdentifier(accessibilityID) + } + + private var height: Float { + #if IOS || VISIONOS || ANDROID + 44 + #else + 30 + #endif + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorPicking.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorPicking.swift index b584a7bfe..ea6eb0d64 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorPicking.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorPicking.swift @@ -4,8 +4,8 @@ import Math enum EditorPicking { /// Input can arrive before transform propagation after a scene edit or reload. /// Compose current local transforms rather than reading a previous frame's global cache. - static func worldMatrix(for entity: Entity, requiringVisible: Bool = false) -> Transform3D? { - guard let transform = entity.components[Transform.self], + static func worldMatrix(for entity: Entity, requiringVisible: Bool = false, overriding poses: [Entity.ID: Transform] = [:]) -> Transform3D? { + guard let transform = poses[entity.id] ?? entity.components[Transform.self], !requiringVisible || (entity.isActive && entity.components[Visibility.self] != .hidden) else { return nil } @@ -17,7 +17,7 @@ enum EditorPicking { !requiringVisible || (parent.isActive && parent.components[Visibility.self] != .hidden) else { return nil } - guard let parentTransform = parent.components[Transform.self] else { + guard let parentTransform = poses[parent.id] ?? parent.components[Transform.self] else { break } matrix = parentTransform.matrix * matrix diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneToolbarButton.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneToolbarButton.swift index 3b9d0efc5..8cd14bd41 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneToolbarButton.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneToolbarButton.swift @@ -7,6 +7,7 @@ struct EditorSceneToolbarButton: View { let identifier: String var isSelected = false var isEnabled = true + var compact = false let action: () -> Void @State private var isHovered = false @@ -17,7 +18,7 @@ struct EditorSceneToolbarButton: View { Text(symbol) .font(AdaEditorMaterialSymbolFont.font(size: 17)) .foregroundColor(isSelected ? theme.editorColors.blue : theme.editorColors.text) - .frame(width: 28, height: 26) + .frame(width: compact ? 24 : 28, height: 26) } .buttonStyle(EditorPickerRowButtonStyle( theme: theme, diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportEditing.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportEditing.swift index 96d5c045f..6fcee3bb9 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportEditing.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportEditing.swift @@ -20,6 +20,7 @@ extension EditorSceneViewportView { sourceURL: document.absolutePath.map { URL(fileURLWithPath: $0) }, resourceRootURL: resourceRootURL, scriptableObjectCatalog: inspectorViewModel.scriptableObjectCatalog, + isHistoryChange: document.statusMessage == "Undo" || document.statusMessage == "Redo", onSelectionChanged: { [weak inspectorViewModel] selection in inspectorViewModel?.selectEntity(selection) if selection != nil { @@ -91,7 +92,9 @@ extension EditorSceneViewportView { model.removeComponent(typeName: typeName, from: selectedEntityID) } }, - updateComponentField: { typeName, field, value in + updateComponentField: { [weak viewportModel] typeName, field, value in + if typeName == EditorBuiltInComponentType.transform, + viewportModel?.applyMeshTerrainInspectorField(field, value: value) == true { return } Self.mutateSceneDocument(document: document, status: "Edited", onDocumentChanged: onDocumentChanged) { model in guard let selectedEntityID = model.editor?.selectedEntity else { return @@ -134,6 +137,10 @@ extension EditorSceneViewportView { viewportPresentation.refresh(viewportModel: viewportModel) viewProxy.setNeedsDisplay() }, + detach: { [weak viewportModel] id in + guard let viewportModel, viewportModel.selectedEditorID == id else { return } + viewportModel.detachSelectedMeshTerrain() + }, settingsChanged: { [weak viewportModel] in guard let viewportModel else { return } viewportPresentation.refresh(viewportModel: viewportModel) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportGizmos.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportGizmos.swift index 3049d04fd..6bccebd7a 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportGizmos.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportGizmos.swift @@ -29,7 +29,7 @@ extension EditorSceneViewportModel { func transformGizmo(size: Size? = nil) -> EditorTransformGizmo? { guard !isAnnotating, - !terrainEditing.isEnabled, + !terrainEditing.usesBrush, !isPerspectiveTransitionActive, let selectedEditorID, let entity = sceneModel?.entities.first(where: { $0.id == selectedEditorID }), entity.enabled, diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrain.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrain.swift new file mode 100644 index 000000000..8f3bf2b2a --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrain.swift @@ -0,0 +1,188 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +extension EditorSceneViewportModel { + func joinSelectedMeshTerrain() { + let operation: MeshTerrainModifierOperation + switch terrainEditing.meshOperation { + case "Subtract": operation = .subtract + case "Conform": + operation = .conform(direction: [0, -1, 0], distance: terrainEditing.meshConformDistance, strength: terrainEditing.meshConformStrength) + default: operation = .union + } + applyMeshTerrainBinding(.join(.init(targetEditorID: terrainEditing.meshTargetEditorID, parts: terrainEditing.meshSelectedParts, + operation: operation, contactWidth: terrainEditing.meshContactWidth))) + } + + func detachSelectedMeshTerrain() { + applyMeshTerrainBinding(.detach) + } + + private func applyMeshTerrainBinding(_ command: MeshTerrainBindingCommand) { + guard !terrainEditing.isSourceBusy, let context = terrainSourceContext, let baseline = sceneModel, + let sourceID = selectedEditorID else { return } + terrainEditing.sourceGeneration = UUID() + let generation = terrainEditing.sourceGeneration + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.finishSourceTerrainTask(generation: generation) } + do { + let snapshot = try await context.application.withWorldAccess { + switch command { + case let .join(options): + try self.captureMeshTerrainJoin(sourceID: sourceID, baseline: baseline, options: options, application: context.application) + case .detach: + try self.captureMeshTerrainDetach(sourceID: sourceID, baseline: baseline, application: context.application) + } + } + try await EditorMeshTerrainTransaction.run(snapshot.input, application: context.application, + isCurrent: { self.terrainEditing.sourceGeneration == generation && self.matchesMeshTerrainBaseline(baseline) }, + onProgress: { self.terrainEditing.sourceStatus = $0 }, + commit: { file in + guard self.matchesMeshTerrainBaseline(baseline), var model = self.sceneModel, + let targetIndex = model.entities.firstIndex(where: { $0.id == snapshot.targetEditorID }), + let sourceIndex = model.entities.firstIndex(where: { $0.id == sourceID }) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let root = context.assetsRoot.resolvingSymlinksInPath().standardizedFileURL + let path = file.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root.path + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + model.entities[targetIndex].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.path.count + 1)) + model.entities[sourceIndex].components[String(reflecting: MeshTerrain3DIdentity.self)] = try snapshot.identity.map(Self.meshPayload) + model.entities[sourceIndex].components[String(reflecting: MeshTerrain3DBinding.self)] = try snapshot.binding.map(Self.meshPayload) + self.sceneModel = model + self.publishTerrainDocument() + self.refreshTerrainSourceControls() + self.onSelectionChanged?(self.selectedEntityViewModel(editorID: self.selectedEditorID, model: model)) + }) + } catch is CancellationError { + } catch { + if self.terrainEditing.sourceGeneration == generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error.localizedDescription + } + } + } + } + + private struct MeshTerrainJoinSnapshot { + let targetEditorID: String + let identity: MeshTerrain3DIdentity? + let binding: MeshTerrain3DBinding? + let input: EditorMeshTerrainTransaction.Input + } + + private enum MeshTerrainBindingCommand: Sendable { + case join(MeshTerrainJoinOptions) + case detach + } + + private struct MeshTerrainJoinOptions: Sendable { + let targetEditorID: String? + let parts: [MeshTerrainPartSelection] + let operation: MeshTerrainModifierOperation + let contactWidth: Float + } + + private func captureMeshTerrainJoin(sourceID: String, baseline: EditorSceneModel, options: MeshTerrainJoinOptions, application: AppWorlds) throws -> MeshTerrainJoinSnapshot { + guard matchesMeshTerrainBaseline(baseline), let root = runtimeEntity(for: sourceID), + let source = baseline.entities.first(where: { $0.id == sourceID }) else { throw EditorTerrainSourceAuthoring.Failure.unavailable } + let previousBinding = root.components[MeshTerrain3DBinding.self] + let targets = baseline.entities.compactMap { entity -> (String, Entity)? in + guard entity.id != sourceID, let owner = runtimeEntity(for: entity.id), owner.components[MeshTerrain3DSourceSurface.self] != nil, + owner.components[MeshTerrain3DIdentity.self] != nil else { return nil } + return (entity.id, owner) + } + let selected: (String, Entity)? + if let targetID = options.targetEditorID { + guard let explicit = targets.first(where: { $0.0 == targetID }) else { + throw EditorTerrainSourceAuthoring.Failure.failed("The selected Mesh Terrain is unavailable. Choose another terrain.") + } + selected = explicit + } else if let binding = previousBinding { + selected = targets.first { $0.1.components[MeshTerrain3DIdentity.self]?.id == binding.target } + } else { selected = targets.first } + guard let (targetID, owner) = selected, let targetIdentity = owner.components[MeshTerrain3DIdentity.self], + let asset = owner.components[MeshTerrain3DSourceSurface.self]?.asset, let settings = owner.components[Terrain3DSource.self], + previousBinding == nil || previousBinding?.target == targetIdentity.id, + let rootMatrix = EditorPicking.worldMatrix(for: root, requiringVisible: true), + let targetMatrix = EditorPicking.worldMatrix(for: owner, requiringVisible: true) else { + throw EditorTerrainSourceAuthoring.Failure.unavailable + } + let captured = try MeshTerrainSourceCapture.capture(root, parts: options.parts) + let identity = root.components[MeshTerrain3DIdentity.self] ?? .init() + let used = Set(asset.document.modifiers.map(\.sourceID)) + guard let next = previousBinding?.sourceID ?? (UInt32(1)...UInt32(513)).first(where: { !used.contains($0) }) else { + throw TriangleGeometryError.budgetExceeded + } + let operation = options.operation + let binding = MeshTerrain3DBinding(target: targetIdentity.id, modifier: previousBinding?.modifier ?? UUID(), sourceID: next, + operation: operation, parts: options.parts, contactWidth: options.contactWidth) + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: application.main) + let oldParts = try previousBinding.map { try MeshTerrainSourceCapture.capture(root, parts: $0.parts).drawReplacements } ?? [] + let oldDraws = Set(oldParts.map(\.entity)) + let previousDraws = ownership.draws.filter { oldDraws.contains($0.entity) } + let possibleCollisions = try MeshTerrainSourceCapture.collisionSources(root, parts: previousBinding?.parts ?? [], in: application.main) + let oldCollisions = Set(ownership.collisions).intersection(possibleCollisions) + let collision = settings.generatesCollision && application.main.physicsWorld3D != nil + let selectedCollisions = operation == .union && collision + ? try MeshTerrainSourceCapture.collisionSources(root, parts: binding.parts, in: application.main) : [] + let selectedDraws = operation == .union ? captured.drawReplacements : [] + let draws = ownership.draws.filter { !oldDraws.contains($0.entity) } + selectedDraws + let collisions = Set(ownership.collisions).subtracting(oldCollisions).union(selectedCollisions).sorted() + var document = asset.document + let existing = document.modifiers.firstIndex { $0.id == binding.modifier } + let retained = existing.map { document.modifiers[$0] } + let modifier = MeshTerrainModifier(id: binding.modifier, sourceID: binding.sourceID, operation: operation, surface: captured.surface, + transform: targetMatrix.inverse * rootMatrix, order: retained?.order ?? document.modifiers.count, + entityID: identity.id, sourcePath: source.components[EditorBuiltInComponentType.model3DSource]?["source"]?.stringValue ?? retained?.sourcePath, + materialLayer: retained?.materialLayer, contactWidth: binding.contactWidth, + rounding: operation == .union ? retained?.rounding : nil, sourceAppearance: operation == .union ? captured.appearance : nil) + if let existing { document.modifiers[existing] = modifier } else { document.modifiers.append(modifier) } + let candidate = MeshTerrainDocumentAsset(document: document, populationRules: asset.populationRules, + manualPlacements: asset.manualPlacements, populationOverrides: asset.populationOverrides) + let update = MeshTerrainBindingUpdate(entity: root.id, modifier: binding.modifier, identity: identity, binding: binding, + draws: Set(selectedDraws.map(\.entity)), collisions: Set(selectedCollisions)) + let previous = MeshTerrainBindingUpdate(entity: root.id, modifier: binding.modifier, + identity: root.components[MeshTerrain3DIdentity.self], binding: previousBinding, + draws: Set(previousDraws.map(\.entity)), collisions: oldCollisions) + return .init(targetEditorID: targetID, identity: identity, binding: binding, + input: .init(owner: owner, original: settings, previous: asset, candidate: candidate, previousOwnership: ownership, + draws: draws, collisions: collisions, bindings: [update], previousBindings: [previous])) + } + + private func captureMeshTerrainDetach(sourceID: String, baseline: EditorSceneModel, application: AppWorlds) throws -> MeshTerrainJoinSnapshot { + guard matchesMeshTerrainBaseline(baseline), let root = runtimeEntity(for: sourceID), let binding = root.components[MeshTerrain3DBinding.self], + let target = baseline.entities.first(where: { runtimeEntity(for: $0.id)?.components[MeshTerrain3DIdentity.self]?.id == binding.target }), + let owner = runtimeEntity(for: target.id), let asset = owner.components[MeshTerrain3DSourceSurface.self]?.asset, + let settings = owner.components[Terrain3DSource.self] else { throw EditorTerrainSourceAuthoring.Failure.unavailable } + let identity = root.components[MeshTerrain3DIdentity.self] + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: application.main) + let sourceOwnership = try MeshTerrain3DOwnership.capture(of: owner, for: root, in: application.main) + let oldDraws = Set(sourceOwnership.draws.map(\.entity)), oldCollisions = Set(sourceOwnership.collisions) + var document = asset.document + document.modifiers.removeAll { $0.id == binding.modifier } + let candidate = MeshTerrainDocumentAsset(document: document, populationRules: asset.populationRules, + manualPlacements: asset.manualPlacements, populationOverrides: asset.populationOverrides) + let detached = MeshTerrainBindingUpdate(entity: root.id, modifier: binding.modifier, identity: identity, binding: nil) + let previous = MeshTerrainBindingUpdate(entity: root.id, modifier: binding.modifier, identity: identity, binding: binding, + draws: oldDraws, collisions: oldCollisions) + return .init(targetEditorID: target.id, identity: identity, binding: nil, + input: .init(owner: owner, original: settings, previous: asset, candidate: candidate, previousOwnership: ownership, + draws: ownership.draws.filter { !oldDraws.contains($0.entity) }, collisions: ownership.collisions.filter { !oldCollisions.contains($0) }, + bindings: [detached], previousBindings: [previous])) + } + + private func matchesMeshTerrainBaseline(_ baseline: EditorSceneModel) -> Bool { + var current = sceneModel, expected = baseline + current?.editor = nil; expected.editor = nil + return current == expected + } + + private static func meshPayload(_ value: Value) throws -> EditorComponentPayload { + try JSONDecoder().decode(EditorComponentPayload.self, from: JSONEncoder().encode(value)) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainControls.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainControls.swift new file mode 100644 index 000000000..854d80118 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainControls.swift @@ -0,0 +1,82 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorSceneViewportModel { + /// Refresh only on selection/installation, with bounded retries while immutable resources load. + func refreshMeshTerrainControls() { + terrainEditing.meshControlsTask?.cancel() + terrainEditing.meshControlsGeneration = UUID() + let generation = terrainEditing.meshControlsGeneration + let selectedID = selectedEditorID + terrainEditing.meshHasBinding = sceneModel?.entities.first(where: { $0.id == selectedID })? + .components[String(reflecting: MeshTerrain3DBinding.self)] != nil + guard terrainEditing.isEnabled, let context = terrainSourceContext else { + terrainEditing.meshTargets = []; terrainEditing.meshSourceName = nil + return + } + let changedSelection = terrainEditing.meshSourceEditorID != selectedID + terrainEditing.meshSourceEditorID = selectedID + if changedSelection { terrainEditing.meshSelectedParts = []; terrainEditing.meshControlsBinding = nil } + terrainEditing.meshControlsTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { if self.terrainEditing.meshControlsGeneration == generation { self.terrainEditing.meshControlsTask = nil } } + for _ in 0..<200 { + do { + let snapshot = try await context.application.withWorldAccess { self.meshTerrainControlsSnapshot(selectedID: selectedID) } + guard !Task.isCancelled, self.terrainEditing.meshControlsGeneration == generation else { return } + self.terrainEditing.meshTargets = snapshot.targets + self.terrainEditing.meshSourceName = snapshot.sourceName + if let bound = snapshot.boundTarget { self.terrainEditing.meshTargetEditorID = bound } + else if !snapshot.targets.contains(where: { $0.id == self.terrainEditing.meshTargetEditorID }) { + self.terrainEditing.meshTargetEditorID = snapshot.targets.first?.id + } + if snapshot.binding != self.terrainEditing.meshControlsBinding { + self.terrainEditing.meshControlsBinding = snapshot.binding + if let binding = snapshot.binding { self.restoreMeshTerrainControlValues(binding) } + } + if !snapshot.pending { return } + try await Task.sleep(for: .milliseconds(50)) + } catch { return } + } + } + } + + private func restoreMeshTerrainControlValues(_ binding: MeshTerrain3DBinding) { + terrainEditing.meshSelectedParts = binding.parts + terrainEditing.meshContactWidth = binding.contactWidth + switch binding.operation { + case .subtract: terrainEditing.meshOperation = "Subtract" + case let .conform(_, distance, strength): + terrainEditing.meshOperation = "Conform" + terrainEditing.meshConformDistance = distance + terrainEditing.meshConformStrength = strength + default: terrainEditing.meshOperation = "Join" + } + } + + private struct MeshTerrainControlsSnapshot { + let targets: [EditorMeshTerrainTarget] + let sourceName: String? + let boundTarget: String? + let binding: MeshTerrain3DBinding? + let pending: Bool + } + + private func meshTerrainControlsSnapshot(selectedID: String?) -> MeshTerrainControlsSnapshot { + var targets: [EditorMeshTerrainTarget] = [], identities: [UUID: String] = [:], pending = false + for entity in sceneModel?.entities ?? [] where entity.components[EditorBuiltInComponentType.terrain3DSource] != nil { + guard let owner = runtimeEntity(for: entity.id), let identity = owner.components[MeshTerrain3DIdentity.self] else { continue } + if owner.components[MeshTerrain3DSourceSurface.self] != nil { + targets.append(.init(id: entity.id, name: entity.name)); identities[identity.id] = entity.id + } else if owner.components[Terrain3DSourceStatus.self]?.error == nil { pending = true } + } + var sourceName: String?, binding: MeshTerrain3DBinding? + if let selectedID, let source = runtimeEntity(for: selectedID), source.components[Terrain3DSource.self] == nil { + binding = source.components[MeshTerrain3DBinding.self] + if (try? MeshTerrainSourceCapture.capture(source, parts: binding?.parts ?? terrainEditing.meshSelectedParts)) != nil { + sourceName = sceneModel?.entities.first { $0.id == selectedID }?.name + } else if source.components[Model3DSource.self] != nil { pending = true } + } + return .init(targets: targets, sourceName: sourceName, boundTarget: binding.flatMap { identities[$0.target] }, binding: binding, pending: pending) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainDrag.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainDrag.swift new file mode 100644 index 000000000..f87e6c2cd --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainDrag.swift @@ -0,0 +1,179 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorSceneViewportModel { + func beginMeshTerrainDrag(editorID: String, payload: EditorComponentPayload, pose: Transform) -> Bool { + let descendants = meshTerrainBoundDescendants(of: editorID) + if !descendants.isEmpty { return beginMeshTerrainGroupDrag(editorID: editorID, descendants: descendants, payload: payload, pose: pose) } + guard let source = runtimeEntity(for: editorID), let binding = source.components[MeshTerrain3DBinding.self], binding.enabled else { return true } + guard !terrainEditing.isSourceBusy, TriangleMeshBoolean.isAvailable, let context = terrainSourceContext else { return false } + let targets = (sceneModel?.entities ?? []).compactMap { entity -> (String, Entity)? in + guard let owner = runtimeEntity(for: entity.id), owner.components[MeshTerrain3DSourceSurface.self] != nil, + owner.components[MeshTerrain3DIdentity.self]?.id == binding.target else { return nil } + return (entity.id, owner) + } + guard targets.count == 1, let target = targets.first else { return false } + let generation = UUID() + terrainEditing.sourceGeneration = generation + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceStatus = "Moving joined mesh…" + terrainEditing.meshDragSession = .init(sourceEditorID: editorID, targetEditorID: target.0, source: source, owner: target.1, + initialPose: pose, initialPayload: payload, initialContent: sceneContent, generation: generation, application: context.application) + return true + } + + func finishMeshTerrainDrag(_ session: EditorMeshTerrainDragSession, cancelled: Bool) { + guard let context = terrainSourceContext else { return } + let expectedModel = sceneModel + let payload = sceneModel?.entities.first { $0.id == session.sourceEditorID }?.components[EditorBuiltInComponentType.transform] + let finalPose = try? payload.flatMap { try EditorComponentPayloadDecoder.decode(Transform.self, payload: $0) as? Transform } + if cancelled { terrainEditing.meshPoseQueue.discard(session.source.id) } + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { return } + var captured: EditorMeshTerrainDragSession.Snapshot? + do { + let snapshot = try await session.captured.value + captured = snapshot + if cancelled { throw CancellationError() } + guard let finalPose, let expectedModel else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + let asset = try await self.awaitMeshTerrainPose(session, pose: finalPose, application: context.application) + let ownership = try await context.application.withWorldAccess { try MeshTerrain3DOwnership.capture(of: session.owner, in: context.application.main) } + let input = EditorMeshTerrainTransaction.Input(owner: session.owner, original: snapshot.source, previous: asset, candidate: asset, + previousOwnership: snapshot.ownership, draws: ownership.draws, collisions: ownership.collisions, + bindings: [session.bindingUpdate(snapshot)], previousBindings: [session.bindingUpdate(snapshot)], + sourceTransforms: [.init(entity: session.source.id, pose: finalPose)], rollbackAsset: snapshot.asset, + rollbackTransforms: [.init(entity: session.source.id, pose: session.initialPose)]) + try await EditorMeshTerrainTransaction.run(input, application: context.application, + isCurrent: { self.terrainEditing.sourceGeneration == session.generation && self.matchesMeshDragModel(expectedModel) }, + onProgress: { self.terrainEditing.sourceStatus = $0 }, + commit: { file in + guard self.matchesMeshDragModel(expectedModel), var model = self.sceneModel, + let index = model.entities.firstIndex(where: { $0.id == session.targetEditorID }) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let root = context.assetsRoot.resolvingSymlinksInPath().standardizedFileURL + let path = file.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root.path + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + model.entities[index].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.path.count + 1)) + self.sceneModel = model + self.publishTerrainDocument() + self.refreshTerrainSourceControls() + }) + } catch { + let failed = !(error is CancellationError) + // Always reach a world safe point after cancellation. Incoming document + // replacement owns its own runtime; never restore over that scene. + if self.sceneContent == session.initialContent { + self.terrainEditing.meshPoseQueue.discard(session.source.id) + let restoreFailure = await Task { @MainActor () -> String? in + do { + if let captured { try await self.restoreMeshTerrainDrag(session, snapshot: captured, application: context.application) } + try await self.synchronizeMeshDragInspector(session, application: context.application) + return nil + } catch { + try? await self.synchronizeMeshDragInspector(session, application: context.application) + return error.localizedDescription + } + }.value + if let restoreFailure, self.terrainEditing.sourceGeneration == session.generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be restored. The last valid surface is still visible." + self.terrainEditing.sourceFailure = restoreFailure + } + } + if failed, self.terrainEditing.sourceFailure == nil, self.terrainEditing.sourceGeneration == session.generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error.localizedDescription + } + } + if self.terrainEditing.meshDragSession === session { self.terrainEditing.meshDragSession = nil } + self.finishSourceTerrainTask(generation: session.generation) + if let model = self.sceneModel { self.onSelectionChanged?(self.selectedEntityViewModel(editorID: self.selectedEditorID, model: model)) } + } + } + + func matchesMeshDragModel(_ expected: EditorSceneModel) -> Bool { + var current = sceneModel, expected = expected + current?.editor = nil; expected.editor = nil + return current == expected + } + + private func awaitMeshTerrainPose(_ session: EditorMeshTerrainDragSession, pose: Transform, application: AppWorlds) async throws -> MeshTerrainDocumentAsset { + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + if let asset = try await application.withWorldAccess({ () throws -> MeshTerrainDocumentAsset? in + guard self.terrainEditing.sourceGeneration == session.generation, + application.main.getEntityByID(session.source.id) === session.source, + application.main.getEntityByID(session.owner.id) === session.owner else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + if let status = session.source.components[MeshTerrain3DBindingStatus.self], status.phase == .failed || status.phase == .orphaned { + throw EditorTerrainSourceAuthoring.Failure.failed(status.error ?? "Mesh Terrain could not be updated.") + } + guard session.source.components[Transform.self] == pose, session.source.components[MeshTerrain3DProposedPose.self] == nil, + session.source.components[MeshTerrain3DBindingStatus.self]?.phase == .applied else { return nil } + return session.owner.components[MeshTerrain3DSourceSurface.self]?.asset + }) { return asset } + try await Task.sleep(for: .milliseconds(10)) + } + throw EditorTerrainSourceAuthoring.Failure.timedOut + } + + private func synchronizeMeshDragInspector(_ session: EditorMeshTerrainDragSession, application: AppWorlds) async throws { + try await application.withWorldAccess { + guard self.sceneContent == session.initialContent, application.main.getEntityByID(session.source.id) === session.source, + var model = self.sceneModel, let index = model.entities.firstIndex(where: { $0.id == session.sourceEditorID }), + let pose = session.source.components[Transform.self] else { return } + let current = model.entities[index].components[EditorBuiltInComponentType.transform] + let currentPose = try current.flatMap { try EditorComponentPayloadDecoder.decode(Transform.self, payload: $0) as? Transform } + let payload: EditorComponentPayload + if pose == session.initialPose { payload = session.initialPayload } + else if pose == currentPose, let current { payload = current } + else { payload = try JSONDecoder().decode(EditorComponentPayload.self, from: JSONEncoder().encode(pose)) } + model.entities[index].components[EditorBuiltInComponentType.transform] = payload + self.sceneModel = model + self.onTransformChanged?(session.sourceEditorID, payload) + } + } + + private func restoreMeshTerrainDrag(_ session: EditorMeshTerrainDragSession, snapshot: EditorMeshTerrainDragSession.Snapshot, application: AppWorlds) async throws { + let request = try await application.withWorldAccess { () throws -> MeshTerrain3DEdit? in + guard application.main.getEntityByID(session.source.id) === session.source, + application.main.getEntityByID(session.owner.id) === session.owner else { return nil } + guard + let source = session.owner.components[Terrain3DSource.self], source.source == snapshot.source.source, + let asset = session.owner.components[MeshTerrain3DSourceSurface.self]?.asset, + session.source.components[MeshTerrain3DBinding.self] == snapshot.binding else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + // The mailbox may have been cancelled before the first scene update. + // Its already validated generation needs no queued work to restore. + if let initialKey = snapshot.asset.compiled?.inputKey, asset.compiled?.inputKey == initialKey, + session.source.components[Transform.self] == session.initialPose, + session.source.components[MeshTerrain3DProposedPose.self] == nil, + session.owner.components[MeshTerrain3DEdit.self] == nil, + session.source.components[MeshTerrain3DBindingStatus.self]?.phase == .applied { return nil } + let request = MeshTerrain3DEdit(expectedInputKey: try asset.authoringKey(), candidate: snapshot.asset, + sourceTransforms: [.init(entity: session.source.id, pose: session.initialPose)], drawReplacements: snapshot.ownership.draws, + collisionReplacements: snapshot.ownership.collisions, bindingUpdates: [session.bindingUpdate(snapshot)]) + session.owner.components[MeshTerrain3DEdit.self] = request + return request + } + guard let request else { return } + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + let done = try await application.withWorldAccess { () throws -> Bool in + guard application.main.getEntityByID(session.owner.id) === session.owner else { return true } + guard let status = session.owner.components[MeshTerrain3DEditStatus.self], status.id == request.id else { return false } + switch status.phase { + case .pending: return false + case .failed: throw EditorTerrainSourceAuthoring.Failure.failed(status.error ?? "Mesh Terrain could not be updated.") + case .cancelled: throw EditorTerrainSourceAuthoring.Failure.failed("Mesh Terrain restoration was cancelled.") + case .applied: + guard session.owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == snapshot.asset.compiled?.inputKey, + session.source.components[Transform.self] == session.initialPose else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + return true + } + } + if done { return } + try await Task.sleep(for: .milliseconds(10)) + } + throw EditorTerrainSourceAuthoring.Failure.timedOut + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainGroupDrag.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainGroupDrag.swift new file mode 100644 index 000000000..5198026f2 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainGroupDrag.swift @@ -0,0 +1,134 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorMeshTerrainGroupDrag { + let editorID: String + let entities: [String] + let initialPose: Transform + let initialPayload: EditorComponentPayload + let initialContent: String + let generation: UUID +} + +extension EditorSceneViewportModel { + func meshTerrainBoundDescendants(of editorID: String, in model: EditorSceneModel? = nil) -> [String] { + guard let model = model ?? sceneModel else { return [] } + let descendants = Set(model.subtreeEntityIDs(rootedAt: editorID).dropFirst()) + let key = String(reflecting: MeshTerrain3DBinding.self) + return model.entities.compactMap { entity in + guard descendants.contains(entity.id), let binding = entity.components[key], binding["enabled"] != .bool(false) else { return nil } + return entity.id + } + } + + func beginMeshTerrainGroupDrag(editorID: String, descendants: [String], payload: EditorComponentPayload, pose: Transform) -> Bool { + guard !terrainEditing.isSourceBusy, TriangleMeshBoolean.isAvailable, terrainSourceContext != nil else { return false } + let generation = UUID() + terrainEditing.sourceGeneration = generation + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceStatus = "Moving joined mesh…" + terrainEditing.meshGroupDrag = .init(editorID: editorID, entities: [editorID] + descendants, initialPose: pose, + initialPayload: payload, initialContent: sceneContent, generation: generation) + return true + } + + func finishMeshTerrainGroupDrag(_ group: EditorMeshTerrainGroupDrag, cancelled: Bool) { + guard let context = terrainSourceContext, let expected = sceneModel else { return } + let payload = expected.entities.first { $0.id == group.editorID }?.components[EditorBuiltInComponentType.transform] + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.terrainEditing.meshGroupDrag?.generation == group.generation { self.terrainEditing.meshGroupDrag = nil } + self.finishSourceTerrainTask(generation: group.generation) + if let model = self.sceneModel { + self.onSelectionChanged?(self.selectedEntityViewModel(editorID: self.selectedEditorID, model: model)) + } + } + do { + if cancelled { throw CancellationError() } + guard let payload, let pose = try EditorComponentPayloadDecoder.decode(Transform.self, payload: payload) as? Transform else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + if pose == group.initialPose { return } + let (input, targetID) = try await context.application.withWorldAccess { + try self.groupTerrainTransaction(group, pose: pose, world: context.application.main) + } + try await EditorMeshTerrainTransaction.run(input, application: context.application, + isCurrent: { self.terrainEditing.sourceGeneration == group.generation && self.matchesMeshDragModel(expected) }, + onProgress: { self.terrainEditing.sourceStatus = $0 }, + commit: { file in + try self.commitMeshTerrainGroupFile(file, targetID: targetID, assetsRoot: context.assetsRoot, expected: expected) + }) + } catch { + if self.sceneContent == group.initialContent, self.matchesMeshDragModel(expected), + let index = self.sceneModel?.entities.firstIndex(where: { $0.id == group.editorID }) { + self.sceneModel?.entities[index].components[EditorBuiltInComponentType.transform] = group.initialPayload + self.onTransformChanged?(group.editorID, group.initialPayload) + } + if !(error is CancellationError), self.terrainEditing.sourceGeneration == group.generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error.localizedDescription + } + } + } + } + + private func groupTerrainTransaction(_ group: EditorMeshTerrainGroupDrag, pose: Transform, world: World) throws + -> (EditorMeshTerrainTransaction.Input, String) { + guard let groupEntity = runtimeEntity(for: group.editorID), world.getEntityByID(groupEntity.id) === groupEntity, + groupEntity.components[Transform.self] == group.initialPose else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + var bound = try group.entities.dropFirst().map { id -> (Entity, MeshTerrain3DBinding) in + guard let entity = runtimeEntity(for: id), let binding = entity.components[MeshTerrain3DBinding.self], binding.enabled else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + return (entity, binding) + } + if let binding = groupEntity.components[MeshTerrain3DBinding.self], binding.enabled { bound.insert((groupEntity, binding), at: 0) } + guard let target = bound.first?.1.target, bound.allSatisfy({ $0.1.target == target }) else { + throw EditorTerrainSourceAuthoring.Failure.failed("This group must have a single Mesh Terrain target.") + } + let targets = (sceneModel?.entities ?? []).compactMap { row -> (String, Entity)? in + guard let entity = runtimeEntity(for: row.id), entity.components[MeshTerrain3DIdentity.self]?.id == target else { return nil } + return (row.id, entity) + } + guard targets.count == 1, let (targetID, owner) = targets.first, + sceneModel?.subtreeEntityIDs(rootedAt: group.editorID).contains(targetID) == false, + owner.components[MeshTerrain3DEdit.self] == nil, let original = owner.components[Terrain3DSource.self], + let asset = owner.components[MeshTerrain3DSourceSurface.self]?.asset, let terrainMatrix = EditorPicking.worldMatrix(for: owner) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: world) + var document = asset.document + let updates = try bound.map { entity, binding in + guard entity.components[MeshTerrain3DBindingStatus.self]?.phase == .applied, + let identity = entity.components[MeshTerrain3DIdentity.self], + let index = document.modifiers.firstIndex(where: { $0.id == binding.modifier }), + let sourceMatrix = EditorPicking.worldMatrix(for: entity, overriding: [groupEntity.id: pose]) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + document.modifiers[index].transform = terrainMatrix.inverse * sourceMatrix + let sourceOwnership = try MeshTerrain3DOwnership.capture(of: owner, for: entity, in: world) + return MeshTerrainBindingUpdate(entity: entity.id, modifier: binding.modifier, identity: identity, binding: binding, + draws: Set(sourceOwnership.draws.map(\.entity)), collisions: Set(sourceOwnership.collisions)) + } + let candidate = MeshTerrainDocumentAsset(document: document, populationRules: asset.populationRules, + manualPlacements: asset.manualPlacements, populationOverrides: asset.populationOverrides) + return (.init(owner: owner, original: original, previous: asset, candidate: candidate, previousOwnership: ownership, + draws: ownership.draws, collisions: ownership.collisions, bindings: updates, previousBindings: updates, + sourceTransforms: [.init(entity: groupEntity.id, pose: pose)], rollbackTransforms: [.init(entity: groupEntity.id, pose: group.initialPose)]), targetID) + } + + private func commitMeshTerrainGroupFile(_ file: URL, targetID: String, assetsRoot: URL, expected: EditorSceneModel) throws { + guard matchesMeshDragModel(expected), var model = sceneModel, let index = model.entities.firstIndex(where: { $0.id == targetID }) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let root = assetsRoot.resolvingSymlinksInPath().standardizedFileURL.path + let path = file.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + model.entities[index].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.count + 1)) + sceneModel = model + publishTerrainDocument() + refreshTerrainSourceControls() + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainHistory.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainHistory.swift new file mode 100644 index 000000000..196efda6c --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainHistory.swift @@ -0,0 +1,49 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorSceneViewportModel { + /// A narrow scene-history delta retains mesh, queries and source draws until + /// their saved generation is ready. Other scene changes use normal loading. + func restoreMeshTerrainHistory(previous: EditorSceneModel, next: EditorSceneModel, sourceURL: URL?) -> Bool { + guard !terrainEditing.isSourceBusy || terrainEditing.meshHistoryQueue.isRunning, let context = terrainSourceContext, + let plan = try? EditorMeshTerrainHistory.Plan.make(previous: previous, next: next), + let owner = runtimeEntity(for: plan.ownerEditorID), owner.components[MeshTerrain3DSourceSurface.self] != nil else { return false } + var roots: [String: Entity] = [:] + for source in plan.sources { + guard let root = runtimeEntity(for: source.editorID) else { return false } + roots[source.editorID] = root + } + let generation = UUID() + terrainEditing.sourceGeneration = generation + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceStatus = "Restoring Mesh Terrain…" + let isCurrent: @MainActor () -> Bool = { [weak self] in + guard let self else { return false } + var current = self.sceneModel, expected = next + current?.editor = nil; expected.editor = nil + return self.terrainEditing.sourceGeneration == generation && current == expected + } + terrainEditing.sourceTask = terrainEditing.meshHistoryQueue.submit(.init( + apply: { [weak self] in + try Task.checkCancellation() + let file = try EditorTerrainResource.resolve(plan.uri, sourceURL: sourceURL, resourceRootURL: context.assetsRoot) + let asset = try await EditorMeshTerrainHistory.prepare(file) + try Task.checkCancellation() + try await EditorMeshTerrainHistory.apply(plan, candidate: asset, file: file, owner: owner, roots: roots, + application: context.application, isCurrent: isCurrent) + if let self, isCurrent() { + self.refreshTerrainSourceControls() + if let model = self.sceneModel { self.onSelectionChanged?(self.selectedEntityViewModel(editorID: self.selectedEditorID, model: model)) } + } + }, + failed: { [weak self] error in + guard let self, isCurrent() else { return } + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error + }, + finished: { [weak self] in self?.finishSourceTerrainTask(generation: generation) } + )) + return true + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainPalette.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainPalette.swift new file mode 100644 index 000000000..e0c850071 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainPalette.swift @@ -0,0 +1,53 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorSceneViewportModel { + func performMeshTerrainResources(_ command: EditorTerrainStrokeCommand) { + guard !terrainEditing.isSourceBusy, let context = terrainSourceContext, let editorID = selectedEditorID, + let baseline = sceneModel else { return } + let generation = UUID() + terrainEditing.sourceGeneration = generation + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.finishSourceTerrainTask(generation: generation) } + let isCurrent = { + var expected = baseline, current = self.sceneModel + expected.editor = nil; current?.editor = nil + return self.terrainEditing.sourceGeneration == generation && current == expected + && self.terrainSourceContext?.application === context.application && self.terrainSourceContext?.assetsRoot == context.assetsRoot + } + do { + let input = try await context.application.withWorldAccess { () throws -> EditorMeshTerrainTransaction.Input? in + guard isCurrent(), let owner = self.runtimeEntity(for: editorID), let surface = owner.components[MeshTerrain3DSourceSurface.self], + let resources = owner.components[MeshTerrain3DSourceResources.self], + let source = owner.components[Terrain3DSource.self] else { throw EditorTerrainSourceAuthoring.Failure.unavailable } + if case let .materials(palette) = command, resources.manifest.materials == palette { return nil } + let ownership = try MeshTerrain3DOwnership.capture(of: owner, in: context.application.main) + return .init(owner: owner, original: source, previous: surface.asset, candidate: surface.asset, previousOwnership: ownership, + draws: ownership.draws, collisions: ownership.collisions, bindings: [], previousBindings: [], resourceCommand: command) + } + guard let input else { return } + try await EditorMeshTerrainTransaction.run(input, application: context.application, isCurrent: isCurrent, + onProgress: { self.terrainEditing.sourceStatus = $0 }, commit: { file in + guard isCurrent(), let index = self.sceneModel?.entities.firstIndex(where: { $0.id == editorID }) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let root = context.assetsRoot.resolvingSymlinksInPath().standardizedFileURL.path + let path = file.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + self.sceneModel?.entities[index].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.count + 1)) + self.publishTerrainDocument() + self.refreshTerrainSourceControls() + }) + } catch is CancellationError { + } catch { + if self.terrainEditing.sourceGeneration == generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error.localizedDescription + } + } + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainSculpt.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainSculpt.swift new file mode 100644 index 000000000..b326beb31 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportMeshTerrainSculpt.swift @@ -0,0 +1,227 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +extension EditorSceneViewportModel { + func handleMeshTerrainSculptPointer(at point: Point, began: Bool, ended: Bool, cancelled: Bool) -> Bool { + if cancelled { endTerrainStroke(cancelled: true); return true } + if terrainEditing.meshSculptPointerActive { + if !began { recordMeshBrushPoint(point) } + if ended { endSourceTerrainStroke() } + return true + } + if ended { endSourceTerrainStroke(); return true } + guard let context = terrainSourceContext, let editorID = selectedEditorID, !terrainEditing.isSourceBusy else { return true } + if began { + let tool: EditorMeshTerrainSculptStroke.Tool + switch (terrainEditing.dockTab, terrainEditing.sourceTool) { + case ("Sculpt", .sculpt): tool = .shape(terrainEditing.mode) + case ("Materials", .paint): tool = .material(.paint(layer: terrainEditing.sourceLayerID)) + case ("Materials", .erasePaint): tool = .material(.erase) + case ("Mesh Instancing", .scatter), ("Mesh Instancing", .eraseMeshes): + tool = .population(.init(prototypeID: terrainEditing.sourcePrototypeID, density: Float(terrainEditing.meshDensity), + seed: terrainEditing.noise.seed, erases: terrainEditing.sourceTool == .eraseMeshes)) + default: + terrainEditing.sourceStatus = "Choose Sculpt to edit the shape of Mesh Terrain." + return true + } + beginMeshTerrainSculpt(at: point, editorID: editorID, tool: tool, application: context.application, assetsRoot: context.assetsRoot) + } else { + terrainEditing.sourceHoverTask?.cancel() + terrainEditing.sourceHoverTask = Task { @MainActor [weak self] in + guard let self else { return } + let hit = try? await context.application.withWorldAccess { self.meshTerrainSculptHit(at: point, editorID: editorID) } + guard !Task.isCancelled, !self.terrainEditing.isSourceBusy, self.selectedEditorID == editorID, self.terrainEditing.usesBrush else { return } + self.terrainEditing.hover = hit.map { .init(editorID: $0.editorID, point: $0.sample.position, matrix: $0.matrix, queries: $0.surface.queries) } + self.terrainEditing.brushRevision &+= 1 + } + } + return true + } + + func updateMeshTerrainSculpt(deltaTime: Float) -> Bool { + guard !terrainEditing.sourceReleaseRequested, let stroke = terrainEditing.meshSculptStroke else { return false } + if consumeMeshBrushPoints(stroke) { return true } + guard let point = terrainEditing.pointer, let sample = meshTerrainSample(at: point, matrix: stroke.hit.matrix, queries: stroke.hit.surface.queries) else { + return false + } + stroke.append(sample, duration: deltaTime) + terrainEditing.hover = .init(editorID: stroke.hit.editorID, point: sample.position, matrix: stroke.hit.matrix, queries: stroke.previewQueries) + terrainEditing.brushRevision &+= 1 + return true + } + + private func recordMeshBrushPoint(_ point: Point) { + guard point.x.isFinite, point.y.isFinite, + (terrainEditing.meshBrushPoints.last ?? terrainEditing.meshBrushLastPoint) != point else { return } + // Keep the most recent position even if a burst fills the bounded buffer. + if terrainEditing.meshBrushPoints.count == 64 { terrainEditing.meshBrushPoints[63] = point } + else { terrainEditing.meshBrushPoints.append(point) } + } + + @discardableResult + private func consumeMeshBrushPoints(_ stroke: EditorMeshTerrainSculptStroke) -> Bool { + guard !terrainEditing.meshBrushPoints.isEmpty else { return false } + let path = terrainEditing.meshBrushPoints + terrainEditing.meshBrushPoints.removeAll(keepingCapacity: true) + var budget = 128, consumed = 0 + var lastHit: TerrainSurfaceQueryHit? + for point in path { + guard budget > 0, !stroke.exceededLimit else { break } + consumed += 1 + let start = terrainEditing.meshBrushLastPoint ?? point + terrainEditing.meshBrushLastPoint = point + let dx = point.x - start.x, dy = point.y - start.y + let length = (dx * dx + dy * dy).squareRoot() + guard length.isFinite else { continue } + let end = meshTerrainSample(at: point, matrix: stroke.hit.matrix, queries: stroke.hit.surface.queries) + let startHit = meshTerrainSample(at: start, matrix: stroke.hit.matrix, queries: stroke.hit.surface.queries) + let worldLength = end.flatMap { end in startHit.map { ($0.position - end.position).length } } ?? 0 + let stride = max(0.05, stroke.radiusForSampling * 0.25) + let steps = min(budget, max(1, Int(min(64, ceil(max(length / 16, worldLength / stride)))))) + budget -= steps + for index in 1...steps { + let t = Float(index) / Float(steps) + let screen = Point(x: start.x + dx * t, y: start.y + dy * t) + let sample = index == steps ? end : meshTerrainSample(at: screen, matrix: stroke.hit.matrix, queries: stroke.hit.surface.queries) + if let sample { stroke.append(sample, duration: 1 / 60); lastHit = sample } + } + } + if consumed < path.count, !stroke.exceededLimit { + terrainEditing.meshBrushPoints = Array(path.dropFirst(consumed)) + } + terrainEditing.hover = lastHit.map { .init(editorID: stroke.hit.editorID, point: $0.position, matrix: stroke.hit.matrix, queries: stroke.previewQueries) } + terrainEditing.brushRevision &+= 1 + return true + } + + private func beginMeshTerrainSculpt(at point: Point, editorID: String, tool: EditorMeshTerrainSculptStroke.Tool, + application: AppWorlds, assetsRoot: URL) { + guard let baseline = sceneModel else { return } + let radius = terrainEditing.radius, strength = terrainEditing.strength, tab = terrainEditing.dockTab + let generation = UUID() + terrainEditing.sourceGeneration = generation + terrainEditing.sourceHoverTask?.cancel() + terrainEditing.sourceReleaseRequested = false + terrainEditing.meshSculptPointerActive = true + terrainEditing.meshBrushLastPoint = point + terrainEditing.meshBrushPoints.removeAll(keepingCapacity: true) + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceStatus = "Drawing mesh terrain…" + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.finishSourceTerrainTask(generation: generation) } + let isCurrent = { + self.terrainEditing.sourceGeneration == generation && self.terrainEditing.usesBrush && self.terrainEditing.dockTab == tab + && self.selectedEditorID == editorID && self.matchesMeshTerrainSculptModel(baseline) + && self.terrainSourceContext?.application === application && self.terrainSourceContext?.assetsRoot == assetsRoot + } + var captured: EditorMeshTerrainSculptStroke? + do { + let stroke = try await application.withWorldAccess { + guard isCurrent(), let hit = self.meshTerrainSculptHit(at: point, editorID: editorID) else { + throw EditorTerrainSourceAuthoring.Failure.unavailable + } + _ = try EditorTerrainResource.resolve(hit.source.source, sourceURL: assetsRoot.appendingPathComponent("scene.ascn"), resourceRootURL: assetsRoot) + if case let .material(operation) = tool { + guard let palette = hit.owner.components[MeshTerrain3DSourceResources.self]?.manifest.materials else { + throw EditorTerrainSourceAuthoring.Failure.unavailable + } + if case let .paint(layer) = operation, !palette.layers.contains(where: { $0.id == layer }) { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + } + if case let .population(settings) = tool, + hit.owner.components[MeshTerrain3DSourceResources.self]?.manifest.meshPrototypes.contains(where: { $0.id == settings.prototypeID }) != true { + throw TerrainInstanceBrushError.missingPrototype + } + let ownership = try MeshTerrain3DOwnership.capture(of: hit.owner, in: application.main) + return try EditorMeshTerrainSculptStroke(hit: hit, ownership: ownership, radius: radius, strength: strength, tool: tool) + } + captured = stroke + try Task.checkCancellation() + self.terrainEditing.meshSculptStroke = stroke + stroke.append(stroke.hit.sample, duration: 1 / 60) + let resident = try await self.previewMeshTerrainSculpt(stroke, application: application, isCurrent: isCurrent) + guard resident.compiled?.inputKey != stroke.hit.surface.asset.compiled?.inputKey else { return } + let input = EditorMeshTerrainTransaction.Input(owner: stroke.hit.owner, original: stroke.hit.source, previous: resident, candidate: resident, + previousOwnership: stroke.ownership, draws: stroke.ownership.draws, collisions: stroke.ownership.collisions, + bindings: [], previousBindings: [], rollbackAsset: stroke.hit.surface.asset) + try await EditorMeshTerrainTransaction.run(input, application: application, isCurrent: isCurrent, + onProgress: { self.terrainEditing.sourceStatus = $0 }, commit: { file in + guard isCurrent(), let index = self.sceneModel?.entities.firstIndex(where: { $0.id == editorID }) else { + throw EditorTerrainSourceAuthoring.Failure.sourceChanged + } + let root = assetsRoot.resolvingSymlinksInPath().standardizedFileURL.path + let path = file.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + self.sceneModel?.entities[index].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.count + 1)) + self.publishTerrainDocument() + self.refreshTerrainSourceControls() + }) + } catch { + if let captured, self.matchesMeshTerrainSculptModel(baseline) { + let failure = await Task { @MainActor () -> String? in + do { + _ = try await captured.publish(captured.hit.surface.asset, application: application, isCurrent: { + self.matchesMeshTerrainSculptModel(baseline) && self.terrainSourceContext?.application === application + }) + return nil + } catch { return error.localizedDescription } + }.value + if let failure, self.terrainEditing.meshSculptStroke === captured, self.matchesMeshTerrainSculptModel(baseline) { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be restored. The last valid surface is still visible." + self.terrainEditing.sourceFailure = failure + } + } + if !(error is CancellationError), self.terrainEditing.sourceFailure == nil, self.terrainEditing.sourceGeneration == generation { + self.terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + self.terrainEditing.sourceFailure = error.localizedDescription + } + } + } + } + + private func previewMeshTerrainSculpt(_ stroke: EditorMeshTerrainSculptStroke, application: AppWorlds, + isCurrent: () -> Bool) async throws -> MeshTerrainDocumentAsset { + var revision: UInt64 = 0 + var accepted = stroke.hit.surface.asset + while true { + try Task.checkCancellation() + guard isCurrent() else { throw CancellationError() } + consumeMeshBrushPoints(stroke) + if stroke.exceededLimit { throw EditorTerrainSourceAuthoring.Failure.failed("Split this brush stroke into smaller strokes.") } + if revision != stroke.revision { + let requested = stroke.revision + let resident = try await stroke.publish(stroke.candidate(), application: application, isCurrent: isCurrent) + accepted = resident.asset + stroke.previewQueries = resident.queries + revision = requested + terrainEditing.brushRevision &+= 1 + } + if terrainEditing.sourceReleaseRequested, revision == stroke.revision, terrainEditing.meshBrushPoints.isEmpty { return accepted } + try await Task.sleep(for: .milliseconds(10)) + } + } + + private func matchesMeshTerrainSculptModel(_ baseline: EditorSceneModel) -> Bool { + var current = sceneModel, expected = baseline + current?.editor = nil; expected.editor = nil + return current == expected + } + + private func meshTerrainSculptHit(at point: Point, editorID: String) -> EditorMeshTerrainSculptStroke.Hit? { + guard !isPerspectiveTransitionActive, let owner = runtimeEntity(for: editorID), let source = owner.components[Terrain3DSource.self], + let surface = owner.components[MeshTerrain3DSourceSurface.self], let matrix = EditorPicking.worldMatrix(for: owner, requiringVisible: true), + let sample = meshTerrainSample(at: point, matrix: matrix, queries: surface.queries) else { return nil } + return .init(editorID: editorID, owner: owner, source: source, surface: surface, matrix: matrix, sample: sample) + } + + private func meshTerrainSample(at point: Point, matrix: Transform3D, queries: TerrainSurfaceQueries?) -> TerrainSurfaceQueryHit? { + let camera = cameraState(for: viewportSize) + guard let queries, let ray = EditorPicking.ray(point: point, viewportSize: viewportSize, cameraTransform: camera.transform.matrix, + projection: camera.projection.makeClipView()), let local = EditorPicking.localRay(ray, matrix: matrix) else { return nil } + return try? queries.raycast(local) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportModel.swift index 69a02c09d..8edf5412d 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportModel.swift @@ -11,6 +11,20 @@ enum EditorSceneViewportDisplayMode: String { case threeD = "3D" } +enum EditorSceneViewportRenderingMode: String, CaseIterable { + case shaded = "Shaded" + case wireframe = "Wireframe" + case shadedWireframe = "Shaded + Wireframe" + + var debugView: DebugView3D { + switch self { + case .shaded: .none + case .wireframe: .wireframe + case .shadedWireframe: .shadedWireframe + } + } +} + enum EditorSceneViewportTool: String, CaseIterable { case select = "Select" case translate = "Move" @@ -35,6 +49,7 @@ final class EditorSceneViewportModel { private var cameraEntityID: Entity.ID? private var sceneEnvironmentCameraID: Entity.ID? var displayMode: EditorSceneViewportDisplayMode = .twoD + var renderingMode: EditorSceneViewportRenderingMode = .shaded var viewportSize: Size = .zero var entitiesByEditorID: [String: Entity.ID] = [:] private var editorIDsByEntityID: [Entity.ID: String] = [:] @@ -43,7 +58,9 @@ final class EditorSceneViewportModel { didSet { markerPositions = sceneModel?.entities.compactMap { entity in guard let payload = entity.components[EditorBuiltInComponentType.transform], - let position = Self.markerPosition(from: payload) else { return nil } + let position = Self.markerPosition(from: payload) else { + return nil + } return MarkerPosition(id: entity.id, position: position) } ?? [] } @@ -55,8 +72,16 @@ final class EditorSceneViewportModel { private var markerPositions: [MarkerPosition] = [] private var sceneSourceURL: URL? private var sceneResourceRootURL: URL? + var terrainSourceContext: (application: AppWorlds, assetsRoot: URL)? { + guard let application = sceneApplication, let root = sceneResourceRootURL ?? sceneSourceURL?.deletingLastPathComponent() else { + return nil + } + return (application, root) + } private var scriptableObjectCatalog: [EditorScriptableObjectDescriptor] = [] - var selectedEditorID: String? + var selectedEditorID: String? { + didSet { if selectedEditorID != oldValue, terrainEditing.isEnabled { refreshTerrainSourceControls() } } + } var showsEntityMarkers = false let terrainEditing = EditorTerrainEditingState() private(set) var activeTool: EditorSceneViewportTool = .translate @@ -98,7 +123,7 @@ final class EditorSceneViewportModel { var onSelectEntity: ((String?) -> Void)? var onSelectionChanged: ((EditorInspectorSidebarViewModel.SelectedEntity?) -> Void)? var onDocumentContentChanged: ((String) -> Void)? - private var onTransformChanged: ((String, EditorComponentPayload) -> Void)? + private(set) var onTransformChanged: ((String, EditorComponentPayload) -> Void)? private struct TransformDrag { var editorID: String @@ -120,11 +145,13 @@ final class EditorSceneViewportModel { sourceURL: URL? = nil, resourceRootURL: URL? = nil, scriptableObjectCatalog: [EditorScriptableObjectDescriptor] = [], + isHistoryChange: Bool = false, onSelectionChanged: @escaping (EditorInspectorSidebarViewModel.SelectedEntity?) -> Void, onDocumentContentChanged: @escaping (String) -> Void, onTransformChanged: ((String, EditorComponentPayload) -> Void)? = nil ) -> EditorSceneRuntimeLoadResult? { let contentChanged = self.sceneContent != sceneContent + let contextChanged = sceneSourceURL != sourceURL || sceneResourceRootURL != resourceRootURL self.sceneSourceURL = sourceURL self.sceneResourceRootURL = resourceRootURL self.scriptableObjectCatalog = scriptableObjectCatalog @@ -137,34 +164,49 @@ final class EditorSceneViewportModel { // A live drag updates the runtime/Inspector; publish one document edit on release. // Redraws still carry the last published document until then. - if let transformDrag, sceneContent == transformDrag.startContent { + if transformDrag?.startContent == sceneContent || spatialEdit?.startContent == sceneContent { return nil } - guard contentChanged else { + guard contentChanged || contextChanged else { return nil } - endTerrainStroke(cancelled: true) - terrainEditing.hover = nil + var previousModel = sceneModel + let incomingModel = preparedModel ?? EditorSceneFileLoader.model(from: sceneContent) + previousModel?.editor = nil + var runtimeModel = incomingModel + runtimeModel?.editor = nil + let runtimeChanged = previousModel != runtimeModel || contextChanged + let supersedingHistory = isHistoryChange && !contextChanged && terrainEditing.meshHistoryQueue.isRunning + // Cancel against the previous document; rollback must never overwrite incoming fields. + if runtimeChanged { + _ = cancelActiveMeshTerrainTransform() + if !supersedingHistory { endTerrainStroke(cancelled: true) } + terrainEditing.hover = nil + } cancelTransformInspectorUpdate() self.sceneContent = sceneContent transformDrag = nil hoveredGizmoHandle = nil - var previousModel = sceneModel - sceneModel = preparedModel ?? EditorSceneFileLoader.model(from: sceneContent) - guard let model = sceneModel else { + sceneModel = incomingModel + guard let model = incomingModel else { + if supersedingHistory { endTerrainStroke(cancelled: true) } return nil } selectedEditorID = model.editor?.selectedEntity - previousModel?.editor = nil - var runtimeModel = model - runtimeModel.editor = nil - // Selection and hierarchy foldouts must not destroy the running preview. - let loadResult = previousModel == runtimeModel ? nil : reloadSceneRuntimeIfReady() + let retainedHistory = isHistoryChange && runtimeChanged && !contextChanged && sceneLoadTask == nil + && previousModel.map { restoreMeshTerrainHistory(previous: $0, next: model, sourceURL: sourceURL) } == true + if runtimeChanged && !retainedHistory && supersedingHistory { endTerrainStroke(cancelled: true) } + let loadResult = runtimeChanged && !retainedHistory ? reloadSceneRuntimeIfReady() : nil onSelectionChanged(selectedEntityViewModel(editorID: selectedEditorID, model: model)) return loadResult } func disconnect() { + endTransformDrag(cancelled: true) + endSpatialTransform(cancelled: true) + terrainEditing.meshControlsTask?.cancel() + terrainEditing.meshControlsTask = nil + terrainEditing.meshControlsGeneration = UUID() presentationPauseTask?.cancel() presentationPauseTask = nil endTerrainStroke(cancelled: true) @@ -174,7 +216,6 @@ final class EditorSceneViewportModel { onSceneLoadingChanged = nil onSceneLoadingProgress = nil onSceneLoaded = nil - endSpatialTransform(cancelled: true) isImmersive = false lastPinchScale = nil cancelTransformInspectorUpdate() @@ -183,6 +224,7 @@ final class EditorSceneViewportModel { } depthGridEntityIDs.removeAll() world = nil + terrainEditing.meshPoseQueue.cancel() sceneApplication = nil presentedGizmo = nil cameraEntityID = nil @@ -215,6 +257,7 @@ final class EditorSceneViewportModel { refreshSceneEnvironmentCamera(in: world) cameraEntityID = isImmersive ? nil : findCameraEntity(in: world)?.id if !isImmersive { applyCamera() } + if terrainEditing.isEnabled { refreshMeshTerrainControls() } } @discardableResult @@ -246,16 +289,23 @@ final class EditorSceneViewportModel { sceneLoadTask = Task { @MainActor [weak self, weak world] in do { let assets = try await EditorSceneAssetCache.prepare( - model: model, sourceURL: sourceURL, resourceRootURL: rootURL, reusing: previous, + model: model, + sourceURL: sourceURL, + resourceRootURL: rootURL, + reusing: previous, onProgress: { [weak self] progress in await self?.reportSceneLoadingProgress(progress, generation: generation) } ) // A cancelled install still owns cleanup of the entities it created. await previousLoad?.value - guard !Task.isCancelled, let self, let world, self.sceneLoadGeneration == generation else { return } + guard !Task.isCancelled, let self, let world, self.sceneLoadGeneration == generation else { + return + } let install = { @MainActor @Sendable () async throws -> EditorSceneRuntimeLoadResult? in - guard !Task.isCancelled, self.sceneLoadGeneration == generation else { return nil } + guard !Task.isCancelled, self.sceneLoadGeneration == generation else { + return nil + } self.onSceneLoadingProgress?(EditorSceneLoadingProgress(phase: .objects)) world.insertResource(assets) return try await self.installSceneRuntimeIncrementally(model, in: world) @@ -268,14 +318,18 @@ final class EditorSceneViewportModel { } else { result = try await install() } - guard !Task.isCancelled, self.sceneLoadGeneration == generation, let result else { return } + guard !Task.isCancelled, self.sceneLoadGeneration == generation, let result else { + return + } self.sceneLoadTask = nil self.onSceneLoadingChanged?(false) self.onSceneLoaded?(result) } catch is CancellationError { // A newer document or a closed viewport owns the next load. } catch { - guard let self, self.sceneLoadGeneration == generation else { return } + guard let self, self.sceneLoadGeneration == generation else { + return + } self.sceneLoadTask = nil self.onSceneLoadingChanged?(false) self.onSceneLoaded?(.emptyWithWarnings([error.localizedDescription])) @@ -301,7 +355,9 @@ final class EditorSceneViewportModel { } private func reportSceneLoadingProgress(_ progress: EditorSceneLoadingProgress, generation: Int) { - guard !Task.isCancelled, sceneLoadGeneration == generation else { return } + guard !Task.isCancelled, sceneLoadGeneration == generation else { + return + } onSceneLoadingProgress?(progress) } @@ -309,7 +365,9 @@ final class EditorSceneViewportModel { /// suspension also survives native window deactivation/reactivation; explicit /// scene installation serializes with it and resumes on return to the tab. func setPresentationActive(_ active: Bool) { - guard active != isPresentationActive else { return } + guard active != isPresentationActive else { + return + } isPresentationActive = active if !active { endTerrainStroke(cancelled: true) @@ -325,9 +383,16 @@ final class EditorSceneViewportModel { private func updatePresentationPause() { presentationPauseTask?.cancel() presentationPauseTask = nil - guard !isPresentationActive, let application = sceneApplication else { return } - presentationPauseTask = Task { @MainActor [weak application] in - guard let application else { return } + guard !isPresentationActive, let application = sceneApplication else { + return + } + let terrainTask = terrainEditing.sourceTask + presentationPauseTask = Task { @MainActor [weak self, weak application] in + await terrainTask?.value + guard !Task.isCancelled, let self, !self.isPresentationActive, self.sceneApplication === application, + let application else { + return + } try? await application.withHostedUpdatesSuspended { while !Task.isCancelled { try await Task.sleep(for: .seconds(3_600)) } } @@ -337,7 +402,6 @@ final class EditorSceneViewportModel { deinit { presentationPauseTask?.cancel() } private func installSceneRuntime(_ model: EditorSceneModel, in world: World) -> EditorSceneRuntimeLoadResult { - let previousEntityIDs = Set(entitiesByEditorID.values) entitiesByEditorID.removeAll() editorIDsByEntityID.removeAll() @@ -436,7 +500,9 @@ final class EditorSceneViewportModel { } func setAnnotationMode(_ enabled: Bool) { - guard isAnnotating != enabled else { return } + guard isAnnotating != enabled else { + return + } endTransformDrag(cancelled: true) if enabled { setTerrainEditing(false) } hoveredGizmoHandle = nil @@ -541,7 +607,7 @@ final class EditorSceneViewportModel { } let radius = max(5, min(18, icon.size * 8)) guard point.x >= -radius, point.x <= size.width + radius, - point.y >= -radius, point.y <= size.height + radius else { continue } + point.y >= -radius, point.y <= size.height + radius else { continue } let rect = Rect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2) context.drawEllipse(in: rect, color: color(for: icon, theme: theme).opacity(icon.isExplicit ? 0.86 : 0.58)) } @@ -573,7 +639,7 @@ final class EditorSceneViewportModel { } let radius: Float = isSelected ? 6 : 4 guard point.x >= -radius, point.x <= size.width + radius, - point.y >= -radius, point.y <= size.height + radius else { continue } + point.y >= -radius, point.y <= size.height + radius else { continue } drawViewportMarker( at: point, radius: radius, @@ -657,7 +723,7 @@ extension EditorSceneViewportModel { onCancelAnnotation?() return true } - if event.keyCode == .escape, terrainEditing.stroke != nil { + if event.keyCode == .escape, terrainEditing.stroke != nil || terrainEditing.isSourceBusy { endTerrainStroke(cancelled: true) return true } @@ -672,13 +738,13 @@ extension EditorSceneViewportModel { switch event.keyCode { case .space, - .w, - .a, - .s, - .d, - .q, - .e, - .shift: + .w, + .a, + .s, + .d, + .q, + .e, + .shift: return true default: return false @@ -687,13 +753,17 @@ extension EditorSceneViewportModel { func handleMouseEvent(_ event: MouseEvent) -> Bool { if event.button == .scrollWheel { - return terrainEditing.stroke != nil || transformDrag != nil || handleScroll(event) + return terrainEditing.stroke != nil || terrainEditing.sourceStroke != nil || transformDrag != nil || handleScroll(event) } if event.phase == .began, event.button == .right { endTerrainStroke() } - if terrainEditing.isEnabled, displayMode == .threeD, !isThreeDRotating, event.button != .right, + if terrainEditing.usesBrush, displayMode == .threeD, !isThreeDRotating, event.button != .right, !pressedKeys.contains(.space) { - return handleTerrainPointer(at: event.mousePosition, began: event.phase == .began && event.button == .left, - ended: event.phase == .ended, cancelled: event.phase == .cancelled) + return handleTerrainPointer( + at: event.mousePosition, + began: event.phase == .began && event.button == .left, + ended: event.phase == .ended, + cancelled: event.phase == .cancelled + ) } if event.phase == .changed, !isAnnotating, transformDrag == nil, !isTwoDPanning, !isThreeDRotating { let gizmo = interactionGizmo() @@ -739,7 +809,7 @@ extension EditorSceneViewportModel { self.lastMousePosition = event.mousePosition return true case .ended, - .cancelled: + .cancelled: defer { isTwoDPanning = false lastMousePosition = nil @@ -789,7 +859,7 @@ extension EditorSceneViewportModel { self.lastMousePosition = event.mousePosition return true case .ended, - .cancelled: + .cancelled: defer { isThreeDRotating = false lastMousePosition = nil @@ -833,6 +903,7 @@ extension EditorSceneViewportModel { else { return false } + guard beginMeshTerrainDrag(editorID: selectedEditorID, payload: payload, pose: transform) else { return false } transformDrag = TransformDrag( editorID: selectedEditorID, startContent: sceneContent, @@ -865,6 +936,32 @@ extension EditorSceneViewportModel { return true } + func applyMeshTerrainInspectorField(_ field: EditorComponentField, value: String) -> Bool { + guard let editorID = selectedEditorID, let model = sceneModel, + let entity = model.entities.first(where: { $0.id == editorID }) else { return false } + let binding = entity.components[String(reflecting: MeshTerrain3DBinding.self)] + guard (binding != nil && binding?["enabled"] != .bool(false)) || !meshTerrainBoundDescendants(of: editorID, in: model).isEmpty else { return false } + guard !terrainEditing.isSourceBusy, let initial = entity.components[EditorBuiltInComponentType.transform], + let initialPose = try? EditorComponentPayloadDecoder.decode(Transform.self, payload: initial) as? Transform else { return true } + var payload = initial + field.write(value, to: &payload) + guard let pose = try? EditorComponentPayloadDecoder.decode(Transform.self, payload: payload) as? Transform, + pose != initialPose else { return true } + guard beginMeshTerrainDrag(editorID: editorID, payload: initial, pose: initialPose), + terrainEditing.meshDragSession != nil || terrainEditing.meshGroupDrag != nil else { + terrainEditing.sourceFailure = "Terrain is not ready. Select a terrain and wait for it to load." + return true + } + applyTransformPayload(payload, editorID: editorID) + cancelTransformInspectorUpdate() + if let group = terrainEditing.meshGroupDrag { + finishMeshTerrainGroupDrag(group, cancelled: false) + } else if let session = terrainEditing.meshDragSession { + finishMeshTerrainDrag(session, cancelled: false) + } + return true + } + private func applyTransformPayload(_ payload: EditorComponentPayload, editorID: String) { guard var model = sceneModel, let index = model.entities.firstIndex(where: { $0.id == editorID }), @@ -910,6 +1007,21 @@ extension EditorSceneViewportModel { guard let drag else { return } + if let group = terrainEditing.meshGroupDrag, group.editorID == drag.editorID { + if cancelled { suppressSelectionOnPointerEnd = true } + cancelTransformInspectorUpdate() + finishMeshTerrainGroupDrag(group, cancelled: cancelled) + return + } + if let session = terrainEditing.meshDragSession, session.sourceEditorID == drag.editorID { + let unchanged = sceneModel?.entities.first(where: { $0.id == drag.editorID })?.components[EditorBuiltInComponentType.transform] == drag.startPayload + if cancelled || unchanged { + suppressSelectionOnPointerEnd = true + cancelTransformInspectorUpdate() + } + finishMeshTerrainDrag(session, cancelled: cancelled || unchanged) + return + } if cancelled { suppressSelectionOnPointerEnd = true applyTransformPayload(drag.startPayload, editorID: drag.editorID) @@ -919,9 +1031,9 @@ extension EditorSceneViewportModel { onSelectionChanged?(selectedEntityViewModel(editorID: drag.editorID, model: model)) } } else if let model = sceneModel, - let entity = model.entities.first(where: { $0.id == drag.editorID }), - entity.components[EditorBuiltInComponentType.transform] != drag.startPayload, - let content = try? model.encodedYAML() { + let entity = model.entities.first(where: { $0.id == drag.editorID }), + entity.components[EditorBuiltInComponentType.transform] != drag.startPayload, + let content = try? model.encodedYAML() { // Commit once on release so Save and Undo see the latest transform immediately. sceneContent = content onDocumentContentChanged?(content) @@ -929,6 +1041,7 @@ extension EditorSceneViewportModel { } func syncRuntimeTransform(editorID: String, payload: EditorComponentPayload) { + if terrainEditing.meshGroupDrag?.editorID == editorID { return } guard let runtimeID = entitiesByEditorID[editorID], let entity = world?.getEntityByID(runtimeID), @@ -936,7 +1049,15 @@ extension EditorSceneViewportModel { else { return } - entity.components += transform + if entity.components[MeshTerrain3DBinding.self]?.enabled == true, let context = terrainSourceContext { + let prepared = terrainEditing.meshDragSession?.sourceEditorID == editorID ? terrainEditing.meshDragSession?.ready : nil + if !terrainEditing.meshPoseQueue.propose(transform, for: entity, application: context.application, prepared: prepared) { + terrainEditing.sourceStatus = "Mesh Terrain could not be updated." + terrainEditing.sourceFailure = "Too many terrain sources are waiting for a pose update." + } + } else { + entity.components += transform + } } func vector(_ value: EditorSceneValue?, count: Int, defaultValues: [Double]) -> [Double] { @@ -976,9 +1097,13 @@ extension EditorSceneViewportModel { guard lastPinchScale == nil else { return true } - if terrainEditing.isEnabled, displayMode == .threeD { - return handleTerrainPointer(at: event.location, began: event.phase == .began, - ended: event.phase == .ended, cancelled: event.phase == .cancelled) + if terrainEditing.usesBrush, displayMode == .threeD { + return handleTerrainPointer( + at: event.location, + began: event.phase == .began, + ended: event.phase == .ended, + cancelled: event.phase == .cancelled + ) } switch event.phase { case .began: @@ -1005,7 +1130,7 @@ extension EditorSceneViewportModel { self.lastTouchPosition = event.location return true case .ended, - .cancelled: + .cancelled: if transformDrag != nil { if event.phase == .ended { _ = updateTransformDrag(to: event.location) @@ -1136,7 +1261,9 @@ extension EditorSceneViewportModel { } func applyCamera() { - guard !isImmersive else { return } + guard !isImmersive else { + return + } guard let cameraEntity = cameraEntity() else { return } @@ -1164,6 +1291,7 @@ extension EditorSceneViewportModel { // Navigation only changes the preview pose; it must not replace lighting // with an unrelated default environment or force authored effects on. var environment = sceneCamera?.components[Environment3D.self] ?? Environment3D() + if renderingMode != .shaded { environment.debugView = renderingMode.debugView } environment.skybox.isEnabled = environment.skybox.isEnabled && smoothPerspectiveBlend > 0 environment.screenSpaceReflection.isEnabled = environment.screenSpaceReflection.isEnabled && smoothPerspectiveBlend > 0 cameraEntity.components += environment @@ -1329,7 +1457,7 @@ extension EditorSceneViewportModel { } else { // Keep camera/light/tile-map selection through the existing bounds fallback. guard let distance = EditorPicking.intersectionDistance(ray: segment.ray, matrix: matrix, bounds: entity.components[BoundingComponent.self]), - distance <= segment.maximumDistance else { + distance <= segment.maximumDistance else { return nil } } @@ -1354,14 +1482,21 @@ extension EditorSceneViewportModel { return nil } - return EditorMeshPicking.pick(ray: ray, world: world, authoredEntities: entitiesByEditorID, - editorIDs: editorIDsByEntityID, cameraID: cameraEntityID) + return EditorMeshPicking.pick( + ray: ray, + world: world, + authoredEntities: entitiesByEditorID, + editorIDs: editorIDsByEntityID, + cameraID: cameraEntityID + ) } } extension EditorSceneViewportModel { private func selectEntity(_ editorID: String?) { - guard selectedEditorID != editorID else { return } + guard selectedEditorID != editorID else { + return + } cancelTransformInspectorUpdate() selectedEditorID = editorID guard var model = sceneModel else { @@ -1417,7 +1552,8 @@ extension EditorSceneViewportModel { scriptableObjects: scriptableObjects, addableScriptableObjects: scriptableObjectCatalog.filter { !attachedScriptableIDs.contains($0.identifier) }, gizmo: gizmo, - hasExplicitGizmo: entity.components[EditorSceneYAMLDocument.editorGizmoComponentName] != nil + hasExplicitGizmo: entity.components[EditorSceneYAMLDocument.editorGizmoComponentName] != nil, + hasJoinedDescendant: !meshTerrainBoundDescendants(of: editorID, in: model).isEmpty ) } @@ -1497,11 +1633,11 @@ extension EditorSceneViewportModel { displayName: descriptor.displayName, fields: (descriptor.fields + graphFields) .filter { typeName != EditorBuiltInComponentType.sprite || EditorSpriteLayoutFields.isVisible($0, in: payload) }.map { - EditorInspectorSidebarViewModel.ComponentField( - typeName: typeName, - field: $0, - value: $0.displayValue(in: payload) - ) + EditorInspectorSidebarViewModel.ComponentField( + typeName: typeName, + field: $0, + value: $0.displayValue(in: payload) + ) }, canRemove: typeName != EditorBuiltInComponentType.transform ) @@ -1554,10 +1690,12 @@ extension EditorSceneViewportModel { } func setSpatialTool(_ mode: SpatialManipulationMode) { - guard let world else { return } + guard let world else { + return + } world.insertResource(SpatialInteractionSettings(mode: mode)) if world.getResource(SpatialSelection.self)?.entityID == nil, let selectedEditorID, - let entity = runtimeEntity(for: selectedEditorID) { + let entity = runtimeEntity(for: selectedEditorID) { world.insertResource(SpatialSelection(entityID: entity.id)) } } @@ -1565,7 +1703,9 @@ extension EditorSceneViewportModel { func authoredEditorID(for runtimeID: Entity.ID) -> String? { var entity = world?.getEntityByID(runtimeID) while let current = entity { - if let id = editorIDsByEntityID[current.id] { return id } + if let id = editorIDsByEntityID[current.id] { + return id + } entity = current.parent } return nil @@ -1576,18 +1716,29 @@ extension EditorSceneViewportModel { } func selectSpatialEntity(_ editorID: String?) { - guard selectedEditorID != editorID else { return } + guard selectedEditorID != editorID else { + return + } endSpatialTransform() selectEntity(editorID) } func beginSpatialTransform(editorID: String) { - guard spatialEdit == nil, let entity = sceneModel?.entities.first(where: { $0.id == editorID }) else { return } - spatialEdit = SpatialEdit(editorID: editorID, startContent: sceneContent, startPayload: entity.components[EditorBuiltInComponentType.transform] ?? [:]) + guard spatialEdit == nil, let entity = sceneModel?.entities.first(where: { $0.id == editorID }) else { + return + } + let payload = entity.components[EditorBuiltInComponentType.transform] ?? [:] + if runtimeEntity(for: editorID)?.components[MeshTerrain3DBinding.self]?.enabled == true || !meshTerrainBoundDescendants(of: editorID).isEmpty { + guard let pose = try? EditorComponentPayloadDecoder.decode(Transform.self, payload: payload) as? Transform, + beginMeshTerrainDrag(editorID: editorID, payload: payload, pose: pose) else { return } + } + spatialEdit = SpatialEdit(editorID: editorID, startContent: sceneContent, startPayload: payload) } func updateSpatialTransform(_ transform: Transform, editorID: String) { - guard spatialEdit?.editorID == editorID else { return } + guard spatialEdit?.editorID == editorID else { + return + } var payload = sceneModel?.entities.first(where: { $0.id == editorID })?.components[EditorBuiltInComponentType.transform] ?? [:] payload["position"] = .array([transform.position.x, transform.position.y, transform.position.z].map { .double(Double($0)) }) payload["rotation"] = .array([transform.rotation.x, transform.rotation.y, transform.rotation.z, transform.rotation.w].map { .double(Double($0)) }) @@ -1596,19 +1747,38 @@ extension EditorSceneViewportModel { } func endSpatialTransform(cancelled: Bool = false) { - guard let edit = spatialEdit else { return } + guard let edit = spatialEdit else { + return + } spatialEdit = nil cancelTransformInspectorUpdate() + if let group = terrainEditing.meshGroupDrag, group.editorID == edit.editorID { + finishMeshTerrainGroupDrag(group, cancelled: cancelled) + return + } + if let session = terrainEditing.meshDragSession, session.sourceEditorID == edit.editorID { + let payload = sceneModel?.entities.first(where: { $0.id == edit.editorID })?.components[EditorBuiltInComponentType.transform] + let pose = try? payload.flatMap { try EditorComponentPayloadDecoder.decode(Transform.self, payload: $0) as? Transform } + finishMeshTerrainDrag(session, cancelled: cancelled || pose == session.initialPose) + return + } if cancelled { applyTransformPayload(edit.startPayload, editorID: edit.editorID) cancelTransformInspectorUpdate() sceneContent = edit.startContent } else if let model = sceneModel, - model.entities.first(where: { $0.id == edit.editorID })?.components[EditorBuiltInComponentType.transform] != edit.startPayload, - let content = try? model.encodedYAML() { + model.entities.first(where: { $0.id == edit.editorID })?.components[EditorBuiltInComponentType.transform] != edit.startPayload, + let content = try? model.encodedYAML() { sceneContent = content onDocumentContentChanged?(content) } if let model = sceneModel { onSelectionChanged?(selectedEntityViewModel(editorID: selectedEditorID, model: model)) } } + + func cancelActiveMeshTerrainTransform() -> Bool { + guard terrainEditing.meshDragSession != nil || terrainEditing.meshGroupDrag != nil else { return false } + if spatialEdit != nil { endSpatialTransform(cancelled: true); return true } + if transformDrag != nil { endTransformDrag(cancelled: true); return true } + return false + } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportSourceTerrain.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportSourceTerrain.swift new file mode 100644 index 000000000..6ae7ce831 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportSourceTerrain.swift @@ -0,0 +1,315 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +extension EditorTerrainEditingState { + struct SourceHit { + let editorID: String + let ownerID: Entity.ID + let payload: EditorComponentPayload + var surface: Terrain3DSourceSurface + let revision: UInt64 + let matrix: Transform3D + var point: Vector3 + } + struct SourceStroke { + var hit: SourceHit + let command: EditorTerrainStrokeCommand + var points: [TerrainBrushPoint] + var time: Double = 0 + } +} + +extension EditorSceneViewportModel { + func handleSourceTerrainPointer(at point: Point, began: Bool, ended: Bool, cancelled: Bool) -> Bool { + guard terrainEditing.stroke == nil, + terrainEditing.isSourceBusy || sceneModel?.entities.contains(where: { $0.components[EditorBuiltInComponentType.terrain3DSource] != nil }) == true else { + return false + } + if cancelled { + endSourceTerrainStroke(cancelled: true) + return true + } + if ended { + endSourceTerrainStroke() + return true + } + guard let context = terrainSourceContext else { + return false + } + if began { + guard !terrainEditing.isSourceBusy else { + return true + } + terrainEditing.sourceHoverTask?.cancel() + terrainEditing.sourceGeneration = UUID() + let generation = terrainEditing.sourceGeneration + terrainEditing.isSourceBusy = true + terrainEditing.sourceReleaseRequested = false + terrainEditing.sourceStatus = "Preparing terrain stroke…" + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { + return + } + defer { self.finishSourceTerrainTask(generation: generation) } + do { + let hit = try await context.application.withWorldAccess { self.preferredSourceTerrainHit(at: point) } + guard !Task.isCancelled, self.terrainEditing.sourceGeneration == generation else { + return + } + guard let hit else { + let released = self.terrainEditing.sourceReleaseRequested + self.terrainEditing.isSourceBusy = false + _ = self.handleLegacyTerrainPointer(at: point, began: true) + if released { self.endTerrainStroke() } + return + } + self.terrainEditing.sourceManifest = hit.surface.manifest + let command = self.makeTerrainSourceCommand(at: hit.point) + let lastPoint = self.terrainEditing.pointer.flatMap { self.sourceTerrainPoint(at: $0, hit: hit) } ?? hit.point + self.terrainEditing.sourceStroke = .init( + hit: hit, + command: command, + points: [.init(position: hit.point, time: 0), .init(position: lastPoint, time: 1 / 60)], + time: 1 / 60 + ) + _ = self.updateSourceTerrainStroke(deltaTime: 1 / 60) + if command.changesResource { + while !self.terrainEditing.sourceReleaseRequested { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(16)) + } + } + try await self.runTerrainSourceCommand(command, hit: hit, generation: generation, live: !command.changesResource) + } catch is CancellationError { + } catch { + if self.terrainEditing.sourceGeneration == generation { + self.terrainEditing.sourceStatus = "Terrain stroke could not be saved. Try again." + } + } + } + } else if !terrainEditing.isSourceBusy { + terrainEditing.sourceHoverTask?.cancel() + terrainEditing.sourceHoverTask = Task { @MainActor [weak self] in + guard let self else { + return + } + let hit = try? await context.application.withWorldAccess { self.preferredSourceTerrainHit(at: point) } + guard !Task.isCancelled, !self.terrainEditing.isSourceBusy else { + return + } + self.terrainEditing.hover = hit.map { .init(editorID: $0.editorID, point: $0.point, matrix: $0.matrix, storage: $0.surface.storage, queries: $0.surface.queries) } + ?? self.terrainHit(at: point, lockedTo: nil) + self.terrainEditing.brushRevision &+= 1 + } + } + return true + } + + func updateSourceTerrainStroke(deltaTime: Float) -> Bool { + guard !terrainEditing.sourceReleaseRequested, var stroke = terrainEditing.sourceStroke, + let pointer = terrainEditing.pointer, deltaTime.isFinite, deltaTime > 0, stroke.points.count < 4_096, + let point = sourceTerrainPoint(at: pointer, hit: stroke.hit) else { + return false + } + stroke.time += Double(min(deltaTime, 0.05)) + stroke.points.append(.init(position: point, time: stroke.time)) + terrainEditing.sourceStroke = stroke + terrainEditing.hover = .init( + editorID: stroke.hit.editorID, + point: point, + matrix: stroke.hit.matrix, + storage: stroke.hit.surface.storage, + queries: stroke.hit.surface.queries + ) + terrainEditing.brushRevision &+= 1 + return true + } + + func endSourceTerrainStroke(cancelled: Bool = false) { + guard cancelled else { + terrainEditing.sourceReleaseRequested = true + return + } + terrainEditing.meshHistoryQueue.cancel() + terrainEditing.sourceGeneration = UUID() + let generation = terrainEditing.sourceGeneration + terrainEditing.sourceTask?.cancel() + terrainEditing.sourceHoverTask?.cancel() + terrainEditing.sourceStroke = nil + terrainEditing.sourceReleaseRequested = true + if let task = terrainEditing.sourceTask { + terrainEditing.sourceTask = Task { @MainActor [weak self] in + await task.value + self?.finishSourceTerrainTask(generation: generation) + } + } else { + finishSourceTerrainTask(generation: generation) + } + } + + func runTerrainSourceCommand(_ command: EditorTerrainStrokeCommand, hit: EditorTerrainEditingState.SourceHit, generation: UUID, live: Bool = false) async throws { + guard let context = terrainSourceContext else { throw EditorTerrainSourceAuthoring.Failure.unavailable } + let owner = try await context.application.withWorldAccess { + guard let owner = context.application.main.getEntityByID(hit.ownerID) else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + return owner + } + let brush = TerrainHeightBrushStroke(settings: .init(operation: .raise), points: [.init(position: hit.point, time: 0), .init(position: hit.point, time: 1 / 60)]) + _ = try await EditorTerrainSourceAuthoring.applyHeightStroke( + to: owner, + application: context.application, + expectedStorage: hit.surface.storage, + expectedRevision: hit.revision, + brush: brush, + command: command, + assetsRoot: context.assetsRoot, + isCurrent: { [weak self] in self?.isCurrentSourceStroke(hit, generation: generation) == true }, + onProgress: { [weak self] phase in + guard let self, self.terrainEditing.sourceGeneration == generation else { + return + } + switch phase { + case .preparing: self.terrainEditing.sourceStatus = "Preparing terrain stroke…" + case .applying: self.terrainEditing.sourceStatus = "Drawing terrain…" + case .saving, .writing: self.terrainEditing.sourceStatus = "Saving terrain stroke…" + } + }, + commit: { [weak self] fork in + guard let self, self.isCurrentSourceStroke(hit, generation: generation), + let index = self.sceneModel?.entities.firstIndex(where: { $0.id == hit.editorID }) else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + let root = context.assetsRoot.resolvingSymlinksInPath().standardizedFileURL + let path = fork.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root.path + "/") else { throw EditorTerrainSourceAuthoring.Failure.sourceChanged } + self.sceneModel?.entities[index].components[EditorBuiltInComponentType.terrain3DSource]?["source"] = .string("@res://" + path.dropFirst(root.path.count + 1)) + self.publishTerrainDocument() + self.refreshTerrainSourceControls() + if let model = self.sceneModel { self.onSelectionChanged?(self.selectedEntityViewModel(editorID: self.selectedEditorID, model: model)) } + }, + nextCommand: live ? { [weak self] in + guard let self, var stroke = self.terrainEditing.sourceStroke else { + return (nil, true) + } + guard stroke.points.count >= 2 else { + return (nil, self.terrainEditing.sourceReleaseRequested) + } + let points = Array(stroke.points.prefix(256)) + stroke.points.removeFirst(points.count - 1) + self.terrainEditing.sourceStroke = stroke + return (self.terrainCommand(stroke.command, with: points), false) + } : nil, + onPublished: { [weak self] surface in + self?.terrainEditing.sourceStroke?.hit.surface = surface + self?.terrainEditing.brushRevision &+= 1 + } + ) + } + + private func terrainCommand(_ command: EditorTerrainStrokeCommand, with points: [TerrainBrushPoint]) -> EditorTerrainStrokeCommand { + switch command { + case let .height(value): .height(.init(settings: value.settings, points: points)) + case let .surface(value): .surface(.init(settings: value.settings, points: points)) + case let .instances(value): .instances(.init(settings: value.settings, points: points)) + default: command + } + } + + private func isCurrentSourceStroke(_ hit: EditorTerrainEditingState.SourceHit, generation: UUID) -> Bool { + terrainEditing.sourceGeneration == generation && terrainEditing.usesBrush && displayMode == .threeD + && entitiesByEditorID[hit.editorID] == hit.ownerID + && sceneModel?.entities.first(where: { $0.id == hit.editorID })?.components[EditorBuiltInComponentType.terrain3DSource] == hit.payload + } + + func finishSourceTerrainTask(generation: UUID) { + guard terrainEditing.sourceGeneration == generation else { + return + } + terrainEditing.sourceTask = nil + terrainEditing.sourceStroke = nil + terrainEditing.meshSculptPointerActive = false + terrainEditing.meshBrushPoints.removeAll(keepingCapacity: true) + terrainEditing.meshBrushLastPoint = nil + terrainEditing.meshSculptStroke = nil + terrainEditing.isSourceBusy = false + if terrainEditing.sourceFailure == nil, terrainEditing.sourceStatus != "Terrain stroke could not be saved. Try again." { terrainEditing.sourceStatus = nil } + } + + private func preferredSourceTerrainHit(at point: Point) -> EditorTerrainEditingState.SourceHit? { + guard let source = sourceTerrainHit(at: point) else { + return nil + } + guard let legacy = terrainHit(at: point, lockedTo: nil) else { + return source + } + let camera = cameraState(for: viewportSize) + let sourceWorld = (source.matrix * Vector4(source.point, 1)).xyz + let legacyWorld = (legacy.matrix * Vector4(legacy.point, 1)).xyz + return (legacyWorld - camera.transform.position).squaredLength < (sourceWorld - camera.transform.position).squaredLength ? nil : source + } + + func terrainSourceEntity(for editorID: String?) -> EditorSceneEntity? { + guard let editorID else { return nil } + return sceneModel?.entities.first { $0.id == editorID && $0.components[EditorBuiltInComponentType.terrain3DSource] != nil } + } + + func isMeshTerrainSource(for editorID: String?) -> Bool { + guard let entity = terrainSourceEntity(for: editorID) else { return false } + if let owner = runtimeEntity(for: entity.id) { + if owner.components[MeshTerrain3DSourceSurface.self] != nil { return true } + if owner.components[Terrain3DSourceSurface.self] != nil { return false } + } + return entity.components[String(reflecting: MeshTerrain3DIdentity.self)] != nil + } + + func selectedTerrainSourceHit(for editorID: String?) -> EditorTerrainEditingState.SourceHit? { + guard let model = sceneModel, let world else { + return nil + } + // An explicit terrain selection owns the command even while loading or + // when its backend cannot use the heightfield tool. + let entities = terrainSourceEntity(for: editorID).map { [$0] } ?? model.entities + for entity in entities where entity.enabled { + guard let payload = entity.components[EditorBuiltInComponentType.terrain3DSource], let id = entitiesByEditorID[entity.id], + let owner = world.getEntityByID(id), let surface = owner.components[Terrain3DSourceSurface.self], + let matrix = EditorPicking.worldMatrix(for: owner, requiringVisible: true) else { continue } + return .init(editorID: entity.id, ownerID: id, payload: payload, surface: surface, revision: surface.storage.revision, matrix: matrix, point: .zero) + } + return nil + } + + private func sourceTerrainHit(at point: Point) -> EditorTerrainEditingState.SourceHit? { + guard let model = sceneModel, let world, !isPerspectiveTransitionActive else { + return nil + } + let camera = cameraState(for: viewportSize) + guard let ray = EditorPicking.ray(point: point, viewportSize: viewportSize, cameraTransform: camera.transform.matrix, projection: camera.projection.makeClipView()) else { + return nil + } + var best: EditorTerrainEditingState.SourceHit? + var distance = Double.infinity + for entity in model.entities where entity.enabled { + guard let payload = entity.components[EditorBuiltInComponentType.terrain3DSource], + let id = entitiesByEditorID[entity.id], let owner = world.getEntityByID(id), + let surface = owner.components[Terrain3DSourceSurface.self], let queries = surface.queries, + let matrix = EditorPicking.worldMatrix(for: owner, requiringVisible: true), let local = EditorPicking.localRay(ray, matrix: matrix), + let hit = try? queries.raycast(local), + surface.storage.height(at: Vector2(hit.position.x, hit.position.z)) != nil else { continue } + let direction = (matrix.inverse * Vector4(ray.direction, 0)).xyz + let length = (Double(direction.x) * Double(direction.x) + Double(direction.y) * Double(direction.y) + Double(direction.z) * Double(direction.z)).squareRoot() + let worldDistance = hit.preciseDistance / length + guard length.isFinite, length > 0, worldDistance < distance else { continue } + distance = worldDistance + best = .init(editorID: entity.id, ownerID: id, payload: payload, surface: surface, revision: surface.storage.revision, matrix: matrix, point: hit.position) + } + return best + } + + private func sourceTerrainPoint(at point: Point, hit: EditorTerrainEditingState.SourceHit) -> Vector3? { + let camera = cameraState(for: viewportSize) + guard let ray = EditorPicking.ray(point: point, viewportSize: viewportSize, cameraTransform: camera.transform.matrix, projection: camera.projection.makeClipView()), + let local = EditorPicking.localRay(ray, matrix: hit.matrix), let queries = hit.surface.queries, + let next = try? queries.raycast(local) else { + return nil + } + return next.position + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrain.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrain.swift index 0ef7e370c..d92693f5c 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrain.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrain.swift @@ -3,10 +3,67 @@ import Foundation import Math import Observation +enum EditorTerrainCreationBackend: String, CaseIterable { + case heightfield = "Heightfield" + case mesh = "Mesh Terrain" +} + +struct EditorMeshTerrainTarget: Equatable, Identifiable { + let id: String + let name: String +} + @MainActor @Observable final class EditorTerrainEditingState { + var creationBackend: EditorTerrainCreationBackend = .mesh + var isSourceBusy = false + var sourceStatus: String? + var sourceFailure: String? + var meshTargetEditorID: String? + var meshTargets: [EditorMeshTerrainTarget] = [] + var meshSourceName: String? + var meshHasBinding = false + @ObservationIgnored var meshControlsTask: Task? + @ObservationIgnored var meshControlsGeneration = UUID() + @ObservationIgnored var meshControlsBinding: MeshTerrain3DBinding? + @ObservationIgnored var meshSourceEditorID: String? + @ObservationIgnored var meshPoseQueue = EditorMeshTerrainPoseQueue() + @ObservationIgnored var meshDragSession: EditorMeshTerrainDragSession? + @ObservationIgnored var meshGroupDrag: EditorMeshTerrainGroupDrag? + @ObservationIgnored var meshHistoryQueue = EditorMeshTerrainHistoryQueue() + @ObservationIgnored var meshSculptPointerActive = false + @ObservationIgnored var meshBrushPoints: [Point] = [] + @ObservationIgnored var meshBrushLastPoint: Point? + @ObservationIgnored var meshSculptStroke: EditorMeshTerrainSculptStroke? + var meshSelectedParts: [MeshTerrainPartSelection] = [] + var meshOperation = "Join" + var meshContactWidth: Float = 1 + var meshConformDistance: Float = 2 + var meshConformStrength: Float = 1 + @ObservationIgnored var brushRevision: UInt64 = 0 + var sourceTool: EditorTerrainTool = .sculpt + var dockTab = "Sculpt" + var sourceAssetFolder: URL? + var sourcePresetName = "" + var sourceManifest: TerrainManifestAsset? + var sourceLayerID: UInt16 = 0 + var sourcePrototypeID: UInt16 = 0 + var meshReference = "" + var textureReference = "" + var volumeOperand = "Sphere" + var meshDensity: Double = 0.15 + var usesTextureVariation = true + var noise = EditorTerrainNoise() + @ObservationIgnored var sourceActions: EditorTerrainSourceActions? + @ObservationIgnored var sourceStroke: SourceStroke? + @ObservationIgnored var sourceTask: Task? + @ObservationIgnored var sourceHoverTask: Task? + @ObservationIgnored var sourceGeneration = UUID() + @ObservationIgnored var sourceReleaseRequested = false var isEnabled = false + /// Object joining uses the ordinary selection/transform interaction inside the terrain dock. + var usesBrush: Bool { isEnabled && dockTab != "Joining" } var mode: EditorTerrainBrushMode = .raise var radius: Float = 4 var strength: Float = 3 @@ -18,6 +75,8 @@ final class EditorTerrainEditingState { var editorID: String var point: Vector3 var matrix: Transform3D + var storage: TerrainHeightStorage? + var queries: TerrainSurfaceQueries? } struct Stroke { @@ -34,15 +93,24 @@ extension EditorSceneViewportModel { endTransformDrag(cancelled: true) terrainEditing.isEnabled = enabled terrainEditing.hover = nil - if enabled { setDisplayMode(.threeD) } + if enabled { + setDisplayMode(.threeD) + refreshTerrainSourceControls() + } else { + refreshMeshTerrainControls() + } } func createTerrain() { endTerrainStroke() - guard var model = sceneModel else { return } + guard var model = sceneModel else { + return + } let id = model.addEntity(name: L10n.text("Terrain"), parentID: nil).id model.addComponent(typeName: EditorBuiltInComponentType.visibility, to: id) - guard let index = model.entities.firstIndex(where: { $0.id == id }) else { return } + guard let index = model.entities.firstIndex(where: { $0.id == id }) else { + return + } model.entities[index].components[EditorTerrainPayload.typeName] = EditorTerrainPayload.encode(Terrain3D()) model.editor?.selectedEntity = id sceneModel = model @@ -53,11 +121,29 @@ extension EditorSceneViewportModel { } func handleTerrainPointer(at position: Point, began: Bool = false, ended: Bool = false, cancelled: Bool = false) -> Bool { - guard terrainEditing.isEnabled, displayMode == .threeD else { return false } + guard terrainEditing.usesBrush, displayMode == .threeD else { + return false + } terrainEditing.pointer = position + if terrainEditing.meshSculptPointerActive || (terrainEditing.stroke == nil && terrainEditing.sourceStroke == nil && isMeshTerrainSource(for: selectedEditorID)) { + return handleMeshTerrainSculptPointer(at: position, began: began, ended: ended, cancelled: cancelled) + } + if handleSourceTerrainPointer(at: position, began: began, ended: ended, cancelled: cancelled) { + return true + } + return handleLegacyTerrainPointer(at: position, began: began, ended: ended, cancelled: cancelled) + } + + func handleLegacyTerrainPointer(at position: Point, began: Bool = false, ended: Bool = false, cancelled: Bool = false) -> Bool { terrainEditing.hover = terrainHit(at: position, lockedTo: terrainEditing.stroke?.editorID) - if cancelled { endTerrainStroke(cancelled: true); return true } - if ended { endTerrainStroke(); return true } + if cancelled { + endTerrainStroke(cancelled: true) + return true + } + if ended { + endTerrainStroke() + return true + } if began, let hit = terrainEditing.hover, let payload = sceneModel?.entities.first(where: { $0.id == hit.editorID })?.components[EditorTerrainPayload.typeName], let terrain = EditorTerrainPayload.decode(payload) { @@ -70,19 +156,34 @@ extension EditorSceneViewportModel { } func updateTerrainStroke(deltaTime: Float) -> Bool { + if terrainEditing.meshSculptStroke != nil { return updateMeshTerrainSculpt(deltaTime: deltaTime) } + if terrainEditing.sourceStroke != nil { + return updateSourceTerrainStroke(deltaTime: deltaTime) + } guard var stroke = terrainEditing.stroke, let pointer = terrainEditing.pointer, let hit = terrainHit(at: pointer, lockedTo: stroke.editorID), let payload = sceneModel?.entities.first(where: { $0.id == stroke.editorID })?.components[EditorTerrainPayload.typeName], - var terrain = EditorTerrainPayload.decode(payload) else { return false } + var terrain = EditorTerrainPayload.decode(payload) else { + return false + } terrainEditing.hover = hit // Resample fast pointer movement so a brush stroke stays continuous. let distance = (hit.point - stroke.previousPoint).length - guard distance.isFinite else { return false } + guard distance.isFinite else { + return false + } let samples = min(128, max(1, Int(ceil(distance / max(0.05, terrainEditing.radius * 0.25))))) for sample in 1...samples { let point = stroke.previousPoint + (hit.point - stroke.previousPoint) * (Float(sample) / Float(samples)) - EditorTerrainBrush.apply(to: &terrain, at: point, mode: terrainEditing.mode, radius: terrainEditing.radius, - strength: terrainEditing.strength, duration: min(deltaTime, 0.05) / Float(samples), flattenHeight: stroke.flattenHeight) + EditorTerrainBrush.apply( + to: &terrain, + at: point, + mode: terrainEditing.mode, + radius: terrainEditing.radius, + strength: terrainEditing.strength, + duration: min(deltaTime, 0.05) / Float(samples), + flattenHeight: stroke.flattenHeight + ) } stroke.previousPoint = hit.point terrainEditing.stroke = stroke @@ -91,7 +192,10 @@ extension EditorSceneViewportModel { } func endTerrainStroke(cancelled: Bool = false) { - guard let stroke = terrainEditing.stroke else { return } + endSourceTerrainStroke(cancelled: cancelled) + guard let stroke = terrainEditing.stroke else { + return + } terrainEditing.stroke = nil if cancelled { applyTerrain(stroke.original, editorID: stroke.editorID) @@ -103,24 +207,36 @@ extension EditorSceneViewportModel { } private func applyTerrain(_ terrain: Terrain3D, editorID: String) { - guard let index = sceneModel?.entities.firstIndex(where: { $0.id == editorID }) else { return } + guard let index = sceneModel?.entities.firstIndex(where: { $0.id == editorID }) else { + return + } sceneModel?.entities[index].components[EditorTerrainPayload.typeName] = EditorTerrainPayload.encode(terrain) if let runtimeID = entitiesByEditorID[editorID], let entity = world?.getEntityByID(runtimeID) { entity.components[Terrain3D.self] = terrain } } - private func publishTerrainDocument() { - guard let model = sceneModel, let content = try? model.encodedYAML() else { return } + func publishTerrainDocument() { + guard let model = sceneModel, let content = try? model.encodedYAML() else { + return + } sceneContent = content onDocumentContentChanged?(content) } - private func terrainHit(at point: Point, lockedTo editorID: String?) -> EditorTerrainEditingState.Hit? { - guard !isPerspectiveTransitionActive, let model = sceneModel else { return nil } + func terrainHit(at point: Point, lockedTo editorID: String?) -> EditorTerrainEditingState.Hit? { + guard !isPerspectiveTransitionActive, let model = sceneModel else { + return nil + } let camera = cameraState(for: viewportSize) - guard let ray = EditorPicking.ray(point: point, viewportSize: viewportSize, - cameraTransform: camera.transform.matrix, projection: camera.projection.makeClipView()) else { return nil } + guard let ray = EditorPicking.ray( + point: point, + viewportSize: viewportSize, + cameraTransform: camera.transform.matrix, + projection: camera.projection.makeClipView() + ) else { + return nil + } var nearest = Float.infinity var result: EditorTerrainEditingState.Hit? for entity in model.entities where editorID == nil || entity.id == editorID { @@ -135,20 +251,30 @@ extension EditorSceneViewportModel { } func drawTerrainBrush(in context: inout UIGraphicsContext, size: Size, theme: Theme) { - guard terrainEditing.isEnabled, displayMode == .threeD, let hit = terrainEditing.hover, - let payload = sceneModel?.entities.first(where: { $0.id == hit.editorID })?.components[EditorTerrainPayload.typeName], - let terrain = EditorTerrainPayload.decode(payload) else { return } + guard terrainEditing.usesBrush, displayMode == .threeD, let hit = terrainEditing.hover else { + return + } + let terrain = sceneModel?.entities.first(where: { $0.id == hit.editorID })?.components[EditorTerrainPayload.typeName].flatMap(EditorTerrainPayload.decode) + guard terrain != nil || hit.storage != nil || hit.queries != nil else { + return + } + let normal = (try? hit.queries?.closest(to: hit.point, maximumWork: 32_768))?.normal ?? Vector3(0, 1, 0) + let tangent = normal.cross(abs(normal.y) < 0.9 ? Vector3(0, 1, 0) : Vector3(1, 0, 0)).normalized + let bitangent = normal.cross(tangent).normalized for segment in 0..<48 { func point(_ index: Int) -> Vector3 { let angle = Float(index) / 48 * Float.pi * 2 let offsetX = Math.cos(angle) * terrainEditing.radius let offsetZ = Math.sin(angle) * terrainEditing.radius - var local = hit.point + Vector3(offsetX, 0, offsetZ) - local.y = (terrain.surfaceHeight(at: Vector2(local.x, local.z)) ?? hit.point.y) + 0.04 + var local = hit.point + tangent * offsetX + bitangent * offsetZ + if let sample = try? hit.queries?.closest(to: local, maximumDistance: max(1, terrainEditing.radius * 2), maximumWork: 32_768) { + local = sample.position + sample.normal * 0.04 + } else { + local.y = (hit.storage?.height(at: Vector2(local.x, local.z)) ?? terrain?.surfaceHeight(at: Vector2(local.x, local.z)) ?? hit.point.y) + 0.04 + } return (hit.matrix * Vector4(local, 1)).xyz } - guard let start = project(point(segment), size: size), let end = project(point(segment + 1), size: size) else { continue } - drawViewportLine(from: start, to: end, lineWidth: 2, color: theme.editorColors.blue, in: &context) + drawProjectedSegment(from: point(segment), to: point(segment + 1), in: &context, size: size, lineWidth: 3, color: theme.editorColors.blue) } } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift new file mode 100644 index 000000000..00534b9c1 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift @@ -0,0 +1,291 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +extension EditorSceneViewportModel { + func refreshTerrainSourceControls() { + terrainEditing.sourceActions = .init( + create: { [weak self] in self?.createTerrainSource() }, + materials: { [weak self] in self?.configureTerrainMaterials() }, + variation: { [weak self] in self?.configureTerrainMaterials(addRules: false) }, + mesh: { [weak self] in self?.registerTerrainMesh() }, + texture: { [weak self] in self?.registerTerrainTexture() }, + extend: { [weak self] in + guard let self else { + return + } + let x = (self.terrainEditing.sourceManifest?.heightRegions.map { Int($0.x) }.max() ?? 0) + self.performTerrainResourceCommand(.region(.init(x: x, z: 0))) + }, + generate: { [weak self] in + guard let self, !self.terrainEditing.isSourceBusy, !self.rejectHeightfieldTool() else { + return + } + if self.terrainEditing.sourceManifest == nil, self.terrainSourceEntity(for: self.selectedEditorID) == nil { + self.createTerrainSource() + } else { + self.performTerrainResourceCommand(.generate(self.terrainEditing.noise)) + } + }, + join: { [weak self] in self?.joinSelectedMeshTerrain() }, + detach: { [weak self] in self?.detachSelectedMeshTerrain() }, + cancel: { [weak self] in + guard let self else { return } + if !self.cancelActiveMeshTerrainTransform() { self.endTerrainStroke(cancelled: true) } + } + ) + refreshMeshTerrainControls() + guard let context = terrainSourceContext else { + return + } + let selection = selectedEditorID + Task { @MainActor [weak self] in + guard let self else { + return + } + let metadata = try? await context.application.withWorldAccess { () -> (TerrainManifestAsset?, String?) in + if let entity = self.terrainSourceEntity(for: selection), let owner = self.runtimeEntity(for: entity.id), + let resources = owner.components[MeshTerrain3DSourceResources.self] { + return (resources.manifest, owner.components[Terrain3DSource.self]?.source) + } + let hit = self.selectedTerrainSourceHit(for: selection) + let source = hit.flatMap { try? EditorTerrainSourcePayload.decode($0.payload).source } + return (hit?.surface.manifest, source) + } + guard self.selectedEditorID == selection, self.terrainSourceContext?.application === context.application, + self.terrainSourceContext?.assetsRoot == context.assetsRoot else { return } + self.terrainEditing.sourceManifest = metadata?.0 + self.terrainEditing.sourceAssetFolder = metadata?.1.flatMap { + try? EditorTerrainResource.resolve($0, sourceURL: context.assetsRoot.appendingPathComponent("scene.ascn"), resourceRootURL: context.assetsRoot) + .deletingLastPathComponent() + } + if let palette = metadata?.0?.materials { + if !palette.layers.contains(where: { $0.id == self.terrainEditing.sourceLayerID }) { + self.terrainEditing.sourceLayerID = palette.fallbackLayerID + } + self.terrainEditing.usesTextureVariation = palette.layers.allSatisfy { $0.variation.isEnabled } + } + if let first = metadata?.0?.meshPrototypes.first { self.terrainEditing.sourcePrototypeID = first.id } + } + } + + func makeTerrainSourceCommand(at point: Vector3) -> EditorTerrainStrokeCommand { + let points: [TerrainBrushPoint] = [.init(position: point, time: 0), .init(position: point, time: 1 / 60)] + switch terrainEditing.sourceTool { + case .sculpt: + let preset = EditorTerrainBrushPreset.all.first { $0.name == terrainEditing.sourcePresetName } + let operation: TerrainHeightBrushOperation + switch terrainEditing.mode { + case .raise: operation = .raise + case .lower: operation = .lower + case .smooth: operation = .smooth + case .flatten: operation = .flatten + } + var settings = TerrainHeightBrushSettings(operation: preset?.operation ?? operation, radius: terrainEditing.radius, strength: terrainEditing.strength) + settings.shape = preset?.shape ?? .softRound + settings.targetHeight = point.y + settings.terraceStep = preset?.step ?? 1 + settings.seed = terrainEditing.noise.seed + settings.noiseAmplitude = terrainEditing.strength + return .height(.init(settings: settings, points: points)) + case .paint, .erasePaint: + var settings = TerrainSurfaceBrushSettings( + operation: terrainEditing.sourceTool == .paint ? .paintLayer : .eraseLayer, + radius: terrainEditing.radius, + strength: terrainEditing.strength + ) + settings.layerID = terrainEditing.sourceLayerID + return .surface(.init(settings: settings, points: points)) + case .scatter, .eraseMeshes: + var settings = TerrainInstanceBrushSettings( + prototypeID: terrainEditing.sourcePrototypeID, + operation: terrainEditing.sourceTool == .scatter ? .paint : .erase, + radius: terrainEditing.radius, + density: Float(terrainEditing.meshDensity) + ) + settings.seed = terrainEditing.noise.seed + settings.minimumDistance = 0.8 + return .instances(.init(settings: settings, points: points)) + case .add, .subtract: + let operand: TerrainVolumeOperand + switch terrainEditing.volumeOperand { + case "Box": operand = .primitive(.box(halfExtents: .one)) + case "Capsule": operand = .primitive(.capsule(radius: 1, halfHeight: 2)) + case "Palette mesh": operand = terrainEditing.sourceManifest?.meshPrototypes.first(where: { $0.id == terrainEditing.sourcePrototypeID }) + .map { .mesh(assetPath: $0.assetPath, scene: $0.scene) } ?? .primitive(.sphere(radius: 1)) + default: operand = .primitive(.sphere(radius: 1)) + } + return .volume(.init( + operation: terrainEditing.sourceTool == .add ? .union : .subtract, + operand: operand, + transform: .init(translation: point, rotation: .identity, scale: Vector3(terrainEditing.radius)) + )) + } + } + + private func configureTerrainMaterials(addRules: Bool = true) { + let variation = TerrainTextureVariation(isEnabled: terrainEditing.usesTextureVariation, seed: terrainEditing.noise.seed) + let palette = EditorTerrainAutoPalette.make(existing: terrainEditing.sourceManifest?.materials, variation: variation, addRules: addRules) + performTerrainResourceCommand(.materials(palette)) + } + + private func registerTerrainMesh() { + guard !terrainEditing.isSourceBusy else { return } + if let file = terrainProjectFile(terrainEditing.meshReference) { + performTerrainResourceCommand(.mesh(file)) + return + } + let selection = selectedEditorID, context = terrainSourceContext + Task { @MainActor [weak self] in + guard let file = await ProjectOpenPicker.pickAssetImportURLs()?.first, let self, + self.selectedEditorID == selection, self.terrainSourceContext?.application === context?.application, + self.terrainSourceContext?.assetsRoot == context?.assetsRoot else { + return + } + self.performTerrainResourceCommand(.mesh(file)) + } + } + + private func registerTerrainTexture() { + guard !terrainEditing.isSourceBusy else { return } + let used = Set(terrainEditing.sourceManifest?.materials?.layers.map(\.id) ?? []) + guard let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else { + return + } + terrainEditing.sourceLayerID = id + if let file = terrainProjectFile(terrainEditing.textureReference) { + performTerrainResourceCommand(.texture(file, layerID: id)) + return + } + let selection = selectedEditorID, context = terrainSourceContext + Task { @MainActor [weak self] in + guard let file = await ProjectOpenPicker.pickAssetImportURLs()?.first, let self, + self.selectedEditorID == selection, self.terrainSourceContext?.application === context?.application, + self.terrainSourceContext?.assetsRoot == context?.assetsRoot else { + return + } + self.performTerrainResourceCommand(.texture(file, layerID: id)) + } + } + + private func terrainProjectFile(_ reference: String) -> URL? { + guard let context = terrainSourceContext, !reference.isEmpty else { + return nil + } + let relative = reference.hasPrefix("@res://") ? String(reference.dropFirst(7)) : reference + let root = context.assetsRoot.resolvingSymlinksInPath().standardizedFileURL + let file = root.appendingPathComponent(relative).resolvingSymlinksInPath().standardizedFileURL + guard file.path.hasPrefix(root.path + "/"), FileManager.default.fileExists(atPath: file.path) else { + return nil + } + return file + } + + private func rejectHeightfieldTool() -> Bool { + guard isMeshTerrainSource(for: selectedEditorID) else { return false } + terrainEditing.sourceFailure = nil + terrainEditing.sourceStatus = "Choose a Heightfield for this tool." + return true + } + + func performTerrainResourceCommand(_ command: EditorTerrainStrokeCommand) { + guard !terrainEditing.isSourceBusy, let context = terrainSourceContext else { + return + } + if isMeshTerrainSource(for: selectedEditorID) { + switch command { + case .materials, .mesh, .texture: performMeshTerrainResources(command) + default: _ = rejectHeightfieldTool() + } + return + } + let selection = selectedEditorID + terrainEditing.sourceGeneration = UUID() + let generation = terrainEditing.sourceGeneration + terrainEditing.isSourceBusy = true + terrainEditing.sourceFailure = nil + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { + return + } + defer { self.finishSourceTerrainTask(generation: generation) } + do { + guard let hit = try await context.application.withWorldAccess({ self.selectedTerrainSourceHit(for: selection) }) else { + throw EditorTerrainSourceAuthoring.Failure.unavailable + } + try await self.runTerrainSourceCommand(command, hit: hit, generation: generation) + } catch { + if self.terrainEditing.sourceGeneration == generation { self.terrainEditing.sourceStatus = "Terrain stroke could not be saved. Try again." } + } + } + } + + private func createTerrainSource() { + guard !terrainEditing.isSourceBusy, let context = terrainSourceContext, let baseline = sceneModel else { + return + } + let settings = terrainEditing.noise + let backend = terrainEditing.creationBackend + terrainEditing.sourceGeneration = UUID() + let generation = terrainEditing.sourceGeneration + terrainEditing.isSourceBusy = true + terrainEditing.sourceTask = Task { @MainActor [weak self] in + guard let self else { + return + } + defer { self.finishSourceTerrainTask(generation: generation) } + var meshReceipt: EditorMeshTerrainCreation.Receipt? + do { + let file = context.assetsRoot.appendingPathComponent("terrain-\(UUID().uuidString.lowercased()).aterrain") + if backend == .mesh { meshReceipt = try await EditorMeshTerrainCreation.create(at: file, noise: settings) } + else { try await Self.writeNewTerrain(at: file, noise: settings) } + guard !Task.isCancelled, self.terrainEditing.sourceGeneration == generation, self.sceneModel == baseline else { + if let meshReceipt { await EditorMeshTerrainCreation.discard(meshReceipt) } + return + } + var model = baseline + let parent = model.entities.first(where: { $0.name == "Environment" })?.id ?? model.addEntity(name: "Environment", parentID: nil).id + let id = model.addEntity(name: L10n.tr(backend == .mesh ? "Mesh Terrain" : "Terrain"), parentID: parent).id + model.addComponent(typeName: EditorBuiltInComponentType.visibility, to: id) + guard let index = model.entities.firstIndex(where: { $0.id == id }) else { + if let meshReceipt { await EditorMeshTerrainCreation.discard(meshReceipt) } + return + } + model.entities[index].components[EditorBuiltInComponentType.terrain3DSource] = EditorTerrainSourcePayload.encode( + Terrain3DSource(source: "@res://" + file.lastPathComponent, streamingRadius: nil, queries: .init()) + ) + if backend == .mesh { + let identity = MeshTerrain3DIdentity() + model.entities[index].components[String(reflecting: MeshTerrain3DIdentity.self)] = ["id": .string(identity.id.uuidString)] + } + model.editor?.selectedEntity = id + self.sceneModel = model + self.selectedEditorID = id + self.publishTerrainDocument() + meshReceipt = nil + _ = self.reloadSceneRuntimeIfReady() + self.refreshTerrainSourceControls() + } catch { + if let meshReceipt { await EditorMeshTerrainCreation.discard(meshReceipt) } + if !(error is CancellationError), self.terrainEditing.sourceGeneration == generation { + self.terrainEditing.sourceStatus = "Terrain stroke could not be saved. Try again." + } + } + } + } + + @concurrent + private static func writeNewTerrain(at file: URL, noise: EditorTerrainNoise) async throws { + var terrain = Terrain3D(resolution: 129, size: 128) + for z in 0..<129 { + try Task.checkCancellation() + for x in 0..<129 { terrain.heights[z * 129 + x] = noise.height(x: Float(x) - 64, z: Float(z) - 64) } + } + let storage = try TerrainHeightStorage(importing: terrain) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -64, originZ: -64), at: file.path) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift index 9a3795da5..e87d2c483 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift @@ -13,7 +13,7 @@ struct EditorSceneViewportView: View { let onPlay: (() -> Void)? let onStop: (() -> Void)? let onDocumentChanged: (EditorSceneDocument) -> Void - var onTerrainToolSelected: (() -> Void)? = nil + var onTerrainToolSelected: (() -> Void)? var initialDisplayMode: EditorSceneViewportDisplayMode = .twoD var isAssetPreview = false var onEditWorldReady: ((World) -> Void)? @@ -77,15 +77,24 @@ struct EditorSceneViewportView: View { private func configureReadyViewport() { viewportModel.setPresentationActive(isDocumentActive) - guard isDocumentActive else { return } - guard !document.isLoading, document.errorMessage == nil else { return } - if isPlayingThisDocument { preparePlayModeViewport() } - else { configureViewportModel() } + guard isDocumentActive else { + return + } + guard !document.isLoading, document.errorMessage == nil else { + return + } + if isPlayingThisDocument { + preparePlayModeViewport() + } else { + configureViewportModel() + } } private var hasImportedModels: Bool { document.sceneModel?.entities.contains { - $0.components[EditorBuiltInComponentType.model3DSource] != nil || $0.components[EditorTerrainPayload.typeName] != nil + $0.components[EditorBuiltInComponentType.model3DSource] != nil + || $0.components[EditorTerrainPayload.typeName] != nil + || $0.components[EditorBuiltInComponentType.terrain3DSource] != nil } == true } @@ -103,14 +112,21 @@ struct EditorSceneViewportView: View { viewportModel.threeDPitch = 0 viewportModel.perspectiveBlend = 1 } - guard configureSceneViewApp(&app) else { return } + guard configureSceneViewApp(&app) else { + return + } let world = app.main - viewportModel.loadSceneWorld(world, application: app, onLoadingChanged: { isLoadingScene = $0 }, - onLoadingProgress: { loadingProgress = $0 }, onLoaded: { result in - if runtimeWarnings != result.warnings { runtimeWarnings = result.warnings } - redrawViewport() - onEditWorldReady?(world) - }) + viewportModel.loadSceneWorld( + world, + application: app, + onLoadingChanged: { isLoadingScene = $0 }, + onLoadingProgress: { loadingProgress = $0 }, + onLoaded: { result in + if runtimeWarnings != result.warnings { runtimeWarnings = result.warnings } + redrawViewport() + onEditWorldReady?(world) + } + ) }, updateContent: { world, deltaTime in world.getResource(EditorSkeleton3DOverlay.self)?.isEnabled = showsBones && displayMode == .threeD @@ -167,10 +183,16 @@ struct EditorSceneViewportView: View { .mask(RectangleShape()) } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + if viewportModel.terrainEditing.isEnabled, !isAssetPreview { + EditorTerrainDockPanel(state: viewportModel.terrainEditing, onChange: redrawViewport) + .frame(maxWidth: .infinity) + } } .overlay(anchor: .top) { - toolbar - .frame(maxWidth: .infinity) + GeometryReader { geometry in + toolbar(width: geometry.size.width) + } + .frame(height: 34) } } @@ -200,7 +222,9 @@ struct EditorSceneViewportView: View { } func configurePlayWorld(_ app: inout AppWorlds) { - guard configureSceneViewApp(&app) else { return } + guard configureSceneViewApp(&app) else { + return + } performanceSession.attach(app, title: document.title) var runtimeInstalled = true do { @@ -226,13 +250,18 @@ struct EditorSceneViewportView: View { let sourceURL = document.absolutePath.map { URL(fileURLWithPath: $0) } let assets = resourceRootURL ?? sourceURL?.deletingLastPathComponent() ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let reportsLoad = runtimeInstalled - app.addPlugin(EditorAdaScriptRuntimeEntryPlugin(sceneModel: model, assetsDirectory: assets, sourceURL: sourceURL, - recordsAchievement: false, onInitialSceneLoaded: { result in + app.addPlugin(EditorAdaScriptRuntimeEntryPlugin( + sceneModel: model, + assetsDirectory: assets, + sourceURL: sourceURL, + recordsAchievement: false, + onInitialSceneLoaded: { result in if reportsLoad { runtimeWarnings = result.warnings } if reportsLoad, result.warnings.isEmpty, sourceURL != nil { EditorAchievementBootstrap.center?.record(EditorAchievementRules.playedScene(model, adaScript: playRuntime != nil)) } - })) + } + )) } @MainActor @@ -338,28 +367,37 @@ struct EditorSceneViewportView: View { .allowsHitTesting(false) } - private var toolbar: some View { - HStack(spacing: 8) { - Text(document.title) - .font(.system(size: 12)) - .foregroundColor(theme.editorColors.text) - .lineLimit(1) - #if !os(iOS) - Text(document.relativePath) - .font(.system(size: 11)) - .foregroundColor(theme.editorColors.muted) - .lineLimit(1) - #endif - Spacer() + private func toolbar(width: Float) -> some View { + let compact = width < 540 + let iconCount = isAssetPreview ? 1 : (annotationAgent == nil ? 4 : 5) + let modeWidth = compact ? min(174, max(92, width - Float(26 * iconCount + 12))) : 174 + let compactModeLabels = modeWidth < 158 ? [EditorSceneViewportRenderingMode.shadedWireframe.rawValue: "Wireframe overlay"] : [:] + return HStack(spacing: compact ? 2 : 8) { + if !compact || displayMode != .threeD { + Text(document.title) + .font(.system(size: 12)) + .foregroundColor(theme.editorColors.text) + .lineLimit(1) + } + if !compact { + #if !os(iOS) + Text(document.relativePath) + .font(.system(size: 11)) + .foregroundColor(theme.editorColors.muted) + .lineLimit(1) + #endif + } + Spacer(minLength: 0) if !isAssetPreview { - entityMarkerVisibilityButton + entityMarkerVisibilityButton(compact: compact) if let agent = annotationAgent { EditorSceneToolbarButton( title: "Annotate", symbol: "\u{E3C9}", identifier: "AdaEditor.SceneViewport.Annotate", isSelected: agent.sceneAnnotations.isSelecting, - isEnabled: agent.activeSessionID != nil && !agent.isSending + isEnabled: agent.activeSessionID != nil && !agent.isSending, + compact: compact ) { agent.sceneAnnotations.toggleSelection() viewportModel.setAnnotationMode(agent.sceneAnnotations.isSelecting) @@ -370,34 +408,82 @@ struct EditorSceneViewportView: View { title: "Terrain", symbol: "\u{E564}", identifier: "AdaEditor.SceneViewport.Terrain", - isSelected: viewportModel.terrainEditing.isEnabled + isSelected: viewportModel.terrainEditing.isEnabled, + compact: compact ) { annotationAgent?.sceneAnnotations.stopSelecting() let enabled = !viewportModel.terrainEditing.isEnabled if enabled { selectViewportMode(.threeD) } viewportModel.setTerrainEditing(enabled) - if enabled { (onTerrainToolSelected ?? onEntitySelected)?() } + if enabled { viewportModel.refreshTerrainSourceControls() } + redrawViewport() + } + } + if !isAssetPreview { + EditorSceneToolbarButton( + title: "Mesh Instancing", + symbol: "\u{E8F1}", + identifier: "AdaEditor.SceneViewport.MeshInstancing", + isSelected: viewportModel.terrainEditing.isEnabled && viewportModel.terrainEditing.dockTab == "Mesh Instancing", + compact: compact + ) { + selectViewportMode(.threeD) + viewportModel.setTerrainEditing(true) + viewportModel.terrainEditing.dockTab = "Mesh Instancing" + viewportModel.terrainEditing.sourceTool = .scatter redrawViewport() } } if displayMode == .threeD { - skeletonVisibilityButton + if !isPlayingThisDocument { + EditorEnumField( + cases: EditorSceneViewportRenderingMode.allCases.map(\.rawValue), + selection: Binding( + get: { viewportModel.renderingMode.rawValue }, + set: { value in + guard let mode = EditorSceneViewportRenderingMode(rawValue: value) else { return } + viewportModel.renderingMode = mode + viewportModel.applyCamera() + redrawViewport() + } + ), + accessibilityID: "AdaEditor.SceneViewport.RenderingMode", + labels: compactModeLabels, + compact: compact + ).frame(width: modeWidth) + } + if compact { + EditorSceneToolbarButton( + title: showsBones ? "Hide bones" : "Show bones", + symbol: "\u{E84E}", + identifier: "AdaEditor.SceneViewport.Bones", + isSelected: showsBones, + compact: true, + action: { showsBones.toggle() } + ) + } else { + skeletonVisibilityButton + } + } + if !compact { + Text(isPlayingThisDocument ? L10n.text("PLAY MODE") : isAssetPreview ? L10n.text("MODEL PREVIEW") : L10n.text("SCENE")) + .font(.system(size: 10)) + .foregroundColor(isPlayingThisDocument ? theme.editorColors.purple : theme.editorColors.muted) } - Text(isPlayingThisDocument ? L10n.text("PLAY MODE") : isAssetPreview ? L10n.text("MODEL PREVIEW") : L10n.text("SCENE")) - .font(.system(size: 10)) - .foregroundColor(isPlayingThisDocument ? theme.editorColors.purple : theme.editorColors.muted) } - .padding(.horizontal, 12) + .padding(.horizontal, compact ? 4 : 12) + .frame(maxWidth: .infinity) .frame(height: 34) .background(theme.editorColors.surface) } - private var entityMarkerVisibilityButton: some View { + private func entityMarkerVisibilityButton(compact: Bool) -> some View { EditorSceneToolbarButton( title: showsEntityMarkers ? "Hide object points" : "Show object points", symbol: "\u{E3B4}", identifier: "AdaEditor.SceneViewport.EntityMarkers", - isSelected: showsEntityMarkers + isSelected: showsEntityMarkers, + compact: compact ) { showsEntityMarkers.toggle() viewportModel.showsEntityMarkers = showsEntityMarkers diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainDockPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainDockPanel.swift new file mode 100644 index 000000000..eda6054cb --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainDockPanel.swift @@ -0,0 +1,239 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Terrain authoring occupies the bottom of its scene viewport, independently of the Inspector. +struct EditorTerrainDockPanel: View { + let state: EditorTerrainEditingState + let onChange: () -> Void + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(L10n.text("Terrain")).font(.system(size: 12, weight: .semibold)) + tab("Sculpt", tool: .sculpt) + tab("Materials", tool: .paint) + tab("Joining", tool: .sculpt) + tab("Mesh Instancing", tool: .scatter) + tab("Height Generator", tool: .sculpt) + Spacer() + if let status = state.sourceStatus { Text(L10n.tr(status)).font(.system(size: 11)).foregroundColor(theme.editorColors.muted).lineLimit(1) } + } + creationControls + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 20) { + if state.dockTab != "Joining" { + VStack(alignment: .leading, spacing: 9) { + Text(L10n.text("Brush Settings")).font(.system(size: 11, weight: .semibold)) + slider("Brush Radius", value: Binding(get: { Double(state.radius) }, set: { state.radius = Float($0); onChange() }), range: 0.5...32, id: "Radius") + slider("Brush Strength", value: Binding(get: { Double(state.strength) }, set: { state.strength = Float($0); onChange() }), range: 0.2...12, id: "Strength") + Text(L10n.text("Drag to draw. Escape cancels. Undo restores the previous surface.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + }.frame(width: 220) + } + if state.dockTab == "Joining" { + EditorMeshTerrainJoiningPanel(state: state, onChange: onChange) + } else if state.dockTab == "Sculpt" { + sculptTools + } else if state.dockTab == "Materials" { + materialTools + } else if state.dockTab == "Mesh Instancing" { + meshTools + } else { + generatorTools + } + } + } + } + .padding(12) + .font(.system(size: 11)) + .foregroundColor(theme.editorColors.text) + .background(theme.editorColors.surface) + .frame(height: dockHeight) + .accessibilityIdentifier("AdaEditor.Terrain.Dock") + } + + private var creationControls: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + Text(L10n.text("Terrain Type")).font(.system(size: 11)) + EditorEnumField(cases: EditorTerrainCreationBackend.allCases.map(\.rawValue), selection: Binding( + get: { state.creationBackend.rawValue }, + set: { if let value = EditorTerrainCreationBackend(rawValue: $0) { state.creationBackend = value } } + ), accessibilityID: "AdaEditor.Terrain.Backend").frame(width: 170) + Button(action: { state.sourceActions?.create() }) { + Text(L10n.text("Create Terrain")).font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 10).frame(height: controlHeight) + } + .buttonStyle(EditorFieldButtonStyle(theme: theme)) + .accessibilityIdentifier("AdaEditor.Terrain.Create") + } + .disabled(state.isSourceBusy) + } + } + + private var controlHeight: Float { + #if IOS || VISIONOS || ANDROID + 44 + #else + 30 + #endif + } + + private var dockHeight: Float { + #if IOS || VISIONOS || ANDROID + 300 + #else + 274 + #endif + } + + private var sculptTools: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + mode(.raise); mode(.lower); mode(.smooth); mode(.flatten) + } + Text(L10n.text("Brush Preset")).font(.system(size: 11)) + EditorEnumField( + cases: EditorTerrainBrushPreset.all.map(\.name), + selection: Binding( + get: { state.sourcePresetName.isEmpty ? "Soft hill" : state.sourcePresetName }, + set: { name in + state.sourcePresetName = name + if let preset = EditorTerrainBrushPreset.all.first(where: { $0.name == name }) { + state.radius = preset.radius; state.strength = preset.strength + } + onChange() + } + ), + accessibilityID: "AdaEditor.Terrain.Preset" + ) + Button(L10n.text("Add region")) { state.sourceActions?.extend() }.disabled(state.isSourceBusy) + }.frame(width: 400) + } + + private var materialTools: some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { + Button(L10n.text("Add Material…")) { state.sourceActions?.texture() } + .disabled(state.isSourceBusy).accessibilityIdentifier("AdaEditor.Terrain.AddMaterial") + Button(L10n.text("Automatic Grass / Rock / Snow")) { state.sourceActions?.materials() }.disabled(state.isSourceBusy) + Toggle(L10n.text("Reduce texture repetition"), isOn: Binding( + get: { state.usesTextureVariation }, + set: { state.usesTextureVariation = $0; state.sourceActions?.variation() } + )).disabled(state.isSourceBusy) + Button(L10n.text("Erase material paint")) { state.sourceTool = .erasePaint; onChange() } + Text(L10n.text("Height and slope choose automatic layers. Paint overrides them.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + }.frame(width: 200) + ForEach(state.sourceManifest?.materials?.layers ?? [], id: \.id) { layer in + Button(action: { state.sourceLayerID = layer.id; state.sourceTool = .paint; onChange() }) { + EditorTerrainMaterialCard(layer: layer, folder: state.sourceAssetFolder, selected: state.sourceLayerID == layer.id) + }.buttonStyle(DefaultButtonStyle()).accessibilityIdentifier("AdaEditor.Terrain.Material.\(layer.id)") + } + } + } + + private var meshTools: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(L10n.text("Paint meshes")) { state.sourceTool = .scatter; onChange() } + Button(L10n.text("Erase meshes")) { state.sourceTool = .eraseMeshes; onChange() } + } + Button(L10n.text("Add Mesh…")) { state.sourceActions?.mesh() } + .disabled(state.isSourceBusy).accessibilityIdentifier("AdaEditor.Terrain.AddMesh") + slider("Mesh Density", value: Binding(get: { state.meshDensity }, set: { state.meshDensity = $0 }), range: 0.01...1, id: "Density") + Text(L10n.text("Import a self-contained GLB or VOX model. Copies share mesh buffers.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + }.frame(width: 250) + VStack(alignment: .leading, spacing: 6) { + ForEach(state.sourceManifest?.meshPrototypes ?? [], id: \.id) { mesh in + Button(action: { state.sourcePrototypeID = mesh.id; state.sourceTool = .scatter; onChange() }) { + Text("#\(mesh.id) · \(mesh.assetPath)").font(.system(size: 10)).lineLimit(1) + .padding(6).background(theme.editorColors.blue.opacity(state.sourcePrototypeID == mesh.id ? 0.25 : 0.05)) + }.buttonStyle(DefaultButtonStyle()) + } + }.frame(width: 230) + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(L10n.text("Add solid")) { state.sourceTool = .add; onChange() } + Button(L10n.text("Subtract solid")) { state.sourceTool = .subtract; onChange() } + } + EditorEnumField(cases: ["Sphere", "Box", "Capsule", "Palette mesh"], selection: Binding(get: { state.volumeOperand }, set: { state.volumeOperand = $0 })) + }.frame(width: 220) + } + } + + private var generatorTools: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + slider("Height Amplitude", value: Binding(get: { Double(state.noise.amplitude) }, set: { state.noise.amplitude = Float($0) }), range: 0...40, id: "NoiseAmplitude") + slider("Noise Frequency", value: Binding(get: { Double(state.noise.frequency) }, set: { state.noise.frequency = Float($0) }), range: 0.005...0.15, id: "NoiseFrequency") + }.frame(width: 250) + VStack(alignment: .leading, spacing: 10) { + Text(L10n.text("Five noise octaves generate hills and valleys.")) + .font(.system(size: 11)) + Text(L10n.text("Seed: \(state.noise.seed)")).font(.system(size: 11)) + Button(L10n.text("New Seed")) { state.noise.seed &+= 1 } + Button(L10n.text("Generate Heightmap")) { state.sourceActions?.generate() } + .disabled(state.isSourceBusy).accessibilityIdentifier("AdaEditor.Terrain.Generate") + }.frame(width: 300) + } + } + + private func tab(_ title: String, tool: EditorTerrainTool) -> some View { + Button(action: { + if state.dockTab != title { + if state.sourceStroke != nil || state.stroke != nil || state.meshSculptPointerActive { state.sourceActions?.cancel?() } + state.sourceHoverTask?.cancel() + state.hover = nil + } + state.dockTab = title; state.sourceTool = tool; onChange() + }) { + Text(L10n.tr(title)).font(.system(size: 11, weight: .semibold)).padding(.horizontal, 10).padding(.vertical, 6) + .background(theme.editorColors.blue.opacity(state.dockTab == title ? 0.25 : 0.04)) + }.buttonStyle(DefaultButtonStyle()).accessibilityIdentifier("AdaEditor.Terrain.Tab.\(title)") + } + + private func mode(_ mode: EditorTerrainBrushMode) -> some View { + Button(action: { state.mode = mode; state.sourcePresetName = ""; state.sourceTool = .sculpt; onChange() }) { + Text(L10n.tr(mode.rawValue)).font(.system(size: 11)).padding(7) + .background(theme.editorColors.blue.opacity(state.mode == mode ? 0.25 : 0.05)) + }.buttonStyle(DefaultButtonStyle()).accessibilityIdentifier("AdaEditor.Terrain.Mode.\(mode.rawValue)") + } + + private func slider(_ title: String, value: Binding, range: ClosedRange, id: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack { Text(L10n.tr(title)); Spacer(); Text(String(format: "%.2f", value.wrappedValue)) }.font(.system(size: 10)) + Slider(value: value, in: range).accessibilityIdentifier("AdaEditor.Terrain.\(id)") + } + } +} + +private struct EditorTerrainMaterialCard: View { + let layer: TerrainLayerReference + let folder: URL? + let selected: Bool + @State private var preview: Image? + @Environment(\.theme) private var theme + + var body: some View { + VStack(spacing: 6) { + ZStack { + Color(red: layer.tint.x, green: layer.tint.y, blue: layer.tint.z) + if let preview { preview.resizable().frame(width: 86, height: 76) } + }.frame(width: 86, height: 76) + Text(layer.name).font(.system(size: 10)).lineLimit(1) + } + .padding(6) + .frame(width: 100) + .background(theme.editorColors.blue.opacity(selected ? 0.25 : 0.05)) + .task { await loadPreview() } + .onChange(of: layer.albedoPath) { _, _ in loadPreview() } + } + + private func loadPreview() { + preview = layer.albedoPath.flatMap { path in folder.flatMap { try? Image(contentsOf: $0.appendingPathComponent(path)) } } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainPanel.swift index cdba82401..cbdde8569 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainPanel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorTerrainPanel.swift @@ -29,6 +29,14 @@ struct EditorTerrainPanel: View { } .buttonStyle(DefaultButtonStyle()) .accessibilityIdentifier("AdaEditor.Terrain.Create") + .disabled(state.isSourceBusy) + if let status = state.sourceStatus { + Text(L10n.tr(status)) + .font(.system(size: 11)) + .foregroundColor(theme.editorColors.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("AdaEditor.Terrain.Source.Status") + } Text(L10n.text("Drag on the ground to shape it. Escape cancels the stroke.")) .font(.system(size: 11)) .foregroundColor(theme.editorColors.muted) diff --git a/Editor/Tests/AdaEditorTests/EditorEnumMenuTests.swift b/Editor/Tests/AdaEditorTests/EditorEnumMenuTests.swift index 173005e65..872406b13 100644 --- a/Editor/Tests/AdaEditorTests/EditorEnumMenuTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorEnumMenuTests.swift @@ -45,6 +45,72 @@ struct EditorEnumMenuTests { #expect(selected == "one") } + @Test func fieldShowsHoverPressAndKeyboardFocusWithoutChangingItsValue() throws { + let previousPresenter = ContextMenuPresentationCenter.present + ContextMenuPresentationCenter.present = { _ in } + defer { ContextMenuPresentationCenter.present = previousPresenter } + let container = UIContainerView(rootView: EditorEnumField(cases: ["one", "two"], selection: .constant("one")).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 220, height: 100) + container.layoutSubviews() + let selector = UINodeSelector.accessibilityIdentifier("AdaEditor.Enum.Toggle") + let frame = try container.uiNode(matching: selector).absoluteFrame + let point = Point(frame.midX, frame.midY) + let hover = Theme.adaEditor.editorColors.text.opacity(0.10) + let pressed = Theme.adaEditor.editorColors.text.opacity(0.16) + let focused = Theme.adaEditor.editorColors.blue.opacity(0.12) + #expect(fillCount(container, color: hover) == 0) + container.onMouseEvent(MouseEvent(window: .empty, button: .none, mousePosition: point, phase: .changed, modifierKeys: [], time: 0)) + container.update(1 / 60); container.layoutIfNeeded() + #expect(fillCount(container, color: hover) > 0) + container.onMouseEvent(MouseEvent(window: .empty, button: .left, mousePosition: point, phase: .began, modifierKeys: [], time: 0)) + container.update(1 / 60); container.layoutIfNeeded() + #expect(fillCount(container, color: pressed) > 0) + container.onMouseEvent(MouseEvent(window: .empty, button: .left, mousePosition: point, phase: .cancelled, modifierKeys: [], time: 0)) + container.onMouseEvent(MouseEvent(window: .empty, button: .none, mousePosition: Point(219, 99), phase: .changed, modifierKeys: [], time: 0)) + _ = try container.uiFocusNode(matching: selector) + container.update(1 / 60); container.layoutIfNeeded() + #expect(fillCount(container, color: focused) > 0) + #expect(try container.uiNode(matching: selector).absoluteFrame == frame) + } + + @Test func keyboardActivationAcceptsCapsLockWithoutRepeatingOrHandlingCommandKeys() throws { + var count = 0 + let previousPresenter = ContextMenuPresentationCenter.present + ContextMenuPresentationCenter.present = { _ in count += 1 } + defer { ContextMenuPresentationCenter.present = previousPresenter } + let field = EditorEnumField(cases: ["one", "two"], selection: .constant("one")) + let container = UIContainerView(rootView: field) + container.frame = Rect(x: 0, y: 0, width: 220, height: 100) + container.layoutSubviews() + _ = try container.uiFocusNode(matching: .accessibilityIdentifier("AdaEditor.Enum.Toggle")) + container.onKeyEvent(KeyEvent(window: .empty, keyCode: .enter, modifiers: [.capsLock], status: .down, time: 0, isRepeated: false)) + #expect(count == 1) + container.onKeyEvent(KeyEvent(window: .empty, keyCode: .enter, modifiers: [.capsLock], status: .down, time: 0, isRepeated: true)) + #expect(count == 1) + container.onKeyEvent(KeyEvent(window: .empty, keyCode: .enter, modifiers: [.capsLock], status: .up, time: 0, isRepeated: false)) + #expect(count == 1) + container.onKeyEvent(KeyEvent(window: .empty, keyCode: .space, modifiers: [], status: .down, time: 0, isRepeated: false)) + #expect(count == 2) + container.onKeyEvent(KeyEvent(window: .empty, keyCode: .space, modifiers: [.main], status: .down, time: 0, isRepeated: false)) + #expect(count == 2) + let disabled = UIContainerView(rootView: field.disabled(true)) + disabled.frame = container.frame + disabled.layoutSubviews() + disabled.onKeyEvent(KeyEvent(window: .empty, keyCode: .space, modifiers: [], status: .down, time: 0, isRepeated: false)) + #expect(count == 2) + } + + private func fillCount(_ container: UIContainerView, color target: Color) -> Int { + let context = UIGraphicsContext() + container.draw(in: container.bounds, with: context) + return context.getDrawCommands().filter { command in + switch command { + case let .drawPath(_, _, .fill(color)), let .drawQuad(_, _, color): color == target + default: false + } + }.count + } + @Test func primaryMenuCancelsDragAndIgnoresDisabledClicks() throws { var presentations = 0 let previousPresenter = ContextMenuPresentationCenter.present diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainBackendTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainBackendTests.swift new file mode 100644 index 000000000..04a573fc8 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainBackendTests.swift @@ -0,0 +1,47 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Mesh terrain backend routing", .serialized) +struct EditorMeshTerrainBackendTests { + @Test("Heightfield generation leaves all resources unchanged when Mesh Terrain is selected") + func selectedMeshDoesNotGenerateAnotherTerrain() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.viewport.terrainEditing.creationBackend = .mesh + fixture.viewport.terrainEditing.noise.amplitude = 0 + fixture.viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(fixture.viewport.selectedEditorID) + let loadDeadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < loadDeadline { + try await support.pump(fixture, frames: 1) + if fixture.viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil, + fixture.viewport.runtimeEntity(for: fixture.id)?.components[Terrain3DSourceSurface.self] != nil { break } + } + try #require(fixture.viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil) + try #require(fixture.viewport.runtimeEntity(for: fixture.id)?.components[Terrain3DSourceSurface.self] != nil) + fixture.viewport.selectedEditorID = fixture.id + fixture.viewport.refreshTerrainSourceControls() + let controlsDeadline = ContinuousClock.now.advanced(by: .seconds(2)) + while fixture.viewport.terrainEditing.sourceManifest == nil, ContinuousClock.now < controlsDeadline { + try await support.pump(fixture, frames: 1) + } + try #require(fixture.viewport.terrainEditing.sourceManifest != nil) + fixture.viewport.selectedEditorID = meshID + let content = fixture.viewport.sceneContent + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + fixture.viewport.terrainEditing.noise.amplitude = 8 + fixture.viewport.terrainEditing.sourceActions?.generate() + try await support.finish(fixture) + #expect(fixture.viewport.sceneContent == content) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + #expect(fixture.viewport.terrainEditing.sourceStatus == "Choose a Heightfield for this tool.") + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainControlTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainControlTests.swift new file mode 100644 index 000000000..3642c5f07 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainControlTests.swift @@ -0,0 +1,57 @@ +@_spi(AdaEngine) @testable import AdaEngine +@_spi(Internal) import AdaUI +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Mesh terrain native controls", .serialized) +struct EditorMeshTerrainControlTests { + init() { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "Mesh terrain controls"))) + } + } + + @Test("Joining controls retain the chosen target and prevent actions while a generation is pending") + func joiningPanelSelectsTargetAndDisablesPendingActions() throws { + let state = EditorTerrainEditingState() + state.dockTab = "Joining" + state.meshSourceName = "Oak trunk" + state.meshTargets = [.init(id: "ground", name: "Ground"), .init(id: "island", name: "Island")] + state.meshTargetEditorID = "ground" + var joined = 0 + state.sourceActions = .init(create: {}, materials: {}, variation: {}, mesh: {}, texture: {}, extend: {}, generate: {}, join: { joined += 1 }) + let previousPresenter = ContextMenuPresentationCenter.present + var presentation: ContextMenuPresentation? + ContextMenuPresentationCenter.present = { presentation = $0 } + defer { ContextMenuPresentationCenter.present = previousPresenter } + let container = UIContainerView(rootView: EditorTerrainDockPanel(state: state, onChange: {}).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1_100, height: 280) + container.layoutSubviews() + let join = UINodeSelector.accessibilityIdentifier("AdaEditor.Terrain.JoinSelected") + #expect(container.uiFindNodes(matching: join).count == 1) + guard !container.uiFindNodes(matching: join).isEmpty else { return } + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.JoinTarget")) + let menu = try #require(presentation) + let choose = try #require(menu.items.first { $0.title == "Island" }?.action) + choose() + #expect(state.meshTargetEditorID == "island") + container.update(1 / 60); container.layoutIfNeeded() + presentation = nil + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.JoinTarget")) + #expect(presentation?.items.filter(\.isSelected).map(\.title) == ["Island"]) + _ = try container.uiTapNode(matching: join) + #expect(joined == 1) + state.isSourceBusy = true + container.update(1 / 60); container.layoutIfNeeded() + _ = try container.uiTapNode(matching: join) + #expect(joined == 1) + state.isSourceBusy = false + state.meshSourceName = nil + container.update(1 / 60); container.layoutIfNeeded() + _ = try container.uiTapNode(matching: join) + #expect(joined == 1) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainDragTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainDragTests.swift new file mode 100644 index 000000000..4ed691c42 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainDragTests.swift @@ -0,0 +1,461 @@ +@_spi(Internal) @testable import AdaApp +@_spi(AdaEngine) @testable import AdaEngine +import AdaInput +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Mesh terrain source gestures", .serialized) +struct EditorMeshTerrainDragTests { + @Test("Moving a joined object's parent saves both poses or rejects the whole move", arguments: [Float(3), Float(200)]) + func movingParentOfJoinedSource(x: Float) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support, grouped: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let groupID = try #require(viewport.sceneModel?.entities.first { $0.id == sourceID }?.parent) + let group = try #require(viewport.runtimeEntity(for: groupID)) + let source = try #require(viewport.runtimeEntity(for: sourceID)) + let terrain = try #require(viewport.runtimeEntity(for: terrainID)) + let originalFile = terrain.components[Terrain3DSource.self]?.source + let originalScene = viewport.sceneContent + let originalCount = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + var pose = try #require(group.components[Transform.self]) + pose.position.x = x + + viewport.beginSpatialTransform(editorID: groupID) + viewport.updateSpatialTransform(pose, editorID: groupID) + #expect(group.components[Transform.self]?.position.x == 0) + #expect(viewport.sceneContent == originalScene) + viewport.endSpatialTransform() + try await support.finish(fixture) + try await support.pump(fixture, frames: 2) + + let queries = try #require(terrain.components[MeshTerrain3DSourceSurface.self]?.queries) + #expect(source.components[Transform.self]?.position.x == 0) + #expect(source.components[MeshTerrain3DSourceMask.self]?.owner == terrain.id) + if x == 3 { + #expect(group.components[Transform.self]?.position.x == x) + #expect(terrain.components[Terrain3DSource.self]?.source != originalFile) + #expect(try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 0) + #expect(try queries.raycast(.init(origin: [x, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == originalCount + 1) + } else { + #expect(viewport.terrainEditing.sourceFailure != nil) + #expect(group.components[Transform.self]?.position.x == 0) + #expect(terrain.components[Terrain3DSource.self]?.source == originalFile) + #expect(try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(viewport.sceneContent == originalScene) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == originalCount) + } + } + + @Test("Group Inspector movement and history keep two joined meshes in the same terrain generation") + func groupInspectorAndHistoryRestoreTwoJoins() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, firstID) = try await joinedFixture(support, grouped: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + var model = try #require(viewport.sceneModel) + let groupID = try #require(model.entities.first { $0.id == firstID }?.parent) + let second = model.addEntity(template: .model3D, parentID: groupID) + let index = try #require(model.entities.firstIndex { $0.id == second.id }) + model.entities[index].components[EditorBuiltInComponentType.mesh3D]?["size"] = .double(2) + model.entities[index].components[EditorBuiltInComponentType.transform]?["position"] = .array([.double(-4), .double(0), .double(0)]) + viewport.sceneModel = model + viewport.publishTerrainDocument() + _ = viewport.reloadSceneRuntimeIfReady() + viewport.selectedEditorID = second.id + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: firstID)?.components[MeshTerrain3DBindingStatus.self]?.phase == .applied { break } + } + viewport.terrainEditing.sourceActions?.join?() + try await support.finish(fixture) + let settledDeadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < settledDeadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: firstID)?.components[MeshTerrain3DBindingStatus.self]?.phase == .applied { break } + } + + let group = try #require(viewport.runtimeEntity(for: groupID)) + let first = try #require(viewport.runtimeEntity(for: firstID)) + let secondRoot = try #require(viewport.runtimeEntity(for: second.id)) + let terrain = try #require(viewport.runtimeEntity(for: terrainID)) + try #require(secondRoot.components[MeshTerrain3DBindingStatus.self]?.phase == .applied) + let originalKey = try #require(terrain.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let historyCount = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let field = try #require(EditorComponentRegistry.descriptor(named: EditorBuiltInComponentType.transform)?.fields.first { $0.key == "position" }) + viewport.selectedEditorID = groupID + try #require(viewport.applyMeshTerrainInspectorField(field, value: "8, 0, 0")) + try await support.finish(fixture) + #expect(viewport.terrainEditing.sourceFailure == nil) + let movedKey = try #require(terrain.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + try #require(movedKey != originalKey) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == historyCount + 1) + + for redo in [false, true] { + try #require(fixture.workbench.performDocumentHistory(redo: redo)) + let document = try #require(fixture.workbench.activeSceneDocument) + viewport.configure(sceneContent: document.content, model: document.sceneModel, sourceURL: fixture.scene, resourceRootURL: fixture.root, + isHistoryChange: true, onSelectionChanged: { _ in }, onDocumentContentChanged: { _ in }) + try await support.finish(fixture) + #expect(viewport.runtimeEntity(for: groupID) === group) + #expect(viewport.runtimeEntity(for: firstID) === first) + #expect(viewport.runtimeEntity(for: second.id) === secondRoot) + #expect(viewport.runtimeEntity(for: terrainID) === terrain) + #expect(group.components[Transform.self]?.position.x == (redo ? 8 : 0)) + #expect(terrain.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == (redo ? movedKey : originalKey)) + let queries = try #require(terrain.components[MeshTerrain3DSourceSurface.self]?.queries) + for x in [Float(0), -4, 8, 4] { + #expect(try queries.raycast(.init(origin: [x, 5, 0], direction: [0, -1, 0]))?.position.y == ((x > 0) == redo ? 1 : 0)) + } + } + } + + @Test("Entering a joined object's position saves both surfaces or retains the accepted edit", arguments: [Float(3), Float(200)]) + func inspectorPositionKeepsJoinedSurface(x: Float) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let source = try #require(viewport.runtimeEntity(for: sourceID)) + let terrain = try #require(viewport.runtimeEntity(for: terrainID)) + let field = try #require(EditorComponentRegistry.descriptor(named: EditorBuiltInComponentType.transform)? + .fields.first { $0.key == "position" }) + let originalFile = terrain.components[Terrain3DSource.self]?.source + let originalScene = viewport.sceneContent + let originalCount = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + + #expect(viewport.applyMeshTerrainInspectorField(field, value: "\(x), 0, 0")) + #expect(viewport.sceneContent == originalScene) + #expect(source.components[Transform.self]?.position.x == 0) + try await support.finish(fixture) + + let queries = try #require(terrain.components[MeshTerrain3DSourceSurface.self]?.queries) + if x == 3 { + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(source.components[Transform.self]?.position.x == x) + #expect(terrain.components[Terrain3DSource.self]?.source != originalFile) + #expect(try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 0) + #expect(try queries.raycast(.init(origin: [x, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == originalCount + 1) + } else { + #expect(viewport.terrainEditing.sourceFailure != nil) + #expect(source.components[Transform.self]?.position.x == 0) + #expect(terrain.components[Terrain3DSource.self]?.source == originalFile) + #expect(try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(viewport.sceneContent == originalScene) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == originalCount) + } + } + + @Test("A spatial transform saves the joined object and its ground as one edit") + func spatialTransformCommitsJoinedSurface() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let owner = try #require(viewport.runtimeEntity(for: terrainID)) + let source = try #require(viewport.runtimeEntity(for: sourceID)) + let oldFile = owner.components[Terrain3DSource.self]?.source + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + var pose = try #require(source.components[Transform.self]) + pose.position.x = 3 + + viewport.beginSpatialTransform(editorID: sourceID) + viewport.updateSpatialTransform(pose, editorID: sourceID) + #expect(source.components[Transform.self]?.position.x == 0) + viewport.endSpatialTransform() + try await support.finish(fixture) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(source.components[Transform.self]?.position.x == 3) + #expect(owner.components[Terrain3DSource.self]?.source != oldFile) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 0) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [3, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count + 1) + } + + @Test("Dragging a joined object past the terrain edge leaves the saved pose, geometry and draw mask intact") + func rejectedJoinedDragRetainsOriginalSurface() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let root = try #require(fixture.viewport.runtimeEntity(for: sourceID)) + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let accepted = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let scene = fixture.viewport.sceneContent + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + // Either direction can be outside the 128-unit terrain. Prefer the + // projection that lies in front of the viewport camera. + let end = try #require([Float(70), -70].lazy.compactMap { offset in + fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + offset, 0, 0), size: fixture.viewport.viewportSize) + }.first) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + try #require(fixture.viewport.updateTransformDrag(to: end)) + fixture.viewport.endTransformDrag(cancelled: false) + try await support.finish(fixture) + #expect(fixture.viewport.terrainEditing.sourceFailure == "Move the whole joined mesh inside the terrain boundary.") + #expect(fixture.viewport.sceneContent == scene) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + #expect(root.components[Transform.self]?.position == .zero) + #expect(root.components[MeshTerrain3DProposedPose.self] == nil) + #expect(root.components[MeshTerrain3DSourceMask.self]?.owner == owner.id) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == accepted) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + } + + @Test("Joining uses the activated options and rejects an explicitly missing target", arguments: [false, true]) + func joiningCapturesOptionsAndRejectsMissingTarget(missing: Bool) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, _) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let oldPayload = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let oldURI = try EditorTerrainSourcePayload.decode(oldPayload).source + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + fixture.viewport.terrainEditing.meshTargetEditorID = missing ? "disappeared" : terrainID + fixture.viewport.terrainEditing.meshOperation = missing ? "Join" : "Subtract" + fixture.viewport.terrainEditing.sourceActions?.join?() + if !missing { + // An observation refresh can arrive before the world-safe-point capture. + fixture.viewport.terrainEditing.meshOperation = "Join" + fixture.viewport.terrainEditing.meshTargetEditorID = "disappeared" + } + try await support.finish(fixture) + let payload = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let uri = try EditorTerrainSourcePayload.decode(payload).source + if missing { + #expect(fixture.viewport.terrainEditing.sourceFailure == "The selected Mesh Terrain is unavailable. Choose another terrain.") + #expect(uri == oldURI) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + } else { + #expect(fixture.viewport.terrainEditing.sourceFailure == nil) + #expect(uri != oldURI) + let file = try EditorTerrainResource.resolve(uri, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + let name = try #require(manifest.meshDocumentFileName) + let saved = try MeshTerrainDocumentAsset(data: Data(contentsOf: file.deletingLastPathComponent().appendingPathComponent(name))) + #expect(saved.document.modifiers.first?.operation == .subtract) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == -1) + } + } + + @Test("Picking selects mesh ground and keeps coincident joined sources selectable") + func meshTerrainPickingRetainsSourceSelection() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + try await fixture.app.withWorldAccess { + let editorIDs = Dictionary(uniqueKeysWithValues: fixture.viewport.entitiesByEditorID.map { ($0.value, $0.key) }) + let ground = EditorMeshPicking.pick(ray: .init(origin: [40, 5, 40], direction: [0, -1, 0]), world: fixture.app.main, + authoredEntities: fixture.viewport.entitiesByEditorID, editorIDs: editorIDs, cameraID: nil) + #expect(ground == terrainID) + let joined = EditorMeshPicking.pick(ray: .init(origin: [0, 5, 0], direction: [0, -1, 0]), world: fixture.app.main, + authoredEntities: fixture.viewport.entitiesByEditorID, editorIDs: editorIDs, cameraID: nil) + #expect(joined == sourceID) + #expect(fixture.viewport.runtimeEntity(for: sourceID)?.components[MeshTerrain3DSourceMask.self] != nil) + } + } + + @Test("A failed gesture restore keeps its last coherent pose and reports the failure") + func cancelJoinedSourceDragReportsRestoreFailure() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let root = try #require(fixture.viewport.runtimeEntity(for: sourceID)) + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let originalFile = try #require(owner.components[Terrain3DSource.self]?.source) + let data = try Data(contentsOf: URL(fileURLWithPath: originalFile)) + defer { try? data.write(to: URL(fileURLWithPath: originalFile)) } + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + let end = try #require(fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + 3, 0, 0), size: fixture.viewport.viewportSize)) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + try #require(fixture.viewport.updateTransformDrag(to: end)) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001, + root.components[MeshTerrain3DProposedPose.self] == nil { break } + } + try #require(abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001) + let acceptedPayload = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == sourceID }?.components[EditorBuiltInComponentType.transform]) + // Exercise a real worker failure: a terrain resource can disappear during a gesture. + try FileManager.default.removeItem(atPath: originalFile) + fixture.viewport.endTransformDrag(cancelled: true) + try await support.finish(fixture) + #expect(fixture.viewport.terrainEditing.sourceFailure != nil) + #expect(fixture.viewport.sceneModel?.entities.first { $0.id == sourceID }?.components[EditorBuiltInComponentType.transform] == acceptedPayload) + #expect(abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 0) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [3, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + } + + @Test("Cancelling a joined-source drag restores its accepted generation without saving", arguments: [false, true]) + func cancelJoinedSourceDrag(afterPreview: Bool) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let root = try #require(fixture.viewport.runtimeEntity(for: sourceID)) + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let original = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let content = fixture.viewport.sceneContent + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + let end = try #require(fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + 3, 0, 0), size: fixture.viewport.viewportSize)) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + try #require(fixture.viewport.updateTransformDrag(to: end)) + if afterPreview { + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001, + root.components[MeshTerrain3DProposedPose.self] == nil { break } + } + try #require(abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001) + } + fixture.viewport.endTransformDrag(cancelled: true) + if afterPreview { + try await support.finish(fixture) + } else { + // No world update is needed when no provisional generation was ever accepted. + // This also exercises cancellation while the editor's scheduler is paused. + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while fixture.viewport.terrainEditing.isSourceBusy, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(!fixture.viewport.terrainEditing.isSourceBusy) + // Keep failed Red attempts clean by allowing their unnecessary rollback to finish. + try await support.finish(fixture) + } + #expect(fixture.viewport.terrainEditing.sourceFailure == nil) + #expect(fixture.viewport.sceneContent == content) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == original) + #expect(root.components[Transform.self]?.position.x == 0) + #expect(root.components[MeshTerrain3DProposedPose.self] == nil) + #expect(owner.components[MeshTerrain3DEdit.self] == nil) + #expect(root.components[MeshTerrain3DBindingStatus.self]?.phase == .applied) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + } + + func joinedFixture(_ support: EditorTerrainSourceViewportTests, grouped: Bool = false) async throws + -> (EditorTerrainSourceViewportTests.Fixture, String, String) { + let fixture = try await support.fixture() + fixture.viewport.terrainEditing.creationBackend = .mesh + fixture.viewport.terrainEditing.noise.amplitude = 0 + fixture.viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let terrainID = try #require(fixture.viewport.selectedEditorID) + var model = try #require(fixture.viewport.sceneModel) + let parent = grouped ? model.addEntity(template: .empty, parentID: nil).id : nil + let source = model.addEntity(template: .model3D, parentID: parent) + let index = try #require(model.entities.firstIndex { $0.id == source.id }) + model.entities[index].components[EditorBuiltInComponentType.mesh3D]?["size"] = .double(2) + fixture.viewport.sceneModel = model + fixture.viewport.publishTerrainDocument() + _ = fixture.viewport.reloadSceneRuntimeIfReady() + fixture.viewport.selectedEditorID = source.id + fixture.viewport.terrainEditing.dockTab = "Joining" + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if try await fixture.app.withWorldAccess({ fixture.viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self] != nil }) { break } + } + fixture.viewport.terrainEditing.sourceActions?.join?() + try await support.finish(fixture) + try #require(fixture.viewport.runtimeEntity(for: source.id)?.components[MeshTerrain3DBinding.self] != nil) + return (fixture, terrainID, source.id) + } + + @Test("Dragging a joined source stages its pose and saves the rebuilt terrain in one history edit") + func joinedSourceDragSavesTerrainAndPoseTogether() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.viewport.terrainEditing.creationBackend = .mesh + fixture.viewport.terrainEditing.noise.amplitude = 0 + fixture.viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let terrainID = try #require(fixture.viewport.selectedEditorID) + var model = try #require(fixture.viewport.sceneModel) + let source = model.addEntity(template: .model3D, parentID: nil) + let index = try #require(model.entities.firstIndex { $0.id == source.id }) + model.entities[index].components[EditorBuiltInComponentType.mesh3D]?["size"] = .double(2) + fixture.viewport.sceneModel = model + fixture.viewport.publishTerrainDocument() + _ = fixture.viewport.reloadSceneRuntimeIfReady() + fixture.viewport.selectedEditorID = source.id + fixture.viewport.terrainEditing.dockTab = "Joining" + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if try await fixture.app.withWorldAccess({ fixture.viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self] != nil }) { break } + } + fixture.viewport.terrainEditing.sourceActions?.join?() + try await support.finish(fixture) + let root = try #require(fixture.viewport.runtimeEntity(for: source.id)) + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + try #require(root.components[MeshTerrain3DBinding.self] != nil) + let previous = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let oldURI = try EditorTerrainSourcePayload.decode(previous).source + let oldFile = try EditorTerrainResource.resolve(oldURI, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let oldData = try Data(contentsOf: oldFile) + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + let end = try #require(fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + 3, 0, 0), size: fixture.viewport.viewportSize)) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + #expect(fixture.viewport.updateTransformDrag(to: end)) + // A UI input safe point must still expose the previous coherent geometry and source pose. + #expect(root.components[Transform.self]?.position.x == 0) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + let movedDeadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < movedDeadline { + try await support.pump(fixture, frames: 1) + if let pose = root.components[Transform.self], abs(pose.position.x - 3) < 0.001, + root.components[MeshTerrain3DProposedPose.self] == nil, root.components[MeshTerrain3DBindingStatus.self]?.phase == .applied { break } + } + #expect(root.components[MeshTerrain3DBindingStatus.self]?.error == nil) + #expect(owner.components[MeshTerrain3DEditStatus.self]?.error == nil) + #expect(abs((root.components[Transform.self]?.position.x ?? 0) - 3) < 0.001) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 0) + fixture.viewport.endTransformDrag() + try await support.finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count + 1) + let changed = try #require(fixture.workbench.activeSceneDocument?.sceneModel) + let payload = try #require(changed.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let uri = try EditorTerrainSourcePayload.decode(payload).source + #expect(uri != oldURI) + let file = try EditorTerrainResource.resolve(uri, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + let name = try #require(manifest.meshDocumentFileName) + let saved = try MeshTerrainDocumentAsset(data: Data(contentsOf: file.deletingLastPathComponent().appendingPathComponent(name))) + _ = try saved.acceptedCompiledProduct() + #expect(abs((saved.document.modifiers.first?.transform.origin.x ?? 0) - 3) < 0.001) + let posePayload = try #require(changed.entities.first { $0.id == source.id }?.components[EditorBuiltInComponentType.transform]) + let pose = try #require(EditorComponentPayloadDecoder.decode(Transform.self, payload: posePayload) as? Transform) + #expect(abs(pose.position.x - 3) < 0.001) + #expect(try Data(contentsOf: oldFile) == oldData) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainFastBrushTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainFastBrushTests.swift new file mode 100644 index 000000000..68703e304 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainFastBrushTests.swift @@ -0,0 +1,51 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor @Suite("Mesh Terrain short pointer gestures", .serialized) +struct EditorMeshTerrainFastBrushTests { + @Test("A paint drag released between render frames covers its whole path") + func meshPaintCapturesReleasePosition() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(viewport.selectedEditorID) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: meshID)) + viewport.terrainEditing.sourceActions?.materials() + try await support.finish(fixture) + let palette = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest.materials) + let layer = try #require(palette.layers.first { $0.id != palette.fallbackLayerID }) + let a = try #require(viewport.project([-6, 0, 0], size: viewport.viewportSize)) + let b = try #require(viewport.project([6, 0, 0], size: viewport.viewportSize)) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + + viewport.terrainEditing.dockTab = "Materials" + viewport.terrainEditing.radius = 2.5 + viewport.terrainEditing.strength = 12 + viewport.terrainEditing.sourceLayerID = layer.id + viewport.terrainEditing.sourceTool = .paint + try #require(viewport.handleTerrainPointer(at: a, began: true)) + try #require(viewport.handleTerrainPointer(at: b, ended: true)) + try await support.finish(fixture) + let asset = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset) + let patch = try #require(asset.compiled?.patches.first) + let paintedX = (patch.paint?.samples ?? []).map { patch.surface.positions[Int(patch.surface.indices[$0.corner])].x } + #expect(paintedX.contains(-6)) + #expect(paintedX.contains(0)) + #expect(paintedX.contains(6)) + #expect(asset.document.paint.contains { $0.anchor.point.x > 5 }) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainHistoryTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainHistoryTests.swift new file mode 100644 index 000000000..3024d229c --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainHistoryTests.swift @@ -0,0 +1,171 @@ +@_spi(Internal) @testable import AdaApp +@_spi(AdaEngine) @testable import AdaEngine +import AdaInput +import Foundation +import Math +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Mesh terrain viewport history", .serialized) +struct EditorMeshTerrainHistoryTests { + @Test("Detaching restores the independent source and ground in one undoable edit") + func detachJoinedMeshRestoresGroundAndSource() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await EditorMeshTerrainDragTests().joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport, workbench = fixture.workbench + let owner = try #require(viewport.runtimeEntity(for: terrainID)) + let source = try #require(viewport.runtimeEntity(for: sourceID)) + let joined = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let binding = try #require(source.components[MeshTerrain3DBinding.self]) + let identity = try #require(source.components[MeshTerrain3DIdentity.self]) + let count = workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let ray = Ray(origin: [0, 5, 0], direction: [0, -1, 0]) + + viewport.terrainEditing.sourceActions?.detach?() + try await support.finish(fixture) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(source.components[MeshTerrain3DBinding.self] == nil) + #expect(source.components[MeshTerrain3DSourceMask.self] == nil) + #expect(source.components[MeshTerrain3DIdentity.self] == identity) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(ray)?.position.y == 0) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.document.modifiers.isEmpty == true) + #expect(viewport.sceneModel?.entities.first(where: { $0.id == sourceID })?.components[String(reflecting: MeshTerrain3DBinding.self)] == nil) + let detached = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + try #require(detached != joined) + #expect(workbench.sceneUndoHistory["terrain"]?.count == count + 1) + + for redo in [false, true] { + try #require(workbench.performDocumentHistory(redo: redo)) + let document = try #require(workbench.activeSceneDocument) + viewport.configure(sceneContent: document.content, model: document.sceneModel, sourceURL: fixture.scene, resourceRootURL: fixture.root, + isHistoryChange: true, onSelectionChanged: { _ in }, onDocumentContentChanged: { _ in }) + try await support.finish(fixture) + #expect(viewport.runtimeEntity(for: terrainID) === owner) + #expect(viewport.runtimeEntity(for: sourceID) === source) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == (redo ? detached : joined)) + #expect(source.components[MeshTerrain3DBinding.self] == (redo ? nil : binding)) + #expect(source.components[MeshTerrain3DSourceMask.self]?.owner == (redo ? nil : owner.id)) + #expect(try owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(ray)?.position.y == (redo ? 0 : 1)) + } + } + + @Test("Rapid history commands retain only the latest requested generation", arguments: [false, true]) + func rapidHistoryKeepsLatestGeneration(finalUndo: Bool) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await EditorMeshTerrainDragTests().joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let source = try #require(fixture.viewport.runtimeEntity(for: sourceID)) + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + let end = try #require(fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + 3, 0, 0), size: fixture.viewport.viewportSize)) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + try #require(fixture.viewport.updateTransformDrag(to: end)) + fixture.viewport.endTransformDrag() + try await support.finish(fixture) + try #require(fixture.viewport.terrainEditing.sourceFailure == nil) + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + for redo in finalUndo ? [false, true, false] : [false, true] { + try #require(fixture.workbench.performDocumentHistory(redo: redo)) + let document = try #require(fixture.workbench.activeSceneDocument) + let viewport = fixture.viewport, workbench = fixture.workbench + viewport.configure(sceneContent: document.content, model: document.sceneModel, sourceURL: fixture.scene, resourceRootURL: fixture.root, + isHistoryChange: true, onSelectionChanged: { _ in }, onDocumentContentChanged: { [weak viewport, weak workbench] content in + workbench?.updateSceneDocument(id: "terrain") { changed in + changed.content = content; changed.sceneModel = viewport?.sceneModel; changed.isDirty = true + } + }) + #expect(viewport.runtimeEntity(for: terrainID) === owner) + #expect(viewport.runtimeEntity(for: sourceID) === source) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.queries != nil) + } + if !finalUndo { + // Undo followed by Redo before an update returns to the resident version. + // Draining that mailbox must not require a scene frame to publish a no-op. + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while fixture.viewport.terrainEditing.isSourceBusy, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(!fixture.viewport.terrainEditing.isSourceBusy) + } + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + var ready = false + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + let terrain = fixture.viewport.runtimeEntity(for: terrainID), root = fixture.viewport.runtimeEntity(for: sourceID) + let queries = terrain?.components[MeshTerrain3DSourceSurface.self]?.queries + #expect(queries != nil) + if let queries, let x = root?.components[Transform.self]?.position.x { + let y = try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y + #expect(abs(x) < 0.001 ? y == 1 : abs(x - 3) < 0.001 && y == 0) + if !fixture.viewport.terrainEditing.isSourceBusy, abs(x - (finalUndo ? 0 : 3)) < 0.001 { ready = true; break } + } + } + #expect(ready) + #expect(fixture.viewport.terrainEditing.sourceFailure == nil) + #expect(fixture.viewport.runtimeEntity(for: terrainID) === owner) + #expect(fixture.viewport.runtimeEntity(for: sourceID) === source) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + } + + @Test("Undo and Redo retain the visible terrain until geometry and source pose can publish together") + func sourceHistoryKeepsEveryFrameCoherent() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, terrainID, sourceID) = try await EditorMeshTerrainDragTests().joinedFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let owner = try #require(fixture.viewport.runtimeEntity(for: terrainID)) + let source = try #require(fixture.viewport.runtimeEntity(for: sourceID)) + let initial = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + fixture.viewport.setActiveTool(.translate) + let gizmo = try #require(fixture.viewport.transformGizmo()) + let start = try #require(gizmo.shapes.first { $0.handle == .x }?.points.last) + let end = try #require(fixture.viewport.project(gizmo.origin + Vector3(gizmo.worldLength + 3, 0, 0), size: fixture.viewport.viewportSize)) + try #require(fixture.viewport.beginTransformDragIfNeeded(at: start, button: .left)) + try #require(fixture.viewport.updateTransformDrag(to: end)) + fixture.viewport.endTransformDrag() + try await support.finish(fixture) + try #require(fixture.viewport.terrainEditing.sourceFailure == nil) + let moved = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + try #require(moved != initial) + for redo in [false, true] { + try #require(fixture.workbench.performDocumentHistory(redo: redo)) + let document = try #require(fixture.workbench.activeSceneDocument) + let viewport = fixture.viewport, workbench = fixture.workbench + viewport.configure(sceneContent: document.content, model: document.sceneModel, sourceURL: fixture.scene, resourceRootURL: fixture.root, + isHistoryChange: true, onSelectionChanged: { _ in }, onDocumentContentChanged: { [weak viewport, weak workbench] content in + workbench?.updateSceneDocument(id: "terrain") { changed in + changed.content = content; changed.sceneModel = viewport?.sceneModel; changed.isDirty = true + } + }) + // History must keep the resident world while the immutable version is prepared. + #expect(viewport.runtimeEntity(for: terrainID) === owner) + #expect(viewport.runtimeEntity(for: sourceID) === source) + #expect(viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self]?.queries != nil) + let expected = redo ? moved : initial + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + var ready = false + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + let terrain = viewport.runtimeEntity(for: terrainID), root = viewport.runtimeEntity(for: sourceID) + let surface = terrain?.components[MeshTerrain3DSourceSurface.self] + #expect(surface?.queries != nil) + if let queries = surface?.queries, let x = root?.components[Transform.self]?.position.x { + let y = try queries.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y + if abs(x) < 0.001 { #expect(y == 1) } + else if abs(x - 3) < 0.001 { #expect(y == 0) } + else { Issue.record("History published an unexpected source pose") } + } + if surface?.asset.compiled?.inputKey == expected, !viewport.terrainEditing.isSourceBusy, + abs((root?.components[Transform.self]?.position.x ?? -100) - (redo ? 3 : 0)) < 0.001 { ready = true; break } + } + #expect(ready) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(viewport.runtimeEntity(for: terrainID) === owner) + #expect(viewport.runtimeEntity(for: sourceID) === source) + } + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainImportTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainImportTests.swift new file mode 100644 index 000000000..0252fe2d4 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainImportTests.swift @@ -0,0 +1,65 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor @Suite("Mesh Terrain project imports", .serialized) +struct EditorMeshTerrainImportTests { + @Test("Mesh Terrain imports and validates the selected texture and mesh without replacing compiled geometry") + func importsMeshTerrainPaletteResources() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(viewport.selectedEditorID) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: meshID)) + let original = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let previous = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest.meshDocumentFileName) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + + let albedo = fixture.root.appendingPathComponent("Bark.png") + let png = try #require(Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGPoCFX6D8IMMAYARWYH+TH0L8MAAAAASUVORK5CYII=")) + try png.write(to: albedo) + viewport.performTerrainResourceCommand(.texture(albedo, layerID: 5)) + try await support.finish(fixture) + let painted = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest) + #expect(painted.materials?.layers.first?.id == 5) + #expect(painted.meshDocumentFileName == previous) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == original) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + + let prototype = fixture.root.appendingPathComponent("Grass.vox") + let model = VOXModel(sizeX: 1, sizeY: 1, sizeZ: 1, voxels: [.init(x: 0, y: 0, z: 0, colorIndex: 1)]) + try VOXWriter().encode(model: model, palette: .fallback).write(to: prototype) + viewport.performTerrainResourceCommand(.mesh(prototype)) + try await support.finish(fixture) + let placed = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest) + #expect(placed.meshPrototypes.count == 1) + #expect(placed.materials == painted.materials) + #expect(placed.meshDocumentFileName == previous) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 2) + + let invalid = fixture.root.appendingPathComponent("Invalid.vox") + try Data("not a VOX".utf8).write(to: invalid) + let accepted = owner.components[Terrain3DSource.self]?.source + let paths = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + viewport.performTerrainResourceCommand(.mesh(invalid)) + try await support.finish(fixture) + #expect(viewport.terrainEditing.sourceFailure != nil) + #expect(owner.components[Terrain3DSource.self]?.source == accepted) + let afterFailure = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest) + #expect(try afterFailure.encodedData() == placed.encodedData()) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 2) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == paths) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaintTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaintTests.swift new file mode 100644 index 000000000..e39bf896d --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaintTests.swift @@ -0,0 +1,70 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor @Suite("Mesh Terrain material gestures", .serialized) +struct EditorMeshTerrainPaintTests { + @Test("The Materials brush and eraser each save one version without replacing geometry") + func paintAndEraseMeshTerrain() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(viewport.selectedEditorID) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: meshID)) + viewport.terrainEditing.sourceActions?.materials() + try await support.finish(fixture) + let palette = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest.materials) + let paint = try #require(palette.layers.first { $0.id != palette.fallbackLayerID }) + let original = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let point = try #require(viewport.project([4, 0, 0], size: viewport.viewportSize)) + let coordinate = TerrainTileCoordinate(x: 0, z: 0) + let queryGeneration = try #require(original.queries?.tileGeneration(at: coordinate)) + + viewport.terrainEditing.dockTab = "Materials" + viewport.terrainEditing.radius = 5 + viewport.terrainEditing.strength = 60 + viewport.terrainEditing.sourceLayerID = paint.id + viewport.terrainEditing.sourceTool = .paint + try #require(viewport.handleTerrainPointer(at: point, began: true)) + try #require(viewport.handleTerrainPointer(at: point, ended: true)) + try await support.finish(fixture) + let painted = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + #expect(painted.asset.document.paint.first?.operation == .paint(layer: paint.id)) + #expect(painted.asset.compiled?.patches.first?.paint?.samples.isEmpty == false) + #expect(painted.queries?.tileGeneration(at: coordinate) == queryGeneration) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + let model = try #require(viewport.sceneModel) + let payload = try #require(model.entities.first { $0.id == meshID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let path = try EditorTerrainSourcePayload.decode(payload).source + let file = try EditorTerrainResource.resolve(path, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + let meshFile = file.deletingLastPathComponent().appendingPathComponent(try #require(manifest.meshDocumentFileName)) + let saved = try MeshTerrainDocumentAsset(data: Data(contentsOf: meshFile)) + #expect(saved.document.paint == painted.asset.document.paint) + + viewport.terrainEditing.sourceTool = .erasePaint + try #require(viewport.handleTerrainPointer(at: point, began: true)) + try #require(viewport.handleTerrainPointer(at: point, ended: true)) + try await support.finish(fixture) + let erased = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + #expect(erased.asset.document.paint.last?.operation == .erase) + #expect(erased.asset.compiled?.patches.first?.paint == nil) + #expect(erased.queries?.tileGeneration(at: coordinate) == queryGeneration) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 2) + #expect(viewport.runtimeEntity(for: meshID) === owner) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaletteTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaletteTests.swift new file mode 100644 index 000000000..9ef13ff04 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPaletteTests.swift @@ -0,0 +1,49 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor @Suite("Mesh Terrain palette authoring", .serialized) +struct EditorMeshTerrainPaletteTests { + @Test("Automatic materials update the selected Mesh Terrain without changing its Heightfield neighbor") + func paletteActionEditsSelectedMesh() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let terrainID = try #require(viewport.selectedEditorID) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: terrainID)) + let resident = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + let model = try #require(viewport.sceneModel) + let legacy = model.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource] + let source = try #require(model.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let uri = try EditorTerrainSourcePayload.decode(source).source + let original = try EditorTerrainResource.resolve(uri, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let bytes = try Data(contentsOf: original) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + viewport.terrainEditing.sourceActions?.materials() + try await support.finish(fixture) + let next = try #require(viewport.sceneModel) + let nextPayload = try #require(next.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let nextURI = try EditorTerrainSourcePayload.decode(nextPayload).source + let file = try EditorTerrainResource.resolve(nextURI, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + #expect(manifest.materials?.layers.count == 3) + #expect(nextURI != uri) + #expect(next.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource] == legacy) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + #expect(viewport.runtimeEntity(for: terrainID) === owner) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == resident.asset.compiled?.inputKey) + #expect(try Data(contentsOf: original) == bytes) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainPopulationBrushTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPopulationBrushTests.swift new file mode 100644 index 000000000..0877347f2 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainPopulationBrushTests.swift @@ -0,0 +1,65 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor @Suite("Mesh Terrain population brush", .serialized) +struct EditorMeshTerrainPopulationBrushTests { + @Test("Mesh Instancing paints generated objects on the selected triangle surface and erases their coverage") + func paintAndEraseMeshTerrainPopulation() async throws { + let support = EditorTerrainSourceViewportTests() + let fixture = try await support.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(viewport.selectedEditorID) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: meshID)) + let modelFile = fixture.root.appendingPathComponent("Grass.vox") + let voxel = VOXModel(sizeX: 1, sizeY: 1, sizeZ: 1, voxels: [.init(x: 0, y: 0, z: 0, colorIndex: 1)]) + try VOXWriter().encode(model: voxel, palette: .fallback).write(to: modelFile) + viewport.performTerrainResourceCommand(.mesh(modelFile)) + try await support.finish(fixture) + let prototype = try #require(owner.components[MeshTerrain3DSourceResources.self]?.manifest.meshPrototypes.first) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let coordinate = TerrainTileCoordinate(x: 0, z: 0) + let original = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.queries?.tileGeneration(at: coordinate)) + let point = try #require(viewport.project([4, 0, 0], size: viewport.viewportSize)) + viewport.terrainEditing.dockTab = "Mesh Instancing" + viewport.terrainEditing.radius = 6 + viewport.terrainEditing.meshDensity = 1 + viewport.terrainEditing.sourcePrototypeID = prototype.id + viewport.terrainEditing.sourceTool = .scatter + try #require(viewport.handleTerrainPointer(at: point, began: true)) + try #require(viewport.handleTerrainPointer(at: point, ended: true)) + try await support.finish(fixture) + let painted = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + let placements = try #require(painted.asset.compiled?.placements) + #expect(!placements.isEmpty) + #expect(placements.allSatisfy { $0.ownership == .generated && $0.prototypeID == prototype.id }) + #expect(painted.asset.populationRules.count == 1) + #expect(painted.queries?.tileGeneration(at: coordinate) == original) + let visible = owner.children.compactMap { $0.components[Mesh3DInstancesComponent.self] }.flatMap(\.instances).map(\.id) + #expect(Set(visible) == Set(placements.map(\.id))) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + + viewport.terrainEditing.sourceTool = .eraseMeshes + try #require(viewport.handleTerrainPointer(at: point, began: true)) + try #require(viewport.handleTerrainPointer(at: point, ended: true)) + try await support.finish(fixture) + let erased = try #require(owner.components[MeshTerrain3DSourceSurface.self]) + #expect(erased.asset.compiled?.placements.isEmpty == true) + #expect(erased.asset.populationRules.first?.id == painted.asset.populationRules.first?.id) + #expect(owner.children.allSatisfy { $0.components[Mesh3DInstancesComponent.self] == nil }) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 2) + #expect(viewport.terrainEditing.sourceFailure == nil) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMeshTerrainSculptTests.swift b/Editor/Tests/AdaEditorTests/EditorMeshTerrainSculptTests.swift new file mode 100644 index 000000000..53b786201 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMeshTerrainSculptTests.swift @@ -0,0 +1,155 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Mesh terrain sculpting", .serialized) +struct EditorMeshTerrainSculptTests { + @Test("Smooth and Flatten edit the selected mesh surface and save one new generation", arguments: [false, true]) + func meshSurfaceSmoothAndFlatten(flatten: Bool) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, meshID, owner) = try await meshFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let center: Vector3 = [4, 0, 0] + let ray = Ray(origin: center + [0, 5, 0], direction: [0, -1, 0]) + func height() throws -> Float { + try #require(owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(ray)?.position.y) + } + func stamp(at center: Vector3) throws { + let point = try #require(viewport.project(center, size: viewport.viewportSize)) + try #require(viewport.handleTerrainPointer(at: point, began: true)) + try #require(viewport.handleTerrainPointer(at: point, ended: true)) + } + viewport.terrainEditing.dockTab = "Sculpt" + viewport.terrainEditing.mode = .raise + viewport.terrainEditing.radius = 1.9 + viewport.terrainEditing.strength = 12 + try stamp(at: center) + try await support.finish(fixture) + let before = try height() + try #require(before > 0.05) + let previous = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + + viewport.terrainEditing.mode = flatten ? .flatten : .smooth + viewport.terrainEditing.radius = 8 + viewport.terrainEditing.strength = 20 + try stamp(at: flatten ? .zero : center + [0, before, 0]) + try await support.finish(fixture) + #expect(try height() < before - 0.008) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(viewport.runtimeEntity(for: meshID) === owner) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey != previous) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history + 1) + } + + @Test("Raise previews the selected triangle surface and saves one mesh terrain version") + func raiseMeshTerrainPublishesBeforeRelease() async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, meshID, owner) = try await meshFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let original = try #require(viewport.sceneModel) + let oldMesh = try #require(original.entities.first { $0.id == meshID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let oldLegacy = try #require(original.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource]) + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + let ray = Ray(origin: [0, 5, 0], direction: [0, -1, 0]) + func height() throws -> Float { + try #require(owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(ray)?.position.y) + } + try #require(try height() == 0) + viewport.terrainEditing.dockTab = "Sculpt" + viewport.terrainEditing.mode = .raise + viewport.terrainEditing.radius = 6 + viewport.terrainEditing.strength = 6 + let point = try #require(viewport.project(.zero, size: viewport.viewportSize)) + #expect(viewport.handleTerrainPointer(at: point, began: true)) + let previewDeadline = ContinuousClock.now.advanced(by: .seconds(6)) + var preview: Float = 0 + while ContinuousClock.now < previewDeadline { + _ = viewport.updateTerrainStroke(deltaTime: 1 / 60) + try await support.pump(fixture, frames: 1) + preview = try height() + if preview > 0.005 { break } + } + #expect(preview > 0.005) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count) + #expect(viewport.handleTerrainPointer(at: point, ended: true)) + try await support.finish(fixture) + #expect(try height() > 0.005) + #expect(viewport.runtimeEntity(for: meshID) === owner) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count + 1) + let updated = try #require(fixture.workbench.activeSceneDocument?.sceneModel) + #expect(updated.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource] == oldLegacy) + let saved = try #require(updated.entities.first { $0.id == meshID }?.components[EditorBuiltInComponentType.terrain3DSource]) + #expect(saved != oldMesh) + } + + @Test("Lower previews a depression and cancel restores the original mesh without files or history", arguments: [false, true]) + func lowerMeshTerrainCancelsWithoutHistory(afterPreview: Bool) async throws { + let support = EditorTerrainSourceViewportTests() + let (fixture, _, owner) = try await meshFixture(support) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let viewport = fixture.viewport + let originalKey = try #require(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey) + let content = viewport.sceneContent + let history = fixture.workbench.sceneUndoHistory["terrain"]?.count + let files = try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() + viewport.terrainEditing.dockTab = "Sculpt" + viewport.terrainEditing.mode = .lower + viewport.terrainEditing.radius = 6 + viewport.terrainEditing.strength = 6 + let point = try #require(viewport.project(.zero, size: viewport.viewportSize)) + try #require(viewport.handleTerrainPointer(at: point, began: true)) + let ray = Ray(origin: [0, 5, 0], direction: [0, -1, 0]) + func height() throws -> Float { + try #require(owner.components[MeshTerrain3DSourceSurface.self]?.queries?.raycast(ray)?.position.y) + } + if afterPreview { + let deadline = ContinuousClock.now.advanced(by: .seconds(6)) + while ContinuousClock.now < deadline { + _ = viewport.updateTerrainStroke(deltaTime: 1 / 60) + try await support.pump(fixture, frames: 1) + if try height() < -0.005 { break } + } + #expect(try height() < -0.005) + } + #expect(viewport.handleTerrainPointer(at: point, cancelled: true)) + if !afterPreview { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while viewport.terrainEditing.isSourceBusy, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } + #expect(!viewport.terrainEditing.isSourceBusy) + } + try await support.finish(fixture) + #expect(try height() == 0) + #expect(owner.components[MeshTerrain3DSourceSurface.self]?.asset.compiled?.inputKey == originalKey) + #expect(owner.components[MeshTerrain3DEdit.self] == nil) + #expect(viewport.terrainEditing.sourceFailure == nil) + #expect(viewport.sceneContent == content) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == history) + #expect(try FileManager.default.subpathsOfDirectory(atPath: fixture.root.path).sorted() == files) + } + + private func meshFixture(_ support: EditorTerrainSourceViewportTests) async throws -> (EditorTerrainSourceViewportTests.Fixture, String, Entity) { + let fixture = try await support.fixture() + let viewport = fixture.viewport + viewport.terrainEditing.creationBackend = .mesh + viewport.terrainEditing.noise.amplitude = 0 + viewport.terrainEditing.sourceActions?.create() + try await support.finish(fixture) + let meshID = try #require(viewport.selectedEditorID) + let loadDeadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < loadDeadline { + try await support.pump(fixture, frames: 1) + if viewport.runtimeEntity(for: meshID)?.components[MeshTerrain3DSourceSurface.self] != nil, + viewport.runtimeEntity(for: fixture.id)?.components[Terrain3DSourceSurface.self] != nil { break } + } + let owner = try #require(viewport.runtimeEntity(for: meshID)) + try #require(owner.components[MeshTerrain3DSourceSurface.self]?.queries != nil) + return (fixture, meshID, owner) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainPickingTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainPickingTests.swift new file mode 100644 index 000000000..8c3ac4e5c --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainPickingTests.swift @@ -0,0 +1,33 @@ +@testable import AdaEditor +@_spi(AdaEngine) import AdaEngine +import Testing + +@MainActor +@Suite("Terrain authored selection") +struct EditorTerrainPickingTests { + @Test("Terrain remains selectable away from its origin when generated meshes belong to children") + func selectsAuthoredTerrain() { + let world = World() + let owner = world.spawn("Terrain") { + Transform(); Terrain3D() + } + let picked = EditorMeshPicking.pick( + ray: Ray(origin: [15, 10, 0], direction: [0, -1, 0]), + world: world, + authoredEntities: ["ground": owner.id], + editorIDs: [owner.id: "ground"], + cameraID: nil + ) + #expect(picked == "ground") + owner.components[Visibility.self] = .hidden + #expect( + EditorMeshPicking.pick( + ray: Ray(origin: [15, 10, 0], direction: [0, -1, 0]), + world: world, + authoredEntities: ["ground": owner.id], + editorIDs: [owner.id: "ground"], + cameraID: nil + ) == nil + ) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainSourceAuthoringTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainSourceAuthoringTests.swift new file mode 100644 index 000000000..847848ecc --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainSourceAuthoringTests.swift @@ -0,0 +1,279 @@ +@_spi(Internal) @testable import AdaApp +@_spi(AdaEngine) @testable import AdaEngine +@_spi(Internal) @testable import AdaRender +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Editor terrain source authoring", .serialized) +struct EditorTerrainSourceAuthoringTests { + @Test("A source stroke saves a private version and leaves the scene's previous resource exact for Undo") + func savedVersion() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let before = try Data(contentsOf: fixture.file) + let original = try #require(fixture.owner.components[Terrain3DSource.self]) + let result = try await perform(fixture, operation: .raise) + let saved = try #require(try? result.get()) + #expect(saved != fixture.file) + #expect(saved.deletingLastPathComponent() == fixture.file.deletingLastPathComponent()) + #expect(try Data(contentsOf: fixture.file) == before) + #expect(try await height(in: fixture.file) == 0) + #expect(try await height(in: saved) > 0.05) + #expect(fixture.owner.components[Terrain3DSource.self]?.source == saved.path) + #expect(original.source == fixture.file.path) + #expect(fixture.owner.components[Terrain3DHeightEdit.self] == nil) + #expect(fixture.owner.components[Terrain3DSave.self] == nil) + } + + @Test("Cancelling before editing or during a filesystem write preserves original assets", arguments: [false, true]) + func cancelledVersion(duringSave: Bool) async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let before = try Data(contentsOf: fixture.file) + let original = fixture.owner.components[Terrain3DSource.self] + let result = try await perform(fixture, operation: .raise, cancel: true, duringSave: duringSave) + guard case let .failure(error) = result else { + #expect(false, "Cancellation must not publish a resource version") + return + } + #expect(error is CancellationError) + let clock = ContinuousClock(), deadline = ContinuousClock().now.advanced(by: .seconds(5)) + while clock.now < deadline { + let files = try FileManager.default.contentsOfDirectory(atPath: fixture.root.path) + if !files.contains(where: { $0.hasPrefix("edit-") && $0.hasSuffix(".aterrain") }) { break } + try await fixture.app.withExclusiveWorldAccess { await fixture.app.main.runScheduler(.preUpdate) } + try await Task.sleep(for: .milliseconds(1)) + } + let remaining = try FileManager.default.contentsOfDirectory(atPath: fixture.root.path) + #expect(!remaining.contains { $0.hasPrefix("edit-") && $0.hasSuffix(".aterrain") }) + #expect(fixture.owner.components[Terrain3DSource.self] == original) + #expect(try Data(contentsOf: fixture.file) == before) + } + + @Test("A no-op gesture creates no resource version or history change") + func unchangedVersion() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let files = try FileManager.default.contentsOfDirectory(atPath: fixture.root.path).sorted() + let original = fixture.owner.components[Terrain3DSource.self] + let result = try await perform(fixture, operation: .smooth) + guard case let .success(saved) = result else { + #expect(false, "Smoothing a flat surface should finish without a changed version") + return + } + #expect(saved == nil) + #expect(fixture.owner.components[Terrain3DSource.self] == original) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.root.path).sorted() == files) + } + + @Test("Replacing the saved root after loading rejects a stroke captured from the earlier surface") + func externalReplacement() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + var changed = Terrain3D(resolution: 65, size: 64) + changed.heights = Array(repeating: 3, count: 65 * 65) + let storage = try TerrainHeightStorage(importing: changed) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -32, originZ: -32), at: fixture.file.path) + let external = try Data(contentsOf: fixture.file) + let result = try await perform(fixture, operation: .raise) + guard case let .failure(error) = result else { + #expect(false, "A captured gesture must not silently adopt an external resource generation") + return + } + if case .sourceChanged? = error as? EditorTerrainSourceAuthoring.Failure {} else { + #expect(false, "The authoring transaction must identify the stale source baseline") + } + #expect(try Data(contentsOf: fixture.file) == external) + #expect(try await height(in: fixture.file) == 3) + } + + @Test("Own image materials, surface paint, imported GLB prototypes and instanced mesh strokes survive saved resource versions") + func resourceTools() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let before = try Data(contentsOf: fixture.file) + let imageFile = fixture.root.appendingPathComponent("custom.png") + let image = Image(width: 2, height: 2, data: Data([UInt8](repeating: 255, count: 16))) + try image.writePNG(to: imageFile) + _ = try #require(try? (await perform(fixture, operation: .raise, command: .texture(imageFile, layerID: 0))).get()) + let material = try #require(fixture.owner.components[Terrain3DSourceSurface.self]?.manifest?.materials) + #expect(material.layers.count == 1) + #expect(material.layers[0].albedoPath != nil) + #expect(material.layers[0].variation.isEnabled) + var paint = TerrainSurfaceBrushSettings(operation: .paintLayer, radius: 3, strength: 1) + paint.layerID = 0 + let points: [TerrainBrushPoint] = [.init(position: .zero, time: 0), .init(position: .zero, time: 0.25)] + _ = try #require(try? (await perform(fixture, operation: .raise, command: .surface(.init(settings: paint, points: points)))).get()) + let paintedStorage = try #require(fixture.owner.components[Terrain3DSourceSurface.self]?.storage) + #expect(paintedStorage.paint(atSample: .init(x: 32, z: 32))?.overrideStrength ?? 0 > 0) + let modelFile = fixture.root.appendingPathComponent("rock.glb") + try modelBytes().write(to: modelFile) + GLTFLoaderResolver.shared.setLoader(NativeGLTFLoader()) + _ = try #require(try? (await perform(fixture, operation: .raise, command: .mesh(modelFile))).get()) + #expect(fixture.owner.components[Terrain3DSourceSurface.self]?.manifest?.meshPrototypes.count == 1) + var mesh = TerrainInstanceBrushSettings(prototypeID: 0, radius: 4, density: 0.2) + mesh.minimumDistance = 0.5 + let result = try #require(try? (await perform(fixture, operation: .raise, command: .instances(.init(settings: mesh, points: points)))).get()) + let cache = try await TerrainRegionCache.open(manifestAt: result.path) + let region = try #require(try await cache.loadRegion(at: .init(x: 0, z: 0))) + #expect(!region.instances.isEmpty) + #expect(try Data(contentsOf: fixture.file) == before) + } + + @Test("Saved volume solids accept material fields and noise generation remains a separate undoable source version") + func volumeAndNoise() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let operation = TerrainVolumeOperation(operation: .union, operand: .primitive(.box(halfExtents: [3, 1, 2])), transform: .init(translation: [0, 3, 0])) + _ = try #require(try? (await perform(fixture, operation: .raise, command: .volume(operation))).get()) + let palette = TerrainMaterialPalette( + layers: [.init(id: 0, name: "Grass"), .init(id: 1, name: "Rock")], + fallbackLayerID: 0, + rules: [.init(layerID: 1, height: .init(minimum: 2, maximum: 20))] + ) + let version = try #require(try? (await perform(fixture, operation: .raise, command: .materials(palette))).get()) + #expect(fixture.owner.components[Terrain3DSourceStatus.self]?.isReady == true) + #expect(fixture.owner.components[Terrain3DSourceSurface.self]?.manifest?.materials?.layers.count == 2) + let oldRoot = try Data(contentsOf: version) + let generated = try #require(try? (await perform(fixture, operation: .raise, command: .generate(.init(seed: 41, amplitude: 5, frequency: 0.04)))).get()) + #expect(try await height(in: generated) != 0) + #expect(try Data(contentsOf: version) == oldRoot) + } + + private func modelBytes() throws -> Data { + let positions: [Float] = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1] + let indices: [UInt16] = [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3] + var binary = positions.withUnsafeBytes { Data($0) } + binary.append(indices.withUnsafeBytes { Data($0) }) + let object: [String: Any] = [ + "asset": ["version": "2.0"], "scene": 0, "scenes": [["nodes": [0]]], "nodes": [["mesh": 0]], + "buffers": [["byteLength": binary.count]], + "bufferViews": [["buffer": 0, "byteOffset": 0, "byteLength": 48], ["buffer": 0, "byteOffset": 48, "byteLength": 24]], + "accessors": [ + ["bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 1]], + ["bufferView": 1, "componentType": 5123, "count": 12, "type": "SCALAR"], + ], + "meshes": [["primitives": [["attributes": ["POSITION": 0], "indices": 1]]]], + ] + var json = try JSONSerialization.data(withJSONObject: object, options: .sortedKeys) + while !json.count.isMultiple(of: 4) { json.append(32) } + var result = Data() + func append(_ value: UInt32) { + var word = value.littleEndian + withUnsafeBytes(of: &word) { result.append(contentsOf: $0) } + } + append(0x46546c67); append(2); append(UInt32(12 + 8 + json.count + 8 + binary.count)) + append(UInt32(json.count)); append(0x4e4f534a); result.append(json) + append(UInt32(binary.count)); append(0x004e4942); result.append(binary) + return result + } + + private struct Fixture { + let root: URL + let file: URL + let app: AppWorlds + let owner: Entity + } + + private func fixture() async throws -> Fixture { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent("TerrainAuthoring-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("original.aterrain") + let storage = try TerrainHeightStorage(importing: Terrain3D(resolution: 65, size: 64)) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -32, originZ: -32), at: file.path) + let app = AppWorlds(main: World(name: "Terrain authoring")) + app.addPlugin(MainSchedulerPlugin()).addPlugin(TransformPlugin()).addPlugin(RenderWorldPlugin()).addPlugin(Model3DPlugin()) + try await app.build() + let owner = app.main.spawn("Terrain") { + Transform() + Terrain3DSource(source: file.path, generatesCollision: false, streamingRadius: nil, queries: .init()) + } + let clock = ContinuousClock(), deadline = ContinuousClock().now.advanced(by: .seconds(10)) + while clock.now < deadline { + try await app.withExclusiveWorldAccess { await app.main.runScheduler(.preUpdate) } + if owner.components[Terrain3DSourceStatus.self]?.isReady == true { break } + try await Task.sleep(for: .milliseconds(1)) + } + try #require(owner.components[Terrain3DSourceSurface.self] != nil) + return Fixture(root: root, file: file, app: app, owner: owner) + } + + private final class Pending { + var task: Task? + var result: Result? + } + + private func perform( + _ fixture: Fixture, + operation: TerrainHeightBrushOperation, + cancel: Bool = false, + duringSave: Bool = false, + command: EditorTerrainStrokeCommand? = nil + ) async throws -> Result { + let pending = Pending() + let storage = try #require(fixture.owner.components[Terrain3DSourceSurface.self]?.storage) + let revision = storage.revision + let settings = TerrainHeightBrushSettings(operation: operation, radius: 2, strength: 2) + let brush = TerrainHeightBrushStroke(settings: settings, points: [.init(position: .zero, time: 0), .init(position: .zero, time: 0.2)]) + pending.task = Task { @MainActor in + do { + let saved = try await EditorTerrainSourceAuthoring.applyHeightStroke( + to: fixture.owner, + application: fixture.app, + expectedStorage: storage, + expectedRevision: revision, + brush: brush, + command: command, + assetsRoot: fixture.root, + onProgress: { phase in + if cancel { + switch phase { + case .preparing where !duringSave: pending.task?.cancel() + case .writing where duringSave: pending.task?.cancel() + default: break + } + } + } + ) + pending.result = .success(saved) + } catch { + print("TERRAIN_AUTHORING_ERROR", String(reflecting: error)) + pending.result = .failure(error) + } + } + let clock = ContinuousClock(), deadline = ContinuousClock().now.advanced(by: .seconds(15)) + while pending.result == nil, clock.now < deadline { + try await fixture.app.withExclusiveWorldAccess { await fixture.app.main.runScheduler(.preUpdate) } + try await Task.sleep(for: .milliseconds(1)) + } + if pending.result == nil { pending.task?.cancel() } + return try #require(pending.result) + } + + private func height(in file: URL) async throws -> Float { + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + let manifest = await cache.manifest + var regions: [TerrainRegionAsset] = [] + for reference in manifest.heightRegions { + if let region = try await cache.loadRegion(at: .init(x: Int(reference.x), z: Int(reference.z))) { regions.append(region) } + } + let storage = try TerrainHeightStorage(regions: regions, spacing: manifest.spacing, origin: [manifest.originX, manifest.originZ]) + return try #require(storage.height(at: .zero)) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainSourceOptionalTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainSourceOptionalTests.swift new file mode 100644 index 000000000..cb3072093 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainSourceOptionalTests.swift @@ -0,0 +1,31 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Editor terrain optional residency", .serialized) +struct EditorTerrainSourceOptionalTests { + @Test("Omitted nil radius retains full-set residency through codecs and scene reopening", arguments: [false, true]) + func optionalRadius(fullSet: Bool) throws { + EditorComponentRegistry.registerBuiltIns() + let source = Terrain3DSource(streamingRadius: fullSet ? nil : 12) + let encoded = try JSONDecoder().decode(EditorComponentPayload.self, from: JSONEncoder().encode(source)) + let name = String(reflecting: Terrain3DSource.self) + for payload in [encoded, EditorTerrainSourcePayload.encode(source)] { + #expect(try EditorTerrainSourcePayload.decode(payload).streamingRadius == source.streamingRadius) + var model = EditorSceneModel.default(projectName: "Terrain") + let id = model.addEntity(name: "Terrain", parentID: nil).id + let index = try #require(model.entities.firstIndex { $0.id == id }) + model.entities[index].components[name] = payload + let world = World(name: "Terrain optional") + let loaded = EditorSceneFileLoader.load(content: try model.encodedYAML(), into: world, loadsPhysicsBodies: false) + let entity = try #require(loaded.entitiesByEditorID[id].flatMap { world.getEntityByID($0) }) + #expect(entity.components[Terrain3DSource.self]?.streamingRadius == source.streamingRadius) + } + let descriptor = try #require(EditorComponentRegistry.descriptor(named: name)) + let defaults = try #require(try descriptor.decode(descriptor.makeDefaultPayload()) as? Terrain3DSource) + #expect(defaults.streamingRadius == 384, "New components still retain the engine's explicit camera-streaming default") + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainSourcePickingTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainSourcePickingTests.swift new file mode 100644 index 000000000..e9896d713 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainSourcePickingTests.swift @@ -0,0 +1,68 @@ +@_spi(Internal) @testable import AdaApp +@_spi(AdaEngine) @testable import AdaEngine +@_spi(Internal) @testable import AdaRender +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Editor terrain surface picking", .serialized) +struct EditorTerrainSourcePickingTests { + @Test("Generated terrain selects its authored owner and orders hits in world distance", arguments: [false, true]) + func sourcePicking(reflected: Bool) async throws { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent("TerrainPick-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let storage = try TerrainHeightStorage(importing: Terrain3D(resolution: 3, size: 2)) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + let file = root.appendingPathComponent("world.aterrain") + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -1, originZ: -1), at: file.path) + let app = AppWorlds(main: World(name: "Terrain picking")) + app.addPlugin(MainSchedulerPlugin()).addPlugin(TransformPlugin()).addPlugin(RenderWorldPlugin()).addPlugin(Model3DPlugin()) + try await app.build() + let owner = app.main.spawn("Terrain") { + Transform(scale: [3, reflected ? -2 : 2, 0.5], position: [10, 5, 0]) + Terrain3DSource(source: file.path, generatesCollision: false, streamingRadius: nil, queries: .init()) + } + let clock = ContinuousClock(), deadline = ContinuousClock().now.advanced(by: .seconds(10)) + while clock.now < deadline { + await app.main.runScheduler(.preUpdate) + if owner.components[Terrain3DSourceStatus.self]?.isReady == true { break } + try await Task.sleep(for: .milliseconds(1)) + } + try #require(owner.components[Terrain3DSourceSurface.self]?.queries != nil) + let ray = Ray(origin: [12.1, 20, 0.35], direction: [0, -1, 0]) + #expect(EditorMeshPicking.pick(ray: ray, world: app.main, authoredEntities: ["terrain": owner.id], editorIDs: [owner.id: "terrain"], cameraID: nil) == "terrain", + "A hit away from the transform origin must select the authored terrain through its committed surface") + let engine = try #require(unsafe RenderEngine.shared) + let cube = app.main.spawn("Closer cube") { + Transform(position: [12.1, 10, 0.35]) + Mesh3DComponent(mesh: Mesh.generateCube(renderDevice: engine.renderDevice), materials: [PBRMaterial()]) + } + let selected = EditorMeshPicking.pick( + ray: ray, + world: app.main, + authoredEntities: ["terrain": owner.id, "cube": cube.id], + editorIDs: [owner.id: "terrain", cube.id: "cube"], + cameraID: nil + ) + #expect(selected == "cube", "Local normalized terrain distance must not override a closer world-space object") + let missed = EditorMeshPicking.pick( + ray: Ray(origin: [30, 20, 0], direction: [0, -1, 0]), + world: app.main, + authoredEntities: ["terrain": owner.id], + editorIDs: [owner.id: "terrain"], + cameraID: nil + ) + #expect(missed == nil) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainSourceTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainSourceTests.swift new file mode 100644 index 000000000..ad066bd60 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainSourceTests.swift @@ -0,0 +1,83 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Editor terrain source", .serialized) +struct EditorTerrainSourceTests { + @Test("Terrain Source is available in the Inspector and its defaults decode") + func inspector() throws { + EditorComponentRegistry.registerBuiltIns() + let name = String(reflecting: Terrain3DSource.self) + let descriptor = try #require(EditorComponentRegistry.descriptor(named: name)) + #expect(descriptor.fields.contains { $0.key == "source" && $0.kind == .assetReference }) + #expect(descriptor.fields.contains { $0.key == "generatesCollision" && $0.kind == .bool }) + let source = try #require(try descriptor.decode(descriptor.makeDefaultPayload()) as? Terrain3DSource) + #expect(source.source.isEmpty) + #expect(source.generatesCollision) + } + + @Test("Portable scene references reopen in edit and Play without leaking preview settings", arguments: [false, true]) + func reopen(play: Bool) async throws { + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("TerrainEditor-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let assets = root.appendingPathComponent("Assets", isDirectory: true) + let directory = assets.appendingPathComponent("Terrain", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let terrainFile = directory.appendingPathComponent("world.aterrain") + let storage = try TerrainHeightStorage(importing: Terrain3D(resolution: 3, size: 2)) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -1, originZ: -1), at: terrainFile.path) + let name = String(reflecting: Terrain3DSource.self) + var model = EditorSceneModel.default(projectName: "Terrain") + let id = model.addEntity(name: "Ground", parentID: nil).id + let index = try #require(model.entities.firstIndex { $0.id == id }) + let source = Terrain3DSource(source: "@res://Terrain/world.aterrain") + model.entities[index].components[name] = try JSONDecoder().decode(EditorComponentPayload.self, from: JSONEncoder().encode(source)) + let content = try model.encodedYAML() + let world = World(name: "Editor terrain") + let loaded = EditorSceneFileLoader.load( + content: content, + into: world, + loadsScriptableObjects: play, + loadsPhysicsBodies: play, + sourceURL: assets.appendingPathComponent("Main.ascn"), + resourceRootURL: assets + ) + #expect(loaded.warnings.isEmpty, Comment(rawValue: loaded.warnings.joined(separator: "\n"))) + let entity = try #require(loaded.entitiesByEditorID[id].flatMap { world.getEntityByID($0) }) + let component = try #require(entity.components[Terrain3DSource.self]) + #expect(component.source == terrainFile.standardizedFileURL.path) + #expect(component.generatesCollision == play) + if !play { #expect(component.queries != nil) } + let reopened = try EditorSceneModel.decode(from: content) + let payload = try #require(reopened.entities.first { $0.id == id }?.components[name]) + #expect(payload["source"] == .string("@res://Terrain/world.aterrain")) + #expect(payload["generatesCollision"] == .bool(true)) + } + + @Test("Terrain paths cannot leave project Assets, use other formats or resolve missing files") + func resourceBoundary() throws { + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("TerrainBoundary-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let assets = root.appendingPathComponent("Assets", isDirectory: true) + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: root.appendingPathComponent("outside.aterrain")) + let name = String(reflecting: Terrain3DSource.self) + for reference in ["@res://../outside.aterrain", "../outside.aterrain", "@res://missing.aterrain", "@res://wrong.png"] { + #expect(throws: (any Error).self) { + try EditorSceneFileLoader.resolvedComponentPayload( + typeName: name, + payload: ["source": .string(reference)], + sourceURL: assets.appendingPathComponent("Main.ascn"), + resourceRootURL: assets + ) + } + } + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainSourceViewportTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainSourceViewportTests.swift new file mode 100644 index 000000000..30a29a471 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainSourceViewportTests.swift @@ -0,0 +1,524 @@ +@_spi(Internal) @testable import AdaApp +@_spi(AdaEngine) @testable import AdaEngine +import AdaInput +@_spi(Internal) @testable import AdaRender +@_spi(Internal) import AdaUI +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Editor terrain source viewport", .serialized) +struct EditorTerrainSourceViewportTests { + @Test("The Joining tab preserves ordinary source selection and transform handles", arguments: [false, true]) + func joiningTabKeepsSelectionAndGizmosAvailable(touch: Bool) async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + var model = try #require(fixture.viewport.sceneModel) + let source = model.addEntity(template: .model3D, parentID: nil) + let index = try #require(model.entities.firstIndex { $0.id == source.id }) + model.entities[index].components[EditorBuiltInComponentType.transform]?["position"] = .array([.double(0), .double(1), .double(0)]) + fixture.viewport.sceneModel = model + _ = fixture.viewport.reloadSceneRuntimeIfReady() + fixture.viewport.selectedEditorID = nil + fixture.viewport.setActiveTool(.select) + try await pump(fixture, frames: 8) + let container = UIContainerView(rootView: EditorTerrainDockPanel(state: fixture.viewport.terrainEditing, onChange: {}).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1_100, height: 280) + container.layoutSubviews() + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.Tab.Joining")) + let projected = try #require(fixture.viewport.project(Vector3(0, 1, 0), size: fixture.viewport.viewportSize)) + let point = Point(projected.x, projected.y) + if touch { + _ = fixture.viewport.handleTouchEvent(TouchEvent(window: .empty, location: point, phase: .began, time: 0)) + _ = fixture.viewport.handleTouchEvent(TouchEvent(window: .empty, location: point, phase: .ended, time: 0.1)) + } else { + _ = fixture.viewport.handleMouseEvent(MouseEvent(window: .empty, button: .left, mousePosition: point, phase: .began, modifierKeys: [], time: 0)) + _ = fixture.viewport.handleMouseEvent(MouseEvent(window: .empty, button: .left, mousePosition: point, phase: .ended, modifierKeys: [], time: 0.1)) + } + #expect(fixture.viewport.selectedEditorID == source.id) + #expect(!fixture.viewport.terrainEditing.isSourceBusy) + if fixture.viewport.terrainEditing.isSourceBusy { + fixture.viewport.endTerrainStroke(cancelled: true) + try await finish(fixture) + } + fixture.viewport.setActiveTool(.translate) + #expect(fixture.viewport.transformGizmo() != nil) + } + + @Test("Joining a selected scene mesh publishes a portable compiled terrain and one Undo record") + func joinSelectedSceneMesh() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.viewport.terrainEditing.creationBackend = .mesh + fixture.viewport.terrainEditing.noise.amplitude = 0 + fixture.viewport.terrainEditing.sourceActions?.create() + try await finish(fixture) + let terrainID = try #require(fixture.viewport.selectedEditorID) + var model = try #require(fixture.viewport.sceneModel) + let source = model.addEntity(template: .model3D, parentID: nil) + let index = try #require(model.entities.firstIndex { $0.id == source.id }) + model.entities[index].components[EditorBuiltInComponentType.mesh3D]?["size"] = .double(2) + fixture.viewport.sceneModel = model + fixture.viewport.publishTerrainDocument() + _ = fixture.viewport.reloadSceneRuntimeIfReady() + fixture.viewport.selectedEditorID = source.id + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline { + try await pump(fixture, frames: 1) + if try await fixture.app.withWorldAccess({ fixture.viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self] != nil }) { break } + } + let before = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let oldFile = try EditorTerrainResource.resolve(try EditorTerrainSourcePayload.decode(before).source, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let oldData = try Data(contentsOf: oldFile) + let count = fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 + fixture.viewport.refreshTerrainSourceControls() + fixture.viewport.terrainEditing.sourceActions?.join?() + try await finish(fixture) + let changed = try #require(fixture.viewport.sceneModel) + let payload = changed.entities.first { $0.id == source.id }?.components[String(reflecting: MeshTerrain3DBinding.self)] + #expect(payload != nil) + guard let payload else { return } + let binding = try JSONDecoder().decode(MeshTerrain3DBinding.self, from: JSONEncoder().encode(payload)) + let identity = try #require(changed.entities.first { $0.id == terrainID }?.components[String(reflecting: MeshTerrain3DIdentity.self)]) + #expect(binding.target.uuidString == identity["id"]?.stringValue) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == count + 1) + let next = try #require(changed.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource]) + let uri = try EditorTerrainSourcePayload.decode(next).source + #expect(uri != (try EditorTerrainSourcePayload.decode(before).source)) + #expect(uri.hasPrefix("@res://edit-")) + let file = try EditorTerrainResource.resolve(uri, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + let name = try #require(manifest.meshDocumentFileName) + let asset = try MeshTerrainDocumentAsset(data: Data(contentsOf: file.deletingLastPathComponent().appendingPathComponent(name))) + #expect(try asset.acceptedCompiledProduct().patches.count == 1) + #expect(asset.document.modifiers.first?.id == binding.modifier) + #expect(try Data(contentsOf: oldFile) == oldData) + try await pump(fixture, frames: 8) + try await fixture.app.withWorldAccess { + let root = try #require(fixture.viewport.runtimeEntity(for: source.id)) + #expect(root.components[MeshTerrain3DSourceMask.self] != nil) + #expect(root.components[MeshTerrain3DBindingStatus.self]?.phase == .applied) + let surface = try #require(fixture.viewport.runtimeEntity(for: terrainID)?.components[MeshTerrain3DSourceSurface.self]) + #expect(try surface.queries?.raycast(.init(origin: [0, 5, 0], direction: [0, -1, 0]))?.position.y == 1) + } + #expect(fixture.workbench.performDocumentHistory(redo: false)) + let undone = try #require(fixture.workbench.activeSceneDocument?.sceneModel) + #expect(undone.entities.first { $0.id == source.id }?.components[String(reflecting: MeshTerrain3DBinding.self)] == nil) + #expect(undone.entities.first { $0.id == terrainID }?.components[EditorBuiltInComponentType.terrain3DSource] == before) + #expect(fixture.workbench.performDocumentHistory(redo: true)) + #expect(fixture.workbench.saveActiveDocument()) + #expect(try EditorSceneModel.decode(from: String(contentsOf: fixture.scene, encoding: .utf8)).entities == changed.entities) + } + + @Test("Create Mesh Terrain writes a compiled portable mesh document and a stable target identity") + func createMeshTerrain() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let original = try Data(contentsOf: fixture.file) + fixture.viewport.terrainEditing.creationBackend = .mesh + fixture.viewport.terrainEditing.sourceActions?.create() + try await finish(fixture) + let model = try #require(fixture.viewport.sceneModel) + let id = try #require(fixture.viewport.selectedEditorID) + let entity = try #require(model.entities.first { $0.id == id }) + let payload = try #require(entity.components[EditorBuiltInComponentType.terrain3DSource]) + let source = try EditorTerrainSourcePayload.decode(payload) + let file = try EditorTerrainResource.resolve(source.source, sourceURL: fixture.scene, resourceRootURL: fixture.root) + let manifest = try TerrainManifestAsset(data: Data(contentsOf: file)) + #expect(manifest.meshDocumentFileName != nil) + guard let name = manifest.meshDocumentFileName else { return } + let document = try MeshTerrainDocumentAsset(data: Data(contentsOf: file.deletingLastPathComponent().appendingPathComponent(name))) + #expect(try document.acceptedCompiledProduct().patches.count > 0) + #expect(entity.components[String(reflecting: MeshTerrain3DIdentity.self)] != nil) + #expect(manifest.heightRegions.isEmpty) + #expect(try Data(contentsOf: fixture.file) == original) + let decoded = try EditorSceneModel.decode(from: model.encodedYAML()) + #expect(decoded.entities.first { $0.id == id }?.components[EditorBuiltInComponentType.terrain3DSource] == payload) + } + + @Test("The native terrain dock chooses Mesh Terrain and creates its real resource") + func backendChoiceCreatesMeshTerrain() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.viewport.terrainEditing.creationBackend = .heightfield + let previousPresenter = ContextMenuPresentationCenter.present + var presentation: ContextMenuPresentation? + ContextMenuPresentationCenter.present = { presentation = $0 } + defer { ContextMenuPresentationCenter.present = previousPresenter } + let container = UIContainerView(rootView: EditorTerrainDockPanel(state: fixture.viewport.terrainEditing, onChange: {}).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1_000, height: 280) + container.layoutSubviews() + let selector = UINodeSelector.accessibilityIdentifier("AdaEditor.Terrain.Backend") + #expect(container.uiFindNodes(matching: selector).count == 1) + guard !container.uiFindNodes(matching: selector).isEmpty else { return } + _ = try container.uiTapNode(matching: selector) + let menu = try #require(presentation) + let choose = try #require(menu.items.first { $0.title == L10n.text("Mesh Terrain") }?.action) + choose() + #expect(fixture.viewport.terrainEditing.creationBackend == .mesh) + container.update(1 / 60); container.layoutIfNeeded() + presentation = nil + _ = try container.uiTapNode(matching: selector) + #expect(presentation?.items.filter(\.isSelected).map(\.title) == [L10n.text("Mesh Terrain")]) + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.Create")) + try await finish(fixture) + let model = try #require(fixture.viewport.sceneModel) + let id = try #require(fixture.viewport.selectedEditorID) + let payload = try #require(model.entities.first { $0.id == id }?.components[EditorBuiltInComponentType.terrain3DSource]) + let source = try EditorTerrainSourcePayload.decode(payload) + let file = try EditorTerrainResource.resolve(source.source, sourceURL: fixture.scene, resourceRootURL: fixture.root) + #expect(try TerrainManifestAsset(data: Data(contentsOf: file)).meshDocumentFileName != nil) + } + + @Test("Viewport source strokes publish one portable history edit; Undo, Redo and scene reopen retain heights") + func history() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let original = try Data(contentsOf: fixture.file) + let point = try screenPoint(fixture) + #expect(fixture.viewport.handleTerrainPointer(at: point, began: true)) + try await pump(fixture, frames: 8) + for _ in 0..<8 { _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) } + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 == 0) + fixture.viewport.endTerrainStroke() + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == 1) + let changed = try #require(fixture.workbench.activeSceneDocument?.sceneModel) + let payload = try #require(changed.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource]) + let source = try EditorTerrainSourcePayload.decode(payload) + #expect(source.source.hasPrefix("@res://edit-")) + #expect(try Data(contentsOf: fixture.file) == original) + let file = try EditorTerrainResource.resolve(source.source, sourceURL: fixture.scene, resourceRootURL: fixture.root) + #expect(try await height(in: file) > 0.05) + #expect(fixture.workbench.performDocumentHistory(redo: false)) + let undone = try #require(fixture.workbench.activeSceneDocument?.sceneModel?.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource]) + #expect(try EditorTerrainSourcePayload.decode(undone).source == "@res://original.aterrain") + #expect(try await height(in: fixture.file) == 0) + #expect(fixture.workbench.performDocumentHistory(redo: true)) + #expect(fixture.workbench.saveActiveDocument()) + let reopened = try EditorSceneModel.decode(from: String(contentsOf: fixture.scene, encoding: .utf8)) + #expect(reopened.entities == changed.entities) + } + + @Test("Escape cancels a source gesture without a history record or private resource") + func escape() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let point = try screenPoint(fixture) + _ = fixture.viewport.handleTerrainPointer(at: point, began: true) + try await pump(fixture, frames: 8) + _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) + #expect(fixture.viewport.handleKeyEvent(KeyEvent(window: RID(), keyCode: .escape, modifiers: [], status: .down, time: 0, isRepeated: false))) + fixture.viewport.endTerrainStroke() + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 == 0) + #expect(try await height(in: fixture.file) == 0) + let files = try FileManager.default.contentsOfDirectory(atPath: fixture.root.path) + #expect(!files.contains { $0.hasPrefix("edit-") && $0.hasSuffix(".aterrain") }) + } + + @Test("Selection-only configuration during source save does not discard the completed gesture") + func selection() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + _ = fixture.viewport.handleTerrainPointer(at: try screenPoint(fixture), began: true) + try await pump(fixture, frames: 8) + for _ in 0..<8 { _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) } + fixture.viewport.endTerrainStroke() + var model = try #require(fixture.viewport.sceneModel) + model.editor?.selectedEntity = nil + configure(fixture.viewport, model: model, root: fixture.root, scene: fixture.scene, workbench: fixture.workbench) + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == 1) + #expect(fixture.viewport.sceneModel?.editor?.selectedEntity == nil) + } + + @Test("Hidden terrain and hidden ancestors cannot receive source strokes", arguments: [false, true]) + func hidden(parentHidden: Bool) async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try await fixture.app.withWorldAccess { + let owner = try #require(fixture.viewport.runtimeEntity(for: fixture.id)) + if parentHidden { + let parent = fixture.app.main.spawn("Hidden terrain group") { Transform(); Visibility.hidden } + parent.addChild(owner) + } else { + owner.components[Visibility.self] = .hidden + } + } + _ = fixture.viewport.handleTerrainPointer(at: try screenPoint(fixture), began: true) + try await pump(fixture, frames: 8) + for _ in 0..<8 { _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) } + fixture.viewport.endTerrainStroke() + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 == 0) + #expect(try await height(in: fixture.file) == 0) + } + + @Test("A nearer legacy patch remains brushable in a mixed source scene without selecting it first") + func mixedTerrain() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + var model = try #require(fixture.viewport.sceneModel) + let id = model.addEntity(name: "Legacy patch", parentID: nil).id + let index = try #require(model.entities.firstIndex { $0.id == id }) + var terrain = Terrain3D(resolution: 65, size: 64) + terrain.heights = Array(repeating: 2, count: 65 * 65) + model.entities[index].components[EditorTerrainPayload.typeName] = EditorTerrainPayload.encode(terrain) + model.editor?.selectedEntity = nil + configure(fixture.viewport, model: model, root: fixture.root, scene: fixture.scene, workbench: fixture.workbench) + let point = try #require(fixture.viewport.project(Vector3(0, 2, 0), size: fixture.viewport.viewportSize)) + _ = fixture.viewport.handleTerrainPointer(at: Point(x: point.x, y: point.y), began: true) + try await pump(fixture, frames: 8) + for _ in 0..<8 { _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) } + fixture.viewport.endTerrainStroke() + try await finish(fixture) + let updated = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == id }?.components[EditorTerrainPayload.typeName]) + #expect(EditorTerrainPayload.decode(updated)?.heights.contains(where: { $0 > 2.05 }) == true) + let source = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource]) + #expect(try EditorTerrainSourcePayload.decode(source).source == "@res://original.aterrain") + } + + @Test("A changed incoming legacy document survives cancellation of its old live stroke") + func incomingLegacyDocument() throws { + let viewport = EditorSceneViewportModel() + var model = EditorSceneModel.default(projectName: "Legacy") + let id = model.addEntity(name: "Ground", parentID: nil).id + let index = try #require(model.entities.firstIndex { $0.id == id }) + model.entities[index].components[EditorTerrainPayload.typeName] = EditorTerrainPayload.encode(Terrain3D(resolution: 17, size: 16)) + viewport.configure(sceneContent: try model.encodedYAML(), model: model, onSelectionChanged: { _ in }, onDocumentContentChanged: { _ in }) + viewport.setViewportSize(Size(width: 800, height: 600)) + viewport.setTerrainEditing(true) + viewport.perspectiveBlend = 1 + let point = try #require(viewport.project(.zero, size: viewport.viewportSize)) + _ = viewport.handleTerrainPointer(at: Point(x: point.x, y: point.y), began: true) + _ = viewport.updateTerrainStroke(deltaTime: 0.05) + var incoming = Terrain3D(resolution: 17, size: 16) + incoming.heights = Array(repeating: 5, count: 17 * 17) + incoming.color = Vector4(0.1, 0.3, 0.7, 1) + model.entities[index].components[EditorTerrainPayload.typeName] = EditorTerrainPayload.encode(incoming) + viewport.configure(sceneContent: try model.encodedYAML(), model: model, onSelectionChanged: { _ in }, onDocumentContentChanged: { _ in }) + #expect(viewport.sceneModel?.entities[index].components[EditorTerrainPayload.typeName] == EditorTerrainPayload.encode(incoming)) + } + + @Test("A short source gesture released before asynchronous capture still applies one undoable stroke") + func shortGesture() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let point = try screenPoint(fixture) + _ = fixture.viewport.handleTerrainPointer(at: point, began: true) + _ = fixture.viewport.handleTerrainPointer(at: point, ended: true) + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == 1) + let payload = try #require(fixture.viewport.sceneModel?.entities.first { $0.id == fixture.id }?.components[EditorBuiltInComponentType.terrain3DSource]) + let source = try EditorTerrainSourcePayload.decode(payload) + let file = try EditorTerrainResource.resolve(source.source, sourceURL: fixture.scene, resourceRootURL: fixture.root) + #expect(try await height(in: file) > 0) + } + + @Test("Source heights visibly change while the pointer is still held, before the single history commit") + func drawingIsLive() async throws { + let fixture = try await fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + _ = fixture.viewport.handleTerrainPointer(at: try screenPoint(fixture), began: true) + let deadline = ContinuousClock().now.advanced(by: .seconds(8)) + var changed = false + while ContinuousClock().now < deadline { + try await pump(fixture, frames: 1) + _ = fixture.viewport.updateTerrainStroke(deltaTime: 0.05) + changed = try await fixture.app.withWorldAccess { + (fixture.viewport.runtimeEntity(for: fixture.id)?.components[Terrain3DSourceSurface.self]?.storage.height(at: .zero) ?? 0) > 0.05 + } + if changed { break } + } + #expect(changed, "The running edit world must show the stroke before mouse release") + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count ?? 0 == 0) + #expect(try await height(in: fixture.file) == 0) + fixture.viewport.endTerrainStroke() + try await finish(fixture) + #expect(fixture.workbench.sceneUndoHistory["terrain"]?.count == 1) + } + + @Test("Source hover draws a surface ring inside the viewport and invalidates cached annotations", arguments: [false, true]) + func visibleBrush(noisy: Bool) async throws { + let fixture = try await fixture(noisy: noisy) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let presentation = EditorViewportPresentation() + presentation.prepareFrame(viewportModel: fixture.viewport, theme: .adaEditor)() + let previous = presentation.presentedAnnotations?.generation + let target = try await fixture.app.withWorldAccess { + let surface = try #require(fixture.viewport.runtimeEntity(for: fixture.id)?.components[Terrain3DSourceSurface.self]) + let height = try #require(surface.storage.height(at: Vector2(0.37, 0.41))) + return Vector3(0.37, height, 0.41) + } + let projected = try #require(fixture.viewport.project(target, size: fixture.viewport.viewportSize)) + fixture.viewport.terrainEditing.radius = 0.5 + _ = fixture.viewport.handleTerrainPointer(at: Point(x: projected.x, y: projected.y)) + try await pump(fixture, frames: 8) + var context = UIGraphicsContext() + fixture.viewport.drawTerrainBrush(in: &context, size: fixture.viewport.viewportSize, theme: .adaEditor) + let lines = context.getDrawCommands().compactMap { command -> (Vector3, Vector3)? in + if case let .drawLine(start, end, _, _) = command { + return (start, end) + } + return nil + } + #expect(lines.count == 48) + #expect(lines.allSatisfy { $0.0.y < 0 && $0.1.y < 0 }) + presentation.prepareFrame(viewportModel: fixture.viewport, theme: .adaEditor)() + #expect(presentation.presentedAnnotations?.generation != previous) + } + + struct Fixture { + let root: URL + let file: URL + let scene: URL + let app: AppWorlds + let viewport: EditorSceneViewportModel + let workbench: EditorWorkbenchViewModel + let id: String + } + + @Test("Brush hover reaches a boolean solid face above the heightmap") + func volumeBrush() async throws { + let fixture = try await fixture(volume: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.viewport.terrainEditing.sourceTool = .paint + fixture.viewport.terrainEditing.radius = 0.5 + let point = try #require(fixture.viewport.project(Vector3(0, 2, -4), size: fixture.viewport.viewportSize)) + _ = fixture.viewport.handleTerrainPointer(at: Point(x: point.x, y: point.y)) + try await pump(fixture, frames: 8) + let hover = try #require(fixture.viewport.terrainEditing.hover) + #expect(hover.point.y > 1) + var context = UIGraphicsContext() + fixture.viewport.drawTerrainBrush(in: &context, size: fixture.viewport.viewportSize, theme: .adaEditor) + #expect(!context.getDrawCommands().isEmpty) + } + + func fixture(noisy: Bool = false, volume: Bool = false) async throws -> Fixture { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("TerrainViewport-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("original.aterrain"), scene = root.appendingPathComponent("scene.ascn") + var terrain = Terrain3D(resolution: 65, size: 64) + if noisy { + var noise = EditorTerrainNoise() + noise.frequency = 0.35 + for z in 0..<65 { + for x in 0..<65 { + terrain.heights[z * 65 + x] = noise.height(x: Float(x - 32), z: Float(z - 32)) + } + } + } + let storage = try TerrainHeightStorage(importing: terrain) + var regions: [TerrainRegionAsset] = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + let volumeSave: TerrainVolumeSave? = volume ? .init( + document: .init(cellSize: 1, origin: [-32, 0, -32], operations: [ + .init(operation: .union, operand: .primitive(.box(halfExtents: [4, 3, 4])), transform: .init(translation: [0, 2, 0])) + ]), + bricks: [] + ) : nil + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: .init(spacing: 1, originX: -32, originZ: -32), at: file.path, volume: volumeSave) + var model = EditorSceneModel.default(projectName: "Terrain") + let id = model.addEntity(name: "Terrain", parentID: nil).id + let index = try #require(model.entities.firstIndex { $0.id == id }) + model.entities[index].components[EditorBuiltInComponentType.terrain3DSource] = EditorTerrainSourcePayload.encode( + Terrain3DSource(source: "@res://original.aterrain", generatesCollision: false, streamingRadius: nil, queries: .init()) + ) + let content = try model.encodedYAML() + try content.write(to: scene, atomically: true, encoding: .utf8) + let document = EditorSceneDocument( + id: "terrain", + title: "Terrain", + relativePath: "scene.ascn", + absolutePath: scene.path, + content: content, + lastSavedContent: content, + sceneModel: model, + errorMessage: nil, + isDirty: false, + statusMessage: nil, + loadSummary: EditorSceneFileLoader.summary(from: content) + ) + let workbench = EditorWorkbenchViewModel(openDocuments: [.scene(document)], activeDocumentID: "terrain") + let app = AppWorlds(main: World(name: "Terrain viewport")) + app.addPlugin(MainSchedulerPlugin()).addPlugin(TransformPlugin()).addPlugin(RenderWorldPlugin()).addPlugin(Model3DPlugin()) + try await app.build() + let viewport = EditorSceneViewportModel() + configure(viewport, model: model, root: root, scene: scene, workbench: workbench) + viewport.loadSceneWorld(app.main, application: app, onLoadingChanged: { _ in }, onLoaded: { _ in }) + viewport.setViewportSize(Size(width: 800, height: 600)) + viewport.setTerrainEditing(true) + viewport.perspectiveBlend = 1 + let fixture = Fixture(root: root, file: file, scene: scene, app: app, viewport: viewport, workbench: workbench, id: id) + let deadline = ContinuousClock().now.advanced(by: .seconds(10)) + while ContinuousClock().now < deadline { + try await pump(fixture, frames: 1) + let ready = try await app.withWorldAccess { + viewport.entitiesByEditorID[id].flatMap { app.main.getEntityByID($0) }?.components[Terrain3DSourceStatus.self]?.isReady == true + } + if ready { + return fixture + } + } + throw TestFailure.notReady + } + + private enum TestFailure: Error { case notReady } + + private func configure(_ viewport: EditorSceneViewportModel, model: EditorSceneModel, root: URL, scene: URL, workbench: EditorWorkbenchViewModel) { + viewport.configure( + sceneContent: (try? model.encodedYAML()) ?? "", + model: model, + sourceURL: scene, + resourceRootURL: root, + onSelectionChanged: { _ in }, + onDocumentContentChanged: { [weak viewport] content in + workbench.updateSceneDocument(id: "terrain") { document in + document.content = content + document.sceneModel = viewport?.sceneModel + document.isDirty = true + } + } + ) + } + + private func screenPoint(_ fixture: Fixture) throws -> Point { + let point = try #require(fixture.viewport.project(.zero, size: fixture.viewport.viewportSize)) + return Point(x: point.x, y: point.y) + } + + func pump(_ fixture: Fixture, frames: Int) async throws { + for _ in 0.. Float { + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + let manifest = await cache.manifest + var regions: [TerrainRegionAsset] = [] + for reference in manifest.heightRegions { + if let region = try await cache.loadRegion(at: .init(x: Int(reference.x), z: Int(reference.z))) { regions.append(region) } + } + let storage = try TerrainHeightStorage(regions: regions, spacing: manifest.spacing, origin: [manifest.originX, manifest.originZ]) + return try #require(storage.height(at: .zero)) + } +} diff --git a/Editor/scripts/stage-build-sdk.py b/Editor/scripts/stage-build-sdk.py index 71963c196..672c99dc1 100644 --- a/Editor/scripts/stage-build-sdk.py +++ b/Editor/scripts/stage-build-sdk.py @@ -45,7 +45,13 @@ def stage(engine, compiler, destination, swan=None): staged = Path(temporary) / "BuildSDK" staged.mkdir() copy_inputs(engine, staged / "AdaEngine", ["Package.swift", "Package.resolved", ".swiftlint.yml", "LICENSE", "NOTICE", - "Sources", "Plugins", "Tools", "Tests", "Assets", "script"]) + "Sources", "Plugins", "Tools", "Tests", "Assets", "script", "Editor/StudioCore"]) + # These checked-in browser modules are required by the Web export plugin. + # Ordinary generated dist directories remain excluded from the SDK. + browser_runtime = Path("Plugins/AdaWebExportPlugin/BrowserWASIShim/dist") + if (engine / browser_runtime).is_dir(): + modules = sorted(file.name for file in (engine / browser_runtime).glob("*.js")) + copy_inputs(engine / browser_runtime, staged / "AdaEngine" / browser_runtime, modules) copy_inputs(compiler, staged / "AdaScript", ["Package.swift", "Package.resolved", "LICENSE", "Makefile", "src", "binding", "tools", "Tests", "examples", "gravity", "gravity.exe"]) host = staged / "AdaScript" / ("gravity.exe" if os.name == "nt" else "gravity") diff --git a/Editor/scripts/test_stage_build_sdk.py b/Editor/scripts/test_stage_build_sdk.py index c847432b9..fb4f0d02f 100644 --- a/Editor/scripts/test_stage_build_sdk.py +++ b/Editor/scripts/test_stage_build_sdk.py @@ -68,6 +68,52 @@ def test_source_sdk_is_self_contained_and_omits_private_build_state(self): sdk.stage(engine, compiler, destination) self.assertFalse((destination / "BuildSDK").exists()) + def test_sdk_includes_local_studio_core_package(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + engine = root / "engine" + compiler = root / "compiler" + package = engine / "Editor/StudioCore/Package.swift" + source = package.parent / "Sources/AdaStudioCore/Core.swift" + source.parent.mkdir(parents=True) + package.write_text("local package") + source.write_text("Swift source") + compiler.mkdir() + for host in ("gravity", "gravity.exe"): + executable = compiler / host + executable.touch() + executable.chmod(0o755) + destination = root / "Moved Studio/BuildSDK" + with patch.object(sdk.subprocess, "run"): + sdk.stage(engine, compiler, destination) + staged_package = destination / "AdaEngine/Editor/StudioCore/Package.swift" + self.assertTrue(staged_package.is_file()) + self.assertEqual(staged_package.read_text(), "local package") + self.assertTrue((staged_package.parent / "Sources/AdaStudioCore/Core.swift").is_file()) + + def test_sdk_keeps_bundled_browser_wasi_runtime(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + engine = root / "engine" + compiler = root / "compiler" + shim = "Plugins/AdaWebExportPlugin/BrowserWASIShim/dist" + for relative in (shim + "/index.js", shim + "/wasi.js", "Demos/Project/dist/generated.txt"): + file = engine / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(relative) + compiler.mkdir() + for host in ("gravity", "gravity.exe"): + executable = compiler / host + executable.touch() + executable.chmod(0o755) + destination = root / "BuildSDK" + with patch.object(sdk.subprocess, "run"): + sdk.stage(engine, compiler, destination) + staged_engine = destination / "AdaEngine" + self.assertTrue((staged_engine / shim / "index.js").is_file()) + self.assertTrue((staged_engine / shim / "wasi.js").is_file()) + self.assertFalse((staged_engine / "Demos/Project/dist").exists()) + def test_android_sdk_keeps_dawn_static_library_after_relocation(self): with tempfile.TemporaryDirectory() as temporary: root=Path(temporary) diff --git a/Editor/scripts/update-offline-documentation.py b/Editor/scripts/update-offline-documentation.py index c09cb424c..d6f28d28a 100644 --- a/Editor/scripts/update-offline-documentation.py +++ b/Editor/scripts/update-offline-documentation.py @@ -2,8 +2,8 @@ """Build the editor's offline guide snapshot from repository documentation.""" import argparse import json -from pathlib import Path import re +from pathlib import Path EDITOR = Path(__file__).resolve().parents[1] ROOT = EDITOR.parent @@ -12,7 +12,7 @@ def articles(): sources = [(EDITOR / f'Documentation/{name}.md', 'Editor') - for name in ['EditorGuide', 'Agent3DWorkflow', 'A2UI', 'AdaScriptGameplayRecipes', 'InspectorGroups', 'BuildExport']] + for name in ['EditorGuide', 'Agent3DWorkflow', 'A2UI', 'AdaScriptGameplayRecipes', 'InspectorGroups', 'BuildExport', 'Terrain']] for module, section in [('AdaEngine', 'AdaEngine'), ('AdaScripting', 'AdaScript')]: catalog = ROOT / f'Sources/{module}/{module}.docc' sources += [(path, section) for path in sorted(catalog.rglob('*.md')) @@ -21,7 +21,11 @@ def articles(): 'SPMDebug', 'Contributing', 'AdaScripting', 'GettingStartedWithAdaScript', 'AdaScriptLanguage', 'AdaScriptECS', 'AdaScriptDiagnosticsAndPerformance'] sources.sort(key=lambda entry: order.index(entry[0].stem) if entry[0].stem in order else len(order)) - titles = {path.stem: re.sub(r'`', '', path.read_text(encoding='utf-8').splitlines()[0].removeprefix('# ')) + identifiers = {path.resolve(): ('EditorTerrain' if path == EDITOR / 'Documentation/Terrain.md' else path.stem) + for path, _ in sources} + if len(set(identifiers.values())) != len(sources): + raise ValueError('Offline documentation article IDs must be unique') + titles = {identifiers[path.resolve()]: re.sub(r'`', '', path.read_text(encoding='utf-8').splitlines()[0].removeprefix('# ')) .replace('Ada Script', 'AdaScript') for path, _ in sources} titles['AdaScripting'] = 'AdaScript' result = [] @@ -29,7 +33,7 @@ def articles(): text = path.read_text(encoding='utf-8') blocks, pending, code, language = [], [], None, '' - def flush(): + def flush(pending=pending, path=path, blocks=blocks): if not pending: return value = '\n'.join(pending).replace('Ada Script', 'AdaScript') @@ -47,9 +51,20 @@ def web_link(match): links.append({'title': match[1], 'destination': match[2]}) return match[1] + def local_link(match): + destination = match[2].strip().strip('<>').split('#', 1)[0] + if not destination or re.match(r'[a-zA-Z][a-zA-Z0-9+.-]*:', destination): + return match[0] + identifier = identifiers.get((path.parent / destination).resolve()) + if identifier is None: + return match[0] + links.append({'title': match[1], 'destination': identifier}) + return match[1] + value = re.sub(r']+)>', doc_link, value) value = re.sub(r'\[([^\]]+)\]\((https?://[^)]+)\)', web_link, value) value = re.sub(r'<(https?://[^>]+)>', lambda m: web_link(['', m[1], m[1]]), value) + value = re.sub(r'\[([^\]]+)\]\(([^)\n]+)\)', local_link, value) value = re.sub(r'``([^`]+)``', r'`\1`', value) value = re.sub(r'(?m)^- ', '• ', value) value = re.sub(r'(?m)^(\d+)\. ', r'\1\\. ', value) @@ -59,8 +74,8 @@ def web_link(match): 'level': len(heading[1]) if heading else 0, 'links': links}) # DocC metadata is presentation configuration, not article text. - text = re.sub(r'(?m)^@Metadata \{\n.*?^\}\n', '', text, flags=re.S) - text = re.sub(r'', '', text, flags=re.S) + text = re.sub(r'(?m)^@Metadata \{\n.*?^\}\n', '', text, flags=re.DOTALL) + text = re.sub(r'', '', text, flags=re.DOTALL) for line in text.splitlines()[1:]: if line.startswith('```'): if code is None: @@ -82,7 +97,8 @@ def web_link(match): if code is not None: raise ValueError(f'Unclosed code fence: {path}') flush() - result.append({'id': path.stem, 'title': titles[path.stem], 'section': section, + identifier = identifiers[path.resolve()] + result.append({'id': identifier, 'title': titles[identifier], 'section': section, 'source': path.relative_to(ROOT).as_posix(), 'blocks': blocks}) return result diff --git a/Package.swift b/Package.swift index 8242fe7d9..115c3a5df 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. -import PackageDescription -import Foundation import CompilerPluginSupport +import Foundation +import PackageDescription #if canImport(AppleProductTypes) import AppleProductTypes @@ -142,6 +142,7 @@ var products: [Product] = [ name: "Math", targets: ["Math"] ), + .library(name: "AdaGeometry", targets: ["AdaGeometry"]), .library( name: "AdaAnimation", targets: ["AdaAnimation"] @@ -233,6 +234,7 @@ var adaEngineDependencies: [Target.Dependency] = [ "AdaUI", "AdaEngineMacros", "AdaAssets", + "AdaGeometry", "AdaPlatform", "AdaAudio", "AdaCorePipelines", @@ -253,9 +255,13 @@ var adaEngineDependencies: [Target.Dependency] = [ // the renderer; this target shares its selected D3D12 device and textures. let dlssBuildConfiguration: [String: Any]? = { #if os(Windows) - guard !isHeadlessCIEnabled, ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_DLSS"] != "1" else { return nil } + guard !isHeadlessCIEnabled, ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_DLSS"] != "1" else { + return nil + } let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - guard let data = try? Data(contentsOf: root.appendingPathComponent(".build-tools/dlss-interop/build.json")) else { return nil } + guard let data = try? Data(contentsOf: root.appendingPathComponent(".build-tools/dlss-interop/build.json")) else { + return nil + } return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] #else return nil @@ -386,6 +392,16 @@ var targets: [Target] = [ adaEngineEmbeddable, adaEngineMacros, .adaTarget(name: "Math"), + .target( + name: "CManifold", + exclude: ["Vendor/LICENSE", "Vendor/AUTHORS", "Vendor/UPSTREAM.json", "README.md", "THIRD_PARTY_NOTICES.txt"], + publicHeadersPath: "include", + cxxSettings: [ + .headerSearchPath("Vendor/include"), .headerSearchPath("Vendor/src"), + .define("MANIFOLD_PAR", to: "-1"), .define("MANIFOLD_NO_IOSTREAM"), .define("MANIFOLD_NO_FILESYSTEM") + ] + ), + .target(name: "AdaGeometry", dependencies: ["Math", .target(name: "CManifold", condition: .when(platforms: [.macOS, .windows, .linux], traits: ["MeshTerrainAuthoring"]))], swiftSettings: swiftSettings), .adaTarget(name: "AdaSpatial", dependencies: ["AdaECS", "AdaTransform", "Math"]), .testTarget(name: "AdaSpatialTests", dependencies: ["AdaSpatial", "AdaECS", "AdaTransform", "Math"], swiftSettings: swiftSettings), .adaTarget( @@ -529,6 +545,7 @@ var targets: [Target] = [ name: "AdaAssets", dependencies: [ "AdaAnimation", + "AdaGeometry", "AdaApp", "AdaUtils", "Math", @@ -647,6 +664,7 @@ var targets: [Target] = [ .adaTarget( name: "AdaScene", dependencies: [ + "AdaGeometry", "AdaApp", "AdaAssets", "AdaECS", @@ -661,7 +679,10 @@ var targets: [Target] = [ "AdaUI", "AdaPhysics" ], - swiftSettings: swiftSettings + swiftSettings: swiftSettings + [ + // Swift 6.3.2 debug WASI can corrupt the heap when publishing large terrain values. + .unsafeFlags(["-Xfrontend", "-disable-large-loadable-types-reg2mem"], .when(platforms: [.wasi], configuration: .debug)) + ] ), .adaTarget( name: "AdaTilemap", @@ -876,7 +897,6 @@ targets.append( // MARK: Build Plugins if isWGPUEnabled { - products.append(.plugin(name: "WebGPUTintPlugin", targets: ["WebGPUTintPlugin"])) targets.append( .plugin( @@ -895,6 +915,14 @@ if isWGPUEnabled { // MARK: Extra +targets.append(.testTarget( + name: "AdaGeometryTests", + dependencies: ["AdaGeometry", "Math"], + swiftSettings: swiftSettings + [ + .define("MESH_TERRAIN_AUTHORING", .when(platforms: [.macOS, .windows, .linux], traits: ["MeshTerrainAuthoring"])) + ] +)) + #if os(Linux) targets += [ .testTarget(name: "AdaPlatformTests", dependencies: ["AdaPlatform", "AdaECS", "AdaInput", "AdaRender", "AdaUI", "AdaUtils", "Math", "X11"], swiftSettings: swiftSettings), @@ -914,7 +942,6 @@ targets.append(.systemLibrary(name: "X11", pkgConfig: "x11", providers: [.apt([" // MARK: - CXX Internal Targets targets += [ - // Box2d .target( @@ -1047,30 +1074,33 @@ targets += [ ), .target( name: "SPIRV-Cross", - exclude: ["CMakeLists.txt", - "CODE_OF_CONDUCT.adoc", - "LICENSE", - "LICENSES", - "Makefile", - "README.md", - "appveyor.yml", - "build_glslang_spirv_tools.sh", - "checkout_glslang_spirv_tools.sh", - "format_all.sh", - "gn", - "pkg-config" - ], - sources: ["spirv_cfg.cpp", - "spirv_cpp.cpp", - "spirv_cross.cpp", - "spirv_cross_c.cpp", - "spirv_cross_parsed_ir.cpp", - "spirv_cross_util.cpp", - "spirv_glsl.cpp", - "spirv_hlsl.cpp", - "spirv_msl.cpp", - "spirv_parser.cpp", - "spirv_reflect.cpp"], + exclude: [ + "CMakeLists.txt", + "CODE_OF_CONDUCT.adoc", + "LICENSE", + "LICENSES", + "Makefile", + "README.md", + "appveyor.yml", + "build_glslang_spirv_tools.sh", + "checkout_glslang_spirv_tools.sh", + "format_all.sh", + "gn", + "pkg-config" + ], + sources: [ + "spirv_cfg.cpp", + "spirv_cpp.cpp", + "spirv_cross.cpp", + "spirv_cross_c.cpp", + "spirv_cross_parsed_ir.cpp", + "spirv_cross_util.cpp", + "spirv_glsl.cpp", + "spirv_hlsl.cpp", + "spirv_msl.cpp", + "spirv_parser.cpp", + "spirv_reflect.cpp" + ], publicHeadersPath: "include", cxxSettings: [ .define("SPIRV_CROSS_C_API_CPP", to: "1"), @@ -1473,7 +1503,7 @@ targets += [ ] #if os(macOS) -//targets.append(contentsOf: swiftLintTargets) +// targets.append(contentsOf: swiftLintTargets) #endif if let config = dlssBuildConfiguration, @@ -1504,7 +1534,8 @@ let package = Package( .trait( name: .wgpuTrait, description: "Enable WebGPU support" - ) + ), + .trait(name: "MeshTerrainAuthoring", description: "Enable the desktop triangle authoring kernel; compiled players do not require it") ], dependencies: [], targets: targets, @@ -1551,7 +1582,9 @@ package.dependencies += [ // Platform conditions on product edges alone do not stop SwiftPM compiling them. for target in package.targets { target.dependencies.removeAll { dependency in - if case let .productItem(_, name, _, _) = dependency { return name == "JavaScriptKit" } + if case let .productItem(_, name, _, _) = dependency { + return name == "JavaScriptKit" + } return false } } @@ -1641,7 +1674,6 @@ private extension Target { plugins: plugins ) } - } // MARK: - Traits diff --git a/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift b/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift index 745279a00..4787e05ac 100644 --- a/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift +++ b/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift @@ -40,14 +40,15 @@ struct AdaShaderTranspilerTool { moduleIncludes: options.moduleIncludes ) let (entryPoint, mainSource) = GLSLProcessor.dropEntryPoint(in: processedSource) - let spirv = try compileSPIRV(source: mainSource, stage: stageSource.stage) + let spirv = try compileSPIRV(source: mainSource, stage: stageSource.stage, defines: options.defines) let wgsl = try runTint( tintExecutable: options.tintExecutable, spirv: spirv, entryPoint: entryPoint ) + let stem = ([options.input.deletingPathExtension().lastPathComponent] + options.defines).joined(separator: ".") let output = options.outputDirectory - .appending(component: options.input.deletingPathExtension().lastPathComponent, directoryHint: .notDirectory) + .appending(component: stem, directoryHint: .notDirectory) .appendingPathExtension(stageSource.stage.fileExtension) .appendingPathExtension("wgsl") @@ -56,9 +57,9 @@ struct AdaShaderTranspilerTool { } } - private static func compileSPIRV(source: String, stage: ShaderStage) throws -> Data { + private static func compileSPIRV(source: String, stage: ShaderStage, defines: [String]) throws -> Data { var error: UnsafePointer? - let source = source.insertingDefaultVertexDefines() + let source = source.insertingDefines(defines) let options = spirv_options(preamble: nil) let binary = source.withCString { sourcePointer in compile_shader_glsl( @@ -81,36 +82,37 @@ struct AdaShaderTranspilerTool { } private static func runTint(tintExecutable: URL, spirv: Data, entryPoint: String) throws -> String { - let temporaryFile = FileManager.default.temporaryDirectory - .appending(component: UUID().uuidString, directoryHint: .notDirectory) - .appendingPathExtension("spv") - try spirv.write(to: temporaryFile) + let directory = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) defer { - try? FileManager.default.removeItem(at: temporaryFile) + try? FileManager.default.removeItem(at: directory) } + let temporaryFile = directory.appendingPathComponent("input.spv") + let outputFile = directory.appendingPathComponent("output.wgsl") + let errorFile = directory.appendingPathComponent("diagnostics.txt") + try spirv.write(to: temporaryFile) + try Data().write(to: errorFile) let process = Process() - let standardOutput = Pipe() - let standardError = Pipe() + let standardError = try FileHandle(forWritingTo: errorFile) + defer { try? standardError.close() } process.executableURL = tintExecutable // GLSL materials may compute derivatives under data-dependent control flow. // Preserve that source behavior when translating SPIR-V; Tint emits the // corresponding WGSL derivative_uniformity diagnostic directive. - process.arguments = ["--format", "wgsl", "--allow-non-uniform-derivatives", "true", temporaryFile.path()] - process.standardOutput = standardOutput + // A generated shader or diagnostic can exceed a pipe's buffer before the child exits. + process.arguments = ["--format", "wgsl", "--allow-non-uniform-derivatives", "true", "-o", outputFile.path(), temporaryFile.path()] + process.standardOutput = FileHandle.nullDevice process.standardError = standardError try process.run() process.waitUntilExit() - let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() - let errorData = standardError.fileHandleForReading.readDataToEndOfFile() - let errorOutput = String(decoding: errorData, as: UTF8.self) - guard process.terminationStatus == 0 else { + let errorOutput = String(decoding: try Data(contentsOf: errorFile), as: UTF8.self) throw ToolError.tintFailed(errorOutput.isEmpty ? "Tint exited with status \(process.terminationStatus)." : errorOutput) } - let output = String(decoding: outputData, as: UTF8.self) + let output = String(decoding: try Data(contentsOf: outputFile), as: UTF8.self) guard !output.isEmpty else { throw ToolError.tintFailed("Tint produced no WGSL output.") } @@ -128,12 +130,14 @@ private struct Options { let outputDirectory: URL let tintExecutable: URL let moduleIncludes: [String: URL] + let defines: [String] init(arguments: ArraySlice) throws { var input: URL? var outputDirectory: URL? var tintExecutable: URL? var moduleIncludes: [String: URL] = [:] + var defines: Set = [] var iterator = arguments.makeIterator() while let argument = iterator.next() { @@ -144,6 +148,12 @@ private struct Options { outputDirectory = try Self.nextURL(from: &iterator, for: argument) case "--tint": tintExecutable = try Self.nextURL(from: &iterator, for: argument) + case "--define": + let name = try Self.nextValue(from: &iterator, for: argument) + guard name.range(of: #"^[A-Z_][A-Z0-9_]*$"#, options: .regularExpression) != nil else { + throw ToolError.invalidArgument("--define expects a GLSL macro name") + } + defines.insert(name) case "--module-include": let value = try Self.nextValue(from: &iterator, for: argument) let parts = value.split(separator: "=", maxSplits: 1).map(String.init) @@ -172,6 +182,7 @@ private struct Options { self.outputDirectory = outputDirectory self.tintExecutable = tintExecutable self.moduleIncludes = moduleIncludes + self.defines = defines.sorted() } private static func nextURL(from iterator: inout I, for argument: String) throws -> URL where I.Element == String { @@ -387,19 +398,20 @@ private enum GLSLProcessor { } private extension String { - func insertingDefaultVertexDefines() -> String { + func insertingDefines(_ additionalDefines: [String]) -> String { let defines = """ #define VERTEX_POSITIONS 1 #define VERTEX_NORMALS 1 #define VERTEX_UVS 1 #define VERTEX_COLORS 1 """ + let preamble = ([defines] + additionalDefines.map { "#define \($0) 1" }).joined(separator: "\n") guard let versionRange = range(of: #"^\s*#version[^\n]*(?:\n|$)"#, options: .regularExpression) else { - return "\(defines)\n\(self)" + return "\(preamble)\n\(self)" } var output = self - output.insert(contentsOf: "\(defines)\n", at: versionRange.upperBound) + output.insert(contentsOf: "\(preamble)\n", at: versionRange.upperBound) return output } } @@ -462,7 +474,7 @@ private enum ToolError: LocalizedError, CustomStringConvertible { var description: String { switch self { case .help: - "Usage: AdaShaderTranspilerTool --input shader.glsl --output-directory --tint [--module-include AdaEngine=]" + "Usage: AdaShaderTranspilerTool --input shader.glsl --output-directory --tint [--define NAME] [--module-include AdaEngine=]" case .invalidArgument(let message): message case .glslangInitializationFailed: diff --git a/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift b/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift index 8cd8f9b68..5fa36dcee 100644 --- a/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift +++ b/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift @@ -10,6 +10,8 @@ import PackagePlugin struct AdaWebExportPlugin: CommandPlugin { // Runtime files belong to the plugin package, including when used by a dependent game. private static var assetsDirectory: URL { URL(fileURLWithPath: #filePath).deletingLastPathComponent() } + private static let flat3DBundle = "AdaEngine_AdaCorePipelines.resources" + private static let flat3DVariants = [["ADA_TERRAIN"], ["ADA_WIREFRAME"], ["ADA_TERRAIN", "ADA_WIREFRAME"]] func performCommand(context: PluginContext, arguments: [String]) async throws { let options: ExportOptions @@ -539,33 +541,37 @@ struct AdaWebExportPlugin: CommandPlugin { ] arguments.append(contentsOf: moduleIncludeArguments) - do { - let generatedOutput = try WebExportSupport.captureStandardOutput( - executable: shaderTranspiler.path(), - arguments: arguments, - workingDirectory: packageDirectory - ) - let generatedEntries = generatedOutput - .split(whereSeparator: \.isNewline) - .map(String.init) - .compactMap { outputPath -> ResourceManifestEntry? in - let outputURL = URL(fileURLWithPath: outputPath) - guard FileManager.default.fileExists(atPath: outputURL.path()) else { - return nil + let needsVariants = resourceBundle.source.lastPathComponent == Self.flat3DBundle + && shaderURL.relativePath(from: resourceBundle.source) == "Shaders/flat3d.glsl" + for defines in [[]] + (needsVariants ? Self.flat3DVariants : []) { + do { + let generatedOutput = try WebExportSupport.captureStandardOutput( + executable: shaderTranspiler.path(), + arguments: arguments + defines.flatMap { ["--define", $0] }, + workingDirectory: packageDirectory + ) + let generatedEntries = generatedOutput + .split(whereSeparator: \.isNewline) + .map(String.init) + .compactMap { outputPath -> ResourceManifestEntry? in + let outputURL = URL(fileURLWithPath: outputPath) + guard FileManager.default.fileExists(atPath: outputURL.path()) else { + return nil + } + let absoluteBuildURL = shaderURL + .deletingLastPathComponent() + .appending(component: outputURL.lastPathComponent, directoryHint: .notDirectory) + return ResourceManifestEntry( + path: absoluteBuildURL.path(), + // The WGSL exists in the export, not in the SwiftPM resource bundle. + url: browserRelativeURL(forRelativePath: outputURL.relativePath(from: outputDirectory)) + ) } - let absoluteBuildURL = shaderURL - .deletingLastPathComponent() - .appending(component: outputURL.lastPathComponent, directoryHint: .notDirectory) - return ResourceManifestEntry( - path: absoluteBuildURL.path(), - // The WGSL exists in the export, not in the SwiftPM resource bundle. - url: browserRelativeURL(forRelativePath: outputURL.relativePath(from: outputDirectory)) - ) - } - entries.append(contentsOf: generatedEntries) - } catch { - if requiresAllShaders { throw error } - Diagnostics.warning("Skipping WGSL generation for \(shaderURL.lastPathComponent): \(error)") + entries.append(contentsOf: generatedEntries) + } catch { + if requiresAllShaders || !defines.isEmpty { throw error } + Diagnostics.warning("Skipping WGSL generation for \(shaderURL.lastPathComponent): \(error)") + } } } @@ -587,6 +593,13 @@ struct AdaWebExportPlugin: CommandPlugin { let manifest = try? JSONDecoder().decode(CachedShaderManifest.self, from: manifestData), manifest.schemaVersion == 1 else { return nil } + if resourceBundles.contains(where: { $0.source.lastPathComponent == Self.flat3DBundle }) { + let requiredVariants = Self.flat3DVariants.flatMap { defines in + let stem = Self.flat3DBundle + "/Shaders/flat3d." + defines.joined(separator: ".") + return [stem + ".vert.wgsl", stem + ".frag.wgsl"] + } + guard Set(requiredVariants).isSubset(of: manifest.wgslPaths) else { return nil } + } var currentSources: [String: URL] = [:] for bundle in resourceBundles { diff --git a/Sources/AdaAssets/Asset.swift b/Sources/AdaAssets/Asset.swift index 8481527eb..1f95badda 100644 --- a/Sources/AdaAssets/Asset.swift +++ b/Sources/AdaAssets/Asset.swift @@ -34,6 +34,9 @@ import Foundation /// Also, your asset can support ``Codable`` behaviour and for this scenario, you should implement only ``init(from decoder: Decoder)`` and ``func encode(to encoder: Encoder)`` methods. /// Meta and other information will be available from userInfo. Use `Decoder.assetsDecodingContext`, `Decoder.assetMeta` and `Encoder.assetMeta` properties to get this info. public protocol Asset: Sendable { + /// Maximum encoded bytes accepted by the loader, before decoding. `nil` leaves the size unrestricted. + static var maximumEncodedBytes: Int? { get } + /// When asset load from the disk, this method will be called. /// /// - Parameter data: Asset's data. @@ -53,6 +56,8 @@ public protocol Asset: Sendable { } extension Asset { + public static var maximumEncodedBytes: Int? { nil } + /// If resource was initiated from resource, than property will return path to that file relative source dir. /// - Warning: Do not override stored value. public var assetPath: String { diff --git a/Sources/AdaAssets/AssetsManager+BoundedRead.swift b/Sources/AdaAssets/AssetsManager+BoundedRead.swift new file mode 100644 index 000000000..fbc5f66f7 --- /dev/null +++ b/Sources/AdaAssets/AssetsManager+BoundedRead.swift @@ -0,0 +1,105 @@ +import Foundation + +#if WASM && canImport(JavaScriptFoundationCompat) && canImport(JavaScriptKit) + import JavaScriptEventLoop + import JavaScriptFoundationCompat + import JavaScriptKit +#endif + +extension AssetsManager { + #if WASM && canImport(JavaScriptFoundationCompat) && canImport(JavaScriptKit) + /// JavaScript objects remain on the browser executor across promise suspensions. + @MainActor + static func readBoundedResponse(url: String, maximumBytes: Int) async throws -> Data { + guard let constructor = JSObject.global.AbortController.function else { + throw AssetError.message("Browser AbortController is unavailable") + } + let controller = constructor.new() + let abort: @MainActor @Sendable () -> Void = { _ = controller.abort?() } + return try await withTaskCancellationHandler { + defer { abort() } + try Task.checkCancellation() + let options = JSObject.global.Object.function?.new() + guard let options else { throw AssetError.message("Browser fetch options are unavailable") } + options.signal = controller.signal + guard let request = try JSObject.global.throwing.fetch?(url, options), + let promise = JSPromise.construct(from: request), + let response = try await promise.value(isolation: MainActor.shared).object + else { + throw AssetError.message("Browser fetch returned an invalid response for \(url)") + } + try Task.checkCancellation() + guard response.ok.boolean == true else { throw AssetError.notExistAtPath(url) } + if response.body.isNull { + return Data() + } + guard let body = response.body.object, + let reader = try body.throwing.getReader?().object + else { + throw AssetError.message("Browser response streaming is unavailable for \(url)") + } + defer { + // The outer abort closes the request on errors/cancellation, without an unobserved cancel promise. + _ = try? reader.throwing.releaseLock?() + } + var data = Data() + while true { + try Task.checkCancellation() + guard let next = try reader.throwing.read?(), + let pending = JSPromise.construct(from: next), + let result = try await pending.value(isolation: MainActor.shared).object + else { + throw AssetError.message("Browser response stream returned an invalid chunk for \(url)") + } + try Task.checkCancellation() + if result.done.boolean == true { + return data + } + guard let chunk = result.value.object, let length = chunk.byteLength.number, + length.isFinite, length >= 0, length.rounded(.down) == length + else { + throw AssetError.message("Browser response stream returned invalid bytes for \(url)") + } + guard length <= Double(maximumBytes - data.count) else { + throw AssetError.encodedSizeLimitExceeded(path: url, limit: maximumBytes) + } + guard let bytes = Data.construct(from: .object(chunk)), Double(bytes.count) == length else { + throw AssetError.message("Browser response bytes could not be read for \(url)") + } + data.append(bytes) + } + } onCancel: { + // Cancellation handlers are synchronous; the abort must run on the JavaScript executor. + Task { @MainActor in abort() } + } + } + #else + @concurrent + static func readBoundedFile(url: URL, maximumBytes: Int) async throws -> Data { + try Task.checkCancellation() + guard try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { + throw AssetError.notExistAtPath(url.path) + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + guard try handle.seekToEnd() <= UInt64(maximumBytes) else { + throw AssetError.encodedSizeLimitExceeded(path: url.path, limit: maximumBytes) + } + try handle.seek(toOffset: 0) + var data = Data() + // The file can grow after the size check. Read at most one byte beyond the remaining budget. + while true { + try Task.checkCancellation() + let remaining = maximumBytes - data.count + let count = remaining >= 65_536 ? 65_536 : remaining + 1 + guard let chunk = try handle.read(upToCount: count), !chunk.isEmpty else { + return data + } + guard chunk.count <= remaining else { + throw AssetError.encodedSizeLimitExceeded(path: url.path, limit: maximumBytes) + } + data.append(chunk) + } + } + #endif +} diff --git a/Sources/AdaAssets/AssetsManager.swift b/Sources/AdaAssets/AssetsManager.swift index b87fc51c4..bf913662c 100644 --- a/Sources/AdaAssets/AssetsManager.swift +++ b/Sources/AdaAssets/AssetsManager.swift @@ -22,6 +22,7 @@ import Tracing public enum AssetError: LocalizedError { case notExistAtPath(String) case message(String) + case encodedSizeLimitExceeded(path: String, limit: Int) public var errorDescription: String? { switch self { @@ -29,6 +30,8 @@ public enum AssetError: LocalizedError { return "Asset not exists at path: \(path)" case let .message(message): return message + case let .encodedSizeLimitExceeded(path, limit): + return "Asset exceeds the encoded byte limit of \(limit): \(path)" } } } @@ -343,13 +346,10 @@ public struct AssetsManager: Resource { _ asset: R, at path: String ) async throws { - try validateVirtualPath(path) - if currentProjectDirectories?.restrictToVirtualRoots == true, path.hasPrefix(resourcePathPrefix) { - throw AssetError.message("Community package resources are read-only") - } + let writablePath = try resolveWritableAssetPath(at: path) try await AdaTrace.span("Assets.save.\(String(reflecting: R.self))") { let fileSystem = FileSystem.current - var processedPath = self.processPath(path) + var processedPath = writablePath if processedPath.url.pathExtension.isEmpty { processedPath.url.appendPathExtension(R.extensions().first ?? "") @@ -687,7 +687,7 @@ public struct AssetsManager: Resource { from path: Path, originalPath _: String ) async throws { - let data = try await self.readData(from: path) + let data = try await self.readData(from: path, maximumBytes: assetType.maximumEncodedBytes) let meta = AssetMeta(filePath: path.url, queryParams: path.query) let decoder = TextAssetDecoder(meta: meta, data: data) try await assetType.loadAndUpdateInternal(from: decoder, oldResource: oldResource) @@ -696,7 +696,7 @@ public struct AssetsManager: Resource { @AssetActor private static func load(from path: Path, originalPath: String, bundle: Bundle?) async throws -> A { - let data = try await self.readData(from: path) + let data = try await self.readData(from: path, maximumBytes: A.maximumEncodedBytes) let meta = AssetMeta(filePath: path.url, queryParams: path.query) let decoder = TextAssetDecoder(meta: meta, data: data) @@ -712,7 +712,15 @@ public struct AssetsManager: Resource { return resource } - private static func readData(from path: Path) async throws -> Data { + private static func readData(from path: Path, maximumBytes: Int?) async throws -> Data { + if let maximumBytes { + guard maximumBytes >= 0 else { throw AssetError.message("Asset byte limit must be nonnegative") } + #if WASM && canImport(JavaScriptFoundationCompat) && canImport(JavaScriptKit) + return try await readBoundedResponse(url: browserFetchURL(for: path.url), maximumBytes: maximumBytes) + #else + return try await readBoundedFile(url: path.url, maximumBytes: maximumBytes) + #endif + } #if WASM && canImport(JavaScriptFoundationCompat) && canImport(JavaScriptKit) let fetchURL = self.browserFetchURL(for: path.url) let responseValue: JSValue @@ -799,8 +807,10 @@ public struct AssetsManager: Resource { } /// Resource boundary for decoders with model-relative dependencies. - static var restrictedResourceRoot: URL? { - guard let directories = currentProjectDirectories, directories.restrictToVirtualRoots else { return nil } + @_spi(Internal) public static var restrictedResourceRoot: URL? { + guard let directories = currentProjectDirectories, directories.restrictToVirtualRoots else { + return nil + } return directories.assetsDirectory } @@ -845,6 +855,15 @@ public struct AssetsManager: Resource { } extension AssetsManager { + @AssetActor + static func resolveWritableAssetPath(at path: String) throws -> Path { + try validateVirtualPath(path) + if currentProjectDirectories?.restrictToVirtualRoots == true, path.hasPrefix(resourcePathPrefix) { + throw AssetError.message("Community package resources are read-only") + } + return processPath(path) + } + struct Path: Sendable, Hashable { var url: URL let query: [AssetQuery] @@ -942,23 +961,33 @@ extension AssetsManager { } private static var isRetiredRestrictedScope: Bool { - guard let scopeID = AppWorldsExecutionContext.currentID else { return false } + guard let scopeID = AppWorldsExecutionContext.currentID else { + return false + } return scopeConfigurations.withLock { $0.retiredRestrictedScopes.contains(scopeID) } } private static func isContainedVirtualPath(_ path: String) -> Bool { - guard !isRetiredRestrictedScope else { return false } - guard let root = virtualRoot(for: path) else { return false } + guard !isRetiredRestrictedScope else { + return false + } + guard let root = virtualRoot(for: path) else { + return false + } let relative = String(path.dropFirst(root.prefix.count).split(separator: "#", maxSplits: 1).first ?? "") guard !relative.hasPrefix("/"), !relative.contains("\\"), !relative.contains(":"), !relative.split(separator: "/", omittingEmptySubsequences: false).contains("..") else { return false } // URL canonicalization can leave a nonexistent leaf unresolved. Reject links // component by component, including broken links, before reads or writes. var cursor = root.url - if (try? FileManager.default.destinationOfSymbolicLink(atPath: cursor.path)) != nil { return false } + if (try? FileManager.default.destinationOfSymbolicLink(atPath: cursor.path)) != nil { + return false + } for component in relative.split(separator: "/") { cursor.appendPathComponent(String(component)) - if (try? FileManager.default.destinationOfSymbolicLink(atPath: cursor.path)) != nil { return false } + if (try? FileManager.default.destinationOfSymbolicLink(atPath: cursor.path)) != nil { + return false + } } // Resolve the existing root before appending a possibly missing asset. // Windows expands 8.3 names only for existing paths; resolving the full @@ -1232,7 +1261,8 @@ public struct ProjectDirectories: Sendable { public init(source: URL, assetsDirectory: URL, storageIdentifier: String, restrictToVirtualRoots: Bool = false) throws(ProjectDirectoriesError) { try Self.validateStorageIdentifier(storageIdentifier) self.init( - source: source, assetsDirectory: assetsDirectory, + source: source, + assetsDirectory: assetsDirectory, userDataDirectory: Self.defaultDirectory(for: .applicationSupportDirectory, namespace: storageIdentifier, fallbackRoot: source.appendingPathComponent(".ada/user")), cacheDirectory: Self.defaultDirectory(for: .cachesDirectory, namespace: storageIdentifier, fallbackRoot: source.appendingPathComponent(".ada/cache")), restrictToVirtualRoots: restrictToVirtualRoots diff --git a/Sources/AdaAssets/GLTF/GLTFImportResult+Validation.swift b/Sources/AdaAssets/GLTF/GLTFImportResult+Validation.swift new file mode 100644 index 000000000..31bdef9cb --- /dev/null +++ b/Sources/AdaAssets/GLTF/GLTFImportResult+Validation.swift @@ -0,0 +1,50 @@ +import Foundation + +extension GLTFImportResult { + /// Validates vertex streams and indexed references before a model creates GPU buffers. + /// Empty metadata-only primitives remain valid; renderable streams require POSITION. + public func validateGeometry() throws { + for mesh in meshes { + for primitive in mesh.primitives { + guard let positions = primitive.attributes[.position] else { + if primitive.attributes.isEmpty && primitive.indices?.isEmpty != false { continue } + throw GLTFGeometryError.missingPositions + } + guard positions.componentCount == 3 else { + throw GLTFGeometryError.invalidAttribute("POSITION") + } + let count = positions.count + for (attribute, data) in primitive.attributes { + let components: Bool + switch attribute { + case .position, .normal: components = data.componentCount == 3 + case .tangent, .joints, .weights: components = data.componentCount == 4 + case .texCoord: components = data.componentCount == 2 + case .color: components = data.componentCount == 3 || data.componentCount == 4 + case .custom: components = data.componentCount > 0 + } + guard components, data.componentCount > 0, data.values.count.isMultiple(of: data.componentCount), + data.count == count, data.values.allSatisfy(\.isFinite) + else { + throw GLTFGeometryError.invalidAttribute(String(describing: attribute)) + } + } + guard primitive.indices?.allSatisfy({ UInt64($0) < UInt64(count) }) != false else { + throw GLTFGeometryError.invalidIndices + } + } + } + } +} + +public enum GLTFGeometryError: LocalizedError, Equatable { + case missingPositions, invalidAttribute(String), invalidIndices + + public var errorDescription: String? { + switch self { + case .missingPositions: "glTF vertex data requires a POSITION attribute" + case .invalidAttribute(let name): "glTF attribute \(name) must have finite values and match the POSITION vertex count and required dimensions" + case .invalidIndices: "glTF indices reference vertices outside the POSITION attribute" + } + } +} diff --git a/Sources/AdaAssets/GLTF/GLTFLoader.swift b/Sources/AdaAssets/GLTF/GLTFLoader.swift index 34c81d1e0..5c3170a4a 100644 --- a/Sources/AdaAssets/GLTF/GLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/GLTFLoader.swift @@ -205,6 +205,8 @@ public struct GLTFImportResult: Sendable { public let animations: [Animation] /// Content the importer read but could not honor, such as an optional extension it does not implement. public let warnings: [String] + /// Accounting for a bounded native import; absent for unconstrained/custom results. + @_spi(Internal) public var importFootprint: ModelImportFootprint? public init( nodes: [Node], diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift index f3932245a..bdfd58b2a 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift @@ -11,8 +11,7 @@ extension NativeGLTFLoader { guard accessor.count >= 1, accessor.count <= Int.max / componentCount else { throw GLTFError.bufferOutOfBounds } - var values = [Double](repeating: 0, count: accessor.count * componentCount) - + let layout: (buffer: Data, offset: Int, stride: Int)? if let bufferViewIndex = accessor.bufferView { guard let bufferViews = gltf.bufferViews, bufferViews.indices.contains(bufferViewIndex) else { throw GLTFError.invalidBufferViewIndex(bufferViewIndex) @@ -32,15 +31,41 @@ extension NativeGLTFLoader { // Validate the entire strided range once, using subtraction/division // so malformed lengths cannot overflow before reaching the bounds check. guard bufferViewOffset >= 0, bufferView.byteLength >= 0, bufferViewOffset <= buffer.count, - bufferView.byteLength <= buffer.count - bufferViewOffset, - accessorOffset >= 0, accessorOffset <= bufferView.byteLength else { + bufferView.byteLength <= buffer.count - bufferViewOffset, + accessorOffset >= 0, accessorOffset <= bufferView.byteLength + else { throw GLTFError.bufferOutOfBounds } let remaining = bufferView.byteLength - accessorOffset guard elementSize <= remaining, accessor.count - 1 <= (remaining - elementSize) / byteStride else { throw GLTFError.bufferOutOfBounds } - let startOffset = bufferViewOffset + accessorOffset + layout = (buffer, bufferViewOffset + accessorOffset, byteStride) + } else { + layout = nil + } + // Validate sparse storage and destinations before allocating the full accessor too. + let sparseData: (indices: [Int], values: [Double])? + if let sparse = accessor.sparse { + guard sparse.count >= 1, sparse.count <= accessor.count else { throw GLTFError.sparseIndexOutOfBounds } + let indices = try decodeSparseIndices(sparse.indices, count: sparse.count, gltf: gltf, buffers: buffers) + guard indices.allSatisfy({ $0 >= 0 && $0 < accessor.count }) else { throw GLTFError.sparseIndexOutOfBounds } + let decoded = try decodeSparseValues( + sparse.values, + count: sparse.count, + componentType: accessor.componentType, + componentCount: componentCount, + normalized: accessor.normalized ?? false, + gltf: gltf, + buffers: buffers + ) + sparseData = (indices, decoded) + } else { + sparseData = nil + } + var values = [Double](repeating: 0, count: accessor.count * componentCount) + if let layout { + let buffer = layout.buffer, startOffset = layout.offset, byteStride = layout.stride try buffer.withUnsafeBytes { bytes in if accessor.componentType == 5126 { // The common position/normal/UV path avoids four Foundation @@ -57,8 +82,10 @@ extension NativeGLTFLoader { let elementOffset = startOffset + elementIndex * byteStride for componentIndex in 0..= 1, sparse.count <= accessor.count else { - throw GLTFError.sparseIndexOutOfBounds - } - let sparseIndices = try decodeSparseIndices(sparse.indices, count: sparse.count, gltf: gltf, buffers: buffers) - let sparseValues = try decodeSparseValues( - sparse.values, - count: sparse.count, - componentType: accessor.componentType, - componentCount: componentCount, - normalized: accessor.normalized ?? false, - gltf: gltf, - buffers: buffers - ) - for sparseIndex in 0..= 0, offset + count * size <= data.count else { + guard offset >= 0, offset <= data.count, count <= (data.count - offset) / size else { throw GLTFError.bufferOutOfBounds } return try (0..= 0, offset + valueCount * size <= data.count else { + guard offset >= 0, offset <= data.count, valueCount <= (data.count - offset) / size else { throw GLTFError.bufferOutOfBounds } return try (0.. 0, limits.maximumDecodedBytes > 0 else { throw ModelImportLimitError.invalidLimits } + guard rootBytes <= limits.maximumEncodedBytes else { throw ModelImportLimitError.encodedBudgetExceeded } + encodedRemaining = limits.maximumEncodedBytes - rootBytes + decodedLimit = limits.maximumDecodedBytes + encodedLimit = limits.maximumEncodedBytes + } else { + encodedRemaining = nil + decodedLimit = nil + encodedLimit = nil + } + } + + mutating func consumeEncoded(_ count: Int) throws { + guard let remaining = encodedRemaining else { + return + } + guard count >= 0, count <= remaining else { throw ModelImportLimitError.encodedBudgetExceeded } + encodedRemaining = remaining - count + } + + mutating func read(_ url: URL) throws -> Data { + guard let limit = encodedRemaining else { + return try Data(contentsOf: url) + } + #if WASM + // A synchronous importer cannot fetch dependencies through a browser stream. + throw AssetError.message("Bounded synchronous model dependency reads are unavailable in the browser") + #else + try Task.checkCancellation() + guard url.isFileURL, try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { + throw AssetError.notExistAtPath(url.path) + } + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + guard try file.seekToEnd() <= UInt64(limit) else { throw ModelImportLimitError.encodedBudgetExceeded } + try file.seek(toOffset: 0) + var result = Data() + while true { + try Task.checkCancellation() + let remaining = limit - result.count + let count = remaining >= 65_536 ? 65_536 : remaining + 1 + guard let chunk = try file.read(upToCount: count), !chunk.isEmpty else { break } + guard chunk.count <= remaining else { throw ModelImportLimitError.encodedBudgetExceeded } + result.append(chunk) + } + try consumeEncoded(result.count) + return result + #endif + } + + /// Check decoded URI length before Foundation creates its Data. The root document already bounds strings. + func preflightDataURI(_ uri: String) throws { + guard let remaining = encodedRemaining, let comma = uri.firstIndex(of: ",") else { + return + } + let metadata = uri[..= 1 else { throw NativeGLTFLoader.GLTFError.bufferOutOfBounds } + try charge(value.count, stride: components * 64) + if let sparse = value.sparse { + guard sparse.count >= 1, sparse.count <= value.count else { throw NativeGLTFLoader.GLTFError.sparseIndexOutOfBounds } + try charge(sparse.count, stride: components * 16 + 8) + } + } + try charge(gltf.nodes?.count ?? 0, stride: 256) + try charge(gltf.materials?.count ?? 0, stride: 512) + try charge(gltf.images?.count ?? 0, stride: 128) + try charge(gltf.accessors?.count ?? 0, stride: 128) + try charge(gltf.buffers?.count ?? 0, stride: 64) + try charge(gltf.bufferViews?.count ?? 0, stride: 64) + try charge(gltf.textures?.count ?? 0, stride: 64) + try charge(gltf.samplers?.count ?? 0, stride: 64) + for node in gltf.nodes ?? [] { + try charge(node.children?.count ?? 0, stride: 8) + try charge(node.weights?.count ?? 0, stride: 8) + } + for scene in gltf.scenes ?? [] { + try charge(1, stride: 128) + try charge(scene.nodes?.count ?? 0, stride: 8) + } + for image in gltf.images ?? [] { + if image.uri == nil, let index = image.bufferView { + guard let views = gltf.bufferViews, views.indices.contains(index) else { throw NativeGLTFLoader.GLTFError.invalidBufferViewIndex(index) } + // Conversion retains a separate Data for every image reference, even when views repeat. + try charge(views[index].byteLength, stride: 1) + } + } + for mesh in gltf.meshes ?? [] { + try Task.checkCancellation() + try charge(1, stride: 128) + try charge(mesh.weights?.count ?? 0, stride: 8) + for name in mesh.extras?.targetNames ?? [] { try charge(name.utf8.count, stride: 1) } + for primitive in mesh.primitives { + try charge(1, stride: 128) + for index in primitive.attributes.values { try accessor(index) } + if let index = primitive.indices { try accessor(index) } + for target in primitive.targets ?? [] { + // Empty targets still create three array fields and synthesized default weights. + try charge(1, stride: 128) + for index in target.values { try accessor(index) } + } + } + } + for skin in gltf.skins ?? [] { + try charge(skin.joints.count, stride: 128) + if let index = skin.inverseBindMatrices { try accessor(index) } + } + for animation in gltf.animations ?? [] { + try Task.checkCancellation() + for sampler in animation.samplers { + try accessor(sampler.input) + try accessor(sampler.output) + } + try charge(animation.channels.count, stride: 128) + } + decodedBytes = decodedLimit - remaining + } +} diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 27543a741..4383586d1 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -42,7 +42,10 @@ public struct NativeGLTFLoader: GLTFLoader { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } data = try handle.read(upToCount: min(maximumTotalBytes, Int.max - 1) + 1) ?? Data() - } else { data = try Data(contentsOf: url) } + } else { + var budget = try NativeGLTFBudget(rootBytes: 0) + data = try budget.read(url) + } return try load(data: data, baseURL: url.deletingLastPathComponent()) } @@ -75,8 +78,9 @@ public struct NativeGLTFLoader: GLTFLoader { return result } - public func load(data: Data, baseURL: URL? = nil) throws -> GLTFImportResult { + public func load(data: Data, baseURL: URL? = nil, allowExternalResources: Bool = true) throws -> GLTFImportResult { if let maximumTotalBytes, data.count > maximumTotalBytes { throw GLTFError.bufferOutOfBounds } + var budget = try NativeGLTFBudget(rootBytes: data.count) let resourceBaseURL = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let gltf: GLTF @@ -113,9 +117,17 @@ public struct NativeGLTFLoader: GLTFLoader { remaining -= count } } - let buffers = try loadBuffers(gltf.buffers ?? [], baseURL: resourceBaseURL, binaryBuffer: binaryBuffer) + if !allowExternalResources { + let uris = (gltf.buffers ?? []).compactMap(\.uri) + (gltf.images ?? []).compactMap(\.uri) + guard uris.allSatisfy({ $0.hasPrefix("data:") }) else { throw GLTFError.invalidDataURI } + } + try budget.preflight(gltf) + let buffers = try loadBuffers(gltf.buffers ?? [], baseURL: resourceBaseURL, binaryBuffer: binaryBuffer, budget: &budget) - return try convertToImportResult(gltf, buffers: buffers, baseURL: resourceBaseURL) + var result = try convertToImportResult(gltf, buffers: buffers, baseURL: resourceBaseURL, budget: &budget) + result.importFootprint = budget.footprint + try result.validateGeometry() + return result } /// Bounds allocations before decodeAccessor creates zero-filled storage, including sparse/unused accessors. @@ -163,9 +175,9 @@ public struct NativeGLTFLoader: GLTFLoader { guard data.count >= 12 else { throw GLTFError.invalidGLB } - let magic = data.subdata(in: 0 ..< 4) + let magic = data.subdata(in: 0..<4) let version = readUInt32(data, at: 4) - let declaredLength = Int(readUInt32(data, at: 8)) + guard let declaredLength = Int(exactly: readUInt32(data, at: 8)) else { throw GLTFError.invalidGLB } if magic != Data("glTF".utf8) || version != 2 || declaredLength != data.count { throw GLTFError.invalidGLB @@ -176,19 +188,19 @@ public struct NativeGLTFLoader: GLTFLoader { var binaryBuffer: Data? while offset < data.count { - guard offset + 8 <= data.count else { + guard offset <= data.count - 8 else { throw GLTFError.invalidGLB } - let chunkLength = Int(readUInt32(data, at: offset)) + guard let chunkLength = Int(exactly: readUInt32(data, at: offset)) else { throw GLTFError.invalidGLB } let chunkType = readUInt32(data, at: offset + 4) - guard offset + 8 + chunkLength <= data.count else { + guard chunkLength <= data.count - offset - 8 else { throw GLTFError.invalidGLB } - let chunkData = data.subdata(in: offset + 8 ..< offset + 8 + chunkLength) + let chunkData = data.subdata(in: offset + 8.. [Data] { + private func loadBuffers(_ gltfBuffers: [GLTF.Buffer], baseURL: URL, binaryBuffer: Data?, budget: inout NativeGLTFBudget) throws -> [Data] { var buffers = [Data]() for (index, buffer) in gltfBuffers.enumerated() { @@ -219,10 +231,13 @@ public struct NativeGLTFLoader: GLTFLoader { } if uri.starts(with: "data:") { - try buffers.append(decodeDataURI(uri)) + try budget.preflightDataURI(uri) + let data = try decodeDataURI(uri) + try budget.consumeEncoded(data.count) + buffers.append(data) } else { let bufferURL = try resourceURL(uri, relativeTo: baseURL) - let data = try Data(contentsOf: bufferURL) + let data = try budget.read(bufferURL) buffers.append(data) } @@ -234,15 +249,18 @@ public struct NativeGLTFLoader: GLTFLoader { return buffers } - private func convertToImportResult(_ gltf: GLTF, buffers: [Data], baseURL: URL) throws -> GLTFImportResult { + private func convertToImportResult(_ gltf: GLTF, buffers: [Data], baseURL: URL, budget: inout NativeGLTFBudget) throws -> GLTFImportResult { let images = try (gltf.images ?? []) .map { image -> GLTFImportResult.Image in if let uri = image.uri { if uri.starts(with: "data:") { - return try GLTFImportResult.Image(uri: nil, data: decodeDataURI(uri), mimeType: image.mimeType) + try budget.preflightDataURI(uri) + let data = try decodeDataURI(uri) + try budget.consumeEncoded(data.count) + return GLTFImportResult.Image(uri: nil, data: data, mimeType: image.mimeType) } let imageURL = try resourceURL(uri, relativeTo: baseURL) - return try GLTFImportResult.Image(uri: imageURL, data: Data(contentsOf: imageURL), mimeType: image.mimeType) + return try GLTFImportResult.Image(uri: imageURL, data: budget.read(imageURL), mimeType: image.mimeType) } else if let bufferViewIndex = image.bufferView { let data = try getBufferViewData(bufferViewIndex, gltf: gltf, buffers: buffers) return GLTFImportResult.Image(uri: nil, data: data, mimeType: image.mimeType) @@ -253,7 +271,7 @@ public struct NativeGLTFLoader: GLTFLoader { let samplers = try importSamplers(gltf) let textures = try (gltf.textures ?? []).enumerated().map { index, texture in guard let source = texture.source, images.indices.contains(source), - texture.sampler.map({ samplers.indices.contains($0) }) ?? true + texture.sampler.map({ samplers.indices.contains($0) }) ?? true else { throw GLTFError.invalidTexture(index) } return GLTFImportResult.Texture(source: source, sampler: texture.sampler) } @@ -363,10 +381,10 @@ public struct NativeGLTFLoader: GLTFLoader { } let offset = bufferView.byteOffset ?? 0 let buffer = buffers[bufferView.buffer] - guard offset >= 0, bufferView.byteLength >= 0, offset + bufferView.byteLength <= buffer.count else { + guard offset >= 0, offset <= buffer.count, bufferView.byteLength >= 0, bufferView.byteLength <= buffer.count - offset else { throw GLTFError.bufferOutOfBounds } - return buffer.subdata(in: offset ..< offset + bufferView.byteLength) + return buffer.subdata(in: offset.. URL { @@ -376,7 +394,8 @@ public struct NativeGLTFLoader: GLTFLoader { let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL.path let canonicalTarget = target.resolvingSymlinksInPath().standardizedFileURL.path guard !decoded.hasPrefix("/"), !decoded.contains(":"), !decoded.contains("\\"), - !decoded.contains("?"), !decoded.contains("#"), canonicalTarget.hasPrefix(canonicalRoot + "/") else { + !decoded.contains("?"), !decoded.contains("#"), canonicalTarget.hasPrefix(canonicalRoot + "/") + else { throw GLTFError.invalidDataURI } } diff --git a/Sources/AdaAssets/ModelImportLimits.swift b/Sources/AdaAssets/ModelImportLimits.swift new file mode 100644 index 000000000..d506a9ea5 --- /dev/null +++ b/Sources/AdaAssets/ModelImportLimits.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Per-model import limits for resource pipelines that must bound work before CPU/GPU allocation. +/// General model imports retain their existing behavior when no limits are installed. +public struct ModelImportLimits: Sendable { + public let maximumEncodedBytes: Int + public let maximumDecodedBytes: Int + + public init(maximumEncodedBytes: Int = 64 * 1_024 * 1_024, maximumDecodedBytes: Int = 128 * 1_024 * 1_024) { + self.maximumEncodedBytes = maximumEncodedBytes + self.maximumDecodedBytes = maximumDecodedBytes + } +} + +/// Limits follow the structured asynchronous resource request without modifying process-wide loader configuration. +public enum ModelImportContext { + @TaskLocal public static var limits: ModelImportLimits? +} + +public enum ModelImportLimitError: LocalizedError, Equatable { + case invalidLimits + case encodedBudgetExceeded + case decodedBudgetExceeded + + public var errorDescription: String? { + switch self { + case .invalidLimits: "Model import limits must be positive" + case .encodedBudgetExceeded: "Model root and dependencies exceed the encoded import budget" + case .decodedBudgetExceeded: "Model data exceed the decoded import budget" + } + } +} + +/// Conservative accounting carried between importer and resource preparation stages. +@_spi(Internal) public struct ModelImportFootprint: Sendable { + public let encodedBytes: Int + public let decodedBytes: Int + + public init(encodedBytes: Int, decodedBytes: Int) { + self.encodedBytes = encodedBytes + self.decodedBytes = decodedBytes + } +} diff --git a/Sources/AdaAssets/OBJ/NativeOBJLoader+Budget.swift b/Sources/AdaAssets/OBJ/NativeOBJLoader+Budget.swift new file mode 100644 index 000000000..b2b34cfb0 --- /dev/null +++ b/Sources/AdaAssets/OBJ/NativeOBJLoader+Budget.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Conservative request-local accounting before OBJ token arrays or mesh expansion. +struct OBJImportBudget { + private var remaining: Int? + + init(data: Data) throws { + guard let limits = ModelImportContext.limits else { + remaining = nil + return + } + guard limits.maximumEncodedBytes > 0, limits.maximumDecodedBytes > 0 else { throw ModelImportLimitError.invalidLimits } + guard data.count <= limits.maximumEncodedBytes else { throw ModelImportLimitError.encodedBudgetExceeded } + remaining = limits.maximumDecodedBytes + try charge(1, stride: 256) + // UTF-8 storage and temporary name/number conversions remain bounded too. + try charge(data.count, stride: 4) + var lines = 1 + var visits = 0 + for byte in data { + if byte == 10 { lines += 1 } + visits += 1 + if visits.isMultiple(of: 65_536) { try Task.checkCancellation() } + } + try charge(lines, stride: 64) + } + + var maximumFields: Int { remaining.map { $0 / 512 } ?? Int.max - 1 } + + mutating func fields(_ count: Int, line: Int) throws { + try charge(count, stride: 512) + if remaining != nil, line.isMultiple(of: 256) { try Task.checkCancellation() } + } + + private mutating func charge(_ count: Int, stride: Int) throws { + guard let available = remaining else { + return + } + guard count >= 0, count <= available / stride else { throw ModelImportLimitError.decodedBudgetExceeded } + remaining = available - count * stride + } +} diff --git a/Sources/AdaAssets/OBJ/NativeOBJLoader.swift b/Sources/AdaAssets/OBJ/NativeOBJLoader.swift index c3fc0aed6..9ba6bbf6a 100644 --- a/Sources/AdaAssets/OBJ/NativeOBJLoader.swift +++ b/Sources/AdaAssets/OBJ/NativeOBJLoader.swift @@ -21,24 +21,34 @@ public struct NativeOBJLoader: OBJLoader { public init() {} public func load(url: URL) async throws -> OBJImportResult { - try load(data: Data(contentsOf: url)) + if let limits = ModelImportContext.limits { + guard limits.maximumEncodedBytes > 0, limits.maximumDecodedBytes > 0 else { throw ModelImportLimitError.invalidLimits } + #if WASM + throw AssetError.message("Bounded direct model URL reads are unavailable in the browser; provide root data") + #else + return try load(data: await AssetsManager.readBoundedFile(url: url, maximumBytes: limits.maximumEncodedBytes)) + #endif + } + return try load(data: Data(contentsOf: url)) } /// Loads an OBJ document from its UTF-8 representation. public func load(data: Data) throws -> OBJImportResult { + var budget = try OBJImportBudget(data: data) guard let source = String(bytes: data, encoding: .utf8) else { throw OBJLoadingError.invalidEncoding } - return try parse(source) + return try parse(source, budget: &budget) } - private func parse(_ source: String) throws -> OBJImportResult { + private func parse(_ source: String, budget: inout OBJImportBudget) throws -> OBJImportResult { var state = ParserState() for (lineIndex, rawLine) in source.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { let lineNumber = lineIndex + 1 let line = rawLine.prefix { $0 != "#" } - let fields = line.split(whereSeparator: \Character.isWhitespace) + let fields = line.split(maxSplits: budget.maximumFields + 1, whereSeparator: \Character.isWhitespace) + try budget.fields(fields.count, line: lineNumber) guard let keyword = fields.first else { continue } @@ -101,14 +111,14 @@ public struct NativeOBJLoader: OBJLoader { } private func parseFloat(_ value: Substring, line: Int) throws -> Float { - guard let result = Float(value) else { + guard let result = Float(value), result.isFinite else { throw OBJLoadingError.invalidNumber(line: line, value: String(value)) } return result } private func parseFaceVertex(_ value: Substring, state: ParserState, line: Int) throws -> FaceVertex { - let components = value.split(separator: "/", omittingEmptySubsequences: false) + let components = value.split(separator: "/", maxSplits: 3, omittingEmptySubsequences: false) guard (1...3).contains(components.count), !components[0].isEmpty else { throw OBJLoadingError.invalidFace(line: line) } @@ -150,6 +160,12 @@ private struct FaceVertex: Hashable { let normal: Int? } +private struct OBJVertexSource { + let positions: [Vector3] + let normals: [Vector3] + let textureCoordinates: [Vector2] +} + private struct PrimitiveBuilder { let materialIndex: Int var positions: [Vector3] = [] @@ -160,15 +176,22 @@ private struct PrimitiveBuilder { var hasNormals = false var hasTextureCoordinates = false - mutating func index(for vertex: FaceVertex, state: ParserState) -> UInt32 { + // Numeric identity is compared only across one synchronous append, never dereferenced. + var indexStorage: UInt { + unsafe indices.withUnsafeBufferPointer { buffer in + unsafe UInt(bitPattern: buffer.baseAddress) + } + } + + mutating func index(for vertex: FaceVertex, source: OBJVertexSource) -> UInt32 { if let index = vertexIndices[vertex] { return index } let index = UInt32(positions.count) - positions.append(state.positions[vertex.position]) - normals.append(vertex.normal.map { state.normals[$0] } ?? Vector3()) - textureCoordinates.append(vertex.textureCoordinate.map { state.textureCoordinates[$0] } ?? Vector2()) + positions.append(source.positions[vertex.position]) + normals.append(vertex.normal.map { source.normals[$0] } ?? Vector3()) + textureCoordinates.append(vertex.textureCoordinate.map { source.textureCoordinates[$0] } ?? Vector2()) hasNormals = hasNormals || vertex.normal != nil hasTextureCoordinates = hasTextureCoordinates || vertex.textureCoordinate != nil vertexIndices[vertex] = index @@ -186,20 +209,50 @@ private struct PrimitiveBuilder { } } +private final class OBJLookupMetrics { + private(set) var comparisons: UInt64 = 0 + + func compare() { + if comparisons < .max { comparisons += 1 } + } +} + +private struct OBJMaterialKey: Hashable { + let id: Int + let metrics: OBJLookupMetrics + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.metrics.compare() + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} + private struct MeshBuilder { var name: String var primitives: [PrimitiveBuilder] = [] + let lookupMetrics = OBJLookupMetrics() + var primitiveIndices: [OBJMaterialKey: Int] = [:] var isEmpty: Bool { primitives.allSatisfy(\.indices.isEmpty) } mutating func primitiveIndex(for materialIndex: Int) -> Int { - if let index = primitives.firstIndex(where: { $0.materialIndex == materialIndex }) { - return index + let key = OBJMaterialKey(id: materialIndex, metrics: lookupMetrics) + let proposed = primitives.count + let slot = Self.resolve(&primitiveIndices[key, default: -1], proposed: proposed) + if slot.inserted { primitives.append(PrimitiveBuilder(materialIndex: materialIndex)) } + return slot.index + } + + private static func resolve(_ value: inout Int, proposed: Int) -> (index: Int, inserted: Bool) { + guard value < 0 else { + return (value, false) } - primitives.append(PrimitiveBuilder(materialIndex: materialIndex)) - return primitives.count - 1 + value = proposed + return (proposed, true) } func makeMesh() -> OBJImportResult.Mesh { @@ -219,6 +272,8 @@ private struct ParserState { var meshes: [MeshBuilder] = [MeshBuilder(name: "Default")] var activeMeshIndex = 0 var activeMaterialIndex = 0 + var faceCount = 0 + var copiedIndexBytes: UInt64 = 0 mutating func selectMesh(named name: String) { if meshes[activeMeshIndex].isEmpty { @@ -246,21 +301,36 @@ private struct ParserState { mutating func appendFace(_ vertices: [FaceVertex]) { let primitiveIndex = meshes[activeMeshIndex].primitiveIndex(for: activeMaterialIndex) - var primitive = meshes[activeMeshIndex].primitives[primitiveIndex] - let first = primitive.index(for: vertices[0], state: self) - + let previousStorage = meshes[activeMeshIndex].primitives[primitiveIndex].indexStorage + let previousCount = meshes[activeMeshIndex].primitives[primitiveIndex].indices.count + // Pass only immutable input streams, never a ParserState retaining the destination. + let source = OBJVertexSource(positions: positions, normals: normals, textureCoordinates: textureCoordinates) + let first = meshes[activeMeshIndex].primitives[primitiveIndex].index(for: vertices[0], source: source) for index in 1..<(vertices.count - 1) { - primitive.indices.append(first) - primitive.indices.append(primitive.index(for: vertices[index], state: self)) - primitive.indices.append(primitive.index(for: vertices[index + 1], state: self)) + let second = meshes[activeMeshIndex].primitives[primitiveIndex].index(for: vertices[index], source: source) + let third = meshes[activeMeshIndex].primitives[primitiveIndex].index(for: vertices[index + 1], source: source) + meshes[activeMeshIndex].primitives[primitiveIndex].indices.append(first) + meshes[activeMeshIndex].primitives[primitiveIndex].indices.append(second) + meshes[activeMeshIndex].primitives[primitiveIndex].indices.append(third) + } + if previousCount > 0, previousStorage != meshes[activeMeshIndex].primitives[primitiveIndex].indexStorage { + let bytes = UInt64(previousCount).multipliedReportingOverflow(by: UInt64(MemoryLayout.stride)) + let total = copiedIndexBytes.addingReportingOverflow(bytes.partialValue) + copiedIndexBytes = bytes.overflow || total.overflow ? .max : total.partialValue } - meshes[activeMeshIndex].primitives[primitiveIndex] = primitive + faceCount += 1 } func makeResult() -> OBJImportResult { - OBJImportResult( + var result = OBJImportResult( meshes: meshes.filter { !$0.isEmpty }.map { $0.makeMesh() }, materials: materials ) + let comparisons = meshes.reduce(UInt64(0)) { total, mesh in + let sum = total.addingReportingOverflow(mesh.lookupMetrics.comparisons) + return sum.overflow ? .max : sum.partialValue + } + result.importDiagnostics = .init(faces: faceCount, copiedIndexBytes: copiedIndexBytes, materialComparisons: comparisons) + return result } } diff --git a/Sources/AdaAssets/OBJ/OBJLoader.swift b/Sources/AdaAssets/OBJ/OBJLoader.swift index a985dfec2..04f8bbbfb 100644 --- a/Sources/AdaAssets/OBJ/OBJLoader.swift +++ b/Sources/AdaAssets/OBJ/OBJLoader.swift @@ -11,6 +11,13 @@ public protocol OBJLoader: Sendable { func load(url: URL) async throws -> OBJImportResult } +/// Per-import observations of real parser buffer replacement work. +@_spi(Internal) public struct OBJImportDiagnostics: Sendable { + public let faces: Int + public let copiedIndexBytes: UInt64 + public let materialComparisons: UInt64 +} + /// The result of importing a Wavefront OBJ file. public struct OBJImportResult: Sendable { public struct Material: Sendable { @@ -55,6 +62,7 @@ public struct OBJImportResult: Sendable { public let meshes: [Mesh] public let materials: [Material] + @_spi(Internal) public var importDiagnostics: OBJImportDiagnostics? public init(meshes: [Mesh], materials: [Material]) { self.meshes = meshes diff --git a/Sources/AdaAssets/Terrain/AssetsManager+Terrain.swift b/Sources/AdaAssets/Terrain/AssetsManager+Terrain.swift new file mode 100644 index 000000000..fdfab8e8a --- /dev/null +++ b/Sources/AdaAssets/Terrain/AssetsManager+Terrain.swift @@ -0,0 +1,57 @@ +import AdaUtils +import Foundation + +extension AssetsManager { + #if !WASM + @AssetActor private static var activeTerrainWrites: Set = [] + #endif + + /// Saves height regions, committing the manifest only after all changed region files are ready. + /// The default replaces the complete set. Incremental mode retains untouched references and checks + /// the supplied baseline against disk before staging and committing. Old generations remain readable. + /// Optional volume changes stage their bricks and operation document in the same transaction. + /// A Mesh Terrain manifest cannot be replaced by this height and volume writer. + /// The byte budget covers supplied encoded regions/bricks and resulting metadata. + @AssetActor + public static func saveTerrainRegions( + _ regions: [TerrainRegionAsset], + manifest: TerrainManifestAsset, + at path: String, + mode: TerrainRegionSaveMode = .replaceAll, + volume: TerrainVolumeSave? = nil, + maximumStagedBytes: Int = 128 * 1_024 * 1_024, + onProgress: (@Sendable (Int, Int) async -> Void)? = nil + ) async throws -> TerrainManifestAsset { + #if WASM + throw AssetError.message("Terrain asset saving requires a native writable project directory") + #else + var destination = try resolveWritableAssetPath(at: path).url + var storedPath = path + if destination.pathExtension.isEmpty { + destination.appendPathExtension("aterrain") + let componentStart = path.lastIndex(where: { $0 == "/" || $0 == "\\" }).map { path.index(after: $0) } ?? path.startIndex + let queryStart = path[componentStart...].firstIndex(of: "#") ?? path.endIndex + storedPath.insert(contentsOf: ".aterrain", at: queryStart) + } + guard destination.isFileURL, destination.pathExtension.lowercased() == "aterrain" else { + throw AssetError.message("Terrain manifest destination must be a local .aterrain file") + } + let directory = destination.deletingLastPathComponent().resolvingSymlinksInPath() + let key = directory.appendingPathComponent(destination.lastPathComponent).standardizedFileURL.path + .precomposedStringWithCanonicalMapping.lowercased() + guard activeTerrainWrites.insert(key).inserted else { throw TerrainSaveError.saveInProgress } + defer { activeTerrainWrites.remove(key) } + var updated = try await TerrainAssetWriter.save( + regions, + manifest: manifest, + destination: destination, + mode: mode, + volume: volume, + maximumBytes: maximumStagedBytes, + onProgress: onProgress + ) + updated.assetMetaInfo = AssetMetaInfo(assetId: .init(), assetPath: storedPath, assetName: destination.lastPathComponent, bundlePath: nil) + return updated + #endif + } +} diff --git a/Sources/AdaAssets/Terrain/MeshTerrainAuthoringSession.swift b/Sources/AdaAssets/Terrain/MeshTerrainAuthoringSession.swift new file mode 100644 index 000000000..c78faa849 --- /dev/null +++ b/Sources/AdaAssets/Terrain/MeshTerrainAuthoringSession.swift @@ -0,0 +1,115 @@ +import AdaGeometry +import Foundation + +public enum MeshTerrainSessionError: Error, Sendable { + case busy, invalidTransaction, historyBudgetExceeded, previewFailed +} + +/// One document's provisional interaction and committed Undo/Redo history. +/// Hosts publish each returned immutable generation at their world boundary. +public actor MeshTerrainAuthoringSession { + private struct Revision { + let asset: MeshTerrainDocumentAsset + let bytes: Int + } + private var committed: Revision + private var provisional: Revision? + private var transactionID: UUID? + private var activeToken = UUID() + private var worker: Task? + private var latestFailed = false + private var undoHistory: [Revision] = [] + private var redoHistory: [Revision] = [] + private var historyBytes = 0 + private let maximumHistoryBytes: Int + private let evaluator = MeshTerrainEvaluator() + + public init(asset: MeshTerrainDocumentAsset, maximumHistoryBytes: Int = 64 * 1_024 * 1_024) throws { + _ = try asset.acceptedCompiledProduct() + guard (0...512 * 1_024 * 1_024).contains(maximumHistoryBytes) else { throw MeshTerrainSessionError.historyBudgetExceeded } + let bytes = try asset.encodedData().count * 2 + guard bytes <= maximumHistoryBytes else { throw MeshTerrainSessionError.historyBudgetExceeded } + committed = .init(asset: asset, bytes: bytes) + self.maximumHistoryBytes = maximumHistoryBytes + } + + public var canUndo: Bool { transactionID == nil && !undoHistory.isEmpty } + public var canRedo: Bool { transactionID == nil && !redoHistory.isEmpty } + public var accepted: MeshTerrainDocumentAsset { provisional?.asset ?? committed.asset } + + public func begin() throws -> UUID { + guard transactionID == nil else { throw MeshTerrainSessionError.busy } + let id = UUID(); transactionID = id; provisional = nil; latestFailed = false + return id + } + + /// Cancels superseded work. A late result cannot replace a later preview or + /// a cancelled interaction. Geometry/population preparation runs off the host UI actor. + public func preview(_ candidate: MeshTerrainDocumentAsset, transaction: UUID) async throws -> MeshTerrainDocumentAsset { + guard transactionID == transaction, candidate.document.id == committed.asset.document.id else { throw MeshTerrainSessionError.invalidTransaction } + worker?.cancel() + let token = UUID(); activeToken = token; latestFailed = false + let evaluator = evaluator + let task = Task { try await candidate.compiling(using: evaluator) } + worker = task + do { + let result = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + try Task.checkCancellation() + guard transactionID == transaction, activeToken == token else { throw CancellationError() } + let bytes = try result.encodedData().count * 2 + guard bytes <= maximumHistoryBytes else { throw MeshTerrainSessionError.historyBudgetExceeded } + provisional = .init(asset: result, bytes: bytes); worker = nil + return result + } catch { + if activeToken == token { worker = nil; latestFailed = true } + throw error + } + } + + public func commit(transaction: UUID) throws -> MeshTerrainDocumentAsset { + guard transactionID == transaction else { throw MeshTerrainSessionError.invalidTransaction } + guard worker == nil else { throw MeshTerrainSessionError.busy } + guard !latestFailed else { throw MeshTerrainSessionError.previewFailed } + if let provisional, try provisional.asset.authoringKey() != committed.asset.authoringKey() { + historyBytes -= redoHistory.reduce(0) { $0 + $1.bytes }; redoHistory = [] + undoHistory.append(committed); historyBytes += committed.bytes + committed = provisional + trimHistory() + } + provisional = nil; transactionID = nil + return committed.asset + } + + public func cancel(transaction: UUID) throws -> MeshTerrainDocumentAsset { + guard transactionID == transaction else { throw MeshTerrainSessionError.invalidTransaction } + worker?.cancel(); worker = nil; activeToken = UUID() + provisional = nil; transactionID = nil; latestFailed = false + return committed.asset + } + + public func undo() throws -> MeshTerrainDocumentAsset { + guard transactionID == nil else { throw MeshTerrainSessionError.busy } + guard let revision = undoHistory.popLast() else { return committed.asset } + historyBytes -= revision.bytes + redoHistory.append(committed); historyBytes += committed.bytes + committed = revision; trimHistory() + return committed.asset + } + + public func redo() throws -> MeshTerrainDocumentAsset { + guard transactionID == nil else { throw MeshTerrainSessionError.busy } + guard let revision = redoHistory.popLast() else { return committed.asset } + historyBytes -= revision.bytes + undoHistory.append(committed); historyBytes += committed.bytes + committed = revision; trimHistory() + return committed.asset + } + + private func trimHistory() { + while historyBytes > maximumHistoryBytes || undoHistory.count + redoHistory.count > 64 { + if !undoHistory.isEmpty { historyBytes -= undoHistory.removeFirst().bytes } + else if !redoHistory.isEmpty { historyBytes -= redoHistory.removeFirst().bytes } + else { break } + } + } +} diff --git a/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset+Compilation.swift b/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset+Compilation.swift new file mode 100644 index 000000000..a61d31f15 --- /dev/null +++ b/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset+Compilation.swift @@ -0,0 +1,51 @@ +import AdaGeometry +import Foundation + +extension MeshTerrainDocumentAsset { + private struct AuthoringInput: Encodable { + let algorithm = "AdaMeshTerrain.compiled.v1.manifold3.5.1" + let document: MeshTerrainDocument + let rules: [MeshTerrainPopulationRule] + let manual: [MeshTerrainPlacement] + let overrides: [MeshTerrainPopulationOverride] + } + + /// Geometry and population inputs share one accepted generation. + public func authoringKey() throws -> Data { + try validate() + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + return MeshTerrainContentKey.digest(try encoder.encode(AuthoringInput(document: document, rules: populationRules, + manual: manualPlacements, overrides: populationOverrides))) + } + + /// Builds an immutable player resource. Failures leave this authoring value + /// and any previously accepted compiled product unchanged. + @concurrent + public func compiling(using evaluator: MeshTerrainEvaluator = .init(), budget: TriangleGeometryBudget = .init()) async throws -> Self { + let key = try authoringKey() + let snapshot = try await evaluator.evaluate(document, budget: budget) + let population = try await evaluator.evaluatePopulation( + populationRules, surface: snapshot, document: document, manual: manualPlacements, overrides: populationOverrides + ) + let paint = try MeshTerrainPaintBaker.bake(document: document, surface: snapshot, budget: budget) + try Task.checkCancellation() + let product = MeshTerrainCompiledProduct( + inputKey: key, patches: snapshot.patches.map { .init(id: $0.id, surface: $0.surface, paint: paint.patches[$0.id]) }, + placements: population.placements, paintOrphans: paint.orphans.isEmpty ? nil : paint.orphans, + orphanedPins: population.orphanedPins.isEmpty ? nil : population.orphanedPins + ) + let result = Self(document: document, populationRules: populationRules, manualPlacements: manualPlacements, + populationOverrides: populationOverrides, compiled: product) + try result.validate() + return result + } + + public func acceptedCompiledProduct() throws -> MeshTerrainCompiledProduct { + guard let compiled else { throw TriangleGeometryError.invalidInput } + try validate() + guard compiled.inputKey == (try authoringKey()), Set(compiled.patches.map(\.id)) == Set(document.sections.map(\.id)) else { + throw TriangleGeometryError.invalidInput + } + return compiled + } +} diff --git a/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset.swift b/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset.swift new file mode 100644 index 000000000..ef4146f3f --- /dev/null +++ b/Sources/AdaAssets/Terrain/MeshTerrainDocumentAsset.swift @@ -0,0 +1,161 @@ +import AdaGeometry +import Foundation +import Math + +/// Immutable authoring document plus optional compiled product. Compiled players +/// consume accepted geometry and placement records without running editor kernels. +public struct MeshTerrainDocumentAsset: Asset { + public static let maximumEncodedBytes: Int? = 32 * 1_024 * 1_024 + public var assetMetaInfo: AssetMetaInfo? + public let document: MeshTerrainDocument + public let populationRules: [MeshTerrainPopulationRule] + public let manualPlacements: [MeshTerrainPlacement] + public let populationOverrides: [MeshTerrainPopulationOverride] + public let compiled: MeshTerrainCompiledProduct? + + public init(document: MeshTerrainDocument, populationRules: [MeshTerrainPopulationRule] = [], + manualPlacements: [MeshTerrainPlacement] = [], populationOverrides: [MeshTerrainPopulationOverride] = [], + compiled: MeshTerrainCompiledProduct? = nil) { + self.document = document; self.populationRules = populationRules; self.manualPlacements = manualPlacements + self.populationOverrides = populationOverrides; self.compiled = compiled + } + + private struct Payload: Codable { + let version: Int + let document: MeshTerrainDocument + let populationRules: [MeshTerrainPopulationRule] + let manualPlacements: [MeshTerrainPlacement] + let populationOverrides: [MeshTerrainPopulationOverride] + let compiled: MeshTerrainCompiledProduct? + private enum CodingKeys: CodingKey { case version, document, populationRules, manualPlacements, populationOverrides, compiled } + init(version: Int, document: MeshTerrainDocument, populationRules: [MeshTerrainPopulationRule], manualPlacements: [MeshTerrainPlacement], + populationOverrides: [MeshTerrainPopulationOverride], compiled: MeshTerrainCompiledProduct?) { + self.version = version; self.document = document; self.populationRules = populationRules; self.manualPlacements = manualPlacements + self.populationOverrides = populationOverrides; self.compiled = compiled + } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(Int.self, forKey: .version) + guard version == 1 else { throw TriangleGeometryError.invalidInput } + document = try container.decode(MeshTerrainDocument.self, forKey: .document) + populationRules = try MeshTerrainDecoding.list(MeshTerrainPopulationRule.self, key: .populationRules, in: container, decoder: decoder, maximum: 128) + manualPlacements = try MeshTerrainDecoding.list(MeshTerrainPlacement.self, key: .manualPlacements, in: container, decoder: decoder, maximum: 262_144) + populationOverrides = try MeshTerrainDecoding.list(MeshTerrainPopulationOverride.self, key: .populationOverrides, in: container, decoder: decoder, maximum: 262_144) + compiled = try container.decodeIfPresent(MeshTerrainCompiledProduct.self, forKey: .compiled) + } + } + + public init(data: Data) throws { + guard data.count <= (Self.maximumEncodedBytes ?? 0) else { throw TriangleGeometryError.budgetExceeded } + let decoder = JSONDecoder() + if let key = MeshTerrainDecodingBudget.key { decoder.userInfo[key] = MeshTerrainDecodingBudget() } + let payload = try decoder.decode(Payload.self, from: data) + guard payload.version == 1 else { throw TriangleGeometryError.invalidInput } + self.init(document: payload.document, populationRules: payload.populationRules, manualPlacements: payload.manualPlacements, + populationOverrides: payload.populationOverrides, compiled: payload.compiled) + try validate() + } + + public func validate() throws { + try document.validate() + guard populationRules.count <= 128, manualPlacements.count <= 262_144, populationOverrides.count <= 262_144, + Set(populationRules.map(\.id)).count == populationRules.count, Set(manualPlacements.map(\.id)).count == manualPlacements.count else { + throw TriangleGeometryError.budgetExceeded + } + for rule in populationRules { try rule.validate() } + for placement in manualPlacements { try placement.validate() } + for override in populationOverrides { + try override.pinned?.validate() + guard populationRules.contains(where: { $0.id == override.ruleID }), + override.pinned == nil || override.pinned?.id == override.candidateID else { throw TriangleGeometryError.invalidInput } + } + try compiled?.validate() + if let compiled { + let knownStrokes = Set(document.paint.map(\.id)) + guard compiled.paintOrphans?.allSatisfy(knownStrokes.contains) != false, + !knownStrokes.isEmpty || compiled.patches.allSatisfy({ $0.paint == nil }) else { + throw TriangleGeometryError.invalidInput + } + } + } + + public func encodedData() throws -> Data { + try validate() + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(Payload(version: 1, document: document, populationRules: populationRules, + manualPlacements: manualPlacements, populationOverrides: populationOverrides, compiled: compiled)) + guard data.count <= (Self.maximumEncodedBytes ?? 0) else { throw TriangleGeometryError.budgetExceeded } + return data + } + + public init(from assetDecoder: any AssetDecoder) async throws { try self.init(data: assetDecoder.assetData) } + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { try assetEncoder.encode(encodedData()) } + public static func extensions() -> [String] { ["atmesh"] } +} + +public struct MeshTerrainCompiledProduct: Codable, Equatable, Sendable { + public let inputKey: Data + public let patches: [MeshTerrainCompiledPatch] + public let placements: [MeshTerrainPlacement] + /// Authored strokes for which a compatible visible surface could not be found. + public let paintOrphans: [UUID]? + public let orphanedPins: [MeshTerrainPlacement]? + + public init(inputKey: Data, patches: [MeshTerrainCompiledPatch], placements: [MeshTerrainPlacement], + paintOrphans: [UUID]? = nil, orphanedPins: [MeshTerrainPlacement]? = nil) { + self.inputKey = inputKey; self.patches = patches; self.placements = placements + self.paintOrphans = paintOrphans; self.orphanedPins = orphanedPins + } + + private enum CodingKeys: CodingKey { case inputKey, patches, placements, paintOrphans, orphanedPins } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + inputKey = try container.decode(Data.self, forKey: .inputKey) + patches = try MeshTerrainDecoding.list(MeshTerrainCompiledPatch.self, key: .patches, in: container, decoder: decoder, maximum: 128) + placements = try MeshTerrainDecoding.list(MeshTerrainPlacement.self, key: .placements, in: container, decoder: decoder, maximum: 262_144) + paintOrphans = try container.contains(.paintOrphans) + ? MeshTerrainDecoding.list(UUID.self, key: .paintOrphans, in: container, decoder: decoder, maximum: 4_096) + : nil + orphanedPins = try container.contains(.orphanedPins) + ? MeshTerrainDecoding.list(MeshTerrainPlacement.self, key: .orphanedPins, in: container, decoder: decoder, maximum: 262_144) + : nil + } + public func validate() throws { + guard inputKey.count == 32, patches.count <= 128, placements.count <= 262_144, + Set(patches.map(\.id)).count == patches.count else { throw TriangleGeometryError.budgetExceeded } + if let paintOrphans { + guard paintOrphans.count <= 4_096, Set(paintOrphans).count == paintOrphans.count else { throw TriangleGeometryError.invalidInput } + } + var paintRows = 0 + for patch in patches { + try patch.surface.validate() + try patch.paint?.validate(cornerCount: patch.surface.indices.count) + paintRows += patch.paint?.samples.count ?? 0 + guard paintRows <= 262_144 else { throw TriangleGeometryError.budgetExceeded } + } + for placement in placements { try placement.validate() } + if let orphanedPins { + guard orphanedPins.count <= 262_144, orphanedPins.allSatisfy({ $0.ownership == .pinned }) else { + throw TriangleGeometryError.invalidInput + } + for pin in orphanedPins { try pin.validate() } + } + } +} + +public struct MeshTerrainCompiledPatch: Codable, Equatable, Sendable { + public let id: UUID + public let surface: TriangleSurface + public let paint: MeshTerrainCompiledPaint? + public init(id: UUID, surface: TriangleSurface, paint: MeshTerrainCompiledPaint? = nil) { + self.id = id; self.surface = surface; self.paint = paint + } +} + +extension MeshTerrainPlacement { + public func validate() throws { + try ScalarSolid.validateTransform(transform) + guard normal.x.isFinite, normal.y.isFinite, normal.z.isFinite else { throw TriangleGeometryError.invalidInput } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainAssetWriter.swift b/Sources/AdaAssets/Terrain/TerrainAssetWriter.swift new file mode 100644 index 000000000..54913f97a --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainAssetWriter.swift @@ -0,0 +1,247 @@ +import Foundation + +#if os(Windows) + import WinSDK +#elseif canImport(Darwin) + import Darwin +#elseif os(Android) + import Android +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +/// Selects complete replacement or an incremental edit of an existing manifest generation. +public enum TerrainRegionSaveMode: Sendable { + case replaceAll + /// Retains all untouched references. Removals must match the supplied baseline generation. + case update(removing: [TerrainRegionReference]) +} + +public enum TerrainSaveError: LocalizedError { + case byteBudgetExceeded(Int) + case tooManyRegions + case saveInProgress + case staleManifest + case meshTerrainRequiresMeshWriter + case invalidRemoval + case commitFailed(UInt32) + + public var errorDescription: String? { + switch self { + case let .byteBudgetExceeded(limit): "Terrain save exceeds its encoded byte budget of \(limit)" + case .tooManyRegions: "Terrain save exceeds 16384 region resources" + case .saveInProgress: "Another terrain save is in progress for this manifest" + case .staleManifest: "Terrain manifest differs from the supplied baseline generation" + case .meshTerrainRequiresMeshWriter: "Height and volume saves cannot replace Mesh Terrain. Use the Mesh Terrain editor." + case .invalidRemoval: "Terrain removal must uniquely match an existing reference and cannot also replace that region" + case let .commitFailed(code): "Terrain manifest commit failed with system error \(code)" + } + } +} + +enum TerrainAssetWriter { + private struct Address: Hashable { + let x: Int32 + let z: Int32 + } + + #if !WASM + @concurrent + static func save( + _ regions: [TerrainRegionAsset], + manifest: TerrainManifestAsset, + destination: URL, + mode: TerrainRegionSaveMode = .replaceAll, + volume: TerrainVolumeSave? = nil, + maximumBytes: Int, + onProgress: (@Sendable (Int, Int) async -> Void)? + ) async throws -> TerrainManifestAsset { + try Task.checkCancellation() + guard maximumBytes >= 0 else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + guard regions.count <= 16_384 else { throw TerrainSaveError.tooManyRegions } + try manifest.validate() + guard manifest.meshDocumentFileName == nil else { throw TerrainSaveError.meshTerrainRequiresMeshWriter } + var references: [TerrainRegionReference] = [] + references.reserveCapacity(regions.count) + var encodedBytes = 0 + // Validate the complete immutable input before creating any filesystem entries. + for region in regions { + try Task.checkCancellation() + try region.validate() + guard encodedBytes <= maximumBytes - region.encodedByteCount else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + encodedBytes += region.encodedByteCount + let name = "height-\(region.x)-\(region.z)-\(UUID().uuidString.lowercased()).atregion" + references.append(.init(x: region.x, z: region.z, fileName: name)) + } + // Check duplicate input addresses before attempting to reuse any prior resources. + try TerrainManifestAsset( + spacing: manifest.spacing, + originX: manifest.originX, + originZ: manifest.originZ, + heightRegions: references, + meshPrototypes: manifest.meshPrototypes, + materials: manifest.materials, + volumeFileName: manifest.volumeFileName + ) + .validate() + let directory = destination.deletingLastPathComponent() + var previous: [Address: TerrainRegionReference] = [:] + for ref in manifest.heightRegions { previous[Address(x: ref.x, z: ref.z)] = ref } + let retained = try retainedReferences(mode: mode, manifest: manifest, replacements: references, previous: previous) + let baselineMode: TerrainRegionSaveMode + if case .update = volume?.mode { baselineMode = .update(removing: []) } else { baselineMode = mode } + try await checkBaseline(mode: baselineMode, manifest: manifest, destination: destination) + let volumePlan: TerrainVolumeWritePlan? + if let volume { + volumePlan = try await TerrainVolumeAssetWriter.plan(volume, manifest: manifest, directory: directory, maximumBytes: maximumBytes - encodedBytes) + if let volumePlan { encodedBytes += volumePlan.encodedByteCount } + } else { + if let name = manifest.volumeFileName { try await TerrainVolumeAssetWriter.validateRetained(name, palette: manifest.materials, directory: directory) } + volumePlan = nil + } + var reused = Array(repeating: false, count: regions.count) + for index in regions.indices { + try Task.checkCancellation() + let region = regions[index] + guard let ref = previous[Address(x: region.x, z: region.z)] else { continue } + let file = directory.appendingPathComponent(ref.fileName) + if try await matches(region, file: file) { references[index] = ref; reused[index] = true } + } + let updated = TerrainManifestAsset( + spacing: manifest.spacing, + originX: manifest.originX, + originZ: manifest.originZ, + heightRegions: retained + references, + meshPrototypes: manifest.meshPrototypes, + materials: manifest.materials, + volumeFileName: volumePlan?.fileName ?? manifest.volumeFileName + ) + let manifestBytes = try updated.encodedData() + guard encodedBytes <= maximumBytes - manifestBytes.count else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + try Task.checkCancellation() + + let fileManager = FileManager.default + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + var created: [URL] = [] + var committed = false + defer { + if !committed { + // These exact files were created by this operation with exclusive creation. + for file in created { try? fileManager.removeItem(at: file) } + } + } + let progressTotal = regions.count + (volumePlan?.progressCount ?? 0) + for (index, region) in regions.enumerated() { + try Task.checkCancellation() + if !reused[index] { + let file = directory.appendingPathComponent(references[index].fileName) + let data = try region.encodedData() + try write(data, to: file, created: &created) + } + await onProgress?(index + 1, progressTotal) + try Task.checkCancellation() + } + if let volumePlan { + try await volumePlan.stage(directory: directory, created: &created, progressOffset: regions.count, total: progressTotal, onProgress: onProgress) + } + let temporary = directory.appendingPathComponent(".ada-terrain-\(UUID().uuidString).tmp") + try write(manifestBytes, to: temporary, created: &created) + try Task.checkCancellation() + try await checkBaseline(mode: baselineMode, manifest: manifest, destination: destination) + try Task.checkCancellation() + try commit(temporary: temporary, destination: destination) + // No cancellation check after the commit: the returned generation is already published. + committed = true + return updated + } + + private static func retainedReferences( + mode: TerrainRegionSaveMode, + manifest: TerrainManifestAsset, + replacements: [TerrainRegionReference], + previous: [Address: TerrainRegionReference] + ) throws -> [TerrainRegionReference] { + guard case let .update(removing) = mode else { + return [] + } + guard removing.count <= 16_384 else { throw TerrainSaveError.tooManyRegions } + let replaced = Set(replacements.map { Address(x: $0.x, z: $0.z) }) + var removed: Set
= [] + for reference in removing { + let address = Address(x: reference.x, z: reference.z) + guard previous[address] == reference, !replaced.contains(address), removed.insert(address).inserted else { + throw TerrainSaveError.invalidRemoval + } + } + let retained = manifest.heightRegions.filter { + let address = Address(x: $0.x, z: $0.z) + return !replaced.contains(address) && !removed.contains(address) + } + guard retained.count <= 16_384 - replacements.count else { throw TerrainSaveError.tooManyRegions } + return retained + } + + private static func checkBaseline(mode: TerrainRegionSaveMode, manifest: TerrainManifestAsset, destination: URL) async throws { + let requiresBaseline: Bool + if case .update = mode { requiresBaseline = true } else { requiresBaseline = false } + guard requiresBaseline || FileManager.default.fileExists(atPath: destination.path) else { return } + let current: TerrainManifestAsset + do { + let bytes = try await AssetsManager.readBoundedFile(url: destination, maximumBytes: 4 * 1_024 * 1_024) + current = try TerrainManifestAsset(data: bytes) + } catch is CancellationError { throw CancellationError() } catch { throw TerrainSaveError.staleManifest } + guard current.meshDocumentFileName == nil else { throw TerrainSaveError.meshTerrainRequiresMeshWriter } + if requiresBaseline { + guard try current.encodedData() == manifest.encodedData() else { throw TerrainSaveError.staleManifest } + } + } + + private static func matches(_ region: TerrainRegionAsset, file: URL) async throws -> Bool { + guard let info = try? file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + info.isRegularFile == true, info.isSymbolicLink != true + else { return false } + let expected = try region.encodedData() + let existing = try? await AssetsManager.readBoundedFile(url: file, maximumBytes: expected.count) + try Task.checkCancellation() + guard let existing else { + return false + } + return existing == expected + } + + static func write(_ data: Data, to url: URL, created: inout [URL]) throws { + try data.write(to: url, options: .withoutOverwriting) + created.append(url) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.synchronize() + } + + private static func commit(temporary: URL, destination: URL) throws { + #if os(Windows) + // Both UTF-16 buffers remain alive until the same-directory atomic rename returns. + let result = unsafe temporary.path.withCString(encodedAs: UTF16.self) { source in + unsafe destination.path.withCString(encodedAs: UTF16.self) { target in + let moved = unsafe MoveFileExW(source, target, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + return (moved, GetLastError()) + } + } + guard result.0 else { throw TerrainSaveError.commitFailed(result.1) } + #elseif canImport(Darwin) || os(Android) || canImport(Glibc) || canImport(Musl) + // POSIX rename replaces a same-filesystem destination atomically. Capture errno + // before either temporary string buffer is released. + let result = unsafe temporary.path.withCString { source in + unsafe destination.path.withCString { target in + let status = unsafe rename(source, target) + return (status, errno) + } + } + guard result.0 == 0 else { throw TerrainSaveError.commitFailed(UInt32(result.1)) } + #else + throw AssetError.message("Atomic terrain saving is unavailable on this native platform") + #endif + } + #endif +} diff --git a/Sources/AdaAssets/Terrain/TerrainLayerReference.swift b/Sources/AdaAssets/Terrain/TerrainLayerReference.swift new file mode 100644 index 000000000..5bd42f4b0 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainLayerReference.swift @@ -0,0 +1,82 @@ +import Foundation +import Math + +public enum TerrainMaterialProjection: String, Codable, Sendable { + case planar, triplanar +} + +/// A stable terrain layer; texture paths are relative to the terrain manifest. +public struct TerrainLayerReference: Codable, Equatable, Sendable { + public let id: UInt16 + public let name: String + public let albedoPath: String? + public let normalPath: String? + /// Linear ambient-occlusion/roughness/metallic channels in R/G/B respectively. + public let ormPath: String? + public let blendHeightPath: String? + /// Terrain-local units per texture repetition. + public let scale: Float + public let tint: Vector4 + public let roughness: Float + public let metallic: Float + public let projection: TerrainMaterialProjection + public let variation: TerrainTextureVariation + + public init( + id: UInt16, + name: String, + albedoPath: String? = nil, + normalPath: String? = nil, + ormPath: String? = nil, + blendHeightPath: String? = nil, + scale: Float = 1, + tint: Vector4 = [1, 1, 1, 1], + roughness: Float = 1, + metallic: Float = 0, + projection: TerrainMaterialProjection = .planar, + variation: TerrainTextureVariation = .init() + ) { + self.id = id; self.name = name; self.albedoPath = albedoPath; self.normalPath = normalPath; self.ormPath = ormPath + self.variation = variation + self.blendHeightPath = blendHeightPath; self.scale = scale; self.tint = tint; self.roughness = roughness; self.metallic = metallic; self.projection = projection + } + + private enum CodingKeys: CodingKey { case id, name, albedoPath, normalPath, ormPath, blendHeightPath, scale, tint, roughness, metallic, projection, variation } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try container.decode(UInt16.self, forKey: .id), + name: try container.decode(String.self, forKey: .name), + albedoPath: try container.decodeIfPresent(String.self, forKey: .albedoPath), + normalPath: try container.decodeIfPresent(String.self, forKey: .normalPath), + ormPath: try container.decodeIfPresent(String.self, forKey: .ormPath), + blendHeightPath: try container.decodeIfPresent(String.self, forKey: .blendHeightPath), + scale: try container.decodeIfPresent(Float.self, forKey: .scale) ?? 1, + tint: try container.decodeIfPresent(Vector4.self, forKey: .tint) ?? [1, 1, 1, 1], + roughness: try container.decodeIfPresent(Float.self, forKey: .roughness) ?? 1, + metallic: try container.decodeIfPresent(Float.self, forKey: .metallic) ?? 0, + projection: try container.decodeIfPresent(TerrainMaterialProjection.self, forKey: .projection) ?? .planar, + variation: try container.decodeIfPresent(TerrainTextureVariation.self, forKey: .variation) ?? .init() + ) + } + + func validate() throws { + try variation.validate() + guard !name.isEmpty, name.utf8.count <= 128, !name.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }), + scale.isFinite, scale > 0, roughness.isFinite, (0...1).contains(roughness), metallic.isFinite, (0...1).contains(metallic), + [tint.x, tint.y, tint.z, tint.w].allSatisfy({ $0.isFinite && (0...1).contains($0) }) + else { + throw TerrainManifestError.malformed("invalid terrain material layer") + } + for path in [albedoPath, normalPath, ormPath, blendHeightPath].compactMap({ $0 }) { + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard !path.isEmpty, path.utf8.count <= 512, + !path.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 || "\\:%?#\"<>|*".unicodeScalars.contains($0) }), + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && $0.utf8.count <= 128 && $0.last != "." && $0.last != " " }) + else { + throw TerrainManifestError.malformed("invalid terrain material texture reference") + } + } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainManifestAsset.swift b/Sources/AdaAssets/Terrain/TerrainManifestAsset.swift new file mode 100644 index 000000000..1b33aea3e --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainManifestAsset.swift @@ -0,0 +1,266 @@ +import Foundation + +public enum TerrainManifestError: LocalizedError { + case malformed(String) + case unsupportedVersion(Int) + + public var errorDescription: String? { + switch self { + case let .malformed(reason): "Invalid terrain manifest: \(reason)" + case let .unsupportedVersion(version): "Unsupported terrain manifest version: \(version)" + } + } +} + +/// A canonical region payload in the same asset directory as its manifest. +public struct TerrainRegionReference: Codable, Hashable, Sendable { + public let x: Int32 + public let z: Int32 + public let fileName: String + + public init(x: Int32, z: Int32, fileName: String) { self.x = x; self.z = z; self.fileName = fileName } +} + +/// Portable terrain layout, static model palette and independently addressable region resources. +public struct TerrainManifestAsset: Asset { + public static let maximumEncodedBytes: Int? = 4 * 1_024 * 1_024 + public var assetMetaInfo: AssetMetaInfo? + public let spacing: Float + public let originX: Float + public let originZ: Float + public let heightRegions: [TerrainRegionReference] + public let meshPrototypes: [TerrainMeshPrototypeReference] + public let materials: TerrainMaterialPalette? + /// Optional immutable operation/cache document in the manifest directory. + public let volumeFileName: String? + /// Version 5 triangle-surface document. Mutually exclusive with height/volume payloads. + public let meshDocumentFileName: String? + + private static let maximumRegions = 16_384 + + private struct Address: Hashable { + let x: Int32 + let z: Int32 + } + + private struct References { + var addresses: Set
= [] + var names: Set = [] + + mutating func validate(_ region: TerrainRegionReference) throws { + try TerrainManifestAsset.validateReference(region) + guard addresses.insert(Address(x: region.x, z: region.z)).inserted, + names.insert(region.fileName.lowercased()).inserted + else { throw TerrainManifestError.malformed("duplicate region reference") } + } + } + + private struct Payload: Codable { + var version: Int + var spacing: Float + var originX: Float + var originZ: Float + var heightRegions: [TerrainRegionReference] + var meshPrototypes: [TerrainMeshPrototypeReference] + var materials: TerrainMaterialPalette? + var volumeFileName: String? + var meshDocumentFileName: String? + + enum CodingKeys: CodingKey { case version, spacing, originX, originZ, heightRegions, meshPrototypes, materials, volumeFileName, meshDocumentFileName } + + init( + version: Int, + spacing: Float, + originX: Float, + originZ: Float, + heightRegions: [TerrainRegionReference], + meshPrototypes: [TerrainMeshPrototypeReference], + materials: TerrainMaterialPalette?, + volumeFileName: String?, + meshDocumentFileName: String? + ) { + self.version = version; self.spacing = spacing; self.originX = originX; self.originZ = originZ; self.heightRegions = heightRegions; self.meshPrototypes = meshPrototypes + self.materials = materials; self.volumeFileName = volumeFileName + self.meshDocumentFileName = meshDocumentFileName + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(version, forKey: .version) + try container.encode(spacing, forKey: .spacing) + try container.encode(originX, forKey: .originX) + try container.encode(originZ, forKey: .originZ) + try container.encode(heightRegions, forKey: .heightRegions) + if !meshPrototypes.isEmpty { try container.encode(meshPrototypes, forKey: .meshPrototypes) } + try container.encodeIfPresent(materials, forKey: .materials) + try container.encodeIfPresent(volumeFileName, forKey: .volumeFileName) + try container.encodeIfPresent(meshDocumentFileName, forKey: .meshDocumentFileName) + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(Int.self, forKey: .version) + guard (1...5).contains(version) else { throw TerrainManifestError.unsupportedVersion(version) } + meshDocumentFileName = try container.decodeIfPresent(String.self, forKey: .meshDocumentFileName) + if let name = meshDocumentFileName { + guard version == 5 else { throw TerrainManifestError.malformed("mesh surface document requires version 5") } + try TerrainManifestAsset.validateMeshReference(name) + } else if version == 5 { throw TerrainManifestError.malformed("version 5 requires a mesh surface document") } + spacing = try container.decode(Float.self, forKey: .spacing) + originX = try container.decode(Float.self, forKey: .originX) + originZ = try container.decode(Float.self, forKey: .originZ) + try TerrainManifestAsset.validateConfiguration(spacing: spacing, originX: originX, originZ: originZ) + if container.contains(.volumeFileName) { + guard version == 4 else { throw TerrainManifestError.malformed("volume document requires version 4") } + let name = try container.decode(String.self, forKey: .volumeFileName) + try TerrainManifestAsset.validateVolumeReference(name) + volumeFileName = name + } else { volumeFileName = nil } + + if container.contains(.materials) { + guard version >= 3 else { throw TerrainManifestError.malformed("material palette requires version 3") } + materials = try container.decode(TerrainMaterialPalette.self, forKey: .materials) + } else { + materials = nil + } + meshPrototypes = [] + if container.contains(.meshPrototypes) { + var palette = try container.nestedUnkeyedContainer(forKey: .meshPrototypes) + if let count = palette.count, count > 256 { throw TerrainManifestError.malformed("mesh palette exceeds 256 entries") } + var ids: Set = [] + while !palette.isAtEnd { + guard version >= 2, meshPrototypes.count < 256 else { throw TerrainManifestError.malformed("invalid mesh palette version or count") } + let reference = try palette.decode(TerrainMeshPrototypeReference.self) + try reference.validate() + guard ids.insert(reference.id).inserted else { throw TerrainManifestError.malformed("duplicate mesh prototype ID") } + meshPrototypes.append(reference) + } + } + var list = try container.nestedUnkeyedContainer(forKey: .heightRegions) + if let count = list.count, count > maximumRegions { throw TerrainManifestError.malformed("region table exceeds 16384 entries") } + heightRegions = [] + heightRegions.reserveCapacity(list.count ?? 0) + var references = References() + while !list.isAtEnd { + guard heightRegions.count < maximumRegions else { throw TerrainManifestError.malformed("region table exceeds 16384 entries") } + let region = try list.decode(TerrainRegionReference.self) + try references.validate(region) + heightRegions.append(region) + } + } + } + + public init( + spacing: Float = 1, + originX: Float = 0, + originZ: Float = 0, + heightRegions: [TerrainRegionReference] = [], + meshPrototypes: [TerrainMeshPrototypeReference] = [], + materials: TerrainMaterialPalette? = nil, + volumeFileName: String? = nil, + meshDocumentFileName: String? = nil + ) { + self.spacing = spacing; self.originX = originX; self.originZ = originZ; self.heightRegions = heightRegions; self.meshPrototypes = meshPrototypes + self.materials = materials; self.volumeFileName = volumeFileName + self.meshDocumentFileName = meshDocumentFileName + } + + public init(data: Data) throws { + guard data.count <= 4 * 1_024 * 1_024 else { throw TerrainManifestError.malformed("encoded byte budget exceeded") } + let payload = try JSONDecoder().decode(Payload.self, from: data) + self.init( + spacing: payload.spacing, + originX: payload.originX, + originZ: payload.originZ, + heightRegions: payload.heightRegions, + meshPrototypes: payload.meshPrototypes, + materials: payload.materials, + volumeFileName: payload.volumeFileName, + meshDocumentFileName: payload.meshDocumentFileName + ) + try validate() + } + + public func encodedData() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode( + Payload( + version: meshDocumentFileName != nil ? 5 : (volumeFileName != nil ? 4 : (materials != nil ? 3 : (meshPrototypes.isEmpty ? 1 : 2))), + spacing: spacing, + originX: originX, + originZ: originZ, + heightRegions: heightRegions, + meshPrototypes: meshPrototypes, + materials: materials, + volumeFileName: volumeFileName, + meshDocumentFileName: meshDocumentFileName + ) + ) + } + + /// Validates resource names, spatial bounds and the bounded region table before serialization. + public func validate() throws { + try Self.validateConfiguration(spacing: spacing, originX: originX, originZ: originZ) + try materials?.validate() + if let volumeFileName { try Self.validateVolumeReference(volumeFileName) } + if let meshDocumentFileName { + try Self.validateMeshReference(meshDocumentFileName) + guard volumeFileName == nil, heightRegions.isEmpty else { throw TerrainManifestError.malformed("mesh surface and heightfield payloads cannot be mixed") } + } + guard heightRegions.count <= Self.maximumRegions else { throw TerrainManifestError.malformed("region table exceeds 16384 entries") } + guard meshPrototypes.count <= 256 else { throw TerrainManifestError.malformed("mesh palette exceeds 256 entries") } + var ids: Set = [] + for reference in meshPrototypes { + try reference.validate() + guard ids.insert(reference.id).inserted else { throw TerrainManifestError.malformed("duplicate mesh prototype ID") } + } + var references = References() + for region in heightRegions { try references.validate(region) } + } + + private static func validateMeshReference(_ name: String) throws { + let bytes = name.utf8 + guard !bytes.isEmpty, bytes.count <= 128, name.hasSuffix(".atmesh"), let first = bytes.first, isAlphanumeric(first), + bytes.allSatisfy({ isAlphanumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }) + else { throw TerrainManifestError.malformed("mesh document must be a local ASCII basename ending in .atmesh") } + } + + private static func validateVolumeReference(_ name: String) throws { + let bytes = name.utf8 + guard !bytes.isEmpty, bytes.count <= 128, name.hasSuffix(".atvolume"), let first = bytes.first, isAlphanumeric(first), + bytes.allSatisfy({ isAlphanumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }) + else { throw TerrainManifestError.malformed("volume document file must be an ASCII basename ending in .atvolume, at most 128 bytes") } + } + + private static func validateConfiguration(spacing: Float, originX: Float, originZ: Float) throws { + let extent = Float(1_048_576) * spacing + guard spacing.isFinite, spacing > 0, originX.isFinite, originZ.isFinite, extent.isFinite, + (originX - extent).isFinite, (originX + extent).isFinite, (originZ - extent).isFinite, (originZ + extent).isFinite + else { + throw TerrainManifestError.malformed("invalid sample spacing or origin") + } + } + + private static func validateReference(_ region: TerrainRegionReference) throws { + guard (-4_096...4_096).contains(region.x), (-4_096...4_096).contains(region.z) else { + throw TerrainManifestError.malformed("region address is outside the supported grid") + } + let bytes = region.fileName.utf8 + guard !bytes.isEmpty, bytes.count <= 128, region.fileName.hasSuffix(".atregion"), + let first = bytes.first, isAlphanumeric(first), + bytes.allSatisfy({ isAlphanumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }) + else { + throw TerrainManifestError.malformed("region file must be an ASCII basename ending in .atregion, at most 128 bytes") + } + } + + private static func isAlphanumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + + public init(from assetDecoder: any AssetDecoder) async throws { try self.init(data: assetDecoder.assetData) } + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { try assetEncoder.encode(encodedData()) } + public static func extensions() -> [String] { ["aterrain"] } +} diff --git a/Sources/AdaAssets/Terrain/TerrainMaterialPalette.swift b/Sources/AdaAssets/Terrain/TerrainMaterialPalette.swift new file mode 100644 index 000000000..04aea30da --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainMaterialPalette.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Bounded authoring palette. Display order never changes the stable IDs stored by paint and rules. +public struct TerrainMaterialPalette: Codable, Equatable, Sendable { + public let layers: [TerrainLayerReference] + public let fallbackLayerID: UInt16 + public let interiorLayerID: UInt16 + public let rules: [TerrainMaterialRule] + + public init(layers: [TerrainLayerReference], fallbackLayerID: UInt16, interiorLayerID: UInt16? = nil, rules: [TerrainMaterialRule] = []) { + self.layers = layers; self.fallbackLayerID = fallbackLayerID; self.interiorLayerID = interiorLayerID ?? fallbackLayerID; self.rules = rules + } + + private enum CodingKeys: CodingKey { case layers, fallbackLayerID, interiorLayerID, rules } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + func list(_ type: Value.Type, key: CodingKeys, maximum: Int) throws -> [Value] { + guard container.contains(key) else { + return [] + } + var input = try container.nestedUnkeyedContainer(forKey: key) + if let count = input.count, count > maximum { throw TerrainManifestError.malformed("terrain material palette count exceeded") } + var values: [Value] = [] + while !input.isAtEnd { + guard values.count < maximum else { throw TerrainManifestError.malformed("terrain material palette count exceeded") } + values.append(try input.decode(type)) + } + return values + } + self.init( + layers: try list(TerrainLayerReference.self, key: .layers, maximum: 32), + fallbackLayerID: try container.decode(UInt16.self, forKey: .fallbackLayerID), + interiorLayerID: try container.decodeIfPresent(UInt16.self, forKey: .interiorLayerID), + rules: try list(TerrainMaterialRule.self, key: .rules, maximum: 128) + ) + try validate() + } + + public func validate() throws { + guard (1...32).contains(layers.count), rules.count <= 128 else { throw TerrainManifestError.malformed("invalid terrain material palette count") } + var ids: Set = [] + for layer in layers { + try layer.validate() + guard ids.insert(layer.id).inserted else { throw TerrainManifestError.malformed("duplicate terrain material layer ID") } + } + guard ids.contains(fallbackLayerID), ids.contains(interiorLayerID) else { throw TerrainManifestError.malformed("missing terrain fallback layer") } + for rule in rules { + try rule.validate() + guard ids.contains(rule.layerID) else { throw TerrainManifestError.malformed("missing terrain rule layer") } + } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainMaterialRule.swift b/Sources/AdaAssets/Terrain/TerrainMaterialRule.swift new file mode 100644 index 000000000..3e611d8fa --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainMaterialRule.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Terrain-local range, with a smooth transition outside its fully weighted interval. +public struct TerrainMaterialRange: Codable, Equatable, Sendable { + public let minimum: Float + public let maximum: Float + public let transition: Float + + public init(minimum: Float, maximum: Float, transition: Float = 0) { + self.minimum = minimum; self.maximum = maximum; self.transition = transition + } + + private enum CodingKeys: CodingKey { case minimum, maximum, transition } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + minimum: try container.decode(Float.self, forKey: .minimum), + maximum: try container.decode(Float.self, forKey: .maximum), + transition: try container.decodeIfPresent(Float.self, forKey: .transition) ?? 0 + ) + } + + func validate() throws { + guard minimum.isFinite, maximum.isFinite, minimum <= maximum, transition.isFinite, transition >= 0 else { + throw TerrainManifestError.malformed("invalid terrain material range") + } + } +} + +/// A saved automatic contribution. Height/curvature use terrain-local units, slope uses degrees. +public struct TerrainMaterialRule: Codable, Equatable, Sendable { + public let layerID: UInt16 + public let enabled: Bool + public let weight: Float + public let height: TerrainMaterialRange? + public let slope: TerrainMaterialRange? + public let curvature: TerrainMaterialRange? + public let seed: UInt64 + public let noiseFrequency: Float + public let noiseStrength: Float + public let maskID: UInt16? + + public init( + layerID: UInt16, + enabled: Bool = true, + weight: Float = 1, + height: TerrainMaterialRange? = nil, + slope: TerrainMaterialRange? = nil, + curvature: TerrainMaterialRange? = nil, + seed: UInt64 = 0, + noiseFrequency: Float = 0.05, + noiseStrength: Float = 0, + maskID: UInt16? = nil + ) { + self.layerID = layerID; self.enabled = enabled; self.weight = weight; self.height = height; self.slope = slope + self.curvature = curvature; self.seed = seed; self.noiseFrequency = noiseFrequency; self.noiseStrength = noiseStrength; self.maskID = maskID + } + + private enum CodingKeys: CodingKey { case layerID, enabled, weight, height, slope, curvature, seed, noiseFrequency, noiseStrength, maskID } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + layerID: try container.decode(UInt16.self, forKey: .layerID), + enabled: try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true, + weight: try container.decodeIfPresent(Float.self, forKey: .weight) ?? 1, + height: try container.decodeIfPresent(TerrainMaterialRange.self, forKey: .height), + slope: try container.decodeIfPresent(TerrainMaterialRange.self, forKey: .slope), + curvature: try container.decodeIfPresent(TerrainMaterialRange.self, forKey: .curvature), + seed: try container.decodeIfPresent(UInt64.self, forKey: .seed) ?? 0, + noiseFrequency: try container.decodeIfPresent(Float.self, forKey: .noiseFrequency) ?? 0.05, + noiseStrength: try container.decodeIfPresent(Float.self, forKey: .noiseStrength) ?? 0, + maskID: try container.decodeIfPresent(UInt16.self, forKey: .maskID) + ) + } + + func validate() throws { + guard weight.isFinite, weight >= 0, weight <= 1_000_000, noiseFrequency.isFinite, noiseFrequency > 0, + noiseStrength.isFinite, (0...1).contains(noiseStrength) + else { + throw TerrainManifestError.malformed("invalid terrain material rule") + } + try height?.validate(); try slope?.validate(); try curvature?.validate() + if let slope, slope.minimum < 0 || slope.maximum > 90 { throw TerrainManifestError.malformed("terrain material slope must be in 0...90 degrees") } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainMeshPrototypeReference.swift b/Sources/AdaAssets/Terrain/TerrainMeshPrototypeReference.swift new file mode 100644 index 000000000..46d2cf4e1 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainMeshPrototypeReference.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A stable palette entry. Paths are portable, relative to the manifest directory. +public struct TerrainMeshPrototypeReference: Codable, Equatable, Sendable { + public let id: UInt16 + public let assetPath: String + public let scene: Int? + /// Optional world-space draw distance; nil retains the instance at every distance. + public let maximumDistance: Float? + + public init(id: UInt16, assetPath: String, scene: Int? = nil, maximumDistance: Float? = nil) { + self.id = id; self.assetPath = assetPath; self.scene = scene; self.maximumDistance = maximumDistance + } + + func validate() throws { + let components = assetPath.split(separator: "/", omittingEmptySubsequences: false) + guard !assetPath.isEmpty, assetPath.utf8.count <= 512, + !assetPath.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 || "\\:%?#\"<>|*".unicodeScalars.contains($0) }), + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && $0.utf8.count <= 128 && $0.last != "." && $0.last != " " }), + ["gltf", "glb", "obj", "vox"].contains(URL(fileURLWithPath: assetPath).pathExtension.lowercased()), + scene.map({ (0..<4_096).contains($0) }) ?? true, + maximumDistance.map({ $0.isFinite && $0 > 0 }) ?? true + else { throw TerrainManifestError.malformed("invalid static model palette reference") } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionAsset+Instances.swift b/Sources/AdaAssets/Terrain/TerrainRegionAsset+Instances.swift new file mode 100644 index 000000000..007d4a1ed --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionAsset+Instances.swift @@ -0,0 +1,166 @@ +import Foundation +import Math + +extension TerrainRegionAsset { + static let maximumInstances = 65_536 + static let instanceRecordBytes = 64 + + var instanceEncodedByteCount: Int { + Self.encodedSize(tileCount: tiles.count, pageCount: pages.count, instanceCount: instances.count, surfaceCount: 0, ruleMaskCount: 0) + } + + func encodedInstanceData() throws -> Data { + var height = Data() + if !tiles.isEmpty || !pages.isEmpty { + let base = TerrainRegionAsset(x: x, z: z, tiles: tiles, pages: pages) + height = Data(try base.encodedData().dropFirst(16)) + } + var bytes = Data("ADATRN02".utf8) + bytes.reserveCapacity(instanceEncodedByteCount) + InstanceWords.append(UInt32(bitPattern: x), to: &bytes) + InstanceWords.append(UInt32(bitPattern: z), to: &bytes) + InstanceWords.append(UInt32(height.isEmpty ? 1 : 2), to: &bytes) + InstanceWords.append(UInt32(0), to: &bytes) + if !height.isEmpty { + Self.appendSection(tag: 1, count: UInt32(tiles.count + pages.count), length: height.count, to: &bytes) + } + Self.appendSection(tag: 2, count: UInt32(instances.count), length: instances.count * Self.instanceRecordBytes, to: &bytes) + bytes.append(height) + for instance in instances { + InstanceWords.append(instance.id, to: &bytes) + InstanceWords.append(instance.prototypeID, to: &bytes) + InstanceWords.append(instance.flags.rawValue, to: &bytes) + InstanceWords.append(instance.localPosition.x.bitPattern, to: &bytes) + InstanceWords.append(instance.localPosition.y.bitPattern, to: &bytes) + InstanceWords.append(instance.localPosition.z.bitPattern, to: &bytes) + InstanceWords.append(instance.rotationBits, to: &bytes) + InstanceWords.append(instance.scale.x.bitPattern, to: &bytes) + InstanceWords.append(instance.scale.y.bitPattern, to: &bytes) + InstanceWords.append(instance.scale.z.bitPattern, to: &bytes) + InstanceWords.append(instance.tintBits, to: &bytes) + InstanceWords.append(instance.seed, to: &bytes) + InstanceWords.append(instance.normalBits, to: &bytes) + InstanceWords.append(instance.anchorOffset.bitPattern, to: &bytes) + InstanceWords.append(UInt32(0), to: &bytes) + } + return bytes + } + + private static func appendSection(tag: UInt16, count: UInt32, length: Int, to data: inout Data) { + InstanceWords.append(tag, to: &data) + InstanceWords.append(UInt16(1), to: &data) + InstanceWords.append(count, to: &data) + InstanceWords.append(UInt64(length), to: &data) + } + + static func decodeInstanceData(_ data: Data) throws -> TerrainRegionAsset { + var reader = InstanceReader(bytes: Array(data), offset: 8) + let x = Int32(bitPattern: try reader.uint32()), z = Int32(bitPattern: try reader.uint32()) + try TerrainRegionAsset(x: x, z: z, tiles: [], pages: []).validate() + let sectionCount = try reader.uint32(), flags = try reader.uint32() + guard (1...2).contains(sectionCount), flags == 0, reader.bytes.count >= 24 + Int(sectionCount) * 16 else { + throw TerrainRegionError.malformed("invalid section table") + } + struct Section { + let tag: UInt16 + let count: Int + let offset: Int + let length: Int + } + var sections: [Section] = [] + var payloadOffset = 24 + Int(sectionCount) * 16 + var previousTag: UInt16 = 0 + for _ in 0.. previousTag, payloadOffset <= reader.bytes.count, length <= UInt64(reader.bytes.count - payloadOffset) else { + throw TerrainRegionError.malformed("duplicate, unordered or truncated section") + } + if tag == 1 { + guard count > 0, count <= 32, length <= 512 * 1_024 - 16 else { throw TerrainRegionError.malformed("height section budget exceeded") } + } else { + guard count > 0, count <= UInt32(maximumInstances), length == UInt64(count) * UInt64(instanceRecordBytes) else { + throw TerrainRegionError.malformed("instance section count or length mismatch") + } + } + sections.append(Section(tag: tag, count: Int(count), offset: payloadOffset, length: Int(length))) + payloadOffset += Int(length) + previousTag = tag + } + guard payloadOffset == reader.bytes.count, sections.last?.tag == 2 else { + throw TerrainRegionError.malformed("unexpected trailing data or missing placement section") + } + var heights = TerrainRegionAsset(x: x, z: z, tiles: [], pages: []) + var records: [TerrainRegionInstance] = [] + for section in sections { + if section.tag == 1 { + var legacy = Data("ADATRN01".utf8) + InstanceWords.append(UInt32(bitPattern: x), to: &legacy) + InstanceWords.append(UInt32(bitPattern: z), to: &legacy) + legacy.append(contentsOf: reader.bytes[section.offset..<(section.offset + section.length)]) + heights = try TerrainRegionAsset(data: legacy) + guard heights.tiles.count + heights.pages.count == section.count else { + throw TerrainRegionError.malformed("height section record count mismatch") + } + } else { + reader.offset = section.offset + records.reserveCapacity(section.count) + for _ in 0..(_ value: T, to data: inout Data) { + for shift in stride(from: 0, to: T.bitWidth, by: 8) { data.append(UInt8(truncatingIfNeeded: value >> shift)) } + } +} + +private struct InstanceReader { + let bytes: [UInt8] + var offset: Int + + mutating func uint16() throws -> UInt16 { try UInt16(word(bytes: 2)) } + mutating func uint32() throws -> UInt32 { try UInt32(word(bytes: 4)) } + mutating func uint64() throws -> UInt64 { try word(bytes: 8) } + mutating func float() throws -> Float { Float(bitPattern: try uint32()) } + + private mutating func word(bytes count: Int) throws -> UInt64 { + guard offset <= bytes.count, count <= bytes.count - offset else { throw TerrainRegionError.malformed("truncated placement record") } + var result: UInt64 = 0 + for shift in stride(from: 0, to: count * 8, by: 8) { + result |= UInt64(bytes[offset]) << shift + offset += 1 + } + return result + } + + mutating func instance() throws -> TerrainRegionInstance { + let id = try uint64(), prototype = try uint16(), flags = try uint16() + let position = try Vector3(float(), float(), float()) + let rotation = try uint64() + let scale = try Vector3(float(), float(), float()) + let tint = try uint32(), seed = try uint32(), normal = try uint32(), anchorOffset = try float() + guard try uint32() == 0 else { throw TerrainRegionError.malformed("nonzero reserved placement word") } + return try TerrainRegionInstance( + id: id, + prototypeID: prototype, + localPosition: position, + rotationBits: rotation, + scale: scale, + tintBits: tint, + seed: seed, + flags: .init(rawValue: flags), + normalBits: normal, + anchorOffset: anchorOffset + ) + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionAsset+Surface.swift b/Sources/AdaAssets/Terrain/TerrainRegionAsset+Surface.swift new file mode 100644 index 000000000..cb185c2f3 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionAsset+Surface.swift @@ -0,0 +1,206 @@ +import Foundation +import Math + +extension TerrainRegionAsset { + static let maximumSurfaceSamples = 65_536 + static let maximumRuleMaskSamples = 262_144 + + var surfaceEncodedByteCount: Int { + Self.encodedSize(tileCount: tiles.count, pageCount: pages.count, instanceCount: instances.count, surfaceCount: surface.count, ruleMaskCount: ruleMasks.count) + } + + func validateSurface() throws { + guard surface.count <= Self.maximumSurfaceSamples, ruleMasks.count <= Self.maximumRuleMaskSamples else { + throw TerrainRegionError.malformed("surface record count exceeds its budget") + } + var samples: Set = [] + for sample in surface { + try sample.validate() + let key = UInt16(sample.z) * 256 + UInt16(sample.x) + guard samples.insert(key).inserted, x != 4_096 || sample.x == 0, z != 4_096 || sample.z == 0 else { + throw TerrainRegionError.malformed("duplicate surface sample or sample outside the grid") + } + } + var masks: Set = [] + for sample in ruleMasks { + try sample.validate() + let key = UInt32(sample.maskID) << 16 | UInt32(sample.z) << 8 | UInt32(sample.x) + guard masks.insert(key).inserted, x != 4_096 || sample.x == 0, z != 4_096 || sample.z == 0 else { + throw TerrainRegionError.malformed("duplicate rule mask or mask outside the grid") + } + } + guard surfaceEncodedByteCount <= (Self.maximumEncodedBytes ?? 0) else { throw TerrainRegionError.malformed("encoded byte budget exceeded") } + } + + func encodedSurfaceData() throws -> Data { + var sections: [(tag: UInt16, count: Int, bytes: Data)] = [] + if !tiles.isEmpty || !pages.isEmpty { + let base = TerrainRegionAsset(x: x, z: z, tiles: tiles, pages: pages) + sections.append((1, tiles.count + pages.count, Data(try base.encodedData().dropFirst(16)))) + } + if !instances.isEmpty { + let base = TerrainRegionAsset(x: x, z: z, tiles: [], pages: [], instances: instances) + sections.append((2, instances.count, Data(try base.encodedData().suffix(instances.count * 64)))) + } + if !surface.isEmpty { + var payload = Data() + payload.reserveCapacity(surface.count * 64) + for sample in surface { + payload.append(contentsOf: [sample.x, sample.z, 0, 0]) + for channel in 0..<4 { SurfaceWords.append(sample.layerIDs[channel], to: &payload) } + for channel in 0..<4 { SurfaceWords.append(sample.weights[channel].bitPattern, to: &payload) } + SurfaceWords.append(sample.overrideStrength.bitPattern, to: &payload) + for channel in 0..<4 { SurfaceWords.append(sample.tint[channel].bitPattern, to: &payload) } + for value in [sample.tintStrength, sample.roughness, sample.roughnessStrength] { SurfaceWords.append(value.bitPattern, to: &payload) } + SurfaceWords.append(UInt32(0), to: &payload) + } + sections.append((3, surface.count, payload)) + } + if !ruleMasks.isEmpty { + var payload = Data() + payload.reserveCapacity(ruleMasks.count * 8) + for sample in ruleMasks { + payload.append(contentsOf: [sample.x, sample.z]) + SurfaceWords.append(sample.maskID, to: &payload) + SurfaceWords.append(sample.value.bitPattern, to: &payload) + } + sections.append((4, ruleMasks.count, payload)) + } + var data = Data("ADATRN03".utf8) + data.reserveCapacity(surfaceEncodedByteCount) + SurfaceWords.append(UInt32(bitPattern: x), to: &data) + SurfaceWords.append(UInt32(bitPattern: z), to: &data) + SurfaceWords.append(UInt32(sections.count), to: &data) + SurfaceWords.append(UInt32(0), to: &data) + for section in sections { Self.appendSurfaceSection(tag: section.tag, count: section.count, length: section.bytes.count, to: &data) } + for section in sections { data.append(section.bytes) } + return data + } + + static func decodeSurfaceData(_ data: Data) throws -> TerrainRegionAsset { + var reader = SurfaceWords.Reader(bytes: Array(data), offset: 8) + let x = Int32(bitPattern: try reader.uint32()), z = Int32(bitPattern: try reader.uint32()) + try TerrainRegionAsset(x: x, z: z, tiles: [], pages: []).validate() + let count = try reader.uint32(), flags = try reader.uint32() + guard (1...4).contains(count), flags == 0, data.count >= 24 + Int(count) * 16 else { + throw TerrainRegionError.malformed("invalid surface section table") + } + var sections: [(tag: UInt16, count: Int, offset: Int, length: Int)] = [] + var offset = 24 + Int(count) * 16 + var previousTag: UInt16 = 0 + for _ in 0.. previousTag, records > 0, offset <= data.count, length <= UInt64(data.count - offset) else { + throw TerrainRegionError.malformed("duplicate, unordered or truncated surface section") + } + let limit = tag == 1 ? 32 : (tag == 2 ? maximumInstances : (tag == 3 ? maximumSurfaceSamples : maximumRuleMaskSamples)) + guard records <= UInt32(limit) else { throw TerrainRegionError.malformed("surface section record budget exceeded") } + if tag == 1 { + guard length <= 512 * 1_024 - 16 else { throw TerrainRegionError.malformed("height section budget exceeded") } + } else { + let stride = tag == 4 ? 8 : 64 + guard length == UInt64(records) * UInt64(stride) else { throw TerrainRegionError.malformed("surface section length mismatch") } + } + sections.append((tag, Int(records), offset, Int(length))) + offset += Int(length) + previousTag = tag + } + guard offset == data.count, sections.contains(where: { $0.tag >= 3 }) else { + throw TerrainRegionError.malformed("trailing bytes or missing surface sections") + } + var height = TerrainRegionAsset(x: x, z: z, tiles: [], pages: []) + var instances: [TerrainRegionInstance] = [], surface: [TerrainRegionSurfaceSample] = [], masks: [TerrainRegionRuleMaskSample] = [] + for section in sections { + reader.offset = section.offset + switch section.tag { + case 1: + var legacy = Data("ADATRN01".utf8) + SurfaceWords.append(UInt32(bitPattern: x), to: &legacy) + SurfaceWords.append(UInt32(bitPattern: z), to: &legacy) + legacy.append(contentsOf: reader.bytes[section.offset..<(section.offset + section.length)]) + height = try TerrainRegionAsset(data: legacy) + guard height.tiles.count + height.pages.count == section.count else { throw TerrainRegionError.malformed("height record count mismatch") } + reader.offset += section.length + case 2: + var legacy = Data("ADATRN02".utf8) + SurfaceWords.append(UInt32(bitPattern: x), to: &legacy) + SurfaceWords.append(UInt32(bitPattern: z), to: &legacy) + SurfaceWords.append(UInt32(1), to: &legacy) + SurfaceWords.append(UInt32(0), to: &legacy) + appendSurfaceSection(tag: 2, count: section.count, length: section.length, to: &legacy) + legacy.append(contentsOf: reader.bytes[section.offset..<(section.offset + section.length)]) + instances = try TerrainRegionAsset(data: legacy).instances + reader.offset += section.length + case 3: + surface.reserveCapacity(section.count) + for _ in 0..(_ value: T, to data: inout Data) { + for shift in stride(from: 0, to: T.bitWidth, by: 8) { data.append(UInt8(truncatingIfNeeded: value >> shift)) } + } + + struct Reader { + let bytes: [UInt8] + var offset: Int + + mutating func byte() throws -> UInt8 { + guard offset < bytes.count else { throw TerrainRegionError.malformed("truncated surface sample") } + defer { offset += 1 } + return bytes[offset] + } + + mutating func uint16() throws -> UInt16 { try UInt16(word(count: 2)) } + mutating func uint32() throws -> UInt32 { try UInt32(word(count: 4)) } + mutating func uint64() throws -> UInt64 { try word(count: 8) } + mutating func float() throws -> Float { Float(bitPattern: try uint32()) } + + private mutating func word(count: Int) throws -> UInt64 { + var value: UInt64 = 0 + for shift in stride(from: 0, to: count * 8, by: 8) { value |= UInt64(try byte()) << shift } + return value + } + + mutating func surface() throws -> TerrainRegionSurfaceSample { + let x = try byte(), z = try byte() + guard try uint16() == 0 else { throw TerrainRegionError.malformed("nonzero reserved surface word") } + let ids = try SIMD4(uint16(), uint16(), uint16(), uint16()) + let weights = try Vector4(float(), float(), float(), float()) + let opacity = try float(), tint = try Vector4(float(), float(), float(), float()) + let tintStrength = try float(), roughness = try float(), roughnessStrength = try float() + guard try uint32() == 0 else { throw TerrainRegionError.malformed("nonzero reserved surface word") } + return .init( + x: x, + z: z, + layerIDs: ids, + weights: weights, + overrideStrength: opacity, + tint: tint, + tintStrength: tintStrength, + roughness: roughness, + roughnessStrength: roughnessStrength + ) + } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionAsset.swift b/Sources/AdaAssets/Terrain/TerrainRegionAsset.swift new file mode 100644 index 000000000..5156f3d04 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionAsset.swift @@ -0,0 +1,217 @@ +import Foundation +import Math + +public enum TerrainRegionError: LocalizedError { + case malformed(String) + case unsupportedVersion(String) + + public var errorDescription: String? { + switch self { + case let .malformed(reason): "Invalid terrain region: \(reason)" + case let .unsupportedVersion(version): "Unsupported terrain region version: \(version)" + } + } +} + +/// The dimensions of an allocated heightfield tile within a region's 4 by 4 tile grid. +public struct TerrainRegionTile: Codable, Equatable, Sendable { + public let x: UInt8 + public let z: UInt8 + public let cellCountX: UInt8 + public let cellCountZ: UInt8 + + public init(x: UInt8, z: UInt8, cellCountX: UInt8 = 64, cellCountZ: UInt8 = 64) { + self.x = x; self.z = z; self.cellCountX = cellCountX; self.cellCountZ = cellCountZ + } +} + +/// One canonical 64 by 64 height page. Borders are owned by the page at their sample address. +public struct TerrainRegionHeightPage: Codable, Equatable, Sendable { + public let x: UInt8 + public let z: UInt8 + public let samples: [Float] + + public init(x: UInt8, z: UInt8, samples: [Float]) { self.x = x; self.z = z; self.samples = samples } +} + +/// An independently loadable region payload; no render resources or neighboring region loads are required. +public struct TerrainRegionAsset: Asset { + public static let maximumEncodedBytes: Int? = 8 * 1_024 * 1_024 + public var assetMetaInfo: AssetMetaInfo? + public let x: Int32 + public let z: Int32 + public let tiles: [TerrainRegionTile] + public let pages: [TerrainRegionHeightPage] + public let instances: [TerrainRegionInstance] + public let surface: [TerrainRegionSurfaceSample] + public let ruleMasks: [TerrainRegionRuleMaskSample] + + /// Only use after validation has bounded both record counts. + var encodedByteCount: Int { + Self.encodedSize(tileCount: tiles.count, pageCount: pages.count, instanceCount: instances.count, surfaceCount: surface.count, ruleMaskCount: ruleMasks.count) + } + + public init( + x: Int32, + z: Int32, + tiles: [TerrainRegionTile], + pages: [TerrainRegionHeightPage], + instances: [TerrainRegionInstance] = [], + surface: [TerrainRegionSurfaceSample] = [], + ruleMasks: [TerrainRegionRuleMaskSample] = [] + ) { + self.x = x; self.z = z; self.tiles = tiles; self.pages = pages; self.instances = instances; self.surface = surface; self.ruleMasks = ruleMasks + } + + public init(data: Data) throws { + guard data.count <= (Self.maximumEncodedBytes ?? 0) else { throw TerrainRegionError.malformed("encoded byte budget exceeded") } + guard data.count >= 24 else { throw TerrainRegionError.malformed("truncated header") } + let signature = Array(data.prefix(8)) + guard signature.prefix(6).elementsEqual("ADATRN".utf8) else { throw TerrainRegionError.malformed("incorrect signature") } + if signature[6] == 48 && signature[7] == 51 { + self = try Self.decodeSurfaceData(data) + return + } + if signature[6] == 48 && signature[7] == 50 { + self = try Self.decodeInstanceData(data) + return + } + guard signature[6] == 48, signature[7] == 49 else { + throw TerrainRegionError.unsupportedVersion(String(bytes: signature[6..<8], encoding: .ascii) ?? "unknown") + } + guard data.count <= 512 * 1_024 else { throw TerrainRegionError.malformed("legacy encoded byte budget exceeded") } + var reader = RegionReader(bytes: Array(data)) + reader.offset = 8 + let x = Int32(bitPattern: try reader.uint32()), z = Int32(bitPattern: try reader.uint32()) + let tileCount = try reader.uint32(), pageCount = try reader.uint32() + // Bound integer counts and exact byte length before reserving or decoding any arrays. + guard tileCount <= 16, pageCount <= 16 else { throw TerrainRegionError.malformed("record count exceeds 16") } + let expectedSize = 24 + Int(tileCount) * 4 + Int(pageCount) * 16_388 + guard data.count == expectedSize else { throw TerrainRegionError.malformed("record length mismatch") } + try Self.validateAddress(x: x, z: z, tileCount: Int(tileCount)) + var tiles: [TerrainRegionTile] = [] + var pages: [TerrainRegionHeightPage] = [] + tiles.reserveCapacity(Int(tileCount)); pages.reserveCapacity(Int(pageCount)) + for _ in 0.. Data { + try validate() + if !surface.isEmpty || !ruleMasks.isEmpty { + return try encodedSurfaceData() + } + if !instances.isEmpty { + return try encodedInstanceData() + } + var data = Data("ADATRN01".utf8) + data.reserveCapacity(encodedByteCount) + Self.append(UInt32(bitPattern: x), to: &data); Self.append(UInt32(bitPattern: z), to: &data) + Self.append(UInt32(tiles.count), to: &data); Self.append(UInt32(pages.count), to: &data) + for tile in tiles { data.append(contentsOf: [tile.x, tile.z, tile.cellCountX, tile.cellCountZ]) } + for page in pages { + data.append(contentsOf: [page.x, page.z, 0, 0]) + for sample in page.samples { Self.append(sample.bitPattern, to: &data) } + } + return data + } + + /// Checks both physical tiles and canonical data-only pages at the positive grid boundary. + public func validate() throws { + guard tiles.count <= 16, pages.count <= 16, instances.count <= Self.maximumInstances else { + throw TerrainRegionError.malformed("region record count exceeds its budget") + } + try Self.validateRecordBudget( + tileCount: tiles.count, + pageCount: pages.count, + instanceCount: instances.count, + surfaceCount: surface.count, + ruleMaskCount: ruleMasks.count + ) + try Self.validateAddress(x: x, z: z, tileCount: tiles.count) + try validateSurface() + var tileIDs: Set = [], pageIDs: Set = [] + for tile in tiles { + guard tile.x < 4, tile.z < 4, (1...64).contains(tile.cellCountX), (1...64).contains(tile.cellCountZ), + tileIDs.insert(Int(tile.z) * 4 + Int(tile.x)).inserted + else { + throw TerrainRegionError.malformed("invalid or duplicate tile") + } + } + for page in pages { + guard page.x < 4, page.z < 4, page.samples.count == 4_096, + pageIDs.insert(Int(page.z) * 4 + Int(page.x)).inserted, + x != 4_096 || page.x == 0, z != 4_096 || page.z == 0 + else { + throw TerrainRegionError.malformed("invalid or duplicate height page") + } + for (index, sample) in page.samples.enumerated() { + guard sample.isFinite, + x != 4_096 || index % 64 == 0 || sample == 0, + z != 4_096 || index / 64 == 0 || sample == 0 + else { + throw TerrainRegionError.malformed("invalid height sample") + } + } + } + var instanceIDs: Set = [] + instanceIDs.reserveCapacity(instances.count) + for instance in instances { + try instance.validate() + guard instanceIDs.insert(instance.id).inserted, + x != 4_096 || instance.localPosition.x == 0, z != 4_096 || instance.localPosition.z == 0 + else { + throw TerrainRegionError.malformed("duplicate instance identity or position outside the grid") + } + } + } + + private static func validateAddress(x: Int32, z: Int32, tileCount: Int) throws { + guard (-4_096...4_096).contains(x), (-4_096...4_096).contains(z) else { + throw TerrainRegionError.malformed("region address is outside the supported grid") + } + guard (x < 4_096 && z < 4_096) || tileCount == 0 else { + throw TerrainRegionError.malformed("the positive boundary can only contain canonical sample pages") + } + } + + private static func append(_ value: UInt32, to data: inout Data) { + for shift in stride(from: 0, to: 32, by: 8) { data.append(UInt8(truncatingIfNeeded: value >> shift)) } + } + + public init(from assetDecoder: any AssetDecoder) async throws { try self.init(data: assetDecoder.assetData) } + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { try assetEncoder.encode(encodedData()) } + public static func extensions() -> [String] { ["atregion"] } +} + +private struct RegionReader { + let bytes: [UInt8] + var offset = 0 + + mutating func byte() throws -> UInt8 { + guard offset < bytes.count else { throw TerrainRegionError.malformed("truncated record") } + defer { offset += 1 } + return bytes[offset] + } + + mutating func uint32() throws -> UInt32 { + var value: UInt32 = 0 + for shift in stride(from: 0, to: 32, by: 8) { value |= UInt32(try byte()) << shift } + return value + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionByteBudget.swift b/Sources/AdaAssets/Terrain/TerrainRegionByteBudget.swift new file mode 100644 index 000000000..f9f1f522c --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionByteBudget.swift @@ -0,0 +1,22 @@ +extension TerrainRegionAsset { + /// Checks the exact native01/02/03 wire size using metadata counts, without walking or copying records. + @_spi(Internal) + public static func validateRecordBudget(tileCount: Int, pageCount: Int, instanceCount: Int, surfaceCount: Int, ruleMaskCount: Int) throws { + guard (0...16).contains(tileCount), (0...16).contains(pageCount), (0...maximumInstances).contains(instanceCount), + (0...maximumSurfaceSamples).contains(surfaceCount), (0...maximumRuleMaskSamples).contains(ruleMaskCount) + else { throw TerrainRegionError.malformed("region record count exceeds its budget") } + guard encodedSize(tileCount: tileCount, pageCount: pageCount, instanceCount: instanceCount, surfaceCount: surfaceCount, ruleMaskCount: ruleMaskCount) + <= (maximumEncodedBytes ?? 0) + else { throw TerrainRegionError.malformed("encoded byte budget exceeded") } + } + + /// Inputs are bounded by record validation before serialization or staging. + static func encodedSize(tileCount: Int, pageCount: Int, instanceCount: Int, surfaceCount: Int, ruleMaskCount: Int) -> Int { + if instanceCount == 0 && surfaceCount == 0 && ruleMaskCount == 0 { + return 24 + tileCount * 4 + pageCount * 16_388 + } + let height = tileCount == 0 && pageCount == 0 ? 0 : 8 + tileCount * 4 + pageCount * 16_388 + let sections = (height > 0 ? 1 : 0) + (instanceCount > 0 ? 1 : 0) + (surfaceCount > 0 ? 1 : 0) + (ruleMaskCount > 0 ? 1 : 0) + return 24 + sections * 16 + height + instanceCount * 64 + surfaceCount * 64 + ruleMaskCount * 8 + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionInstance.swift b/Sources/AdaAssets/Terrain/TerrainRegionInstance.swift new file mode 100644 index 000000000..bcded2869 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionInstance.swift @@ -0,0 +1,173 @@ +import Math + +/// Attachment and optional nearby collision requests. Decorative placements have no collision by default. +public struct TerrainInstanceFlags: OptionSet, Sendable, Hashable { + public let rawValue: UInt16 + public init(rawValue: UInt16) { self.rawValue = rawValue } + public static let anchored = Self(rawValue: 1) + public static let alignToNormal = Self(rawValue: 2) + public static let collision = Self(rawValue: 4) +} + +/// One compact placement; position and offset use base-cell units relative to the region's origin. +/// Quaternion/normal channels use signed16-bit encoding; tints use normalized RGBA8. +public struct TerrainRegionInstance: Equatable, Sendable { + public let id: UInt64 + public let prototypeID: UInt16 + public let localPosition: Vector3 + public let scale: Vector3 + public let seed: UInt32 + public let flags: TerrainInstanceFlags + public let anchorOffset: Float + // Preserve encoded channels across load/save without renormalization/requantization drift. + let rotationBits: UInt64 + let tintBits: UInt32 + let normalBits: UInt32 + + public var rotation: Quat { + let x = Self.snorm(UInt16(truncatingIfNeeded: rotationBits)) + let y = Self.snorm(UInt16(truncatingIfNeeded: rotationBits >> 16)) + let z = Self.snorm(UInt16(truncatingIfNeeded: rotationBits >> 32)) + let w = Self.snorm(UInt16(truncatingIfNeeded: rotationBits >> 48)) + let length = (x * x + y * y + z * z + w * w).squareRoot() + return Quat(x: Float(x / length), y: Float(y / length), z: Float(z / length), w: Float(w / length)) + } + + public var tint: Vector4 { + Vector4(Float(tintBits & 255), Float((tintBits >> 8) & 255), Float((tintBits >> 16) & 255), Float(tintBits >> 24)) / 255 + } + + public var anchorNormal: Vector3 { + var x = Self.snorm(UInt16(truncatingIfNeeded: normalBits)) + var z = Self.snorm(UInt16(truncatingIfNeeded: normalBits >> 16)) + let y = 1 - abs(x) - abs(z) + if y < 0 { + let previousX = x + x = (1 - abs(z)) * Self.sign(x) + z = (1 - abs(previousX)) * Self.sign(z) + } + let length = (x * x + y * y + z * z).squareRoot() + return Vector3(Float(x / length), Float(y / length), Float(z / length)) + } + + public init( + id: UInt64, + prototypeID: UInt16, + localPosition: Vector3, + rotation: Quat = .identity, + scale: Vector3 = .one, + tint: Vector4 = .one, + seed: UInt32 = 0, + flags: TerrainInstanceFlags = [.anchored], + anchorNormal: Vector3 = [0, 1, 0], + anchorOffset: Float = 0 + ) throws { + guard rotation.x.isFinite, rotation.y.isFinite, rotation.z.isFinite, rotation.w.isFinite, + anchorNormal.x.isFinite, anchorNormal.y.isFinite, anchorNormal.z.isFinite, + (0...1).contains(tint.x), (0...1).contains(tint.y), (0...1).contains(tint.z), (0...1).contains(tint.w) + else { + throw TerrainRegionError.malformed("invalid instance rotation, normal or tint") + } + let qx = Double(rotation.x), qy = Double(rotation.y), qz = Double(rotation.z), qw = Double(rotation.w) + let qLength = (qx * qx + qy * qy + qz * qz + qw * qw).squareRoot() + let nx = Double(anchorNormal.x), ny = Double(anchorNormal.y), nz = Double(anchorNormal.z) + let nLength = abs(nx) + abs(ny) + abs(nz) + guard qLength > 0, nLength > 0 else { + throw TerrainRegionError.malformed("instance rotation and attachment normal must be nonzero") + } + let flip = qw < 0 || (qw == 0 && (qx < 0 || (qx == 0 && (qy < 0 || (qy == 0 && qz < 0))))) + let divisor = flip ? -qLength : qLength + let packedRotation = + UInt64(Self.pack(qx / divisor)) | UInt64(Self.pack(qy / divisor)) << 16 + | UInt64(Self.pack(qz / divisor)) << 32 | UInt64(Self.pack(qw / divisor)) << 48 + var ox = nx / nLength, oz = nz / nLength + if ny < 0 { + let previousX = ox + ox = (1 - abs(oz)) * Self.sign(ox) + oz = (1 - abs(previousX)) * Self.sign(oz) + } + let packedNormal = UInt32(Self.pack(ox)) | UInt32(Self.pack(oz)) << 16 + let packedTint = + UInt32((tint.x * 255).rounded()) | UInt32((tint.y * 255).rounded()) << 8 + | UInt32((tint.z * 255).rounded()) << 16 | UInt32((tint.w * 255).rounded()) << 24 + try self.init( + id: id, + prototypeID: prototypeID, + localPosition: localPosition, + rotationBits: packedRotation, + scale: scale, + tintBits: packedTint, + seed: seed, + flags: flags, + normalBits: packedNormal, + anchorOffset: anchorOffset + ) + } + + init( + id: UInt64, + prototypeID: UInt16, + localPosition: Vector3, + rotationBits: UInt64, + scale: Vector3, + tintBits: UInt32, + seed: UInt32, + flags: TerrainInstanceFlags, + normalBits: UInt32, + anchorOffset: Float + ) throws { + self.id = id; self.prototypeID = prototypeID; self.localPosition = localPosition + self.rotationBits = rotationBits; self.scale = scale; self.tintBits = tintBits + self.seed = seed; self.flags = flags; self.normalBits = normalBits; self.anchorOffset = anchorOffset + try validate() + } + + /// Updates surface attachment while retaining stable IDs and unrelated compact channels exactly. + /// Height and offset use base-cell units; nil rotation retains the existing packed quaternion. + public func updatingAnchor(localHeight: Float, normal: Vector3, rotation: Quat? = nil) throws -> Self { + let position = Vector3(localPosition.x, localHeight, localPosition.z) + let channels = try Self( + id: id, + prototypeID: prototypeID, + localPosition: position, + rotation: rotation ?? .identity, + scale: scale, + seed: seed, + flags: flags, + anchorNormal: normal, + anchorOffset: anchorOffset + ) + return try Self( + id: id, + prototypeID: prototypeID, + localPosition: position, + rotationBits: rotation == nil ? rotationBits : channels.rotationBits, + scale: scale, + tintBits: tintBits, + seed: seed, + flags: flags, + normalBits: channels.normalBits, + anchorOffset: anchorOffset + ) + } + + func validate() throws { + guard (0..<256).contains(localPosition.x), (0..<256).contains(localPosition.z), localPosition.y.isFinite, + scale.x.isFinite, scale.y.isFinite, scale.z.isFinite, + (0.0001...10_000).contains(abs(scale.x)), (0.0001...10_000).contains(abs(scale.y)), (0.0001...10_000).contains(abs(scale.z)), + flags.rawValue & ~UInt16(7) == 0, anchorOffset.isFinite, rotationBits != 0 + else { + throw TerrainRegionError.malformed("invalid instance position, scale, flags or attachment") + } + for shift in stride(from: 0, to: 64, by: 16) where UInt16(truncatingIfNeeded: rotationBits >> shift) == 0x8000 { + throw TerrainRegionError.malformed("invalid signed rotation channel") + } + guard UInt16(truncatingIfNeeded: normalBits) != 0x8000, UInt16(truncatingIfNeeded: normalBits >> 16) != 0x8000 else { + throw TerrainRegionError.malformed("invalid signed normal channel") + } + } + + private static func snorm(_ bits: UInt16) -> Double { Double(Int16(bitPattern: bits)) / 32_767 } + private static func pack(_ value: Double) -> UInt16 { UInt16(bitPattern: Int16((max(-1, min(1, value)) * 32_767).rounded())) } + private static func sign(_ value: Double) -> Double { value < 0 ? -1 : 1 } +} diff --git a/Sources/AdaAssets/Terrain/TerrainRegionSurfaceSample.swift b/Sources/AdaAssets/Terrain/TerrainRegionSurfaceSample.swift new file mode 100644 index 000000000..91302b07c --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainRegionSurfaceSample.swift @@ -0,0 +1,67 @@ +import Foundation +import Math + +/// One artist-authored canonical sample, addressed within a 256-cell region. +/// The explicit override blends with automatic rules; zero override reveals those rules. +public struct TerrainRegionSurfaceSample: Equatable, Sendable { + public let x: UInt8 + public let z: UInt8 + public let layerIDs: SIMD4 + public let weights: Vector4 + public let overrideStrength: Float + public let tint: Vector4 + public let tintStrength: Float + public let roughness: Float + public let roughnessStrength: Float + + public init( + x: UInt8, + z: UInt8, + layerIDs: SIMD4 = .zero, + weights: Vector4 = .zero, + overrideStrength: Float = 0, + tint: Vector4 = .one, + tintStrength: Float = 0, + roughness: Float = 1, + roughnessStrength: Float = 0 + ) { + self.x = x; self.z = z; self.layerIDs = layerIDs; self.weights = weights; self.overrideStrength = overrideStrength + self.tint = tint; self.tintStrength = tintStrength; self.roughness = roughness; self.roughnessStrength = roughnessStrength + } + + func validate(allowEmpty: Bool = false) throws { + func unit(_ value: Float) -> Bool { value.isFinite && (0...1).contains(value) } + guard unit(overrideStrength), unit(tintStrength), unit(roughness), unit(roughnessStrength), + allowEmpty || overrideStrength > 0 || tintStrength > 0 || roughnessStrength > 0 + else { throw TerrainRegionError.malformed("invalid or empty surface override") } + var total: Float = 0 + for channel in 0..<4 { + guard unit(weights[channel]), unit(tint[channel]) else { throw TerrainRegionError.malformed("invalid surface channels") } + if weights[channel] > 0 { + for previous in 0.. 0 { + guard layerIDs[previous] != layerIDs[channel] else { throw TerrainRegionError.malformed("duplicate painted layer") } + } + } + total += weights[channel] + } + guard overrideStrength == 0 ? total == 0 : abs(total - 1) <= 0.0001 else { + throw TerrainRegionError.malformed("paint weights must be normalized") + } + } +} + +/// A nonzero artist-authored rule mask at one canonical sample; absence means zero. +public struct TerrainRegionRuleMaskSample: Equatable, Sendable { + public let x: UInt8 + public let z: UInt8 + public let maskID: UInt16 + public let value: Float + + public init(x: UInt8, z: UInt8, maskID: UInt16, value: Float) { + self.x = x; self.z = z; self.maskID = maskID; self.value = value + } + + func validate() throws { + guard value.isFinite, value > 0, value <= 1 else { throw TerrainRegionError.malformed("invalid or empty rule mask") } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainSurfacePaint.swift b/Sources/AdaAssets/Terrain/TerrainSurfacePaint.swift new file mode 100644 index 000000000..5bd20b894 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainSurfacePaint.swift @@ -0,0 +1,58 @@ +import Math + +/// Address-independent artist-authored surface channels. Zero strengths reveal automatic shading. +public struct TerrainSurfacePaint: Equatable, Sendable { + public let layerIDs: SIMD4 + public let weights: Vector4 + public let overrideStrength: Float + public let tint: Vector4 + public let tintStrength: Float + public let roughness: Float + public let roughnessStrength: Float + + public init( + layerIDs: SIMD4 = .zero, + weights: Vector4 = .zero, + overrideStrength: Float = 0, + tint: Vector4 = .one, + tintStrength: Float = 0, + roughness: Float = 1, + roughnessStrength: Float = 0 + ) { + self.layerIDs = layerIDs; self.weights = weights; self.overrideStrength = overrideStrength + self.tint = tint; self.tintStrength = tintStrength; self.roughness = roughness; self.roughnessStrength = roughnessStrength + } + + public var isEmpty: Bool { overrideStrength == 0 && tintStrength == 0 && roughnessStrength == 0 } + + /// Checks finite normalized channels. Empty values are valid and erase their canonical sample. + public func validate() throws { try regionSample(x: 0, z: 0).validate(allowEmpty: true) } + + public func regionSample(x: UInt8, z: UInt8) -> TerrainRegionSurfaceSample { + .init( + x: x, + z: z, + layerIDs: layerIDs, + weights: weights, + overrideStrength: overrideStrength, + tint: tint, + tintStrength: tintStrength, + roughness: roughness, + roughnessStrength: roughnessStrength + ) + } +} + +extension TerrainRegionSurfaceSample { + public var paint: TerrainSurfacePaint { + .init( + layerIDs: layerIDs, + weights: weights, + overrideStrength: overrideStrength, + tint: tint, + tintStrength: tintStrength, + roughness: roughness, + roughnessStrength: roughnessStrength + ) + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainTextureVariation.swift b/Sources/AdaAssets/Terrain/TerrainTextureVariation.swift new file mode 100644 index 000000000..3b720aeea --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainTextureVariation.swift @@ -0,0 +1,59 @@ +/// Terrain-local texture randomization. Disabled by default to preserve existing authored appearances. +public struct TerrainTextureVariation: Codable, Equatable, Sendable { + public let isEnabled: Bool + public let seed: UInt64 + public let flipU: Bool + public let flipV: Bool + public let quarterTurns: Bool + public let randomOffset: Bool + /// Texture repetitions per variation cell; adjacent randomized samples blend across cells. + public let cellScale: Float + public let macroStrength: Float + /// Terrain-local units per low-frequency macro cell. + public let macroScale: Float + + public init( + isEnabled: Bool = false, + seed: UInt64 = 0, + flipU: Bool = true, + flipV: Bool = true, + quarterTurns: Bool = true, + randomOffset: Bool = true, + cellScale: Float = 2, + macroStrength: Float = 0.1, + macroScale: Float = 32 + ) { + self.isEnabled = isEnabled + self.seed = seed + self.flipU = flipU + self.flipV = flipV + self.quarterTurns = quarterTurns + self.randomOffset = randomOffset + self.cellScale = cellScale + self.macroStrength = macroStrength + self.macroScale = macroScale + } + + private enum CodingKeys: CodingKey { case isEnabled, seed, flipU, flipV, quarterTurns, randomOffset, cellScale, macroStrength, macroScale } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + isEnabled: try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false, + seed: try container.decodeIfPresent(UInt64.self, forKey: .seed) ?? 0, + flipU: try container.decodeIfPresent(Bool.self, forKey: .flipU) ?? true, + flipV: try container.decodeIfPresent(Bool.self, forKey: .flipV) ?? true, + quarterTurns: try container.decodeIfPresent(Bool.self, forKey: .quarterTurns) ?? true, + randomOffset: try container.decodeIfPresent(Bool.self, forKey: .randomOffset) ?? true, + cellScale: try container.decodeIfPresent(Float.self, forKey: .cellScale) ?? 2, + macroStrength: try container.decodeIfPresent(Float.self, forKey: .macroStrength) ?? 0.1, + macroScale: try container.decodeIfPresent(Float.self, forKey: .macroScale) ?? 32 + ) + } + + func validate() throws { + guard cellScale.isFinite, (0.25...64).contains(cellScale), macroStrength.isFinite, (0...1).contains(macroStrength), macroScale.isFinite, macroScale > 0 else { + throw TerrainManifestError.malformed("invalid terrain texture variation") + } + } +} diff --git a/Sources/AdaAssets/Terrain/TerrainVolumeAssetWriter.swift b/Sources/AdaAssets/Terrain/TerrainVolumeAssetWriter.swift new file mode 100644 index 000000000..a734976b6 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainVolumeAssetWriter.swift @@ -0,0 +1,186 @@ +import Foundation + +#if !WASM + struct TerrainVolumeWritePlan: Sendable { + let fileName: String + let documentBytes: Data? + let bricks: [TerrainVolumeBrickAsset] + let references: [TerrainVolumeBrickReference] + let reused: [Bool] + let encodedByteCount: Int + var progressCount: Int { bricks.count + 1 } + + func stage( + directory: URL, + created: inout [URL], + progressOffset: Int, + total: Int, + onProgress: (@Sendable (Int, Int) async -> Void)? + ) async throws { + for index in bricks.indices { + try Task.checkCancellation() + if !reused[index] { + let bytes = try bricks[index].encodedData() + try TerrainAssetWriter.write(bytes, to: directory.appendingPathComponent(references[index].fileName), created: &created) + } + await onProgress?(progressOffset + index + 1, total) + try Task.checkCancellation() + } + if let documentBytes { + try TerrainAssetWriter.write(documentBytes, to: directory.appendingPathComponent(fileName), created: &created) + } + await onProgress?(progressOffset + progressCount, total) + try Task.checkCancellation() + } + } + + enum TerrainVolumeAssetWriter { + private struct Address: Hashable { + let x: Int32 + let y: Int32 + let z: Int32 + init(_ brick: TerrainVolumeBrickAsset) { x = brick.x; y = brick.y; z = brick.z } + init(_ reference: TerrainVolumeBrickReference) { x = reference.x; y = reference.y; z = reference.z } + } + + static func plan(_ request: TerrainVolumeSave, manifest: TerrainManifestAsset, directory: URL, maximumBytes: Int) async throws -> TerrainVolumeWritePlan { + try Task.checkCancellation() + try request.document.validate() + guard maximumBytes >= 0 else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + guard request.bricks.count <= TerrainVolumeDocumentAsset.maximumBricks else { throw TerrainSaveError.tooManyRegions } + let brickBytes = TerrainVolumeBrickAsset.maximumEncodedBytes ?? 0 + guard request.bricks.count <= maximumBytes / brickBytes else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + let encodedBricks = request.bricks.count * brickBytes + var baseline: TerrainVolumeDocumentAsset? + do { + if let name = manifest.volumeFileName { + baseline = try await readDocument(name, directory: directory) + } else { baseline = nil } + } catch is CancellationError { throw CancellationError() } catch { + if case .replaceAll = request.mode { baseline = nil } else { throw TerrainSaveError.staleManifest } + } + var previous: [Address: TerrainVolumeBrickReference] = [:] + if let baseline { for reference in baseline.bricks { previous[Address(reference)] = reference } } + var replacements: [TerrainVolumeBrickReference] = [], addresses: Set
= [] + let layers = manifest.materials.map { Set($0.layers.map(\.id)) } + try validateOperations(request.document, palette: manifest.materials) + for brick in request.bricks { + try Task.checkCancellation() + try brick.validate() + guard addresses.insert(Address(brick)).inserted else { throw TerrainVolumeAssetError.malformed("duplicate supplied volume brick") } + for (index, tag) in brick.materialTags.enumerated() where tag != TerrainVolumeBrickAsset.unassignedMaterial { + if index.isMultiple(of: 256) { try Task.checkCancellation() } + guard layers?.contains(UInt16(tag)) == true else { throw TerrainVolumeAssetError.malformed("missing cached volume material layer") } + } + let name = "volume-\(brick.x)-\(brick.y)-\(brick.z)-\(UUID().uuidString.lowercased()).atvolbrick" + replacements.append(.init(x: brick.x, y: brick.y, z: brick.z, fileName: name)) + } + let retained = try retainedReferences(request, baseline: baseline, replacing: addresses, previous: previous) + try await validateReferences(retained, palette: manifest.materials, directory: directory) + if let baseline, !retained.isEmpty, (baseline.cellSize != request.document.cellSize || baseline.origin != request.document.origin) { + throw TerrainVolumeAssetError.malformed("changing the volume lattice requires replacing its cached bricks") + } + var reused = [Bool](repeating: false, count: request.bricks.count) + for index in request.bricks.indices { + let brick = request.bricks[index] + if let reference = previous[Address(brick)], try await matches(brick, file: directory.appendingPathComponent(reference.fileName)) { + replacements[index] = reference; reused[index] = true + } + } + let canonical = (retained + replacements).sorted { ($0.z, $0.y, $0.x) < ($1.z, $1.y, $1.x) } + let updated = TerrainVolumeDocumentAsset( + cellSize: request.document.cellSize, + origin: request.document.origin, + operations: request.document.operations, + bricks: canonical + ) + let bytes = try updated.encodedData() + guard bytes.count <= maximumBytes - encodedBricks else { throw TerrainSaveError.byteBudgetExceeded(maximumBytes) } + let count = encodedBricks + bytes.count + if let baseline, let name = manifest.volumeFileName, try baseline.encodedData() == bytes { + return .init(fileName: name, documentBytes: nil, bricks: request.bricks, references: replacements, reused: reused, encodedByteCount: count) + } + return .init( + fileName: "volume-\(UUID().uuidString.lowercased()).atvolume", + documentBytes: bytes, + bricks: request.bricks, + references: replacements, + reused: reused, + encodedByteCount: count + ) + } + + static func validateRetained(_ name: String, palette: TerrainMaterialPalette?, directory: URL) async throws { + let document = try await readDocument(name, directory: directory) + try validateOperations(document, palette: palette) + try await validateReferences(document.bricks, palette: palette, directory: directory) + } + + private static func validateOperations(_ document: TerrainVolumeDocumentAsset, palette: TerrainMaterialPalette?) throws { + let layers = Set(palette?.layers.map(\.id) ?? []) + for operation in document.operations where operation.isEnabled { + try Task.checkCancellation() + if let layer = operation.layerID, !layers.contains(layer) { + throw TerrainVolumeAssetError.malformed("missing active volume material layer") + } + } + } + + private static func validateReferences(_ references: [TerrainVolumeBrickReference], palette: TerrainMaterialPalette?, directory: URL) async throws { + let layers = Set(palette?.layers.map(\.id) ?? []) + for reference in references { + try Task.checkCancellation() + let file = directory.appendingPathComponent(reference.fileName) + let info = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + guard info.isRegularFile == true, info.isSymbolicLink != true, info.fileSize == TerrainVolumeBrickAsset.maximumEncodedBytes else { + throw TerrainVolumeAssetError.malformed("missing or incomplete cached volume brick: \(reference.fileName)") + } + // Retain only one decoded brick at a time; its codec bounds bytes before allocation. + let bytes = try await AssetsManager.readBoundedFile(url: file, maximumBytes: TerrainVolumeBrickAsset.maximumEncodedBytes ?? 0) + let brick = try TerrainVolumeBrickAsset(data: bytes) + guard brick.x == reference.x, brick.y == reference.y, brick.z == reference.z else { + throw TerrainVolumeAssetError.malformed("cached volume brick address mismatch") + } + for (index, tag) in brick.materialTags.enumerated() where tag != TerrainVolumeBrickAsset.unassignedMaterial { + if index.isMultiple(of: 256) { try Task.checkCancellation() } + guard layers.contains(UInt16(tag)) else { throw TerrainVolumeAssetError.malformed("missing retained volume material layer") } + } + } + } + + private static func retainedReferences( + _ request: TerrainVolumeSave, + baseline: TerrainVolumeDocumentAsset?, + replacing: Set
, + previous: [Address: TerrainVolumeBrickReference] + ) throws -> [TerrainVolumeBrickReference] { + guard case let .update(removing) = request.mode else { + return [] + } + guard let baseline, baseline.bricks == request.document.bricks else { throw TerrainSaveError.staleManifest } + guard removing.count <= TerrainVolumeDocumentAsset.maximumBricks else { throw TerrainSaveError.tooManyRegions } + var removed: Set
= [] + for reference in removing { + let address = Address(reference) + guard previous[address] == reference, !replacing.contains(address), removed.insert(address).inserted else { throw TerrainSaveError.invalidRemoval } + } + let retained = baseline.bricks.filter { !replacing.contains(Address($0)) && !removed.contains(Address($0)) } + guard retained.count <= TerrainVolumeDocumentAsset.maximumBricks - request.bricks.count else { throw TerrainSaveError.tooManyRegions } + return retained + } + + private static func readDocument(_ name: String, directory: URL) async throws -> TerrainVolumeDocumentAsset { + let data = try await AssetsManager.readBoundedFile(url: directory.appendingPathComponent(name), maximumBytes: TerrainVolumeDocumentAsset.maximumEncodedBytes ?? 0) + return try TerrainVolumeDocumentAsset(data: data) + } + + private static func matches(_ brick: TerrainVolumeBrickAsset, file: URL) async throws -> Bool { + guard let info = try? file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + info.isRegularFile == true, info.isSymbolicLink != true else { return false } + let expected = try brick.encodedData() + let existing = try? await AssetsManager.readBoundedFile(url: file, maximumBytes: expected.count) + try Task.checkCancellation() + return existing == expected + } + } +#endif diff --git a/Sources/AdaAssets/Terrain/TerrainVolumeBrickAsset.swift b/Sources/AdaAssets/Terrain/TerrainVolumeBrickAsset.swift new file mode 100644 index 000000000..846c43edd --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainVolumeBrickAsset.swift @@ -0,0 +1,109 @@ +import Foundation + +public enum TerrainVolumeAssetError: LocalizedError, Equatable, Sendable { + case malformed(String) + case unsupportedVersion(String) + + public var errorDescription: String? { + switch self { + case let .malformed(reason): "Invalid terrain volume: \(reason)" + case let .unsupportedVersion(version): "Unsupported terrain volume version: \(version)" + } + } +} + +/// Canonical 33-cubed samples for an independently loadable 32-cell volume brick. +public struct TerrainVolumeBrickAsset: Asset { + public static let sampleCount = 33 * 33 * 33 + public static let unassignedMaterial = UInt32.max + public static let maximumEncodedBytes: Int? = 24 + sampleCount * 8 + public var assetMetaInfo: AssetMetaInfo? + public let x: Int32 + public let y: Int32 + public let z: Int32 + public let values: [Float] + /// Stable UInt16 layer ID, or unassignedMaterial to use terrain rules/interior fallback. + public let materialTags: [UInt32] + + public init(x: Int32, y: Int32, z: Int32, values: [Float], materialTags: [UInt32]) { + self.x = x; self.y = y; self.z = z; self.values = values; self.materialTags = materialTags + } + + public init(data: Data) throws { + try Task.checkCancellation() + guard data.count == Self.maximumEncodedBytes else { throw TerrainVolumeAssetError.malformed("incorrect volume brick byte count") } + // A Data slice need not start at zero. The bounded byte copy supplies canonical indices. + let bytes = Array(data) + guard bytes.prefix(6).elementsEqual("ADAVOL".utf8) else { throw TerrainVolumeAssetError.malformed("incorrect volume brick signature") } + guard bytes[6] == 48, bytes[7] == 49 else { + throw TerrainVolumeAssetError.unsupportedVersion(String(bytes: bytes[6..<8], encoding: .ascii) ?? "unknown") + } + let x = Int32(bitPattern: Self.read(bytes, at: 8)), y = Int32(bitPattern: Self.read(bytes, at: 12)), z = Int32(bitPattern: Self.read(bytes, at: 16)) + try Self.validateAddress(x, y, z) + guard Self.read(bytes, at: 20) == UInt32(Self.sampleCount) else { throw TerrainVolumeAssetError.malformed("incorrect volume sample count") } + // Validate the complete bounded payload before allocating either decoded sample array. + for index in 0.. Data { + try validate() + var data = Data("ADAVOL01".utf8) + data.reserveCapacity(Self.maximumEncodedBytes ?? 0) + Self.append(UInt32(bitPattern: x), to: &data); Self.append(UInt32(bitPattern: y), to: &data); Self.append(UInt32(bitPattern: z), to: &data) + Self.append(UInt32(Self.sampleCount), to: &data) + for index in values.indices { + if index.isMultiple(of: 256) { try Task.checkCancellation() } + Self.append(values[index].bitPattern, to: &data); Self.append(materialTags[index], to: &data) + } + return data + } + + public func validate() throws { + try Task.checkCancellation() + try Self.validateAddress(x, y, z) + guard values.count == Self.sampleCount, materialTags.count == Self.sampleCount else { + throw TerrainVolumeAssetError.malformed("volume bricks require exactly 33 cubed density samples and tags") + } + for index in values.indices { + if index.isMultiple(of: 256) { try Task.checkCancellation() } + let tag = materialTags[index] + guard values[index].isFinite, tag <= UInt32(UInt16.max) || tag == Self.unassignedMaterial else { + throw TerrainVolumeAssetError.malformed("nonfinite volume density or invalid material tag") + } + } + } + + private static func validateAddress(_ x: Int32, _ y: Int32, _ z: Int32) throws { + guard (-32_768..<32_768).contains(x), (-32_768..<32_768).contains(y), (-32_768..<32_768).contains(z) else { + throw TerrainVolumeAssetError.malformed("volume brick address is outside the supported lattice") + } + } + + private static func read(_ bytes: [UInt8], at offset: Int) -> UInt32 { + // Callers establish exact payload length and bounded record indices first. + UInt32(bytes[offset]) | (UInt32(bytes[offset + 1]) << 8) | (UInt32(bytes[offset + 2]) << 16) | (UInt32(bytes[offset + 3]) << 24) + } + + private static func append(_ value: UInt32, to data: inout Data) { + for shift in stride(from: 0, to: 32, by: 8) { data.append(UInt8(truncatingIfNeeded: value >> shift)) } + } + public init(from assetDecoder: any AssetDecoder) async throws { try self.init(data: assetDecoder.assetData) } + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { try assetEncoder.encode(encodedData()) } + public static func extensions() -> [String] { ["atvolbrick"] } +} diff --git a/Sources/AdaAssets/Terrain/TerrainVolumeDocumentAsset.swift b/Sources/AdaAssets/Terrain/TerrainVolumeDocumentAsset.swift new file mode 100644 index 000000000..cede17c09 --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainVolumeDocumentAsset.swift @@ -0,0 +1,124 @@ +import Foundation +import Math + +/// Portable operation history and independently addressable last-valid density caches. +public struct TerrainVolumeDocumentAsset: Asset { + public static let maximumEncodedBytes: Int? = 4 * 1_024 * 1_024 + public static let maximumOperations = 512 + public static let maximumBricks = 16_384 + public var assetMetaInfo: AssetMetaInfo? + public let cellSize: Float + public let origin: Vector3 + public let operations: [TerrainVolumeOperation] + public let bricks: [TerrainVolumeBrickReference] + + private struct Address: Hashable { + let x: Int32 + let y: Int32 + let z: Int32 + } + + private struct Payload: Codable { + let version: Int + let cellSize: Float + let origin: Vector3 + let operations: [TerrainVolumeOperation] + let bricks: [TerrainVolumeBrickReference] + + private enum CodingKeys: CodingKey { case version, cellSize, origin, operations, bricks } + + init(_ document: TerrainVolumeDocumentAsset) { + version = 1; cellSize = document.cellSize; origin = document.origin; operations = document.operations; bricks = document.bricks + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(Int.self, forKey: .version) + guard version == 1 else { throw TerrainVolumeAssetError.unsupportedVersion(String(version)) } + cellSize = try container.decode(Float.self, forKey: .cellSize) + origin = try container.decode(Vector3.self, forKey: .origin) + try TerrainVolumeDocumentAsset.validateConfiguration(cellSize: cellSize, origin: origin) + func list(_ type: Value.Type, key: CodingKeys, maximum: Int, label: String) throws -> [Value] { + var input = try container.nestedUnkeyedContainer(forKey: key) + if let count = input.count, count > maximum { throw TerrainVolumeAssetError.malformed("\(label) table exceeds \(maximum) entries") } + var result: [Value] = [] + result.reserveCapacity(input.count ?? 0) + while !input.isAtEnd { + if result.count.isMultiple(of: 256) { try Task.checkCancellation() } + guard result.count < maximum else { throw TerrainVolumeAssetError.malformed("\(label) table exceeds \(maximum) entries") } + result.append(try input.decode(type)) + } + return result + } + operations = try list(TerrainVolumeOperation.self, key: .operations, maximum: maximumOperations, label: "operation") + bricks = try list(TerrainVolumeBrickReference.self, key: .bricks, maximum: maximumBricks, label: "brick") + } + } + + public init(cellSize: Float, origin: Vector3 = .zero, operations: [TerrainVolumeOperation] = [], bricks: [TerrainVolumeBrickReference] = []) { + self.cellSize = cellSize; self.origin = origin; self.operations = operations; self.bricks = bricks + } + + public init(data: Data) throws { + try Task.checkCancellation() + guard data.count <= (Self.maximumEncodedBytes ?? 0) else { throw TerrainVolumeAssetError.malformed("volume document encoded byte budget exceeded") } + let payload = try JSONDecoder().decode(Payload.self, from: data) + self.init(cellSize: payload.cellSize, origin: payload.origin, operations: payload.operations, bricks: payload.bricks) + try validate() + } + + public func encodedData() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let bytes = try encoder.encode(Payload(self)) + guard bytes.count <= (Self.maximumEncodedBytes ?? 0) else { throw TerrainVolumeAssetError.malformed("volume document encoded byte budget exceeded") } + try Task.checkCancellation() + return bytes + } + + public func validate() throws { + try Task.checkCancellation() + try Self.validateConfiguration(cellSize: cellSize, origin: origin) + guard operations.count <= Self.maximumOperations else { throw TerrainVolumeAssetError.malformed("operation table exceeds 512 entries") } + guard bricks.count <= Self.maximumBricks else { throw TerrainVolumeAssetError.malformed("brick table exceeds 16384 entries") } + var ids: Set = [] + for operation in operations { + try Task.checkCancellation() + try operation.validate() + guard ids.insert(operation.id).inserted else { throw TerrainVolumeAssetError.malformed("duplicate volume operation identity") } + } + var addresses: Set
= [], names: Set = [] + for brick in bricks { + try Task.checkCancellation() + try Self.validateReference(brick) + guard addresses.insert(.init(x: brick.x, y: brick.y, z: brick.z)).inserted, names.insert(brick.fileName.lowercased()).inserted else { + throw TerrainVolumeAssetError.malformed("duplicate volume brick address or file name") + } + } + } + + private static func validateConfiguration(cellSize: Float, origin: Vector3) throws { + let extent = Float(1_048_576) * cellSize + guard cellSize.isFinite, cellSize > 0, extent.isFinite else { throw TerrainVolumeAssetError.malformed("invalid volume lattice precision") } + for axis in 0..<3 { + guard origin[axis].isFinite, (origin[axis] - extent).isFinite, (origin[axis] + extent).isFinite else { + throw TerrainVolumeAssetError.malformed("invalid volume lattice origin") + } + } + } + + private static func validateReference(_ brick: TerrainVolumeBrickReference) throws { + guard (-32_768..<32_768).contains(brick.x), (-32_768..<32_768).contains(brick.y), (-32_768..<32_768).contains(brick.z) else { + throw TerrainVolumeAssetError.malformed("volume brick address is outside the supported lattice") + } + let bytes = brick.fileName.utf8 + func alphanumeric(_ byte: UInt8) -> Bool { (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) } + guard !bytes.isEmpty, bytes.count <= 128, brick.fileName.hasSuffix(".atvolbrick"), let first = bytes.first, alphanumeric(first), + bytes.allSatisfy({ alphanumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }) + else { throw TerrainVolumeAssetError.malformed("volume brick file must be an ASCII basename ending in .atvolbrick, at most 128 bytes") } + } + public init(from assetDecoder: any AssetDecoder) async throws { try self.init(data: assetDecoder.assetData) } + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { try assetEncoder.encode(encodedData()) } + public static func extensions() -> [String] { ["atvolume"] } +} diff --git a/Sources/AdaAssets/Terrain/TerrainVolumeOperation.swift b/Sources/AdaAssets/Terrain/TerrainVolumeOperation.swift new file mode 100644 index 000000000..5688ca63b --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainVolumeOperation.swift @@ -0,0 +1,47 @@ +import Foundation +import Math + +/// A reusable operand source. Static mesh paths are portable relative to the terrain manifest. +public enum TerrainVolumeOperand: Codable, Equatable, Sendable { + case primitive(ScalarSolidPrimitive) + case mesh(assetPath: String, scene: Int?) +} + +/// Ordered non-destructive solid edit. Array order determines evaluation order; identity survives reordering. +public struct TerrainVolumeOperation: Codable, Equatable, Sendable { + public let id: UUID + public let operation: ScalarBooleanOperation + public let operand: TerrainVolumeOperand + public let transform: Transform3D + public let layerID: UInt16? + public let isEnabled: Bool + + public init( + id: UUID = UUID(), + operation: ScalarBooleanOperation, + operand: TerrainVolumeOperand, + transform: Transform3D = .identity, + layerID: UInt16? = nil, + isEnabled: Bool = true + ) { + self.id = id; self.operation = operation; self.operand = operand; self.transform = transform; self.layerID = layerID; self.isEnabled = isEnabled + } + + public func validate() throws { + try ScalarSolid.validateTransform(transform) + switch operand { + case let .primitive(primitive): _ = try ScalarSolid(primitive: primitive, transform: transform) + case let .mesh(path, scene): try TerrainMeshPrototypeReference(id: 0, assetPath: path, scene: scene).validate() + } + } +} + +/// Immutable cache resource, addressed in the document's signed 32-cell lattice. +public struct TerrainVolumeBrickReference: Codable, Equatable, Sendable { + public let x: Int32 + public let y: Int32 + public let z: Int32 + public let fileName: String + + public init(x: Int32, y: Int32, z: Int32, fileName: String) { self.x = x; self.y = y; self.z = z; self.fileName = fileName } +} diff --git a/Sources/AdaAssets/Terrain/TerrainVolumeSave.swift b/Sources/AdaAssets/Terrain/TerrainVolumeSave.swift new file mode 100644 index 000000000..c8cd8bc8f --- /dev/null +++ b/Sources/AdaAssets/Terrain/TerrainVolumeSave.swift @@ -0,0 +1,16 @@ +/// Selects complete volume cache replacement or an incremental save retaining untouched bricks. +public enum TerrainVolumeSaveMode: Sendable { + case replaceAll + case update(removing: [TerrainVolumeBrickReference]) +} + +/// Desired ordered operations and private brick snapshots for one manifest publication. +public struct TerrainVolumeSave: Sendable { + public let document: TerrainVolumeDocumentAsset + public let bricks: [TerrainVolumeBrickAsset] + public let mode: TerrainVolumeSaveMode + + public init(document: TerrainVolumeDocumentAsset, bricks: [TerrainVolumeBrickAsset], mode: TerrainVolumeSaveMode = .replaceAll) { + self.document = document; self.bricks = bricks; self.mode = mode + } +} diff --git a/Sources/AdaAssets/Voxel/VOXLoader+Budget.swift b/Sources/AdaAssets/Voxel/VOXLoader+Budget.swift new file mode 100644 index 000000000..64071a2a8 --- /dev/null +++ b/Sources/AdaAssets/Voxel/VOXLoader+Budget.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Shared only by value readers within one synchronous decode; no reference escapes the call. +final class VOXDecodeBudget { + private var remaining: Int + + private init(remaining: Int) { self.remaining = remaining } + + static func make(rootBytes: Int, limits: ModelImportLimits?) throws -> VOXDecodeBudget? { + guard let limits else { + return nil + } + guard limits.maximumEncodedBytes > 0, limits.maximumDecodedBytes > 0 else { throw ModelImportLimitError.invalidLimits } + guard rootBytes <= limits.maximumEncodedBytes else { throw ModelImportLimitError.encodedBudgetExceeded } + guard limits.maximumDecodedBytes >= 4_096, rootBytes <= (limits.maximumDecodedBytes - 4_096) / 16 else { + throw ModelImportLimitError.decodedBudgetExceeded + } + return .init(remaining: limits.maximumDecodedBytes - 4_096 - rootBytes * 16) + } + + func records(_ type: T.Type, count: Int) throws { + try charge(count, stride: max(1, MemoryLayout.stride)) + } + + func charge(_ count: Int, stride: Int) throws { + guard count >= 0, count <= remaining / stride else { throw ModelImportLimitError.decodedBudgetExceeded } + remaining -= count * stride + } +} diff --git a/Sources/AdaAssets/Voxel/VOXLoader.swift b/Sources/AdaAssets/Voxel/VOXLoader.swift index bbf8081f0..d175e55e1 100644 --- a/Sources/AdaAssets/Voxel/VOXLoader.swift +++ b/Sources/AdaAssets/Voxel/VOXLoader.swift @@ -24,11 +24,20 @@ public struct VOXLoader: Sendable { public init() {} public func load(url: URL) async throws -> VOXFile { - try load(data: Data(contentsOf: url)) + if let limits = ModelImportContext.limits { + guard limits.maximumEncodedBytes > 0, limits.maximumDecodedBytes > 0 else { throw ModelImportLimitError.invalidLimits } + #if WASM + throw AssetError.message("Bounded direct model URL reads are unavailable in the browser; provide root data") + #else + return try load(data: await AssetsManager.readBoundedFile(url: url, maximumBytes: limits.maximumEncodedBytes)) + #endif + } + return try load(data: Data(contentsOf: url)) } public func load(data: Data) throws -> VOXFile { - var reader = VOXReader(data: data) + let budget = try VOXDecodeBudget.make(rootBytes: data.count, limits: ModelImportContext.limits) + var reader = VOXReader(data: data, budget: budget) guard try reader.readTag() == "VOX " else { throw VOXLoadingError.invalidHeader @@ -129,6 +138,8 @@ public struct VOXLoader: Sendable { throw VOXLoadingError.truncated } + try content.records(VOXModel.self, count: 1) + try content.records(VOXVoxel.self, count: count) var voxels: [VOXVoxel] = [] voxels.reserveCapacity(count) for _ in 0..(repeating: 0) if let value = attributes["_t"] { - let components = value.split(separator: " ").compactMap { Int($0) } - guard components.count == 3 else { + let components = value.split(separator: " ", maxSplits: 3) + guard components.count == 3, let x = Int(components[0]), let y = Int(components[1]), let z = Int(components[2]) else { throw VOXLoadingError.invalidAttribute(key: "_t", value: value) } - translation = SIMD3(components[0], components[1], components[2]) + translation = SIMD3(x, y, z) } var frameIndex: Int? @@ -269,20 +282,26 @@ public struct VOXLoader: Sendable { private struct VOXReader { private let data: Data private let end: Int + private let budget: VOXDecodeBudget? private(set) var position: Int - init(data: Data) { + init(data: Data, budget: VOXDecodeBudget?) { self.data = data self.position = data.startIndex self.end = data.endIndex + self.budget = budget } - private init(data: Data, start: Int, end: Int) { + private init(data: Data, start: Int, end: Int, budget: VOXDecodeBudget?) { self.data = data self.position = start self.end = end + self.budget = budget } + func records(_ type: T.Type, count: Int) throws { try budget?.records(type, count: count) } + func checkCancellation() throws { if budget != nil { try Task.checkCancellation() } } + var isAtEnd: Bool { position >= end } var remaining: Int { end - position } @@ -297,8 +316,8 @@ private struct VOXReader { position = try offset(advancedBy: count) } - func subReader(length: Int) throws -> VOXReader { - VOXReader(data: data, start: position, end: try offset(advancedBy: length)) + func subReader(length: Int) throws -> Self { + Self(data: data, start: position, end: try offset(advancedBy: length), budget: budget) } mutating func readUInt8() throws -> UInt8 { @@ -336,7 +355,8 @@ private struct VOXReader { } let bytes = data[position.. String { @@ -344,9 +364,11 @@ private struct VOXReader { guard length <= remaining else { throw VOXLoadingError.truncated } + try budget?.charge(length, stride: 4) let bytes = data[position.. [String: String] { @@ -355,6 +377,7 @@ private struct VOXReader { guard count <= remaining / 8 else { throw VOXLoadingError.truncated } + try budget?.charge(count, stride: 96) var result: [String: String] = [:] for _ in 0..