diff --git a/src/kit.ts b/src/kit.ts index 97990002..d1999a15 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -14,6 +14,7 @@ import { } from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import type { Placement } from "./generator"; +import { collectionChildCount } from "./kitNames"; function tex(loader: TextureLoader, url: string, srgb = false): Texture { const t = loader.load(url); @@ -131,8 +132,7 @@ export class Kit { } count(collection: string): number { - const c = this.manifest.collections[collection]; - return c?.children?.length || 1; + return collectionChildCount(this.manifest, collection); } /** Set the floor emissive intensity on BOTH the exterior floor material and its diff --git a/src/kitNames.ts b/src/kitNames.ts new file mode 100644 index 00000000..a297194a --- /dev/null +++ b/src/kitNames.ts @@ -0,0 +1,49 @@ +/** + * Placement keys are `COL[collection][index]` / `OBJ[name]`. Kit.count used to + * return 1 for a missing collection (`|| 1`), so a typo'd mesh name compiled to + * `COL[typo][0]` and vanished at instance time with only a console warning. + */ + +export interface KitNameCollection { + children?: { index: number; kind: string; name: string }[]; + missing?: boolean; +} + +export interface KitNameManifest { + collections: Record; + objects: Record; +} + +const COL_KEY = /^COL\[(.+)\]\[(\d+)\]$/; +const OBJ_KEY = /^OBJ\[(.+)\]$/; + +export function collectionChildCount(manifest: KitNameManifest, name: string): number { + const c = manifest.collections[name]; + const n = c?.children?.length ?? 0; + if (!c || c.missing || n === 0) { + throw new Error(`kit: unknown or empty collection ${JSON.stringify(name)}`); + } + return n; +} + +export function assertKnownPlacementKey(key: string, manifest: KitNameManifest): void { + const col = key.match(COL_KEY); + if (col) { + const n = collectionChildCount(manifest, col[1]); + const idx = Number(col[2]); + if (!Number.isInteger(idx) || idx < 0 || idx >= n) { + throw new Error( + `kit: index ${idx} out of range for collection ${JSON.stringify(col[1])} (n=${n})`, + ); + } + return; + } + const obj = key.match(OBJ_KEY); + if (obj) { + if (!(obj[1] in manifest.objects)) { + throw new Error(`kit: unknown object ${JSON.stringify(obj[1])}`); + } + return; + } + throw new Error(`kit: malformed placement key ${JSON.stringify(key)}`); +} diff --git a/tools/kitNames.test.ts b/tools/kitNames.test.ts new file mode 100644 index 00000000..ed922dbe --- /dev/null +++ b/tools/kitNames.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { assertKnownPlacementKey, collectionChildCount } from "../src/kitNames"; +import type { KitNameManifest } from "../src/kitNames"; + +const manifest = JSON.parse( + readFileSync(new URL("../public/assets/kit_manifest.json", import.meta.url), "utf8"), +) as KitNameManifest; +const generatorSrc = readFileSync(new URL("../src/generator.ts", import.meta.url), "utf8"); + +test("collectionChildCount throws on a typo'd mesh name (the old || 1 hole)", () => { + assert.throws( + () => collectionChildCount(manifest, "ground side wall"), + /unknown or empty collection "ground side wall"/, + ); +}); + +test("collectionChildCount accepts live Blender collection names", () => { + assert.equal(collectionChildCount(manifest, "groud side wall"), 1); + assert.ok(collectionChildCount(manifest, "wall.001") > 1); +}); + +test("every COL() / OBJ() name in generator.ts exists in kit_manifest.json", () => { + const cols = new Set( + [...generatorSrc.matchAll(/COL\(\s*"([^"]+)"/g)].map((m) => m[1]), + ); + for (const m of generatorSrc.matchAll(/`COL\[([^\]]+)\]\[/g)) { + if (!m[1].includes("${")) cols.add(m[1]); + } + const objs = new Set( + [...generatorSrc.matchAll(/OBJ\(\s*"([^"]+)"/g)].map((m) => m[1]), + ); + const missingCols = [...cols].filter((n) => { + try { + collectionChildCount(manifest, n); + return false; + } catch { + return true; + } + }); + const missingObjs = [...objs].filter((n) => !(n in manifest.objects)); + assert.deepEqual(missingCols, []); + assert.deepEqual(missingObjs, []); +}); + +test("assertKnownPlacementKey pin: in-range COL and known OBJ pass; OOB / unknown fail", () => { + assertKnownPlacementKey("COL[wall.001][0]", manifest); + assertKnownPlacementKey("OBJ[store_roof]", manifest); + assert.throws(() => assertKnownPlacementKey("COL[wall.001][99999]", manifest), /out of range/); + assert.throws(() => assertKnownPlacementKey("COL[not-a-mesh][0]", manifest), /unknown or empty/); + assert.throws(() => assertKnownPlacementKey("OBJ[nope]", manifest), /unknown object/); + assert.throws(() => assertKnownPlacementKey("lights.001", manifest), /malformed/); +});