mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 07:18:31 +00:00
h3d-carve-13: extract S-section lookahead helpers into src/camera.js
Partial cut (Option A) per contract ~/Feedback-harness/hive/plans/h3d-cut13-contract.md. What moves: - lookaheadEndTime (factory-private) — 1 beyond-subst: _measureStarts → getMeasureStarts() - lookaheadBootstrapTime (exported) - lookaheadComputeFretBounds (exported) — uses lowerBoundT from geometry.js (new import) - lookaheadTargetWorldX (exported) DI delta: +9 params to createCamera() Constants (+4): NFRETS=24, CAM_LOOKAHEAD_MEASURES=9, CAM_LOOKAHEAD_SEC=3.0, CAM_FRET_EDGE_BLEND=0.1 Getter (+1): getMeasureStarts: () => _measureStarts Fn refs (+4): validString, getChartAnchorAt, xFretMid, xFret Total createCamera() DI: 57 (was 48) Deferred to cut 15 (U-section entanglement): lookaheadSmoothCamStep, _applyNoteCamTargets — write S-section state vars Region B (per-frame cam target block, ~90 lines) — reads 8 update() locals Region C (song-change + bootstrap block, ~174 lines) — U-section init code Call sites unchanged (destructured names unchanged): lookaheadComputeFretBounds: lines 7474, 7941 lookaheadBootstrapTime: line 7939 lookaheadTargetWorldX: lines 7943, 10062 Tests: 17 new (highway_3d_camera_lookahead.test.js), 2 retargeted to cameraSrc. Suite: 1353/1355 pass (2 pre-existing failures in unrelated test files). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e230629770
commit
66aa829362
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.48.0",
|
"version": "3.49.0",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"scriptType": "module",
|
"scriptType": "module",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
@@ -6621,102 +6621,9 @@ import { createArp } from './src/arp.js'; // h3d-carve-12
|
|||||||
getMAccentCore: () => mAccentCore,
|
getMAccentCore: () => mAccentCore,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Lookahead fret bounds + smooth camera ───────────────────────── */
|
/* ── h3d-carve-13: S-section lookahead helpers → src/camera.js ──── */
|
||||||
// End time of the lookahead window = start of the measure that is
|
// lookaheadEndTime (private), lookaheadBootstrapTime, lookaheadComputeFretBounds,
|
||||||
// CAM_LOOKAHEAD_MEASURES measures ahead of the current one. Uses the
|
// lookaheadTargetWorldX extracted below in the createCamera() destructure.
|
||||||
// _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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookaheadSmoothCamStep(dtSec, tgtXWorld, tgtSpanInt) {
|
function lookaheadSmoothCamStep(dtSec, tgtXWorld, tgtSpanInt) {
|
||||||
const d = Math.min(0.2, Math.max(1e-4, dtSec));
|
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-10: drawScoreFx → src/score-fx.js ────────────────── */
|
||||||
|
|
||||||
/* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */
|
/* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */
|
||||||
const { effectiveVfov, camUpdate } = createCamera({
|
const { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX } = createCamera({
|
||||||
// Constants
|
// Constants
|
||||||
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
|
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
|
||||||
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
|
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
|
||||||
@@ -11728,6 +11635,10 @@ import { createArp } from './src/arp.js'; // h3d-carve-12
|
|||||||
aspectPaneKey: _aspectPaneKey,
|
aspectPaneKey: _aspectPaneKey,
|
||||||
resolveTuneFor: _resolveTuneFor,
|
resolveTuneFor: _resolveTuneFor,
|
||||||
aspectRegisterPane: _aspectRegisterPane,
|
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 ───────────────────────────────────────────────── */
|
/* ── Resize helper ───────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
|
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
|
||||||
// aspectRegisterPane
|
// 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
|
import { _ssActive } from './utils.js'; // h3d-carve-3
|
||||||
|
|
||||||
export function createCamera({
|
export function createCamera({
|
||||||
@@ -41,6 +41,10 @@ export function createCamera({
|
|||||||
getFretRowFitBoost, setFretRowFitBoost,
|
getFretRowFitBoost, setFretRowFitBoost,
|
||||||
// ── Function refs ───────────────────────────────────────────────────────
|
// ── Function refs ───────────────────────────────────────────────────────
|
||||||
sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane,
|
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
|
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
|
||||||
// camera should use for the given pane aspect. With the bridge off (or
|
// camera should use for the given pane aspect. With the bridge off (or
|
||||||
@@ -272,5 +276,100 @@ export function createCamera({
|
|||||||
setFretRowFitBoost(_fretRowFitBoost); // write-back
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ test('createCamera({...}) wiring has correct naming correspondence (no param swa
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Extract the argument block from the createCamera call.
|
// 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);
|
const callStart = src.indexOf(ANCHOR);
|
||||||
assert.ok(callStart >= 0, 'createCamera call must be findable in screen.js');
|
assert.ok(callStart >= 0, 'createCamera call must be findable in screen.js');
|
||||||
const blockStart = callStart + ANCHOR.length - 1; // points to the opening {
|
const blockStart = callStart + ANCHOR.length - 1; // points to the opening {
|
||||||
|
|||||||
@@ -98,27 +98,29 @@ test('measure-start cache only keeps beats with measure >= 0', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
|
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
|
||||||
|
// h3d-carve-13: lookaheadEndTime moved to src/camera.js — retarget to cameraSrc.
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
cameraSrc,
|
||||||
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
|
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
|
||||||
'lookaheadEndTime(now) helper must exist',
|
'lookaheadEndTime(now) helper must exist',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
cameraSrc,
|
||||||
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
|
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
|
||||||
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
|
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
|
||||||
);
|
);
|
||||||
// No beats → seconds fallback.
|
// No beats → seconds fallback.
|
||||||
assert.match(
|
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/,
|
/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',
|
'lookaheadEndTime must fall back to seconds when there are no measures',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => {
|
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(
|
assert.match(
|
||||||
src,
|
cameraSrc,
|
||||||
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
|
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
|
||||||
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
|
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user