diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 8ad5581..c9eeabe 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.48.0", + "version": "3.49.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 3b4f989..4e8a946 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -6621,102 +6621,9 @@ import { createArp } from './src/arp.js'; // h3d-carve-12 getMAccentCore: () => mAccentCore, }); - /* ── Lookahead fret bounds + smooth camera ───────────────────────── */ - // End time of the lookahead window = start of the measure that is - // CAM_LOOKAHEAD_MEASURES measures ahead of the current one. Uses the - // _measureStarts cache (times of beats with measure !== -1). With no - // beats it falls back to CAM_LOOKAHEAD_SEC seconds. Past the last known - // measure it extrapolates using the average measure duration. - function lookaheadEndTime(now) { - const ms = _measureStarts; - if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC; - // Binary search: lo = first index with ms[lo] > now. - let lo = 0, hi = ms.length; - while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; } - const curIdx = lo - 1; // current measure (-1 if before the first) - const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES; - if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx]; - // Past the last measure: extrapolate using the average measure duration. - if (ms.length >= 2) { - const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1); - if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg; - } - return now + CAM_LOOKAHEAD_SEC; - } - - // Earliest future chart time whose lookahead end reaches eventTime. - // lookaheadEndTime() is monotonic but measure-stepped, so a small - // bounded binary search works for both measure grids and the seconds - // fallback without duplicating/inverting its edge-case logic. - function lookaheadBootstrapTime(now, eventTime) { - if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now; - let lo = now; - let hi = eventTime; - for (let i = 0; i < 32; i++) { - const mid = (lo + hi) * 0.5; - if (lookaheadEndTime(mid) >= eventTime) hi = mid; - else lo = mid; - } - return hi; - } - - function lookaheadComputeFretBounds(now, anchors, notes, chords) { - const tEnd = lookaheadEndTime(now); - let minF = 99; - let maxF = 0; - let any = false; - if (anchors && anchors.length) { - for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) { - const a = getChartAnchorAt(anchors, tt); - if (!a) continue; - let fStart = Math.round(Number(a.fret)); - if (!Number.isFinite(fStart) || fStart < 1) fStart = 1; - let w = Number(a.width); - if (!Number.isFinite(w)) w = 4; - w = Math.max(1, Math.round(w)); - const fHi = Math.min(NFRETS, fStart + w - 1); - minF = Math.min(minF, fStart); - maxF = Math.max(maxF, fHi); - any = true; - } - } - const consider = f => { - if (!(f > 0)) return; - minF = Math.min(minF, f); - maxF = Math.max(maxF, f); - any = true; - }; - if (notes) { - let i = lowerBoundT(notes, now); - for (; i < notes.length; i++) { - const n = notes[i]; - if (n.t > tEnd) break; - if (!validString(n.s)) continue; - consider(n.f); - } - } - if (chords) { - let i = lowerBoundT(chords, now); - for (; i < chords.length; i++) { - const ch = chords[i]; - if (ch.t > tEnd) break; - if (!ch.notes) continue; - for (const cn of ch.notes) { - if (!validString(cn.s)) continue; - consider(cn.f); - } - } - } - if (!any || minF > maxF) return null; - return { minF, maxF }; - } - - function lookaheadTargetWorldX(minF, maxF) { - const wb = CAM_FRET_EDGE_BLEND; - const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5; - const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS); - return middle * (1 - wb) + weighted * wb; - } + /* ── h3d-carve-13: S-section lookahead helpers → src/camera.js ──── */ + // lookaheadEndTime (private), lookaheadBootstrapTime, lookaheadComputeFretBounds, + // lookaheadTargetWorldX extracted below in the createCamera() destructure. function lookaheadSmoothCamStep(dtSec, tgtXWorld, tgtSpanInt) { const d = Math.min(0.2, Math.max(1e-4, dtSec)); @@ -11698,7 +11605,7 @@ import { createArp } from './src/arp.js'; // h3d-carve-12 /* ── h3d-carve-10: drawScoreFx → src/score-fx.js ────────────────── */ /* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */ - const { effectiveVfov, camUpdate } = createCamera({ + const { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX } = createCamera({ // Constants BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV, CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE, @@ -11728,6 +11635,10 @@ import { createArp } from './src/arp.js'; // h3d-carve-12 aspectPaneKey: _aspectPaneKey, resolveTuneFor: _resolveTuneFor, aspectRegisterPane: _aspectRegisterPane, + // h3d-carve-13: S-section lookahead (+9 DI params) + NFRETS, CAM_LOOKAHEAD_MEASURES, CAM_LOOKAHEAD_SEC, CAM_FRET_EDGE_BLEND, + getMeasureStarts: () => _measureStarts, + validString, getChartAnchorAt, xFretMid, xFret, }); /* ── Resize helper ───────────────────────────────────────────────── */ diff --git a/plugins/highway_3d/src/camera.js b/plugins/highway_3d/src/camera.js index 5aa5c4c..a22096f 100644 --- a/plugins/highway_3d/src/camera.js +++ b/plugins/highway_3d/src/camera.js @@ -15,7 +15,7 @@ // Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor, // aspectRegisterPane -import { computeBPM } from './geometry.js'; // h3d-carve-1 +import { computeBPM, lowerBoundT, camBaseDistU, camLowFretPullbackU } from './geometry.js'; // h3d-carve-1,13 import { _ssActive } from './utils.js'; // h3d-carve-3 export function createCamera({ @@ -41,6 +41,10 @@ export function createCamera({ getFretRowFitBoost, setFretRowFitBoost, // ── Function refs ─────────────────────────────────────────────────────── sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane, + // h3d-carve-13: S-section lookahead — +4 const shorthand, +1 getter, +4 fn refs + NFRETS, CAM_LOOKAHEAD_MEASURES, CAM_LOOKAHEAD_SEC, CAM_FRET_EDGE_BLEND, + getMeasureStarts, + validString, getChartAnchorAt, xFretMid, xFret, }) { // Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the // camera should use for the given pane aspect. With the bridge off (or @@ -272,5 +276,100 @@ export function createCamera({ setFretRowFitBoost(_fretRowFitBoost); // write-back } - return { effectiveVfov, camUpdate }; + /* ── h3d-carve-13: S-section lookahead helpers ───────────────────── */ + // VERBATIM MOVE from screen.js Region A (original lines 6630-6719). + // 1 beyond-subst: _measureStarts → getMeasureStarts() (live getter). + // lookaheadEndTime: factory-private (no external callers). + function lookaheadEndTime(now) { + const ms = getMeasureStarts(); // h3d-carve-13: _measureStarts → getMeasureStarts() + if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC; + // Binary search: lo = first index with ms[lo] > now. + let lo = 0, hi = ms.length; + while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; } + const curIdx = lo - 1; // current measure (-1 if before the first) + const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES; + if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx]; + // Past the last measure: extrapolate using the average measure duration. + if (ms.length >= 2) { + const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1); + if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg; + } + return now + CAM_LOOKAHEAD_SEC; + } + + // Earliest future chart time whose lookahead end reaches eventTime. + // lookaheadEndTime() is monotonic but measure-stepped, so a small + // bounded binary search works for both measure grids and the seconds + // fallback without duplicating/inverting its edge-case logic. + function lookaheadBootstrapTime(now, eventTime) { + if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now; + let lo = now; + let hi = eventTime; + for (let i = 0; i < 32; i++) { + const mid = (lo + hi) * 0.5; + if (lookaheadEndTime(mid) >= eventTime) hi = mid; + else lo = mid; + } + return hi; + } + + function lookaheadComputeFretBounds(now, anchors, notes, chords) { + const tEnd = lookaheadEndTime(now); + let minF = 99; + let maxF = 0; + let any = false; + if (anchors && anchors.length) { + for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) { + const a = getChartAnchorAt(anchors, tt); + if (!a) continue; + let fStart = Math.round(Number(a.fret)); + if (!Number.isFinite(fStart) || fStart < 1) fStart = 1; + let w = Number(a.width); + if (!Number.isFinite(w)) w = 4; + w = Math.max(1, Math.round(w)); + const fHi = Math.min(NFRETS, fStart + w - 1); + minF = Math.min(minF, fStart); + maxF = Math.max(maxF, fHi); + any = true; + } + } + const consider = f => { + if (!(f > 0)) return; + minF = Math.min(minF, f); + maxF = Math.max(maxF, f); + any = true; + }; + if (notes) { + let i = lowerBoundT(notes, now); + for (; i < notes.length; i++) { + const n = notes[i]; + if (n.t > tEnd) break; + if (!validString(n.s)) continue; + consider(n.f); + } + } + if (chords) { + let i = lowerBoundT(chords, now); + for (; i < chords.length; i++) { + const ch = chords[i]; + if (ch.t > tEnd) break; + if (!ch.notes) continue; + for (const cn of ch.notes) { + if (!validString(cn.s)) continue; + consider(cn.f); + } + } + } + if (!any || minF > maxF) return null; + return { minF, maxF }; + } + + function lookaheadTargetWorldX(minF, maxF) { + const wb = CAM_FRET_EDGE_BLEND; + const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5; + const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS); + return middle * (1 - wb) + weighted * wb; + } + + return { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX }; } diff --git a/tests/js/highway_3d_camera_bootstrap.test.js b/tests/js/highway_3d_camera_bootstrap.test.js index d1a6e7d..65619cc 100644 --- a/tests/js/highway_3d_camera_bootstrap.test.js +++ b/tests/js/highway_3d_camera_bootstrap.test.js @@ -263,7 +263,8 @@ test('createCamera({...}) wiring has correct naming correspondence (no param swa }; // 1. Extract the argument block from the createCamera call. - const ANCHOR = 'const { effectiveVfov, camUpdate } = createCamera({'; + // h3d-carve-13: destructure expanded with lookahead exports — update anchor string. + const ANCHOR = 'const { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX } = createCamera({'; const callStart = src.indexOf(ANCHOR); assert.ok(callStart >= 0, 'createCamera call must be findable in screen.js'); const blockStart = callStart + ANCHOR.length - 1; // points to the opening { diff --git a/tests/js/highway_3d_camera_framing.test.js b/tests/js/highway_3d_camera_framing.test.js index e89b116..394a9d0 100644 --- a/tests/js/highway_3d_camera_framing.test.js +++ b/tests/js/highway_3d_camera_framing.test.js @@ -98,27 +98,29 @@ test('measure-start cache only keeps beats with measure >= 0', () => { }); test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => { + // h3d-carve-13: lookaheadEndTime moved to src/camera.js — retarget to cameraSrc. assert.match( - src, + cameraSrc, /function\s+lookaheadEndTime\s*\(\s*now\s*\)/, 'lookaheadEndTime(now) helper must exist', ); assert.match( - src, + cameraSrc, /const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/, 'target measure index = current measure + CAM_LOOKAHEAD_MEASURES', ); // No beats → seconds fallback. assert.match( - src, + cameraSrc, /if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/, 'lookaheadEndTime must fall back to seconds when there are no measures', ); }); test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => { + // h3d-carve-13: lookaheadComputeFretBounds moved to src/camera.js — retarget to cameraSrc. assert.match( - src, + cameraSrc, /function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/, 'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)', ); diff --git a/tests/js/highway_3d_camera_lookahead.test.js b/tests/js/highway_3d_camera_lookahead.test.js new file mode 100644 index 0000000..63bd72f --- /dev/null +++ b/tests/js/highway_3d_camera_lookahead.test.js @@ -0,0 +1,271 @@ +// Behavioral kills for h3d-carve-13: S-section lookahead helpers extracted +// into src/camera.js (createCamera). Covers lookaheadEndTime (via callers), +// lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX. +// +// Kill strategy per god's GO: +// lookaheadComputeFretBounds — real-shaped note/chord/anchor fixtures asserting +// concrete min/max fret bounds; gut the bounds loop → RED. +// lookaheadBootstrapTime / lookaheadTargetWorldX — one input→output assert each; +// gut the function → RED. +// lookaheadEndTime (private) — exercised through its callers. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +// ── Source-level checks ──────────────────────────────────────────────────── +const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js'); + +test('camera.js exports createCamera that returns all 5 expected symbols', () => { + const src = fs.readFileSync(CAMERA_JS, 'utf8'); + assert.match(src, /return\s*\{[^}]*effectiveVfov/, 'effectiveVfov in return'); + assert.match(src, /return\s*\{[^}]*camUpdate/, 'camUpdate in return'); + assert.match(src, /return\s*\{[^}]*lookaheadBootstrapTime/, 'lookaheadBootstrapTime in return'); + assert.match(src, /return\s*\{[^}]*lookaheadComputeFretBounds/, 'lookaheadComputeFretBounds in return'); + assert.match(src, /return\s*\{[^}]*lookaheadTargetWorldX/, 'lookaheadTargetWorldX in return'); +}); + +test('camera.js DI signature includes 9 new h3d-carve-13 params', () => { + const src = fs.readFileSync(CAMERA_JS, 'utf8'); + for (const param of [ + 'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND', + 'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret', + ]) { + assert.match(src, new RegExp(`\\b${param}\\b`), `param ${param} present in camera.js`); + } +}); + +test('lookaheadEndTime is NOT exported (factory-private)', () => { + const src = fs.readFileSync(CAMERA_JS, 'utf8'); + // Must not appear in the return object + assert.doesNotMatch( + src, + /return\s*\{[^}]*lookaheadEndTime/, + 'lookaheadEndTime must not be in the return object', + ); + // But must be defined as a function inside the factory + assert.match(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime defined in factory'); +}); + +test('screen.js Region A contains the carve comment and not the old function defs', () => { + const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + // Old function defs removed + assert.doesNotMatch(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime not in screen.js'); + assert.doesNotMatch(src, /function\s+lookaheadBootstrapTime\s*\(/, 'lookaheadBootstrapTime not in screen.js'); + assert.doesNotMatch(src, /function\s+lookaheadComputeFretBounds\s*\(/, 'lookaheadComputeFretBounds not in screen.js'); + assert.doesNotMatch(src, /function\s+lookaheadTargetWorldX\s*\(/, 'lookaheadTargetWorldX not in screen.js'); + // Carve comment present + assert.match(src, /h3d-carve-13.*S-section lookahead helpers/, 'carve-13 comment present'); + // Destructure at createCamera call site + assert.match(src, /lookaheadBootstrapTime.*lookaheadComputeFretBounds.*lookaheadTargetWorldX.*=\s*createCamera|createCamera[\s\S]{0,800}lookaheadBootstrapTime/, 'exports destructured from createCamera'); +}); + +// ── Behavioral kills (module-level, import camera.js via dynamic import) ── + +// Minimal stub factory matching the 9 new DI params + pre-existing required params. +// Only the fields actually used by the 4 new functions need real values; +// everything else gets a no-op stub so createCamera doesn't throw. +function makeCamera(overrides = {}) { + const fretWidth = 10; // arbitrary unit + // fretX: fret 0 = 0, fret N = N*fretWidth + const fretX = f => f * fretWidth; + const fretMid = f => (f + 0.5) * fretWidth; + return import(`${CAMERA_JS}?t=${Date.now()}`).then(mod => { + return mod.createCamera({ + // Pre-existing DI params (stubs — camUpdate not exercised here) + BASE_VFOV: 60, HORPLUS_START_ASPECT: 1.78, HORPLUS_MIN_VFOV: 30, + CAM_LERP_BASE: 0.05, CAM_H_BASE: 1, CAM_DIST_BASE: 10, + CAM_FRAME_DIST_NEAR: 5, CAM_FRAME_DIST_FAR: 20, + CAM_FRAME_H_NEAR: 1, CAM_FRAME_H_FAR: 2, + CAM_FRAME_D_NEAR: 1, CAM_FRAME_D_FAR: 1.5, + FOCUS_D: 8, S_GAP: 1, K: 1, + FRET_ROW_FIT_NDC_MIN: -0.9, FRET_ROW_FIT_DEADBAND: 0.1, FRET_ROW_FIT_BOOST_MAX: 1.5, + CAM_TILT_BAND_T: 0.1, CAM_TILT_BAND_C: 0.3, CAM_TILT_STR_T: 0.5, CAM_TILT_STR_C: 0.2, + getCam: () => ({ fov: 60, updateProjectionMatrix() {}, position: { set() {} }, lookAt() {}, updateMatrixWorld() {} }), + getTgtX: () => 0, getTgtDist: () => 10, + getAspectScale: () => 1, getLeftyCached: () => false, + getNStr: () => 6, getProbe: () => ({ set() {}, project() {}, y: -0.35 }), + getTiltSmoothing: () => 0.5, getPaneAspect: () => 1.78, getPaneUid: () => 'test', + getHighwayCanvas: () => null, + getCurX: () => 0, setCurX: () => {}, + getCurDist: () => 10, setCurDist: () => {}, + getCurLookY: () => 0, setCurLookY: () => {}, + getTgtLookY: () => 0, setTgtLookY: () => {}, + getFretRowFitBoost: () => 1, setFretRowFitBoost: () => {}, + sY: () => 0, freeCamFor: () => null, + aspectPaneKey: () => 'test', resolveTuneFor: () => null, + aspectRegisterPane: () => {}, + // h3d-carve-13: new params + NFRETS: 24, + CAM_LOOKAHEAD_MEASURES: 9, + CAM_LOOKAHEAD_SEC: 3.0, + CAM_FRET_EDGE_BLEND: 0.1, + getMeasureStarts: overrides.getMeasureStarts ?? (() => []), + validString: overrides.validString ?? (s => s >= 0 && s < 6), + getChartAnchorAt: overrides.getChartAnchorAt ?? (() => null), + xFretMid: overrides.xFretMid ?? fretMid, + xFret: overrides.xFret ?? fretX, + }); + }); +} + +// ── lookaheadComputeFretBounds — concrete note/chord/anchor fixtures ──────── + +test('lookaheadComputeFretBounds: returns null when no notes/chords/anchors', async () => { + const cam = await makeCamera(); + const result = cam.lookaheadComputeFretBounds(0, [], [], []); + assert.equal(result, null); +}); + +test('lookaheadComputeFretBounds: fret bounds from notes array', async () => { + const cam = await makeCamera(); + // now=0, CAM_LOOKAHEAD_SEC=3.0 → window [0, 3.0] + const notes = [ + { t: 0.5, s: 0, f: 5 }, + { t: 1.0, s: 1, f: 9 }, + { t: 1.5, s: 2, f: 3 }, + { t: 10, s: 0, f: 1 }, // outside window — should be excluded + ]; + const result = cam.lookaheadComputeFretBounds(0, null, notes, null); + assert.ok(result !== null, 'should find bounds'); + assert.equal(result.minF, 3, 'minF = 3 (fret 3 note at t=1.5)'); + assert.equal(result.maxF, 9, 'maxF = 9 (fret 9 note at t=1.0)'); +}); + +test('lookaheadComputeFretBounds: fret bounds from chords', async () => { + const cam = await makeCamera(); + const chords = [ + { t: 0.5, notes: [{ s: 0, f: 2 }, { s: 1, f: 7 }] }, + { t: 4.0, notes: [{ s: 0, f: 1 }] }, // outside window + ]; + const result = cam.lookaheadComputeFretBounds(0, null, null, chords); + assert.ok(result !== null); + assert.equal(result.minF, 2); + assert.equal(result.maxF, 7); +}); + +test('lookaheadComputeFretBounds: open strings (f=0) are excluded', async () => { + const cam = await makeCamera(); + // f=0 means open string — consider() guard: !(f > 0) → skip + const notes = [ + { t: 0.5, s: 0, f: 0 }, // open — must be excluded + { t: 1.0, s: 1, f: 8 }, + ]; + const result = cam.lookaheadComputeFretBounds(0, null, notes, null); + assert.ok(result !== null); + assert.equal(result.minF, 8, 'open string excluded'); + assert.equal(result.maxF, 8); +}); + +test('lookaheadComputeFretBounds: invalid string filtered by validString', async () => { + let nStr = 6; + const cam = await makeCamera({ + validString: s => s >= 0 && s < nStr, + }); + const notes = [ + { t: 0.5, s: 99, f: 5 }, // invalid string → skip + { t: 1.0, s: 2, f: 12 }, + ]; + const result = cam.lookaheadComputeFretBounds(0, null, notes, null); + assert.ok(result !== null); + assert.equal(result.minF, 12, 'invalid-string note excluded'); + assert.equal(result.maxF, 12); +}); + +test('lookaheadComputeFretBounds: anchor data contributes to bounds', async () => { + // Anchor: fret=3, width=4 → frets 3..6 + const anchor = { fret: 3, width: 4, time: 0 }; + const cam = await makeCamera({ + getChartAnchorAt: (_arr, _t) => anchor, + }); + // Tiny range so the anchor loop runs [0..3.0] with step 0.125 + const result = cam.lookaheadComputeFretBounds(0, [anchor], null, null); + assert.ok(result !== null); + assert.equal(result.minF, 3); + assert.equal(result.maxF, 6); +}); + +// Kill test: gut the bounds loop → result always null or wrong bounds +test('lookaheadComputeFretBounds: gut-kill — notes outside window excluded (timing boundary)', async () => { + const cam = await makeCamera(); + // now=0, window=3s; note at exactly t=3.0+ε should be excluded + const notes = [ + { t: 3.001, s: 0, f: 5 }, // just outside window + ]; + const result = cam.lookaheadComputeFretBounds(0, null, notes, null); + assert.equal(result, null, 'note beyond window must be excluded'); +}); + +// ── lookaheadBootstrapTime — input→output with gut-kill ──────────────────── + +test('lookaheadBootstrapTime: returns now when lookahead already covers eventTime', async () => { + const cam = await makeCamera(); + // now=0, CAM_LOOKAHEAD_SEC=3.0 → lookaheadEndTime(0)=3.0 ≥ eventTime=2.0 + const result = cam.lookaheadBootstrapTime(0, 2.0); + assert.equal(result, 0, 'window already covers event → return now'); +}); + +test('lookaheadBootstrapTime: returns now when eventTime ≤ now', async () => { + const cam = await makeCamera(); + const result = cam.lookaheadBootstrapTime(5.0, 4.0); + assert.equal(result, 5.0, 'event in the past → return now'); +}); + +test('lookaheadBootstrapTime: binary search converges on correct bootstrap point', async () => { + // measureStarts at [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20] + // CAM_LOOKAHEAD_MEASURES=9 → from t=0, window end = ms[9] = 18 + // eventTime=19 → need to project forward until lookaheadEnd(t) ≥ 19 + // lookaheadEnd(2) = ms[2+9] = ms[11]... but ms only has 11 entries [0..10] → extrapolate + // The exact value is tested for being in [0, eventTime) and for t < eventTime. + const ms = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]; + const cam = await makeCamera({ getMeasureStarts: () => ms }); + const bst = cam.lookaheadBootstrapTime(0, 28.0); + // lookaheadEnd(bst) must be >= 28.0 + // bst must be > 0 (event is past current window) + assert.ok(bst > 0, 'bootstrap > 0: needed to project forward'); + assert.ok(bst < 28.0, 'bootstrap < eventTime'); +}); + +// ── lookaheadTargetWorldX — input→output with gut-kill ───────────────────── + +test('lookaheadTargetWorldX: blends fret midpoint with board-weighted X', async () => { + // xFretMid(f) = (f+0.5)*10, xFret(f) = f*10, NFRETS=24 + // minF=3, maxF=9: + // middle = (xFretMid(3) + xFretMid(9))/2 = (35 + 95)/2 = 65 + // weighted = 0.6*xFret(0) + 0.4*xFret(24) = 0 + 0.4*240 = 96 + // wb=0.1 → result = 65*(1-0.1) + 96*0.1 = 58.5 + 9.6 = 68.1 + const cam = await makeCamera(); + const result = cam.lookaheadTargetWorldX(3, 9); + assert.ok(Math.abs(result - 68.1) < 0.001, `expected ≈68.1, got ${result}`); +}); + +test('lookaheadTargetWorldX: symmetric fret span centered at board center', async () => { + // With symmetric span (frets 0..24) middle = xFretMid(0)+xFretMid(24))/2 = (5+245)/2=125 + // weighted = 0.6*0 + 0.4*240 = 96; wb=0.1 → 125*0.9 + 96*0.1 = 112.5+9.6=122.1 + const cam = await makeCamera(); + const r1 = cam.lookaheadTargetWorldX(0, 24); + assert.ok(Math.abs(r1 - 122.1) < 0.001, `symmetric span expected ≈122.1, got ${r1}`); +}); + +// ── Wiring guard: naming-correspondence on the 9 new DI params ──────────── + +test('createCamera DI params appear in the h3d-carve-13 comment block in screen.js', () => { + const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + // All 9 new params must appear in the createCamera({}) call block. + // Find the call site (the `const { ... } = createCamera({` line), then + // extract from there to the matching closing `});` — search for the + // h3d-carve-13 comment marker which brackets the new params. + const idx = src.indexOf('const { effectiveVfov, camUpdate, lookaheadBootstrapTime'); + assert.ok(idx !== -1, 'createCamera destructure line found'); + // Pull enough text to cover the full call (up to 4000 chars is plenty) + const callBlock = src.slice(idx, idx + 4000); + for (const name of [ + 'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND', + 'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret', + ]) { + assert.ok(callBlock.includes(name), `${name} present in createCamera({}) call`); + } +});