auto-git:

[change] src/commands/apply-terrain-brush-patch-command.ts
 [change] src/core/terrain-brush.ts
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document.ts
 [change] src/geometry/terrain-brush.ts
 [change] src/geometry/terrain-mesh.ts
This commit is contained in:
2026-05-02 11:24:29 +02:00
parent 47defd9e03
commit e9adf9f37e
6 changed files with 166 additions and 19 deletions

View File

@@ -51,7 +51,8 @@ export function isTerrainBrushPatchEmpty(patch: TerrainBrushPatch): boolean {
return ( return (
patch.heightSamples.length === 0 && patch.heightSamples.length === 0 &&
patch.paintWeights.length === 0 && patch.paintWeights.length === 0 &&
patch.foliageMaskValues.length === 0 patch.foliageMaskValues.length === 0 &&
patch.foliageBlockerMaskValues.length === 0
); );
} }
@@ -89,7 +90,10 @@ export function createApplyTerrainBrushPatchCommand(
paintWeights: options.patch.paintWeights.map((entry) => ({ ...entry })), paintWeights: options.patch.paintWeights.map((entry) => ({ ...entry })),
foliageMaskValues: options.patch.foliageMaskValues.map((entry) => ({ foliageMaskValues: options.patch.foliageMaskValues.map((entry) => ({
...entry ...entry
})) })),
foliageBlockerMaskValues: options.patch.foliageBlockerMaskValues.map(
(entry) => ({ ...entry })
)
}; };
let previousSelection: EditorSelection | null = null; let previousSelection: EditorSelection | null = null;
let previousToolMode: ToolMode | null = null; let previousToolMode: ToolMode | null = null;
@@ -167,6 +171,21 @@ export function createApplyTerrainBrushPatchCommand(
} }
} }
for (const entry of patch.foliageBlockerMaskValues) {
assertValidPatchEntry(
entry,
terrain.foliageBlockerMask.values.length,
"Terrain foliage blocker mask"
);
terrain.foliageBlockerMask.values[entry.index] =
direction === "forward" ? entry.after : entry.before;
renderDirtyBounds = mergeTerrainSampleIndexIntoBounds(
renderDirtyBounds,
terrain,
entry.index
);
}
markTerrainRenderSamplesDirty(terrain, renderDirtyBounds); markTerrainRenderSamplesDirty(terrain, renderDirtyBounds);
context.setDocument({ context.setDocument({

View File

@@ -7,7 +7,9 @@ export type TerrainBrushTool =
| "flatten" | "flatten"
| "paint" | "paint"
| "foliagePaint" | "foliagePaint"
| "foliageErase"; | "foliageErase"
| "foliageBlockerPaint"
| "foliageBlockerErase";
export interface TerrainBrushSettings { export interface TerrainBrushSettings {
radius: number; radius: number;
@@ -17,7 +19,10 @@ export interface TerrainBrushSettings {
export interface ArmedTerrainSculptBrushState extends TerrainBrushSettings { export interface ArmedTerrainSculptBrushState extends TerrainBrushSettings {
terrainId: string; terrainId: string;
tool: Exclude<TerrainBrushTool, "paint" | "foliagePaint" | "foliageErase">; tool: Exclude<
TerrainBrushTool,
"paint" | "foliagePaint" | "foliageErase" | "foliageBlockerPaint" | "foliageBlockerErase"
>;
} }
export interface ArmedTerrainPaintBrushState extends TerrainBrushSettings { export interface ArmedTerrainPaintBrushState extends TerrainBrushSettings {
@@ -33,10 +38,17 @@ export interface ArmedTerrainFoliagePaintBrushState
foliageLayerId: string; foliageLayerId: string;
} }
export interface ArmedTerrainFoliageBlockerBrushState
extends TerrainBrushSettings {
terrainId: string;
tool: "foliageBlockerPaint" | "foliageBlockerErase";
}
export type ArmedTerrainBrushState = export type ArmedTerrainBrushState =
| ArmedTerrainSculptBrushState | ArmedTerrainSculptBrushState
| ArmedTerrainPaintBrushState | ArmedTerrainPaintBrushState
| ArmedTerrainFoliagePaintBrushState; | ArmedTerrainFoliagePaintBrushState
| ArmedTerrainFoliageBlockerBrushState;
export interface TerrainSampleValuePatch { export interface TerrainSampleValuePatch {
index: number; index: number;
@@ -53,6 +65,7 @@ export interface TerrainBrushPatch {
heightSamples: TerrainSampleValuePatch[]; heightSamples: TerrainSampleValuePatch[];
paintWeights: TerrainSampleValuePatch[]; paintWeights: TerrainSampleValuePatch[];
foliageMaskValues: TerrainFoliageMaskValuePatch[]; foliageMaskValues: TerrainFoliageMaskValuePatch[];
foliageBlockerMaskValues: TerrainSampleValuePatch[];
} }
export interface TerrainBrushStrokeCommit { export interface TerrainBrushStrokeCommit {
@@ -143,6 +156,10 @@ export function getTerrainBrushToolLabel(tool: TerrainBrushTool): string {
return "Paint Foliage"; return "Paint Foliage";
case "foliageErase": case "foliageErase":
return "Erase Foliage"; return "Erase Foliage";
case "foliageBlockerPaint":
return "Paint Foliage Blocker";
case "foliageBlockerErase":
return "Clear Foliage Blocker";
} }
} }

View File

@@ -2320,6 +2320,38 @@ function readTerrain(value: unknown, label: string): Terrain {
}) })
); );
})(); })();
const foliageBlockerMask =
value.foliageBlockerMask === undefined
? undefined
: (() => {
if (!isRecord(value.foliageBlockerMask)) {
throw new Error(`${label}.foliageBlockerMask must be an object.`);
}
if (!Array.isArray(value.foliageBlockerMask.values)) {
throw new Error(
`${label}.foliageBlockerMask.values must be an array.`
);
}
return {
resolutionX: expectFiniteNumber(
value.foliageBlockerMask.resolutionX,
`${label}.foliageBlockerMask.resolutionX`
),
resolutionZ: expectFiniteNumber(
value.foliageBlockerMask.resolutionZ,
`${label}.foliageBlockerMask.resolutionZ`
),
values: value.foliageBlockerMask.values.map(
(maskSample, sampleIndex) =>
expectFiniteNumber(
maskSample,
`${label}.foliageBlockerMask.values.${sampleIndex}`
)
)
};
})();
return createTerrain({ return createTerrain({
id: expectString(value.id, `${label}.id`), id: expectString(value.id, `${label}.id`),
@@ -2339,7 +2371,8 @@ function readTerrain(value: unknown, label: string): Terrain {
heights, heights,
layers, layers,
paintWeights, paintWeights,
foliageMasks foliageMasks,
foliageBlockerMask
}); });
} }

