diff --git a/.github/workflows/pack-checks.yml b/.github/workflows/pack-checks.yml new file mode 100644 index 0000000..4076a72 --- /dev/null +++ b/.github/workflows/pack-checks.yml @@ -0,0 +1,61 @@ +# The pack CI (roadmap 34 E2): every PR to main runs the pack tools' unit tests and +# `kit-build check` over EVERY pack — manifest schema (incl. lods / behavior), GLB size, triangle +# budgets, texture caps, emissive policy, scale, pivot, LOD files, the static z-fight probe and +# thumbnails. Failures that are known and accepted live in tools/kit-build/allow.json with a reason. +# Make the "pack-checks" job a REQUIRED status check on main (Settings → Branches / Rulesets). +name: pack checks + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pack-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + pack-checks: + name: pack-checks + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: tools/meshy/package-lock.json + + - name: install the pack tools (tools/meshy — no browser, no Meshy key) + working-directory: tools/meshy + run: npm ci --no-audit --no-fund + + - name: unit tests (kit-build, lod, the kit builds, meshy) + run: | + node --test tools/kit-build/test/*.test.mjs tools/lod/test/*.test.mjs \ + tools/architecture-kit/test/*.test.mjs tools/scifi-kit/test/*.test.mjs \ + tools/town-kit/test/*.test.mjs tools/anim/test/*.test.mjs interior-kit/_src/test/*.test.mjs + (cd tools/meshy && npm test) + + - name: kit-build check (every pack) + run: node tools/kit-build/kit-build.mjs check --summary "$GITHUB_STEP_SUMMARY" --json pack-check.json + + - name: budget report + if: always() + run: node tools/kit-build/kit-build.mjs report --md pack-budget.md + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: pack-check + path: | + pack-check.json + pack-budget.md + if-no-files-found: ignore diff --git a/README.md b/README.md index e73e0f5..90fcc51 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ props-kit/ Props & Interiors: 33 furnishing / dressing / g scifi-kit/ Sci-fi & Modern Kit: 28 snap-together station pieces + dressing on the 1 m grid (CC0, made with Meshy.ai; kit.md = how to build) tools/scifi-kit/ its reproducible build (Meshy jobs, post-processing, procedural pieces) +tools/kit-build/ THE pack build tool (post step, texture cap, emissive policy, decimate + to budget, LODs, thumbnails, budget report) and the pack CI checks — + see tools/kit-build/README.md tools/lod/ no z-fighting (defight) + offline LODs: .lod1/.lod2.glb and each row's "lods" field — see tools/lod/README.md interactive-kit/ Interactive Kit: 19 animated, functional pieces — doors WITH frames, @@ -45,6 +48,14 @@ Each `index.json` row: `{name, title, value | zip, attribution, copyright, licen installable pack. The formats are documented in the app repo's [PACKS.md](https://github.com/theprototype-app/core/blob/main/PACKS.md). +## Checks + +Every PR to `main` runs `.github/workflows/pack-checks.yml`: the pack tools' unit tests and +`node tools/kit-build/kit-build.mjs check` over every pack (manifest schema incl. `lods` / +`behavior`, GLB ≤ 5 MiB, triangle budgets, texture caps, emissive policy, scale, pivot, LOD +files, the static z-fight probe, thumbnails). Run it locally before pushing +(`cd tools/meshy && npm ci` once). A new pack needs its policy row in `tools/kit-build/packs.json`. + ## Versioning The app pins a ref (`@format-1`, `PACKS_BASE` in core's `src/lib/packs.js`) so pack diff --git a/architecture-kit/Block/screenshot/screenshot.webp b/architecture-kit/Block/screenshot/screenshot.webp deleted file mode 100644 index e886f68..0000000 Binary files a/architecture-kit/Block/screenshot/screenshot.webp and /dev/null differ diff --git a/default/SvelteRune/glTF-Binary/SvelteRune.glb b/default/SvelteRune/glTF-Binary/SvelteRune.glb index f6a04b8..d624c7e 100644 Binary files a/default/SvelteRune/glTF-Binary/SvelteRune.glb and b/default/SvelteRune/glTF-Binary/SvelteRune.glb differ diff --git a/interactive-kit/default.json b/interactive-kit/default.json index f6ca80d..0c92267 100644 --- a/interactive-kit/default.json +++ b/interactive-kit/default.json @@ -10,7 +10,7 @@ "type": "door", "clip": "open", "closeClip": "close", - "trigger": "click", + "trigger": "clik", "autoplay": false, "sound": "door", "collider": "follow" @@ -51,7 +51,7 @@ }, { "name": "DoorDouble", - "label": "Double door in a sandstone wall (2 × 3 m)", + "label": "Double door in a sandstone wall (2 \u00d7 3 m)", "screenshot": "screenshot/screenshot.webp", "variants": { "glTF-Binary": "door-double.glb" @@ -105,7 +105,7 @@ }, { "name": "Portcullis", - "label": "Portcullis in a gate wall (2 × 4.5 m)", + "label": "Portcullis in a gate wall (2 \u00d7 4.5 m)", "screenshot": "screenshot/screenshot.webp", "variants": { "glTF-Binary": "portcullis.glb" @@ -351,7 +351,7 @@ }, { "name": "PressurePlate", - "label": "Pressure plate 1 × 1 m (steps sink it)", + "label": "Pressure plate 1 \u00d7 1 m (steps sink it)", "screenshot": "screenshot/screenshot.webp", "variants": { "glTF-Binary": "pressure-plate.glb" diff --git a/interior-kit/_src/build.mjs b/interior-kit/_src/build.mjs index 8ee86d0..7c2591c 100644 --- a/interior-kit/_src/build.mjs +++ b/interior-kit/_src/build.mjs @@ -15,27 +15,25 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; import { ITEMS, pivotOf } from './items.mjs'; import { PIECES, WALL_FACE } from './procedural.mjs'; -const TOOLS = process.env.MESHY_TOOLS ?? new URL('../../tools/meshy', import.meta.url).pathname; +// the shared pack tooling (roadmap 34 E2: tools/kit-build instead of a fork per kit) — gltf-transform +// comes through its loader, so the Documents here and its helpers are ONE module instance +const { TOOLS, load, tool } = await import('../../tools/kit-build/lib/deps.mjs'); +const { gradeLinear: grade, inpaintBlack, repairNormals, flatForSync } = await import('../../tools/kit-build/lib/post.mjs'); const STAGING = process.env.MESHY_STAGING ?? path.join(os.homedir(), '.code/lanes-30/meshy/staging'); const REQUESTER = '33-pack-interior'; -const { postProcess } = await import(`${TOOLS}/lib/post.js`); -const sharp = createRequire(`${TOOLS}/package.json`)('sharp'); -const { renderThumbs } = await import(`${TOOLS}/lib/thumb.js`); -const { NodeIO } = await import(`${TOOLS}/node_modules/@gltf-transform/core/dist/index.js`); -const { ALL_EXTENSIONS } = await import(`${TOOLS}/node_modules/@gltf-transform/extensions/dist/index.js`); -const { mergeDocuments, dedup, prune, getBounds, unpartition, flatten, transformMesh, join, simplify, weld } = await import(`${TOOLS}/node_modules/@gltf-transform/functions/dist/index.js`); -const { MeshoptSimplifier } = await import(`${TOOLS}/node_modules/meshoptimizer/index.js`); +const { postProcess } = await tool('lib/post.js'); +const { renderThumbs } = await tool('lib/thumb.js'); +const { NodeIO } = await load('@gltf-transform/core'); +const { ALL_EXTENSIONS } = await load('@gltf-transform/extensions'); +const { mergeDocuments, dedup, prune, getBounds, unpartition, flatten, transformMesh, join, simplify, weld } = await load('@gltf-transform/functions'); +const { MeshoptSimplifier } = await load('meshoptimizer'); -// 33-pack-fix-lod's defight (tools/lod, its own PR): settles coplanar layers in Meshy meshes — -// their materials are double-sided, so even OPPOSITE-facing coplanar layers flicker. Optional -// until that PR is on main: without it the build warns and ships the meshes unsettled. -const DEFIGHT = new URL('../../tools/lod/defight.mjs', import.meta.url).pathname; -const { defightFile } = fs.existsSync(DEFIGHT) ? await import(DEFIGHT) : { defightFile: null }; -if (!defightFile) console.warn('WARNING: tools/lod/defight.mjs missing (33-pack-fix-lod) — Meshy meshes are NOT defighted'); +// 33-pack-fix-lod's defight (tools/lod): settles coplanar layers in Meshy meshes — their materials +// are double-sided, so even OPPOSITE-facing coplanar layers flicker. +const { defightFile } = await import('../../tools/lod/defight.mjs'); const HERE = path.dirname(fileURLToPath(import.meta.url)); const PACK = path.resolve(HERE, '..'); @@ -62,144 +60,6 @@ function sizeOf(doc) { return { min: b.min, max: b.max, size: [0, 1, 2].map((i) => b.max[i] - b.min[i]) }; } -/** Colour-grade every base-colour texture: per-channel multiply (+ offset) */ -async function grade(doc, { mul, add = [0, 0, 0], only }) { - for (const mat of doc.getRoot().listMaterials()) { - const tex = mat.getBaseColorTexture(); - const img = tex?.getImage(); - if (!img) continue; - if (only === 'teal') { - // SELECTIVE: only teal-ish texels (green and blue well above red) — a painted cupboard - // darkens to the pack's deep teal while its marble top and brass stay as they are - const { data, info } = await sharp(Buffer.from(img)).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - for (let i = 0; i < data.length; i += 3) { - const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; - const k = Math.min(1, Math.max(0, (Math.min(g, b) - r - 12) / 30)); // soft mask - if (k <= 0) continue; - for (let c = 0; c < 3; c++) data[i + c] = Math.round(data[i + c] * (1 - k + k * mul[c])); - } - tex.setImage(await sharp(data, { raw: info }).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); - continue; - } - tex.setImage(await sharp(Buffer.from(img)).linear(mul, add).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); - } -} - -/** Repair texels Meshy left unpainted (pure black holes in the atlas — the bed's headboard - * showed one): diffuse the surrounding colour into every near-black texel. 0 credits. */ -async function inpaintBlack(doc, { max = 30 } = {}) { - let fixed = 0; - for (const mat of doc.getRoot().listMaterials()) { - const tex = mat.getBaseColorTexture(); - const img = tex?.getImage(); - if (!img) continue; - const { data, info } = await sharp(Buffer.from(img)).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - const { width: w, height: h } = info; - const hole = new Uint8Array(w * h); - let n = 0; - for (let i = 0; i < w * h; i++) if (Math.max(data[i * 3], data[i * 3 + 1], data[i * 3 + 2]) < max) (hole[i] = 1), n++; - if (!n) continue; - // onion-peel: fill hole texels that touch known texels with their neighbours' mean, repeat - for (let pass = 0; pass < 2048 && n; pass++) { - const next = []; - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) { - const i = y * w + x; - if (!hole[i]) continue; - const acc = [0, 0, 0]; - let k = 0; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) { - const xx = x + dx; - const yy = y + dy; - if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue; - const j = yy * w + xx; - if (hole[j]) continue; - for (let c = 0; c < 3; c++) acc[c] += data[j * 3 + c]; - k++; - } - if (k >= 2) next.push([i, acc.map((v) => Math.round(v / k))]); - } - if (!next.length) break; - for (const [i, col] of next) { - for (let c = 0; c < 3; c++) data[i * 3 + c] = col[c]; - hole[i] = 0; - n--; - fixed++; - } - } - tex.setImage(await sharp(data, { raw: { width: w, height: h, channels: 3 } }).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); - } - return fixed; -} - -/** Repair Meshy's broken vertex normals (every Meshy piece; the bed's headboard rendered a - * black triangle): (1) a ZERO / NaN normal (Meshy ships a few) shades black — it takes the - * area-weighted mean of its triangles' face normals; (2) a triangle whose vertex normals - * point AGAINST its winding renders dark under the double-sided material — it gets its own - * vertices carrying its face normal. Returns { zero, flipped }. */ -function repairNormals(doc) { - let fixed = 0; - let zero = 0; - for (const mesh of doc.getRoot().listMeshes()) - for (const prim of mesh.listPrimitives()) { - const names = prim.listSemantics(); - const attrs = names.map((s) => prim.getAttribute(s)); - const P = prim.getAttribute('POSITION'); - const N = prim.getAttribute('NORMAL'); - const I = prim.getIndices(); - if (!N || !I) continue; - { - const acc = new Float64Array(N.getCount() * 3); - const ix = I.getArray(); - for (let t = 0; t < ix.length; t += 3) { - const [a, b, c] = [ix[t], ix[t + 1], ix[t + 2]].map((i) => P.getElement(i, [])); - const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - const w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; - for (let k = 0; k < 3; k++) for (let j = 0; j < 3; j++) acc[ix[t + k] * 3 + j] += n[j]; - } - for (let i = 0; i < N.getCount(); i++) { - const n = N.getElement(i, []); - if (Math.hypot(...n) > 0.5) continue; - const m = [acc[i * 3], acc[i * 3 + 1], acc[i * 3 + 2]]; - const l = Math.hypot(...m) || 1; - N.setElement(i, l > 1e-12 ? m.map((x) => x / l) : [0, 1, 0]); - zero++; - } - } - const arrays = attrs.map((a) => Array.from(a.getArray())); - const idx = Array.from(I.getArray()); - for (let t = 0; t < idx.length; t += 3) { - const [a, b, c] = [idx[t], idx[t + 1], idx[t + 2]].map((i) => P.getElement(i, [])); - const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - const w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; - const len = Math.hypot(...n); - if (len < 1e-12) continue; - let neg = 0; - for (let k = 0; k < 3; k++) { - const vn = N.getElement(idx[t + k], []); - if (vn[0] * n[0] + vn[1] * n[1] + vn[2] * n[2] < 0) neg++; - } - if (neg < 2) continue; - for (let k = 0; k < 3; k++) { - const src = idx[t + k]; - attrs.forEach((attr, j) => { - const size = attr.getElementSize(); - const el = names[j] === 'NORMAL' ? n.map((x) => x / len) : attr.getElement(src, []); - arrays[j].push(...el.slice(0, size)); - }); - idx[t + k] = arrays[names.indexOf('POSITION')].length / 3 - 1; - } - fixed++; - } - if (!fixed) continue; - attrs.forEach((attr, j) => attr.setArray(new (attr.getArray().constructor)(arrays[j]))); - I.setArray(new Uint32Array(idx)); - } - return { zero, flipped: fixed }; -} - async function buildMeshy(item, raw, out) { const { wallLine, ...post } = item.post; const report = await postProcess(raw, out, { ...post, pivot: post.pivot ?? 'bottom-center' }); @@ -214,7 +74,7 @@ async function buildMeshy(item, raw, out) { if (wallLine) for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, WALL_FACE, 1]); await io.write(out, doc); } - const fight = defightFile ? await defightFile(out) : null; + const fight = await defightFile(out); return { source: path.relative(STAGING, raw), trisIn: report.trisIn, ...(fight && fight.before ? { defight: { pairs: fight.before, dropped: fight.dropped, pushed: fight.pushed } } : {}), ...(item.grade ? { grade: item.grade.mul } : {}), ...(inpainted ? { inpainted } : {}), ...(normals.zero || normals.flipped ? { normalsRepaired: normals } : {}) }; } @@ -263,29 +123,6 @@ async function kitbash(list, out, maxTris = 7500) { return { source: `kitbash: ${list.map((l) => l[0]).join(' + ')}`, trisIn: before }; } -/** - * FLAT FOR SYNC (props-kit): every mesh node a direct child of the scene, one primitive per - * mesh. Core's scene sync sends a nested Group with its WORLD pose and the receiver parents - * it under the import root, so a nested import lands off by its own offset on peers. - */ -async function flatForSync(doc) { - await doc.transform(flatten()); - const scene = sceneOf(doc); - for (const node of [...scene.listChildren()]) { - const mesh = node.getMesh(); - if (!mesh || mesh.listPrimitives().length < 2) continue; - mesh.listPrimitives().forEach((prim, i) => { - const name = `${node.getName()}_${prim.getMaterial()?.getName() || i}`; - const part = doc.createNode(name).setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale()); - part.setMesh(doc.createMesh(name).addPrimitive(prim)); - scene.addChild(part); - }); - node.dispose(); - mesh.dispose(); - } - await doc.transform(prune()); -} - const tag = (doc, item) => { const scene = sceneOf(doc); scene.setName(item.name); diff --git a/nature-kit/build/finalize.mjs b/nature-kit/build/finalize.mjs index ebe6f1e..60c6bdd 100644 --- a/nature-kit/build/finalize.mjs +++ b/nature-kit/build/finalize.mjs @@ -18,14 +18,15 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const STAGING = process.env.MESHY_STAGING || path.join(os.homedir(), '.code/lanes-30/meshy/staging/30c-pack-nature'); const require = createRequire(path.join(TOOL, 'package.json')); -const sharp = require('sharp'); const { NodeIO, Document } = require('@gltf-transform/core'); const { ALL_EXTENSIONS } = require('@gltf-transform/extensions'); -const { flatten, clearNodeTransform, compactPrimitive, getBounds, mergeDocuments, unpartition, dedup, prune } = require('@gltf-transform/functions'); +const { getBounds, mergeDocuments, unpartition, dedup, prune } = require('@gltf-transform/functions'); const { postProcess } = await import(pathToFileURL(path.join(TOOL, 'lib/post.js')).href); +// the shared post steps (roadmap 34 E2: tools/kit-build instead of a fork per kit) +const { cutBase, gradeHue: grade, dropEmissive } = await import('../../tools/kit-build/lib/post.mjs'); const HERE = path.dirname(new URL(import.meta.url).pathname); const PACK = path.resolve(HERE, '..'); @@ -51,112 +52,6 @@ function stagingRaw(job, stage) { return path.join(dir, `${stage}-${n}`, 'raw.glb'); } -/** Bake node transforms, then drop every triangle lying entirely below `frac` of the height - * (Meshy likes to stand plants on a flat round "base" disc — on real terrain that reads as a - * coaster). Returns the path of a cut copy. */ -async function cutBase(input, frac) { - const doc = await io.read(input); - await doc.transform(flatten()); - for (const node of doc.getRoot().listNodes()) if (node.getMesh()) clearNodeTransform(node); - const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; - const b = getBounds(scene); - const cut = b.min[1] + frac * (b.max[1] - b.min[1]); - let dropped = 0; - for (const mesh of doc.getRoot().listMeshes()) { - for (const prim of mesh.listPrimitives()) { - const pos = prim.getAttribute('POSITION'); - const idx = prim.getIndices(); - const n = idx ? idx.getCount() : pos.getCount(); - const get = (i) => (idx ? idx.getScalar(i) : i); - const keep = []; - for (let t = 0; t < n; t += 3) { - const a = get(t), c = get(t + 1), d = get(t + 2); - const ymax = Math.max(pos.getElement(a, [])[1], pos.getElement(c, [])[1], pos.getElement(d, [])[1]); - if (ymax < cut) dropped++; - else keep.push(a, c, d); - } - const arr = pos.getCount() > 65535 ? new Uint32Array(keep) : new Uint16Array(keep); - const acc = doc.createAccessor().setType('SCALAR').setArray(arr).setBuffer(pos.getBuffer()); - prim.setIndices(acc); - compactPrimitive(prim); - } - } - await doc.transform(prune()); - const out = path.join(TMP, `cut-${path.basename(path.dirname(path.dirname(input)))}-${Math.random().toString(36).slice(2, 7)}.glb`); - await io.write(out, doc); - return { out, dropped }; -} - -const rgb2hsv = (r, g, b) => { - const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; - let h = 0; - if (d) h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; - return [(h * 60 + 360) % 360, mx ? d / mx : 0, mx]; -}; -const hsv2rgb = (h, s, v) => { - const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; - const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]; - return [r + m, g + m, b + m]; -}; -const inRange = (h, [a, b]) => (a <= b ? h >= a && h <= b : h >= a || h <= b); - -/** Colour grade the base-colour textures: each rule moves pixels whose hue is in `hue` - * (degrees, wraps) by `shift` degrees / scales saturation `sat` and value `val`, feathered - * at the range edges so there is no hard seam. The 0-credit alternative to a retexture. */ -async function grade(file, rules) { - const doc = await io.read(file); - for (const mat of doc.getRoot().listMaterials()) { - const tex = mat.getBaseColorTexture(); - if (!tex) continue; - const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - for (let i = 0; i < data.length; i += 3) { - let [h, s, v] = rgb2hsv(data[i] / 255, data[i + 1] / 255, data[i + 2] / 255); - let tint = null; - for (const r of rules) { - if (s < (r.minSat ?? 0.12) || !inRange(h, r.hue)) continue; - if (r.maxSat != null && s > r.maxSat) continue; - if ((r.minVal != null && v < r.minVal) || (r.maxVal != null && v > r.maxVal)) continue; - const [a, b] = r.hue; - const span = (b - a + 360) % 360 || 360; - const t = ((h - a + 360) % 360) / span; // 0..1 across the range - const w = r.feather === false ? 1 : Math.min(1, Math.min(t, 1 - t) / 0.15); // feather the outer 15% - h = (h + (r.shift ?? 0) * w + 360) % 360; - s = Math.min(1, s * (1 + ((r.sat ?? 1) - 1) * w)); - v = Math.min(1, v * (1 + ((r.val ?? 1) - 1) * w)); - if (r.tint) tint = [r.tint, (r.amount ?? 0.3) * w, v]; - break; - } - let [R, G, B] = hsv2rgb(h, s, v); - if (tint) { - // multiply-style tint: keep the painted value, pull the colour toward the tint - const [hex, k, vv] = tint; - const c = [1, 3, 5].map((j) => parseInt(hex.slice(j, j + 2), 16) / 255); - const cmax = Math.max(...c); - [R, G, B] = [R, G, B].map((x, j) => x * (1 - k) + (c[j] / cmax) * vv * k); - } - data[i] = R * 255; data[i + 1] = G * 255; data[i + 2] = B * 255; - } - const jpg = await sharp(data, { raw: info }).jpeg({ quality: 86 }).toBuffer(); - tex.setImage(new Uint8Array(jpg)).setMimeType('image/jpeg'); - } - await io.write(file, doc); -} - -/** Meshy-6 refines sometimes paint a NON-black emissive map (the oak's canopy: mean 33-47 of - * 255), which makes foliage glow at night and wash out by day. Nothing in a nature kit emits - * light, so the map and factor go (meshy-post only drops a fully black one). */ -async function dropEmissive(file) { - const doc = await io.read(file); - let n = 0; - for (const mat of doc.getRoot().listMaterials()) { - if (mat.getEmissiveTexture() || mat.getEmissiveFactor().some((v) => v > 0)) n++; - mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); - } - await doc.transform(prune()); - await io.write(file, doc); - return n; -} - /** merge built items into one GLB; parts: [{item, pos, rotY, scale}] */ async function kitbash(parts, out, pivot = 'bottom-center') { const doc = new Document(); @@ -211,7 +106,7 @@ for (const it of spec.items) { if (it.kind === 'meshy') { let src = stagingRaw(it.job, it.stage ?? 'refine'); let dropped = 0; - if (it.cutBase) ({ out: src, dropped } = await cutBase(src, it.cutBase)); + if (it.cutBase) ({ out: src, dropped } = await cutBase(src, it.cutBase, TMP)); await postProcess(src, out, it.post ?? {}); if (!it.keepEmissive) await dropEmissive(out); if (it.grade) await grade(out, it.grade); diff --git a/nature-kit/build/grass.mjs b/nature-kit/build/grass.mjs index a399043..ff98c52 100644 --- a/nature-kit/build/grass.mjs +++ b/nature-kit/build/grass.mjs @@ -9,7 +9,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const require = createRequire(path.join(TOOL, 'package.json')); const sharp = require('sharp'); const { Document, NodeIO } = require('@gltf-transform/core'); diff --git a/nature-kit/build/leaves.mjs b/nature-kit/build/leaves.mjs index cd8df65..7e48a3f 100644 --- a/nature-kit/build/leaves.mjs +++ b/nature-kit/build/leaves.mjs @@ -8,7 +8,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const require = createRequire(path.join(TOOL, 'package.json')); const sharp = require('sharp'); const { Document, NodeIO } = require('@gltf-transform/core'); diff --git a/nature-kit/build/render.mjs b/nature-kit/build/render.mjs index 9444226..4f1d54d 100644 --- a/nature-kit/build/render.mjs +++ b/nature-kit/build/render.mjs @@ -9,7 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const require = createRequire(path.join(TOOL, 'package.json')); const { chromium } = require('playwright-core'); const sharp = require('sharp'); diff --git a/nature-kit/build/slope.mjs b/nature-kit/build/slope.mjs index 9fa3ebd..de20370 100644 --- a/nature-kit/build/slope.mjs +++ b/nature-kit/build/slope.mjs @@ -3,7 +3,7 @@ // of its upward-facing triangles (0° = flat). `node slope.mjs Hill/glTF-Binary/Hill.glb` import path from 'node:path'; import { createRequire } from 'node:module'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const require = createRequire(path.join(TOOL, 'package.json')); const { NodeIO } = require('@gltf-transform/core'); const doc = await new NodeIO().read(process.argv[2]); diff --git a/nature-kit/build/water.mjs b/nature-kit/build/water.mjs index d152cf5..4950d6b 100644 --- a/nature-kit/build/water.mjs +++ b/nature-kit/build/water.mjs @@ -8,7 +8,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; -const TOOL = process.env.MESHY_TOOL || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOL = process.env.MESHY_TOOL || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../tools/meshy'); const require = createRequire(path.join(TOOL, 'package.json')); const sharp = require('sharp'); const { Document, NodeIO } = require('@gltf-transform/core'); diff --git a/props-kit/_src/build.mjs b/props-kit/_src/build.mjs index d351b3f..bec7dce 100644 --- a/props-kit/_src/build.mjs +++ b/props-kit/_src/build.mjs @@ -11,18 +11,19 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ITEMS } from './items.mjs'; -import { createRequire } from 'node:module'; import { PIECES, appendFlames } from './procedural.mjs'; -const TOOLS = process.env.MESHY_TOOLS ?? '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +// the shared pack tooling (roadmap 34 E2: tools/kit-build instead of a fork per kit) — gltf-transform +// comes through its loader, so the Documents here and its helpers are ONE module instance +const { TOOLS, load, tool } = await import('../../tools/kit-build/lib/deps.mjs'); +const { gradeLinear: grade, albedoGlow, flatForSync } = await import('../../tools/kit-build/lib/post.mjs'); const STAGING = process.env.MESHY_STAGING ?? path.join(os.homedir(), '.code/lanes-30/meshy/staging'); const REQUESTER = '30c-pack-props'; -const { postProcess } = await import(`${TOOLS}/lib/post.js`); -const sharp = createRequire(`${TOOLS}/package.json`)('sharp'); -const { renderThumbs } = await import(`${TOOLS}/lib/thumb.js`); -const { NodeIO } = await import(`${TOOLS}/node_modules/@gltf-transform/core/dist/index.js`); -const { ALL_EXTENSIONS } = await import(`${TOOLS}/node_modules/@gltf-transform/extensions/dist/index.js`); -const { mergeDocuments, dedup, prune, getBounds, unpartition, flatten } = await import(`${TOOLS}/node_modules/@gltf-transform/functions/dist/index.js`); +const { postProcess } = await tool('lib/post.js'); +const { renderThumbs } = await tool('lib/thumb.js'); +const { NodeIO } = await load('@gltf-transform/core'); +const { ALL_EXTENSIONS } = await load('@gltf-transform/extensions'); +const { mergeDocuments, dedup, prune, getBounds, unpartition } = await load('@gltf-transform/functions'); const HERE = path.dirname(fileURLToPath(import.meta.url)); const PACK = path.resolve(HERE, '..'); @@ -57,25 +58,13 @@ function autoFlames(kind, b) { return []; } -/** Colour-grade every base-colour texture: per-channel multiply (+ offset), so a piece - * Meshy painted off-palette (a pale pine table) matches the kit's oak without a paid retexture. */ -async function grade(doc, { mul, add = [0, 0, 0] }) { - for (const mat of doc.getRoot().listMaterials()) { - const tex = mat.getBaseColorTexture(); - const img = tex?.getImage(); - if (!img) continue; - tex.setImage(await sharp(Buffer.from(img)).linear(mul, add).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); - } -} - async function buildMeshy(item, raw, out) { const report = await postProcess(raw, out, { ...item.post, pivot: item.post.pivot ?? 'bottom-center' }); if (item.flames || item.grade || item.glow) { const doc = await io.read(out); if (item.grade) await grade(doc, item.grade); - // glow: the albedo doubles as the emissive map, so bright panes (a lantern's amber - // glass) light up while dark metal stays dark — a lit look with no extra texture - if (item.glow) for (const mat of doc.getRoot().listMaterials()) mat.setEmissiveTexture(mat.getBaseColorTexture()).setEmissiveFactor([item.glow, item.glow * 0.8, item.glow * 0.55]); + // glow: the albedo doubles as the emissive map (kit-build's albedoGlow) + if (item.glow) albedoGlow(doc, item.glow); if (item.flames) appendFlames(doc, Array.isArray(item.flames) ? item.flames : autoFlames(item.flames, sizeOf(doc))); await io.write(out, doc); } @@ -113,35 +102,6 @@ async function crateStack(out) { } const KITBASH = { crateStack }; -/** - * FLAT FOR SYNC: every mesh node a direct child of the scene, one primitive per mesh. - * Core's scene sync (commandsHandler sendObject) sends a leaf mesh with its LOCAL pose - * but a nested Group / Object3D-with-children with its WORLD pose, which the receiver - * then parents under the import root — so on a peer every nested level adds the - * object's position again (measured: a placed Rug sat 2× its drop offset away on peer - * B). GLTFLoader turns a multi-primitive mesh into a Group, so primitives are split to - * one mesh each; flatten() lifts nested nodes to the scene with baked transforms. - * @param {any} doc - */ -async function flatForSync(doc) { - await doc.transform(flatten()); - const root = doc.getRoot(); - const scene = root.getDefaultScene() ?? root.listScenes()[0]; - for (const node of [...scene.listChildren()]) { - const mesh = node.getMesh(); - if (!mesh || mesh.listPrimitives().length < 2) continue; - mesh.listPrimitives().forEach((prim, i) => { - const name = `${node.getName()}_${prim.getMaterial()?.getName() || i}`; - const part = doc.createNode(name).setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale()); - part.setMesh(doc.createMesh(name).addPrimitive(prim)); - scene.addChild(part); - }); - node.dispose(); - mesh.dispose(); - } - await doc.transform(prune()); -} - const report = {}; const reportFile = path.join(HERE, 'build-report.json'); const prev = fs.existsSync(reportFile) ? JSON.parse(fs.readFileSync(reportFile, 'utf8')) : {}; diff --git a/props-kit/_src/cover.mjs b/props-kit/_src/cover.mjs index 30d4c73..0ceca9a 100644 --- a/props-kit/_src/cover.mjs +++ b/props-kit/_src/cover.mjs @@ -9,7 +9,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; -const TOOLS = process.env.MESHY_TOOLS ?? '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOLS = process.env.MESHY_TOOLS ?? new URL('../../tools/meshy', import.meta.url).pathname; const { NodeIO } = await import(`${TOOLS}/node_modules/@gltf-transform/core/dist/index.js`); const { ALL_EXTENSIONS } = await import(`${TOOLS}/node_modules/@gltf-transform/extensions/dist/index.js`); const { mergeDocuments, dedup, prune, unpartition } = await import(`${TOOLS}/node_modules/@gltf-transform/functions/dist/index.js`); diff --git a/props-kit/_src/procedural.mjs b/props-kit/_src/procedural.mjs index 9793edb..3ca580e 100644 --- a/props-kit/_src/procedural.mjs +++ b/props-kit/_src/procedural.mjs @@ -11,11 +11,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { SWATCHES, rugSvg, tapestrySvg, jpeg } from './textures.mjs'; -const TOOLS = process.env.MESHY_TOOLS ?? '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +// tools/meshy through the shared loader (roadmap 34 E2): the build hands its Documents to appendFlames, +// so gltf-transform must be the same module instance as the build's +const { TOOLS, load } = await import('../../tools/kit-build/lib/deps.mjs'); const THREE = await import(`${TOOLS}/node_modules/three/build/three.module.js`); const { mergeGeometries } = await import(`${TOOLS}/node_modules/three/examples/jsm/utils/BufferGeometryUtils.js`); -const { Document, NodeIO } = await import(`${TOOLS}/node_modules/@gltf-transform/core/dist/index.js`); -const { KHRMaterialsEmissiveStrength } = await import(`${TOOLS}/node_modules/@gltf-transform/extensions/dist/index.js`); +const { Document, NodeIO } = await load('@gltf-transform/core'); +const { KHRMaterialsEmissiveStrength } = await load('@gltf-transform/extensions'); /** texels: one swatch tile covers this many metres */ const TILE_M = 0.8; diff --git a/props-kit/_src/textures.mjs b/props-kit/_src/textures.mjs index df89918..987ae07 100644 --- a/props-kit/_src/textures.mjs +++ b/props-kit/_src/textures.mjs @@ -5,7 +5,7 @@ // slate, and the pack's ONE accent colour — teal (#2f7f7b), the painted crate's. import { createRequire } from 'node:module'; -const TOOLS = process.env.MESHY_TOOLS ?? '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; +const TOOLS = process.env.MESHY_TOOLS ?? new URL('../../tools/meshy', import.meta.url).pathname; const sharp = createRequire(`${TOOLS}/package.json`)('sharp'); export const TEAL = '#2a6e6a'; diff --git a/tools/architecture-kit/build.mjs b/tools/architecture-kit/build.mjs index 897add2..d947095 100644 --- a/tools/architecture-kit/build.mjs +++ b/tools/architecture-kit/build.mjs @@ -17,6 +17,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { TOOLS, kitPost } from './kit-post.mjs'; +import { defightFile } from '../lod/defight.mjs'; import { kitCut, DERIVE } from './kit-cut.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -111,6 +112,10 @@ async function buildItem(it) { const next = path.join(tmp, 'cut.glb'); report = { ...report, cut: await kitCut(cur, next, typeof it.cut === 'string' ? DERIVE[it.cut](it.cutOpts) : it.cut) }; cur = next; + // the cut's mortar core / lining meets the post step's surfaces in their planes: settle + // z-fights AFTER it (defight in the post step ran before the cut existed) — a rebuild then + // reproduces the shipped, settled walls (34-pack-ci: WallStone/WallPlaster byte-identical) + await defightFile(cur, cur); } fs.copyFileSync(cur, out); const doc = await io.read(out); diff --git a/tools/architecture-kit/kit-post.mjs b/tools/architecture-kit/kit-post.mjs index f8716ac..c6c0c69 100644 --- a/tools/architecture-kit/kit-post.mjs +++ b/tools/architecture-kit/kit-post.mjs @@ -1,165 +1,4 @@ -// kit-post: the architecture kit's post step around tools/meshy's meshy-post. -// -// raw Meshy GLB → pre-rotate (Meshy often lays a floor tile or roof slab upright, and -// meshy-post only turns about Y) → meshy-post (weld, simplify, stretch to the exact -// dims, pivot, 1024² JPEG) → SEAM CLAMP → pack GLB. -// -// The seam clamp is what makes pieces snap without gaps: Meshy's ends are never quite -// planar (a stone sticks out 2 cm, the next one is 3 cm short), so after the stretch -// every vertex within `eps` of a bbox face named in `clamp` is moved ONTO that face. -// Two walls placed end to end then share one exact plane at their joint. -// -// node kit-post.mjs '' -// job = meshy-post's job + { preRotate?: {x?, y?, z?} degrees, clamp?: "x" | "xz" | "xyz" | "x-y" …, -// eps?: metres (default 0.04), offset?: [x, y, z] metres applied last, -// albedo?: {saturation, brightness, hue} grade of the base colour, dropEmissive?: false to keep it } -// `clamp` lists axes; a trailing "-y" etc. means "the MIN face of y only" (a floor's bottom). -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { defightFile } from '../lod/defight.mjs'; - -export const TOOLS = process.env.MESHY_TOOLS || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; -const req = createRequire(path.join(TOOLS, 'package.json')); -const load = async (/** @type {string} */ id) => import(pathToFileURL(req.resolve(id)).href); - -const { NodeIO } = await load('@gltf-transform/core'); -const { ALL_EXTENSIONS } = await load('@gltf-transform/extensions'); -const { getBounds, transformMesh, prune } = await load('@gltf-transform/functions'); -const { postProcess } = await import(pathToFileURL(path.join(TOOLS, 'lib/post.js')).href); -const sharp = (await load('sharp')).default; - -const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); -const AX = { x: 0, y: 1, z: 2 }; - -/** column-major 4x4 rotation about one axis @param {'x'|'y'|'z'} axis @param {number} deg */ -function rot(axis, deg) { - const a = (deg * Math.PI) / 180; - const c = Math.cos(a); - const s = Math.sin(a); - if (axis === 'x') return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; - if (axis === 'y') return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; - return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -} - -/** Parse "xz" / "x-y" / "+y" into [{axis, side}] (side: -1 min, +1 max, 0 both). @param {string} spec */ -export function parseClamp(spec) { - const out = []; - let sign = 0; - for (const ch of spec || '') { - if (ch === '-') sign = -1; - else if (ch === '+') sign = 1; - else if (ch in AX) { - out.push({ axis: AX[/** @type {'x'|'y'|'z'} */ (ch)], side: sign }); - sign = 0; - } - } - return out; -} - -/** - * Move every vertex within eps of a named bbox face onto it. Returns how many moved. - * @param {any} doc @param {string} spec @param {number} eps - */ -export function seamClamp(doc, spec, eps = 0.04) { - const faces = parseClamp(spec); - if (!faces.length) return 0; - const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; - const b = getBounds(scene); - let moved = 0; - const seen = new Set(); - for (const mesh of doc.getRoot().listMeshes()) { - for (const prim of mesh.listPrimitives()) { - const pos = prim.getAttribute('POSITION'); - if (!pos || seen.has(pos)) continue; - seen.add(pos); - const v = [0, 0, 0]; - for (let i = 0; i < pos.getCount(); i++) { - pos.getElement(i, v); - let changed = false; - for (const { axis, side } of faces) { - if (side <= 0 && v[axis] - b.min[axis] < eps && v[axis] !== b.min[axis]) { - v[axis] = b.min[axis]; - changed = true; - } - if (side >= 0 && b.max[axis] - v[axis] < eps && v[axis] !== b.max[axis]) { - v[axis] = b.max[axis]; - changed = true; - } - } - if (changed) { - pos.setElement(i, v); - moved++; - } - } - } - } - return moved; -} - -/** - * @param {string} input @param {string} output @param {any} job - */ -export async function kitPost(input, output, job) { - let src = input; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-post-')); - try { - if (job.preRotate && Object.values(job.preRotate).some(Boolean)) { - const doc = await io.read(input); - for (const axis of /** @type {const} */ (['x', 'y', 'z'])) { - const deg = job.preRotate[axis]; - if (!deg) continue; - // bake node transforms first would be meshy-post's job; Meshy raws are a - // single identity node, so rotating the mesh data is exact here - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (rot(axis, deg))); - } - src = path.join(tmp, 'rotated.glb'); - await io.write(src, doc); - } - const mid = path.join(tmp, 'post.glb'); - const report = await postProcess(src, mid, job); - const doc = await io.read(mid); - const moved = seamClamp(doc, job.clamp ?? '', job.eps ?? 0.04); - if (job.offset) { - // a piece that shares ANOTHER piece's pivot (a window sits 0.95 m up on the wall's - // bottom-centre origin, so both place at the same grid point and rotation) - const t = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, job.offset[0] ?? 0, job.offset[1] ?? 0, job.offset[2] ?? 0, 1]; - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (t)); - } - let graded = 0; - for (const mat of doc.getRoot().listMaterials()) { - // architecture never glows: Meshy's refine sometimes ships a faint emissive map - // (a glowing floor is baked light in disguise) — drop it outright - if (job.dropEmissive !== false) mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); - // a colour GRADE of the albedo, so every piece sits in one palette (Meshy read - // "oxblood" as cherry red and "slate" as lavender): sharp.modulate + JPEG - const tex = job.albedo && mat.getBaseColorTexture(); - if (tex) { - const { saturation = 1, brightness = 1, hue = 0 } = job.albedo; - tex.setImage(new Uint8Array(await sharp(Buffer.from(tex.getImage())).modulate({ saturation, brightness, hue }).jpeg({ quality: 86 }).toBuffer())); - tex.setMimeType('image/jpeg'); - graded++; - } - } - await doc.transform(prune()); - await io.write(output, doc); - // the seam clamp folds relief within eps of a face INTO it: coplanar layers that - // z-fight while the camera moves (roadmap 33 K1, the Block). Settle them last. - const fight = await defightFile(output, output); - return { ...report, output, clamped: moved, defight: { pairs: fight.before, dropped: fight.dropped, pushed: fight.pushed }, graded, bytesOut: fs.statSync(output).size }; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [input, output, jobJson] = process.argv.slice(2); - if (!input || !output) { - console.error("usage: kit-post.mjs ''"); - process.exit(2); - } - const report = await kitPost(input, output, JSON.parse(jobJson || '{}')); - console.log(JSON.stringify(report)); -} +// kit-post: the architecture kit's post step now lives in tools/kit-build/lib/post.mjs — roadmap 34 +// E2, one pack build tool instead of four forks. This file stays so build.mjs and the tests keep +// their import. CLI: node tools/kit-build/kit-build.mjs post '' +export { TOOLS, kitPost, parseClamp, seamClamp } from '../kit-build/lib/post.mjs'; diff --git a/tools/kit-build/README.md b/tools/kit-build/README.md new file mode 100644 index 0000000..40fe981 --- /dev/null +++ b/tools/kit-build/README.md @@ -0,0 +1,99 @@ +# tools/kit-build — the one pack build tool, and the pack CI + +Every kit used to carry its own copy of the post-processing (architecture, sci-fi and town had a +`kit-post.mjs` each; nature, props and interior their own grade / emissive / flat-for-sync +helpers). They now share this tool. A kit keeps only its **recipe** — which Meshy raw, kitbash or +procedural piece makes each item — and everything after it is here. + +``` +cd tools/meshy && npm ci # once: the deps every pack tool uses +node tools/kit-build/kit-build.mjs … +``` + +| command | what it does | +|---|---| +| `build [--only A,B] [--no-thumbs]` | the pack's recipe → `pass --write` → judged defight → LOD1/LOD2 → thumbnails → `check` | +| `post ''` | the one post step on a Meshy raw: pre-rotate, meshy-post (weld, **decimate to `targetTris`**, stretch to `dims`, pivot, textures to the cap as JPEG), seam clamp, offset, **emissive policy** + albedo grade, recolor, glow mask, defight (`lib/post.mjs` documents the job) | +| `pass [--write]` | on the SHIPPED LOD0s, from `packs.json`: emissive policy, **texture cap per pack**, PNG → JPEG for an opaque file over the 5 MiB share cap, **decimate to the category budget**. Dry run unless `--write`; a compliant file is never rewritten | +| `lod ` | `tools/lod`: judged defight of LOD0s, then LOD1/LOD2 files + each row's `lods` (P1), then `--check` | +| `thumbs [--only A,B]` | renders each row's screenshot (512², transparent, yaw 35°) | +| `flicker [--record]` | the static z-fight probe; `--record` RENDERS every file over the limit (headless) and writes `flicker-baseline.json` | +| `report [pack…] [--md f] [--json f]` | the **budget report**: per item tris vs budget, MiB, largest texture, LOD triangles, z-fight cm² | +| `check [pack…] [--summary f] [--json f]` | **the CI** — exit 1 on any error that `allow.json` does not list | + +The recipes `build` runs: `tools/architecture-kit/build.mjs`, `tools/scifi-kit/build.mjs`, +`tools/town-kit/build.mjs`, `tools/interactive-kit/build.mjs`, `nature-kit/build/finalize.mjs`, +`props-kit/_src/build.mjs`, `interior-kit/_src/build.mjs` (they read the staged Meshy raws under +`~/.code/lanes-30/meshy/staging/`; nothing here spends credits). + +**Byte-stable.** When the forks were folded in (roadmap 34), the old and the new code rebuilt the +architecture, nature, props, interior and sci-fi kits from the same raws: 159 / 159 GLBs +byte-identical. `pass --all` on main changes exactly one file (default/SvelteRune, see below). + +## The checks (`check`, run on every packs PR) + +| check | rule | +|---|---| +| index | `index.json` rows: name, title, exactly one of value / zip, attribution, copyright, license, https source; files exist; every pack has a policy in `packs.json` | +| manifest | each model list: row shape incl. **`lods`** (P1: files beside LOD0, ratios in (0, 1], finest first) and **`behavior`** (P2: type, clip, trigger, autoplay only for `loop`, closeClip only for door/toggle, no unknown keys — core silently normalises a typo, the CI refuses it); unique names; no orphan GLBs (warn) | +| size | every GLB ≤ 5 MiB (the share cap — bigger never reaches a peer); warn > 2 MiB | +| tris | LOD0's drawn triangles ≤ the item's category budget | +| textures | largest texture side ≤ the pack's cap | +| emissive | a pack whose policy is `none` has nothing that glows; an emissive map under a black factor warns | +| scale | the bbox is in metres: largest extent ≥ 5 cm and ≤ the category's maximum | +| pivot | the origin is where the item's pivot rule says (below) | +| lods | items over 2 000 triangles ship LOD files; each exists, has fewer triangles than LOD0, its `ratio` is the measured one (± 0.05), keeps LOD0's mesh-node names (core swaps BY NAME) and its clips | +| behavior | the clips a behavior names exist in the GLB and in every LOD file | +| flicker | the static z-fight probe (`tools/lod/coplanar.mjs`: coplanar triangle pairs, both facings when double-sided) ≤ 1 cm² — or at most the file's recorded baseline | +| thumb | the row's screenshot exists, decodes, ≥ 64 px | + +### Policy: `packs.json` + +`categories` give the triangle budget and largest extent (tile 2 500 · prop 6 000 · modular 6 500 · +functional 7 000 · hero 8 000 · set 12 000 · tree 15 000 · legacy 25 000). `packs` give each pack's +`textureCap`, `emissive` (`none` | `allowed`), default `category` and `pivot`, and per-item +overrides. **A new pack needs a row here** — the check says so. + +Pivot rules: `bottom-center` (default) · `bottom-center-back` (a tapestry, a torch: back on the +origin) · `top-center` (hangs: a chandelier, a fan) · `wall-pivot` (shares the wall's bottom-centre +origin and starts above the floor: a window) · `wall-face` (origin on the wall line, the piece on +the wall's face: interior trims, a wall screen) · `foot` (stands on its origin, which is under the +piece but not its middle: a signpost's post) · `hinge` (origin within 5 cm of the piece: a lever +handle) · `any` (legacy packs that keep their source scene's coordinates). + +### Accepted failures: `allow.json` + +`{pack, item ('*' = all), check, file?, reason}`. Every entry carries the reason it is accepted; an +entry that matches nothing is reported so it gets removed once the item is fixed. Today: 15 items +`tools/lod` builds no level for (its visual gate refuses every candidate — foliage, straw and fine +detail need impostor levels, roadmap-33 F2) and nature-kit's PondSet render flicker. + +### The z-fight baseline: `flicker-baseline.json` + +The static probe over-counts: a coplanar pair hidden inside a piece, or one whose two layers carry +the same texels, never shows — which is why 33-pack-fix-lod only rewrote a file when a RENDER +flickered clearly less. A file over the static limit therefore passes only if it was rendered +(`tools/lod/judge.mjs` flicker: 512², 12 orbit poses, micro-triplets) at ≤ 400 px and recorded with +its overlap; the CI holds it to that area (+2 %). After `defight-all` or a rebuild: + +``` +node tools/kit-build/kit-build.mjs flicker --record # refuses (exit 1) a file over 400 px +``` + +## Making the check required (repo admin) + +The workflow is `.github/workflows/pack-checks.yml`, job **`pack-checks`**. In GitHub: Settings → +Branches → branch protection rule for `main` (or Rules → Rulesets) → *Require status checks to pass* +→ add `pack-checks`. Or: + +``` +gh api -X PUT repos/theprototype-app/packs/branches/main/protection --input - <<'JSON' +{"required_status_checks": {"strict": false, "contexts": ["pack-checks"]}, + "enforce_admins": false, "required_pull_request_reviews": null, "restrictions": null} +JSON +``` + +## Tests + +`node --test tools/kit-build/test/*.test.mjs` — a fixture pack that passes every check, then each +check shown red on the one thing it exists to catch (and `pass` fixing it, byte-stable after). diff --git a/tools/kit-build/allow.json b/tools/kit-build/allow.json new file mode 100644 index 0000000..cf650d0 --- /dev/null +++ b/tools/kit-build/allow.json @@ -0,0 +1,102 @@ +{ + "note": "Known, accepted failures of `kit-build check` — each with the reason it is accepted. {pack, item ('*' = every item), check, file? (one file only), reason}. An entry that matches nothing is reported, so remove it once the item is fixed.", + "allow": [ + { + "pack": "nature-kit", + "item": "Birch", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: no level reaches <= 80 % within the error caps (best 1 / 1) — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "Palm", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: no level reaches <= 80 % within the error caps (best 1 / 1) — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "Bush", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.86 / 0.83 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "BushAutumn", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.86 / 0.83 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "Fern", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.9 / 1 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "FlowerPatch", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.97 / 0.97 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "props-kit", + "item": "PottedPlant", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 1 / 1 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "scifi-kit", + "item": "ServerRack", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.82 / 0.81 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "scifi-kit", + "item": "PlantPod", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.9 / 0.9 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "interior-kit", + "item": "Plant", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 1 / 1 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "interior-kit", + "item": "Chandelier", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 1 / 1 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "town-kit", + "item": "FlowerBox", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.82 / 0.82 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "town-kit", + "item": "FlowerBoxLong", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.82 / 0.82 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "town-kit", + "item": "HayStack", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.85 / 0.84 of LOD0 — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "default", + "item": "SvelteRune", + "check": "lods", + "reason": "tools/lod (33-pack-fix-lod) builds no level: best 0.88 / 0.82 of LOD0 (the starter model, 2000-tri floor) — the visual gate refuses every candidate (foliage / straw / fine detail needs impostor levels, roadmap-33 F2); core's automatic runtime LOD still applies" + }, + { + "pack": "nature-kit", + "item": "PondSet", + "check": "flicker", + "file": "nature-kit/PondSet/glTF-Binary/PondSet.glb", + "reason": "render probe 404 px (> the 400 px recording limit) — the same 404 px 33-pack-fix-lod measured; none of defight's candidates flickers clearly less (best 403 px), and the roadmap-33 in-app scan passed it under the patch limit. The kitbash's reeds and pond edge, not a fixable coplanar layer; re-judge if the pond is rebuilt" + } + ] +} diff --git a/tools/kit-build/flicker-baseline.json b/tools/kit-build/flicker-baseline.json new file mode 100644 index 0000000..48bced4 --- /dev/null +++ b/tools/kit-build/flicker-baseline.json @@ -0,0 +1,714 @@ +{ + "note": "The static z-fight probe's accepted files (kit-build check: a file over 1 cm² of coplanar overlap passes only at or under its recorded area). Each was RENDERED (tools/lod/judge.mjs flicker: 512², 12 orbit poses) and flickers at most 400 px; px = that measurement. Written by: node tools/kit-build/kit-build.mjs flicker --record", + "limitPx": 400, + "files": { + "arcane-kit/AlchemyTable/glTF-Binary/alchemy-table.glb": { + "cm2": 103.09, + "pairs": 5, + "px": 50, + "why": "not in defight-report.json (built before tools/lod or outside it)" + }, + "arcane-kit/AlchemyTable/glTF-Binary/alchemy-table.lod1.glb": { + "cm2": 101.3, + "pairs": 4, + "px": 29, + "why": "a level of AlchemyTable: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "arcane-kit/AlchemyTable/glTF-Binary/alchemy-table.lod2.glb": { + "cm2": 101.3, + "pairs": 4, + "px": 35, + "why": "a level of AlchemyTable: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "arcane-kit/PotionShelf/glTF-Binary/potion-shelf.glb": { + "cm2": 9.99, + "pairs": 6, + "px": 13, + "why": "not in defight-report.json (built before tools/lod or outside it)" + }, + "arcane-kit/PotionShelf/glTF-Binary/potion-shelf.lod1.glb": { + "cm2": 9.99, + "pairs": 6, + "px": 20, + "why": "a level of PotionShelf: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "arcane-kit/RuneRug/glTF-Binary/rune-rug.glb": { + "cm2": 2.31, + "pairs": 6, + "px": 25, + "why": "not in defight-report.json (built before tools/lod or outside it)" + }, + "arcane-kit/RuneRug/glTF-Binary/rune-rug.lod1.glb": { + "cm2": 1.99, + "pairs": 4, + "px": 30, + "why": "a level of RuneRug: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "arcane-kit/Telescope/glTF-Binary/telescope.lod1.glb": { + "cm2": 3.39, + "pairs": 6, + "px": 68, + "why": "a level of Telescope: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Arch/glTF-Binary/arch.lod1.glb": { + "cm2": 7.9, + "pairs": 5, + "px": 20, + "why": "a level of Arch: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Block/glTF-Binary/block.lod1.glb": { + "cm2": 11.57, + "pairs": 7, + "px": 276, + "why": "a level of Block: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Chimney/glTF-Binary/chimney.glb": { + "cm2": 1023.53, + "pairs": 24, + "px": 23, + "why": "defight left it: no candidate flickers clearly less (render 23 → 23 px)" + }, + "architecture-kit/Chimney/glTF-Binary/chimney.lod1.glb": { + "cm2": 483.67, + "pairs": 14, + "px": 19, + "why": "a level of Chimney: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Column/glTF-Binary/column.glb": { + "cm2": 63.84, + "pairs": 15, + "px": 3, + "why": "defight left it: no candidate flickers clearly less (render 3 → 3 px)" + }, + "architecture-kit/Column/glTF-Binary/column.lod1.glb": { + "cm2": 18.23, + "pairs": 3, + "px": 4, + "why": "a level of Column: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Column/glTF-Binary/column.lod2.glb": { + "cm2": 1.72, + "pairs": 2, + "px": 6, + "why": "a level of Column: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Door/glTF-Binary/door.glb": { + "cm2": 18.21, + "pairs": 7, + "px": 16, + "why": "defight left it: no candidate flickers clearly less (render 16 → 16 px)" + }, + "architecture-kit/Door/glTF-Binary/door.lod1.glb": { + "cm2": 13.92, + "pairs": 7, + "px": 19, + "why": "a level of Door: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Fence/glTF-Binary/fence.glb": { + "cm2": 15.32, + "pairs": 19, + "px": 200, + "why": "defight left it: no candidate flickers clearly less (render 200 → 200 px)" + }, + "architecture-kit/Fence/glTF-Binary/fence.lod1.glb": { + "cm2": 15.2, + "pairs": 17, + "px": 207, + "why": "a level of Fence: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Fence/glTF-Binary/fence.lod2.glb": { + "cm2": 2.43, + "pairs": 5, + "px": 42, + "why": "a level of Fence: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/FloorSlate/glTF-Binary/floor-slate.glb": { + "cm2": 577.97, + "pairs": 16, + "px": 87, + "why": "defight left it: no candidate flickers clearly less (render 87 → 87 px)" + }, + "architecture-kit/FloorStone/glTF-Binary/floor-stone.glb": { + "cm2": 577.97, + "pairs": 16, + "px": 48, + "why": "defight left it: no candidate flickers clearly less (render 48 → 48 px)" + }, + "architecture-kit/FloorWood/glTF-Binary/floor-wood.glb": { + "cm2": 577.97, + "pairs": 16, + "px": 45, + "why": "defight left it: no candidate flickers clearly less (render 45 → 45 px)" + }, + "architecture-kit/Gate/glTF-Binary/gate.glb": { + "cm2": 146.21, + "pairs": 34, + "px": 37, + "why": "defight left it: no candidate flickers clearly less (render 37 → 35 px)" + }, + "architecture-kit/Gate/glTF-Binary/gate.lod1.glb": { + "cm2": 144.73, + "pairs": 28, + "px": 37, + "why": "a level of Gate: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Gate/glTF-Binary/gate.lod2.glb": { + "cm2": 139.82, + "pairs": 25, + "px": 33, + "why": "a level of Gate: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Pillar/glTF-Binary/pillar.glb": { + "cm2": 551.59, + "pairs": 24, + "px": 14, + "why": "defight left it: no candidate flickers clearly less (render 14 → 13 px)" + }, + "architecture-kit/Pillar/glTF-Binary/pillar.lod1.glb": { + "cm2": 131.66, + "pairs": 6, + "px": 13, + "why": "a level of Pillar: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Ramp/glTF-Binary/ramp.lod1.glb": { + "cm2": 1.85, + "pairs": 1, + "px": 176, + "why": "a level of Ramp: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Ramp/glTF-Binary/ramp.lod2.glb": { + "cm2": 1.85, + "pairs": 1, + "px": 199, + "why": "a level of Ramp: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/RoofSlope/glTF-Binary/roof-slope.glb": { + "cm2": 1040.06, + "pairs": 8, + "px": 296, + "why": "defight (surface) settled the visible layers; the rest are hidden" + }, + "architecture-kit/RoofSlope/glTF-Binary/roof-slope.lod1.glb": { + "cm2": 732.02, + "pairs": 6, + "px": 185, + "why": "a level of RoofSlope: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Stairs/glTF-Binary/stairs.glb": { + "cm2": 382.66, + "pairs": 139, + "px": 96, + "why": "defight (drop-only) settled the visible layers; the rest are hidden" + }, + "architecture-kit/Stairs/glTF-Binary/stairs.lod1.glb": { + "cm2": 186.88, + "pairs": 53, + "px": 52, + "why": "a level of Stairs: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Stairs/glTF-Binary/stairs.lod2.glb": { + "cm2": 118.63, + "pairs": 25, + "px": 33, + "why": "a level of Stairs: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/StairsWood/glTF-Binary/stairs-wood.glb": { + "cm2": 455.88, + "pairs": 151, + "px": 95, + "why": "defight left it: no candidate flickers clearly less (render 95 → 79 px)" + }, + "architecture-kit/StairsWood/glTF-Binary/stairs-wood.lod1.glb": { + "cm2": 180.01, + "pairs": 53, + "px": 55, + "why": "a level of StairsWood: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/StairsWood/glTF-Binary/stairs-wood.lod2.glb": { + "cm2": 118.29, + "pairs": 21, + "px": 18, + "why": "a level of StairsWood: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "architecture-kit/Tower/glTF-Binary/tower.glb": { + "cm2": 1329.54, + "pairs": 313, + "px": 78, + "why": "defight left it: no candidate flickers clearly less (render 78 → 77 px)" + }, + "architecture-kit/Tower/glTF-Binary/tower.lod1.glb": { + "cm2": 530.42, + "pairs": 79, + "px": 68, + "why": "a level of Tower: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "cube_diorama/Desk/glTF-Binary/desk.glb": { + "cm2": 25.51, + "pairs": 5, + "px": 4, + "why": "defight left it: no candidate flickers clearly less (render 4 → 5 px)" + }, + "cube_diorama/Dresser/glTF-Binary/dresser.glb": { + "cm2": 5009.29, + "pairs": 16, + "px": 7, + "why": "defight left it: no candidate flickers clearly less (render 7 → 8 px)" + }, + "cube_diorama/Dresser/glTF-Binary/dresser.lod1.glb": { + "cm2": 5105.64, + "pairs": 20, + "px": 6, + "why": "a level of Dresser: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "cube_diorama/Dresser/glTF-Binary/dresser.lod2.glb": { + "cm2": 5060.6, + "pairs": 18, + "px": 9, + "why": "a level of Dresser: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "cube_diorama/Window/glTF-Binary/window.glb": { + "cm2": 136.03, + "pairs": 22, + "px": 15, + "why": "defight left it: no candidate flickers clearly less (render 15 → 15 px)" + }, + "interactive-kit/DoorStudded/glTF-Binary/door-studded.glb": { + "cm2": 1953.37, + "pairs": 32, + "px": 50, + "why": "not in defight-report.json (built before tools/lod or outside it)" + }, + "interactive-kit/DoorStudded/glTF-Binary/door-studded.lod1.glb": { + "cm2": 1961.41, + "pairs": 31, + "px": 55, + "why": "a level of DoorStudded: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/DoorStudded/glTF-Binary/door-studded.lod2.glb": { + "cm2": 1846.21, + "pairs": 16, + "px": 69, + "why": "a level of DoorStudded: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/DoorWood/glTF-Binary/door-wood.lod1.glb": { + "cm2": 146.98, + "pairs": 4, + "px": 31, + "why": "a level of DoorWood: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/Drawers/glTF-Binary/drawers.lod2.glb": { + "cm2": 243.33, + "pairs": 1, + "px": 41, + "why": "a level of Drawers: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/IronGate/glTF-Binary/iron-gate.lod2.glb": { + "cm2": 7.57, + "pairs": 1, + "px": 174, + "why": "a level of IronGate: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/Portcullis/glTF-Binary/portcullis.glb": { + "cm2": 3624.92, + "pairs": 79, + "px": 226, + "why": "not in defight-report.json (built before tools/lod or outside it)" + }, + "interactive-kit/Portcullis/glTF-Binary/portcullis.lod1.glb": { + "cm2": 3560.37, + "pairs": 49, + "px": 230, + "why": "a level of Portcullis: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "interactive-kit/Shutters/glTF-Binary/shutters.lod1.glb": { + "cm2": 1.05, + "pairs": 2, + "px": 77, + "why": "a level of Shutters: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "nature-kit/Birch/glTF-Binary/Birch.glb": { + "cm2": 4.41, + "pairs": 1, + "px": 25, + "why": "defight left it: no candidate flickers clearly less (render 25 → 25 px)" + }, + "nature-kit/Bridge/glTF-Binary/Bridge.lod1.glb": { + "cm2": 2.46, + "pairs": 3, + "px": 37, + "why": "a level of Bridge: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "nature-kit/Bridge/glTF-Binary/Bridge.lod2.glb": { + "cm2": 2.46, + "pairs": 3, + "px": 33, + "why": "a level of Bridge: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "nature-kit/Bush/glTF-Binary/Bush.glb": { + "cm2": 40.57, + "pairs": 1, + "px": 41, + "why": "defight left it: no candidate flickers clearly less (render 41 → 40 px)" + }, + "nature-kit/BushAutumn/glTF-Binary/BushAutumn.glb": { + "cm2": 40.57, + "pairs": 1, + "px": 39, + "why": "defight left it: no candidate flickers clearly less (render 39 → 38 px)" + }, + "nature-kit/Fern/glTF-Binary/Fern.glb": { + "cm2": 136.42, + "pairs": 18, + "px": 67, + "why": "defight left it: no candidate flickers clearly less (render 67 → 66 px)" + }, + "nature-kit/FlowerPatch/glTF-Binary/FlowerPatch.glb": { + "cm2": 79.03, + "pairs": 66, + "px": 238, + "why": "defight left it: no candidate flickers clearly less (render 238 → 238 px)" + }, + "nature-kit/Flowers/glTF-Binary/Flowers.glb": { + "cm2": 29.84, + "pairs": 20, + "px": 18, + "why": "defight left it: no candidate flickers clearly less (render 18 → 18 px)" + }, + "nature-kit/FlowersViolet/glTF-Binary/FlowersViolet.glb": { + "cm2": 29.84, + "pairs": 20, + "px": 21, + "why": "defight left it: no candidate flickers clearly less (render 21 → 20 px)" + }, + "nature-kit/Log/glTF-Binary/Log.lod1.glb": { + "cm2": 3.18, + "pairs": 2, + "px": 37, + "why": "a level of Log: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "nature-kit/Pine/glTF-Binary/Pine.glb": { + "cm2": 17.75, + "pairs": 3, + "px": 31, + "why": "defight left it: no candidate flickers clearly less (render 31 → 31 px)" + }, + "nature-kit/PondSet/glTF-Binary/PondSet.lod1.glb": { + "cm2": 11.54, + "pairs": 3, + "px": 300, + "why": "a level of PondSet: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "nature-kit/Reeds/glTF-Binary/Reeds.glb": { + "cm2": 134.8, + "pairs": 8, + "px": 62, + "why": "defight left it: no candidate flickers clearly less (render 62 → 47 px)" + }, + "props-kit/Bed/glTF-Binary/bed.glb": { + "cm2": 2437.17, + "pairs": 48, + "px": 48, + "why": "defight left it: no candidate flickers clearly less (render 48 → 48 px)" + }, + "props-kit/Bed/glTF-Binary/bed.lod1.glb": { + "cm2": 1901.46, + "pairs": 33, + "px": 25, + "why": "a level of Bed: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Bed/glTF-Binary/bed.lod2.glb": { + "cm2": 1707.81, + "pairs": 31, + "px": 22, + "why": "a level of Bed: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Bench/glTF-Binary/bench.glb": { + "cm2": 298.04, + "pairs": 52, + "px": 113, + "why": "defight left it: no candidate flickers clearly less (render 113 → 105 px)" + }, + "props-kit/Bench/glTF-Binary/bench.lod1.glb": { + "cm2": 313.22, + "pairs": 30, + "px": 74, + "why": "a level of Bench: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Bookcase/glTF-Binary/bookcase.glb": { + "cm2": 4441.45, + "pairs": 23, + "px": 29, + "why": "defight left it: no candidate flickers clearly less (render 29 → 29 px)" + }, + "props-kit/Bookcase/glTF-Binary/bookcase.lod1.glb": { + "cm2": 2581.66, + "pairs": 6, + "px": 32, + "why": "a level of Bookcase: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Candles/glTF-Binary/candles.glb": { + "cm2": 5.94, + "pairs": 20, + "px": 13, + "why": "defight left it: no candidate flickers clearly less (render 13 → 14 px)" + }, + "props-kit/Candles/glTF-Binary/candles.lod1.glb": { + "cm2": 4.29, + "pairs": 6, + "px": 12, + "why": "a level of Candles: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Chest/glTF-Binary/chest.glb": { + "cm2": 210.01, + "pairs": 18, + "px": 57, + "why": "defight left it: no candidate flickers clearly less (render 57 → 46 px)" + }, + "props-kit/Chest/glTF-Binary/chest.lod1.glb": { + "cm2": 209.65, + "pairs": 16, + "px": 53, + "why": "a level of Chest: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Chest/glTF-Binary/chest.lod2.glb": { + "cm2": 148.74, + "pairs": 12, + "px": 29, + "why": "a level of Chest: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Hatch/glTF-Binary/hatch.glb": { + "cm2": 33.37, + "pairs": 12, + "px": 24, + "why": "defight left it: no candidate flickers clearly less (render 22 → 24 px)" + }, + "props-kit/Ladder/glTF-Binary/ladder.glb": { + "cm2": 70, + "pairs": 8, + "px": 2, + "why": "defight left it: no candidate flickers clearly less (render 2 → 2 px)" + }, + "props-kit/Lantern/glTF-Binary/lantern.glb": { + "cm2": 2.66, + "pairs": 6, + "px": 33, + "why": "defight (drop-only) settled the visible layers; the rest are hidden" + }, + "props-kit/Lantern/glTF-Binary/lantern.lod1.glb": { + "cm2": 2.03, + "pairs": 3, + "px": 21, + "why": "a level of Lantern: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/PottedPlant/glTF-Binary/potted-plant.glb": { + "cm2": 53.6, + "pairs": 19, + "px": 28, + "why": "defight left it: no candidate flickers clearly less (render 28 → 23 px)" + }, + "props-kit/Table/glTF-Binary/table.glb": { + "cm2": 249.04, + "pairs": 108, + "px": 77, + "why": "defight left it: no candidate flickers clearly less (render 77 → 66 px)" + }, + "props-kit/Table/glTF-Binary/table.lod1.glb": { + "cm2": 240.54, + "pairs": 18, + "px": 64, + "why": "a level of Table: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/WallTorch/glTF-Binary/wall-torch.glb": { + "cm2": 4.07, + "pairs": 8, + "px": 8, + "why": "defight left it: no candidate flickers clearly less (render 8 → 5 px)" + }, + "props-kit/WallTorch/glTF-Binary/wall-torch.lod1.glb": { + "cm2": 2.88, + "pairs": 4, + "px": 9, + "why": "a level of WallTorch: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/WallTorch/glTF-Binary/wall-torch.lod2.glb": { + "cm2": 2.88, + "pairs": 4, + "px": 5, + "why": "a level of WallTorch: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Well/glTF-Binary/well.glb": { + "cm2": 122.18, + "pairs": 3, + "px": 45, + "why": "defight left it: no candidate flickers clearly less (render 45 → 45 px)" + }, + "props-kit/Well/glTF-Binary/well.lod1.glb": { + "cm2": 128.78, + "pairs": 1, + "px": 39, + "why": "a level of Well: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Workbench/glTF-Binary/workbench.glb": { + "cm2": 55.93, + "pairs": 16, + "px": 69, + "why": "defight left it: no candidate flickers clearly less (render 69 → 69 px)" + }, + "props-kit/Workbench/glTF-Binary/workbench.lod1.glb": { + "cm2": 54.54, + "pairs": 9, + "px": 56, + "why": "a level of Workbench: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "props-kit/Workbench/glTF-Binary/workbench.lod2.glb": { + "cm2": 53.01, + "pairs": 7, + "px": 60, + "why": "a level of Workbench: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Chair/glTF-Binary/chair.glb": { + "cm2": 75.16, + "pairs": 3, + "px": 25, + "why": "defight left it: no candidate flickers clearly less (render 25 → 25 px)" + }, + "scifi-kit/Chair/glTF-Binary/chair.lod1.glb": { + "cm2": 62.35, + "pairs": 2, + "px": 27, + "why": "a level of Chair: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/CornerPost/glTF-Binary/corner-post.glb": { + "cm2": 5889.88, + "pairs": 217, + "px": 7, + "why": "defight left it: no candidate flickers clearly less (render 7 → 7 px)" + }, + "scifi-kit/CornerPost/glTF-Binary/corner-post.lod1.glb": { + "cm2": 1116.33, + "pairs": 112, + "px": 6, + "why": "a level of CornerPost: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Desk/glTF-Binary/desk.glb": { + "cm2": 79.92, + "pairs": 11, + "px": 71, + "why": "defight left it: no candidate flickers clearly less (render 71 → 68 px)" + }, + "scifi-kit/Desk/glTF-Binary/desk.lod1.glb": { + "cm2": 1.76, + "pairs": 3, + "px": 105, + "why": "a level of Desk: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Desk/glTF-Binary/desk.lod2.glb": { + "cm2": 1.32, + "pairs": 1, + "px": 113, + "why": "a level of Desk: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Drone/glTF-Binary/drone.lod1.glb": { + "cm2": 1.38, + "pairs": 1, + "px": 16, + "why": "a level of Drone: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/FloorPlate/glTF-Binary/floor-plate.glb": { + "cm2": 186402.81, + "pairs": 1243, + "px": 86, + "why": "defight left it: no candidate flickers clearly less (render 86 → 79 px)" + }, + "scifi-kit/FloorPlate/glTF-Binary/floor-plate.lod1.glb": { + "cm2": 1274.29, + "pairs": 22, + "px": 98, + "why": "a level of FloorPlate: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/FloorPlate/glTF-Binary/floor-plate.lod2.glb": { + "cm2": 1424.03, + "pairs": 19, + "px": 74, + "why": "a level of FloorPlate: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Lamp/glTF-Binary/lamp.glb": { + "cm2": 27.55, + "pairs": 32, + "px": 5, + "why": "defight left it: no candidate flickers clearly less (render 3 → 3 px)" + }, + "scifi-kit/PlantPod/glTF-Binary/plant-pod.glb": { + "cm2": 112.98, + "pairs": 9, + "px": 95, + "why": "defight left it: no candidate flickers clearly less (render 95 → 92 px)" + }, + "scifi-kit/Railing/glTF-Binary/railing.glb": { + "cm2": 2.13, + "pairs": 8, + "px": 13, + "why": "defight left it: no candidate flickers clearly less (render 13 → 14 px)" + }, + "scifi-kit/Ramp/glTF-Binary/ramp.glb": { + "cm2": 176, + "pairs": 4, + "px": 24, + "why": "defight left it: no candidate flickers clearly less (render 25 → 24 px)" + }, + "scifi-kit/ServerRack/glTF-Binary/server-rack.glb": { + "cm2": 7561.23, + "pairs": 43, + "px": 35, + "why": "defight left it: no candidate flickers clearly less (render 35 → 28 px)" + }, + "scifi-kit/SlidingDoor/glTF-Binary/sliding-door.glb": { + "cm2": 47.29, + "pairs": 27, + "px": 61, + "why": "defight left it: no candidate flickers clearly less (render 61 → 58 px)" + }, + "scifi-kit/SlidingDoor/glTF-Binary/sliding-door.lod1.glb": { + "cm2": 47.05, + "pairs": 22, + "px": 42, + "why": "a level of SlidingDoor: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/SlidingDoor/glTF-Binary/sliding-door.lod2.glb": { + "cm2": 36.87, + "pairs": 18, + "px": 40, + "why": "a level of SlidingDoor: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Stairs/glTF-Binary/stairs.lod2.glb": { + "cm2": 6.21, + "pairs": 8, + "px": 245, + "why": "a level of Stairs: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/TeleportPad/glTF-Binary/teleport-pad.lod2.glb": { + "cm2": 3.62, + "pairs": 3, + "px": 12, + "why": "a level of TeleportPad: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "scifi-kit/Vent/glTF-Binary/vent.glb": { + "cm2": 578.44, + "pairs": 75, + "px": 21, + "why": "defight left it: no candidate flickers clearly less (render 21 → 21 px)" + }, + "town-kit/ClockTowerTop/glTF-Binary/clock-tower-top.lod2.glb": { + "cm2": 1614.25, + "pairs": 1, + "px": 34, + "why": "a level of ClockTowerTop: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + }, + "town-kit/MarketStall/glTF-Binary/market-stall.lod2.glb": { + "cm2": 26.39, + "pairs": 1, + "px": 53, + "why": "a level of MarketStall: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)" + } + } +} diff --git a/tools/kit-build/kit-build.mjs b/tools/kit-build/kit-build.mjs new file mode 100644 index 0000000..91a9f29 --- /dev/null +++ b/tools/kit-build/kit-build.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// kit-build — THE pack build tool (roadmap 34 E2). One command line for every step a pack goes +// through after its recipe made the GLBs; the per-kit forks of these steps are gone (see README.md). +// +// node tools/kit-build/kit-build.mjs … (deps: cd tools/meshy && npm ci) +// +// build [--only A,B] [--no-thumbs] run the pack's recipe, then lod + thumbs + report +// post '' the one post step on one Meshy raw (lib/post.mjs) +// pass [--write] decimate to budget, texture cap, emissive policy on +// the SHIPPED GLBs (dry run unless --write; a compliant +// file is left byte-identical) +// lod defight (judged) + LOD1/LOD2 + --check (tools/lod) +// thumbs [--only A,B] render each row's screenshot (512², tools/meshy) +// flicker [--record] static z-fight probe; --record renders every file over +// the limit (headless) and writes flicker-baseline.json +// report [pack…] [--md file] [--json file] the budget report (tris vs budget, MiB, textures, LODs) +// check [pack…] [--json file] [--summary file] the CI: exit 1 on any error not in allow.json +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { REPO } from './lib/deps.mjs'; + +const [cmd, ...rest] = process.argv.slice(2); +const flag = (/** @type {string} */ f) => rest.includes(f); +const opt = (/** @type {string} */ f) => (rest.includes(f) ? rest[rest.indexOf(f) + 1] : null); +const positional = () => rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--md', '--json', '--summary', '--only'].includes(rest[i - 1]))); + +/** where each pack's recipe lives (what makes its GLBs from Meshy raws / procedural code) */ +export const RECIPES = { + 'architecture-kit': ['tools/architecture-kit/build.mjs', '--no-thumbs'], + 'scifi-kit': ['tools/scifi-kit/build.mjs', '--no-thumbs'], + 'town-kit': ['tools/town-kit/build.mjs', '--no-thumbs'], + 'interactive-kit': ['tools/interactive-kit/build.mjs'], + 'nature-kit': ['nature-kit/build/finalize.mjs', '--no-thumbs'], + 'props-kit': ['props-kit/_src/build.mjs', '--no-shots'], + 'interior-kit': ['interior-kit/_src/build.mjs', '--no-shots'] +}; + +const node = (/** @type {string[]} */ args) => execFileSync(process.execPath, args, { cwd: REPO, stdio: 'inherit' }); + +async function main() { + switch (cmd) { + case 'check': { + const { runChecks } = await import('./lib/check.mjs'); + const { printFindings, summaryMarkdown } = await import('./lib/report.mjs'); + const packs = positional(); + const t0 = Date.now(); + const r = await runChecks(REPO, { packs: packs.length ? packs : null }); + const errors = printFindings(r.findings); + console.log(`\n${r.items.length} items checked in ${((Date.now() - t0) / 1000).toFixed(1)} s`); + if (opt('--json')) fs.writeFileSync(/** @type {string} */ (opt('--json')), JSON.stringify(r, null, 1) + '\n'); + if (opt('--summary')) fs.appendFileSync(/** @type {string} */ (opt('--summary')), summaryMarkdown(r)); + process.exitCode = errors ? 1 : 0; + return; + } + case 'report': { + const { runChecks } = await import('./lib/check.mjs'); + const { budgetMarkdown } = await import('./lib/report.mjs'); + const packs = positional(); + const r = await runChecks(REPO, { packs: packs.length ? packs : null }); + const md = budgetMarkdown(r); + if (opt('--md')) fs.writeFileSync(/** @type {string} */ (opt('--md')), md); + else process.stdout.write(md); + if (opt('--json')) fs.writeFileSync(/** @type {string} */ (opt('--json')), JSON.stringify(r.items, null, 1) + '\n'); + return; + } + case 'post': { + const [input, output, job] = rest; + if (!input || !output) throw new Error("usage: kit-build post ''"); + const { kitPost } = await import('./lib/post.mjs'); + console.log(JSON.stringify(await kitPost(input, output, JSON.parse(job || '{}')))); + return; + } + case 'pass': { + const { passPack } = await import('./lib/pass.mjs'); + const { loadConfig } = await import('./lib/check.mjs'); + const packs = flag('--all') ? Object.keys(loadConfig(REPO).packs.packs) : positional(); + if (!packs.length) throw new Error('usage: kit-build pass [--write]'); + let changed = 0; + for (const p of packs) changed += await passPack(REPO, p, { write: flag('--write') }); + console.log(`${changed} file(s) ${flag('--write') ? 'rewritten' : 'would change (dry run; --write to apply)'}`); + return; + } + case 'lod': { + const [pack] = positional(); + if (!pack) throw new Error('usage: kit-build lod '); + node(['tools/lod/defight-all.mjs', pack]); + node(['tools/lod/lod.mjs', pack]); + node(['tools/lod/lod.mjs', '--check', pack]); + return; + } + case 'thumbs': { + const [pack] = positional(); + if (!pack) throw new Error('usage: kit-build thumbs [--only A,B]'); + const { thumbsPack } = await import('./lib/pass.mjs'); + const only = opt('--only')?.split(',') ?? null; + console.log(`${await thumbsPack(REPO, pack, only)} thumbnail(s) rendered`); + return; + } + case 'flicker': { + const { flickerPacks } = await import('./lib/flicker.mjs'); + const { loadConfig } = await import('./lib/check.mjs'); + const packs = flag('--all') ? Object.keys(loadConfig(REPO).packs.packs) : positional(); + await flickerPacks(REPO, packs, { record: flag('--record') }); + return; + } + case 'build': { + const [pack] = positional(); + const recipe = RECIPES[/** @type {keyof RECIPES} */ (pack)]; + if (!recipe) throw new Error(`kit-build build: no recipe for "${pack}" (have: ${Object.keys(RECIPES).join(', ')})`); + const only = opt('--only'); + node([...recipe, ...(only ? ['--only', only] : [])]); + const { passPack, thumbsPack } = await import('./lib/pass.mjs'); + await passPack(REPO, pack, { write: true }); + node(['tools/lod/defight-all.mjs', pack]); + node(['tools/lod/lod.mjs', pack]); + if (!flag('--no-thumbs')) await thumbsPack(REPO, pack, only?.split(',') ?? null); + node(['tools/kit-build/kit-build.mjs', 'check', pack]); + return; + } + default: + console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); + process.exitCode = cmd ? 2 : 0; + } +} + +await main(); diff --git a/tools/kit-build/lib/check.mjs b/tools/kit-build/lib/check.mjs new file mode 100644 index 0000000..930c691 --- /dev/null +++ b/tools/kit-build/lib/check.mjs @@ -0,0 +1,243 @@ +// check: the pack CI (roadmap 34 E2) — every rule a packs PR must pass, over every pack in index.json. +// +// index index.json rows: shape, files exist +// manifest each pack's model list: row shape (incl. `lods`, `behavior`), names unique, files exist, +// no orphan GLBs +// size every GLB ≤ 5 MiB (the share cap: bigger never reaches a peer), warn > 2 MiB +// tris LOD0 triangles ≤ the item's category budget (packs.json) +// textures largest texture side ≤ the pack's cap +// emissive a pack whose policy is "none" has nothing that glows; an emissive map with a black +// factor is a wasted texture fetch (warn) +// scale the bbox is in metres (category's largest extent, never a speck) +// pivot the origin is where the item's pivot rule says (bottom-centre unless packs.json says) +// lods items over 2 000 triangles ship LOD files; each file exists, is coarser than LOD0, keeps +// LOD0's mesh-node names and clips, and its ratio is the measured one (± 0.05) +// behavior a behavior's clips exist in the GLB (and in every LOD file) +// flicker the static z-fight probe (tools/lod/coplanar.mjs: coplanar triangle pairs, both facings +// when double-sided) ≤ 1 cm² — or no worse than the file's recorded baseline +// (flicker-baseline.json, written by `kit-build flicker --record` after a RENDER probe) +// thumb the row's screenshot exists and decodes, ≥ 64 px +// +// A failure is an ERROR unless allow.json lists it (pack, item, check) with a reason; an allow-list +// entry that matched nothing is reported so it gets cleaned up. +import fs from 'node:fs'; +import path from 'node:path'; +import { load } from './deps.mjs'; +import { inspectGlb } from './inspect.mjs'; +import { indexRowProblems, itemRowProblems, behaviorClipProblems, pivotProblem, scaleProblem, safeRel } from './rules.mjs'; +import { probeFile } from '../../lod/coplanar.mjs'; + +const sharp = (await load('sharp')).default; +const isUrl = (/** @type {any} */ p) => typeof p === 'string' && /^https?:\/\//.test(p); +const KB = (/** @type {number} */ b) => `${(b / 1024 / 1024).toFixed(2)} MiB`; + +/** @param {string} repo */ +export function loadConfig(repo) { + const dir = path.join(repo, 'tools/kit-build'); + const read = (/** @type {string} */ f, /** @type {any} */ dflt) => (fs.existsSync(path.join(dir, f)) ? JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) : dflt); + return { packs: read('packs.json', null), allow: read('allow.json', { allow: [] }).allow, baseline: read('flicker-baseline.json', { files: {} }).files }; +} + +/** the item's resolved policy @param {any} cfg @param {string} pack @param {string} item */ +export function itemPolicy(cfg, pack, item) { + const p = cfg.packs.packs[pack]; + if (!p) return null; + const o = p.items?.[item] ?? {}; + const category = o.category ?? p.category; + const cat = cfg.packs.categories[category]; + if (!cat) throw new Error(`packs.json: ${pack}/${item} has unknown category "${category}"`); + return { category, budget: cat.tris, maxExtent: cat.maxExtent, pivot: o.pivot ?? p.pivot ?? 'bottom-center', textureCap: o.textureCap ?? p.textureCap, emissive: p.emissive }; +} + +/** + * @param {string} repo the packs checkout + * @param {{packs?: string[] | null, flicker?: boolean}} [o] + */ +export async function runChecks(repo, o = {}) { + const cfg = loadConfig(repo); + const L = cfg.packs.limits; + /** @type {{level: 'error'|'warn', pack: string, item: string, check: string, msg: string, file?: string, allowed?: string}[]} */ + const findings = []; + /** @type {any[]} */ + const items = []; + const add = (/** @type {'error'|'warn'} */ level, /** @type {string} */ pack, /** @type {string} */ item, /** @type {string} */ check, /** @type {string} */ msg, /** @type {string} */ file = '') => + findings.push({ level, pack, item, check, msg, ...(file ? { file } : {}) }); + + const index = JSON.parse(fs.readFileSync(path.join(repo, 'index.json'), 'utf8')); + if (!Array.isArray(index)) add('error', '-', '-', 'index', 'index.json is not an array'); + const seenPacks = new Set(); + /** baseline entries a file over the limit leaned on */ + const baselineUsed = new Set(); + for (const row of index) { + const name = row?.name ?? '?'; + for (const m of indexRowProblems(row)) add('error', name, '-', 'index', m); + if (seenPacks.has(name)) add('error', name, '-', 'index', 'duplicate pack name'); + seenPacks.add(name); + for (const k of ['value', 'attribution', 'cover']) if (typeof row[k] === 'string' && !isUrl(row[k]) && !fs.existsSync(path.join(repo, row[k]))) add('error', name, '-', 'index', `${k} ${row[k]} does not exist`); + if (typeof row.zip === 'string' && !fs.existsSync(path.join(repo, row.zip.replace(/^\//, '')))) add('error', name, '-', 'index', `zip ${row.zip} does not exist`); + if (row.value && !cfg.packs.packs[name]) add('error', name, '-', 'index', 'pack has no policy in tools/kit-build/packs.json (texture cap, emissive, category, pivot)'); + } + + for (const row of index) { + if (!row?.value || isUrl(row.value)) continue; + const pack = row.name; + if (o.packs && !o.packs.includes(pack)) continue; + const listFile = path.join(repo, row.value); + if (!fs.existsSync(listFile)) continue; + /** @type {any[]} */ + let rows; + try { + rows = JSON.parse(fs.readFileSync(listFile, 'utf8')); + } catch (e) { + add('error', pack, '-', 'manifest', `${row.value} is not JSON: ${/** @type {Error} */ (e).message}`); + continue; + } + if (!Array.isArray(rows)) { + add('error', pack, '-', 'manifest', `${row.value} is not an array`); + continue; + } + const base = path.dirname(listFile); + const referenced = new Set(); + const names = new Set(); + for (const r of rows) { + const item = r?.name ?? '?'; + for (const m of itemRowProblems(r)) add('error', pack, item, 'manifest', m); + if (names.has(item)) add('error', pack, item, 'manifest', 'duplicate item name'); + names.add(item); + const glbRef = r?.variants?.['glTF-Binary']; + if (typeof glbRef !== 'string' || isUrl(glbRef) || !safeRel(glbRef)) continue; // upstream-fetch rows (khronos) carry no bytes here + const dir = path.join(base, item); + const glb = path.join(dir, 'glTF-Binary', glbRef); + referenced.add(path.resolve(glb)); + if (!fs.existsSync(glb)) { + add('error', pack, item, 'manifest', `glTF-Binary/${glbRef} does not exist`); + continue; + } + const pol = itemPolicy(cfg, pack, item); + const info = await inspectGlb(glb); + const rec = { ...info, pack, item, file: path.relative(repo, glb), category: pol?.category, budget: pol?.budget, lods: [] }; + items.push(rec); + // size + const sizeCheck = (/** @type {string} */ f, /** @type {number} */ bytes) => { + if (bytes > L.glbFailBytes) add('error', pack, item, 'size', `${path.basename(f)} is ${KB(bytes)} — over the 5 MiB share cap (it never reaches a peer)`, f); + else if (bytes > L.glbWarnBytes) add('warn', pack, item, 'size', `${path.basename(f)} is ${KB(bytes)} (> 2 MiB)`, f); + }; + sizeCheck(rec.file, info.bytes); + if (pol) { + if (info.tris > pol.budget) add('error', pack, item, 'tris', `${info.tris} triangles is over the ${pol.category} budget of ${pol.budget} — decimate (kit-build pass --write) or re-categorise`); + const big = Math.max(0, ...info.textures.map((t) => Math.max(...(t.size ?? [0])))); + if (big > pol.textureCap) add('error', pack, item, 'textures', `a ${big}² texture is over the pack's ${pol.textureCap} cap (kit-build pass --write)`); + if (pol.emissive === 'none') { + const lit = info.materials.filter((m) => m.emissive || m.emissiveTexture).map((m) => m.name || '(unnamed)'); + if (lit.length) add('error', pack, item, 'emissive', `the pack's policy is "nothing glows" but ${lit.join(', ')} ${lit.length > 1 ? 'are' : 'is'} emissive (kit-build pass --write)`); + } + const s = scaleProblem(info.size, pol.maxExtent, L.minExtent); + if (s) add('error', pack, item, 'scale', s); + const pv = pivotProblem(info.min, info.max, pol.pivot); + if (pv) add('error', pack, item, 'pivot', `${pv}; rule "${pol.pivot}"`); + } + for (const m of info.materials) if (m.emissiveTexture && !m.emissive) add('warn', pack, item, 'emissive', `material ${m.name || '(unnamed)'} has an emissive map with a black factor (a wasted texture)`); + // lods + const lods = Array.isArray(r.lods) ? r.lods : []; + if (!lods.length && info.tris > L.lodRequiredOver) add('error', pack, item, 'lods', `${info.tris} triangles and no LOD files (node tools/kit-build/kit-build.mjs lod ${pack})`); + for (const l of lods) { + if (!safeRel(l?.file)) continue; + const lf = path.join(dir, 'glTF-Binary', l.file); + referenced.add(path.resolve(lf)); + if (!fs.existsSync(lf)) { + add('error', pack, item, 'lods', `${l.file} does not exist`); + continue; + } + const li = await inspectGlb(lf); + rec.lods.push({ file: path.relative(repo, lf), bytes: li.bytes, tris: li.tris, ratio: l.ratio }); + sizeCheck(path.relative(repo, lf), li.bytes); + if (li.tris >= info.tris) add('error', pack, item, 'lods', `${l.file} has ${li.tris} triangles, not fewer than LOD0's ${info.tris}`); + const measured = info.tris ? li.tris / info.tris : 0; + if (typeof l.ratio === 'number' && Math.abs(measured - l.ratio) > 0.05) add('error', pack, item, 'lods', `${l.file}: ratio ${l.ratio} but the file has ${(measured * 100).toFixed(0)} % of LOD0's triangles`); + const lod0Names = new Set(info.meshNodes); + const strangers = li.meshNodes.filter((n) => !lod0Names.has(n)); + if (strangers.length && li.meshNodes.length !== info.meshNodes.length) add('error', pack, item, 'lods', `${l.file} has mesh nodes LOD0 does not (${strangers.slice(0, 4).join(', ')}) — core swaps levels BY NODE NAME`); + const missing = info.animations.filter((a) => !li.animations.includes(a)); + if (missing.length) add('error', pack, item, 'lods', `${l.file} lacks LOD0's clips ${missing.join(', ')} (an animated item keeps animating at every level)`); + if (r.behavior) for (const m of behaviorClipProblems(r.behavior, li.animations)) add('error', pack, item, 'behavior', `${l.file}: ${m}`); + if (o.flicker !== false) await flicker(path.relative(repo, lf)); + } + if (r.behavior) for (const m of behaviorClipProblems(r.behavior, info.animations)) add('error', pack, item, 'behavior', m); + // thumb + const shot = r.screenshot; + if (!isUrl(shot)) { + const cands = shot ? [shot] : ['thumb.webp', 'thumb.png']; + const found = cands.map((c) => path.join(dir, c)).find((f) => fs.existsSync(f)); + if (!found) add('error', pack, item, 'thumb', `no thumbnail (${cands.join(' / ')}) — node tools/kit-build/kit-build.mjs thumbs ${pack} --only ${item}`); + else { + try { + const meta = await sharp(found).metadata(); + if (Math.min(meta.width ?? 0, meta.height ?? 0) < L.thumbMinPx) add('error', pack, item, 'thumb', `${path.relative(dir, found)} is ${meta.width}×${meta.height} (< ${L.thumbMinPx} px)`); + } catch (e) { + add('error', pack, item, 'thumb', `${path.relative(dir, found)} does not decode: ${/** @type {Error} */ (e).message}`); + } + } + } + if (o.flicker !== false) await flicker(rec.file); + + /** the static z-fight probe against the 1 cm² limit or the recorded baseline @param {string} f */ + async function flicker(f) { + const pr = await probeFile(path.join(repo, f)); + const cm2 = pr.area * 1e4; + const bl = cfg.baseline[f]; + const ref = f === rec.file ? rec : rec.lods.find((/** @type {any} */ x) => x.file === f); + if (ref) Object.assign(ref, { flickerPairs: pr.pairs, flickerCm2: +cm2.toFixed(2) }); + if (cm2 <= L.flickerCm2) return; + if (bl) baselineUsed.add(f); + if (bl && cm2 <= bl.cm2 * 1.02 + 0.05) return; + add( + 'error', + pack, + item, + 'flicker', + bl + ? `${path.basename(f)}: ${pr.pairs} coplanar pairs, ${cm2.toFixed(1)} cm² — WORSE than its baseline ${bl.cm2} cm²` + : `${path.basename(f)}: ${pr.pairs} coplanar pairs, ${cm2.toFixed(1)} cm² of overlap (limit ${L.flickerCm2} cm²) — node tools/lod/defight-all.mjs ${pack}, then kit-build flicker ${pack} --record if the render probe passes`, + f + ); + } + } + // orphans: GLBs under the pack folder that no row references (shipped bytes nobody loads) + for (const f of walk(base)) if (/\.glb$/i.test(f) && !referenced.has(path.resolve(f))) add('warn', pack, '-', 'manifest', `${path.relative(repo, f)} is not referenced by ${row.value}`, path.relative(repo, f)); + } + + // baseline entries nothing needs any more (the file is gone, or now under the limit) + if (o.flicker !== false) + for (const f of Object.keys(cfg.baseline)) { + const pack = f.split('/')[0]; + if ((!o.packs || o.packs.includes(pack)) && !baselineUsed.has(f)) add('warn', pack, f.split('/')[1] ?? '-', 'flicker', `${f} is in flicker-baseline.json but is gone or now under the limit — re-record: kit-build flicker ${pack} --record`, f); + } + + // the allow-list + const used = new Set(); + for (const f of findings) { + if (f.level !== 'error') continue; + const hit = cfg.allow.findIndex((/** @type {any} */ a) => a.pack === f.pack && (a.item === f.item || a.item === '*') && a.check === f.check && (!a.file || a.file === f.file)); + if (hit >= 0) { + f.allowed = cfg.allow[hit].reason; + used.add(hit); + } + } + cfg.allow.forEach((/** @type {any} */ a, /** @type {number} */ i) => { + if (!used.has(i) && (!o.packs || o.packs.includes(a.pack))) add('warn', a.pack, a.item, 'allow', `allow-list entry for "${a.check}" matched nothing — remove it from tools/kit-build/allow.json`); + }); + return { findings, items }; +} + +/** @param {string} dir @returns {string[]} */ +function walk(dir) { + /** @type {string[]} */ + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '_src' || e.name === 'build') continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...walk(p)); + else out.push(p); + } + return out; +} diff --git a/tools/kit-build/lib/deps.mjs b/tools/kit-build/lib/deps.mjs new file mode 100644 index 0000000..5d31fb5 --- /dev/null +++ b/tools/kit-build/lib/deps.mjs @@ -0,0 +1,26 @@ +// The one dependency set every pack tool uses: tools/meshy's node_modules (`cd tools/meshy && npm ci`). +// MESHY_TOOLS overrides the folder (a lane that keeps its tools checkout elsewhere). +// +// ONE MODULE INSTANCE: gltf-transform checks `instanceof` (prune, flatten, …), so a Document must be +// read and transformed through the SAME build of it. Every pack tool (kit-build, tools/lod, the kit +// builds) loads the CommonJS build through `load` here; meshy-post (tools/meshy/lib/post.js) uses the +// ES build internally and is only ever handed FILE paths. +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const REPO = path.resolve(HERE, '../../..'); +export const TOOLS = process.env.MESHY_TOOLS || path.join(REPO, 'tools/meshy'); +const req = createRequire(path.join(TOOLS, 'package.json')); +/** a package from tools/meshy's node_modules @param {string} id @returns {Promise} */ +export const load = async (id) => import(pathToFileURL(req.resolve(id)).href); +/** import a file of tools/meshy (lib/post.js, lib/thumb.js) @param {string} rel */ +export const tool = async (rel) => import(pathToFileURL(path.join(TOOLS, rel)).href); + +const { NodeIO, Logger } = await load('@gltf-transform/core'); +const { ALL_EXTENSIONS } = await load('@gltf-transform/extensions'); +/** a NodeIO with every extension, quiet (stdout carries reports) */ +export function makeIO() { + return new NodeIO().registerExtensions(ALL_EXTENSIONS).setLogger(new Logger(Logger.Verbosity.ERROR)); +} diff --git a/tools/kit-build/lib/flicker.mjs b/tools/kit-build/lib/flicker.mjs new file mode 100644 index 0000000..0e3ff32 --- /dev/null +++ b/tools/kit-build/lib/flicker.mjs @@ -0,0 +1,94 @@ +// flicker: the z-fight gate's two probes. +// +// STATIC (CI, every PR): tools/lod/coplanar.mjs — triangle pairs in one plane (facing within 1°, +// corners within 1 mm; both facings when the material is double-sided) overlapping by > 1 mm². +// Deterministic, no GPU, ~15 s for every pack. But it OVER-counts: a coplanar pair hidden inside a +// piece, or one whose two layers carry the same texels, never shows. 33-pack-fix-lod therefore +// judged every file by a RENDER: defight rewrites a file only when the render flickers clearly less. +// +// RENDER (local, headless Chromium — tools/lod/judge.mjs `flicker`): 12 orbit poses, each a +// micro-triplet (±0.02°); a pixel flickers when the middle frame disagrees with both neighbours while +// they agree — the in-app probe's metric, offline. +// +// `kit-build flicker --record` renders every file over the static limit and records it in +// flicker-baseline.json {file: {cm2, pairs, px, why}} — refused (not recorded, exit 1) when the render +// flickers more than LIMIT_PX. The CI then lets such a file through at its recorded area, never worse. +import fs from 'node:fs'; +import path from 'node:path'; +import { probeFile } from '../../lod/coplanar.mjs'; +import { loadConfig } from './check.mjs'; +import { packRows } from './pass.mjs'; + +/** the render flicker a recorded file may have at most (512², 12 poses — see the header) */ +export const LIMIT_PX = 400; + +/** + * @param {string} repo @param {string[]} packs @param {{record?: boolean}} [o] + */ +export async function flickerPacks(repo, packs, o = {}) { + const cfg = loadConfig(repo); + const limit = cfg.packs.limits.flickerCm2; + const basePath = path.join(repo, 'tools/kit-build/flicker-baseline.json'); + const doc = fs.existsSync(basePath) ? JSON.parse(fs.readFileSync(basePath, 'utf8')) : { files: {} }; + const defight = fs.existsSync(path.join(repo, 'tools/lod/defight-report.json')) ? JSON.parse(fs.readFileSync(path.join(repo, 'tools/lod/defight-report.json'), 'utf8')) : {}; + /** @type {any} */ + let judge = null; + let refused = 0; + try { + for (const pack of packs) { + for (const { row, glb } of packRows(repo, pack)) { + const files = [glb, ...(Array.isArray(row.lods) ? row.lods.map((/** @type {any} */ l) => path.join(path.dirname(glb), l.file)) : [])]; + for (const f of files) { + if (!fs.existsSync(f)) continue; + const rel = path.relative(repo, f); + const pr = await probeFile(f); + const cm2 = +(pr.area * 1e4).toFixed(2); + if (cm2 <= limit) { + if (o.record && doc.files[rel]) delete doc.files[rel]; + continue; + } + if (!o.record) { + const bl = doc.files[rel]; + console.log(`${bl ? (cm2 <= bl.cm2 * 1.02 + 0.05 ? 'baseline' : 'WORSE ') : 'OVER '} ${rel}: ${pr.pairs} pairs, ${cm2} cm²${bl ? ` (recorded ${bl.cm2} cm², ${bl.px} px)` : ''}`); + continue; + } + if (!judge) { + const { Judge } = await import('../../lod/judge.mjs'); + judge = new Judge(); + await judge.open(); + } + const px = await judge.flicker(fs.readFileSync(f)); + const isLod0 = f === glb; + const d = defight[`${pack}/${row.name}`]; + const why = isLod0 + ? d?.left + ? `defight left it: ${d.left}${d.flicker ? ` (render ${d.flicker[0]} → ${d.flicker[1]} px)` : ''}` + : d?.mode + ? `defight (${d.mode}) settled the visible layers; the rest are hidden` + : 'not in defight-report.json (built before tools/lod or outside it)' + : `a level of ${row.name}: tools/lod simplifies LOD0 per primitive and only drops hidden duplicates (core draws it far away, with LOD0's material)`; + if (px > LIMIT_PX) { + refused++; + console.log(`REFUSED ${rel}: ${pr.pairs} pairs, ${cm2} cm², render ${px} px > ${LIMIT_PX} — fix it (tools/lod/defight-all.mjs ${pack})`); + continue; + } + doc.files[rel] = { cm2, pairs: pr.pairs, px, why }; + console.log(`recorded ${rel}: ${pr.pairs} pairs, ${cm2} cm², render ${px} px`); + } + } + } + } finally { + await judge?.close(); + } + if (o.record) { + const files = Object.fromEntries(Object.entries(doc.files).sort(([a], [b]) => a.localeCompare(b))); + const out = { + note: `The static z-fight probe's accepted files (kit-build check: a file over ${limit} cm² of coplanar overlap passes only at or under its recorded area). Each was RENDERED (tools/lod/judge.mjs flicker: 512², 12 orbit poses) and flickers at most ${LIMIT_PX} px; px = that measurement. Written by: node tools/kit-build/kit-build.mjs flicker --record`, + limitPx: LIMIT_PX, + files + }; + fs.writeFileSync(basePath, JSON.stringify(out, null, 1) + '\n'); + console.log(`${Object.keys(files).length} file(s) in flicker-baseline.json${refused ? `, ${refused} REFUSED` : ''}`); + } + if (refused) process.exitCode = 1; +} diff --git a/tools/kit-build/lib/inspect.mjs b/tools/kit-build/lib/inspect.mjs new file mode 100644 index 0000000..be1321b --- /dev/null +++ b/tools/kit-build/lib/inspect.mjs @@ -0,0 +1,67 @@ +// inspect: what a shipped GLB IS, measured — the numbers the budget report and the CI checks read. +import fs from 'node:fs'; +import { load, makeIO } from './deps.mjs'; + +const { getBounds } = await load('@gltf-transform/functions'); +const io = makeIO(); + +/** triangles DRAWN: per node that references a mesh (a kitbash instances one mesh several times) @param {any} doc */ +export function drawnTris(doc) { + let n = 0; + for (const node of doc.getRoot().listNodes()) { + const mesh = node.getMesh(); + if (!mesh) continue; + for (const p of mesh.listPrimitives()) { + if (p.getMode() !== 4) continue; + const idx = p.getIndices(); + n += (idx ? idx.getCount() : (p.getAttribute('POSITION')?.getCount() ?? 0)) / 3; + } + } + return Math.round(n); +} + +/** @param {any} doc */ +export function sceneOf(doc) { + return doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; +} + +/** + * @param {string} file + * @returns {Promise<{file: string, bytes: number, tris: number, min: number[], max: number[], size: number[], + * nodes: number, meshNodes: string[], meshes: number, prims: number, materials: {name: string, doubleSided: boolean, + * emissive: boolean, emissiveTexture: boolean}[], textures: {size: number[] | null, mime: string, bytes: number}[], + * animations: string[], extras: any}>} + */ +export async function inspectGlb(file) { + const doc = await io.read(file); + return { file, bytes: fs.statSync(file).size, ...inspectDoc(doc) }; +} + +/** @param {any} doc */ +export function inspectDoc(doc) { + const root = doc.getRoot(); + const scene = sceneOf(doc); + const b = scene ? getBounds(scene) : { min: [0, 0, 0], max: [0, 0, 0] }; + const finite = b.min.every(Number.isFinite) && b.max.every(Number.isFinite); + const min = finite ? b.min.map((v) => +v.toFixed(4)) : [0, 0, 0]; + const max = finite ? b.max.map((v) => +v.toFixed(4)) : [0, 0, 0]; + return { + tris: drawnTris(doc), + min, + max, + size: [0, 1, 2].map((i) => +(max[i] - min[i]).toFixed(4)), + nodes: root.listNodes().length, + meshNodes: root.listNodes().filter((n) => n.getMesh()).map((n) => n.getName()), + meshes: root.listMeshes().length, + prims: root.listMeshes().reduce((s, m) => s + m.listPrimitives().length, 0), + materials: root.listMaterials().map((m) => ({ + name: m.getName(), + doubleSided: m.getDoubleSided(), + emissive: m.getEmissiveFactor().some((v) => v > 0), + emissiveTexture: !!m.getEmissiveTexture() + })), + textures: root.listTextures().map((t) => ({ size: t.getSize(), mime: t.getMimeType(), bytes: t.getImage()?.byteLength ?? 0 })), + animations: root.listAnimations().map((a) => a.getName()), + extras: scene?.getExtras() ?? {} + }; +} diff --git a/tools/kit-build/lib/pass.mjs b/tools/kit-build/lib/pass.mjs new file mode 100644 index 0000000..c5bf80f --- /dev/null +++ b/tools/kit-build/lib/pass.mjs @@ -0,0 +1,147 @@ +// pass: the pack-level steps on SHIPPED GLBs (LOD0 of every row), driven by packs.json — +// 1. emissive policy a pack whose policy is "none" keeps nothing that glows +// 2. texture cap any texture over the pack's cap is resized to it (same format) +// 3. share cap a GLB still over 5 MiB re-encodes its PNG textures as JPEG, largest first, +// when every material using one is OPAQUE (the alpha channel is never read) +// 4. decimate LOD0 over its category's triangle budget is simplified to it (meshoptimizer, +// node names and hierarchy kept) — then `kit-build lod ` rebuilds levels +// A file that needs none of it is never rewritten (byte-identical). And thumbnails. +import fs from 'node:fs'; +import path from 'node:path'; +import { load, tool, makeIO } from './deps.mjs'; +import { drawnTris } from './inspect.mjs'; +import { loadConfig, itemPolicy } from './check.mjs'; + +const { simplify, weld } = await load('@gltf-transform/functions'); +const { MeshoptSimplifier } = await load('meshoptimizer'); +const sharp = (await load('sharp')).default; +const io = makeIO(); +const isUrl = (/** @type {any} */ p) => typeof p === 'string' && /^https?:\/\//.test(p); + +/** the local rows of a pack: [{row, dir, glb}] @param {string} repo @param {string} pack */ +export function packRows(repo, pack) { + const index = JSON.parse(fs.readFileSync(path.join(repo, 'index.json'), 'utf8')); + const entry = index.find((/** @type {any} */ r) => r.name === pack); + if (!entry?.value || isUrl(entry.value)) return []; + const listFile = path.join(repo, entry.value); + const base = path.dirname(listFile); + return JSON.parse(fs.readFileSync(listFile, 'utf8')) + .filter((/** @type {any} */ r) => typeof r?.variants?.['glTF-Binary'] === 'string' && !isUrl(r.variants['glTF-Binary'])) + .map((/** @type {any} */ row) => ({ row, dir: path.join(base, row.name), glb: path.join(base, row.name, 'glTF-Binary', row.variants['glTF-Binary']) })); +} + +/** the materials that use a texture @param {any} tex */ +const usersOf = (tex) => tex.listParents().filter((/** @type {any} */ p) => p.propertyType === 'Material'); + +/** emissive policy "none": drop every emissive map and factor @param {any} doc */ +export function dropAllEmissive(doc) { + let n = 0; + for (const mat of doc.getRoot().listMaterials()) { + if (!mat.getEmissiveTexture() && !mat.getEmissiveFactor().some((/** @type {number} */ v) => v > 0)) continue; + mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); + n++; + } + return n; +} + +/** resize every texture over `cap` (largest side) to fit it, keeping its format @param {any} doc @param {number} cap */ +export async function capTextures(doc, cap) { + let n = 0; + for (const tex of doc.getRoot().listTextures()) { + const size = tex.getSize(); + if (!size || Math.max(...size) <= cap) continue; + const img = sharp(Buffer.from(tex.getImage())).resize(cap, cap, { fit: 'inside' }); + const png = tex.getMimeType() === 'image/png'; + tex.setImage(new Uint8Array(await (png ? img.png({ compressionLevel: 9 }) : img.jpeg({ quality: 86 })).toBuffer())).setMimeType(png ? 'image/png' : 'image/jpeg'); + n++; + } + return n; +} + +/** PNG → JPEG (q 86, the pipeline's quality), largest first, while the GLB is over `maxBytes`, for + * textures only OPAQUE materials use @param {any} doc @param {number} maxBytes */ +export async function fitShareCap(doc, maxBytes) { + let n = 0; + const pngs = doc + .getRoot() + .listTextures() + .filter((/** @type {any} */ t) => t.getMimeType() === 'image/png' && usersOf(t).every((/** @type {any} */ m) => m.getAlphaMode() === 'OPAQUE')) + .sort((/** @type {any} */ a, /** @type {any} */ b) => b.getImage().byteLength - a.getImage().byteLength); + for (const tex of pngs) { + if ((await io.writeBinary(doc)).byteLength <= maxBytes) break; + tex.setImage(new Uint8Array(await sharp(Buffer.from(tex.getImage())).flatten().jpeg({ quality: 86 }).toBuffer())).setMimeType('image/jpeg'); + if (tex.getURI()) tex.setURI(tex.getURI().replace(/\.png$/i, '.jpg')); + n++; + } + return n; +} + +/** simplify to `budget` drawn triangles (meshy-post's error ladder; nodes, names, clips kept) + * @param {any} doc @param {number} budget */ +export async function decimateTo(doc, budget) { + const before = drawnTris(doc); + if (before <= budget) return 0; + await MeshoptSimplifier.ready; + await doc.transform(weld()); + for (const error of [0.001, 0.005, 0.01, 0.02, 0.05]) { + const ratio = budget / drawnTris(doc); + if (ratio >= 1) break; + await doc.transform(simplify({ simplifier: MeshoptSimplifier, ratio, error, lockBorder: false })); + if (drawnTris(doc) <= budget) break; + } + return before - drawnTris(doc); +} + +/** + * @param {string} repo @param {string} pack @param {{write?: boolean}} [o] + * @returns {Promise} files that changed (or would) + */ +export async function passPack(repo, pack, o = {}) { + const cfg = loadConfig(repo); + let changed = 0; + let decimated = 0; + for (const { row, glb } of packRows(repo, pack)) { + if (!fs.existsSync(glb)) continue; + const pol = itemPolicy(cfg, pack, row.name); + if (!pol) continue; + const doc = await io.read(glb); + const did = []; + if (pol.emissive === 'none') { + const n = dropAllEmissive(doc); + if (n) did.push(`emissive dropped on ${n} material(s)`); + } + const capped = await capTextures(doc, pol.textureCap); + if (capped) did.push(`${capped} texture(s) resized to ${pol.textureCap}`); + const tris = await decimateTo(doc, pol.budget); + if (tris) { + did.push(`decimated by ${tris} triangles to the ${pol.category} budget ${pol.budget}`); + decimated++; + } + const fitted = await fitShareCap(doc, cfg.packs.limits.glbFailBytes); + if (fitted) did.push(`${fitted} opaque PNG texture(s) → JPEG to fit the 5 MiB share cap`); + if (!did.length) continue; + changed++; + const rel = path.relative(repo, glb); + if (o.write) { + const before = fs.statSync(glb).size; + await io.write(glb, doc); + console.log(`${rel}: ${did.join('; ')} (${before} → ${fs.statSync(glb).size} bytes)`); + } else console.log(`${rel}: would — ${did.join('; ')}`); + } + if (decimated) console.log(`${pack}: ${decimated} LOD0(s) decimated — run: node tools/kit-build/kit-build.mjs lod ${pack}`); + return changed; +} + +/** + * Render each row's screenshot (512², transparent, yaw 35° — tools/meshy's thumbnailer, the one + * every kit used) to the path the row names (thumb.webp when it names none). + * @param {string} repo @param {string} pack @param {string[] | null} only + */ +export async function thumbsPack(repo, pack, only) { + const { renderThumbs } = await tool('lib/thumb.js'); + const todo = packRows(repo, pack) + .filter(({ row, glb }) => (!only || only.includes(row.name)) && fs.existsSync(glb) && !isUrl(row.screenshot)) + .map(({ row, dir, glb }) => ({ glb, out: path.join(dir, row.screenshot ?? 'thumb.webp') })); + if (todo.length) await renderThumbs(todo, { size: 512, bg: null, yaw: 35 }); + return todo.length; +} diff --git a/tools/kit-build/lib/post.mjs b/tools/kit-build/lib/post.mjs new file mode 100644 index 0000000..ad4abe9 --- /dev/null +++ b/tools/kit-build/lib/post.mjs @@ -0,0 +1,535 @@ +// post: THE pack post step (roadmap 34 E2). Every kit used to carry its own fork of it — +// tools/architecture-kit, tools/scifi-kit and tools/town-kit had kit-post.mjs, nature-kit/build/finalize.mjs, +// props-kit/_src/build.mjs and interior-kit/_src/build.mjs their own grade / emissive / flat-for-sync +// helpers. They now all import from here; each function below is the fork's code unchanged (same +// sharp calls, same JPEG quality, same order), so a rebuild writes the same bytes. +// +// raw Meshy GLB → pre-rotate (Meshy often lays a floor tile or roof slab upright, and meshy-post +// only turns about Y) → meshy-post (weld, decimate to targetTris, stretch to the exact dims, pivot, +// textures to the cap as JPEG) → SEAM CLAMP → offset → EMISSIVE POLICY + albedo grade → recolor → +// glow mask → prune → defight → pack GLB. +// +// kitPost(input, output, job): job = meshy-post's job + { +// preRotate?: {x?, y?, z?} degrees, clamp?: "x" | "xz" | "xyz" | "x-y" …, eps?: metres (default 0.04), +// offset?: [x, y, z] metres applied last, albedo?: {saturation, brightness, hue} grade of the base colour, +// dropEmissive?: false to keep it (THE EMISSIVE POLICY: by default nothing a post step builds glows unless +// the job says so — `glow` below, or dropEmissive: false), +// glow?: {hue: [lo, hi], sat, val, strength} — light the albedo's own light strips (glowMask), +// recolor?: {hue: [lo, hi], sat, val, shift, minSat} — grade that hue band only (recolor), +// defight?: false to skip the z-fight pass (town-kit runs it itself, with a judge) } +// `clamp` lists axes; a trailing "-y" etc. means "the MIN face of y only" (a floor's bottom). +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { load, tool, makeIO, TOOLS } from './deps.mjs'; +import { defightFile } from '../../lod/defight.mjs'; + +export { TOOLS }; +const { KHRMaterialsEmissiveStrength } = await load('@gltf-transform/extensions'); +const { getBounds, transformMesh, prune, flatten, clearNodeTransform, compactPrimitive } = await load('@gltf-transform/functions'); +const { postProcess } = await tool('lib/post.js'); +const sharp = (await load('sharp')).default; + +const io = makeIO(); +const AX = { x: 0, y: 1, z: 2 }; + +/** column-major 4x4 rotation about one axis @param {'x'|'y'|'z'} axis @param {number} deg */ +export function rot(axis, deg) { + const a = (deg * Math.PI) / 180; + const c = Math.cos(a); + const s = Math.sin(a); + if (axis === 'x') return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; + if (axis === 'y') return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; + return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +} + +/** Parse "xz" / "x-y" / "+y" into [{axis, side}] (side: -1 min, +1 max, 0 both). @param {string} spec */ +export function parseClamp(spec) { + const out = []; + let sign = 0; + for (const ch of spec || '') { + if (ch === '-') sign = -1; + else if (ch === '+') sign = 1; + else if (ch in AX) { + out.push({ axis: AX[/** @type {'x'|'y'|'z'} */ (ch)], side: sign }); + sign = 0; + } + } + return out; +} + +/** + * The seam clamp is what makes pieces snap without gaps: Meshy's ends are never quite planar (a + * stone sticks out 2 cm, the next one is 3 cm short), so after the stretch every vertex within + * `eps` of a bbox face named in `clamp` is moved ONTO that face. Two walls placed end to end then + * share one exact plane at their joint. Returns how many vertices moved. + * @param {any} doc @param {string} spec @param {number} eps + */ +export function seamClamp(doc, spec, eps = 0.04) { + const faces = parseClamp(spec); + if (!faces.length) return 0; + const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; + const b = getBounds(scene); + let moved = 0; + const seen = new Set(); + for (const mesh of doc.getRoot().listMeshes()) { + for (const prim of mesh.listPrimitives()) { + const pos = prim.getAttribute('POSITION'); + if (!pos || seen.has(pos)) continue; + seen.add(pos); + const v = [0, 0, 0]; + for (let i = 0; i < pos.getCount(); i++) { + pos.getElement(i, v); + let changed = false; + for (const { axis, side } of faces) { + if (side <= 0 && v[axis] - b.min[axis] < eps && v[axis] !== b.min[axis]) { + v[axis] = b.min[axis]; + changed = true; + } + if (side >= 0 && b.max[axis] - v[axis] < eps && v[axis] !== b.max[axis]) { + v[axis] = b.max[axis]; + changed = true; + } + } + if (changed) { + pos.setElement(i, v); + moved++; + } + } + } + } + return moved; +} + +/** rgb (0-1) → [h°, s, v] */ +export function hsv(/** @type {number} */ r, /** @type {number} */ g, /** @type {number} */ b) { + const max = Math.max(r, g, b); + const d = max - Math.min(r, g, b); + const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; + return [h, max ? d / max : 0, max]; +} + +/** + * Sci-fi pieces carry their own lights (a door's light stripes, a pad's rings, a screen): Meshy + * paints them into the albedo as bright teal but ships no usable emissive map. The mask keeps every + * albedo pixel whose hue is in [lo, hi]° with saturation ≥ sat and value ≥ val (0-1), black + * elsewhere, and becomes the emissive texture — so exactly the painted lights glow, never the teal + * PAINT (darker) or the white panels (unsaturated). Returns the fraction of pixels lit (in %), 0 + * when the material has no albedo. + * @param {any} doc @param {any} mat @param {{hue: number[], sat?: number, val?: number, strength?: number}} o + */ +export async function glowMask(doc, mat, o) { + const tex = mat.getBaseColorTexture(); + if (!tex) return 0; + const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + const out = Buffer.alloc(data.length); + let lit = 0; + for (let i = 0; i < data.length; i += 3) { + const r = data[i] / 255; + const g = data[i + 1] / 255; + const b = data[i + 2] / 255; + const max = Math.max(r, g, b); + const d = max - Math.min(r, g, b); + const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; + if (max >= (o.val ?? 0.55) && d / (max || 1) >= (o.sat ?? 0.35) && h >= o.hue[0] && h <= o.hue[1]) { + out[i] = data[i]; + out[i + 1] = data[i + 1]; + out[i + 2] = data[i + 2]; + lit++; + } + } + const img = await sharp(out, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer(); + const em = doc.createTexture(`${mat.getName() || 'mat'}_glow`).setImage(new Uint8Array(img)).setMimeType('image/jpeg'); + mat.setEmissiveTexture(em).setEmissiveFactor([1, 1, 1]); + if (o.strength && o.strength !== 1) { + const ext = doc.createExtension(KHRMaterialsEmissiveStrength); + mat.setExtension('KHR_materials_emissive_strength', ext.createEmissiveStrength().setEmissiveStrength(o.strength)); + } + return +((100 * lit) / (info.width * info.height)).toFixed(2); +} + +/** + * A hue-selective grade of the albedo: pixels whose hue is in [lo, hi]° (a band with lo > hi wraps + * through 0°, reds: [340, 15]; saturation ≥ minSat, default 0.12, so greys are left alone) get their + * saturation × sat, value × val and hue + shift° — e.g. the wall's all-teal frame → warm gunmetal, + * keeping its painted shading. Returns % of pixels. + * @param {any} mat @param {{hue: number[], sat?: number, val?: number, shift?: number, minSat?: number}} o + */ +export async function recolor(mat, o) { + const tex = mat.getBaseColorTexture(); + if (!tex) return 0; + const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + let n = 0; + for (let i = 0; i < data.length; i += 3) { + const [h, s, v] = hsv(data[i] / 255, data[i + 1] / 255, data[i + 2] / 255); + const inBand = o.hue[0] <= o.hue[1] ? h >= o.hue[0] && h <= o.hue[1] : h >= o.hue[0] || h <= o.hue[1]; + if (s < (o.minSat ?? 0.12) || !inBand) continue; + const s2 = Math.min(1, s * (o.sat ?? 1)); + const v2 = Math.min(1, v * (o.val ?? 1)); + // back to rgb: keep the pixel's own channel ratios' hue, re-spread for s2/v2 + const h2 = (h + (o.shift ?? 0) + 360) % 360; + const c = v2 * s2; + const x = c * (1 - Math.abs(((h2 / 60) % 2) - 1)); + const m = v2 - c; + const [r, g, b] = h2 < 60 ? [c, x, 0] : h2 < 120 ? [x, c, 0] : h2 < 180 ? [0, c, x] : h2 < 240 ? [0, x, c] : h2 < 300 ? [x, 0, c] : [c, 0, x]; + data[i] = Math.round((r + m) * 255); + data[i + 1] = Math.round((g + m) * 255); + data[i + 2] = Math.round((b + m) * 255); + n++; + } + tex.setImage(new Uint8Array(await sharp(data, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer())).setMimeType('image/jpeg'); + return +((100 * n) / (info.width * info.height)).toFixed(2); +} + +/** + * The kit post step (architecture / sci-fi / town): see the header. + * @param {string} input @param {string} output @param {any} job + */ +export async function kitPost(input, output, job) { + let src = input; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-post-')); + try { + if (job.preRotate && Object.values(job.preRotate).some(Boolean)) { + const doc = await io.read(input); + for (const axis of /** @type {const} */ (['x', 'y', 'z'])) { + const deg = job.preRotate[axis]; + if (!deg) continue; + // bake node transforms first would be meshy-post's job; Meshy raws are a + // single identity node, so rotating the mesh data is exact here + for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (rot(axis, deg))); + } + src = path.join(tmp, 'rotated.glb'); + await io.write(src, doc); + } + const mid = path.join(tmp, 'post.glb'); + const report = await postProcess(src, mid, job); + const doc = await io.read(mid); + const moved = seamClamp(doc, job.clamp ?? '', job.eps ?? 0.04); + if (job.offset) { + // a piece that shares ANOTHER piece's pivot (a window sits 0.95 m up on the wall's + // bottom-centre origin, so both place at the same grid point and rotation) + const t = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, job.offset[0] ?? 0, job.offset[1] ?? 0, job.offset[2] ?? 0, 1]; + for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (t)); + } + let graded = 0; + for (const mat of doc.getRoot().listMaterials()) { + // a wall or a crate never glows: Meshy's refine sometimes ships a faint emissive map + // (a glowing floor is baked light in disguise) — drop it outright + if (job.dropEmissive !== false) mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); + // a colour GRADE of the albedo, so every piece sits in one palette (Meshy read + // "oxblood" as cherry red and "slate" as lavender): sharp.modulate + JPEG + const tex = job.albedo && mat.getBaseColorTexture(); + if (tex) { + const { saturation = 1, brightness = 1, hue = 0 } = job.albedo; + tex.setImage(new Uint8Array(await sharp(Buffer.from(tex.getImage())).modulate({ saturation, brightness, hue }).jpeg({ quality: 86 }).toBuffer())); + tex.setMimeType('image/jpeg'); + graded++; + } + } + let recolored = 0; + if (job.recolor) for (const mat of doc.getRoot().listMaterials()) recolored += await recolor(mat, job.recolor); + let glowing = 0; + if (job.glow) for (const mat of doc.getRoot().listMaterials()) glowing += await glowMask(doc, mat, job.glow); + await doc.transform(prune()); + await io.write(output, doc); + if (job.defight === false) return { ...report, output, clamped: moved, graded, recolored, glowing, bytesOut: fs.statSync(output).size }; + // the seam clamp folds relief within eps of a face INTO it: coplanar layers that + // z-fight while the camera moves (roadmap 33 K1, the Block). Settle them last. + const fight = await defightFile(output, output); + return { ...report, output, clamped: moved, defight: { pairs: fight.before, dropped: fight.dropped, pushed: fight.pushed }, graded, recolored, glowing, bytesOut: fs.statSync(output).size }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +// ---- the EMISSIVE POLICY on a whole file (nature-kit: nothing in nature emits light) ---- + +/** Meshy-6 refines sometimes paint a NON-black emissive map (the oak's canopy: mean 33-47 of 255), + * which makes foliage glow at night and wash out by day (meshy-post only drops a fully black one). + * Drops every material's emissive map and factor; returns how many had one. + * @param {string} file */ +export async function dropEmissive(file) { + const doc = await io.read(file); + let n = 0; + for (const mat of doc.getRoot().listMaterials()) { + if (mat.getEmissiveTexture() || mat.getEmissiveFactor().some((/** @type {number} */ v) => v > 0)) n++; + mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); + } + await doc.transform(prune()); + await io.write(file, doc); + return n; +} + +/** props-kit: the albedo doubles as the emissive map, so bright panes (a lantern's amber glass) light + * up while dark metal stays dark — a lit look with no extra texture. @param {any} doc @param {number} k */ +export function albedoGlow(doc, k) { + for (const mat of doc.getRoot().listMaterials()) mat.setEmissiveTexture(mat.getBaseColorTexture()).setEmissiveFactor([k, k * 0.8, k * 0.55]); +} + +// ---- colour grades (0-credit palette fixes and variants) ---- + +const rgb2hsv = (/** @type {number} */ r, /** @type {number} */ g, /** @type {number} */ b) => { + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; + let h = 0; + if (d) h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; + return [(h * 60 + 360) % 360, mx ? d / mx : 0, mx]; +}; +const hsv2rgb = (/** @type {number} */ h, /** @type {number} */ s, /** @type {number} */ v) => { + const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; + const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]; + return [r + m, g + m, b + m]; +}; +const inRange = (/** @type {number} */ h, /** @type {number[]} */ [a, b]) => (a <= b ? h >= a && h <= b : h >= a || h <= b); + +/** nature-kit: colour grade the base-colour textures of a FILE: each rule moves pixels whose hue is + * in `hue` (degrees, wraps) by `shift` degrees / scales saturation `sat` and value `val`, feathered at + * the range edges so there is no hard seam (optional tint). The 0-credit alternative to a retexture. + * @param {string} file @param {any[]} rules */ +export async function gradeHue(file, rules) { + const doc = await io.read(file); + for (const mat of doc.getRoot().listMaterials()) { + const tex = mat.getBaseColorTexture(); + if (!tex) continue; + const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + for (let i = 0; i < data.length; i += 3) { + let [h, s, v] = rgb2hsv(data[i] / 255, data[i + 1] / 255, data[i + 2] / 255); + /** @type {any} */ + let tint = null; + for (const r of rules) { + if (s < (r.minSat ?? 0.12) || !inRange(h, r.hue)) continue; + if (r.maxSat != null && s > r.maxSat) continue; + if ((r.minVal != null && v < r.minVal) || (r.maxVal != null && v > r.maxVal)) continue; + const [a, b] = r.hue; + const span = (b - a + 360) % 360 || 360; + const t = ((h - a + 360) % 360) / span; // 0..1 across the range + const w = r.feather === false ? 1 : Math.min(1, Math.min(t, 1 - t) / 0.15); // feather the outer 15% + h = (h + (r.shift ?? 0) * w + 360) % 360; + s = Math.min(1, s * (1 + ((r.sat ?? 1) - 1) * w)); + v = Math.min(1, v * (1 + ((r.val ?? 1) - 1) * w)); + if (r.tint) tint = [r.tint, (r.amount ?? 0.3) * w, v]; + break; + } + let [R, G, B] = hsv2rgb(h, s, v); + if (tint) { + // multiply-style tint: keep the painted value, pull the colour toward the tint + const [hex, k, vv] = tint; + const c = [1, 3, 5].map((j) => parseInt(hex.slice(j, j + 2), 16) / 255); + const cmax = Math.max(...c); + [R, G, B] = [R, G, B].map((x, j) => x * (1 - k) + (c[j] / cmax) * vv * k); + } + data[i] = R * 255; data[i + 1] = G * 255; data[i + 2] = B * 255; + } + const jpg = await sharp(data, { raw: info }).jpeg({ quality: 86 }).toBuffer(); + tex.setImage(new Uint8Array(jpg)).setMimeType('image/jpeg'); + } + await io.write(file, doc); +} + +/** props-kit / interior-kit: per-channel multiply (+ offset) of every base-colour texture, so a piece + * Meshy painted off-palette (a pale pine table) matches the kit's oak without a paid retexture. + * `only: 'teal'` (interior-kit) grades only teal-ish texels (green and blue well above red, a soft + * mask) — a painted cupboard darkens to the pack's deep teal while its marble top and brass stay. + * @param {any} doc @param {{mul: number[], add?: number[], only?: string}} o */ +export async function gradeLinear(doc, { mul, add = [0, 0, 0], only }) { + for (const mat of doc.getRoot().listMaterials()) { + const tex = mat.getBaseColorTexture(); + const img = tex?.getImage(); + if (!img) continue; + if (only === 'teal') { + const { data, info } = await sharp(Buffer.from(img)).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + for (let i = 0; i < data.length; i += 3) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + const k = Math.min(1, Math.max(0, (Math.min(g, b) - r - 12) / 30)); // soft mask + if (k <= 0) continue; + for (let c = 0; c < 3; c++) data[i + c] = Math.round(data[i + c] * (1 - k + k * mul[c])); + } + tex.setImage(await sharp(data, { raw: info }).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); + continue; + } + tex.setImage(await sharp(Buffer.from(img)).linear(mul, add).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); + } +} + +// ---- repairs ---- + +/** nature-kit: bake node transforms, then drop every triangle lying entirely below `frac` of the + * height (Meshy likes to stand plants on a flat round "base" disc — on real terrain that reads as a + * coaster). Writes a cut copy into `tmpDir` and returns its path. + * @param {string} input @param {number} frac @param {string} tmpDir */ +export async function cutBase(input, frac, tmpDir) { + const doc = await io.read(input); + await doc.transform(flatten()); + for (const node of doc.getRoot().listNodes()) if (node.getMesh()) clearNodeTransform(node); + const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; + const b = getBounds(scene); + const cut = b.min[1] + frac * (b.max[1] - b.min[1]); + let dropped = 0; + for (const mesh of doc.getRoot().listMeshes()) { + for (const prim of mesh.listPrimitives()) { + const pos = prim.getAttribute('POSITION'); + const idx = prim.getIndices(); + const n = idx ? idx.getCount() : pos.getCount(); + const get = (/** @type {number} */ i) => (idx ? idx.getScalar(i) : i); + const keep = []; + for (let t = 0; t < n; t += 3) { + const a = get(t), c = get(t + 1), d = get(t + 2); + const ymax = Math.max(pos.getElement(a, [])[1], pos.getElement(c, [])[1], pos.getElement(d, [])[1]); + if (ymax < cut) dropped++; + else keep.push(a, c, d); + } + const arr = pos.getCount() > 65535 ? new Uint32Array(keep) : new Uint16Array(keep); + const acc = doc.createAccessor().setType('SCALAR').setArray(arr).setBuffer(pos.getBuffer()); + prim.setIndices(acc); + compactPrimitive(prim); + } + } + await doc.transform(prune()); + const out = path.join(tmpDir, `cut-${path.basename(path.dirname(path.dirname(input)))}-${Math.random().toString(36).slice(2, 7)}.glb`); + await io.write(out, doc); + return { out, dropped }; +} + +/** interior-kit: repair texels Meshy left unpainted (pure black holes in the atlas — the bed's + * headboard showed one): diffuse the surrounding colour into every near-black texel. 0 credits. + * @param {any} doc @param {{max?: number}} [o] */ +export async function inpaintBlack(doc, { max = 30 } = {}) { + let fixed = 0; + for (const mat of doc.getRoot().listMaterials()) { + const tex = mat.getBaseColorTexture(); + const img = tex?.getImage(); + if (!img) continue; + const { data, info } = await sharp(Buffer.from(img)).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + const { width: w, height: h } = info; + const hole = new Uint8Array(w * h); + let n = 0; + for (let i = 0; i < w * h; i++) if (Math.max(data[i * 3], data[i * 3 + 1], data[i * 3 + 2]) < max) (hole[i] = 1), n++; + if (!n) continue; + // onion-peel: fill hole texels that touch known texels with their neighbours' mean, repeat + for (let pass = 0; pass < 2048 && n; pass++) { + /** @type {[number, number[]][]} */ + const next = []; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const i = y * w + x; + if (!hole[i]) continue; + const acc = [0, 0, 0]; + let k = 0; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) { + const xx = x + dx; + const yy = y + dy; + if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue; + const j = yy * w + xx; + if (hole[j]) continue; + for (let c = 0; c < 3; c++) acc[c] += data[j * 3 + c]; + k++; + } + if (k >= 2) next.push([i, acc.map((v) => Math.round(v / k))]); + } + if (!next.length) break; + for (const [i, col] of next) { + for (let c = 0; c < 3; c++) data[i * 3 + c] = col[c]; + hole[i] = 0; + n--; + fixed++; + } + } + tex.setImage(await sharp(data, { raw: { width: w, height: h, channels: 3 } }).jpeg({ quality: 86 }).toBuffer()).setMimeType('image/jpeg'); + } + return fixed; +} + +/** interior-kit: repair Meshy's broken vertex normals (the bed's headboard rendered a black triangle): + * (1) a ZERO / NaN normal shades black — it takes the area-weighted mean of its triangles' face + * normals; (2) a triangle whose vertex normals point AGAINST its winding renders dark under the + * double-sided material — it gets its own vertices carrying its face normal. Returns { zero, flipped }. + * @param {any} doc */ +export function repairNormals(doc) { + let fixed = 0; + let zero = 0; + for (const mesh of doc.getRoot().listMeshes()) + for (const prim of mesh.listPrimitives()) { + const names = prim.listSemantics(); + const attrs = names.map((/** @type {string} */ s) => prim.getAttribute(s)); + const P = prim.getAttribute('POSITION'); + const N = prim.getAttribute('NORMAL'); + const I = prim.getIndices(); + if (!N || !I) continue; + { + const acc = new Float64Array(N.getCount() * 3); + const ix = I.getArray(); + for (let t = 0; t < ix.length; t += 3) { + const [a, b, c] = [ix[t], ix[t + 1], ix[t + 2]].map((i) => P.getElement(i, [])); + const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + for (let k = 0; k < 3; k++) for (let j = 0; j < 3; j++) acc[ix[t + k] * 3 + j] += n[j]; + } + for (let i = 0; i < N.getCount(); i++) { + const n = N.getElement(i, []); + if (Math.hypot(...n) > 0.5) continue; + const m = [acc[i * 3], acc[i * 3 + 1], acc[i * 3 + 2]]; + const l = Math.hypot(...m) || 1; + N.setElement(i, l > 1e-12 ? m.map((x) => x / l) : [0, 1, 0]); + zero++; + } + } + const arrays = attrs.map((/** @type {any} */ a) => Array.from(a.getArray())); + const idx = Array.from(I.getArray()); + for (let t = 0; t < idx.length; t += 3) { + const [a, b, c] = [idx[t], idx[t + 1], idx[t + 2]].map((i) => P.getElement(i, [])); + const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + const len = Math.hypot(...n); + if (len < 1e-12) continue; + let neg = 0; + for (let k = 0; k < 3; k++) { + const vn = N.getElement(idx[t + k], []); + if (vn[0] * n[0] + vn[1] * n[1] + vn[2] * n[2] < 0) neg++; + } + if (neg < 2) continue; + for (let k = 0; k < 3; k++) { + const src = idx[t + k]; + attrs.forEach((/** @type {any} */ attr, /** @type {number} */ j) => { + const size = attr.getElementSize(); + const el = names[j] === 'NORMAL' ? n.map((x) => x / len) : attr.getElement(src, []); + arrays[j].push(...el.slice(0, size)); + }); + idx[t + k] = arrays[names.indexOf('POSITION')].length / 3 - 1; + } + fixed++; + } + if (!fixed) continue; + attrs.forEach((/** @type {any} */ attr, /** @type {number} */ j) => attr.setArray(new (attr.getArray().constructor)(arrays[j]))); + I.setArray(new Uint32Array(idx)); + } + return { zero, flipped: fixed }; +} + +/** + * FLAT FOR SYNC (props-kit, interior-kit): every mesh node a direct child of the scene, one primitive + * per mesh. Core's scene sync (commandsHandler sendObject) sends a leaf mesh with its LOCAL pose but a + * nested Group / Object3D-with-children with its WORLD pose, which the receiver then parents under the + * import root — so on a peer every nested level adds the object's position again (measured: a placed + * Rug sat 2× its drop offset away on peer B). GLTFLoader turns a multi-primitive mesh into a Group, so + * primitives are split to one mesh each; flatten() lifts nested nodes to the scene with baked transforms. + * @param {any} doc + */ +export async function flatForSync(doc) { + await doc.transform(flatten()); + const root = doc.getRoot(); + const scene = root.getDefaultScene() ?? root.listScenes()[0]; + for (const node of [...scene.listChildren()]) { + const mesh = node.getMesh(); + if (!mesh || mesh.listPrimitives().length < 2) continue; + mesh.listPrimitives().forEach((/** @type {any} */ prim, /** @type {number} */ i) => { + const name = `${node.getName()}_${prim.getMaterial()?.getName() || i}`; + const part = doc.createNode(name).setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale()); + part.setMesh(doc.createMesh(name).addPrimitive(prim)); + scene.addChild(part); + }); + node.dispose(); + mesh.dispose(); + } + await doc.transform(prune()); +} diff --git a/tools/kit-build/lib/report.mjs b/tools/kit-build/lib/report.mjs new file mode 100644 index 0000000..21717f1 --- /dev/null +++ b/tools/kit-build/lib/report.mjs @@ -0,0 +1,70 @@ +// report: the check's findings for a terminal and a GitHub step summary, and the BUDGET REPORT — +// every item against its category budget, its bytes, textures, LOD levels and z-fight area. + +/** @param {any[]} findings @returns {number} errors that are not allow-listed */ +export function printFindings(findings) { + const order = { error: 0, warn: 1 }; + const sorted = [...findings].sort((a, b) => (a.allowed ? 1 : 0) - (b.allowed ? 1 : 0) || order[a.level] - order[b.level] || a.pack.localeCompare(b.pack) || a.item.localeCompare(b.item)); + let errors = 0; + let allowed = 0; + let warns = 0; + for (const f of sorted) { + const where = `${f.pack}/${f.item}`.padEnd(34); + if (f.allowed) { + allowed++; + if (process.env.KIT_VERBOSE) console.log(`allowed ${where} [${f.check}] ${f.msg}\n ↳ ${f.allowed}`); + } else if (f.level === 'error') { + errors++; + console.log(`ERROR ${where} [${f.check}] ${f.msg}`); + } else { + warns++; + console.log(`warn ${where} [${f.check}] ${f.msg}`); + } + } + console.log(`\n${errors} error(s), ${warns} warning(s), ${allowed} allow-listed (KIT_VERBOSE=1 lists them with their reasons)`); + return errors; +} + +/** markdown for $GITHUB_STEP_SUMMARY @param {{findings: any[], items: any[]}} r */ +export function summaryMarkdown(r) { + const errors = r.findings.filter((f) => f.level === 'error' && !f.allowed); + const warns = r.findings.filter((f) => f.level === 'warn'); + const allowed = r.findings.filter((f) => f.allowed); + const row = (/** @type {any} */ f) => `| ${f.pack} | ${f.item} | ${f.check} | ${String(f.msg).replace(/\|/g, '\\|')} |`; + const lines = [ + `## Pack checks — ${errors.length ? `❌ ${errors.length} error(s)` : '✅ pass'}`, + '', + `${r.items.length} items · ${errors.length} errors · ${warns.length} warnings · ${allowed.length} allow-listed`, + '' + ]; + if (errors.length) lines.push('| pack | item | check | problem |', '|---|---|---|---|', ...errors.map(row), ''); + if (warns.length) lines.push('
warnings', '', '| pack | item | check | note |', '|---|---|---|---|', ...warns.map(row), '', '
', ''); + if (allowed.length) + lines.push('
allow-listed', '', '| pack | item | check | reason |', '|---|---|---|---|', ...allowed.map((f) => `| ${f.pack} | ${f.item} | ${f.check} | ${String(f.allowed).replace(/\|/g, '\\|')} |`), '', '
', ''); + return lines.join('\n') + '\n'; +} + +/** the budget report @param {{findings: any[], items: any[]}} r */ +export function budgetMarkdown(r) { + const out = ['# Pack budget report', '', 'LOD0 triangles against the item\'s category budget (tools/kit-build/packs.json), bytes, largest texture, LOD levels (triangles), static z-fight area.', '']; + const byPack = new Map(); + for (const it of r.items) { + if (!byPack.has(it.pack)) byPack.set(it.pack, []); + byPack.get(it.pack).push(it); + } + for (const [pack, items] of byPack) { + const tris = items.reduce((/** @type {number} */ s, /** @type {any} */ i) => s + i.tris, 0); + const bytes = items.reduce((/** @type {number} */ s, /** @type {any} */ i) => s + i.bytes + i.lods.reduce((/** @type {number} */ a, /** @type {any} */ l) => a + l.bytes, 0), 0); + const errs = r.findings.filter((f) => f.pack === pack && f.level === 'error' && !f.allowed).length; + out.push(`## ${pack} — ${items.length} items, ${(bytes / 1048576).toFixed(1)} MiB, ${tris.toLocaleString('en')} LOD0 triangles${errs ? `, **${errs} errors**` : ''}`, ''); + out.push('| item | category | tris / budget | MiB | texture | LODs | z-fight cm² |', '|---|---|---|---|---|---|---|'); + for (const i of items) { + const pct = i.budget ? Math.round((100 * i.tris) / i.budget) : 0; + const tex = Math.max(0, ...i.textures.map((/** @type {any} */ t) => Math.max(...(t.size ?? [0])))); + const lods = i.lods.length ? i.lods.map((/** @type {any} */ l) => l.tris).join(' / ') : '—'; + out.push(`| ${i.item} | ${i.category ?? '?'} | ${i.tris} / ${i.budget ?? '?'} (${pct} %) | ${(i.bytes / 1048576).toFixed(2)} | ${tex || '—'} | ${lods} | ${i.flickerCm2 ?? '—'} |`); + } + out.push(''); + } + return out.join('\n') + '\n'; +} diff --git a/tools/kit-build/lib/rules.mjs b/tools/kit-build/lib/rules.mjs new file mode 100644 index 0000000..b9b078d --- /dev/null +++ b/tools/kit-build/lib/rules.mjs @@ -0,0 +1,164 @@ +// rules: the pure halves of the pack checks (no file access) — what a row, a pivot, a behavior or a +// LOD list must look like. check.mjs feeds them measured numbers; test/rules.test.mjs proves each one +// red on a bad input. + +/** core's behaviorCore.js enums (33 P2) — kept in step by hand; a new type needs core first */ +export const BEHAVIOR_TYPES = ['door', 'toggle', 'oneshot', 'loop']; +export const BEHAVIOR_TRIGGERS = ['click', 'proximity', 'knock']; +const BEHAVIOR_KEYS = ['type', 'clip', 'closeClip', 'trigger', 'autoplay', 'sound', 'collider']; +/** core lodGroupCore MAX_LEVELS is 6 (LOD0 + 5 files) */ +export const MAX_LOD_FILES = 5; +const ROW_KEYS = ['name', 'label', 'screenshot', 'variants', 'lods', 'behavior']; +const INDEX_KEYS = ['name', 'title', 'value', 'zip', 'attribution', 'copyright', 'license', 'source', 'cover']; +export const PIVOTS = ['bottom-center', 'bottom-center-back', 'top-center', 'wall-pivot', 'wall-face', 'foot', 'hinge', 'any']; + +/** a repo-relative path that stays inside its folder @param {any} p */ +export const safeRel = (p) => typeof p === 'string' && p.trim() !== '' && !p.includes('..') && !p.startsWith('/') && !/^[a-z]+:/i.test(p); +const isUrl = (/** @type {any} */ p) => typeof p === 'string' && /^https?:\/\//.test(p); + +/** + * One index.json row. @param {any} row @returns {string[]} problems + */ +export function indexRowProblems(row) { + const out = []; + if (!row || typeof row !== 'object') return ['not an object']; + if (typeof row.name !== 'string' || !/^[a-z0-9_-]+$/i.test(row.name)) out.push('name must be a folder-safe string'); + if (typeof row.title !== 'string' || !row.title.trim()) out.push('title missing'); + if (!!row.value === !!row.zip) out.push('needs exactly one of value | zip'); + for (const k of ['value', 'attribution', 'cover']) if (row[k] !== undefined && !safeRel(row[k]) && !isUrl(row[k])) out.push(`${k} must be a repo-relative path or an https URL`); + if (row.zip !== undefined && typeof row.zip !== 'string') out.push('zip must be a path'); + if (typeof row.attribution !== 'string') out.push('attribution missing'); + for (const k of ['copyright', 'license']) if (typeof row[k] !== 'string') out.push(`${k} must be a string ("" when none)`); + if (!isUrl(row.source)) out.push('source must be an https URL'); + for (const k of Object.keys(row)) if (!INDEX_KEYS.includes(k)) out.push(`unknown key "${k}"`); + return out; +} + +/** + * One item row of a pack's model list (default.json). Shape only; files are check.mjs's. + * @param {any} row @returns {string[]} + */ +export function itemRowProblems(row) { + const out = []; + if (!row || typeof row !== 'object') return ['not an object']; + if (typeof row.name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(row.name)) out.push('name must be a folder-safe string'); + if (row.label !== undefined && typeof row.label !== 'string') out.push('label must be a string'); + if (row.screenshot !== undefined && !safeRel(row.screenshot) && !isUrl(row.screenshot)) out.push('screenshot must be a relative path or an https URL'); + const glb = row.variants?.['glTF-Binary']; + if (typeof glb !== 'string' || !(safeRel(glb) || isUrl(glb))) out.push('variants["glTF-Binary"] must be a file name or an https URL'); + else if (!isUrl(glb) && !/\.glb$/i.test(glb)) out.push('variants["glTF-Binary"] must be a .glb'); + if (row.lods !== undefined) out.push(...lodsProblems(row.lods)); + if (row.behavior !== undefined) out.push(...behaviorProblems(row.behavior)); + for (const k of Object.keys(row)) if (!ROW_KEYS.includes(k)) out.push(`unknown key "${k}"`); + return out; +} + +/** + * The `lods` field (contract P1): [{file, ratio}], finest first. Core silently DROPS a bad entry, + * so the CI is strict where the app is lenient. @param {any} lods @returns {string[]} + */ +export function lodsProblems(lods) { + if (!Array.isArray(lods) || !lods.length) return ['lods must be a non-empty array']; + const out = []; + if (lods.length > MAX_LOD_FILES) out.push(`lods: at most ${MAX_LOD_FILES} files (core draws LOD0 + ${MAX_LOD_FILES})`); + let prev = 1; + lods.forEach((l, i) => { + if (!l || typeof l !== 'object') return out.push(`lods[${i}] is not an object`); + if (!safeRel(l.file) || !/\.glb$/i.test(l.file) || l.file.includes('/')) out.push(`lods[${i}].file must be a .glb beside LOD0 (no folders, no ..)`); + if (typeof l.ratio !== 'number' || !(l.ratio > 0 && l.ratio <= 1)) out.push(`lods[${i}].ratio must be a number in (0, 1]`); + else if (l.ratio > prev) out.push(`lods[${i}].ratio ${l.ratio} is coarser-first: list levels finest first`); + else prev = l.ratio; + for (const k of Object.keys(l)) if (k !== 'file' && k !== 'ratio') out.push(`lods[${i}]: unknown key "${k}"`); + }); + return out; +} + +/** + * The `behavior` field (33 P2). Core normalizes (an unknown trigger becomes 'click', an autoplay on a + * door is ignored); the CI refuses instead, so a typo cannot ship as a silent default. + * @param {any} b @returns {string[]} + */ +export function behaviorProblems(b) { + if (!b || typeof b !== 'object' || Array.isArray(b)) return ['behavior must be an object']; + const out = []; + if (!BEHAVIOR_TYPES.includes(b.type)) out.push(`behavior.type must be one of ${BEHAVIOR_TYPES.join('|')}`); + if (typeof b.clip !== 'string' || !b.clip.trim() || b.clip.length > 64) out.push('behavior.clip must be a clip name (1-64 chars)'); + if (b.trigger !== undefined && !BEHAVIOR_TRIGGERS.includes(b.trigger)) out.push(`behavior.trigger must be one of ${BEHAVIOR_TRIGGERS.join('|')}`); + if (b.autoplay !== undefined && typeof b.autoplay !== 'boolean') out.push('behavior.autoplay must be true or false'); + if (b.autoplay === true && b.type !== 'loop') out.push('behavior.autoplay is only for type "loop" (nothing else plays on placement)'); + if (b.closeClip !== undefined) { + if (b.type !== 'door' && b.type !== 'toggle') out.push('behavior.closeClip is only for door / toggle'); + else if (typeof b.closeClip !== 'string' || !b.closeClip.trim()) out.push('behavior.closeClip must be a clip name'); + else if (b.closeClip === b.clip) out.push('behavior.closeClip equals clip (leave it out to play clip backwards)'); + } + if (b.sound !== undefined && (typeof b.sound !== 'string' || !b.sound.trim())) out.push('behavior.sound must be a game-sound name'); + if (b.collider !== undefined && b.collider !== 'follow') out.push('behavior.collider must be "follow" (or absent)'); + for (const k of Object.keys(b)) if (!BEHAVIOR_KEYS.includes(k)) out.push(`behavior: unknown key "${k}"`); + return out; +} + +/** the clips a behavior needs, all present in the GLB? @param {any} b @param {string[]} clips */ +export function behaviorClipProblems(b, clips) { + const out = []; + for (const k of ['clip', 'closeClip']) if (typeof b?.[k] === 'string' && !clips.includes(b[k])) out.push(`behavior.${k} "${b[k]}" is not a clip of the GLB (has: ${clips.join(', ') || 'none'})`); + return out; +} + +/** tolerance: absolute floor or a share of the extent, whichever is larger */ +const tol = (/** @type {number} */ extent, /** @type {number} */ share, /** @type {number} */ floor) => Math.max(floor, share * extent); + +/** + * Where the origin sits in the bbox, per axis. centre: within max(3 cm, 10 % of the extent) of the + * middle; a face: within max(2 cm, 2 %) of it. + * @param {number[]} min @param {number[]} max + */ +export function pivotOf(min, max) { + /** @param {number} i */ + const axis = (i) => { + const e = max[i] - min[i]; + const c = (min[i] + max[i]) / 2; + const f = tol(e, 0.02, 0.02); + return { centre: Math.abs(c) <= tol(e, 0.1, 0.03), min: Math.abs(min[i]) <= f, max: Math.abs(max[i]) <= f, above: min[i] >= -f, inside: min[i] <= 0.05 && max[i] >= -0.05 }; + }; + return { x: axis(0), y: axis(1), z: axis(2) }; +} + +/** + * Does the bbox satisfy the declared pivot rule? @param {number[]} min @param {number[]} max + * @param {string} rule @returns {string | null} what is wrong, null when it holds + */ +export function pivotProblem(min, max, rule) { + const p = pivotOf(min, max); + const at = `bbox x ${min[0].toFixed(2)}..${max[0].toFixed(2)}, y ${min[1].toFixed(2)}..${max[1].toFixed(2)}, z ${min[2].toFixed(2)}..${max[2].toFixed(2)}`; + switch (rule) { + case 'any': + return null; + case 'bottom-center': + return p.x.centre && p.z.centre && p.y.min ? null : `pivot is not bottom-centre (${at})`; + case 'bottom-center-back': + return p.x.centre && p.z.min && p.y.min ? null : `pivot is not bottom-centre-back (${at})`; + case 'top-center': + return p.x.centre && p.z.centre && p.y.max ? null : `pivot is not top-centre (${at})`; + case 'wall-pivot': + return p.x.centre && p.z.centre && p.y.above ? null : `pivot is not the wall's bottom-centre (${at})`; + case 'wall-face': + return p.x.centre && p.y.above && min[2] >= -0.02 && min[2] <= 0.3 ? null : `pivot is not on the wall line behind the piece (${at})`; + case 'foot': + return p.y.min && min[0] <= 0 && max[0] >= 0 && min[2] <= 0 && max[2] >= 0 ? null : `pivot is not under the piece (${at})`; + case 'hinge': + return p.x.inside && p.y.inside && p.z.inside ? null : `the hinge pivot is outside the piece (${at})`; + } + return `unknown pivot rule "${rule}" (one of ${PIVOTS.join('|')})`; +} + +/** + * Scale sanity: the bbox is in METRES — a piece exported in centimetres is 100× too big, one in + * kilometres a speck. @param {number[]} size @param {number} maxExtent @param {number} minExtent + */ +export function scaleProblem(size, maxExtent, minExtent) { + if (!size.every(Number.isFinite)) return 'bbox is not finite'; + const big = Math.max(...size); + if (big < minExtent) return `largest extent ${big.toFixed(3)} m is under ${minExtent} m — exported in km, or empty?`; + if (big > maxExtent) return `largest extent ${big.toFixed(2)} m is over the category's ${maxExtent} m — exported in cm?`; + return null; +} diff --git a/tools/kit-build/packs.json b/tools/kit-build/packs.json new file mode 100644 index 0000000..a132daf --- /dev/null +++ b/tools/kit-build/packs.json @@ -0,0 +1,390 @@ +{ + "note": "kit-build's per-pack policy (roadmap 34 E2). categories: triangle budget of LOD0 (drawn, per node) and the largest bbox extent in metres. packs: textureCap (largest texture side), emissive ('none' = nothing in the pack may glow, 'allowed'), the default category and pivot, and per-item overrides. Pivots: bottom-center | bottom-center-back | top-center | wall-pivot (shares the wall's bottom-centre origin, starts above the floor) | wall-face (origin on the wall line, piece on the wall's face) | foot (stands on its origin, which is under the piece but not its middle: a signpost's post, a pipe's open end) | hinge (origin within 5 cm of the piece: a moving part) | any (legacy packs that keep their source scene's coordinates).", + "limits": { + "glbFailBytes": 5242880, + "glbWarnBytes": 2097152, + "lodRequiredOver": 2000, + "minExtent": 0.05, + "flickerCm2": 1, + "thumbMinPx": 64 + }, + "categories": { + "tile": { + "tris": 2500, + "maxExtent": 8.5, + "about": "floor / road / path tiles, rugs, flat terrain" + }, + "prop": { + "tris": 6000, + "maxExtent": 4, + "about": "furniture, dressing, small structures" + }, + "modular": { + "tris": 6500, + "maxExtent": 10, + "about": "snap-together building pieces: walls, posts, stairs, towers" + }, + "functional": { + "tris": 7000, + "maxExtent": 5, + "about": "animated items with a behavior (doors, gates, lids)" + }, + "hero": { + "tris": 8000, + "maxExtent": 3, + "about": "showcase props, few per scene" + }, + "set": { + "tris": 12000, + "maxExtent": 6, + "about": "kitbashed groups (a crate stack, a pond set)" + }, + "tree": { + "tris": 15000, + "maxExtent": 12, + "about": "trees" + }, + "legacy": { + "tris": 25000, + "maxExtent": 10, + "about": "the starter packs from before the kits (default, cube_diorama)" + } + }, + "packs": { + "default": { + "textureCap": 1024, + "emissive": "allowed", + "category": "legacy", + "pivot": "any" + }, + "cube_diorama": { + "textureCap": 2048, + "emissive": "none", + "category": "legacy", + "pivot": "any" + }, + "nature-kit": { + "textureCap": 1024, + "emissive": "none", + "category": "prop", + "pivot": "bottom-center", + "items": { + "Oak": { + "category": "tree" + }, + "OakAutumn": { + "category": "tree" + }, + "Pine": { + "category": "tree" + }, + "Birch": { + "category": "tree" + }, + "DeadTree": { + "category": "tree" + }, + "Palm": { + "category": "tree" + }, + "PondSet": { + "category": "set" + }, + "BoulderCluster": { + "category": "set" + }, + "FlowerPatch": { + "category": "set" + }, + "PathTile": { + "category": "tile" + }, + "FallenLeaves": { + "category": "tile" + }, + "PondWater": { + "category": "tile" + }, + "Hill": { + "category": "tile" + }, + "SteppingStones": { + "category": "set" + }, + "Cliff": { + "category": "modular", + "pivot": "bottom-center-back" + }, + "Bridge": { + "category": "modular" + } + } + }, + "architecture-kit": { + "textureCap": 1024, + "emissive": "none", + "category": "modular", + "pivot": "bottom-center", + "items": { + "FloorStone": { + "category": "tile" + }, + "FloorWood": { + "category": "tile" + }, + "FloorSlate": { + "category": "tile" + }, + "Window": { + "pivot": "wall-pivot" + } + } + }, + "props-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "prop", + "pivot": "bottom-center", + "items": { + "CrateStack": { + "category": "set" + }, + "Rug": { + "category": "tile" + }, + "Hatch": { + "pivot": "bottom-center-back" + }, + "Tapestry": { + "pivot": "bottom-center-back" + }, + "WallButton": { + "pivot": "bottom-center-back" + }, + "WallTorch": { + "pivot": "bottom-center-back" + }, + "LeverHandle": { + "pivot": "hinge" + }, + "Signpost": { + "pivot": "foot" + } + } + }, + "scifi-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "modular", + "pivot": "bottom-center", + "items": { + "FloorPlate": { + "category": "tile" + }, + "FloorGrate": { + "category": "tile" + }, + "CeilingLight": { + "category": "tile" + }, + "Console": { + "category": "prop" + }, + "ServerRack": { + "category": "prop" + }, + "Crate": { + "category": "prop" + }, + "CrateLarge": { + "category": "prop" + }, + "Canister": { + "category": "prop" + }, + "Chair": { + "category": "prop" + }, + "Desk": { + "category": "prop" + }, + "PlantPod": { + "category": "prop" + }, + "TeleportPad": { + "category": "prop" + }, + "Drone": { + "category": "prop" + }, + "Lamp": { + "category": "prop" + }, + "PipeElbow": { + "pivot": "foot" + }, + "Vent": { + "pivot": "wall-face" + }, + "WallScreen": { + "pivot": "wall-face" + } + } + }, + "interactive-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "functional", + "pivot": "bottom-center", + "items": { + "Shutters": { + "pivot": "wall-pivot" + }, + "Banner": { + "pivot": "bottom-center-back" + }, + "Torch": { + "pivot": "bottom-center-back" + }, + "WallButton": { + "pivot": "bottom-center-back" + }, + "CeilingFan": { + "pivot": "top-center" + }, + "Lever": { + "pivot": "hinge" + } + } + }, + "interior-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "prop", + "pivot": "bottom-center", + "items": { + "TavernTableSet": { + "category": "set" + }, + "DiningSet": { + "category": "set" + }, + "RugRound": { + "category": "tile" + }, + "BackBar": { + "pivot": "wall-face" + }, + "Cornice": { + "pivot": "wall-face" + }, + "DoubleBed": { + "pivot": "wall-face" + }, + "Fireplace": { + "pivot": "wall-face" + }, + "KitchenCounter": { + "pivot": "wall-face" + }, + "Picture": { + "pivot": "wall-face" + }, + "Skirting": { + "pivot": "wall-face" + }, + "SkirtingDoorway": { + "pivot": "wall-face" + }, + "Stove": { + "pivot": "wall-face" + }, + "Wainscot": { + "pivot": "wall-face" + }, + "WainscotDoorway": { + "pivot": "wall-face" + }, + "WallSconce": { + "pivot": "wall-face" + }, + "WallShelfBooks": { + "pivot": "wall-face" + }, + "Wardrobe": { + "pivot": "wall-face" + }, + "Chandelier": { + "pivot": "top-center" + } + } + }, + "town-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "prop", + "pivot": "bottom-center", + "items": { + "Road": { + "category": "tile" + }, + "RoadCurb": { + "category": "tile" + }, + "RoadCorner": { + "category": "tile" + }, + "RoadCrossing": { + "category": "tile" + }, + "Sidewalk": { + "category": "tile" + }, + "StoneStairs": { + "category": "modular" + }, + "ClockTowerTop": { + "category": "modular" + }, + "Bridge": { + "category": "modular" + }, + "Fence": { + "category": "modular" + }, + "FenceGate": { + "category": "functional" + }, + "LoadedCart": { + "category": "set" + }, + "BarrelCluster": { + "category": "set", + "pivot": "foot" + }, + "SacksBarrel": { + "category": "set" + }, + "Banner": { + "pivot": "wall-face" + }, + "Signpost": { + "pivot": "foot" + } + } + }, + "arcane-kit": { + "textureCap": 1024, + "emissive": "allowed", + "category": "hero", + "pivot": "bottom-center" + }, + "khronos-sample-assets": { + "textureCap": 4096, + "emissive": "allowed", + "category": "legacy", + "pivot": "any", + "about": "upstream-fetch: every row is an absolute URL into KhronosGroup/glTF-Sample-Assets, no bytes here" + } + } +} diff --git a/tools/kit-build/test/check.test.mjs b/tools/kit-build/test/check.test.mjs new file mode 100644 index 0000000..8d511fc --- /dev/null +++ b/tools/kit-build/test/check.test.mjs @@ -0,0 +1,235 @@ +// kit-build check: the fixture pack passes, and every check goes RED when its one thing is broken +// (the counterfactual per guard). No browser, no network. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runChecks } from '../lib/check.mjs'; +import { passPack } from '../lib/pass.mjs'; +import { fixture, boxGlb, errorsOf } from './fixture.mjs'; + +/** run the checks on a fixture and return its unallowed errors @param {(f: any) => any} [edit] */ +async function errors(edit) { + const dir = await fixture(edit); + try { + return errorsOf(await runChecks(dir)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} +const has = (/** @type {string[]} */ errs, /** @type {string} */ check, /** @type {RegExp} */ re) => + assert.ok(errs.some((e) => e.startsWith(`${check}:`) && re.test(e)), `expected a ${check} error matching ${re}, got:\n${errs.join('\n') || '(none)'}`); + +test('the fixture pack passes every check', async () => { + assert.deepEqual(await errors(), []); +}); + +test('index: a row without a source URL, and a value file that does not exist', async () => { + const e = await errors((f) => { + delete f.index[0].source; + f.index.push({ ...f.index[0], name: 'ghost', value: 'ghost/default.json', source: 'https://x.y' }); + }); + has(e, 'index', /source must be an https URL/); + has(e, 'index', /ghost\/default.json does not exist/); +}); + +test('manifest: an unknown row key (a typo) and a duplicate item', async () => { + const e = await errors((f) => { + f.rows[0].lable = 'typo'; + f.rows.push({ ...f.rows[0] }); + }); + has(e, 'manifest', /unknown key "lable"/); + has(e, 'manifest', /duplicate item name/); +}); + +test('size: a GLB over the share cap', async () => { + has(await errors((f) => (f.limits.glbFailBytes = 1000)), 'size', /over the 5 MiB share cap/); +}); + +test('tris: LOD0 over its category budget', async () => { + has(await errors((f) => (f.categories.prop.tris = 10)), 'tris', /12 triangles is over the prop budget of 10/); +}); + +test('textures: a texture over the pack cap', async () => { + has(await errors(async (f) => (f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ texture: 2048 }))), 'textures', /2048² texture is over the pack's 1024 cap/); +}); + +test('emissive: a glowing material in a "nothing glows" pack', async () => { + const e = await errors(async (f) => { + f.policy.emissive = 'none'; + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ emissive: true }); + }); + has(e, 'emissive', /policy is "nothing glows"/); +}); + +test('scale: a box exported in centimetres', async () => { + has(await errors(async (f) => (f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ scale: 100 }))), 'scale', /exported in cm/); +}); + +test('pivot: a box whose origin is off to the side (and the declared exception lets a wall piece through)', async () => { + has(await errors(async (f) => (f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ offset: [2, 0, 0] }))), 'pivot', /not bottom-centre/); + assert.deepEqual( + await errors(async (f) => { + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ offset: [0, 1, 0] }); + f.policy.items = { Box: { pivot: 'wall-pivot' } }; + }), + [] + ); +}); + +test('lods: required over the floor; a missing file; a wrong ratio; a level with foreign node names; a level without the clips', async () => { + has(await errors((f) => (f.limits.lodRequiredOver = 5)), 'lods', /no LOD files/); + has(await errors((f) => (f.rows[0].lods = [{ file: 'box.lod1.glb', ratio: 0.67 }])), 'lods', /box.lod1.glb does not exist/); + has( + await errors(async (f) => { + f.rows[0].lods = [{ file: 'box.lod1.glb', ratio: 0.3 }]; + f.files['kit/Box/glTF-Binary/box.lod1.glb'] = await boxGlb({ faces: 4 }); + }), + 'lods', + /ratio 0.3 but the file has 67 %/ + ); + assert.deepEqual( + await errors(async (f) => { + f.rows[0].lods = [{ file: 'box.lod1.glb', ratio: 0.67 }]; + f.files['kit/Box/glTF-Binary/box.lod1.glb'] = await boxGlb({ faces: 4 }); + }), + [], + 'a right level passes' + ); + has( + await errors(async (f) => { + // LOD0 draws two nodes; the level one, under a name LOD0 does not have (core cannot match it) + f.rows[0].lods = [{ file: 'box.lod1.glb', ratio: 0.33 }]; + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ twin: 'Lid' }); + f.files['kit/Box/glTF-Binary/box.lod1.glb'] = await boxGlb({ faces: 4, nodeName: 'Other' }); + }), + 'lods', + /BY NODE NAME|has mesh nodes LOD0 does not/ + ); + has( + await errors(async (f) => { + f.rows[0].lods = [{ file: 'box.lod1.glb', ratio: 0.67 }]; + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ clips: ['open'] }); + f.files['kit/Box/glTF-Binary/box.lod1.glb'] = await boxGlb({ faces: 4 }); + }), + 'lods', + /lacks LOD0's clips open/ + ); +}); + +test('behavior: a clip the GLB does not have; a door that autoplays', async () => { + const door = { type: 'door', clip: 'open', trigger: 'click' }; + assert.deepEqual( + await errors(async (f) => { + f.rows[0].behavior = door; + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ clips: ['open'] }); + }), + [] + ); + has(await errors((f) => (f.rows[0].behavior = door)), 'behavior', /"open" is not a clip of the GLB/); + has(await errors((f) => (f.rows[0].behavior = { ...door, autoplay: true })), 'manifest', /autoplay is only for type "loop"/); +}); + +test('flicker: a decal lying ON a face fails; its recorded baseline lets it through; worse than the baseline fails', async () => { + const decal = async (/** @type {any} */ f) => (f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ coplanar: true })); + has(await errors(decal), 'flicker', /coplanar pairs, 3600\.0 cm² of overlap/); + const file = 'kit/Box/glTF-Binary/box.glb'; + assert.deepEqual(await errors(async (f) => (await decal(f), (f.baseline[file] = { cm2: 3600, pairs: 2, px: 10, why: 'test' }))), []); + has(await errors(async (f) => (await decal(f), (f.baseline[file] = { cm2: 100, pairs: 2, px: 10, why: 'test' }))), 'flicker', /WORSE than its baseline/); +}); + +test('thumb: a row whose screenshot is missing, or too small', async () => { + has(await errors((f) => delete f.files['kit/Box/thumb.webp']), 'thumb', /no thumbnail/); + has(await errors((f) => (f.limits.thumbMinPx = 256)), 'thumb', /\(< 256 px\)/); +}); + +test('allow-list: a listed failure is accepted with its reason; an entry that matches nothing warns', async () => { + const dir = await fixture((f) => { + f.categories.prop.tris = 10; + f.allow = [ + { pack: 'kit', item: 'Box', check: 'tris', reason: 'test' }, + { pack: 'kit', item: 'Gone', check: 'lods', reason: 'stale' } + ]; + }); + try { + const r = await runChecks(dir); + assert.deepEqual(errorsOf(r), []); + assert.equal(r.findings.find((x) => x.check === 'tris')?.allowed, 'test'); + assert.ok(r.findings.some((x) => x.level === 'warn' && x.check === 'allow' && x.item === 'Gone')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('pass: fixes texture cap and emissive policy — and leaves a compliant file byte-identical', async () => { + const dir = await fixture(async (f) => { + f.policy.emissive = 'none'; + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ texture: 2048, emissive: true }); + }); + const glb = path.join(dir, 'kit/Box/glTF-Binary/box.glb'); + try { + assert.ok(errorsOf(await runChecks(dir)).length >= 2, 'red before the pass'); + const before = fs.readFileSync(glb); + assert.equal(await passPack(dir, 'kit', {}), 1, 'a dry run reports it'); + assert.deepEqual(fs.readFileSync(glb), before, 'a dry run writes nothing'); + assert.equal(await passPack(dir, 'kit', { write: true }), 1); + assert.deepEqual(errorsOf(await runChecks(dir)), [], 'green after the pass'); + const fixed = fs.readFileSync(glb); + assert.equal(await passPack(dir, 'kit', { write: true }), 0, 'nothing left to do'); + assert.deepEqual(fs.readFileSync(glb), fixed, 'and the file is not rewritten'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('pass: an opaque PNG over the share cap becomes JPEG', async () => { + const dir = await fixture(async (f) => { + f.files['kit/Box/glTF-Binary/box.glb'] = await boxGlb({ png: 512 }); + f.limits.glbFailBytes = 400_000; + }); + try { + has(errorsOf(await runChecks(dir)), 'size', /share cap/); + await passPack(dir, 'kit', { write: true }); + assert.deepEqual(errorsOf(await runChecks(dir)), []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('pass: LOD0 over its triangle budget is decimated to it (node names kept)', async () => { + const { load, makeIO } = await import('../lib/deps.mjs'); + const { Document } = await load('@gltf-transform/core'); + // a 2 × 2 m floor tile of 40 × 40 quads = 3 200 triangles, flat: meshopt can take nearly all of them + const doc = new Document(); + const buf = doc.createBuffer(); + const n = 40; + const P = []; + const I = []; + for (let z = 0; z <= n; z++) for (let x = 0; x <= n; x++) P.push((2 * x) / n - 1, 0, (2 * z) / n - 1); + for (let z = 0; z < n; z++) + for (let x = 0; x < n; x++) { + const a = z * (n + 1) + x; + I.push(a, a + n + 1, a + 1, a + 1, a + n + 1, a + n + 2); + } + const prim = doc + .createPrimitive() + .setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(P)).setBuffer(buf)) + .setIndices(doc.createAccessor().setType('SCALAR').setArray(new Uint16Array(I)).setBuffer(buf)) + .setMaterial(doc.createMaterial('M')); + doc.createScene('Scene').addChild(doc.createNode('Tile').setMesh(doc.createMesh('Tile').addPrimitive(prim))); + const tile = Buffer.from(await makeIO().writeBinary(doc)); + const dir = await fixture((f) => { + f.files['kit/Box/glTF-Binary/box.glb'] = tile; + f.categories.prop.tris = 500; + }); + try { + has(errorsOf(await runChecks(dir)), 'tris', /3200 triangles is over the prop budget of 500/); + await passPack(dir, 'kit', { write: true }); + const r = await runChecks(dir); + assert.deepEqual(errorsOf(r), []); + assert.ok(r.items[0].tris <= 500 && r.items[0].tris > 0, `decimated to ${r.items[0].tris}`); + assert.deepEqual(r.items[0].meshNodes, ['Tile']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tools/kit-build/test/fixture.mjs b/tools/kit-build/test/fixture.mjs new file mode 100644 index 0000000..0308fb6 --- /dev/null +++ b/tools/kit-build/test/fixture.mjs @@ -0,0 +1,110 @@ +// A one-pack repo in a temp folder that passes every check — each test breaks ONE thing in it. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { load, makeIO, REPO } from '../lib/deps.mjs'; + +const { Document } = await load('@gltf-transform/core'); +const sharp = (await load('sharp')).default; + +/** the real policy file, so the categories the tests use are the shipped ones */ +export const REAL = JSON.parse(fs.readFileSync(path.join(REPO, 'tools/kit-build/packs.json'), 'utf8')); + +/** + * A box GLB: x/z -0.5..0.5, y 0..1 (bottom-centre pivot), 12 triangles, one node "Box". + * @param {{scale?: number, offset?: number[], faces?: number, texture?: number, emissive?: boolean, + * coplanar?: boolean, clips?: string[], nodeName?: string, twin?: string, png?: number}} [o] + */ +export async function boxGlb(o = {}) { + const doc = new Document(); + const buf = doc.createBuffer(); + const k = o.scale ?? 1; + const off = o.offset ?? [0, 0, 0]; + const P = []; + const I = []; + // six faces as quads [corner, u, v] in the unit box + const faces = [ + [[0, 0, 1], [1, 0, 0], [0, 1, 0]], + [[1, 0, 0], [-1, 0, 0], [0, 1, 0]], + [[0, 0, 0], [0, 0, 1], [0, 1, 0]], + [[1, 0, 1], [0, 0, -1], [0, 1, 0]], + [[0, 1, 1], [1, 0, 0], [0, 0, -1]], + [[0, 0, 0], [1, 0, 0], [0, 0, 1]] + ].slice(0, o.faces ?? 6); + if (o.coplanar) faces.push([[0.2, 1, 0.8], [0.6, 0, 0], [0, 0, -0.6]]); // a decal lying ON the top face + for (const [c, u, v] of faces) { + const b = P.length / 3; + for (const [a, bb] of [[0, 0], [1, 0], [1, 1], [0, 1]]) for (let i = 0; i < 3; i++) P.push(((c[i] + a * u[i] + bb * v[i] - (i === 1 ? 0 : 0.5)) * k) + off[i]); + I.push(b, b + 1, b + 2, b, b + 2, b + 3); + } + const pos = doc.createAccessor().setType('VEC3').setArray(new Float32Array(P)).setBuffer(buf); + const idx = doc.createAccessor().setType('SCALAR').setArray(new Uint16Array(I)).setBuffer(buf); + const mat = doc.createMaterial('M').setDoubleSided(true); + if (o.emissive) mat.setEmissiveFactor([1, 0.5, 0]); + let uv = null; + if (o.texture || o.png) { + const n = o.texture ?? o.png ?? 64; + const img = sharp({ create: { width: n, height: n, channels: 3, background: '#a08060' } }); + // a photo-like gradient with fine grain: big as PNG, small as JPEG (the share-cap test) + const raw = Buffer.alloc(n * n * 3); + let r = 7; + for (let i = 0; i < raw.length; i++) { + r ^= r << 13; r ^= r >>> 17; r ^= r << 5; + raw[i] = ((((i / 3) % n) * 200) / n + (r & 15)) | 0; + } + const bytes = o.png ? await sharp(raw, { raw: { width: n, height: n, channels: 3 } }).png().toBuffer() : await img.jpeg().toBuffer(); + mat.setBaseColorTexture(doc.createTexture('T').setImage(new Uint8Array(bytes)).setMimeType(o.png ? 'image/png' : 'image/jpeg')); + uv = doc.createAccessor().setType('VEC2').setArray(new Float32Array((P.length / 3) * 2)).setBuffer(buf); + } + const prim = doc.createPrimitive().setAttribute('POSITION', pos).setIndices(idx).setMaterial(mat); + if (uv) prim.setAttribute('TEXCOORD_0', uv); + const mesh = doc.createMesh('Box').addPrimitive(prim); + const node = doc.createNode(o.nodeName ?? 'Box').setMesh(mesh); + const scene = doc.createScene('Scene').addChild(node); + if (o.twin) scene.addChild(doc.createNode(o.twin).setMesh(mesh).setTranslation([0, 0, 0])); + for (const name of o.clips ?? []) { + const t = doc.createAccessor().setType('SCALAR').setArray(new Float32Array([0, 1])).setBuffer(buf); + const v = doc.createAccessor().setType('VEC3').setArray(new Float32Array([0, 0, 0, 0, 0.1, 0])).setBuffer(buf); + const s = doc.createAnimationSampler().setInput(t).setOutput(v).setInterpolation('LINEAR'); + doc.createAnimation(name).addSampler(s).addChannel(doc.createAnimationChannel().setTargetNode(node).setTargetPath('translation').setSampler(s)); + } + return Buffer.from(await makeIO().writeBinary(doc)); +} + +/** + * Write the fixture repo. `edit` may change {index, rows, policy, limits, allow, baseline, files} + * before it is written; files are {relPath: Buffer | string}. + * @param {(f: any) => void | Promise} [edit] + */ +export async function fixture(edit) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-check-')); + const f = { + index: [{ name: 'kit', title: 'Kit', value: 'kit/default.json', attribution: 'kit/attribution.html', copyright: '', license: 'CC0-1.0', source: 'https://github.com/theprototype-app/packs' }], + rows: [{ name: 'Box', label: 'Box', screenshot: 'thumb.webp', variants: { 'glTF-Binary': 'box.glb' } }], + policy: { textureCap: 1024, emissive: 'allowed', category: 'prop', pivot: 'bottom-center' }, + limits: { ...REAL.limits }, + categories: JSON.parse(JSON.stringify(REAL.categories)), + allow: [], + baseline: {}, + files: /** @type {Record} */ ({ + 'kit/attribution.html': '

