// presets_test.mjs — Lane A M5 self-check. Runs under plain `node`, no deps, no three.js // (presets.js takes bboxes as plain arrays; expected values below are computed inline). // node scenegod/web/presets_test.mjs import assert from 'node:assert/strict'; import { LIGHTS, SHOTS } from './grammar.js'; import { fovForFocal, solveShot, applyLight } from './presets.js'; const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps; const R2D = 180 / Math.PI; // ---- fov from focal: 2·atan(12/f) full-frame vertical ---- for(const [mm, want] of [[18, 2*Math.atan(12/18)*R2D], [35, 2*Math.atan(12/35)*R2D], [85, 2*Math.atan(12/85)*R2D]]) assert.ok(near(fovForFocal(mm), want, 1e-9), `fov(${mm}mm) = ${fovForFocal(mm)} != ${want}`); assert.ok(near(fovForFocal(35), 37.849, 0.001), '35mm ≈ 37.849° vertical'); // ---- shot distance: CU frames tighter than MS on a 1.7m subject → shorter camera distance ---- const bbox = { bboxMin: [-0.3, 0, -0.2], bboxMax: [0.3, 1.7, 0.2] }; const cu = solveShot({ ...bbox, shot: 'CU', focal: 35 }); const ms = solveShot({ ...bbox, shot: 'MS', focal: 35 }); assert.ok(cu.dist < ms.dist, `CU dist ${cu.dist} !< MS dist ${ms.dist}`); // exact: dist = (span·H/2) / tan(fovV/2) const expDist = s => (SHOTS[s].span * 1.7 / 2) / Math.tan(Math.atan(12/35)); assert.ok(near(cu.dist, expDist('CU'), 1e-9) && near(ms.dist, expDist('MS'), 1e-9), 'dist formula'); // aim height sits at the shot's center fraction of subject height assert.ok(near(cu.aim[1], 0.87 * 1.7, 1e-9), 'CU centers at .87H'); // ---- dutch angle sets roll (euler z), straight-on shot keeps yaw/pitch 0 ---- const dutch = solveShot({ ...bbox, shot: 'MS', angle: 'dutch', focal: 35 }); assert.ok(near(dutch.rot[2], 8 * Math.PI/180, 1e-9), `dutch roll ${dutch.rot[2]*R2D}° != 8°`); assert.ok(near(dutch.rot[0], 0, 1e-9) && near(dutch.rot[1], 0, 1e-9), 'dutch: no stray pitch/yaw'); const eye = solveShot({ ...bbox, shot: 'MS', angle: 'eye', focal: 35 }); assert.ok(near(eye.rot[2], 0, 1e-9), 'eye level has no roll'); // low angle puts the camera below the aim point, looking up (positive pitch) const low = solveShot({ ...bbox, shot: 'MS', angle: 'low', focal: 35 }); assert.ok(low.pos[1] < low.aim[1] && low.rot[0] > 0, 'low angle: camera below aim, pitched up'); // ---- applyLight('golden_hour') returns the grammar.js values verbatim ---- const calls = []; const stub = { _ents: [], entities(){ return this._ents; }, async addEntity(d){ const en = { id: 'e' + (this._ents.length + 1), kind: d.kind, label: d.label, params: { ...d.params } }; this._ents.push(en); return en; }, setParam(id, k, v){ calls.push(['param', id, k, v]); }, setTransform(id, t){ calls.push(['tf', id, t]); }, }; const g = LIGHTS.golden_hour; const v = await applyLight(stub, 'golden_hour'); assert.deepEqual(v.sun, g.sun, 'sun values from grammar'); assert.deepEqual(v.ambient, g.ambient, 'ambient values from grammar'); assert.equal(v.bg, g.bg, 'bg from grammar'); assert.ok(v.sunId && v.ambientId, 'sun + ambient entities created'); assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.sunId && c[2] === 'intensity' && c[3] === g.sun.intensity), 'sun intensity applied to stage'); assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.ambientId && c[2] === 'bg' && c[3] === g.bg), 'bg applied via ambient entity'); // second call reuses the same entities (no light spam) await applyLight(stub, 'noir'); assert.equal(stub._ents.length, 2, 'presets reuse the sun/ambient pair'); console.log('presets_test: ALL PASS');