// 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, MOVES, FOCALS, MOVE_MAX_DUR } from './grammar.js'; import { fovForFocal, solveShot, applyLight, solveMove, moveDuration, stepShot, stepFocal, applyOps } 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'); // ================= M11 CAMERA MOVES ================= // Independent check that a camera state actually LOOKS AT a point: three's Euler('XYZ') builds // the camera's +Z column as [sin y, −sin x·cos y, cos x·cos y] (Matrix4.makeRotationFromEuler), // and a camera looks down −Z. So (pos − aim) normalised must equal that column. Derived from // three's matrix, NOT from lookAtEuler's internals — so it catches a wrong solver, not a typo. const zAxis = ([x, y, z]) => [Math.sin(y), -Math.sin(x)*Math.cos(y), Math.cos(x)*Math.cos(y)]; const unit = v => { const n = Math.hypot(...v) || 1; return v.map(c => c/n); }; const dist = (a, b) => Math.hypot(a[0]-b[0], a[1]-b[1], a[2]-b[2]); function aimsAt(state, aim, what){ const want = unit([state.pos[0]-aim[0], state.pos[1]-aim[1], state.pos[2]-aim[2]]), got = zAxis(state.rot); for(let i = 0; i < 3; i++) assert.ok(near(want[i], got[i], 1e-9), `${what}: camera does not aim at the subject (axis ${i}: ${got[i]} vs ${want[i]})`); } const SUBJ = { bboxMin: [-0.3, 0, -0.2], bboxMax: [0.3, 1.7, 0.2] }; // a 1.7m person at the origin const mv = (move, o = {}) => solveMove({ ...SUBJ, shot: 'MS', angle: 'eye', focal: 35, move, ...o }); // the aim checker must have teeth: a camera turned 17° off its subject has to fail it assert.throws(() => aimsAt({ pos: [0, 1.1, 3], rot: [0, 0.3, 0] }, [0, 1.1, 0], 'rigged'), /does not aim/, 'aimsAt would catch a mis-aimed camera'); // ---- ladders ---- assert.equal(stepShot('MS', 1), 'MCU', 'push in: MS → MCU'); assert.equal(stepShot('MS', -1), 'MWS', 'pull out: MS → MWS'); assert.equal(stepShot('ECU', 1), 'ECU', 'tightest rung clamps'); assert.equal(stepFocal(35, 1), 50, 'zoom in: 35 → 50mm'); assert.equal(stepFocal(35, -1), 24, 'zoom out: 35 → 24mm'); assert.ok(stepFocal(85, 1) > 85 && stepFocal(18, -1) < 18, 'past the ends: keep going by factor'); // ---- PUSH IN / PULL OUT: dolly along the view axis ---- const push = mv('push_in'), pull = mv('pull_out'); assert.ok(dist(push.to.pos, push.aim) < dist(push.from.pos, push.aim), 'push in ends CLOSER'); assert.ok(dist(pull.to.pos, pull.aim) > dist(pull.from.pos, pull.aim), 'pull out ends FARTHER'); aimsAt(push.from, push.aim, 'push in start'); aimsAt(push.to, push.aim, 'push in end'); aimsAt(pull.from, pull.aim, 'pull out start'); aimsAt(pull.to, pull.aim, 'pull out end'); // the END distance is exactly the next rung's shot distance (MS → MCU), and it is a DOLLY: // rotation and fov are untouched, and the move is purely along the start→aim ray. assert.ok(near(dist(push.to.pos, push.aim), solveShot({ ...SUBJ, shot: 'MCU', focal: 35 }).dist, 1e-9), 'push in lands at the MCU distance'); for(let i = 0; i < 3; i++) assert.ok(near(push.to.rot[i], push.from.rot[i], 1e-12), 'dolly does not re-aim'); assert.equal(push.to.fov, push.from.fov, 'a dolly does NOT change fov'); const along = unit([push.aim[0]-push.from.pos[0], push.aim[1]-push.from.pos[1], push.aim[2]-push.from.pos[2]]); const trav = [push.to.pos[0]-push.from.pos[0], push.to.pos[1]-push.from.pos[1], push.to.pos[2]-push.from.pos[2]]; const travU = unit(trav); for(let i = 0; i < 3; i++) assert.ok(near(travU[i], along[i], 1e-9), 'push in travels along the view axis'); // already at the tightest rung → still moves in (clamp factor), still aims const ecu = mv('push_in', { shot: 'ECU' }); assert.ok(dist(ecu.to.pos, ecu.aim) < dist(ecu.from.pos, ecu.aim), 'ECU push in still pushes'); aimsAt(ecu.to, ecu.aim, 'ECU push in end'); // ---- TRUCK: lateral, parallel, subject stays framed ---- for(const [name, sign] of [['truck_L', -1], ['truck_R', 1]]){ const t = mv(name); const d0 = dist(t.from.pos, t.aim), d1 = dist(t.to.pos, t.aim); assert.ok(Math.abs(d1/d0 - 1) < 0.1, `${name}: distance to subject roughly constant (${d0} → ${d1})`); assert.deepEqual(t.to.rot, t.from.rot, `${name}: camera stays PARALLEL (no re-aim)`); assert.equal(t.to.fov, t.from.fov, `${name}: no lens change`); const dv = [t.to.pos[0]-t.from.pos[0], t.to.pos[1]-t.from.pos[1], t.to.pos[2]-t.from.pos[2]]; const view = unit([t.aim[0]-t.from.pos[0], t.aim[1]-t.from.pos[1], t.aim[2]-t.from.pos[2]]); assert.ok(Math.hypot(...dv) > 0.2, `${name}: actually moves`); assert.ok(near(dv[0]*view[0] + dv[1]*view[1] + dv[2]*view[2], 0, 1e-9), `${name}: purely lateral`); assert.ok(near(dv[1], 0, 1e-12), `${name}: stays level`); // the subject must still be inside the frame: lateral offset < half the frame width at that distance const halfW = Math.tan(t.from.fov/2 * Math.PI/180) * d0 * (16/9); assert.ok(Math.hypot(...dv) < halfW, `${name}: subject stays in frame (${Math.hypot(...dv)} < ${halfW})`); // the subject faces +Z at yaw 0, so the camera sits on +Z looking −Z: its own right is +X assert.equal(Math.sign(dv[0]), sign, `${name}: goes to the camera's own ${sign < 0 ? 'left' : 'right'}`); } const tl = mv('truck_L'), tr = mv('truck_R'); for(const i of [0, 2]) assert.ok(near(tl.to.pos[i] - tl.from.pos[i], -(tr.to.pos[i] - tr.from.pos[i]), 1e-12), 'truck left is exactly the mirror of truck right'); // framing is span-locked, so the lateral METRES are the same on any lens (what changes is the angle // swept). That is the whole point of frame-relative amounts — assert it, don't assume it. const lat = m => m.to.pos[0] - m.from.pos[0]; assert.ok(near(lat(mv('truck_R', { focal: 85 })), lat(tr), 1e-9), 'truck is frame-relative, not lens-relative'); assert.ok(lat(mv('truck_R', { shot: 'WS' })) > lat(tr), 'a wider shot trucks farther'); // ---- CRANE: vertical + re-aim ---- const up = mv('crane_up'), down = mv('crane_down'); assert.ok(up.to.pos[1] > up.from.pos[1], 'crane up rises'); assert.ok(down.to.pos[1] < down.from.pos[1], 'crane down drops'); for(const i of [0, 2]){ assert.ok(near(up.to.pos[i], up.from.pos[i], 1e-12), 'crane is vertical only'); assert.ok(near(down.to.pos[i], down.from.pos[i], 1e-12), 'crane is vertical only'); } aimsAt(up.to, up.aim, 'crane up end'); aimsAt(down.to, down.aim, 'crane down end'); // eye level starts flat; rising tips the lens DOWN (negative pitch), dropping tips it UP assert.ok(near(up.from.rot[0], 0, 1e-9), 'eye-level start is flat'); assert.ok(up.to.rot[0] < -0.01, `crane up pitches down (${up.to.rot[0]})`); assert.ok(down.to.rot[0] > 0.01, `crane down pitches up (${down.to.rot[0]})`); assert.equal(up.to.fov, up.from.fov, 'crane changes no lens'); // ---- ORBIT: constant distance, exact sweep ---- for(const [name, sign] of [['orbit_L', -1], ['orbit_R', 1]]){ const o = mv(name); assert.ok(near(dist(o.to.pos, o.aim), dist(o.from.pos, o.aim), 1e-9), `${name}: constant distance`); assert.ok(near(o.to.pos[1], o.from.pos[1], 1e-12), `${name}: stays at its height`); aimsAt(o.to, o.aim, `${name} end`); const ang = p => Math.atan2(p[0] - o.aim[0], p[2] - o.aim[2]); const swept = ((ang(o.to.pos) - ang(o.from.pos)) * 180/Math.PI + 540) % 360 - 180; assert.ok(near(swept, sign * MOVES[name].degrees, 1e-9), `${name}: swept ${swept}° != ${sign*25}°`); assert.equal(o.to.fov, o.from.fov, `${name}: no lens change`); } // a custom sweep is honoured (a 90° orbit_R on a subject at the origin lands due +X, still framed) const q = mv('orbit_R', { degrees: 90 }); assert.ok(near(q.to.pos[0] - q.aim[0], dist(q.from.pos, q.aim), 1e-9) && near(q.to.pos[2], q.aim[2], 1e-9), 'a 90° orbit ends square to the side, at the same distance'); aimsAt(q.to, q.aim, '90° orbit end'); // one mental model: orbit left and truck left both leave toward the camera's left assert.equal(Math.sign(mv('orbit_L').to.pos[0] - mv('orbit_L').from.pos[0]), Math.sign(tl.to.pos[0] - tl.from.pos[0]), 'orbit left goes the same way as truck left'); // ---- ZOOM: fov ONLY. This is the assertion that keeps a zoom honestly distinct from a dolly. ---- const zin = mv('zoom_in'), zout = mv('zoom_out'); for(const [n, z] of [['zoom_in', zin], ['zoom_out', zout]]){ assert.deepEqual(z.to.pos, z.from.pos, `${n}: the camera does NOT move`); assert.deepEqual(z.to.rot, z.from.rot, `${n}: the camera does NOT turn`); assert.ok(near(dist(z.to.pos, z.aim), dist(z.from.pos, z.aim), 0), `${n}: distance to subject unchanged`); } assert.ok(zin.to.fov < zin.from.fov, 'zoom in narrows the fov'); assert.ok(zout.to.fov > zout.from.fov, 'zoom out widens it'); assert.ok(near(zin.to.fov, fovForFocal(50), 1e-12) && zin.endFocal === 50, 'zoom in = the next lens up'); // and the pair that proves the two are not the same button: push in moves and keeps fov, // zoom in keeps position and changes fov — same intent, opposite mechanism. assert.ok(push.to.fov === push.from.fov && !near(dist(push.to.pos, push.aim), dist(push.from.pos, push.aim), 1e-6), 'push in = position, not lens'); assert.ok(zin.to.pos.every((v, i) => v === zin.from.pos[i]) && zin.to.fov !== zin.from.fov, 'zoom in = lens, not position'); // ---- durations ---- assert.equal(moveDuration(0, 10), MOVE_MAX_DUR, 'whole scene → capped at the max'); assert.equal(moveDuration(8, 10), 2, 'default = what is left of the scene'); assert.equal(moveDuration(9.9, 10), 0.5, 'never shorter than half a second'); assert.equal(moveDuration(0, 0), MOVE_MAX_DUR, 'no timeline → the cap'); assert.equal(moveDuration(12, 10), MOVE_MAX_DUR, 'playhead past the end → the cap'); // ---- applyOps: the `move` event contract Lane B consumes, and "+ angle" ---- const fired = []; globalThis.dispatchEvent = ev => { fired.push(ev.detail); return true; }; globalThis.window = { timeline: { time: 2, duration: 10 } }; // playhead at 2s in a 10s scene const sstub = { _ents: [], _n: 0, _active: null, _tf: {}, entities(){ return this._ents; }, getEntity(id){ return this._ents.find(e => e.id === id); }, async addEntity(d){ const en = { id: 'e' + (++this._n), kind: d.kind, label: d.label, params: { ...d.params } }; this._ents.push(en); this._tf[en.id] = { pos:[0,0,0], rot:[0,0,0], scale:1 }; return en; }, entityTransform(id){ return this._tf[id]; }, entityBBox(id){ return id === 'subj' ? { min: SUBJ.bboxMin, max: SUBJ.bboxMax } : null; }, setTransform(id, t){ this._tf[id] = { ...this._tf[id], ...t }; }, setParam(id, k, v){ const e = this.getEntity(id); if(e) e.params[k] = v; }, setActiveCamera(id){ this._active = id; }, activeCamera(){ return this._active; }, viewAspect(){ return 16/9; }, }; sstub._ents.push({ id: 'subj', kind: 'character', label: 'man', params: {} }); sstub._tf.subj = { pos: [0,0,0], rot: [0,0,0], scale: 1 }; const [m1] = await applyOps(sstub, [{ op:'move', subject:'man', move:'push_in', shot:'MS', focal:35 }]); assert.ok(!m1.error, 'move op applied: ' + m1.error); const ev = fired.at(-1); assert.equal(ev.op, 'move', 'op name'); for(const k of ['camId', 't0', 'dur', 'ease', 'from', 'to', 'entityIds']) assert.ok(ev[k] !== undefined, 'move event carries ' + k); assert.equal(ev.t0, 2, 't0 = the playhead'); assert.equal(ev.dur, 6, 'dur = rest of scene (8s) capped at 6'); assert.equal(ev.ease, 'inout', 'default ease'); assert.deepEqual(ev.entityIds, [ev.camId], 'entityIds = the camera'); for(const s of [ev.from, ev.to]){ assert.ok(Array.isArray(s.pos) && s.pos.length === 3 && s.pos.every(Number.isFinite), 'state pos'); assert.ok(Array.isArray(s.rot) && s.rot.length === 3 && s.rot.every(Number.isFinite), 'state rot'); assert.ok(Number.isFinite(s.fov), 'state fov'); } // the START state is LIVE on the camera (the viewport shows the move's first frame) assert.deepEqual(sstub.entityTransform(ev.camId).pos, ev.from.pos, 'start pos applied to the camera'); assert.deepEqual(sstub.entityTransform(ev.camId).rot, ev.from.rot, 'start rot applied to the camera'); assert.equal(sstub.getEntity(ev.camId).params.fov, ev.from.fov, 'start fov applied to the camera'); assert.equal(sstub._active, ev.camId, 'the move camera goes live'); // explicit dur/ease win; a bad ease falls back rather than shipping garbage to the timeline const [m2] = await applyOps(sstub, [{ op:'move', subject:'man', move:'orbit_L', dur:2.5, ease:'linear' }]); assert.equal(fired.at(-1).dur, 2.5, 'explicit dur'); assert.equal(fired.at(-1).ease, 'linear', 'linear ease'); await applyOps(sstub, [{ op:'move', subject:'man', move:'orbit_L', ease:'bouncy' }]); assert.equal(fired.at(-1).ease, 'inout', 'unknown ease → inout'); assert.equal(m2.camId, ev.camId, 'a second move reuses the same shot camera'); assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 1, 'no camera spam'); // "+ angle" (newCam) — a NEW camera, existing ones untouched const camA = ev.camId, posA = [...sstub.entityTransform(camA).pos]; const [s2] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'CU', angle:'low', focal:85, newCam:true }]); assert.ok(!s2.error, '+ angle applied: ' + s2.error); assert.notEqual(s2.camId, camA, '+ angle made a NEW camera'); assert.equal(sstub.getEntity(s2.camId).label, 'cam2', 'auto label cam2'); assert.deepEqual(sstub.entityTransform(camA).pos, posA, '+ angle leaves the existing camera alone'); assert.equal(sstub._active, s2.camId, 'the new angle goes live'); assert.equal(fired.at(-1).op, 'shot', '+ angle still emits a shot op (B keys + cuts it)'); const [s3] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'WS', newCam:true }]); assert.equal(sstub.getEntity(s3.camId).label, 'cam3', 'next one is cam3'); assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 3, 'three cameras of coverage'); // 🎥 frame keeps adjusting WHAT YOU ARE LOOKING AT: with cam3 live it reframes cam3, makes no // camera, and leaves the other two where they are (this is the bug + angle would otherwise cause). const posB = [...sstub.entityTransform(camA).pos], pos2 = [...sstub.entityTransform(s2.camId).pos]; const [s4] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'MCU' }]); assert.equal(s4.camId, s3.camId, '🎥 frame reframes the LIVE camera'); assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 3, 'no camera created'); assert.deepEqual(sstub.entityTransform(camA).pos, posB, 'other cameras untouched'); assert.deepEqual(sstub.entityTransform(s2.camId).pos, pos2, 'other cameras untouched'); sstub.setActiveCamera(null); // director orbit cam live → fall back to the old shotcam-or-first const [s5] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'MS' }]); assert.equal(s5.camId, camA, 'no active camera → the original shotcam, as before'); console.log('presets_test: ALL PASS (' + Object.keys(MOVES).length + ' moves; push in ' + dist(push.from.pos, push.aim).toFixed(2) + 'm → ' + dist(push.to.pos, push.aim).toFixed(2) + 'm, orbit ±' + MOVES.orbit_R.degrees + '°, zoom ' + FOCALS[FOCALS.indexOf(35)] + '→' + zin.endFocal + 'mm at a fixed ' + dist(zin.from.pos, zin.aim).toFixed(2) + 'm)');