mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:34:30 +00:00
refactor(h3d-carve-1): extract pure geometry helpers to src/geometry.js
Moves 7 exports from screen.js to a new ES module src/geometry.js: geoFretX (was fretX, now 2-arg), dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, computeBPM, _makeGaussTex. Internal helpers _fretXLog / _fretXUniStep / _fretXUni are module-private inside geometry.js. Module-level constants (SCALE, K, FRET_SCALE, NFRETS, FRET_SPACING_*, TS) are duplicated at geometry.js module scope — values are compile-time and never vary at runtime. screen.js keeps a 1-arg delegator (1 beyond-subst): const fretX = f => geoFretX(f, _h3dFretUniform); No call site changes; fretMid / fretColumnWorldW / slideOffsetWorldX / _recomputeFretSpacingDerived stay in screen.js and use the delegator. Tests: - highway_3d_geometry.test.js: class-killer via import() with 9 known-answer assertions (geoFretX uniform/log invariants, dZ linearity, slideTrailEnd, computeBPM BPM estimate). - highway_3d_fret_spacing: test updated to match delegator pattern + adds GEOMETRY_JS read to verify geoFretX export. - highway_3d_sustain_bloom: retargeted _makeGaussTex check to GEOMETRY_JS (definition moved); call-site check stays on SCREEN_JS. - highway_3d_panel_controls: vm loader strips ES import line and injects geometry stubs; all factory-statics tests unaffected. Suite: 169/169 pass node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js Version bump: 3.35.0 -> 3.36.0 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
d5f622f31b
commit
84d33769b8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.35.0",
|
||||
"version": "3.36.0",
|
||||
"type": "visualization",
|
||||
"scriptType": "module",
|
||||
"bundled": true,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// main player and per-panel in splitscreen without any architectural
|
||||
// changes.
|
||||
|
||||
import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, computeBPM, _makeGaussTex } from './src/geometry.js'; // h3d-carve-1
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -1510,22 +1512,11 @@
|
||||
* Pure helpers
|
||||
* ====================================================================== */
|
||||
|
||||
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
|
||||
const _fretXLog = f => {
|
||||
if (f <= 0) return 0;
|
||||
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
|
||||
if (f <= FRET_SPACING_ANCHOR_F) return raw;
|
||||
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
|
||||
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
|
||||
};
|
||||
// Uniform spacing — same column width per fret (chart-format style).
|
||||
// Total board width equals the logarithmic NFRETS position for consistency.
|
||||
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
|
||||
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
|
||||
// _fretXLog, _fretXUniStep, _fretXUni — moved to src/geometry.js (h3d-carve-1).
|
||||
|
||||
let _h3dFretUniform = true;
|
||||
try { _h3dFretUniform = localStorage.getItem('highway_3d.fretSpacing') !== 'logarithmic'; } catch (_) {}
|
||||
const fretX = f => _h3dFretUniform ? _fretXUni(f) : _fretXLog(f);
|
||||
const fretX = f => geoFretX(f, _h3dFretUniform); // h3d-carve-1: delegator (1 beyond-subst)
|
||||
|
||||
window.h3dSetFretSpacing = mode => {
|
||||
// Validate against the two supported modes before persisting so an
|
||||
@@ -1568,24 +1559,9 @@
|
||||
const m = w / _fretLabelScaleRefW;
|
||||
return Math.max(0.32, Math.min(1.45, m));
|
||||
}
|
||||
const dZ = dt => -dt * TS;
|
||||
// dZ — moved to src/geometry.js (h3d-carve-1).
|
||||
|
||||
/**
|
||||
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
|
||||
* Prefer `sl` when both are present — matches RS wire.
|
||||
* @returns {{ endFret: number, unpitched: boolean } | null}
|
||||
*/
|
||||
function slideTrailEnd(n) {
|
||||
const sl = n.sl;
|
||||
const slu = n.slu;
|
||||
if (Number.isFinite(sl) && sl >= 0) {
|
||||
return { endFret: sl | 0, unpitched: false };
|
||||
}
|
||||
if (Number.isFinite(slu) && slu >= 0) {
|
||||
return { endFret: slu | 0, unpitched: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// slideTrailEnd — moved to src/geometry.js (h3d-carve-1).
|
||||
|
||||
/**
|
||||
* Lateral slide offset along the fretboard during sustain — easing
|
||||
@@ -1605,15 +1581,7 @@
|
||||
return (endX - startX) * w;
|
||||
}
|
||||
|
||||
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
|
||||
// and locked (frets 1-12) branches compose tgtDist from these, so
|
||||
// any future tuning of the base zoom curve or low-fret pullback
|
||||
// lands in both branches without drift.
|
||||
// span — camDistMax - camDistMin in fret-span units
|
||||
// minFret — lowest fretted note in the camera window (or 1 for
|
||||
// the locked branch, which assumes nut chords)
|
||||
const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
|
||||
const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
|
||||
// camBaseDistU, camLowFretPullbackU — moved to src/geometry.js (h3d-carve-1).
|
||||
|
||||
// World-units-per-fret near mid-neck. Used by the camera-X hysteresis
|
||||
// gate (issue #34) to convert a fret-equivalent dead zone into world
|
||||
@@ -1632,52 +1600,7 @@
|
||||
FRET_WIDTH_MID = fretX(7) - fretX(6);
|
||||
}
|
||||
|
||||
function computeBPM(beats, t) {
|
||||
if (!beats || beats.length < 2) return 120;
|
||||
let lo = 0, hi = beats.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
let closest = lo;
|
||||
if (lo === beats.length) closest = beats.length - 1;
|
||||
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
|
||||
const start = Math.max(0, closest - 2);
|
||||
const end = Math.min(beats.length - 1, closest + 2);
|
||||
let sum = 0, count = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
const dt = beats[i + 1].time - beats[i].time;
|
||||
if (dt > 0) { sum += dt; count++; }
|
||||
}
|
||||
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
|
||||
}
|
||||
|
||||
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
|
||||
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u−0.5)²/σ²),
|
||||
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
|
||||
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
|
||||
// so the additive bloom fades gradually rather than cutting off sharply.
|
||||
// Power-of-two width keeps WebGL mipmapping happy.
|
||||
function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
|
||||
const data = new Uint8Array(w * 4);
|
||||
for (let i = 0; i < w; i++) {
|
||||
const u = i / (w - 1);
|
||||
const d = (u - 0.5) / sigma;
|
||||
const v = Math.exp(-0.5 * d * d);
|
||||
const a = Math.round(v * 255);
|
||||
data[i * 4] = 255;
|
||||
data[i * 4 + 1] = 255;
|
||||
data[i * 4 + 2] = 255;
|
||||
data[i * 4 + 3] = a;
|
||||
}
|
||||
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
|
||||
// LinearFilter on both axes so the bloom plane interpolates smoothly
|
||||
// when scaled — the default NearestFilter causes visible banding.
|
||||
tex.magFilter = ThreeLib.LinearFilter;
|
||||
tex.minFilter = ThreeLib.LinearFilter;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
// computeBPM, _makeGaussTex — moved to src/geometry.js (h3d-carve-1).
|
||||
|
||||
/* ======================================================================
|
||||
* Three.js module — lazily loaded, memoized
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Pure geometry helpers — h3d-carve-1.
|
||||
*
|
||||
* All exports are stateless; they depend only on the compile-time constants
|
||||
* below (which mirror their factory-scope counterparts in screen.js verbatim
|
||||
* and never vary at runtime). No DOM, no Three.js imports, no side-effects.
|
||||
*
|
||||
* screen.js keeps a 1-arg delegator:
|
||||
* const fretX = f => geoFretX(f, _h3dFretUniform);
|
||||
* so no call site in screen.js changes.
|
||||
*/
|
||||
|
||||
// ── Compile-time constants (mirror screen.js; never vary at runtime) ─────────
|
||||
|
||||
const SCALE = 2.25;
|
||||
const K = SCALE / 300;
|
||||
// Horizontal stretch factor for fret X positions.
|
||||
const FRET_SCALE = SCALE * 1.1;
|
||||
const NFRETS = 24;
|
||||
/**
|
||||
* Pure 12-semitone spacing compresses toward the bridge; multiply each
|
||||
* segment above this fret by the factor so high positions stay
|
||||
* slightly more playable/readable in 3D.
|
||||
*/
|
||||
const FRET_SPACING_STRETCH_ABOVE12 = 1.1;
|
||||
const FRET_SPACING_ANCHOR_F = 12;
|
||||
/** Note travel speed. */
|
||||
const TS = 230 * K;
|
||||
|
||||
// ── Fret X ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
|
||||
const _fretXLog = f => {
|
||||
if (f <= 0) return 0;
|
||||
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
|
||||
if (f <= FRET_SPACING_ANCHOR_F) return raw;
|
||||
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
|
||||
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
|
||||
};
|
||||
// Uniform spacing — same column width per fret (chart-format style).
|
||||
// Total board width equals the logarithmic NFRETS position for consistency.
|
||||
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
|
||||
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
|
||||
|
||||
/**
|
||||
* World-space X position for fret `f`.
|
||||
* @param {number} f fret number
|
||||
* @param {boolean} uniform true → uniform (chart-format) spacing; false → logarithmic
|
||||
*/
|
||||
export const geoFretX = (f, uniform) => uniform ? _fretXUni(f) : _fretXLog(f);
|
||||
|
||||
// ── Time → Z ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert a time delta (seconds) to a world-space Z offset (notes travel toward −Z). */
|
||||
export const dZ = dt => -dt * TS;
|
||||
|
||||
// ── Slide trail ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
|
||||
* Prefer `sl` when both are present — matches RS wire.
|
||||
* @returns {{ endFret: number, unpitched: boolean } | null}
|
||||
*/
|
||||
export function slideTrailEnd(n) {
|
||||
const sl = n.sl;
|
||||
const slu = n.slu;
|
||||
if (Number.isFinite(sl) && sl >= 0) {
|
||||
return { endFret: sl | 0, unpitched: false };
|
||||
}
|
||||
if (Number.isFinite(slu) && slu >= 0) {
|
||||
return { endFret: slu | 0, unpitched: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Camera distance building blocks ──────────────────────────────────────────
|
||||
|
||||
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
|
||||
// and locked (frets 1-12) branches compose tgtDist from these, so
|
||||
// any future tuning of the base zoom curve or low-fret pullback
|
||||
// lands in both branches without drift.
|
||||
// span — camDistMax - camDistMin in fret-span units
|
||||
// minFret — lowest fretted note in the camera window (or 1 for
|
||||
// the locked branch, which assumes nut chords)
|
||||
export const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
|
||||
export const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
|
||||
|
||||
// ── BPM estimation ────────────────────────────────────────────────────────────
|
||||
|
||||
export function computeBPM(beats, t) {
|
||||
if (!beats || beats.length < 2) return 120;
|
||||
let lo = 0, hi = beats.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
let closest = lo;
|
||||
if (lo === beats.length) closest = beats.length - 1;
|
||||
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
|
||||
const start = Math.max(0, closest - 2);
|
||||
const end = Math.min(beats.length - 1, closest + 2);
|
||||
let sum = 0, count = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
const dt = beats[i + 1].time - beats[i].time;
|
||||
if (dt > 0) { sum += dt; count++; }
|
||||
}
|
||||
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
|
||||
}
|
||||
|
||||
// ── Gaussian bloom texture ────────────────────────────────────────────────────
|
||||
|
||||
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
|
||||
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u−0.5)²/σ²),
|
||||
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
|
||||
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
|
||||
// so the additive bloom fades gradually rather than cutting off sharply.
|
||||
// Power-of-two width keeps WebGL mipmapping happy.
|
||||
export function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
|
||||
const data = new Uint8Array(w * 4);
|
||||
for (let i = 0; i < w; i++) {
|
||||
const u = i / (w - 1);
|
||||
const d = (u - 0.5) / sigma;
|
||||
const v = Math.exp(-0.5 * d * d);
|
||||
const a = Math.round(v * 255);
|
||||
data[i * 4] = 255;
|
||||
data[i * 4 + 1] = 255;
|
||||
data[i * 4 + 2] = 255;
|
||||
data[i * 4 + 3] = a;
|
||||
}
|
||||
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
|
||||
// LinearFilter on both axes so the bloom plane interpolates smoothly
|
||||
// when scaled — the default NearestFilter causes visible banding.
|
||||
tex.magFilter = ThreeLib.LinearFilter;
|
||||
tex.minFilter = ThreeLib.LinearFilter;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
// The board can render fret columns either Uniform (equal width, the chart
|
||||
// Remastered style) or Logarithmic (real instrument geometry), switchable at
|
||||
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
|
||||
// refactor that renames the storage key, drops the uniform/log branch in
|
||||
// fretX, or stops validating the mode would silently regress the setting.
|
||||
// refactor that renames the storage key, drops the delegator in fretX, or
|
||||
// stops validating the mode would silently regress the setting.
|
||||
//
|
||||
// Since h3d-carve-1, the uniform/log branch lives in src/geometry.js
|
||||
// (geoFretX); screen.js keeps a 1-arg delegator:
|
||||
// const fretX = f => geoFretX(f, _h3dFretUniform);
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
@@ -13,6 +17,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
|
||||
test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
@@ -23,12 +28,18 @@ test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key
|
||||
);
|
||||
});
|
||||
|
||||
test('fretX switches between the uniform and logarithmic implementations', () => {
|
||||
test('fretX is a 1-arg delegator to geoFretX in screen.js (h3d-carve-1)', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+fretX\s*=\s*f\s*=>\s*_h3dFretUniform\s*\?\s*_fretXUni\(f\)\s*:\s*_fretXLog\(f\)/,
|
||||
'fretX must pick _fretXUni when _h3dFretUniform else _fretXLog',
|
||||
/const\s+fretX\s*=\s*f\s*=>\s*geoFretX\(\s*f\s*,\s*_h3dFretUniform\s*\)/,
|
||||
'screen.js fretX must delegate to geoFretX(f, _h3dFretUniform) from geometry.js',
|
||||
);
|
||||
const geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
|
||||
assert.match(
|
||||
geo,
|
||||
/export\s+const\s+geoFretX\s*=\s*\(\s*f\s*,\s*uniform\s*\)\s*=>/,
|
||||
'geometry.js must export geoFretX as a 2-arg function (fret, uniform)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Class-killer for src/geometry.js — h3d-carve-1.
|
||||
//
|
||||
// Uses dynamic import() (not the vm source-scan pattern) so Node actually
|
||||
// evaluates the ES module and its exports are the real runtime values.
|
||||
// A refactor that renames geoFretX, changes the uniform/logarithmic
|
||||
// decision, removes slideTrailEnd, or breaks computeBPM's BPM estimate
|
||||
// would be caught here before any other test.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
|
||||
test('geoFretX returns 0 for fret 0 in both modes', async () => {
|
||||
const { geoFretX } = await import(GEOMETRY_JS);
|
||||
assert.strictEqual(geoFretX(0, true), 0, 'uniform: fret 0 must be 0');
|
||||
assert.strictEqual(geoFretX(0, false), 0, 'logarithmic: fret 0 must be 0');
|
||||
});
|
||||
|
||||
test('geoFretX uniform spacing is linear — fret N is N × fret 1', async () => {
|
||||
const { geoFretX } = await import(GEOMETRY_JS);
|
||||
const step = geoFretX(1, true);
|
||||
assert.ok(step > 0, 'uniform step must be positive');
|
||||
assert.ok(Math.abs(geoFretX(5, true) - 5 * step) < 1e-9, 'fret 5 must be 5 × step');
|
||||
assert.ok(Math.abs(geoFretX(12, true) - 12 * step) < 1e-9, 'fret 12 must be 12 × step');
|
||||
});
|
||||
|
||||
test('geoFretX logarithmic spacing is non-linear — frets compress toward the bridge', async () => {
|
||||
const { geoFretX } = await import(GEOMETRY_JS);
|
||||
const d1 = geoFretX(1, false);
|
||||
const d2 = geoFretX(2, false) - geoFretX(1, false);
|
||||
const d3 = geoFretX(3, false) - geoFretX(2, false);
|
||||
assert.ok(d1 > d2, 'fret 1 gap must be wider than fret 2 gap (compression toward bridge)');
|
||||
assert.ok(d2 > d3, 'fret 2 gap must be wider than fret 3 gap');
|
||||
});
|
||||
|
||||
test('geoFretX uniform and logarithmic agree at fret 24 (total board width)', async () => {
|
||||
const { geoFretX } = await import(GEOMETRY_JS);
|
||||
// By construction: _fretXUniStep = _fretXLog(24) / 24, so geoFretX(24, uniform)
|
||||
// equals geoFretX(24, logarithmic). This is the board-width invariant.
|
||||
const uniWidth = geoFretX(24, true);
|
||||
const logWidth = geoFretX(24, false);
|
||||
assert.ok(Math.abs(uniWidth - logWidth) < 1e-9, 'board width must be identical in both modes');
|
||||
});
|
||||
|
||||
test('dZ converts positive dt to a negative Z delta', async () => {
|
||||
const { dZ } = await import(GEOMETRY_JS);
|
||||
assert.ok(dZ(1) < 0, 'positive time delta must produce negative Z (notes travel toward camera)');
|
||||
assert.ok(dZ(0) === 0, 'zero dt must produce zero dZ');
|
||||
assert.ok(Math.abs(dZ(2) / dZ(1) - 2) < 1e-9, 'dZ must be linear in dt');
|
||||
});
|
||||
|
||||
test('slideTrailEnd returns null for notes with no slide fields', async () => {
|
||||
const { slideTrailEnd } = await import(GEOMETRY_JS);
|
||||
assert.strictEqual(slideTrailEnd({}), null);
|
||||
assert.strictEqual(slideTrailEnd({ sl: -1 }), null, 'negative sl must be ignored');
|
||||
});
|
||||
|
||||
test('slideTrailEnd prefers sl over slu and marks pitched/unpitched correctly', async () => {
|
||||
const { slideTrailEnd } = await import(GEOMETRY_JS);
|
||||
assert.deepStrictEqual(slideTrailEnd({ sl: 7 }), { endFret: 7, unpitched: false });
|
||||
assert.deepStrictEqual(slideTrailEnd({ slu: 5 }), { endFret: 5, unpitched: true });
|
||||
assert.deepStrictEqual(slideTrailEnd({ sl: 7, slu: 5 }), { endFret: 7, unpitched: false });
|
||||
});
|
||||
|
||||
test('computeBPM returns 120 for degenerate inputs', async () => {
|
||||
const { computeBPM } = await import(GEOMETRY_JS);
|
||||
assert.strictEqual(computeBPM(null, 0), 120);
|
||||
assert.strictEqual(computeBPM([], 0), 120);
|
||||
assert.strictEqual(computeBPM([{ time: 0 }], 0), 120, 'single beat has no interval');
|
||||
});
|
||||
|
||||
test('computeBPM estimates 120 BPM from evenly-spaced beats', async () => {
|
||||
const { computeBPM } = await import(GEOMETRY_JS);
|
||||
// 120 BPM = 0.5 s per beat
|
||||
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time }));
|
||||
const bpm = computeBPM(beats, 1.0);
|
||||
assert.ok(Math.abs(bpm - 120) < 0.01, `expected ~120 BPM, got ${bpm}`);
|
||||
});
|
||||
@@ -28,7 +28,11 @@ function loadHighway3dStatics() {
|
||||
1,
|
||||
'expected exactly one factory-registration anchor in screen.js',
|
||||
);
|
||||
const instrumented = src.replace(
|
||||
// Since h3d-carve-1 screen.js starts with an ES module import statement.
|
||||
// vm.runInContext does not support static import — strip the import line
|
||||
// and provide stub implementations of the geometry exports in the sandbox.
|
||||
const stripped = src.replace(/^import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];\s*\/\/[^\n]*\n/m, '');
|
||||
const instrumented = stripped.replace(
|
||||
ANCHOR,
|
||||
`${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`,
|
||||
);
|
||||
@@ -50,6 +54,15 @@ function loadHighway3dStatics() {
|
||||
register() {},
|
||||
},
|
||||
},
|
||||
// Geometry stubs — panel-controls test only reads factory statics;
|
||||
// it never invokes the render path where these are called (h3d-carve-1).
|
||||
geoFretX: (f, _uniform) => f * 0.1,
|
||||
dZ: dt => -dt,
|
||||
slideTrailEnd: () => null,
|
||||
camBaseDistU: span => span,
|
||||
camLowFretPullbackU: () => 0,
|
||||
computeBPM: () => 120,
|
||||
_makeGaussTex: () => ({}),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// stops using additive blending, or bumps the bloom renderOrder above the
|
||||
// core rail (16) would silently regress or invert the effect.
|
||||
//
|
||||
// Since h3d-carve-1, _makeGaussTex is defined in src/geometry.js and
|
||||
// imported into screen.js; the call site (_bloomGaussTex = _makeGaussTex(...))
|
||||
// remains in screen.js.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
@@ -13,14 +17,16 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
|
||||
|
||||
test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_makeGaussTex\s*\(/,
|
||||
'_makeGaussTex must exist to build the bloom gaussian texture',
|
||||
geo,
|
||||
/export\s+function\s+_makeGaussTex\s*\(/,
|
||||
'_makeGaussTex must be exported from geometry.js to build the bloom gaussian texture',
|
||||
);
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_bloomGaussTex\s*=\s*_makeGaussTex\(/,
|
||||
|
||||
Reference in New Issue
Block a user