From 94dec56eb4a6662dc7cdafa2a5b9fd1b79abbc86 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 20 Apr 2026 02:36:38 +0200 Subject: [PATCH] auto-git: [add] src/rendering/terrain-layer-material.ts [add] tests/domain/terrains.test.ts [change] src/app/App.tsx [change] src/core/terrain-brush.ts [change] src/document/migrate-scene-document.ts [change] src/document/scene-document-validation.ts [change] src/document/scene-document.ts [change] src/document/terrains.ts [change] src/geometry/terrain-brush.ts [change] src/geometry/terrain-mesh.ts [change] src/runtime-three/rapier-collision-world.ts [change] src/runtime-three/runtime-host.ts [change] src/runtime-three/runtime-scene-build.ts [change] src/viewport-three/ViewportCanvas.tsx [change] src/viewport-three/ViewportPanel.tsx [change] src/viewport-three/viewport-host.ts [change] tests/domain/build-runtime-scene.test.ts [change] tests/domain/rapier-collision-world.test.ts [change] tests/domain/terrain.command.test.ts [change] tests/domain/water-material.test.ts [change] tests/geometry/terrain-brush.test.ts [change] tests/geometry/terrain-mesh.test.ts [change] tests/serialization/scene-document-json.test.ts [change] tests/unit/terrain-foundation.integration.test.tsx [change] tests/unit/viewport-canvas.test.tsx --- src/app/App.tsx | 444 ++++++++++++++++- src/core/terrain-brush.ts | 35 +- src/document/migrate-scene-document.ts | 44 +- src/document/scene-document-validation.ts | 81 ++- src/document/scene-document.ts | 4 +- src/document/terrains.ts | 466 +++++++++++++++++- src/geometry/terrain-brush.ts | 97 +++- src/geometry/terrain-mesh.ts | 25 + src/rendering/terrain-layer-material.ts | 144 ++++++ src/runtime-three/rapier-collision-world.ts | 51 +- src/runtime-three/runtime-host.ts | 62 ++- src/runtime-three/runtime-scene-build.ts | 87 +++- src/viewport-three/ViewportCanvas.tsx | 7 +- src/viewport-three/ViewportPanel.tsx | 2 +- src/viewport-three/viewport-host.ts | 120 ++++- tests/domain/build-runtime-scene.test.ts | 51 ++ tests/domain/rapier-collision-world.test.ts | 90 ++++ tests/domain/terrain.command.test.ts | 31 +- tests/domain/terrains.test.ts | 63 +++ tests/domain/water-material.test.ts | 90 +++- tests/geometry/terrain-brush.test.ts | 53 +- tests/geometry/terrain-mesh.test.ts | 42 ++ .../serialization/scene-document-json.test.ts | 76 +++ .../terrain-foundation.integration.test.tsx | 50 ++ tests/unit/viewport-canvas.test.tsx | 54 ++ 25 files changed, 2199 insertions(+), 70 deletions(-) create mode 100644 src/rendering/terrain-layer-material.ts create mode 100644 tests/domain/terrains.test.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 58603eb5..b2dcda16 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -83,6 +83,7 @@ import { } from "../core/selection"; import { clampTerrainBrushFalloff, + clampTerrainPaintLayerIndex, clampTerrainBrushRadius, clampTerrainBrushStrength, createDefaultTerrainBrushSettings, @@ -213,10 +214,17 @@ import { type ScenePathPoint } from "../document/paths"; import { + areTerrainsEqual, createTerrain, getTerrainBounds, + getTerrainFootprintDepth, + getTerrainFootprintWidth, getTerrainKindLabel, + getTerrainLayerLabel, getTerrains, + MIN_TERRAIN_SAMPLE_COUNT, + resizeTerrainGrid, + TERRAIN_LAYER_COUNT, type Terrain } from "../document/terrains"; import { @@ -715,7 +723,7 @@ function formatPlayerStartGamepadCameraLookBindingLabel( const STARTER_MATERIAL_ORDER = new Map( STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index]) ); -const TERRAIN_BRUSH_TOOLS: TerrainBrushTool[] = [ +const TERRAIN_SCULPT_BRUSH_TOOLS: Exclude[] = [ "raise", "lower", "smooth", @@ -2181,6 +2189,13 @@ export function App({ store, initialStatusMessage }: AppProps) { : null; const selectedTerrainBounds = selectedTerrain === null ? null : getTerrainBounds(selectedTerrain); + const selectedTerrainFootprint = + selectedTerrain === null + ? null + : { + width: getTerrainFootprintWidth(selectedTerrain), + depth: getTerrainFootprintDepth(selectedTerrain) + }; const selectedTerrainHeightRange = selectedTerrainBounds === null ? null @@ -2572,19 +2587,49 @@ export function App({ store, initialStatusMessage }: AppProps) { ); const [armedTerrainBrushTool, setArmedTerrainBrushTool] = useState(null); + const [activeTerrainPaintLayerIndex, setActiveTerrainPaintLayerIndex] = + useState(0); const [terrainBrushSettings, setTerrainBrushSettings] = useState( createDefaultTerrainBrushSettings() ); + const [terrainSampleCountXDraft, setTerrainSampleCountXDraft] = useState( + "9" + ); + const [terrainSampleCountZDraft, setTerrainSampleCountZDraft] = useState( + "9" + ); + const [terrainCellSizeDraft, setTerrainCellSizeDraft] = useState("1"); const activeTerrainBrushState: ArmedTerrainBrushState | null = selectedTerrain === null || armedTerrainBrushTool === null ? null - : { - terrainId: selectedTerrain.id, - tool: armedTerrainBrushTool, - radius: terrainBrushSettings.radius, - strength: terrainBrushSettings.strength, - falloff: terrainBrushSettings.falloff - }; + : armedTerrainBrushTool === "paint" + ? { + terrainId: selectedTerrain.id, + tool: "paint", + layerIndex: clampTerrainPaintLayerIndex(activeTerrainPaintLayerIndex), + radius: terrainBrushSettings.radius, + strength: terrainBrushSettings.strength, + falloff: terrainBrushSettings.falloff + } + : { + terrainId: selectedTerrain.id, + tool: armedTerrainBrushTool, + radius: terrainBrushSettings.radius, + strength: terrainBrushSettings.strength, + falloff: terrainBrushSettings.falloff + }; + const resolvedTerrainPaintLayerIndex = clampTerrainPaintLayerIndex( + activeTerrainPaintLayerIndex + ); + const selectedTerrainActivePaintLayer = + selectedTerrain?.layers[resolvedTerrainPaintLayerIndex] ?? null; + const selectedTerrainActivePaintMaterial = + selectedTerrainActivePaintLayer?.materialId === null || + selectedTerrainActivePaintLayer === null + ? null + : (editorState.document.materials[ + selectedTerrainActivePaintLayer.materialId + ] ?? null); const [ambientLightIntensityDraft, setAmbientLightIntensityDraft] = useState( String(editorState.document.world.ambientLight.intensity) ); @@ -3411,6 +3456,19 @@ export function App({ store, initialStatusMessage }: AppProps) { setModelScaleDraft(createVec3Draft(selectedModelInstance.scale)); }, [selectedModelInstance]); + useEffect(() => { + if (selectedTerrain === null) { + setTerrainSampleCountXDraft("9"); + setTerrainSampleCountZDraft("9"); + setTerrainCellSizeDraft("1"); + return; + } + + setTerrainSampleCountXDraft(String(selectedTerrain.sampleCountX)); + setTerrainSampleCountZDraft(String(selectedTerrain.sampleCountZ)); + setTerrainCellSizeDraft(String(selectedTerrain.cellSize)); + }, [selectedTerrain]); + useEffect(() => { const projectTime = editorState.projectDocument.time; @@ -7466,8 +7524,14 @@ export function App({ store, initialStatusMessage }: AppProps) { } setArmedTerrainBrushTool(tool); + const paintLayerLabel = + tool === "paint" + ? ` ${getTerrainLayerLabel( + clampTerrainPaintLayerIndex(activeTerrainPaintLayerIndex) + ).toLowerCase()}` + : ""; setStatusMessage( - `Armed ${getTerrainBrushToolLabel(tool)} terrain brush for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Drag in the viewport to edit the selected terrain.` + `Armed ${getTerrainBrushToolLabel(tool)} terrain brush${paintLayerLabel} for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Drag in the viewport to edit the selected terrain.` ); }; @@ -7492,6 +7556,54 @@ export function App({ store, initialStatusMessage }: AppProps) { })); }; + const handleTerrainPaintLayerChange = (value: string) => { + setActiveTerrainPaintLayerIndex(clampTerrainPaintLayerIndex(Number(value))); + }; + + const handleTerrainLayerMaterialChange = ( + layerIndex: number, + materialId: string + ) => { + if (selectedTerrain === null) { + return; + } + + const nextMaterialId = materialId === "" ? null : materialId; + const currentMaterialId = selectedTerrain.layers[layerIndex]?.materialId ?? null; + + if (currentMaterialId === nextMaterialId) { + return; + } + + try { + const nextTerrain = createTerrain({ + ...selectedTerrain, + layers: selectedTerrain.layers.map((layer, currentLayerIndex) => ({ + materialId: + currentLayerIndex === layerIndex ? nextMaterialId : layer.materialId + })) + }); + + store.executeCommand( + createUpsertTerrainCommand({ + terrain: nextTerrain, + label: `Set ${getTerrainLayerLabel(layerIndex).toLowerCase()} material` + }) + ); + + setStatusMessage( + `${getTerrainLayerLabel(layerIndex)} now uses ${ + nextMaterialId === null + ? "no assigned material" + : editorState.document.materials[nextMaterialId]?.name ?? + nextMaterialId + }.` + ); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const handleCommitTerrainBrushStroke = ( commit: TerrainBrushStrokeCommit ): boolean => { @@ -7664,6 +7776,26 @@ export function App({ store, initialStatusMessage }: AppProps) { setStatusMessage(successMessage); }; + const commitTerrainChange = ( + currentTerrain: Terrain, + nextTerrain: Terrain, + commandLabel: string, + successMessage: string + ): boolean => { + if (areTerrainsEqual(currentTerrain, nextTerrain)) { + return false; + } + + store.executeCommand( + createUpsertTerrainCommand({ + terrain: nextTerrain, + label: commandLabel + }) + ); + setStatusMessage(successMessage); + return true; + }; + const applyModelInstanceChange = () => { if (selectedModelInstance === null) { setStatusMessage("Select a model instance before editing it."); @@ -7696,6 +7828,56 @@ export function App({ store, initialStatusMessage }: AppProps) { } }; + const applyTerrainGridChange = () => { + if (selectedTerrain === null) { + setStatusMessage("Select a terrain before resizing its grid."); + return; + } + + try { + const nextTerrain = resizeTerrainGrid(selectedTerrain, { + sampleCountX: Number(terrainSampleCountXDraft), + sampleCountZ: Number(terrainSampleCountZDraft), + cellSize: Number(terrainCellSizeDraft) + }); + const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList); + + commitTerrainChange( + selectedTerrain, + nextTerrain, + "Resize terrain grid", + `Resampled ${terrainLabel} to ${nextTerrain.sampleCountX} x ${nextTerrain.sampleCountZ} samples with ${nextTerrain.cellSize}m square cells.` + ); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + + const handleTerrainCollisionEnabledChange = (enabled: boolean) => { + if (selectedTerrain === null || selectedTerrain.collisionEnabled === enabled) { + return; + } + + try { + const nextTerrain = createTerrain({ + ...selectedTerrain, + collisionEnabled: enabled + }); + const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList); + + commitTerrainChange( + selectedTerrain, + nextTerrain, + enabled ? "Enable terrain collision" : "Disable terrain collision", + enabled + ? `${terrainLabel} now participates in runner collision as a heightfield.` + : `${terrainLabel} no longer contributes runner collision.` + ); + } catch (error) { + setStatusMessage(getErrorMessage(error)); + } + }; + const setPlayerStartMovementTemplateEditorDraft = ( template: PlayerStartMovementTemplate ) => { @@ -15177,13 +15359,161 @@ export function App({ store, initialStatusMessage }: AppProps) {
-
Terrain Tools
+
Collision
+ +
+ Hidden terrain keeps collision. Disabled terrain is removed + from editor picking, rendering, and runtime collision. +
+
+ +
+
Grid Settings
+
+ Resizing keeps the terrain centered and resamples heights + and paint across the new grid. +
+
+ + + +
+ + {selectedTerrainFootprint === null ? null : ( +
+ Footprint {selectedTerrainFootprint.width}m x{" "} + {selectedTerrainFootprint.depth}m with square cells +
+ )} +
+ +
+
Terrain Sculpt
- {TERRAIN_BRUSH_TOOLS.map((tool) => ( + {TERRAIN_SCULPT_BRUSH_TOOLS.map((tool) => (
-
Brush
+
Terrain Paint
+
+ +
+ +
+ {getTerrainLayerLabel(resolvedTerrainPaintLayerIndex)} uses{" "} + {selectedTerrainActivePaintMaterial?.name ?? + selectedTerrainActivePaintLayer?.materialId ?? + "no assigned material"} + . +
+
+ {selectedTerrain.layers.map((layer, layerIndex) => ( + + ))} +
+
+ +
+
Brush Settings