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:
444
src/app/App.tsx
444
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<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>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Terrain } from "../document/terrains";
|
||||
import { TERRAIN_LAYER_COUNT } from "../document/terrains";
|
||||
|
||||
export type TerrainBrushTool = "raise" | "lower" | "smooth" | "flatten";
|
||||
export type TerrainBrushTool =
|
||||
| "raise"
|
||||
| "lower"
|
||||
| "smooth"
|
||||
| "flatten"
|
||||
| "paint";
|
||||
|
||||
export interface TerrainBrushSettings {
|
||||
radius: number;
|
||||
@@ -8,11 +14,21 @@ export interface TerrainBrushSettings {
|
||||
falloff: number;
|
||||
}
|
||||
|
||||
export interface ArmedTerrainBrushState extends TerrainBrushSettings {
|
||||
export interface ArmedTerrainSculptBrushState extends TerrainBrushSettings {
|
||||
terrainId: string;
|
||||
tool: TerrainBrushTool;
|
||||
tool: Exclude<TerrainBrushTool, "paint">;
|
||||
}
|
||||
|
||||
export interface ArmedTerrainPaintBrushState extends TerrainBrushSettings {
|
||||
terrainId: string;
|
||||
tool: "paint";
|
||||
layerIndex: number;
|
||||
}
|
||||
|
||||
export type ArmedTerrainBrushState =
|
||||
| ArmedTerrainSculptBrushState
|
||||
| ArmedTerrainPaintBrushState;
|
||||
|
||||
export interface TerrainBrushStrokeCommit {
|
||||
terrain: Terrain;
|
||||
commandLabel: string;
|
||||
@@ -73,6 +89,17 @@ export function createDefaultTerrainBrushSettings(): TerrainBrushSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export function clampTerrainPaintLayerIndex(layerIndex: number): number {
|
||||
if (!Number.isFinite(layerIndex)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
TERRAIN_LAYER_COUNT - 1,
|
||||
Math.max(0, Math.round(layerIndex))
|
||||
);
|
||||
}
|
||||
|
||||
export function getTerrainBrushToolLabel(tool: TerrainBrushTool): string {
|
||||
switch (tool) {
|
||||
case "raise":
|
||||
@@ -83,6 +110,8 @@ export function getTerrainBrushToolLabel(tool: TerrainBrushTool): string {
|
||||
return "Smooth";
|
||||
case "flatten":
|
||||
return "Flatten";
|
||||
case "paint":
|
||||
return "Paint";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,9 @@ import {
|
||||
import {
|
||||
BOX_BRUSH_SCENE_DOCUMENT_VERSION,
|
||||
ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
|
||||
AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION,
|
||||
AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
||||
AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION,
|
||||
AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION,
|
||||
CONTROL_SURFACE_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
||||
DEFAULT_PROJECT_NAME,
|
||||
@@ -1855,6 +1858,36 @@ function readTerrain(value: unknown, label: string): Terrain {
|
||||
const heights = value.heights.map((heightValue, index) =>
|
||||
expectFiniteNumber(heightValue, `${label}.heights.${index}`)
|
||||
);
|
||||
const layers =
|
||||
value.layers === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
if (!Array.isArray(value.layers) || value.layers.some((layer) => !isRecord(layer))) {
|
||||
throw new Error(`${label}.layers must be an array of layer objects.`);
|
||||
}
|
||||
|
||||
return value.layers.map((layerValue, layerIndex) => ({
|
||||
materialId:
|
||||
layerValue.materialId === undefined || layerValue.materialId === null
|
||||
? null
|
||||
: expectString(
|
||||
layerValue.materialId,
|
||||
`${label}.layers.${layerIndex}.materialId`
|
||||
)
|
||||
}));
|
||||
})();
|
||||
const paintWeights =
|
||||
value.paintWeights === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
if (!Array.isArray(value.paintWeights)) {
|
||||
throw new Error(`${label}.paintWeights must be an array.`);
|
||||
}
|
||||
|
||||
return value.paintWeights.map((paintWeight, index) =>
|
||||
expectFiniteNumber(paintWeight, `${label}.paintWeights.${index}`)
|
||||
);
|
||||
})();
|
||||
|
||||
return createTerrain({
|
||||
id: expectString(value.id, `${label}.id`),
|
||||
@@ -1863,11 +1896,17 @@ function readTerrain(value: unknown, label: string): Terrain {
|
||||
),
|
||||
visible: expectBoolean(value.visible, `${label}.visible`),
|
||||
enabled: expectBoolean(value.enabled, `${label}.enabled`),
|
||||
collisionEnabled:
|
||||
value.collisionEnabled === undefined
|
||||
? undefined
|
||||
: expectBoolean(value.collisionEnabled, `${label}.collisionEnabled`),
|
||||
position: readVec3(value.position, `${label}.position`),
|
||||
sampleCountX,
|
||||
sampleCountZ,
|
||||
cellSize,
|
||||
heights
|
||||
heights,
|
||||
layers,
|
||||
paintWeights
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4841,6 +4880,9 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
|
||||
source.version !== PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== PROJECT_SEQUENCE_UNIFIED_VISIBILITY_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -66,7 +66,11 @@ import {
|
||||
type ProjectTimeSettings
|
||||
} from "./project-time-settings";
|
||||
import { MIN_SCENE_PATH_POINT_COUNT, type ScenePath } from "./paths";
|
||||
import { MIN_TERRAIN_SAMPLE_COUNT, type Terrain } from "./terrains";
|
||||
import {
|
||||
MIN_TERRAIN_SAMPLE_COUNT,
|
||||
TERRAIN_LAYER_COUNT,
|
||||
type Terrain
|
||||
} from "./terrains";
|
||||
import {
|
||||
isAdvancedRenderingWaterReflectionMode,
|
||||
isAdvancedRenderingShadowMapSize,
|
||||
@@ -1652,6 +1656,7 @@ function validateScenePath(
|
||||
function validateTerrain(
|
||||
terrain: Terrain,
|
||||
path: string,
|
||||
document: SceneDocument,
|
||||
diagnostics: SceneDiagnostic[]
|
||||
) {
|
||||
if (!isBoolean(terrain.visible)) {
|
||||
@@ -1698,6 +1703,17 @@ function validateTerrain(
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBoolean(terrain.collisionEnabled)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-terrain-collision-enabled",
|
||||
"Terrain collisionEnabled must remain a boolean.",
|
||||
`${path}.collisionEnabled`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isPositiveInteger(terrain.sampleCountX) ||
|
||||
terrain.sampleCountX < MIN_TERRAIN_SAMPLE_COUNT
|
||||
@@ -1762,6 +1778,67 @@ function validateTerrain(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (terrain.layers.length !== TERRAIN_LAYER_COUNT) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-terrain-layer-count",
|
||||
`Terrain layers must contain exactly ${TERRAIN_LAYER_COUNT} authored layer slots.`,
|
||||
`${path}.layers`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < terrain.layers.length; index += 1) {
|
||||
const layer = terrain.layers[index];
|
||||
|
||||
if (layer.materialId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (document.materials[layer.materialId] === undefined) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-terrain-layer-material",
|
||||
`Terrain layer material reference ${layer.materialId} does not exist in the document material registry.`,
|
||||
`${path}.layers.${index}.materialId`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPaintWeightCount =
|
||||
terrain.sampleCountX * terrain.sampleCountZ * (TERRAIN_LAYER_COUNT - 1);
|
||||
|
||||
if (terrain.paintWeights.length !== expectedPaintWeightCount) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-terrain-paint-weight-count",
|
||||
`Terrain paint weights must contain exactly ${expectedPaintWeightCount} values.`,
|
||||
`${path}.paintWeights`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < terrain.paintWeights.length; index += 1) {
|
||||
const paintWeight = terrain.paintWeights[index];
|
||||
|
||||
if (isFiniteNumber(paintWeight) && paintWeight >= 0 && paintWeight <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"invalid-terrain-paint-weight",
|
||||
"Terrain paint weights must remain finite values between 0 and 1.",
|
||||
`${path}.paintWeights.${index}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthoredEntityState(
|
||||
@@ -5718,7 +5795,7 @@ export function validateSceneDocument(
|
||||
}
|
||||
|
||||
registerAuthoredId(terrain.id, path, seenIds, diagnostics);
|
||||
validateTerrain(terrain, path, diagnostics);
|
||||
validateTerrain(terrain, path, document, diagnostics);
|
||||
}
|
||||
|
||||
for (const [modelInstanceKey, modelInstance] of Object.entries(
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
} from "../sequencer/project-sequences";
|
||||
import type { Terrain } from "./terrains";
|
||||
|
||||
export const SCENE_DOCUMENT_VERSION = 65 as const;
|
||||
export const SCENE_DOCUMENT_VERSION = 67 as const;
|
||||
export const AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION = 67 as const;
|
||||
export const AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION = 66 as const;
|
||||
export const AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION = 65 as const;
|
||||
export const FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION = 64 as const;
|
||||
export const NPC_DIALOGUE_LINE_SPEAKER_REMOVED_SCENE_DOCUMENT_VERSION =
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
export interface TerrainLayer {
|
||||
materialId: string | null;
|
||||
}
|
||||
|
||||
export interface Terrain {
|
||||
id: string;
|
||||
kind: "terrain";
|
||||
name?: string;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
collisionEnabled: boolean;
|
||||
position: Vec3;
|
||||
sampleCountX: number;
|
||||
sampleCountZ: number;
|
||||
cellSize: number;
|
||||
heights: number[];
|
||||
layers: TerrainLayer[];
|
||||
paintWeights: number[];
|
||||
}
|
||||
|
||||
export const DEFAULT_TERRAIN_VISIBLE = true;
|
||||
export const DEFAULT_TERRAIN_ENABLED = true;
|
||||
export const DEFAULT_TERRAIN_COLLISION_ENABLED = true;
|
||||
export const MIN_TERRAIN_SAMPLE_COUNT = 2;
|
||||
export const DEFAULT_TERRAIN_SAMPLE_COUNT_X = 9;
|
||||
export const DEFAULT_TERRAIN_SAMPLE_COUNT_Z = 9;
|
||||
export const DEFAULT_TERRAIN_CELL_SIZE = 1;
|
||||
export const DEFAULT_TERRAIN_HEIGHT = 0;
|
||||
export const TERRAIN_LAYER_COUNT = 4;
|
||||
export const DEFAULT_TERRAIN_LAYER_MATERIAL_IDS = [
|
||||
"patchy_grass_ground_250x250",
|
||||
"patchy_weedy_dirt_ground_300x300",
|
||||
"ground_sand_300x300",
|
||||
"concrete_wall_cladding_250x250"
|
||||
] as const;
|
||||
|
||||
function cloneVec3(vector: Vec3): Vec3 {
|
||||
return {
|
||||
@@ -72,6 +87,82 @@ export function normalizeTerrainCellSize(value: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTerrainLayerMaterialId(
|
||||
value: string | null | undefined,
|
||||
label: string
|
||||
): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${label} must be a string or null.`);
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
return normalizedValue.length === 0 ? null : normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeTerrainCollisionEnabled(value: boolean): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error("Terrain collisionEnabled must be a boolean.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getTerrainLayerLabel(layerIndex: number): string {
|
||||
if (!Number.isInteger(layerIndex) || layerIndex < 0 || layerIndex >= TERRAIN_LAYER_COUNT) {
|
||||
throw new Error(`Terrain layer index ${layerIndex} is out of range.`);
|
||||
}
|
||||
|
||||
return layerIndex === 0 ? "Base Layer" : `Layer ${layerIndex + 1}`;
|
||||
}
|
||||
|
||||
export function createDefaultTerrainLayers(): TerrainLayer[] {
|
||||
return Array.from({ length: TERRAIN_LAYER_COUNT }, (_, layerIndex) => ({
|
||||
materialId: DEFAULT_TERRAIN_LAYER_MATERIAL_IDS[layerIndex] ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export function cloneTerrainLayers(
|
||||
layers: readonly TerrainLayer[]
|
||||
): TerrainLayer[] {
|
||||
return layers.map((layer, layerIndex) => ({
|
||||
materialId: normalizeTerrainLayerMaterialId(
|
||||
layer.materialId,
|
||||
`Terrain layer ${layerIndex}`
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTerrainLayers(
|
||||
layers: readonly TerrainLayer[] | undefined
|
||||
): TerrainLayer[] {
|
||||
if (layers === undefined) {
|
||||
return createDefaultTerrainLayers();
|
||||
}
|
||||
|
||||
if (layers.length !== TERRAIN_LAYER_COUNT) {
|
||||
throw new Error(
|
||||
`Terrain layers must contain exactly ${TERRAIN_LAYER_COUNT} layer slots.`
|
||||
);
|
||||
}
|
||||
|
||||
return layers.map((layer, layerIndex) => {
|
||||
if (typeof layer !== "object" || layer === null) {
|
||||
throw new Error(`Terrain layer ${layerIndex} must be an object.`);
|
||||
}
|
||||
|
||||
return {
|
||||
materialId: normalizeTerrainLayerMaterialId(
|
||||
layer.materialId,
|
||||
`Terrain layer ${layerIndex}.materialId`
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createFlatTerrainHeights(
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number,
|
||||
@@ -93,6 +184,26 @@ export function createFlatTerrainHeights(
|
||||
return new Array(normalizedSampleCountX * normalizedSampleCountZ).fill(height);
|
||||
}
|
||||
|
||||
export function createFlatTerrainPaintWeights(
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number
|
||||
): number[] {
|
||||
const normalizedSampleCountX = normalizeTerrainSampleCount(
|
||||
sampleCountX,
|
||||
"Terrain sampleCountX"
|
||||
);
|
||||
const normalizedSampleCountZ = normalizeTerrainSampleCount(
|
||||
sampleCountZ,
|
||||
"Terrain sampleCountZ"
|
||||
);
|
||||
|
||||
return new Array(
|
||||
normalizedSampleCountX *
|
||||
normalizedSampleCountZ *
|
||||
(TERRAIN_LAYER_COUNT - 1)
|
||||
).fill(0);
|
||||
}
|
||||
|
||||
export function getTerrainSampleIndex(
|
||||
terrain: Pick<Terrain, "sampleCountX" | "sampleCountZ">,
|
||||
sampleX: number,
|
||||
@@ -117,6 +228,14 @@ export function getTerrainSampleIndex(
|
||||
return sampleZ * terrain.sampleCountX + sampleX;
|
||||
}
|
||||
|
||||
export function getTerrainPaintWeightSampleOffset(
|
||||
terrain: Pick<Terrain, "sampleCountX" | "sampleCountZ">,
|
||||
sampleX: number,
|
||||
sampleZ: number
|
||||
): number {
|
||||
return getTerrainSampleIndex(terrain, sampleX, sampleZ) * (TERRAIN_LAYER_COUNT - 1);
|
||||
}
|
||||
|
||||
export function getTerrainHeightAtSample(
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
@@ -125,6 +244,90 @@ export function getTerrainHeightAtSample(
|
||||
return terrain.heights[getTerrainSampleIndex(terrain, sampleX, sampleZ)] ?? 0;
|
||||
}
|
||||
|
||||
function normalizeTerrainPaintWeights(
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number,
|
||||
paintWeights: readonly number[] | undefined
|
||||
): number[] {
|
||||
const expectedLength =
|
||||
sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1);
|
||||
const normalizedPaintWeights =
|
||||
paintWeights === undefined
|
||||
? createFlatTerrainPaintWeights(sampleCountX, sampleCountZ)
|
||||
: [...paintWeights];
|
||||
|
||||
if (normalizedPaintWeights.length !== expectedLength) {
|
||||
throw new Error(
|
||||
`Terrain paint weights must contain exactly ${expectedLength} values.`
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let sampleIndex = 0;
|
||||
sampleIndex < sampleCountX * sampleCountZ;
|
||||
sampleIndex += 1
|
||||
) {
|
||||
const offset = sampleIndex * (TERRAIN_LAYER_COUNT - 1);
|
||||
let weightSum = 0;
|
||||
|
||||
for (
|
||||
let layerOffset = 0;
|
||||
layerOffset < TERRAIN_LAYER_COUNT - 1;
|
||||
layerOffset += 1
|
||||
) {
|
||||
const value = normalizedPaintWeights[offset + layerOffset];
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Terrain paint weights must remain finite.");
|
||||
}
|
||||
|
||||
const clampedValue = Math.min(1, Math.max(0, value));
|
||||
normalizedPaintWeights[offset + layerOffset] = clampedValue;
|
||||
weightSum += clampedValue;
|
||||
}
|
||||
|
||||
if (weightSum <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scale = 1 / weightSum;
|
||||
|
||||
for (
|
||||
let layerOffset = 0;
|
||||
layerOffset < TERRAIN_LAYER_COUNT - 1;
|
||||
layerOffset += 1
|
||||
) {
|
||||
normalizedPaintWeights[offset + layerOffset] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedPaintWeights;
|
||||
}
|
||||
|
||||
export function getTerrainSampleLayerWeights(
|
||||
terrain: Pick<Terrain, "sampleCountX" | "sampleCountZ" | "paintWeights">,
|
||||
sampleX: number,
|
||||
sampleZ: number
|
||||
): [number, number, number, number] {
|
||||
const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ);
|
||||
const layer1 = terrain.paintWeights[offset] ?? 0;
|
||||
const layer2 = terrain.paintWeights[offset + 1] ?? 0;
|
||||
const layer3 = terrain.paintWeights[offset + 2] ?? 0;
|
||||
const baseLayer = Math.max(0, 1 - (layer1 + layer2 + layer3));
|
||||
const weightSum = baseLayer + layer1 + layer2 + layer3;
|
||||
|
||||
if (weightSum <= 0) {
|
||||
return [1, 0, 0, 0];
|
||||
}
|
||||
|
||||
return [
|
||||
baseLayer / weightSum,
|
||||
layer1 / weightSum,
|
||||
layer2 / weightSum,
|
||||
layer3 / weightSum
|
||||
];
|
||||
}
|
||||
|
||||
export function getTerrainWorldSamplePosition(
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
@@ -137,9 +340,21 @@ export function getTerrainWorldSamplePosition(
|
||||
};
|
||||
}
|
||||
|
||||
export function getTerrainFootprintWidth(
|
||||
terrain: Pick<Terrain, "sampleCountX" | "cellSize">
|
||||
): number {
|
||||
return (terrain.sampleCountX - 1) * terrain.cellSize;
|
||||
}
|
||||
|
||||
export function getTerrainFootprintDepth(
|
||||
terrain: Pick<Terrain, "sampleCountZ" | "cellSize">
|
||||
): number {
|
||||
return (terrain.sampleCountZ - 1) * terrain.cellSize;
|
||||
}
|
||||
|
||||
export function getTerrainBounds(terrain: Terrain): { min: Vec3; max: Vec3 } {
|
||||
const width = (terrain.sampleCountX - 1) * terrain.cellSize;
|
||||
const depth = (terrain.sampleCountZ - 1) * terrain.cellSize;
|
||||
const width = getTerrainFootprintWidth(terrain);
|
||||
const depth = getTerrainFootprintDepth(terrain);
|
||||
let minHeight = Number.POSITIVE_INFINITY;
|
||||
let maxHeight = Number.NEGATIVE_INFINITY;
|
||||
|
||||
@@ -167,6 +382,225 @@ export function getTerrainBounds(terrain: Terrain): { min: Vec3; max: Vec3 } {
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function lerp(start: number, end: number, alpha: number): number {
|
||||
return start + (end - start) * alpha;
|
||||
}
|
||||
|
||||
function sampleTerrainHeightAtGridCoordinate(
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
sampleZ: number
|
||||
): number {
|
||||
const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1);
|
||||
const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1);
|
||||
const minSampleX = Math.floor(clampedSampleX);
|
||||
const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1);
|
||||
const minSampleZ = Math.floor(clampedSampleZ);
|
||||
const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1);
|
||||
const blendX = clampedSampleX - minSampleX;
|
||||
const blendZ = clampedSampleZ - minSampleZ;
|
||||
const height00 = getTerrainHeightAtSample(terrain, minSampleX, minSampleZ);
|
||||
const height10 = getTerrainHeightAtSample(terrain, maxSampleX, minSampleZ);
|
||||
const height01 = getTerrainHeightAtSample(terrain, minSampleX, maxSampleZ);
|
||||
const height11 = getTerrainHeightAtSample(terrain, maxSampleX, maxSampleZ);
|
||||
|
||||
return lerp(
|
||||
lerp(height00, height10, blendX),
|
||||
lerp(height01, height11, blendX),
|
||||
blendZ
|
||||
);
|
||||
}
|
||||
|
||||
function getStoredTerrainPaintWeightAtSample(
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
sampleZ: number,
|
||||
layerOffset: number
|
||||
): number {
|
||||
const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ);
|
||||
return terrain.paintWeights[offset + layerOffset] ?? 0;
|
||||
}
|
||||
|
||||
function sampleTerrainPaintWeightAtGridCoordinate(
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
sampleZ: number,
|
||||
layerOffset: number
|
||||
): number {
|
||||
const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1);
|
||||
const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1);
|
||||
const minSampleX = Math.floor(clampedSampleX);
|
||||
const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1);
|
||||
const minSampleZ = Math.floor(clampedSampleZ);
|
||||
const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1);
|
||||
const blendX = clampedSampleX - minSampleX;
|
||||
const blendZ = clampedSampleZ - minSampleZ;
|
||||
const weight00 = getStoredTerrainPaintWeightAtSample(
|
||||
terrain,
|
||||
minSampleX,
|
||||
minSampleZ,
|
||||
layerOffset
|
||||
);
|
||||
const weight10 = getStoredTerrainPaintWeightAtSample(
|
||||
terrain,
|
||||
maxSampleX,
|
||||
minSampleZ,
|
||||
layerOffset
|
||||
);
|
||||
const weight01 = getStoredTerrainPaintWeightAtSample(
|
||||
terrain,
|
||||
minSampleX,
|
||||
maxSampleZ,
|
||||
layerOffset
|
||||
);
|
||||
const weight11 = getStoredTerrainPaintWeightAtSample(
|
||||
terrain,
|
||||
maxSampleX,
|
||||
maxSampleZ,
|
||||
layerOffset
|
||||
);
|
||||
|
||||
return lerp(
|
||||
lerp(weight00, weight10, blendX),
|
||||
lerp(weight01, weight11, blendX),
|
||||
blendZ
|
||||
);
|
||||
}
|
||||
|
||||
function createTerrainPositionFromCenter(
|
||||
center: Vec3,
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number,
|
||||
cellSize: number
|
||||
): Vec3 {
|
||||
return {
|
||||
x: center.x - ((sampleCountX - 1) * cellSize) * 0.5,
|
||||
y: center.y,
|
||||
z: center.z - ((sampleCountZ - 1) * cellSize) * 0.5
|
||||
};
|
||||
}
|
||||
|
||||
function getTerrainFootprintCenter(terrain: Terrain): Vec3 {
|
||||
return {
|
||||
x: terrain.position.x + getTerrainFootprintWidth(terrain) * 0.5,
|
||||
y: terrain.position.y,
|
||||
z: terrain.position.z + getTerrainFootprintDepth(terrain) * 0.5
|
||||
};
|
||||
}
|
||||
|
||||
function createResampledTerrainHeights(
|
||||
terrain: Terrain,
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number
|
||||
): number[] {
|
||||
const heights = new Array<number>(sampleCountX * sampleCountZ);
|
||||
|
||||
for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) {
|
||||
const normalizedSampleZ =
|
||||
sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1);
|
||||
const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1);
|
||||
|
||||
for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) {
|
||||
const normalizedSampleX =
|
||||
sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1);
|
||||
const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1);
|
||||
|
||||
heights[sampleZ * sampleCountX + sampleX] =
|
||||
sampleTerrainHeightAtGridCoordinate(
|
||||
terrain,
|
||||
sourceSampleX,
|
||||
sourceSampleZ
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return heights;
|
||||
}
|
||||
|
||||
function createResampledTerrainPaintWeights(
|
||||
terrain: Terrain,
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number
|
||||
): number[] {
|
||||
const paintWeights = new Array<number>(
|
||||
sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1)
|
||||
);
|
||||
|
||||
for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) {
|
||||
const normalizedSampleZ =
|
||||
sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1);
|
||||
const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1);
|
||||
|
||||
for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) {
|
||||
const normalizedSampleX =
|
||||
sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1);
|
||||
const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1);
|
||||
const offset =
|
||||
(sampleZ * sampleCountX + sampleX) * (TERRAIN_LAYER_COUNT - 1);
|
||||
|
||||
for (
|
||||
let layerOffset = 0;
|
||||
layerOffset < TERRAIN_LAYER_COUNT - 1;
|
||||
layerOffset += 1
|
||||
) {
|
||||
paintWeights[offset + layerOffset] =
|
||||
sampleTerrainPaintWeightAtGridCoordinate(
|
||||
terrain,
|
||||
sourceSampleX,
|
||||
sourceSampleZ,
|
||||
layerOffset
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paintWeights;
|
||||
}
|
||||
|
||||
export function resizeTerrainGrid(
|
||||
terrain: Terrain,
|
||||
options: Pick<Terrain, "sampleCountX" | "sampleCountZ" | "cellSize"> & {
|
||||
preserveCenter?: boolean;
|
||||
}
|
||||
): Terrain {
|
||||
const sampleCountX = normalizeTerrainSampleCount(
|
||||
options.sampleCountX,
|
||||
"Terrain sampleCountX"
|
||||
);
|
||||
const sampleCountZ = normalizeTerrainSampleCount(
|
||||
options.sampleCountZ,
|
||||
"Terrain sampleCountZ"
|
||||
);
|
||||
const cellSize = normalizeTerrainCellSize(options.cellSize);
|
||||
const preserveCenter = options.preserveCenter ?? true;
|
||||
const nextPosition = preserveCenter
|
||||
? createTerrainPositionFromCenter(
|
||||
getTerrainFootprintCenter(terrain),
|
||||
sampleCountX,
|
||||
sampleCountZ,
|
||||
cellSize
|
||||
)
|
||||
: cloneVec3(terrain.position);
|
||||
|
||||
return createTerrain({
|
||||
...terrain,
|
||||
position: nextPosition,
|
||||
sampleCountX,
|
||||
sampleCountZ,
|
||||
cellSize,
|
||||
heights: createResampledTerrainHeights(terrain, sampleCountX, sampleCountZ),
|
||||
paintWeights: createResampledTerrainPaintWeights(
|
||||
terrain,
|
||||
sampleCountX,
|
||||
sampleCountZ
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultTerrainPosition(
|
||||
sampleCountX: number,
|
||||
sampleCountZ: number,
|
||||
@@ -187,11 +621,14 @@ export function createTerrain(
|
||||
| "name"
|
||||
| "visible"
|
||||
| "enabled"
|
||||
| "collisionEnabled"
|
||||
| "position"
|
||||
| "sampleCountX"
|
||||
| "sampleCountZ"
|
||||
| "cellSize"
|
||||
| "heights"
|
||||
| "layers"
|
||||
| "paintWeights"
|
||||
>
|
||||
> = {}
|
||||
): Terrain {
|
||||
@@ -214,8 +651,17 @@ export function createTerrain(
|
||||
overrides.heights !== undefined
|
||||
? [...overrides.heights]
|
||||
: createFlatTerrainHeights(sampleCountX, sampleCountZ);
|
||||
const layers = normalizeTerrainLayers(overrides.layers);
|
||||
const paintWeights = normalizeTerrainPaintWeights(
|
||||
sampleCountX,
|
||||
sampleCountZ,
|
||||
overrides.paintWeights
|
||||
);
|
||||
const visible = overrides.visible ?? DEFAULT_TERRAIN_VISIBLE;
|
||||
const enabled = overrides.enabled ?? DEFAULT_TERRAIN_ENABLED;
|
||||
const collisionEnabled = normalizeTerrainCollisionEnabled(
|
||||
overrides.collisionEnabled ?? DEFAULT_TERRAIN_COLLISION_ENABLED
|
||||
);
|
||||
|
||||
assertFiniteVec3(position, "Terrain position");
|
||||
|
||||
@@ -243,11 +689,14 @@ export function createTerrain(
|
||||
name: normalizeTerrainName(overrides.name),
|
||||
visible,
|
||||
enabled,
|
||||
collisionEnabled,
|
||||
position,
|
||||
sampleCountX,
|
||||
sampleCountZ,
|
||||
cellSize,
|
||||
heights
|
||||
heights,
|
||||
layers,
|
||||
paintWeights
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,12 +711,21 @@ export function areTerrainsEqual(left: Terrain, right: Terrain): boolean {
|
||||
left.name === right.name &&
|
||||
left.visible === right.visible &&
|
||||
left.enabled === right.enabled &&
|
||||
left.collisionEnabled === right.collisionEnabled &&
|
||||
areVec3Equal(left.position, right.position) &&
|
||||
left.sampleCountX === right.sampleCountX &&
|
||||
left.sampleCountZ === right.sampleCountZ &&
|
||||
left.cellSize === right.cellSize &&
|
||||
left.heights.length === right.heights.length &&
|
||||
left.heights.every((height, index) => height === right.heights[index])
|
||||
left.heights.every((height, index) => height === right.heights[index]) &&
|
||||
left.layers.length === right.layers.length &&
|
||||
left.layers.every(
|
||||
(layer, index) => layer.materialId === right.layers[index]?.materialId
|
||||
) &&
|
||||
left.paintWeights.length === right.paintWeights.length &&
|
||||
left.paintWeights.every(
|
||||
(weight, index) => weight === right.paintWeights[index]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { Vec3 } from "../core/vector";
|
||||
import type {
|
||||
ArmedTerrainBrushState,
|
||||
TerrainBrushSettings,
|
||||
TerrainBrushTool
|
||||
} from "../core/terrain-brush";
|
||||
import {
|
||||
createTerrain,
|
||||
getTerrainHeightAtSample,
|
||||
getTerrainPaintWeightSampleOffset,
|
||||
getTerrainSampleIndex,
|
||||
getTerrainSampleLayerWeights,
|
||||
TERRAIN_LAYER_COUNT,
|
||||
type Terrain
|
||||
} from "../document/terrains";
|
||||
|
||||
@@ -154,14 +158,54 @@ function getTerrainSmoothTargetHeight(
|
||||
: total / count;
|
||||
}
|
||||
|
||||
function createTerrainPaintTargetWeights(
|
||||
layerIndex: number
|
||||
): [number, number, number, number] {
|
||||
if (
|
||||
!Number.isInteger(layerIndex) ||
|
||||
layerIndex < 0 ||
|
||||
layerIndex >= TERRAIN_LAYER_COUNT
|
||||
) {
|
||||
throw new Error(`Terrain paint layer index ${layerIndex} is out of range.`);
|
||||
}
|
||||
|
||||
return [
|
||||
layerIndex === 0 ? 1 : 0,
|
||||
layerIndex === 1 ? 1 : 0,
|
||||
layerIndex === 2 ? 1 : 0,
|
||||
layerIndex === 3 ? 1 : 0
|
||||
];
|
||||
}
|
||||
|
||||
function setTerrainSamplePaintWeights(
|
||||
paintWeights: number[],
|
||||
terrain: Terrain,
|
||||
sampleX: number,
|
||||
sampleZ: number,
|
||||
weights: readonly [number, number, number, number]
|
||||
) {
|
||||
const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ);
|
||||
paintWeights[offset] = weights[1];
|
||||
paintWeights[offset + 1] = weights[2];
|
||||
paintWeights[offset + 2] = weights[3];
|
||||
}
|
||||
|
||||
export function applyTerrainBrushStamp(options: {
|
||||
terrain: Terrain;
|
||||
center: TerrainBrushPoint;
|
||||
settings: TerrainBrushSettings;
|
||||
tool: TerrainBrushTool;
|
||||
referenceHeight?: number | null;
|
||||
layerIndex?: number | null;
|
||||
}): Terrain {
|
||||
const { terrain, center, settings, tool, referenceHeight = null } = options;
|
||||
const {
|
||||
terrain,
|
||||
center,
|
||||
settings,
|
||||
tool,
|
||||
referenceHeight = null,
|
||||
layerIndex = null
|
||||
} = options;
|
||||
const { radius, strength, falloff } = settings;
|
||||
const minSampleX = Math.max(
|
||||
0,
|
||||
@@ -180,7 +224,9 @@ export function applyTerrainBrushStamp(options: {
|
||||
Math.ceil((center.z - terrain.position.z + radius) / terrain.cellSize)
|
||||
);
|
||||
const sourceHeights = terrain.heights;
|
||||
const sourcePaintWeights = terrain.paintWeights;
|
||||
const nextHeights = [...sourceHeights];
|
||||
const nextPaintWeights = [...sourcePaintWeights];
|
||||
const smoothingStrength = clamp01(strength);
|
||||
let changed = false;
|
||||
|
||||
@@ -233,6 +279,41 @@ export function applyTerrainBrushStamp(options: {
|
||||
clamp01(smoothingStrength * weight)
|
||||
);
|
||||
break;
|
||||
case "paint": {
|
||||
if (layerIndex === null) {
|
||||
throw new Error("Paint terrain brush stamps require a layer index.");
|
||||
}
|
||||
|
||||
const currentWeights = getTerrainSampleLayerWeights(
|
||||
terrain,
|
||||
sampleX,
|
||||
sampleZ
|
||||
);
|
||||
const targetWeights = createTerrainPaintTargetWeights(layerIndex);
|
||||
const blend = clamp01(smoothingStrength * weight);
|
||||
const nextWeights: [number, number, number, number] = [
|
||||
lerp(currentWeights[0], targetWeights[0], blend),
|
||||
lerp(currentWeights[1], targetWeights[1], blend),
|
||||
lerp(currentWeights[2], targetWeights[2], blend),
|
||||
lerp(currentWeights[3], targetWeights[3], blend)
|
||||
];
|
||||
|
||||
if (
|
||||
nextWeights[1] !== currentWeights[1] ||
|
||||
nextWeights[2] !== currentWeights[2] ||
|
||||
nextWeights[3] !== currentWeights[3]
|
||||
) {
|
||||
setTerrainSamplePaintWeights(
|
||||
nextPaintWeights,
|
||||
terrain,
|
||||
sampleX,
|
||||
sampleZ,
|
||||
nextWeights
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextHeight !== currentHeight) {
|
||||
@@ -242,7 +323,13 @@ export function applyTerrainBrushStamp(options: {
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? createTerrain({ ...terrain, heights: nextHeights }) : terrain;
|
||||
return changed
|
||||
? createTerrain({
|
||||
...terrain,
|
||||
heights: nextHeights,
|
||||
paintWeights: nextPaintWeights
|
||||
})
|
||||
: terrain;
|
||||
}
|
||||
|
||||
export function getTerrainBrushStrokeSpacing(
|
||||
@@ -251,3 +338,9 @@ export function getTerrainBrushStrokeSpacing(
|
||||
): number {
|
||||
return Math.max(terrain.cellSize * 0.5, settings.radius * 0.25);
|
||||
}
|
||||
|
||||
export function getTerrainBrushPaintLayerIndex(
|
||||
brushState: ArmedTerrainBrushState
|
||||
): number | null {
|
||||
return brushState.tool === "paint" ? brushState.layerIndex : null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { BufferAttribute, BufferGeometry } from "three";
|
||||
import type { Vec3 } from "../core/vector";
|
||||
import {
|
||||
getTerrainHeightAtSample,
|
||||
getTerrainSampleLayerWeights,
|
||||
TERRAIN_LAYER_COUNT,
|
||||
type Terrain
|
||||
} from "../document/terrains";
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface DerivedTerrainMeshData {
|
||||
positions: Float32Array;
|
||||
normals: Float32Array;
|
||||
uvs: Float32Array;
|
||||
layerWeights: Float32Array;
|
||||
indices: Uint32Array;
|
||||
cellTriangulation: TerrainCellTriangulation[];
|
||||
localBounds: {
|
||||
@@ -86,14 +89,21 @@ export function buildTerrainDerivedMeshData(
|
||||
const vertexCount = terrain.sampleCountX * terrain.sampleCountZ;
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const uvs = new Float32Array(vertexCount * 2);
|
||||
const layerWeights = new Float32Array(vertexCount * TERRAIN_LAYER_COUNT);
|
||||
const localBounds = createEmptyLocalBounds();
|
||||
let vertexOffset = 0;
|
||||
let uvOffset = 0;
|
||||
let layerWeightOffset = 0;
|
||||
|
||||
for (let sampleZ = 0; sampleZ < terrain.sampleCountZ; sampleZ += 1) {
|
||||
for (let sampleX = 0; sampleX < terrain.sampleCountX; sampleX += 1) {
|
||||
const localX = sampleX * terrain.cellSize;
|
||||
const localY = getTerrainHeightAtSample(terrain, sampleX, sampleZ);
|
||||
const sampleLayerWeights = getTerrainSampleLayerWeights(
|
||||
terrain,
|
||||
sampleX,
|
||||
sampleZ
|
||||
);
|
||||
const localZ = sampleZ * terrain.cellSize;
|
||||
positions[vertexOffset] = localX;
|
||||
positions[vertexOffset + 1] = localY;
|
||||
@@ -110,6 +120,16 @@ export function buildTerrainDerivedMeshData(
|
||||
uvs[uvOffset] = terrain.position.x + localX;
|
||||
uvs[uvOffset + 1] = terrain.position.z + localZ;
|
||||
uvOffset += 2;
|
||||
|
||||
for (
|
||||
let layerIndex = 0;
|
||||
layerIndex < TERRAIN_LAYER_COUNT;
|
||||
layerIndex += 1
|
||||
) {
|
||||
layerWeights[layerWeightOffset + layerIndex] =
|
||||
sampleLayerWeights[layerIndex];
|
||||
}
|
||||
layerWeightOffset += TERRAIN_LAYER_COUNT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +167,10 @@ export function buildTerrainDerivedMeshData(
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geometry.setAttribute(
|
||||
"terrainLayerWeights",
|
||||
new BufferAttribute(layerWeights, TERRAIN_LAYER_COUNT)
|
||||
);
|
||||
geometry.setIndex(new BufferAttribute(indices, 1));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
@@ -159,6 +183,7 @@ export function buildTerrainDerivedMeshData(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
layerWeights,
|
||||
indices,
|
||||
cellTriangulation,
|
||||
localBounds
|
||||
|
||||
144
src/rendering/terrain-layer-material.ts
Normal file
144
src/rendering/terrain-layer-material.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
ClampToEdgeWrapping,
|
||||
Color,
|
||||
DataTexture,
|
||||
LinearFilter,
|
||||
Material,
|
||||
MeshBasicMaterial,
|
||||
MeshStandardMaterial,
|
||||
RGBAFormat,
|
||||
SRGBColorSpace,
|
||||
UnsignedByteType,
|
||||
type Texture
|
||||
} from "three";
|
||||
|
||||
import type { MaterialDef } from "../materials/starter-material-library";
|
||||
|
||||
interface TerrainLayerBlendMaterialOptions {
|
||||
layerTextures: readonly [Texture, Texture, Texture, Texture];
|
||||
emissiveHex?: number;
|
||||
emissiveIntensity?: number;
|
||||
wireframe?: boolean;
|
||||
}
|
||||
|
||||
let fallbackTerrainLayerTexture: Texture | null = null;
|
||||
|
||||
function createFallbackTerrainLayerTexture(): Texture {
|
||||
const texture = new DataTexture(
|
||||
new Uint8Array([174, 167, 154, 255]),
|
||||
1,
|
||||
1,
|
||||
RGBAFormat,
|
||||
UnsignedByteType
|
||||
);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function getFallbackTerrainLayerTexture(): Texture {
|
||||
if (fallbackTerrainLayerTexture === null) {
|
||||
fallbackTerrainLayerTexture = createFallbackTerrainLayerTexture();
|
||||
}
|
||||
|
||||
return fallbackTerrainLayerTexture;
|
||||
}
|
||||
|
||||
export function getTerrainLayerTexture(
|
||||
material: MaterialDef | null,
|
||||
textureLookup: (material: MaterialDef) => Texture
|
||||
): Texture {
|
||||
return material === null
|
||||
? getFallbackTerrainLayerTexture()
|
||||
: textureLookup(material);
|
||||
}
|
||||
|
||||
export function getTerrainLayerPreviewColor(material: MaterialDef | null): number {
|
||||
return material === null
|
||||
? new Color("#aea79a").getHex()
|
||||
: new Color(material.swatchColorHex).getHex();
|
||||
}
|
||||
|
||||
export function createTerrainLayerBlendMaterial(
|
||||
options: TerrainLayerBlendMaterialOptions
|
||||
): Material {
|
||||
if (options.wireframe === true) {
|
||||
return new MeshBasicMaterial({
|
||||
color: 0xf2ece2,
|
||||
wireframe: true
|
||||
});
|
||||
}
|
||||
|
||||
const material = new MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
map: options.layerTextures[0],
|
||||
emissive: options.emissiveHex ?? 0x000000,
|
||||
emissiveIntensity: options.emissiveIntensity ?? 0,
|
||||
roughness: 1,
|
||||
metalness: 0
|
||||
});
|
||||
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.terrainLayerMap0 = { value: options.layerTextures[0] };
|
||||
shader.uniforms.terrainLayerMap1 = { value: options.layerTextures[1] };
|
||||
shader.uniforms.terrainLayerMap2 = { value: options.layerTextures[2] };
|
||||
shader.uniforms.terrainLayerMap3 = { value: options.layerTextures[3] };
|
||||
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
attribute vec4 terrainLayerWeights;
|
||||
varying vec4 vTerrainLayerWeights;
|
||||
varying vec2 vTerrainUv;`
|
||||
)
|
||||
.replace(
|
||||
"#include <uv_vertex>",
|
||||
`#include <uv_vertex>
|
||||
vTerrainUv = uv;
|
||||
vTerrainLayerWeights = terrainLayerWeights;`
|
||||
);
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec4 vTerrainLayerWeights;
|
||||
varying vec2 vTerrainUv;
|
||||
uniform sampler2D terrainLayerMap0;
|
||||
uniform sampler2D terrainLayerMap1;
|
||||
uniform sampler2D terrainLayerMap2;
|
||||
uniform sampler2D terrainLayerMap3;`
|
||||
)
|
||||
.replace(
|
||||
"#include <map_fragment>",
|
||||
`vec4 terrainWeights = max(vTerrainLayerWeights, 0.0);
|
||||
float terrainWeightSum =
|
||||
terrainWeights.x +
|
||||
terrainWeights.y +
|
||||
terrainWeights.z +
|
||||
terrainWeights.w;
|
||||
|
||||
if (terrainWeightSum <= 0.0) {
|
||||
terrainWeights = vec4(1.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
terrainWeights /= terrainWeightSum;
|
||||
}
|
||||
|
||||
vec4 terrainLayerColor =
|
||||
texture2D(terrainLayerMap0, vTerrainUv) * terrainWeights.x +
|
||||
texture2D(terrainLayerMap1, vTerrainUv) * terrainWeights.y +
|
||||
texture2D(terrainLayerMap2, vTerrainUv) * terrainWeights.z +
|
||||
texture2D(terrainLayerMap3, vTerrainUv) * terrainWeights.w;
|
||||
diffuseColor *= terrainLayerColor;`
|
||||
);
|
||||
};
|
||||
|
||||
material.customProgramCacheKey = () => "terrain-layer-blend-v1";
|
||||
material.needsUpdate = true;
|
||||
return material;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { getFirstPersonPlayerShapeSignature } from "./player-collision";
|
||||
import type {
|
||||
RuntimeBrushTriMeshCollider,
|
||||
RuntimeNpcCollider,
|
||||
RuntimeTerrainHeightfieldCollider,
|
||||
RuntimeSceneCollider
|
||||
} from "./runtime-scene-build";
|
||||
|
||||
@@ -86,7 +87,11 @@ function scaleBoundsCenter(bounds: { min: Vec3; max: Vec3 }, scale: Vec3): Vec3
|
||||
};
|
||||
}
|
||||
|
||||
function createRapierHeightfieldHeights(collider: GeneratedModelHeightfieldCollider): Float32Array {
|
||||
function createRapierHeightfieldHeights(collider: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
heights: ArrayLike<number>;
|
||||
}): Float32Array {
|
||||
const heights = new Float32Array(collider.heights.length);
|
||||
|
||||
// Rapier's heightfield samples are column-major, with the Z axis varying
|
||||
@@ -182,6 +187,45 @@ function attachTerrainModelCollider(world: RAPIER.World, collider: GeneratedMode
|
||||
);
|
||||
}
|
||||
|
||||
function attachTerrainCollider(
|
||||
world: RAPIER.World,
|
||||
collider: RuntimeTerrainHeightfieldCollider
|
||||
) {
|
||||
if (collider.rows < 2 || collider.cols < 2) {
|
||||
throw new Error(
|
||||
`Terrain collider ${collider.terrainId} must have at least a 2x2 height sample grid.`
|
||||
);
|
||||
}
|
||||
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.fixed().setTranslation(
|
||||
collider.position.x,
|
||||
collider.position.y,
|
||||
collider.position.z
|
||||
)
|
||||
);
|
||||
const rowSubdivisions = collider.rows - 1;
|
||||
const colSubdivisions = collider.cols - 1;
|
||||
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.heightfield(
|
||||
rowSubdivisions,
|
||||
colSubdivisions,
|
||||
createRapierHeightfieldHeights(collider),
|
||||
{
|
||||
x: collider.maxX - collider.minX,
|
||||
y: 1,
|
||||
z: collider.maxZ - collider.minZ
|
||||
}
|
||||
).setTranslation(
|
||||
(collider.minX + collider.maxX) * 0.5,
|
||||
0,
|
||||
(collider.minZ + collider.maxZ) * 0.5
|
||||
),
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
function attachDynamicModelCollider(world: RAPIER.World, collider: GeneratedModelCompoundCollider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
|
||||
@@ -385,6 +429,11 @@ export class RapierCollisionWorld {
|
||||
});
|
||||
|
||||
for (const collider of colliders) {
|
||||
if (collider.source === "terrain") {
|
||||
attachTerrainCollider(world, collider);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collider.source === "brush") {
|
||||
attachBrushCollider(world, collider);
|
||||
continue;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Vector3,
|
||||
SpotLight,
|
||||
TextureLoader,
|
||||
Texture,
|
||||
WebGLRenderTarget,
|
||||
WebGLRenderer
|
||||
} from "three";
|
||||
@@ -88,6 +89,10 @@ import {
|
||||
} from "../rendering/water-material";
|
||||
import { createFogQualityMaterial } from "../rendering/fog-material";
|
||||
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
||||
import {
|
||||
createTerrainLayerBlendMaterial,
|
||||
getTerrainLayerTexture
|
||||
} from "../rendering/terrain-layer-material";
|
||||
import {
|
||||
applyWhiteboxBevelToMaterial,
|
||||
shouldApplyWhiteboxBevel
|
||||
@@ -1924,14 +1929,7 @@ export class RuntimeHost {
|
||||
kind: "terrain",
|
||||
enabled: true
|
||||
}).geometry;
|
||||
const mesh = new Mesh(
|
||||
geometry,
|
||||
new MeshStandardMaterial({
|
||||
color: 0x708b57,
|
||||
roughness: 0.98,
|
||||
metalness: 0
|
||||
})
|
||||
);
|
||||
const mesh = new Mesh(geometry, this.createRuntimeTerrainMaterial(terrain));
|
||||
|
||||
mesh.position.set(
|
||||
terrain.position.x,
|
||||
@@ -1948,6 +1946,18 @@ export class RuntimeHost {
|
||||
this.applyShadowState();
|
||||
}
|
||||
|
||||
private createRuntimeTerrainMaterial(terrain: RuntimeTerrain): Material {
|
||||
const layerTextures = terrain.layers.map((layer) =>
|
||||
getTerrainLayerTexture(layer.material, (material) =>
|
||||
this.getOrCreateTextureSet(material).baseColor
|
||||
)
|
||||
) as [Texture, Texture, Texture, Texture];
|
||||
|
||||
return createTerrainLayerBlendMaterial({
|
||||
layerTextures
|
||||
});
|
||||
}
|
||||
|
||||
private createFogMaterialSet(
|
||||
brush: RuntimeBoxBrushInstance,
|
||||
volumeRenderPaths: {
|
||||
@@ -2771,7 +2781,43 @@ export class RuntimeHost {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const terrain of this.runtimeScene.terrains) {
|
||||
if (!terrain.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedMesh = buildTerrainDerivedMeshData({
|
||||
...terrain,
|
||||
kind: "terrain",
|
||||
enabled: true
|
||||
});
|
||||
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: derivedMesh.positions,
|
||||
indices: derivedMesh.indices,
|
||||
mergeProfile: "aggressive",
|
||||
transform: {
|
||||
position: terrain.position,
|
||||
rotationDegrees: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
scale: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const collider of this.runtimeScene.colliders) {
|
||||
if (collider.source === "terrain") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collider.kind === "trimesh" && collider.source === "brush") {
|
||||
if (collider.brushId === brush.id) {
|
||||
continue;
|
||||
|
||||
@@ -56,7 +56,13 @@ import {
|
||||
type ScenePath,
|
||||
type ScenePathPoint
|
||||
} from "../document/paths";
|
||||
import { getTerrainBounds, getTerrains, type Terrain } from "../document/terrains";
|
||||
import {
|
||||
getTerrainBounds,
|
||||
getTerrainFootprintDepth,
|
||||
getTerrainFootprintWidth,
|
||||
getTerrains,
|
||||
type Terrain
|
||||
} from "../document/terrains";
|
||||
import {
|
||||
cloneWorldSettings,
|
||||
type WorldSettings
|
||||
@@ -169,11 +175,35 @@ export interface RuntimeTerrain {
|
||||
id: string;
|
||||
name?: string;
|
||||
visible: boolean;
|
||||
collisionEnabled: boolean;
|
||||
position: Vec3;
|
||||
sampleCountX: number;
|
||||
sampleCountZ: number;
|
||||
cellSize: number;
|
||||
heights: number[];
|
||||
layers: Array<{
|
||||
materialId: string | null;
|
||||
material: MaterialDef | null;
|
||||
}>;
|
||||
paintWeights: number[];
|
||||
}
|
||||
|
||||
export interface RuntimeTerrainHeightfieldCollider {
|
||||
kind: "heightfield";
|
||||
source: "terrain";
|
||||
terrainId: string;
|
||||
position: Vec3;
|
||||
rows: number;
|
||||
cols: number;
|
||||
heights: Float32Array;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
worldBounds: {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeBrushTriMeshCollider {
|
||||
@@ -205,6 +235,7 @@ export interface RuntimeNpcCollider {
|
||||
|
||||
export type RuntimeSceneCollider =
|
||||
| RuntimeBrushTriMeshCollider
|
||||
| RuntimeTerrainHeightfieldCollider
|
||||
| GeneratedModelCollider
|
||||
| RuntimeNpcCollider;
|
||||
|
||||
@@ -724,16 +755,53 @@ function buildRuntimeCollider(brush: Brush): RuntimeBrushTriMeshCollider {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeTerrain(terrain: Terrain): RuntimeTerrain {
|
||||
function buildRuntimeTerrain(
|
||||
terrain: Terrain,
|
||||
document: SceneDocument
|
||||
): RuntimeTerrain {
|
||||
return {
|
||||
id: terrain.id,
|
||||
name: terrain.name,
|
||||
visible: terrain.visible,
|
||||
collisionEnabled: terrain.collisionEnabled,
|
||||
position: cloneVec3(terrain.position),
|
||||
sampleCountX: terrain.sampleCountX,
|
||||
sampleCountZ: terrain.sampleCountZ,
|
||||
cellSize: terrain.cellSize,
|
||||
heights: [...terrain.heights]
|
||||
heights: [...terrain.heights],
|
||||
layers: terrain.layers.map((layer) => ({
|
||||
materialId: layer.materialId,
|
||||
material: resolveRuntimeMaterial(document, layer.materialId)
|
||||
})),
|
||||
paintWeights: [...terrain.paintWeights]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeTerrainCollider(
|
||||
terrain: Terrain
|
||||
): RuntimeTerrainHeightfieldCollider | null {
|
||||
if (!terrain.collisionEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bounds = getTerrainBounds(terrain);
|
||||
|
||||
return {
|
||||
kind: "heightfield",
|
||||
source: "terrain",
|
||||
terrainId: terrain.id,
|
||||
position: cloneVec3(terrain.position),
|
||||
rows: terrain.sampleCountX,
|
||||
cols: terrain.sampleCountZ,
|
||||
heights: new Float32Array(terrain.heights),
|
||||
minX: 0,
|
||||
maxX: getTerrainFootprintWidth(terrain),
|
||||
minZ: 0,
|
||||
maxZ: getTerrainFootprintDepth(terrain),
|
||||
worldBounds: {
|
||||
min: cloneVec3(bounds.min),
|
||||
max: cloneVec3(bounds.max)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1610,7 +1678,9 @@ export function buildRuntimeSceneFromDocument(
|
||||
const enabledTerrains = getTerrains(document.terrains).filter(
|
||||
(terrain) => terrain.enabled
|
||||
);
|
||||
const terrains = enabledTerrains.map(buildRuntimeTerrain);
|
||||
const terrains = enabledTerrains.map((terrain) =>
|
||||
buildRuntimeTerrain(terrain, document)
|
||||
);
|
||||
const staticColliders: RuntimeSceneCollider[] = [];
|
||||
const volumes: RuntimeBoxVolumeCollection = {
|
||||
fog: [],
|
||||
@@ -1635,6 +1705,15 @@ export function buildRuntimeSceneFromDocument(
|
||||
|
||||
volumes.water.push(buildRuntimeWaterVolume(brush));
|
||||
}
|
||||
|
||||
for (const terrain of enabledTerrains) {
|
||||
const terrainCollider = buildRuntimeTerrainCollider(terrain);
|
||||
|
||||
if (terrainCollider !== null) {
|
||||
staticColliders.push(terrainCollider);
|
||||
}
|
||||
}
|
||||
|
||||
const enabledModelInstances = getModelInstances(
|
||||
document.modelInstances
|
||||
).filter((modelInstance) => modelInstance.enabled);
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ViewportCanvas({
|
||||
viewportGridVisible,
|
||||
selection,
|
||||
activeSelectionId,
|
||||
terrainBrushState,
|
||||
terrainBrushState = null,
|
||||
toolMode,
|
||||
toolPreview,
|
||||
transformSession,
|
||||
@@ -297,7 +297,7 @@ export function ViewportCanvas({
|
||||
? getWhiteboxSelectionFeedbackLabel(sceneDocument, selection)
|
||||
: null;
|
||||
const terrainBrushOverlayVisible =
|
||||
toolMode === "select" && terrainBrushState !== null;
|
||||
toolMode === "select" && terrainBrushState != null;
|
||||
const resolvedViewportBackground =
|
||||
editorSimulationScene !== null && editorSimulationClock !== null
|
||||
? resolveRuntimeDayNightWorldState(
|
||||
@@ -378,6 +378,9 @@ export function ViewportCanvas({
|
||||
data-testid={`viewport-terrain-brush-preview-${panelId}`}
|
||||
>
|
||||
terrain · {terrainBrushState.tool}
|
||||
{terrainBrushState.tool === "paint"
|
||||
? ` · layer ${terrainBrushState.layerIndex + 1}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
{selectedWhiteboxLabel === null ? null : (
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ViewportPanel({
|
||||
viewportGridVisible,
|
||||
selection,
|
||||
activeSelectionId,
|
||||
terrainBrushState,
|
||||
terrainBrushState = null,
|
||||
toolMode,
|
||||
toolPreview,
|
||||
transformSession,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
TorusGeometry,
|
||||
SpotLight,
|
||||
TextureLoader,
|
||||
Texture,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderTarget,
|
||||
@@ -205,6 +206,11 @@ import {
|
||||
} from "../rendering/advanced-rendering";
|
||||
import { createFogQualityMaterial } from "../rendering/fog-material";
|
||||
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
||||
import {
|
||||
createTerrainLayerBlendMaterial,
|
||||
getTerrainLayerPreviewColor,
|
||||
getTerrainLayerTexture
|
||||
} from "../rendering/terrain-layer-material";
|
||||
import {
|
||||
applyWhiteboxBevelToMaterial,
|
||||
shouldApplyWhiteboxBevel
|
||||
@@ -348,10 +354,12 @@ const TERRAIN_SELECTED_COLOR = 0xe0c17f;
|
||||
const TERRAIN_ACTIVE_COLOR = 0xf0d8a2;
|
||||
const TERRAIN_ACTIVE_EMISSIVE = 0x5c4623;
|
||||
const TERRAIN_SELECTED_EMISSIVE = 0x3f2d17;
|
||||
const TERRAIN_HOVERED_EMISSIVE = 0x24311b;
|
||||
const TERRAIN_BRUSH_PREVIEW_RAISE_COLOR = 0x8dd977;
|
||||
const TERRAIN_BRUSH_PREVIEW_LOWER_COLOR = 0xe17b75;
|
||||
const TERRAIN_BRUSH_PREVIEW_SMOOTH_COLOR = 0x7dbbf1;
|
||||
const TERRAIN_BRUSH_PREVIEW_FLATTEN_COLOR = 0xf1d37d;
|
||||
const TERRAIN_BRUSH_PREVIEW_PAINT_COLOR = 0x8eb9ff;
|
||||
const TERRAIN_BRUSH_PREVIEW_OFFSET = 0.05;
|
||||
const BOX_CREATE_PREVIEW_FILL = 0x89b6ff;
|
||||
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
|
||||
@@ -1016,10 +1024,22 @@ export class ViewportHost {
|
||||
this.currentTerrainBrushState?.terrainId !== terrainBrushState?.terrainId;
|
||||
const toolChanged =
|
||||
this.currentTerrainBrushState?.tool !== terrainBrushState?.tool;
|
||||
const layerChanged =
|
||||
this.currentTerrainBrushState?.tool === "paint" &&
|
||||
terrainBrushState?.tool === "paint"
|
||||
? this.currentTerrainBrushState.layerIndex !==
|
||||
terrainBrushState.layerIndex
|
||||
: this.currentTerrainBrushState?.tool === "paint" ||
|
||||
terrainBrushState?.tool === "paint";
|
||||
|
||||
this.currentTerrainBrushState = terrainBrushState;
|
||||
|
||||
if (terrainChanged || toolChanged || terrainBrushState === null) {
|
||||
if (
|
||||
terrainChanged ||
|
||||
toolChanged ||
|
||||
layerChanged ||
|
||||
terrainBrushState === null
|
||||
) {
|
||||
this.cancelActiveTerrainBrushStroke(false);
|
||||
}
|
||||
|
||||
@@ -4902,10 +4922,18 @@ export class ViewportHost {
|
||||
this.applyShadowState();
|
||||
}
|
||||
|
||||
private createTerrainMaterial(terrainId: string): Material {
|
||||
const selected = isTerrainSelected(this.currentSelection, terrainId);
|
||||
const hovered = isTerrainSelected(this.hoveredSelection, terrainId);
|
||||
const active = selected && this.currentActiveSelectionId === terrainId;
|
||||
private resolveTerrainLayerMaterial(materialId: string | null): MaterialDef | null {
|
||||
if (materialId === null || this.currentDocument === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.currentDocument.materials[materialId] ?? null;
|
||||
}
|
||||
|
||||
private createTerrainMaterial(terrain: Terrain): Material {
|
||||
const selected = isTerrainSelected(this.currentSelection, terrain.id);
|
||||
const hovered = isTerrainSelected(this.hoveredSelection, terrain.id);
|
||||
const active = selected && this.currentActiveSelectionId === terrain.id;
|
||||
const color = active
|
||||
? TERRAIN_ACTIVE_COLOR
|
||||
: selected
|
||||
@@ -4921,16 +4949,23 @@ export class ViewportHost {
|
||||
});
|
||||
}
|
||||
|
||||
return new MeshStandardMaterial({
|
||||
color,
|
||||
emissive: active
|
||||
const layerTextures = terrain.layers.map((layer) =>
|
||||
getTerrainLayerTexture(
|
||||
this.resolveTerrainLayerMaterial(layer.materialId),
|
||||
(material) => this.getOrCreateTextureSet(material).baseColor
|
||||
)
|
||||
) as [Texture, Texture, Texture, Texture];
|
||||
|
||||
return createTerrainLayerBlendMaterial({
|
||||
layerTextures,
|
||||
emissiveHex: active
|
||||
? TERRAIN_ACTIVE_EMISSIVE
|
||||
: selected
|
||||
? TERRAIN_SELECTED_EMISSIVE
|
||||
: 0x000000,
|
||||
emissiveIntensity: active ? 0.26 : selected ? 0.18 : 0,
|
||||
roughness: 0.98,
|
||||
metalness: 0
|
||||
: hovered
|
||||
? TERRAIN_HOVERED_EMISSIVE
|
||||
: 0x000000,
|
||||
emissiveIntensity: active ? 0.26 : selected ? 0.18 : hovered ? 0.08 : 0
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4952,7 +4987,7 @@ export class ViewportHost {
|
||||
const derivedMesh = buildTerrainDerivedMeshData(displayedTerrain);
|
||||
const mesh = new Mesh(
|
||||
derivedMesh.geometry,
|
||||
this.createTerrainMaterial(displayedTerrain.id)
|
||||
this.createTerrainMaterial(displayedTerrain)
|
||||
);
|
||||
|
||||
mesh.position.set(
|
||||
@@ -6647,6 +6682,34 @@ export class ViewportHost {
|
||||
});
|
||||
}
|
||||
|
||||
for (const terrain of getTerrains(document.terrains)) {
|
||||
if (!terrain.enabled || !terrain.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedMesh = buildTerrainDerivedMeshData(terrain);
|
||||
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: derivedMesh.positions,
|
||||
indices: derivedMesh.indices,
|
||||
mergeProfile: "aggressive",
|
||||
transform: {
|
||||
position: terrain.position,
|
||||
rotationDegrees: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
scale: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
||||
if (modelInstance.collision.mode === "none") {
|
||||
continue;
|
||||
@@ -6888,8 +6951,9 @@ export class ViewportHost {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayedTerrain = this.getDisplayedTerrainState(terrain.id) ?? terrain;
|
||||
const previousMaterial = renderObjects.mesh.material;
|
||||
renderObjects.mesh.material = this.createTerrainMaterial(terrain.id);
|
||||
renderObjects.mesh.material = this.createTerrainMaterial(displayedTerrain);
|
||||
previousMaterial.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6928,10 +6992,8 @@ export class ViewportHost {
|
||||
);
|
||||
}
|
||||
|
||||
private getTerrainBrushPreviewColor(
|
||||
tool: ArmedTerrainBrushState["tool"]
|
||||
): number {
|
||||
switch (tool) {
|
||||
private getTerrainBrushPreviewColor(brushState: ArmedTerrainBrushState): number {
|
||||
switch (brushState.tool) {
|
||||
case "raise":
|
||||
return TERRAIN_BRUSH_PREVIEW_RAISE_COLOR;
|
||||
case "lower":
|
||||
@@ -6940,6 +7002,23 @@ export class ViewportHost {
|
||||
return TERRAIN_BRUSH_PREVIEW_SMOOTH_COLOR;
|
||||
case "flatten":
|
||||
return TERRAIN_BRUSH_PREVIEW_FLATTEN_COLOR;
|
||||
case "paint": {
|
||||
const terrain =
|
||||
this.getDisplayedTerrainState(brushState.terrainId) ??
|
||||
this.currentDocument?.terrains[brushState.terrainId] ??
|
||||
null;
|
||||
|
||||
if (terrain === null) {
|
||||
return TERRAIN_BRUSH_PREVIEW_PAINT_COLOR;
|
||||
}
|
||||
|
||||
const terrainLayer = terrain.layers[brushState.layerIndex] ?? null;
|
||||
const terrainMaterial =
|
||||
terrainLayer === null
|
||||
? null
|
||||
: this.resolveTerrainLayerMaterial(terrainLayer.materialId);
|
||||
return getTerrainLayerPreviewColor(terrainMaterial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6989,7 +7068,7 @@ export class ViewportHost {
|
||||
previousGeometry.dispose();
|
||||
|
||||
const previewColor = this.getTerrainBrushPreviewColor(
|
||||
this.currentTerrainBrushState.tool
|
||||
this.currentTerrainBrushState
|
||||
);
|
||||
(this.terrainBrushPreviewLine.material as LineBasicMaterial).color.setHex(
|
||||
previewColor
|
||||
@@ -7109,7 +7188,8 @@ export class ViewportHost {
|
||||
center: point,
|
||||
settings: toolState,
|
||||
tool: toolState.tool,
|
||||
referenceHeight
|
||||
referenceHeight,
|
||||
layerIndex: toolState.tool === "paint" ? toolState.layerIndex : null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user