diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 0a37910..bc61390 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.35.0", + "version": "3.36.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 44b6775..8c2b5ae 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -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 diff --git a/plugins/highway_3d/src/geometry.js b/plugins/highway_3d/src/geometry.js new file mode 100644 index 0000000..04492e7 --- /dev/null +++ b/plugins/highway_3d/src/geometry.js @@ -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; +} diff --git a/tests/js/highway_3d_fret_spacing.test.js b/tests/js/highway_3d_fret_spacing.test.js index 5355ca7..c4bf3ed 100644 --- a/tests/js/highway_3d_fret_spacing.test.js +++ b/tests/js/highway_3d_fret_spacing.test.js @@ -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)', ); }); diff --git a/tests/js/highway_3d_geometry.test.js b/tests/js/highway_3d_geometry.test.js new file mode 100644 index 0000000..d57ad36 --- /dev/null +++ b/tests/js/highway_3d_geometry.test.js @@ -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}`); +}); diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index 3c80d63..cc71427 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -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 }); diff --git a/tests/js/highway_3d_sustain_bloom.test.js b/tests/js/highway_3d_sustain_bloom.test.js index 7787efe..a9651c6 100644 --- a/tests/js/highway_3d_sustain_bloom.test.js +++ b/tests/js/highway_3d_sustain_bloom.test.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\(/,