auto-git:
[add] src/foliage/bundled-foliage.ts
This commit is contained in:
406
src/foliage/bundled-foliage.ts
Normal file
406
src/foliage/bundled-foliage.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import {
|
||||||
|
FOLIAGE_PROTOTYPE_LOD_LEVELS,
|
||||||
|
createFoliagePrototype,
|
||||||
|
isFoliagePrototypeLodLevel,
|
||||||
|
type FoliagePrototype,
|
||||||
|
type FoliagePrototypeCategory,
|
||||||
|
type FoliagePrototypeLodLevel
|
||||||
|
} from "./foliage";
|
||||||
|
|
||||||
|
export const BUNDLED_FOLIAGE_PUBLIC_ROOT = "/foliage" as const;
|
||||||
|
export const EXPECTED_BUNDLED_FOLIAGE_PROTOTYPE_COUNT = 40 as const;
|
||||||
|
export const EXPECTED_BUNDLED_FOLIAGE_LOD_COUNT = 4 as const;
|
||||||
|
|
||||||
|
export const DEFAULT_BUNDLED_FOLIAGE_LOD_MAX_DISTANCE: Record<
|
||||||
|
FoliagePrototypeLodLevel,
|
||||||
|
number
|
||||||
|
> = {
|
||||||
|
0: 18,
|
||||||
|
1: 36,
|
||||||
|
2: 72,
|
||||||
|
3: 140
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_BUNDLED_FOLIAGE_LOD_CAST_SHADOW: Record<
|
||||||
|
FoliagePrototypeLodLevel,
|
||||||
|
boolean
|
||||||
|
> = {
|
||||||
|
0: true,
|
||||||
|
1: true,
|
||||||
|
2: false,
|
||||||
|
3: false
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface BundledFoliageLodFile {
|
||||||
|
prototypeId: string;
|
||||||
|
prototypeName: string;
|
||||||
|
label: string;
|
||||||
|
categoryDirectory: string | null;
|
||||||
|
category: FoliagePrototypeCategory;
|
||||||
|
level: FoliagePrototypeLodLevel;
|
||||||
|
relativePath: string;
|
||||||
|
bundledPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundledFoliagePrototypeGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
categoryDirectory: string | null;
|
||||||
|
category: FoliagePrototypeCategory;
|
||||||
|
lods: BundledFoliageLodFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BundledFoliageDiagnosticCode =
|
||||||
|
| "duplicate-foliage-lod"
|
||||||
|
| "duplicate-foliage-prototype-id"
|
||||||
|
| "invalid-foliage-filename"
|
||||||
|
| "invalid-foliage-lod-level"
|
||||||
|
| "missing-foliage-lod"
|
||||||
|
| "unexpected-foliage-prototype-count"
|
||||||
|
| "unexpected-foliage-lod-count"
|
||||||
|
| "invalid-bundled-foliage-lod-source"
|
||||||
|
| "invalid-bundled-foliage-lod-path";
|
||||||
|
|
||||||
|
export interface BundledFoliageDiagnostic {
|
||||||
|
code: BundledFoliageDiagnosticCode;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
prototypeId?: string;
|
||||||
|
level?: FoliagePrototypeLodLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundledFoliageGroupingResult {
|
||||||
|
groups: BundledFoliagePrototypeGroup[];
|
||||||
|
diagnostics: BundledFoliageDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(path: string): string {
|
||||||
|
let normalizedPath = path.replace(/\\/gu, "/").trim();
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith("/")) {
|
||||||
|
normalizedPath = normalizedPath.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith("public/foliage/")) {
|
||||||
|
normalizedPath = normalizedPath.slice("public/foliage/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith("foliage/")) {
|
||||||
|
normalizedPath = normalizedPath.slice("foliage/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathSegments(relativePath: string): string[] {
|
||||||
|
return relativePath.split("/").filter((segment) => segment.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBundledFoliagePrototypeId(prototypeName: string): string {
|
||||||
|
const slug = prototypeName
|
||||||
|
.replace(/([a-z0-9])([A-Z])/gu, "$1-$2")
|
||||||
|
.replace(/[^a-zA-Z0-9]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/gu, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return `bundled-foliage-${slug.length > 0 ? slug : "unnamed"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBundledFoliageLabel(prototypeName: string): string {
|
||||||
|
return prototypeName
|
||||||
|
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
||||||
|
.replace(/[_-]+/gu, " ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferBundledFoliageCategory(
|
||||||
|
categoryDirectory: string | null,
|
||||||
|
prototypeName: string
|
||||||
|
): FoliagePrototypeCategory {
|
||||||
|
const normalizedCategory = categoryDirectory?.toLowerCase() ?? "";
|
||||||
|
const normalizedName = prototypeName.toLowerCase();
|
||||||
|
|
||||||
|
if (/flower|daisy/u.test(normalizedName)) {
|
||||||
|
return "flower";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCategory.includes("grass")) {
|
||||||
|
return "grass";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedCategory.includes("weed") ||
|
||||||
|
normalizedCategory.includes("wheat")
|
||||||
|
) {
|
||||||
|
return "weed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/bush|shrub/u.test(normalizedName)) {
|
||||||
|
return "bush";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBundledFoliageLodFile(
|
||||||
|
path: string
|
||||||
|
): { file: BundledFoliageLodFile | null; diagnostic: BundledFoliageDiagnostic | null } {
|
||||||
|
const relativePath = normalizeRelativePath(path);
|
||||||
|
const segments = getPathSegments(relativePath);
|
||||||
|
const fileName = segments.at(-1) ?? "";
|
||||||
|
|
||||||
|
if (!fileName.toLowerCase().endsWith(".glb")) {
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
diagnostic: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lodMatch = /^(.+)_LOD([0-9]+)\.glb$/u.exec(fileName);
|
||||||
|
|
||||||
|
if (lodMatch === null) {
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
diagnostic: {
|
||||||
|
code: "invalid-foliage-filename",
|
||||||
|
message: `Bundled foliage file ${path} must be named name_LOD0.glb through name_LOD3.glb.`,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototypeName = lodMatch[1] ?? "";
|
||||||
|
const numericLevel = Number(lodMatch[2]);
|
||||||
|
|
||||||
|
if (!isFoliagePrototypeLodLevel(numericLevel)) {
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
diagnostic: {
|
||||||
|
code: "invalid-foliage-lod-level",
|
||||||
|
message: `Bundled foliage file ${path} uses unsupported LOD${numericLevel}.`,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryDirectory = segments.length >= 3 ? segments.at(-3) ?? null : null;
|
||||||
|
const label = formatBundledFoliageLabel(prototypeName);
|
||||||
|
const prototypeId = toBundledFoliagePrototypeId(prototypeName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
prototypeId,
|
||||||
|
prototypeName,
|
||||||
|
label,
|
||||||
|
categoryDirectory,
|
||||||
|
category: inferBundledFoliageCategory(categoryDirectory, prototypeName),
|
||||||
|
level: numericLevel,
|
||||||
|
relativePath,
|
||||||
|
bundledPath: `${BUNDLED_FOLIAGE_PUBLIC_ROOT}/${relativePath}`
|
||||||
|
},
|
||||||
|
diagnostic: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupBundledFoliageFiles(
|
||||||
|
paths: readonly string[]
|
||||||
|
): BundledFoliageGroupingResult {
|
||||||
|
const diagnostics: BundledFoliageDiagnostic[] = [];
|
||||||
|
const mutableGroups = new Map<
|
||||||
|
string,
|
||||||
|
Omit<BundledFoliagePrototypeGroup, "lods"> & {
|
||||||
|
lods: Partial<Record<FoliagePrototypeLodLevel, BundledFoliageLodFile>>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const parsedFile = parseBundledFoliageLodFile(path);
|
||||||
|
|
||||||
|
if (parsedFile.diagnostic !== null) {
|
||||||
|
diagnostics.push(parsedFile.diagnostic);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedFile.file === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parsedFile.file;
|
||||||
|
const existingGroup = mutableGroups.get(file.prototypeId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingGroup !== undefined &&
|
||||||
|
existingGroup.name !== file.prototypeName
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "duplicate-foliage-prototype-id",
|
||||||
|
message: `Bundled foliage prototype id ${file.prototypeId} is shared by ${existingGroup.name} and ${file.prototypeName}.`,
|
||||||
|
path: file.relativePath,
|
||||||
|
prototypeId: file.prototypeId
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group =
|
||||||
|
existingGroup ??
|
||||||
|
{
|
||||||
|
id: file.prototypeId,
|
||||||
|
name: file.prototypeName,
|
||||||
|
label: file.label,
|
||||||
|
categoryDirectory: file.categoryDirectory,
|
||||||
|
category: file.category,
|
||||||
|
lods: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (group.lods[file.level] !== undefined) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "duplicate-foliage-lod",
|
||||||
|
message: `Bundled foliage prototype ${file.prototypeName} has more than one LOD${file.level} file.`,
|
||||||
|
path: file.relativePath,
|
||||||
|
prototypeId: file.prototypeId,
|
||||||
|
level: file.level
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.lods[file.level] = file;
|
||||||
|
mutableGroups.set(file.prototypeId, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [...mutableGroups.values()]
|
||||||
|
.map((group) => {
|
||||||
|
for (const level of FOLIAGE_PROTOTYPE_LOD_LEVELS) {
|
||||||
|
if (group.lods[level] === undefined) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "missing-foliage-lod",
|
||||||
|
message: `Bundled foliage prototype ${group.name} is missing LOD${level}.`,
|
||||||
|
prototypeId: group.id,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
lods: FOLIAGE_PROTOTYPE_LOD_LEVELS.flatMap((level) => {
|
||||||
|
const lod = group.lods[level];
|
||||||
|
return lod === undefined ? [] : [lod];
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
diagnostics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBundledFoliagePrototype(
|
||||||
|
group: BundledFoliagePrototypeGroup
|
||||||
|
): FoliagePrototype {
|
||||||
|
return createFoliagePrototype({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
category: group.category,
|
||||||
|
lods: group.lods.map((lod) => ({
|
||||||
|
level: lod.level,
|
||||||
|
source: "bundled",
|
||||||
|
bundledPath: lod.bundledPath,
|
||||||
|
maxDistance: DEFAULT_BUNDLED_FOLIAGE_LOD_MAX_DISTANCE[lod.level],
|
||||||
|
castShadow: DEFAULT_BUNDLED_FOLIAGE_LOD_CAST_SHADOW[lod.level]
|
||||||
|
})),
|
||||||
|
defaultCullDistance: DEFAULT_BUNDLED_FOLIAGE_LOD_MAX_DISTANCE[3]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBundledFoliageManifest(
|
||||||
|
paths: readonly string[]
|
||||||
|
): FoliagePrototype[] {
|
||||||
|
const grouping = groupBundledFoliageFiles(paths);
|
||||||
|
|
||||||
|
if (grouping.diagnostics.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Bundled foliage manifest is invalid: ${grouping.diagnostics
|
||||||
|
.map((diagnostic) => diagnostic.message)
|
||||||
|
.join(" | ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouping.groups.map((group) => createBundledFoliagePrototype(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBundledFoliageManifest(
|
||||||
|
prototypes: readonly FoliagePrototype[],
|
||||||
|
expectedPrototypeCount = EXPECTED_BUNDLED_FOLIAGE_PROTOTYPE_COUNT
|
||||||
|
): BundledFoliageDiagnostic[] {
|
||||||
|
const diagnostics: BundledFoliageDiagnostic[] = [];
|
||||||
|
const seenPrototypeIds = new Set<string>();
|
||||||
|
|
||||||
|
if (prototypes.length !== expectedPrototypeCount) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "unexpected-foliage-prototype-count",
|
||||||
|
message: `Bundled foliage manifest must expose ${expectedPrototypeCount} prototypes; found ${prototypes.length}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prototype of prototypes) {
|
||||||
|
if (seenPrototypeIds.has(prototype.id)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "duplicate-foliage-prototype-id",
|
||||||
|
message: `Bundled foliage prototype id ${prototype.id} appears more than once.`,
|
||||||
|
prototypeId: prototype.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPrototypeIds.add(prototype.id);
|
||||||
|
|
||||||
|
if (prototype.lods.length !== EXPECTED_BUNDLED_FOLIAGE_LOD_COUNT) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "unexpected-foliage-lod-count",
|
||||||
|
message: `Bundled foliage prototype ${prototype.id} must expose ${EXPECTED_BUNDLED_FOLIAGE_LOD_COUNT} LODs.`,
|
||||||
|
prototypeId: prototype.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const level of FOLIAGE_PROTOTYPE_LOD_LEVELS) {
|
||||||
|
const lod = prototype.lods.find(
|
||||||
|
(candidate) => candidate.level === level
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lod === undefined) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "missing-foliage-lod",
|
||||||
|
message: `Bundled foliage prototype ${prototype.id} is missing LOD${level}.`,
|
||||||
|
prototypeId: prototype.id,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lod.source !== "bundled") {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "invalid-bundled-foliage-lod-source",
|
||||||
|
message: `Bundled foliage prototype ${prototype.id} LOD${level} must use a bundled source.`,
|
||||||
|
prototypeId: prototype.id,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lod.bundledPath.startsWith(`${BUNDLED_FOLIAGE_PUBLIC_ROOT}/`)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "invalid-bundled-foliage-lod-path",
|
||||||
|
message: `Bundled foliage prototype ${prototype.id} LOD${level} must point under ${BUNDLED_FOLIAGE_PUBLIC_ROOT}.`,
|
||||||
|
path: lod.bundledPath,
|
||||||
|
prototypeId: prototype.id,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user