diff --git a/src/commands/apply-terrain-brush-patch-command.ts b/src/commands/apply-terrain-brush-patch-command.ts index 8b6d6c3a..ce59ebf7 100644 --- a/src/commands/apply-terrain-brush-patch-command.ts +++ b/src/commands/apply-terrain-brush-patch-command.ts @@ -51,7 +51,8 @@ export function isTerrainBrushPatchEmpty(patch: TerrainBrushPatch): boolean { return ( patch.heightSamples.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 })), foliageMaskValues: options.patch.foliageMaskValues.map((entry) => ({ ...entry - })) + })), + foliageBlockerMaskValues: options.patch.foliageBlockerMaskValues.map( + (entry) => ({ ...entry }) + ) }; let previousSelection: EditorSelection | 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); context.setDocument({ diff --git a/src/core/terrain-brush.ts b/src/core/terrain-brush.ts index 49459bac..814d31a5 100644 --- a/src/core/terrain-brush.ts +++ b/src/core/terrain-brush.ts @@ -7,7 +7,9 @@ export type TerrainBrushTool = | "flatten" | "paint" | "foliagePaint" - | "foliageErase"; + | "foliageErase" + | "foliageBlockerPaint" + | "foliageBlockerErase"; export interface TerrainBrushSettings { radius: number; @@ -17,7 +19,10 @@ export interface TerrainBrushSettings { export interface ArmedTerrainSculptBrushState extends TerrainBrushSettings { terrainId: string; - tool: Exclude; + tool: Exclude< + TerrainBrushTool, + "paint" | "foliagePaint" | "foliageErase" | "foliageBlockerPaint" | "foliageBlockerErase" + >; } export interface ArmedTerrainPaintBrushState extends TerrainBrushSettings { @@ -33,10 +38,17 @@ export interface ArmedTerrainFoliagePaintBrushState foliageLayerId: string; } +export interface ArmedTerrainFoliageBlockerBrushState + extends TerrainBrushSettings { + terrainId: string; + tool: "foliageBlockerPaint" | "foliageBlockerErase"; +} + export type ArmedTerrainBrushState = | ArmedTerrainSculptBrushState | ArmedTerrainPaintBrushState - | ArmedTerrainFoliagePaintBrushState; + | ArmedTerrainFoliagePaintBrushState + | ArmedTerrainFoliageBlockerBrushState; export interface TerrainSampleValuePatch { index: number; @@ -53,6 +65,7 @@ export interface TerrainBrushPatch { heightSamples: TerrainSampleValuePatch[]; paintWeights: TerrainSampleValuePatch[]; foliageMaskValues: TerrainFoliageMaskValuePatch[]; + foliageBlockerMaskValues: TerrainSampleValuePatch[]; } export interface TerrainBrushStrokeCommit { @@ -143,6 +156,10 @@ export function getTerrainBrushToolLabel(tool: TerrainBrushTool): string { return "Paint Foliage"; case "foliageErase": return "Erase Foliage"; + case "foliageBlockerPaint": + return "Paint Foliage Blocker"; + case "foliageBlockerErase": + return "Clear Foliage Blocker"; } } diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index 1e7a850f..a010ab08 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -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({ id: expectString(value.id, `${label}.id`), @@ -2339,7 +2371,8 @@ function readTerrain(value: unknown, label: string): Terrain { heights, layers, paintWeights, - foliageMasks + foliageMasks, + foliageBlockerMask }); } diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 04b38c56..f067d030 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -35,7 +35,8 @@ import { type FoliagePrototypeRegistry } 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_MASKS_SCENE_DOCUMENT_VERSION = 93 as const; export const FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION = 92 as const; diff --git a/src/geometry/terrain-brush.ts b/src/geometry/terrain-brush.ts index 6cf60340..f73af89c 100644 --- a/src/geometry/terrain-brush.ts +++ b/src/geometry/terrain-brush.ts @@ -7,6 +7,7 @@ import type { } from "../core/terrain-brush"; import { createTerrain, + getTerrainFoliageBlockerMaskValueAtSample, getOrCreateTerrainFoliageMask, getTerrainHeightAtSample, getTerrainFoliageMask, @@ -36,6 +37,7 @@ export interface TerrainBrushStampMutationResult { heightSampleIndices: number[]; paintWeightIndices: number[]; foliageMaskValueIndices: TerrainFoliageMaskValueIndex[]; + foliageBlockerMaskValueIndices: number[]; } export interface TerrainFoliageMaskValueIndex { @@ -337,7 +339,8 @@ export function applyTerrainBrushStampInPlace(options: { dirtyBounds: null, heightSampleIndices: [], paintWeightIndices: [], - foliageMaskValueIndices: [] + foliageMaskValueIndices: [], + foliageBlockerMaskValueIndices: [] }; } @@ -356,6 +359,7 @@ export function applyTerrainBrushStampInPlace(options: { const heightSampleIndices: number[] = []; const paintWeightIndices: number[] = []; const foliageMaskValueIndices: TerrainFoliageMaskValueIndex[] = []; + const foliageBlockerMaskValueIndices: number[] = []; const markDirty = (sampleX: number, sampleZ: number) => { if (dirtyBounds === null) { @@ -494,6 +498,29 @@ export function applyTerrainBrushStampInPlace(options: { } 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) { @@ -509,7 +536,8 @@ export function applyTerrainBrushStampInPlace(options: { dirtyBounds, heightSampleIndices, paintWeightIndices, - foliageMaskValueIndices + foliageMaskValueIndices, + foliageBlockerMaskValueIndices }; } @@ -519,6 +547,7 @@ export function createTerrainBrushPatchFromTerrains(options: { heightSampleIndices: Iterable; paintWeightIndices: Iterable; foliageMaskValueIndices?: Iterable; + foliageBlockerMaskValueIndices?: Iterable; }): TerrainBrushPatch { const { before, after } = options; @@ -530,7 +559,9 @@ export function createTerrainBrushPatchFromTerrains(options: { before.sampleCountX !== after.sampleCountX || before.sampleCountZ !== after.sampleCountZ || 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( "Terrain brush patches require matching terrain sample dimensions." @@ -540,6 +571,8 @@ export function createTerrainBrushPatchFromTerrains(options: { const heightSamples: TerrainBrushPatch["heightSamples"] = []; const paintWeights: TerrainBrushPatch["paintWeights"] = []; const foliageMaskValues: TerrainBrushPatch["foliageMaskValues"] = []; + const foliageBlockerMaskValues: TerrainBrushPatch["foliageBlockerMaskValues"] = + []; const normalizeIndices = ( indices: Iterable, length: number, @@ -641,11 +674,41 @@ export function createTerrainBrushPatchFromTerrains(options: { 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 { terrainId: before.id, heightSamples, paintWeights, - foliageMaskValues + foliageMaskValues, + foliageBlockerMaskValues }; } diff --git a/src/geometry/terrain-mesh.ts b/src/geometry/terrain-mesh.ts index 72811b46..cda9b3b8 100644 --- a/src/geometry/terrain-mesh.ts +++ b/src/geometry/terrain-mesh.ts @@ -3,6 +3,7 @@ import { BufferAttribute, BufferGeometry } from "three"; import type { Vec3 } from "../core/vector"; import { getTerrainFoliageMask, + getTerrainFoliageBlockerMaskValueAtSample, getTerrainFoliageMaskValueAtSample, getTerrainHeightAtSample, getTerrainSampleLayerWeights, @@ -47,6 +48,7 @@ const TERRAIN_LOD_HYSTERESIS_RATIO = 0.16; interface TerrainMeshBuildOptions { foliageMaskLayerId?: string | null; + foliageBlockerMask?: boolean; } export interface TerrainLodLevelMeshData { @@ -225,13 +227,19 @@ export function buildTerrainDerivedMeshData( } layerWeightOffset += TERRAIN_LAYER_COUNT; foliageMaskWeights[foliageMaskWeightOffset] = - foliageMask === null - ? 0 - : getTerrainFoliageMaskValueAtSample( - foliageMask, + options.foliageBlockerMask === true + ? getTerrainFoliageBlockerMaskValueAtSample( + terrain.foliageBlockerMask, sampleX, sampleZ - ); + ) + : foliageMask === null + ? 0 + : getTerrainFoliageMaskValueAtSample( + foliageMask, + sampleX, + sampleZ + ); foliageMaskWeightOffset += 1; } } @@ -399,9 +407,15 @@ function pushTerrainLodVertex( ? null : getTerrainFoliageMask(terrain, options.foliageMaskLayerId); foliageMaskWeights.push( - foliageMask === null - ? 0 - : getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ) + options.foliageBlockerMask === true + ? getTerrainFoliageBlockerMaskValueAtSample( + terrain.foliageBlockerMask, + sampleX, + sampleZ + ) + : foliageMask === null + ? 0 + : getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ) ); }