CC0

', + 'kit/Box/glTF-Binary/box.glb': await boxGlb(), + 'kit/Box/thumb.webp': await sharp({ create: { width: 128, height: 128, channels: 3, background: '#888' } }).webp().toBuffer() + }) + }; + if (edit) await edit(f); + const w = (/** @type {string} */ rel, /** @type {Buffer | string} */ data) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), data); + }; + w('index.json', JSON.stringify(f.index)); + w('kit/default.json', JSON.stringify(f.rows)); + for (const [rel, data] of Object.entries(f.files)) w(rel, data); + w('tools/kit-build/packs.json', JSON.stringify({ limits: f.limits, categories: f.categories, packs: { kit: f.policy } })); + w('tools/kit-build/allow.json', JSON.stringify({ allow: f.allow })); + w('tools/kit-build/flicker-baseline.json', JSON.stringify({ files: f.baseline })); + return dir; +} + +/** the unallowed errors of a run, as "check: message" @param {any} r */ +export const errorsOf = (r) => r.findings.filter((/** @type {any} */ x) => x.level === 'error' && !x.allowed).map((/** @type {any} */ x) => `${x.check}: ${x.msg}`); diff --git a/tools/kit-build/test/rules.test.mjs b/tools/kit-build/test/rules.test.mjs new file mode 100644 index 0000000..9ab1d67 --- /dev/null +++ b/tools/kit-build/test/rules.test.mjs @@ -0,0 +1,70 @@ +// kit-build rules: the pure checks, each shown red on the input it exists to catch. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { behaviorProblems, behaviorClipProblems, lodsProblems, itemRowProblems, indexRowProblems, pivotProblem, scaleProblem } from '../lib/rules.mjs'; + +test('behavior: the shipped door passes; typos and rule breaks do not', () => { + const door = { type: 'door', clip: 'open', closeClip: 'close', trigger: 'click', autoplay: false, sound: 'door', collider: 'follow' }; + assert.deepEqual(behaviorProblems(door), []); + assert.deepEqual(behaviorProblems({ type: 'loop', clip: 'spin', trigger: 'click', autoplay: true }), [], 'an ambient loop may autoplay'); + assert.match(behaviorProblems({ ...door, type: 'dor' }).join(), /type must be one of/); + assert.match(behaviorProblems({ ...door, trigger: 'clik' }).join(), /trigger must be one of/, 'core would silently make it "click"'); + assert.match(behaviorProblems({ ...door, autoplay: true }).join(), /only for type "loop"/); + assert.match(behaviorProblems({ ...door, closeClip: 'open' }).join(), /equals clip/); + assert.match(behaviorProblems({ type: 'oneshot', clip: 'x', closeClip: 'y' }).join(), /only for door \/ toggle/); + assert.match(behaviorProblems({ ...door, colider: 'follow' }).join(), /unknown key "colider"/); + assert.match(behaviorProblems({ ...door, clip: '' }).join(), /clip must be a clip name/); + assert.deepEqual(behaviorClipProblems(door, ['idle', 'open', 'close']), []); + assert.match(behaviorClipProblems(door, ['idle', 'open']).join(), /closeClip "close" is not a clip/); +}); + +test('lods: finest first, files beside LOD0, ratios in (0, 1]', () => { + assert.deepEqual(lodsProblems([{ file: 'a.lod1.glb', ratio: 0.5 }, { file: 'a.lod2.glb', ratio: 0.2 }]), []); + assert.match(lodsProblems([{ file: 'a.lod2.glb', ratio: 0.2 }, { file: 'a.lod1.glb', ratio: 0.5 }]).join(), /finest first/); + assert.match(lodsProblems([{ file: '../b/a.lod1.glb', ratio: 0.5 }]).join(), /beside LOD0/); + assert.match(lodsProblems([{ file: 'sub/a.lod1.glb', ratio: 0.5 }]).join(), /beside LOD0/); + assert.match(lodsProblems([{ file: 'a.lod1.glb', ratio: 0 }]).join(), /ratio must be a number in \(0, 1\]/); + assert.match(lodsProblems([{ file: 'a.lod1.glb', ratio: '0.5' }]).join(), /ratio must be a number/); + assert.match(lodsProblems([]).join(), /non-empty/); +}); + +test('rows: the shape core reads', () => { + const row = { name: 'WallStone', label: 'Wall', screenshot: 'screenshot/screenshot.webp', variants: { 'glTF-Binary': 'wall-stone.glb' } }; + assert.deepEqual(itemRowProblems(row), []); + assert.deepEqual(itemRowProblems({ ...row, variants: { 'glTF-Binary': 'https://raw.githubusercontent.com/x/y.glb' }, screenshot: 'https://x/y.jpg' }), [], 'upstream-fetch rows'); + assert.match(itemRowProblems({ ...row, variants: {} }).join(), /glTF-Binary/); + assert.match(itemRowProblems({ ...row, variants: { 'glTF-Binary': 'wall.gltf' } }).join(), /must be a .glb/); + assert.match(itemRowProblems({ ...row, name: 'Wall Stone' }).join(), /folder-safe/); + assert.match(itemRowProblems({ ...row, lod: [] }).join(), /unknown key "lod"/); + const idx = { name: 'kit', title: 'Kit', value: 'kit/default.json', attribution: 'kit/a.html', copyright: '', license: 'CC0-1.0', source: 'https://github.com/x' }; + assert.deepEqual(indexRowProblems(idx), []); + assert.match(indexRowProblems({ ...idx, zip: 'kit/k.zip' }).join(), /exactly one of value \| zip/); + assert.match(indexRowProblems({ ...idx, license: undefined }).join(), /license must be a string/); +}); + +test('pivot rules: each preset accepts its shape and refuses an off-origin piece', () => { + const wall = [[-1, 0, -0.125], [1, 3, 0.125]]; + assert.equal(pivotProblem(wall[0], wall[1], 'bottom-center'), null); + assert.match(String(pivotProblem([0, 0, -0.125], [2, 3, 0.125], 'bottom-center')), /not bottom-centre/, 'origin at a corner'); + assert.match(String(pivotProblem([-1, -1.5, -0.125], [1, 1.5, 0.125], 'bottom-center')), /not bottom-centre/, 'origin in the middle of the height'); + assert.equal(pivotProblem([-0.55, 0.97, -0.25], [0.55, 2.23, 0.25], 'wall-pivot'), null, 'a window on the wall origin'); + assert.match(String(pivotProblem([-0.55, -0.5, -0.25], [0.55, 2.23, 0.25], 'wall-pivot')), /wall's bottom-centre/); + assert.equal(pivotProblem([-0.67, 0, 0], [0.67, 1.66, 0.09], 'bottom-center-back'), null, 'a tapestry'); + assert.equal(pivotProblem([-0.46, -0.92, -0.44], [0.46, 0, 0.44], 'top-center'), null, 'a chandelier'); + assert.match(String(pivotProblem([-0.46, 0, -0.44], [0.46, 0.92, 0.44], 'top-center')), /top-centre/); + assert.equal(pivotProblem([-1, 0, 0.125], [1, 0.25, 0.15], 'wall-face'), null, 'a skirting on the wall face'); + assert.match(String(pivotProblem([-1, 0, 1.2], [1, 0.25, 1.3], 'wall-face')), /wall line/); + assert.equal(pivotProblem([-0.75, 0, -0.45], [0.86, 2.46, 0.79], 'foot'), null, 'a signpost on its post'); + assert.match(String(pivotProblem([0.2, 0, 0.2], [1, 2, 1], 'foot')), /not under the piece/); + assert.equal(pivotProblem([-0.07, -0.03, -0.04], [0.07, 0.51, 0.04], 'hinge'), null); + assert.match(String(pivotProblem([1, 1, 1], [2, 2, 2], 'hinge')), /outside the piece/); + assert.equal(pivotProblem([5, 5, 5], [6, 6, 6], 'any'), null); + assert.match(String(pivotProblem(wall[0], wall[1], 'middle')), /unknown pivot rule/); +}); + +test('scale: metres, not centimetres or kilometres', () => { + assert.equal(scaleProblem([2, 3, 0.25], 10, 0.05), null); + assert.match(String(scaleProblem([200, 300, 25], 10, 0.05)), /exported in cm/); + assert.match(String(scaleProblem([0.002, 0.003, 0.0002], 10, 0.05)), /in km, or empty/); + assert.match(String(scaleProblem([NaN, 1, 1], 10, 0.05)), /not finite/); +}); diff --git a/tools/lod/README.md b/tools/lod/README.md index 5729503..1a062b8 100644 --- a/tools/lod/README.md +++ b/tools/lod/README.md @@ -28,7 +28,7 @@ skipped item with its reason. `defight-all.mjs` LOOKS at every candidate (surface rule, bbox-middle rule, drop-only) against the original render and keeps the one with the least overlap among those within Δ 30; none → the file is left as is. Every file's outcome is in `defight-report.json`. The - architecture/scifi kit-post runs it last (their seam clamp is what folds relief into + architecture/scifi post step (tools/kit-build/lib/post.mjs) runs it last (their seam clamp is what folds relief into coplanar layers). A kitbashed lining must never sit flush with a cut wall: scifi's `procedural.mjs` insets it 4 mm (`tools/scifi-kit/inset-linings.mjs` for shipped GLBs) — inset BEFORE defight. diff --git a/tools/scifi-kit/build.mjs b/tools/scifi-kit/build.mjs index fbf8697..203eb54 100644 --- a/tools/scifi-kit/build.mjs +++ b/tools/scifi-kit/build.mjs @@ -19,6 +19,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { TOOLS, kitPost } from './kit-post.mjs'; +import { defightFile } from '../lod/defight.mjs'; import { kitCut, DERIVE } from './kit-cut.mjs'; import { writePiece, OPENINGS } from './procedural.mjs'; @@ -122,6 +123,10 @@ async function buildItem(it) { report = { ...report, cut: await kitCut(cur, next, typeof it.cut === 'string' ? DERIVE[it.cut](it.cutOpts ?? OPENINGS[it.cut]) : it.cut) }; cur = next; } + // a cut (mirror, opening) or a kitbash (a wall + its lining or glass) makes surfaces meet in + // one plane AFTER the post step's defight ran: settle them last (34-pack-ci — without this a + // rebuild ships 300-3000 cm² of coplanar overlap on every wall that the shipped files do not have) + if (it.cut || it.kitbash) await defightFile(cur, cur); fs.copyFileSync(cur, out); const doc = await io.read(out); // FLAT for sync (props-kit finding 2): core's sendObject sends a nested node with its diff --git a/tools/scifi-kit/kit-post.mjs b/tools/scifi-kit/kit-post.mjs index 4445006..13d7aec 100644 --- a/tools/scifi-kit/kit-post.mjs +++ b/tools/scifi-kit/kit-post.mjs @@ -1,249 +1,5 @@ -// kit-post: the sci-fi kit's post step around tools/meshy's meshy-post. -// -// raw Meshy GLB → pre-rotate (Meshy often lays a floor tile or roof slab upright, and -// meshy-post only turns about Y) → meshy-post (weld, simplify, stretch to the exact -// dims, pivot, 1024² JPEG) → SEAM CLAMP → pack GLB. -// -// The seam clamp is what makes pieces snap without gaps: Meshy's ends are never quite -// planar (a stone sticks out 2 cm, the next one is 3 cm short), so after the stretch -// every vertex within `eps` of a bbox face named in `clamp` is moved ONTO that face. -// Two walls placed end to end then share one exact plane at their joint. -// -// node kit-post.mjs '' -// job = meshy-post's job + { preRotate?: {x?, y?, z?} degrees, clamp?: "x" | "xz" | "xyz" | "x-y" …, -// eps?: metres (default 0.04), offset?: [x, y, z] metres applied last, -// albedo?: {saturation, brightness, hue} grade of the base colour, dropEmissive?: false to keep it, -// glow?: {hue: [lo, hi], sat, val, strength} — light the albedo's own light strips (see glowMask), -// recolor?: {hue: [lo, hi], sat, val, shift} — scale saturation/value (and shift the hue) of that band only (see recolor) } -// `clamp` lists axes; a trailing "-y" etc. means "the MIN face of y only" (a floor's bottom). -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { defightFile } from '../lod/defight.mjs'; - -export const TOOLS = process.env.MESHY_TOOLS || '/home/deck/.code/theprototype-app/packs-lane-30c-tools/tools/meshy'; -const req = createRequire(path.join(TOOLS, 'package.json')); -const load = async (/** @type {string} */ id) => import(pathToFileURL(req.resolve(id)).href); - -const { NodeIO } = await load('@gltf-transform/core'); -const { ALL_EXTENSIONS, KHRMaterialsEmissiveStrength } = await load('@gltf-transform/extensions'); -const { getBounds, transformMesh, prune } = await load('@gltf-transform/functions'); -const { postProcess } = await import(pathToFileURL(path.join(TOOLS, 'lib/post.js')).href); -const sharp = (await load('sharp')).default; - -const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); -const AX = { x: 0, y: 1, z: 2 }; - -/** column-major 4x4 rotation about one axis @param {'x'|'y'|'z'} axis @param {number} deg */ -function rot(axis, deg) { - const a = (deg * Math.PI) / 180; - const c = Math.cos(a); - const s = Math.sin(a); - if (axis === 'x') return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; - if (axis === 'y') return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; - return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -} - -/** Parse "xz" / "x-y" / "+y" into [{axis, side}] (side: -1 min, +1 max, 0 both). @param {string} spec */ -export function parseClamp(spec) { - const out = []; - let sign = 0; - for (const ch of spec || '') { - if (ch === '-') sign = -1; - else if (ch === '+') sign = 1; - else if (ch in AX) { - out.push({ axis: AX[/** @type {'x'|'y'|'z'} */ (ch)], side: sign }); - sign = 0; - } - } - return out; -} - -/** - * Move every vertex within eps of a named bbox face onto it. Returns how many moved. - * @param {any} doc @param {string} spec @param {number} eps - */ -export function seamClamp(doc, spec, eps = 0.04) { - const faces = parseClamp(spec); - if (!faces.length) return 0; - const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; - const b = getBounds(scene); - let moved = 0; - const seen = new Set(); - for (const mesh of doc.getRoot().listMeshes()) { - for (const prim of mesh.listPrimitives()) { - const pos = prim.getAttribute('POSITION'); - if (!pos || seen.has(pos)) continue; - seen.add(pos); - const v = [0, 0, 0]; - for (let i = 0; i < pos.getCount(); i++) { - pos.getElement(i, v); - let changed = false; - for (const { axis, side } of faces) { - if (side <= 0 && v[axis] - b.min[axis] < eps && v[axis] !== b.min[axis]) { - v[axis] = b.min[axis]; - changed = true; - } - if (side >= 0 && b.max[axis] - v[axis] < eps && v[axis] !== b.max[axis]) { - v[axis] = b.max[axis]; - changed = true; - } - } - if (changed) { - pos.setElement(i, v); - moved++; - } - } - } - } - return moved; -} - -/** - * Sci-fi pieces carry their own lights (a door's light stripes, a pad's rings, a screen): - * Meshy paints them into the albedo as bright teal but ships no usable emissive map. The - * mask keeps every albedo pixel whose hue is in [lo, hi]° with saturation ≥ sat and value - * ≥ val (0-1), black elsewhere, and becomes the emissive texture — so exactly the painted - * lights glow, never the teal PAINT (darker) or the white panels (unsaturated). - * Returns the fraction of pixels lit (in %), 0 when the material has no albedo. - * @param {any} doc @param {any} mat @param {{hue: number[], sat?: number, val?: number, strength?: number}} o - */ -export async function glowMask(doc, mat, o) { - const tex = mat.getBaseColorTexture(); - if (!tex) return 0; - const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - const out = Buffer.alloc(data.length); - let lit = 0; - for (let i = 0; i < data.length; i += 3) { - const r = data[i] / 255; - const g = data[i + 1] / 255; - const b = data[i + 2] / 255; - const max = Math.max(r, g, b); - const d = max - Math.min(r, g, b); - const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; - if (max >= (o.val ?? 0.55) && d / (max || 1) >= (o.sat ?? 0.35) && h >= o.hue[0] && h <= o.hue[1]) { - out[i] = data[i]; - out[i + 1] = data[i + 1]; - out[i + 2] = data[i + 2]; - lit++; - } - } - const img = await sharp(out, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer(); - const em = doc.createTexture(`${mat.getName() || 'mat'}_glow`).setImage(new Uint8Array(img)).setMimeType('image/jpeg'); - mat.setEmissiveTexture(em).setEmissiveFactor([1, 1, 1]); - if (o.strength && o.strength !== 1) { - const ext = doc.createExtension(KHRMaterialsEmissiveStrength); - mat.setExtension('KHR_materials_emissive_strength', ext.createEmissiveStrength().setEmissiveStrength(o.strength)); - } - return +((100 * lit) / (info.width * info.height)).toFixed(2); -} - -/** rgb (0-1) → [h°, s, v] */ -export function hsv(r, g, b) { - const max = Math.max(r, g, b); - const d = max - Math.min(r, g, b); - const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; - return [h, max ? d / max : 0, max]; -} - -/** - * A hue-selective grade of the albedo: pixels whose hue is in [lo, hi]° (and saturation - * ≥ 0.12, so greys are left alone) get their saturation × sat, value × val and hue + shift° — e.g. the - * wall's all-teal frame → warm gunmetal, keeping its painted shading. Returns % of pixels. - * @param {any} mat @param {{hue: number[], sat?: number, val?: number, shift?: number}} o - */ -export async function recolor(mat, o) { - const tex = mat.getBaseColorTexture(); - if (!tex) return 0; - const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - let n = 0; - for (let i = 0; i < data.length; i += 3) { - const [h, s, v] = hsv(data[i] / 255, data[i + 1] / 255, data[i + 2] / 255); - if (s < 0.12 || h < o.hue[0] || h > o.hue[1]) continue; - const s2 = Math.min(1, s * (o.sat ?? 1)); - const v2 = Math.min(1, v * (o.val ?? 1)); - // back to rgb: keep the pixel's own channel ratios' hue, re-spread for s2/v2 - const h2 = (h + (o.shift ?? 0) + 360) % 360; - const c = v2 * s2; - const x = c * (1 - Math.abs(((h2 / 60) % 2) - 1)); - const m = v2 - c; - const [r, g, b] = h2 < 60 ? [c, x, 0] : h2 < 120 ? [x, c, 0] : h2 < 180 ? [0, c, x] : h2 < 240 ? [0, x, c] : h2 < 300 ? [x, 0, c] : [c, 0, x]; - data[i] = Math.round((r + m) * 255); - data[i + 1] = Math.round((g + m) * 255); - data[i + 2] = Math.round((b + m) * 255); - n++; - } - tex.setImage(new Uint8Array(await sharp(data, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer())).setMimeType('image/jpeg'); - return +((100 * n) / (info.width * info.height)).toFixed(2); -} - -/** - * @param {string} input @param {string} output @param {any} job - */ -export async function kitPost(input, output, job) { - let src = input; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-post-')); - try { - if (job.preRotate && Object.values(job.preRotate).some(Boolean)) { - const doc = await io.read(input); - for (const axis of /** @type {const} */ (['x', 'y', 'z'])) { - const deg = job.preRotate[axis]; - if (!deg) continue; - // bake node transforms first would be meshy-post's job; Meshy raws are a - // single identity node, so rotating the mesh data is exact here - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (rot(axis, deg))); - } - src = path.join(tmp, 'rotated.glb'); - await io.write(src, doc); - } - const mid = path.join(tmp, 'post.glb'); - const report = await postProcess(src, mid, job); - const doc = await io.read(mid); - const moved = seamClamp(doc, job.clamp ?? '', job.eps ?? 0.04); - if (job.offset) { - // a piece that shares ANOTHER piece's pivot (a window sits 0.95 m up on the wall's - // bottom-centre origin, so both place at the same grid point and rotation) - const t = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, job.offset[0] ?? 0, job.offset[1] ?? 0, job.offset[2] ?? 0, 1]; - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (t)); - } - let graded = 0; - for (const mat of doc.getRoot().listMaterials()) { - // a wall or a crate never glows: Meshy's refine sometimes ships a faint emissive map - // (a glowing floor is baked light in disguise) — drop it outright - if (job.dropEmissive !== false) mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); - // a colour GRADE of the albedo, so every piece sits in one palette (Meshy read - // "oxblood" as cherry red and "slate" as lavender): sharp.modulate + JPEG - const tex = job.albedo && mat.getBaseColorTexture(); - if (tex) { - const { saturation = 1, brightness = 1, hue = 0 } = job.albedo; - tex.setImage(new Uint8Array(await sharp(Buffer.from(tex.getImage())).modulate({ saturation, brightness, hue }).jpeg({ quality: 86 }).toBuffer())); - tex.setMimeType('image/jpeg'); - graded++; - } - } - let recolored = 0; - if (job.recolor) for (const mat of doc.getRoot().listMaterials()) recolored += await recolor(mat, job.recolor); - let glowing = 0; - if (job.glow) for (const mat of doc.getRoot().listMaterials()) glowing += await glowMask(doc, mat, job.glow); - await doc.transform(prune()); - await io.write(output, doc); - // the seam clamp folds relief within eps of a face INTO it: coplanar layers that - // z-fight while the camera moves (roadmap 33 K1, the Block). Settle them last. - const fight = await defightFile(output, output); - return { ...report, output, clamped: moved, defight: { pairs: fight.before, dropped: fight.dropped, pushed: fight.pushed }, graded, recolored, glowing, bytesOut: fs.statSync(output).size }; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [input, output, jobJson] = process.argv.slice(2); - if (!input || !output) { - console.error("usage: kit-post.mjs ''"); - process.exit(2); - } - const report = await kitPost(input, output, JSON.parse(jobJson || '{}')); - console.log(JSON.stringify(report)); -} +// kit-post: the sci-fi kit's post step (glow mask, recolor included) now lives in +// tools/kit-build/lib/post.mjs — roadmap 34 E2, one pack build tool instead of four forks. This file +// stays so build.mjs and the tests keep their import. +// CLI: node tools/kit-build/kit-build.mjs post '' +export { TOOLS, kitPost, parseClamp, seamClamp, glowMask, recolor, hsv } from '../kit-build/lib/post.mjs'; diff --git a/tools/town-kit/kit-post.mjs b/tools/town-kit/kit-post.mjs index 126b3da..cb26cab 100644 --- a/tools/town-kit/kit-post.mjs +++ b/tools/town-kit/kit-post.mjs @@ -1,247 +1,9 @@ -// kit-post: the sci-fi kit's post step around tools/meshy's meshy-post. -// -// raw Meshy GLB → pre-rotate (Meshy often lays a floor tile or roof slab upright, and -// meshy-post only turns about Y) → meshy-post (weld, simplify, stretch to the exact -// dims, pivot, 1024² JPEG) → SEAM CLAMP → pack GLB. -// -// The seam clamp is what makes pieces snap without gaps: Meshy's ends are never quite -// planar (a stone sticks out 2 cm, the next one is 3 cm short), so after the stretch -// every vertex within `eps` of a bbox face named in `clamp` is moved ONTO that face. -// Two walls placed end to end then share one exact plane at their joint. -// -// node kit-post.mjs '' -// job = meshy-post's job + { preRotate?: {x?, y?, z?} degrees, clamp?: "x" | "xz" | "xyz" | "x-y" …, -// eps?: metres (default 0.04), offset?: [x, y, z] metres applied last, -// albedo?: {saturation, brightness, hue} grade of the base colour, dropEmissive?: false to keep it, -// glow?: {hue: [lo, hi], sat, val, strength} — light the albedo's own light strips (see glowMask), -// recolor?: {hue: [lo, hi], sat, val, shift} — scale saturation/value (and shift the hue) of that band only (see recolor) } -// `clamp` lists axes; a trailing "-y" etc. means "the MIN face of y only" (a floor's bottom). -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; +// kit-post: the town kit's post step now lives in tools/kit-build/lib/post.mjs — roadmap 34 E2, one +// pack build tool instead of four forks. This file stays so build.mjs and the tests keep their import. +import { kitPost as post } from '../kit-build/lib/post.mjs'; -export const TOOLS = process.env.MESHY_TOOLS || path.resolve(path.dirname(new URL(import.meta.url).pathname), '../meshy'); -const req = createRequire(path.join(TOOLS, 'package.json')); -const load = async (/** @type {string} */ id) => import(pathToFileURL(req.resolve(id)).href); +export { TOOLS, parseClamp, seamClamp, glowMask, recolor, hsv } from '../kit-build/lib/post.mjs'; -const { NodeIO } = await load('@gltf-transform/core'); -const { ALL_EXTENSIONS, KHRMaterialsEmissiveStrength } = await load('@gltf-transform/extensions'); -const { getBounds, transformMesh, prune } = await load('@gltf-transform/functions'); -const { postProcess } = await import(pathToFileURL(path.join(TOOLS, 'lib/post.js')).href); -const sharp = (await load('sharp')).default; - -const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); -const AX = { x: 0, y: 1, z: 2 }; - -/** column-major 4x4 rotation about one axis @param {'x'|'y'|'z'} axis @param {number} deg */ -function rot(axis, deg) { - const a = (deg * Math.PI) / 180; - const c = Math.cos(a); - const s = Math.sin(a); - if (axis === 'x') return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; - if (axis === 'y') return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; - return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -} - -/** Parse "xz" / "x-y" / "+y" into [{axis, side}] (side: -1 min, +1 max, 0 both). @param {string} spec */ -export function parseClamp(spec) { - const out = []; - let sign = 0; - for (const ch of spec || '') { - if (ch === '-') sign = -1; - else if (ch === '+') sign = 1; - else if (ch in AX) { - out.push({ axis: AX[/** @type {'x'|'y'|'z'} */ (ch)], side: sign }); - sign = 0; - } - } - return out; -} - -/** - * Move every vertex within eps of a named bbox face onto it. Returns how many moved. - * @param {any} doc @param {string} spec @param {number} eps - */ -export function seamClamp(doc, spec, eps = 0.04) { - const faces = parseClamp(spec); - if (!faces.length) return 0; - const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]; - const b = getBounds(scene); - let moved = 0; - const seen = new Set(); - for (const mesh of doc.getRoot().listMeshes()) { - for (const prim of mesh.listPrimitives()) { - const pos = prim.getAttribute('POSITION'); - if (!pos || seen.has(pos)) continue; - seen.add(pos); - const v = [0, 0, 0]; - for (let i = 0; i < pos.getCount(); i++) { - pos.getElement(i, v); - let changed = false; - for (const { axis, side } of faces) { - if (side <= 0 && v[axis] - b.min[axis] < eps && v[axis] !== b.min[axis]) { - v[axis] = b.min[axis]; - changed = true; - } - if (side >= 0 && b.max[axis] - v[axis] < eps && v[axis] !== b.max[axis]) { - v[axis] = b.max[axis]; - changed = true; - } - } - if (changed) { - pos.setElement(i, v); - moved++; - } - } - } - } - return moved; -} - -/** - * Sci-fi pieces carry their own lights (a door's light stripes, a pad's rings, a screen): - * Meshy paints them into the albedo as bright teal but ships no usable emissive map. The - * mask keeps every albedo pixel whose hue is in [lo, hi]° with saturation ≥ sat and value - * ≥ val (0-1), black elsewhere, and becomes the emissive texture — so exactly the painted - * lights glow, never the teal PAINT (darker) or the white panels (unsaturated). - * Returns the fraction of pixels lit (in %), 0 when the material has no albedo. - * @param {any} doc @param {any} mat @param {{hue: number[], sat?: number, val?: number, strength?: number}} o - */ -export async function glowMask(doc, mat, o) { - const tex = mat.getBaseColorTexture(); - if (!tex) return 0; - const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - const out = Buffer.alloc(data.length); - let lit = 0; - for (let i = 0; i < data.length; i += 3) { - const r = data[i] / 255; - const g = data[i + 1] / 255; - const b = data[i + 2] / 255; - const max = Math.max(r, g, b); - const d = max - Math.min(r, g, b); - const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; - if (max >= (o.val ?? 0.55) && d / (max || 1) >= (o.sat ?? 0.35) && h >= o.hue[0] && h <= o.hue[1]) { - out[i] = data[i]; - out[i + 1] = data[i + 1]; - out[i + 2] = data[i + 2]; - lit++; - } - } - const img = await sharp(out, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer(); - const em = doc.createTexture(`${mat.getName() || 'mat'}_glow`).setImage(new Uint8Array(img)).setMimeType('image/jpeg'); - mat.setEmissiveTexture(em).setEmissiveFactor([1, 1, 1]); - if (o.strength && o.strength !== 1) { - const ext = doc.createExtension(KHRMaterialsEmissiveStrength); - mat.setExtension('KHR_materials_emissive_strength', ext.createEmissiveStrength().setEmissiveStrength(o.strength)); - } - return +((100 * lit) / (info.width * info.height)).toFixed(2); -} - -/** rgb (0-1) → [h°, s, v] */ -export function hsv(r, g, b) { - const max = Math.max(r, g, b); - const d = max - Math.min(r, g, b); - const h = d === 0 ? 0 : max === r ? (60 * ((g - b) / d) + 360) % 360 : max === g ? 60 * ((b - r) / d) + 120 : 60 * ((r - g) / d) + 240; - return [h, max ? d / max : 0, max]; -} - -/** - * A hue-selective grade of the albedo: pixels whose hue is in [lo, hi]° (and saturation - * ≥ 0.12, so greys are left alone) get their saturation × sat, value × val and hue + shift° — e.g. the - * wall's all-teal frame → warm gunmetal, keeping its painted shading. Returns % of pixels. - * @param {any} mat @param {{hue: number[], sat?: number, val?: number, shift?: number}} o - */ -export async function recolor(mat, o) { - const tex = mat.getBaseColorTexture(); - if (!tex) return 0; - const { data, info } = await sharp(Buffer.from(tex.getImage())).removeAlpha().raw().toBuffer({ resolveWithObject: true }); - let n = 0; - for (let i = 0; i < data.length; i += 3) { - const [h, s, v] = hsv(data[i] / 255, data[i + 1] / 255, data[i + 2] / 255); - // a band with lo > hi wraps through 0° (reds: [340, 15]) - const inBand = o.hue[0] <= o.hue[1] ? h >= o.hue[0] && h <= o.hue[1] : h >= o.hue[0] || h <= o.hue[1]; - if (s < (o.minSat ?? 0.12) || !inBand) continue; - const s2 = Math.min(1, s * (o.sat ?? 1)); - const v2 = Math.min(1, v * (o.val ?? 1)); - // back to rgb: keep the pixel's own channel ratios' hue, re-spread for s2/v2 - const h2 = (h + (o.shift ?? 0) + 360) % 360; - const c = v2 * s2; - const x = c * (1 - Math.abs(((h2 / 60) % 2) - 1)); - const m = v2 - c; - const [r, g, b] = h2 < 60 ? [c, x, 0] : h2 < 120 ? [x, c, 0] : h2 < 180 ? [0, c, x] : h2 < 240 ? [0, x, c] : h2 < 300 ? [x, 0, c] : [c, 0, x]; - data[i] = Math.round((r + m) * 255); - data[i + 1] = Math.round((g + m) * 255); - data[i + 2] = Math.round((b + m) * 255); - n++; - } - tex.setImage(new Uint8Array(await sharp(data, { raw: { width: info.width, height: info.height, channels: 3 } }).jpeg({ quality: 88 }).toBuffer())).setMimeType('image/jpeg'); - return +((100 * n) / (info.width * info.height)).toFixed(2); -} - -/** - * @param {string} input @param {string} output @param {any} job - */ -export async function kitPost(input, output, job) { - let src = input; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-post-')); - try { - if (job.preRotate && Object.values(job.preRotate).some(Boolean)) { - const doc = await io.read(input); - for (const axis of /** @type {const} */ (['x', 'y', 'z'])) { - const deg = job.preRotate[axis]; - if (!deg) continue; - // bake node transforms first would be meshy-post's job; Meshy raws are a - // single identity node, so rotating the mesh data is exact here - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (rot(axis, deg))); - } - src = path.join(tmp, 'rotated.glb'); - await io.write(src, doc); - } - const mid = path.join(tmp, 'post.glb'); - const report = await postProcess(src, mid, job); - const doc = await io.read(mid); - const moved = seamClamp(doc, job.clamp ?? '', job.eps ?? 0.04); - if (job.offset) { - // a piece that shares ANOTHER piece's pivot (a window sits 0.95 m up on the wall's - // bottom-centre origin, so both place at the same grid point and rotation) - const t = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, job.offset[0] ?? 0, job.offset[1] ?? 0, job.offset[2] ?? 0, 1]; - for (const mesh of doc.getRoot().listMeshes()) transformMesh(mesh, /** @type {any} */ (t)); - } - let graded = 0; - for (const mat of doc.getRoot().listMaterials()) { - // a wall or a crate never glows: Meshy's refine sometimes ships a faint emissive map - // (a glowing floor is baked light in disguise) — drop it outright - if (job.dropEmissive !== false) mat.setEmissiveTexture(null).setEmissiveFactor([0, 0, 0]); - // a colour GRADE of the albedo, so every piece sits in one palette (Meshy read - // "oxblood" as cherry red and "slate" as lavender): sharp.modulate + JPEG - const tex = job.albedo && mat.getBaseColorTexture(); - if (tex) { - const { saturation = 1, brightness = 1, hue = 0 } = job.albedo; - tex.setImage(new Uint8Array(await sharp(Buffer.from(tex.getImage())).modulate({ saturation, brightness, hue }).jpeg({ quality: 86 }).toBuffer())); - tex.setMimeType('image/jpeg'); - graded++; - } - } - let recolored = 0; - if (job.recolor) for (const mat of doc.getRoot().listMaterials()) recolored += await recolor(mat, job.recolor); - let glowing = 0; - if (job.glow) for (const mat of doc.getRoot().listMaterials()) glowing += await glowMask(doc, mat, job.glow); - await doc.transform(prune()); - await io.write(output, doc); - return { ...report, output, clamped: moved, graded, recolored, glowing, bytesOut: fs.statSync(output).size }; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [input, output, jobJson] = process.argv.slice(2); - if (!input || !output) { - console.error("usage: kit-post.mjs ''"); - process.exit(2); - } - const report = await kitPost(input, output, JSON.parse(jobJson || '{}')); - console.log(JSON.stringify(report)); -} +/** town-kit runs the z-fight pass itself, with a judge (build.mjs) — so not here + * @param {string} input @param {string} output @param {any} job */ +export const kitPost = (input, output, job) => post(input, output, { defight: false, ...job });