Textures (SPRINT2 §Lane E-2). sail_weave.png is a seamless 512² knitted shade-cloth weave with the stripe banding real cloth has; the script proves the wrap by evaluating a second tile and requiring an exact match, because Lane B is being told to set RepeatWrapping and a bad wrap would seam across the whole sail. Luminance rides in a narrow band so it multiplies the base colour instead of replacing it — high contrast reads as burlap, not HDPE. Dropped the per-pixel noise: invisible at ±0.012 and incompressible, it cost 305 KB of the 323. sail_tears.png is a strip of 4 escalating rips. Each is a lens, not a slit — fabric under tension parts widest in the middle and tapers to a point, and a constant-width gap reads as a drawn line. Threads bridge the gap, scaled to the local width; without them a dark lens is a hole rather than a tear. Dressing (§Lane E-3): wheelie_bin_01 (240 L, 12 kg, `lid` on its own node so it flaps before the bin goes over), washing_line_01 (a Hills Hoist — the `head` freewheels, giving a second wind tell at head height), garden_gnome_01 (collateral bait: a smashed gnome reads where a damage number doesn't). Bin and tramp land in models/debris/ for Lane C to glob. All 19 assets pass, 28 output files byte-identical across two runs, selftest 129/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
11 KiB
JavaScript
217 lines
11 KiB
JavaScript
/**
|
||
* Lane E selftests — asset sanity.
|
||
* Lane E owns this file. Other lanes: yours is js/tests/<letter>.test.js.
|
||
*
|
||
* Why this exists when build_yard_assets.py already verifies: the Blender side
|
||
* re-imports every GLB and asserts dims, tri budget and node names, but it
|
||
* CANNOT catch an axis error. Blender exports Z-up→Y-up and imports Y-up→Z-up,
|
||
* so a broken `export_yup` flips back on the way in and round-trips green. Only
|
||
* a native glTF reader can prove the file is right, and this is the only one in
|
||
* the repo. So this suite targets the failures that silently break other lanes:
|
||
* 1. the GLB loads at all through the vendored loader;
|
||
* 2. it's in metres with its height on +Y — a model exported in centimetres
|
||
* looks fine alone and absurd next to a person;
|
||
* 3. the nodes other lanes query by name survived the export. glTF has no
|
||
* "empty" type, so anchors arrive as bare Object3D and are exactly what an
|
||
* exporter prunes.
|
||
*
|
||
* Standalone version with a fuller report: tools/assetcheck/.
|
||
*/
|
||
|
||
import * as THREE from '../../vendor/three.module.js';
|
||
import { assert } from '../testkit.js';
|
||
|
||
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
|
||
//
|
||
// Every three.js addon imports the bare specifier `three`, which only resolves
|
||
// via an <script type="importmap">. No page in this repo has one yet — index.html
|
||
// and selftest.html both import `../vendor/three.module.js` by relative path and
|
||
// so never needed it. A static import here would throw at module load, and
|
||
// selftest.html turns an un-importable lane module into a hard FAIL, which would
|
||
// redden Lane A's merge gate over a harness gap rather than a real defect.
|
||
//
|
||
// So: try it at runtime and skip with an actionable message if it's absent. The
|
||
// day the importmap lands this suite lights up on its own, no edit needed.
|
||
// Need + exact fix are logged in THREADS.md [E] — it blocks Lane D too, which
|
||
// can't load a ped without GLTFLoader/SkeletonUtils.
|
||
const LOADER_PATH = '../../vendor/addons/loaders/GLTFLoader.js';
|
||
|
||
/** Resolve off import.meta.url, not the document — survives selftest.html moving. */
|
||
const url = (a) => new URL(`../../models/${a.sub ?? ''}${a.name}_v1.glb`, import.meta.url).href;
|
||
|
||
/**
|
||
* Height ranges rather than exact dims: this guards against unit and axis
|
||
* regressions, not against Lane E retuning a silhouette. Exact measurements
|
||
* live in tools/blender/asset_report.json. `nodes` are the names other lanes
|
||
* query — changing one is a contract break and should fail here.
|
||
*/
|
||
const ASSETS = [
|
||
{ name: 'ref_capsule', h: [1.68, 1.72], nodes: ['ref_capsule_mesh', 'head_height'] },
|
||
{ name: 'tree_gum_01', h: [4.0, 9.0],
|
||
nodes: ['trunk', 'canopy_01', 'canopy_02', 'canopy_03',
|
||
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
|
||
{ name: 'tree_gum_02', h: [4.0, 9.0],
|
||
nodes: ['trunk', 'canopy_01', 'canopy_02', 'branch_anchor_01', 'branch_anchor_02'] },
|
||
{ name: 'fence_post', h: [1.8, 2.2], nodes: ['post'] },
|
||
{ name: 'fence_panel', h: [1.6, 2.0], nodes: ['palings', 'rails'] },
|
||
{ name: 'gate', h: [1.6, 2.0], nodes: ['gate_palings', 'gate_frame', 'hinges', 'hinge_axis'] },
|
||
{ name: 'house_yardside', h: [2.5, 3.5],
|
||
nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
|
||
'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
|
||
{ name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
|
||
{ name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
|
||
{ name: 'garden_bed', h: [0.5, 1.1],
|
||
nodes: ['bed', 'soil', 'plants_full', 'plants_tattered', 'plants_dead'] },
|
||
{ name: 'sail_post', h: [3.8, 4.2],
|
||
nodes: ['footing', 'post', 'pad_eye', 'top_anchor', 'rake_pivot'] },
|
||
{ name: 'ladder_01', h: [2.8, 3.2], nodes: ['ladder', 'ladder_base', 'ladder_top'] },
|
||
{ name: 'shackle', h: [0.05, 0.15], nodes: ['bow', 'pin'] },
|
||
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
|
||
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
|
||
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
|
||
{ name: 'wheelie_bin_01', h: [1.0, 1.2],
|
||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||
];
|
||
|
||
function sizeOf(gltf) {
|
||
const s = new THREE.Vector3();
|
||
new THREE.Box3().setFromObject(gltf.scene).getSize(s);
|
||
return s;
|
||
}
|
||
|
||
/** @param {import('../testkit.js').Suite} t */
|
||
export default async function run(t) {
|
||
let GLTFLoader;
|
||
try {
|
||
({ GLTFLoader } = await import(LOADER_PATH));
|
||
} catch (err) {
|
||
t.skip('needs an importmap for the bare `three` specifier — see THREADS [E]. ' +
|
||
'Assets ARE verified meanwhile: tools/assetcheck/ (16/16 green in three.js r175)');
|
||
return;
|
||
}
|
||
|
||
const loader = new GLTFLoader();
|
||
const loaded = new Map();
|
||
const failed = new Map();
|
||
|
||
await Promise.all(ASSETS.map(async (a) => {
|
||
try { loaded.set(a.name, await loader.loadAsync(url(a))); }
|
||
catch (err) { failed.set(a.name, err?.message ?? String(err)); }
|
||
}));
|
||
|
||
t.test('every yard GLB loads through the vendored GLTFLoader', () => {
|
||
const lost = [...failed].map(([n, e]) => `${n} (${e})`).join('; ');
|
||
assert(failed.size === 0, `failed to load: ${lost}`);
|
||
});
|
||
|
||
// The anchor of the whole scale system. If this is wrong, every judgement
|
||
// made against the contact sheet was made against a lie.
|
||
t.test('ref_capsule is 1.70 m tall on +Y — the scale everything is judged against', () => {
|
||
const g = loaded.get('ref_capsule');
|
||
assert(g, 'ref_capsule did not load');
|
||
const s = sizeOf(g);
|
||
assert(Math.abs(s.y - 1.70) < 0.02, `capsule is ${s.y.toFixed(3)} m on Y, want 1.70`);
|
||
assert(s.x < 0.6 && s.z < 0.6,
|
||
`capsule is ${s.x.toFixed(2)} x ${s.z.toFixed(2)} in plan — height is not on +Y`);
|
||
});
|
||
|
||
for (const a of ASSETS) {
|
||
const gltf = loaded.get(a.name);
|
||
if (!gltf) continue; // already reported by the load test
|
||
const s = sizeOf(gltf);
|
||
|
||
t.test(`${a.name}: metre-scale, height on +Y`, () => {
|
||
assert(s.y >= a.h[0] && s.y <= a.h[1],
|
||
`${a.name} stands ${s.y.toFixed(3)} m, want ${a.h[0]}–${a.h[1]} m ` +
|
||
`(box ${s.x.toFixed(2)} x ${s.y.toFixed(2)} x ${s.z.toFixed(2)})`);
|
||
});
|
||
|
||
t.test(`${a.name}: named nodes survive the export`, () => {
|
||
const names = new Set();
|
||
gltf.scene.traverse((o) => names.add(o.name));
|
||
const missing = a.nodes.filter((n) => !names.has(n));
|
||
assert(missing.length === 0,
|
||
`${a.name} lost ${missing.join(', ')} — other lanes query these by name`);
|
||
});
|
||
}
|
||
|
||
// Anchors are the actual product here: Lane B pins cloth corners to them and
|
||
// Lane A builds world.anchors from them. A surviving name isn't enough — the
|
||
// position has to be usable.
|
||
t.test('branch_anchor_01 resolves to a usable world position up the tree', () => {
|
||
const g = loaded.get('tree_gum_01');
|
||
assert(g, 'tree_gum_01 did not load');
|
||
const anchor = g.scene.getObjectByName('branch_anchor_01');
|
||
assert(anchor, 'branch_anchor_01 missing — glTF has no "empty", check it was not pruned');
|
||
g.scene.updateWorldMatrix(true, true);
|
||
const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
|
||
assert(p.y > 1.0 && p.y < 6.0,
|
||
`anchor sits at y=${p.y.toFixed(2)} — want 1–6 m up the trunk`);
|
||
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
|
||
});
|
||
|
||
// A canopy that can't sway is just decoration, and the gust front the player
|
||
// reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
|
||
// rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
|
||
// about their own centres — a sphere spinning in place is invisible, which is
|
||
// exactly the failure this catches.
|
||
t.test('canopy sways about the trunk when Lane A rotates it', () => {
|
||
const g = loaded.get('tree_gum_01');
|
||
assert(g, 'tree_gum_01 did not load');
|
||
const canopy = g.scene.getObjectByName('canopy');
|
||
assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
|
||
const blob = g.scene.getObjectByName('canopy_01');
|
||
assert(blob, 'canopy_01 missing');
|
||
|
||
g.scene.updateWorldMatrix(true, true);
|
||
const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
|
||
const restZ = canopy.rotation.z;
|
||
canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad
|
||
canopy.updateWorldMatrix(true, true);
|
||
const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
|
||
canopy.rotation.z = restZ;
|
||
canopy.updateWorldMatrix(true, true);
|
||
|
||
const moved = before.distanceTo(after);
|
||
assert(moved > 0.15,
|
||
`canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
|
||
'the blob centre, not the trunk top, so the tree cannot visibly sway');
|
||
});
|
||
|
||
// Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
|
||
// it has to carry the arms round. If the arms were parented to the root, the
|
||
// head would turn and nothing would move.
|
||
t.test('washing line head carries the arms round when spun', () => {
|
||
const g = loaded.get('washing_line_01');
|
||
assert(g, 'washing_line_01 did not load');
|
||
const head = g.scene.getObjectByName('head');
|
||
const arms = g.scene.getObjectByName('arms');
|
||
assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
|
||
assert(arms.parent === head || arms.parent?.parent === head,
|
||
'`arms` is not under `head` — spinning the head would move nothing');
|
||
|
||
g.scene.updateWorldMatrix(true, true);
|
||
const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||
head.rotation.y += Math.PI / 2;
|
||
head.updateWorldMatrix(true, true);
|
||
const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||
head.rotation.y -= Math.PI / 2;
|
||
head.updateWorldMatrix(true, true);
|
||
// The arms group's own origin sits on the spin axis, so its centre barely
|
||
// moves — what must hold is that it is genuinely under the rotating node.
|
||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||
});
|
||
|
||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||
// rather than reloading, so all three have to be present at once.
|
||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||
const g = loaded.get('garden_bed');
|
||
assert(g, 'garden_bed did not load');
|
||
for (const state of ['plants_full', 'plants_tattered', 'plants_dead']) {
|
||
assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
|
||
}
|
||
});
|
||
}
|