HardYards/web/world/js/tests/e.test.js
m3ultra f8710b56bc Sweep and taper the tree limbs; pin the anchors that ride them
Each limb was one straight uniform tube — a peg poking out of a pole, and the
most visible art problem in docs/yard_day.jpg. They now leave the trunk thick,
sweep up on a bezier and taper to nothing, which is what a gum does. +96 tris on
gum_01, silhouette bounds unchanged.

Nothing physical moved. The tip is a bezier endpoint and the four rng draws per
branch are the same four in the same order, so every branch_anchor_* is
bit-for-bit where it was — verified before and after, all five identical to 6dp.
Lane A's winning line rigs off t2 and is untouched; no balance number can shift.

e.test.js now hard-codes those five world positions as a tripwire, so no future
art change of mine can quietly invalidate a sprint of someone else's physics. If
it goes red, the art moved an anchor — fix the art, not the numbers.

Card text and the dawn-tint treatment go to Lane A in THREADS. The tint is a CSS
snippet rather than a ninth texture: only two of my eight are consumed, and a
full-screen gradient needs no asset at all.

Selftest 262/0/0, 38/38 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:25:03 +10:00

351 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'] },
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
nodes: ['stump', 'head', 'hat', 'shards'] },
{ name: 'fence_panel_snapped', h: [1.70, 1.90],
nodes: ['palings', 'rails', 'debris_palings'] },
{ name: 'broom_01', h: [1.35, 1.50],
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
{ name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
];
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`);
});
}
// These five numbers are load-bearing for the whole repo: Lane A's winning
// line rigs off t2, and every balance figure B and C measured assumes the
// branch anchors sit exactly here. I reshaped the limbs in Sprint 7 for looks
// and pinned the tips bit-for-bit to do it; this is the tripwire that keeps
// the next bit of art from quietly invalidating a sprint of physics.
// If it fails, the art moved an anchor — fix the art, don't touch the numbers.
t.test('branch anchors have not moved — the balance numbers depend on them', () => {
const EXPECT = {
tree_gum_01: {
branch_anchor_01: [-0.960, 3.636, -1.460], // Blender (x,y,z) -> glTF (x,z,-y)
branch_anchor_02: [-0.941, 4.145, 0.777],
branch_anchor_03: [-1.382, 5.227, 0.978],
},
tree_gum_02: {
branch_anchor_01: [-0.167, 2.950, 1.154],
branch_anchor_02: [0.142, 3.722, 1.043],
},
};
for (const [tree, anchors] of Object.entries(EXPECT)) {
const g = loaded.get(tree);
assert(g, `${tree} did not load`);
g.scene.updateWorldMatrix(true, true);
for (const [n, want] of Object.entries(anchors)) {
const o = g.scene.getObjectByName(n);
assert(o, `${tree}/${n} missing`);
const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
const got = [p.x, p.y, p.z];
for (let i = 0; i < 3; i++) {
assert(Math.abs(got[i] - want[i]) < 0.005,
`${tree}/${n} moved on ${'xyz'[i]}: ${got[i].toFixed(3)} vs ${want[i]} ` +
`(full ${got.map((v) => v.toFixed(3)).join(', ')})`);
}
}
}
});
// 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 16 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');
});
// DESIGN.md: rake the post away from the load — so rake is a runtime rotation,
// not baked. Rotating rake_pivot must carry the post and its top_anchor over
// while the concrete footing stays level in the ground. A post whose footing
// tips out of the dirt with it isn't raked, it's falling.
t.test('sail_post rakes about rake_pivot with the footing left planted', () => {
const g = loaded.get('sail_post');
assert(g, 'sail_post did not load');
const rake = g.scene.getObjectByName('rake_pivot');
const anchor = g.scene.getObjectByName('top_anchor');
const footing = g.scene.getObjectByName('footing');
assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing');
const at = () => {
g.scene.updateWorldMatrix(true, true);
return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld),
new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)];
};
const [a0, f0] = at();
rake.rotation.x += (8 * Math.PI) / 180; // Lane A rakes 8°
const [a1, f1] = at();
rake.rotation.x -= (8 * Math.PI) / 180;
g.scene.updateWorldMatrix(true, true);
const head = a0.distanceTo(a1), foot = f0.distanceTo(f1);
assert(head > 0.3,
`an 8° rake moved the head only ${head.toFixed(3)} m — rake_pivot has no children`);
assert(foot < 0.01,
`the footing moved ${foot.toFixed(3)} m — concrete should stay planted`);
});
// 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');
});
// Custom props are a contract, not decoration — and I have been telling other
// lanes to read these since Sprint 1 without ever checking they survive the
// export. glTF `extras` arrive as three's userData, but only if export_extras
// held all the way through; if it silently dropped, Lane A's gnome scores $0
// and Lane B's anchors all rate the same, both of which would look like a
// gameplay decision rather than a missing field.
t.test('glTF extras survive as userData — the props other lanes read', () => {
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
assert(gnome, 'gnome root node missing');
assert(gnome.userData?.collateral_value === 25,
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
assert(typeof canopy?.userData?.sway_amp === 'number',
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
assert(typeof branch?.userData?.rating_hint === 'number',
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
assert(typeof bin?.userData?.mass_hint === 'number',
'wheelie_bin mass_hint lost — Lane C throws it with this');
});
// The wreckage has to drop into the intact asset's place, so both variants
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
// swap needs a fudge offset per prop and will grow one.
t.test('broken variants sit on the same ground plane as their intact twin', () => {
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
['fence_panel', 'fence_panel_snapped']]) {
for (const n of [intact, broken]) {
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
assert(Math.abs(box.min.y) < 0.03,
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
}
}
});
// glTF has no node-visibility flag and Blender's hide_render does NOT survive
// the export — every node arrives visible:true. That shipped as a real bug
// from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead
// superimposed, and the asserts missed it because they only checked the nodes
// EXISTED. So the flag rides in extras, and this pins the flag, not the node.
t.test('optional nodes carry hidden_by_default — hide_render does not export', () => {
const bed = loaded.get('garden_bed')?.scene;
assert(bed, 'garden_bed did not load');
const on = ['plants_full'], off = ['plants_tattered', 'plants_dead'];
for (const n of on) {
assert(!bed.getObjectByName(n).userData?.hidden_by_default,
`${n} should start visible — it's the default state`);
}
for (const n of off) {
assert(bed.getObjectByName(n).userData?.hidden_by_default === true,
`${n} must carry hidden_by_default or the bed renders all three wilt states at once`);
}
const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow');
assert(glow?.userData?.hidden_by_default === true,
'window_glow must carry hidden_by_default or the house is lit at noon');
});
// 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`);
}
});
}