HardYards/web/world/js/tests/e.test.js
m3ultra 0f5f4d8368 Make rake_pivot a real pivot; log Sprint 2 in THREADS
rake_pivot shipped as a childless empty, so rotating it moved nothing — and
rotating the whole GLB instead, which is the only alternative, tips the concrete
footing out of the ground along with the post. DESIGN.md makes raking away from
the load a player decision, so the handle has to actually work.

It now holds post + pad_eye + top_anchor, with footing left on the root: rake it
8° and the post leans while the concrete stays planted. Asserted both directions
in e.test.js — the head must travel over 0.3 m and the footing under 0.01.

Same class as the canopy bug, found the same way: by driving the handle in a
test instead of eyeballing the model. Exported dims unchanged.

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

247 lines
12 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'] },
];
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 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');
});
// 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`);
}
});
}