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

@@ -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;
}

View File

@@ -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