View File

@@ -35,7 +35,8 @@ import {
type FoliagePrototypeRegistry type FoliagePrototypeRegistry
} from "../foliage/foliage"; } from "../foliage/foliage";
export const SCENE_DOCUMENT_VERSION = 94 as const; export const SCENE_DOCUMENT_VERSION = 95 as const;
export const FOLIAGE_BLOCKER_MASKS_SCENE_DOCUMENT_VERSION = 95 as const;
export const FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION = 94 as const; export const FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION = 94 as const;
export const FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION = 93 as const; export const FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION = 93 as const;
export const FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION = 92 as const; export const FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION = 92 as const;

View File

@@ -7,6 +7,7 @@ import type {
} from "../core/terrain-brush"; } from "../core/terrain-brush";
import { import {
createTerrain, createTerrain,
getTerrainFoliageBlockerMaskValueAtSample,
getOrCreateTerrainFoliageMask, getOrCreateTerrainFoliageMask,
getTerrainHeightAtSample, getTerrainHeightAtSample,
getTerrainFoliageMask, getTerrainFoliageMask,
@@ -36,6 +37,7 @@ export interface TerrainBrushStampMutationResult {
heightSampleIndices: number[]; heightSampleIndices: number[];
paintWeightIndices: number[]; paintWeightIndices: number[];
foliageMaskValueIndices: TerrainFoliageMaskValueIndex[]; foliageMaskValueIndices: TerrainFoliageMaskValueIndex[];
foliageBlockerMaskValueIndices: number[];
} }
export interface TerrainFoliageMaskValueIndex { export interface TerrainFoliageMaskValueIndex {
@@ -337,7 +339,8 @@ export function applyTerrainBrushStampInPlace(options: {
dirtyBounds: null, dirtyBounds: null,
heightSampleIndices: [], heightSampleIndices: [],
paintWeightIndices: [], paintWeightIndices: [],
foliageMaskValueIndices: [] foliageMaskValueIndices: [],
foliageBlockerMaskValueIndices: []
}; };
} }
@@ -356,6 +359,7 @@ export function applyTerrainBrushStampInPlace(options: {
const heightSampleIndices: number[] = []; const heightSampleIndices: number[] = [];
const paintWeightIndices: number[] = []; const paintWeightIndices: number[] = [];
const foliageMaskValueIndices: TerrainFoliageMaskValueIndex[] = []; const foliageMaskValueIndices: TerrainFoliageMaskValueIndex[] = [];
const foliageBlockerMaskValueIndices: number[] = [];
const markDirty = (sampleX: number, sampleZ: number) => { const markDirty = (sampleX: number, sampleZ: number) => {
if (dirtyBounds === null) { if (dirtyBounds === null) {
@@ -494,6 +498,29 @@ export function applyTerrainBrushStampInPlace(options: {
} }
continue; continue;
} }
case "foliageBlockerPaint":
case "foliageBlockerErase": {
const maskIndex = getTerrainFoliageMaskSampleIndex(
terrain.foliageBlockerMask,
sampleX,
sampleZ
);
const currentMaskValue =
terrain.foliageBlockerMask.values[maskIndex] ?? 0;
const targetMaskValue = tool === "foliageBlockerPaint" ? 1 : 0;
const nextMaskValue = lerp(
currentMaskValue,
targetMaskValue,
clamp01(smoothingStrength * weight)
);
if (nextMaskValue !== currentMaskValue) {
terrain.foliageBlockerMask.values[maskIndex] = nextMaskValue;
foliageBlockerMaskValueIndices.push(maskIndex);
markDirty(sampleX, sampleZ);
}
continue;
}
} }
if (nextHeight !== currentHeight) { if (nextHeight !== currentHeight) {
@@ -509,7 +536,8 @@ export function applyTerrainBrushStampInPlace(options: {
dirtyBounds, dirtyBounds,
heightSampleIndices, heightSampleIndices,
paintWeightIndices, paintWeightIndices,
foliageMaskValueIndices foliageMaskValueIndices,
foliageBlockerMaskValueIndices
}; };
} }
@@ -519,6 +547,7 @@ export function createTerrainBrushPatchFromTerrains(options: {
heightSampleIndices: Iterable<number>; heightSampleIndices: Iterable<number>;
paintWeightIndices: Iterable<number>; paintWeightIndices: Iterable<number>;
foliageMaskValueIndices?: Iterable<TerrainFoliageMaskValueIndex>; foliageMaskValueIndices?: Iterable<TerrainFoliageMaskValueIndex>;
foliageBlockerMaskValueIndices?: Iterable<number>;
}): TerrainBrushPatch { }): TerrainBrushPatch {
const { before, after } = options; const { before, after } = options;
@@ -530,7 +559,9 @@ export function createTerrainBrushPatchFromTerrains(options: {
before.sampleCountX !== after.sampleCountX || before.sampleCountX !== after.sampleCountX ||
before.sampleCountZ !== after.sampleCountZ || before.sampleCountZ !== after.sampleCountZ ||
before.heights.length !== after.heights.length || before.heights.length !== after.heights.length ||
before.paintWeights.length !== after.paintWeights.length before.paintWeights.length !== after.paintWeights.length ||
before.foliageBlockerMask.values.length !==
after.foliageBlockerMask.values.length
) { ) {
throw new Error( throw new Error(
"Terrain brush patches require matching terrain sample dimensions." "Terrain brush patches require matching terrain sample dimensions."
@@ -540,6 +571,8 @@ export function createTerrainBrushPatchFromTerrains(options: {
const heightSamples: TerrainBrushPatch["heightSamples"] = []; const heightSamples: TerrainBrushPatch["heightSamples"] = [];
const paintWeights: TerrainBrushPatch["paintWeights"] = []; const paintWeights: TerrainBrushPatch["paintWeights"] = [];
const foliageMaskValues: TerrainBrushPatch["foliageMaskValues"] = []; const foliageMaskValues: TerrainBrushPatch["foliageMaskValues"] = [];
const foliageBlockerMaskValues: TerrainBrushPatch["foliageBlockerMaskValues"] =
[];
const normalizeIndices = ( const normalizeIndices = (
indices: Iterable<number>, indices: Iterable<number>,
length: number, length: number,
@@ -641,11 +674,41 @@ export function createTerrainBrushPatchFromTerrains(options: {
left.layerId.localeCompare(right.layerId) || left.index - right.index left.layerId.localeCompare(right.layerId) || left.index - right.index
); );
for (const maskIndex of normalizeIndices(
options.foliageBlockerMaskValueIndices ?? [],
before.foliageBlockerMask.values.length,
"Terrain foliage blocker mask"
)) {
const beforeValue =
getTerrainFoliageBlockerMaskValueAtSample(
before.foliageBlockerMask,
maskIndex % before.foliageBlockerMask.resolutionX,
Math.floor(maskIndex / before.foliageBlockerMask.resolutionX)
) ?? 0;
const afterValue =
getTerrainFoliageBlockerMaskValueAtSample(
after.foliageBlockerMask,
maskIndex % after.foliageBlockerMask.resolutionX,
Math.floor(maskIndex / after.foliageBlockerMask.resolutionX)
) ?? 0;
if (beforeValue === afterValue) {
continue;
}
foliageBlockerMaskValues.push({
index: maskIndex,
before: beforeValue,
after: afterValue
});
}
return { return {
terrainId: before.id, terrainId: before.id,
heightSamples, heightSamples,
paintWeights, paintWeights,
foliageMaskValues foliageMaskValues,
foliageBlockerMaskValues
}; };
} }

View File

@@ -3,6 +3,7 @@ import { BufferAttribute, BufferGeometry } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import { import {
getTerrainFoliageMask, getTerrainFoliageMask,
getTerrainFoliageBlockerMaskValueAtSample,
getTerrainFoliageMaskValueAtSample, getTerrainFoliageMaskValueAtSample,
getTerrainHeightAtSample, getTerrainHeightAtSample,
getTerrainSampleLayerWeights, getTerrainSampleLayerWeights,
@@ -47,6 +48,7 @@ const TERRAIN_LOD_HYSTERESIS_RATIO = 0.16;
interface TerrainMeshBuildOptions { interface TerrainMeshBuildOptions {
foliageMaskLayerId?: string | null; foliageMaskLayerId?: string | null;
foliageBlockerMask?: boolean;
} }
export interface TerrainLodLevelMeshData { export interface TerrainLodLevelMeshData {
@@ -225,13 +227,19 @@ export function buildTerrainDerivedMeshData(
} }
layerWeightOffset += TERRAIN_LAYER_COUNT; layerWeightOffset += TERRAIN_LAYER_COUNT;
foliageMaskWeights[foliageMaskWeightOffset] = foliageMaskWeights[foliageMaskWeightOffset] =
foliageMask === null options.foliageBlockerMask === true
? 0 ? getTerrainFoliageBlockerMaskValueAtSample(
: getTerrainFoliageMaskValueAtSample( terrain.foliageBlockerMask,
foliageMask,
sampleX, sampleX,
sampleZ sampleZ
); )
: foliageMask === null
? 0
: getTerrainFoliageMaskValueAtSample(
foliageMask,
sampleX,
sampleZ
);
foliageMaskWeightOffset += 1; foliageMaskWeightOffset += 1;
} }
} }
@@ -399,9 +407,15 @@ function pushTerrainLodVertex(
? null ? null
: getTerrainFoliageMask(terrain, options.foliageMaskLayerId); : getTerrainFoliageMask(terrain, options.foliageMaskLayerId);
foliageMaskWeights.push( foliageMaskWeights.push(
foliageMask === null options.foliageBlockerMask === true
? 0 ? getTerrainFoliageBlockerMaskValueAtSample(
: getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ) terrain.foliageBlockerMask,
sampleX,
sampleZ
)
: foliageMask === null
? 0
: getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ)
); );
} }