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
This commit is contained in:
2026-04-20 02:36:38 +02:00
parent 02f6d058a0
commit 94dec56eb4
25 changed files with 2199 additions and 70 deletions

View File

@@ -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<TerrainBrushTool, "paint">[] = [
"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<TerrainBrushTool | null>(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) {
</div>
<div className="form-section">
<div className="label">Terrain Tools</div>
<div className="label">Collision</div>
<label className="form-field form-field--toggle">
<span className="label">Enable runner heightfield collision</span>
<input
data-testid="terrain-collision-enabled"
type="checkbox"
checked={selectedTerrain.collisionEnabled}
onChange={(event) =>
handleTerrainCollisionEnabledChange(
event.currentTarget.checked
)
}
/>
</label>
<div className="material-summary">
Hidden terrain keeps collision. Disabled terrain is removed
from editor picking, rendering, and runtime collision.
</div>
</div>
<div className="form-section">
<div className="label">Grid Settings</div>
<div className="material-summary">
Resizing keeps the terrain centered and resamples heights
and paint across the new grid.
</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">Samples X</span>
<input
data-testid="terrain-grid-sample-count-x"
className="text-input"
type="number"
min={MIN_TERRAIN_SAMPLE_COUNT}
step="1"
value={terrainSampleCountXDraft}
onChange={(event) =>
setTerrainSampleCountXDraft(
event.currentTarget.value
)
}
onBlur={applyTerrainGridChange}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyTerrainGridChange
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyTerrainGridChange
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyTerrainGridChange
)
}
/>
</label>
<label className="form-field">
<span className="label">Samples Z</span>
<input
data-testid="terrain-grid-sample-count-z"
className="text-input"
type="number"
min={MIN_TERRAIN_SAMPLE_COUNT}
step="1"
value={terrainSampleCountZDraft}
onChange={(event) =>
setTerrainSampleCountZDraft(
event.currentTarget.value
)
}
onBlur={applyTerrainGridChange}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyTerrainGridChange
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyTerrainGridChange
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyTerrainGridChange
)
}
/>
</label>
<label className="form-field">
<span className="label">Cell Size</span>
<input
data-testid="terrain-grid-cell-size"
className="text-input"
type="number"
min="0.1"
step="0.1"
value={terrainCellSizeDraft}
onChange={(event) =>
setTerrainCellSizeDraft(event.currentTarget.value)
}
onBlur={applyTerrainGridChange}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyTerrainGridChange
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyTerrainGridChange
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyTerrainGridChange
)
}
/>
</label>
</div>
<button
className="viewport-panel__button"
type="button"
data-testid="terrain-grid-apply"
onClick={applyTerrainGridChange}
>
Apply Grid
</button>
{selectedTerrainFootprint === null ? null : (
<div className="material-summary">
Footprint {selectedTerrainFootprint.width}m x{" "}
{selectedTerrainFootprint.depth}m with square cells
</div>
)}
</div>
<div className="form-section">
<div className="label">Terrain Sculpt</div>
<div
className="viewport-panel__control-group"
role="group"
aria-label="Terrain brush tools"
aria-label="Terrain sculpt brush tools"
>
{TERRAIN_BRUSH_TOOLS.map((tool) => (
{TERRAIN_SCULPT_BRUSH_TOOLS.map((tool) => (
<button
key={tool}
className={`viewport-panel__button ${armedTerrainBrushTool === tool ? "viewport-panel__button--active" : ""}`}
@@ -15199,12 +15529,88 @@ export function App({ store, initialStatusMessage }: AppProps) {
<div className="material-summary">
{armedTerrainBrushTool === null
? "No terrain brush is armed. Existing selection and transforms stay unchanged."
: `${getTerrainBrushToolLabel(armedTerrainBrushTool)} is armed for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Click the active tool again to disarm it.`}
: armedTerrainBrushTool === "paint"
? `Paint is armed for ${getTerrainLabelById(selectedTerrain.id, terrainList)} on ${getTerrainLayerLabel(resolvedTerrainPaintLayerIndex).toLowerCase()}.`
: `${getTerrainBrushToolLabel(armedTerrainBrushTool)} is armed for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Click the active tool again to disarm it.`}
</div>
</div>
<div className="form-section">
<div className="label">Brush</div>
<div className="label">Terrain Paint</div>
<div
className="viewport-panel__control-group"
role="group"
aria-label="Terrain paint controls"
>
<button
className={`viewport-panel__button ${armedTerrainBrushTool === "paint" ? "viewport-panel__button--active" : ""}`}
type="button"
data-testid="terrain-brush-tool-paint"
aria-pressed={armedTerrainBrushTool === "paint"}
onClick={() => handleArmTerrainBrushTool("paint")}
>
Paint Layer
</button>
</div>
<label className="form-field">
<span className="label">Active Layer</span>
<select
data-testid="terrain-paint-active-layer"
value={resolvedTerrainPaintLayerIndex}
onChange={(event) =>
handleTerrainPaintLayerChange(event.currentTarget.value)
}
>
{Array.from(
{ length: TERRAIN_LAYER_COUNT },
(_, layerIndex) => layerIndex
).map((layerIndex) => (
<option key={layerIndex} value={layerIndex}>
{getTerrainLayerLabel(layerIndex)}
</option>
))}
</select>
</label>
<div className="material-summary">
{getTerrainLayerLabel(resolvedTerrainPaintLayerIndex)} uses{" "}
{selectedTerrainActivePaintMaterial?.name ??
selectedTerrainActivePaintLayer?.materialId ??
"no assigned material"}
.
</div>
<div className="terrain-layer-list">
{selectedTerrain.layers.map((layer, layerIndex) => (
<label
key={layerIndex}
className="form-field"
>
<span className="label">
{getTerrainLayerLabel(layerIndex)}
</span>
<select
data-testid={`terrain-layer-material-${layerIndex}`}
value={layer.materialId ?? ""}
onChange={(event) =>
handleTerrainLayerMaterialChange(
layerIndex,
event.currentTarget.value
)
}
>
<option value="">Unassigned</option>
{materialList.map((material) => (
<option key={material.id} value={material.id}>
{material.name}
</option>
))}
</select>
</label>
))}
</div>
</div>
<div className="form-section">
<div className="label">Brush Settings</div>
<label className="form-field">
<span className="label">
Radius {terrainBrushSettings.radius.toFixed(2)}m
@@ -15263,10 +15669,12 @@ export function App({ store, initialStatusMessage }: AppProps) {
<div className="form-section">
<div className="label">Grid</div>
<div className="material-summary">
{(selectedTerrain.sampleCountX - 1) * selectedTerrain.cellSize}m x{" "}
{(selectedTerrain.sampleCountZ - 1) * selectedTerrain.cellSize}m footprint
</div>
{selectedTerrainFootprint === null ? null : (
<div className="material-summary">
{selectedTerrainFootprint.width}m x{" "}
{selectedTerrainFootprint.depth}m footprint
</div>
)}
<div className="material-summary">
{selectedTerrain.heights.length} height samples
</div>