auto-git:
[add] src/foliage/foliage.ts
This commit is contained in:
412
src/foliage/foliage.ts
Normal file
412
src/foliage/foliage.ts
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { createOpaqueId } from "../core/ids";
|
||||||
|
|
||||||
|
export const FOLIAGE_PROTOTYPE_CATEGORIES = [
|
||||||
|
"grass",
|
||||||
|
"weed",
|
||||||
|
"flower",
|
||||||
|
"bush",
|
||||||
|
"other"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FoliagePrototypeCategory =
|
||||||
|
(typeof FOLIAGE_PROTOTYPE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
export const FOLIAGE_PROTOTYPE_LOD_LEVELS = [0, 1, 2, 3] as const;
|
||||||
|
|
||||||
|
export type FoliagePrototypeLodLevel =
|
||||||
|
(typeof FOLIAGE_PROTOTYPE_LOD_LEVELS)[number];
|
||||||
|
|
||||||
|
export type FoliagePrototypeLod =
|
||||||
|
| {
|
||||||
|
level: FoliagePrototypeLodLevel;
|
||||||
|
source: "bundled";
|
||||||
|
bundledPath: string;
|
||||||
|
maxDistance: number;
|
||||||
|
castShadow: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
level: FoliagePrototypeLodLevel;
|
||||||
|
source: "projectAsset";
|
||||||
|
modelAssetId: string;
|
||||||
|
maxDistance: number;
|
||||||
|
castShadow: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FoliagePrototype {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
category: FoliagePrototypeCategory;
|
||||||
|
lods: FoliagePrototypeLod[];
|
||||||
|
minScale: number;
|
||||||
|
maxScale: number;
|
||||||
|
randomYaw: boolean;
|
||||||
|
alignToNormal: number;
|
||||||
|
densityWeight: number;
|
||||||
|
colorVariation: number;
|
||||||
|
windStrength: number;
|
||||||
|
windPhaseRandomness: number;
|
||||||
|
defaultCullDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoliageLayer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prototypeIds: string[];
|
||||||
|
density: number;
|
||||||
|
minScale: number;
|
||||||
|
maxScale: number;
|
||||||
|
minSlopeDegrees: number;
|
||||||
|
maxSlopeDegrees: number;
|
||||||
|
alignToNormal: number;
|
||||||
|
noiseScale: number;
|
||||||
|
noiseStrength: number;
|
||||||
|
noiseThreshold: number;
|
||||||
|
colorVariation: number;
|
||||||
|
seed: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FoliagePrototypeRegistry = Record<string, FoliagePrototype>;
|
||||||
|
export type FoliageLayerRegistry = Record<string, FoliageLayer>;
|
||||||
|
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_MIN_SCALE = 0.85;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_MAX_SCALE = 1.2;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_ALIGN_TO_NORMAL = 0.6;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_DENSITY_WEIGHT = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_COLOR_VARIATION = 0.12;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_WIND_STRENGTH = 0.35;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_WIND_PHASE_RANDOMNESS = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_PROTOTYPE_CULL_DISTANCE = 140;
|
||||||
|
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_DENSITY = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_MIN_SCALE = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_MAX_SCALE = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_MIN_SLOPE_DEGREES = 0;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_MAX_SLOPE_DEGREES = 40;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_ALIGN_TO_NORMAL = 0.6;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_NOISE_SCALE = 8;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_NOISE_STRENGTH = 0.5;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_NOISE_THRESHOLD = 0;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_COLOR_VARIATION = 0.12;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_SEED = 1;
|
||||||
|
export const DEFAULT_FOLIAGE_LAYER_ENABLED = true;
|
||||||
|
|
||||||
|
export function isFoliagePrototypeCategory(
|
||||||
|
value: unknown
|
||||||
|
): value is FoliagePrototypeCategory {
|
||||||
|
return FOLIAGE_PROTOTYPE_CATEGORIES.includes(
|
||||||
|
value as FoliagePrototypeCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFoliagePrototypeLodLevel(
|
||||||
|
value: unknown
|
||||||
|
): value is FoliagePrototypeLodLevel {
|
||||||
|
return FOLIAGE_PROTOTYPE_LOD_LEVELS.includes(
|
||||||
|
value as FoliagePrototypeLodLevel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyFoliagePrototypeRegistry(): FoliagePrototypeRegistry {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyFoliageLayerRegistry(): FoliageLayerRegistry {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonEmptyString(value: string, label: string): string {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
|
||||||
|
if (trimmedValue.length === 0) {
|
||||||
|
throw new Error(`${label} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFiniteNumber(value: number, label: string): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`${label} must be a finite number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeFiniteNumber(
|
||||||
|
value: number,
|
||||||
|
label: string
|
||||||
|
): number {
|
||||||
|
const numberValue = normalizeFiniteNumber(value, label);
|
||||||
|
|
||||||
|
if (numberValue < 0) {
|
||||||
|
throw new Error(`${label} must be zero or greater.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUnitNumber(value: number, label: string): number {
|
||||||
|
const numberValue = normalizeFiniteNumber(value, label);
|
||||||
|
|
||||||
|
if (numberValue < 0 || numberValue > 1) {
|
||||||
|
throw new Error(`${label} must be between 0 and 1.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScaleRange(minScale: number, maxScale: number): {
|
||||||
|
minScale: number;
|
||||||
|
maxScale: number;
|
||||||
|
} {
|
||||||
|
const normalizedMinScale = normalizeNonNegativeFiniteNumber(
|
||||||
|
minScale,
|
||||||
|
"Foliage minScale"
|
||||||
|
);
|
||||||
|
const normalizedMaxScale = normalizeNonNegativeFiniteNumber(
|
||||||
|
maxScale,
|
||||||
|
"Foliage maxScale"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedMaxScale < normalizedMinScale) {
|
||||||
|
throw new Error("Foliage maxScale must be greater than or equal to minScale.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minScale: normalizedMinScale,
|
||||||
|
maxScale: normalizedMaxScale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFoliagePrototypeLod(
|
||||||
|
lod: FoliagePrototypeLod
|
||||||
|
): FoliagePrototypeLod {
|
||||||
|
const level = lod.level;
|
||||||
|
|
||||||
|
if (!isFoliagePrototypeLodLevel(level)) {
|
||||||
|
throw new Error("Foliage prototype LOD level must be 0, 1, 2, or 3.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDistance = normalizeNonNegativeFiniteNumber(
|
||||||
|
lod.maxDistance,
|
||||||
|
"Foliage prototype LOD maxDistance"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof lod.castShadow !== "boolean") {
|
||||||
|
throw new Error("Foliage prototype LOD castShadow must be a boolean.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lod.source === "bundled") {
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
source: "bundled",
|
||||||
|
bundledPath: normalizeNonEmptyString(
|
||||||
|
lod.bundledPath,
|
||||||
|
"Foliage prototype LOD bundledPath"
|
||||||
|
),
|
||||||
|
maxDistance,
|
||||||
|
castShadow: lod.castShadow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
source: "projectAsset",
|
||||||
|
modelAssetId: normalizeNonEmptyString(
|
||||||
|
lod.modelAssetId,
|
||||||
|
"Foliage prototype LOD modelAssetId"
|
||||||
|
),
|
||||||
|
maxDistance,
|
||||||
|
castShadow: lod.castShadow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFoliagePrototype(
|
||||||
|
prototype: FoliagePrototype
|
||||||
|
): FoliagePrototype {
|
||||||
|
const scaleRange = normalizeScaleRange(
|
||||||
|
prototype.minScale,
|
||||||
|
prototype.maxScale
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isFoliagePrototypeCategory(prototype.category)) {
|
||||||
|
throw new Error("Foliage prototype category must be supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeNonEmptyString(prototype.id, "Foliage prototype id"),
|
||||||
|
label: normalizeNonEmptyString(prototype.label, "Foliage prototype label"),
|
||||||
|
category: prototype.category,
|
||||||
|
lods: prototype.lods.map((lod) => cloneFoliagePrototypeLod(lod)),
|
||||||
|
minScale: scaleRange.minScale,
|
||||||
|
maxScale: scaleRange.maxScale,
|
||||||
|
randomYaw: prototype.randomYaw,
|
||||||
|
alignToNormal: normalizeUnitNumber(
|
||||||
|
prototype.alignToNormal,
|
||||||
|
"Foliage prototype alignToNormal"
|
||||||
|
),
|
||||||
|
densityWeight: normalizeNonNegativeFiniteNumber(
|
||||||
|
prototype.densityWeight,
|
||||||
|
"Foliage prototype densityWeight"
|
||||||
|
),
|
||||||
|
colorVariation: normalizeUnitNumber(
|
||||||
|
prototype.colorVariation,
|
||||||
|
"Foliage prototype colorVariation"
|
||||||
|
),
|
||||||
|
windStrength: normalizeNonNegativeFiniteNumber(
|
||||||
|
prototype.windStrength,
|
||||||
|
"Foliage prototype windStrength"
|
||||||
|
),
|
||||||
|
windPhaseRandomness: normalizeUnitNumber(
|
||||||
|
prototype.windPhaseRandomness,
|
||||||
|
"Foliage prototype windPhaseRandomness"
|
||||||
|
),
|
||||||
|
defaultCullDistance: normalizeNonNegativeFiniteNumber(
|
||||||
|
prototype.defaultCullDistance,
|
||||||
|
"Foliage prototype defaultCullDistance"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFoliagePrototype(
|
||||||
|
overrides: Pick<FoliagePrototype, "id" | "label" | "lods"> &
|
||||||
|
Partial<Omit<FoliagePrototype, "id" | "label" | "lods">>
|
||||||
|
): FoliagePrototype {
|
||||||
|
return cloneFoliagePrototype({
|
||||||
|
id: overrides.id,
|
||||||
|
label: overrides.label,
|
||||||
|
category: overrides.category ?? "other",
|
||||||
|
lods: overrides.lods,
|
||||||
|
minScale: overrides.minScale ?? DEFAULT_FOLIAGE_PROTOTYPE_MIN_SCALE,
|
||||||
|
maxScale: overrides.maxScale ?? DEFAULT_FOLIAGE_PROTOTYPE_MAX_SCALE,
|
||||||
|
randomYaw: overrides.randomYaw ?? true,
|
||||||
|
alignToNormal:
|
||||||
|
overrides.alignToNormal ?? DEFAULT_FOLIAGE_PROTOTYPE_ALIGN_TO_NORMAL,
|
||||||
|
densityWeight:
|
||||||
|
overrides.densityWeight ?? DEFAULT_FOLIAGE_PROTOTYPE_DENSITY_WEIGHT,
|
||||||
|
colorVariation:
|
||||||
|
overrides.colorVariation ?? DEFAULT_FOLIAGE_PROTOTYPE_COLOR_VARIATION,
|
||||||
|
windStrength:
|
||||||
|
overrides.windStrength ?? DEFAULT_FOLIAGE_PROTOTYPE_WIND_STRENGTH,
|
||||||
|
windPhaseRandomness:
|
||||||
|
overrides.windPhaseRandomness ??
|
||||||
|
DEFAULT_FOLIAGE_PROTOTYPE_WIND_PHASE_RANDOMNESS,
|
||||||
|
defaultCullDistance:
|
||||||
|
overrides.defaultCullDistance ??
|
||||||
|
DEFAULT_FOLIAGE_PROTOTYPE_CULL_DISTANCE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFoliagePrototypeRegistry(
|
||||||
|
prototypes: FoliagePrototypeRegistry
|
||||||
|
): FoliagePrototypeRegistry {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(prototypes).map(([prototypeId, prototype]) => [
|
||||||
|
prototypeId,
|
||||||
|
cloneFoliagePrototype(prototype)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFoliageLayer(layer: FoliageLayer): FoliageLayer {
|
||||||
|
const scaleRange = normalizeScaleRange(layer.minScale, layer.maxScale);
|
||||||
|
|
||||||
|
if (layer.maxSlopeDegrees < layer.minSlopeDegrees) {
|
||||||
|
throw new Error(
|
||||||
|
"Foliage layer maxSlopeDegrees must be greater than or equal to minSlopeDegrees."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeNonEmptyString(layer.id, "Foliage layer id"),
|
||||||
|
name: normalizeNonEmptyString(layer.name, "Foliage layer name"),
|
||||||
|
prototypeIds: layer.prototypeIds.map((prototypeId) =>
|
||||||
|
normalizeNonEmptyString(prototypeId, "Foliage layer prototype id")
|
||||||
|
),
|
||||||
|
density: normalizeNonNegativeFiniteNumber(
|
||||||
|
layer.density,
|
||||||
|
"Foliage layer density"
|
||||||
|
),
|
||||||
|
minScale: scaleRange.minScale,
|
||||||
|
maxScale: scaleRange.maxScale,
|
||||||
|
minSlopeDegrees: normalizeFiniteNumber(
|
||||||
|
layer.minSlopeDegrees,
|
||||||
|
"Foliage layer minSlopeDegrees"
|
||||||
|
),
|
||||||
|
maxSlopeDegrees: normalizeFiniteNumber(
|
||||||
|
layer.maxSlopeDegrees,
|
||||||
|
"Foliage layer maxSlopeDegrees"
|
||||||
|
),
|
||||||
|
alignToNormal: normalizeUnitNumber(
|
||||||
|
layer.alignToNormal,
|
||||||
|
"Foliage layer alignToNormal"
|
||||||
|
),
|
||||||
|
noiseScale: normalizeNonNegativeFiniteNumber(
|
||||||
|
layer.noiseScale,
|
||||||
|
"Foliage layer noiseScale"
|
||||||
|
),
|
||||||
|
noiseStrength: normalizeUnitNumber(
|
||||||
|
layer.noiseStrength,
|
||||||
|
"Foliage layer noiseStrength"
|
||||||
|
),
|
||||||
|
noiseThreshold: normalizeUnitNumber(
|
||||||
|
layer.noiseThreshold,
|
||||||
|
"Foliage layer noiseThreshold"
|
||||||
|
),
|
||||||
|
colorVariation: normalizeUnitNumber(
|
||||||
|
layer.colorVariation,
|
||||||
|
"Foliage layer colorVariation"
|
||||||
|
),
|
||||||
|
seed: normalizeFiniteNumber(layer.seed, "Foliage layer seed"),
|
||||||
|
enabled: layer.enabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFoliageLayer(
|
||||||
|
overrides: Partial<FoliageLayer> = {}
|
||||||
|
): FoliageLayer {
|
||||||
|
return cloneFoliageLayer({
|
||||||
|
id: overrides.id ?? createOpaqueId("foliage-layer"),
|
||||||
|
name: overrides.name ?? "Foliage Layer",
|
||||||
|
prototypeIds: overrides.prototypeIds ?? [],
|
||||||
|
density: overrides.density ?? DEFAULT_FOLIAGE_LAYER_DENSITY,
|
||||||
|
minScale: overrides.minScale ?? DEFAULT_FOLIAGE_LAYER_MIN_SCALE,
|
||||||
|
maxScale: overrides.maxScale ?? DEFAULT_FOLIAGE_LAYER_MAX_SCALE,
|
||||||
|
minSlopeDegrees:
|
||||||
|
overrides.minSlopeDegrees ?? DEFAULT_FOLIAGE_LAYER_MIN_SLOPE_DEGREES,
|
||||||
|
maxSlopeDegrees:
|
||||||
|
overrides.maxSlopeDegrees ?? DEFAULT_FOLIAGE_LAYER_MAX_SLOPE_DEGREES,
|
||||||
|
alignToNormal:
|
||||||
|
overrides.alignToNormal ?? DEFAULT_FOLIAGE_LAYER_ALIGN_TO_NORMAL,
|
||||||
|
noiseScale: overrides.noiseScale ?? DEFAULT_FOLIAGE_LAYER_NOISE_SCALE,
|
||||||
|
noiseStrength:
|
||||||
|
overrides.noiseStrength ?? DEFAULT_FOLIAGE_LAYER_NOISE_STRENGTH,
|
||||||
|
noiseThreshold:
|
||||||
|
overrides.noiseThreshold ?? DEFAULT_FOLIAGE_LAYER_NOISE_THRESHOLD,
|
||||||
|
colorVariation:
|
||||||
|
overrides.colorVariation ?? DEFAULT_FOLIAGE_LAYER_COLOR_VARIATION,
|
||||||
|
seed: overrides.seed ?? DEFAULT_FOLIAGE_LAYER_SEED,
|
||||||
|
enabled: overrides.enabled ?? DEFAULT_FOLIAGE_LAYER_ENABLED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFoliageLayerRegistry(
|
||||||
|
layers: FoliageLayerRegistry
|
||||||
|
): FoliageLayerRegistry {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(layers).map(([layerId, layer]) => [
|
||||||
|
layerId,
|
||||||
|
cloneFoliageLayer(layer)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foliagePrototypeReferencesProjectAsset(
|
||||||
|
prototype: FoliagePrototype,
|
||||||
|
assetId: string
|
||||||
|
): boolean {
|
||||||
|
return prototype.lods.some(
|
||||||
|
(lod) => lod.source === "projectAsset" && lod.modelAssetId === assetId
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user