refactor(h3d-carve-9): extract W-section (camera lerp) → src/camera.js

effectiveVfov + camUpdate (~192 lines) extracted from the screen.js IIFE
into a createCamera() ES-module factory in plugins/highway_3d/src/camera.js.
screen.js imports and destructures the return { effectiveVfov, camUpdate }.

DI surface (48 params): 22 plain constants, 11 getters, 5 getter+setter pairs,
5 fn-refs (DI-renamed: _freeCamFor→freeCamFor, _aspectPaneKey→aspectPaneKey,
_resolveTuneFor→resolveTuneFor, _aspectRegisterPane→aspectRegisterPane).
Peer ES imports at module level: computeBPM (geometry.js), _ssActive (utils.js).
Per-call write-backs: setCurX, setCurDist, setCurLookY, setTgtLookY,
setFretRowFitBoost (all write-backs confirmed by setter class-killer tests).

Test retargeting (54 tests across 4 files):
- highway_3d_wide_fov.test.js:   6 tests → cameraSrc; 4 regexes updated for
  DI-renamed fn refs (resolveTuneFor, aspectRegisterPane, aspectPaneKey/getPaneUid)
- highway_3d_camera_framing.test.js: 5 tests → cameraSrc; getTgtDist() regex fix
- highway_3d_camera_bootstrap.test.js: extractFn retargeted to cameraSrc;
  getTgtX() ordering-check fix; 2 new setter class-killers added (setCurX,
  setFretRowFitBoost)
- highway_3d_lefty.test.js: shoulder-offset test → cameraSrc + getLeftyCached()
- highway_3d_panel_controls.test.js: createCamera stub added

Bite proofs:
- Gut effectiveVfov in camera.js → wide_fov not ok 4 (RED) ✓
- Gut camUpdate body (H_NEAR lerp) → framing not ok 3 (RED) ✓
- Gut camUpdate body (curX+=) → bootstrap not ok 11 (RED) ✓
- Sever setCurX → bootstrap not ok 12 (RED) ✓
- Sever setFretRowFitBoost → bootstrap not ok 13 (RED) ✓

Suite: 1271/1273 pass; 2 pre-existing failures unchanged from cut-8 baseline
(#46 analyser fallback, #591 nut-labels — both in flight before cut 9).

plugin.json: 3.44.0 → 3.45.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 16:37:20 +02:00
co-authored by Claude Sonnet 4.6
parent 4a45ed8782
commit 10c7ec8a9d
8 changed files with 390 additions and 226 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.44.0",
"version": "3.45.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+33 -199
View File
@@ -14,6 +14,7 @@ import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
import { createFx } from './src/fx.js'; // h3d-carve-8
import { createCamera } from './src/camera.js'; // h3d-carve-9
(function () {
'use strict';
@@ -12670,205 +12671,38 @@ import { createFx } from './src/fx.js'; // h3d-carve-8
ctx.restore();
}
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
// overrides (if any) laid on top, so a single split pane can be framed
// independently. The base is seeded from defaults + localStorage on
// first read, so a persisted tuning session applies on load without
// opening the panel. Every field is finite-coerced. When disabled (or
// splitOnly and not in a split) the tune is treated as null, so
// effectiveVfov returns the base vertical fov and cam.fov is restored
// to it. The fov write is guarded on an actual change so a steady pane
// costs nothing.
const _paneKey = _aspectPaneKey(
bundle && bundle.songInfo && bundle.songInfo.arrangement, _paneUid);
// Only feed the Target-picker registry while the tuner is open (same
// gate as the readout). Closed → nothing is registered, so the registry
// can't grow for users who never open the panel; the key is still
// resolved below so any saved overrides keep applying.
if (window.__h3dAspectPanelOpen) _aspectRegisterPane(_paneKey);
const _aspTune = _resolveTuneFor(_paneKey);
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(_paneAspect, _tune);
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a per-pane live readout for the tuner panel (only while it's
// open, so the steady path stays allocation-free). Keyed by pane so
// the panel can show the reading for whichever target is selected.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
_slot.aspect = _paneAspect; _slot.vfov = _vfov;
_ro.__last = _paneKey;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = _freeCamFor(highwayCanvas);
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
curX += (tgtX - curX) * lerp;
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (tgtDist * _fretRowFitBoost - curDist) * lerp;
const dist = curDist * aspectScale;
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
// wide (FAR, fret 1<->20) -> higher/pulled back.
const _zt = Math.max(0, Math.min(1,
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
_vy *= _heightMul; // height
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
}
cam.position.set(_camX, _camY, _camZ);
// Self-correcting look-at Y: project the fretboard's near-edge centre
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
// toward the fretboard centre so the camera tilts to re-frame it.
// This lets the camera adapt to any panel aspect ratio automatically.
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
// The deadband width and correction strength are both blended
// between Twitchy and Calm bounds by the user's tiltSmoothing
// setting — twitchy = re-frame aggressively (narrow band, strong
// nudge); calm = let small drift ride (wide band, weak nudge).
const DESIRED_NDC_Y = -0.35;
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
}
curLookY += (tgtLookY - curLookY) * lerp;
// Final look-at with the corrected Y (overrides the tentative one above).
// User tilt (pitch) + pan offsets layer on top when the free-cam is
// enabled; each is coerced to a finite number to avoid a NaN look-at.
if (_freeCam && _freeCam.enabled) {
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
}
/* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */
const { effectiveVfov, camUpdate } = createCamera({
// Constants
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
CAM_FRAME_DIST_NEAR, CAM_FRAME_DIST_FAR,
CAM_FRAME_H_NEAR, CAM_FRAME_H_FAR,
CAM_FRAME_D_NEAR, CAM_FRAME_D_FAR,
FOCUS_D, S_GAP, K,
FRET_ROW_FIT_NDC_MIN, FRET_ROW_FIT_DEADBAND, FRET_ROW_FIT_BOOST_MAX,
CAM_TILT_BAND_T, CAM_TILT_BAND_C, CAM_TILT_STR_T, CAM_TILT_STR_C,
// Live-accessor getters
getCam: () => cam,
getTgtX: () => tgtX, getTgtDist: () => tgtDist,
getAspectScale: () => aspectScale, getLeftyCached: () => _leftyCached,
getNStr: () => nStr, getProbe: () => _probe,
getTiltSmoothing: () => tiltSmoothing,
getPaneAspect: () => _paneAspect, getPaneUid: () => _paneUid,
getHighwayCanvas: () => highwayCanvas,
// Getter+setter pairs (write-backs)
getCurX: () => curX, setCurX: (v) => { curX = v; },
getCurDist: () => curDist, setCurDist: (v) => { curDist = v; },
getCurLookY: () => curLookY, setCurLookY: (v) => { curLookY = v; },
getTgtLookY: () => tgtLookY, setTgtLookY: (v) => { tgtLookY = v; },
getFretRowFitBoost: () => _fretRowFitBoost, setFretRowFitBoost: (v) => { _fretRowFitBoost = v; },
// Function refs
sY,
freeCamFor: _freeCamFor,
aspectPaneKey: _aspectPaneKey,
resolveTuneFor: _resolveTuneFor,
aspectRegisterPane: _aspectRegisterPane,
});
/* ── Resize helper ───────────────────────────────────────────────── */
function applySize(w, h) {
+276
View File
@@ -0,0 +1,276 @@
// h3d-carve-9: W-section (camera lerp) — effectiveVfov + camUpdate.
// Cut 13 (S-section lookahead) extends this factory in place: same createCamera({…})
// destructure grows with additional DI params and returned symbols.
//
// DI surface (per §2 of the cut-9 contract):
// Constants (22) — BASE_VFOV, HORPLUS_*, CAM_LERP_BASE, CAM_H/DIST_BASE,
// 6× CAM_FRAME_*, FOCUS_D, S_GAP, K, 3× FRET_ROW_FIT_*,
// 4× CAM_TILT_*
// Getters (11) — getCam, getTgtX/Dist, getAspectScale, getLeftyCached,
// getNStr, getProbe, getTiltSmoothing, getPaneAspect/Uid,
// getHighwayCanvas
// Pairs (5) — getCurX/setCurX, getCurDist/setCurDist,
// getCurLookY/setCurLookY, getTgtLookY/setTgtLookY,
// getFretRowFitBoost/setFretRowFitBoost
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
// aspectRegisterPane
import { computeBPM } from './geometry.js'; // h3d-carve-1
import { _ssActive } from './utils.js'; // h3d-carve-3
export function createCamera({
// ── Constants ──────────────────────────────────────────────────────────
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
CAM_FRAME_DIST_NEAR, CAM_FRAME_DIST_FAR,
CAM_FRAME_H_NEAR, CAM_FRAME_H_FAR,
CAM_FRAME_D_NEAR, CAM_FRAME_D_FAR,
FOCUS_D, S_GAP, K,
FRET_ROW_FIT_NDC_MIN, FRET_ROW_FIT_DEADBAND, FRET_ROW_FIT_BOOST_MAX,
CAM_TILT_BAND_T, CAM_TILT_BAND_C, CAM_TILT_STR_T, CAM_TILT_STR_C,
// ── Live-accessor getters ───────────────────────────────────────────────
getCam,
getTgtX, getTgtDist, getAspectScale, getLeftyCached,
getNStr, getProbe, getTiltSmoothing, getPaneAspect, getPaneUid,
getHighwayCanvas,
// ── Getter+setter pairs (write-backs) ──────────────────────────────────
getCurX, setCurX,
getCurDist, setCurDist,
getCurLookY, setCurLookY,
getTgtLookY, setTgtLookY,
getFretRowFitBoost, setFretRowFitBoost,
// ── Function refs ───────────────────────────────────────────────────────
sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane,
}) {
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
// VERBATIM MOVE. 0 beyond-subst: BASE_VFOV / HORPLUS_* / HORPLUS_MIN_VFOV
// are plain DI params in the factory destructure — no rewires needed.
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
// VERBATIM MOVE. DI rewires — 25 beyond-subst:
// Local aliases intro (10): cam, paneAspect, curX, curDist, curLookY,
// tgtLookY, _fretRowFitBoost, nStr, _probe, tiltSmoothing
// Fn-ref renames (4): _aspectPaneKey→aspectPaneKey,
// _aspectRegisterPane→aspectRegisterPane,
// _resolveTuneFor→resolveTuneFor, _freeCamFor→freeCamFor
// Direct getter calls (6): getPaneUid, getTgtX, getTgtDist,
// getAspectScale, getLeftyCached, getHighwayCanvas
// Write-back setter calls (5): setCurX, setCurDist, setCurLookY,
// setTgtLookY, setFretRowFitBoost
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
// overrides (if any) laid on top, so a single split pane can be framed
// independently. The base is seeded from defaults + localStorage on
// first read, so a persisted tuning session applies on load without
// opening the panel. Every field is finite-coerced. When disabled (or
// splitOnly and not in a split) the tune is treated as null, so
// effectiveVfov returns the base vertical fov and cam.fov is restored
// to it. The fov write is guarded on an actual change so a steady pane
// costs nothing.
const cam = getCam(); // DI rewire: live-accessor
const paneAspect = getPaneAspect(); // DI rewire: live-accessor (replaces _paneAspect)
const _paneKey = aspectPaneKey( // DI rewire: fn-ref rename
bundle && bundle.songInfo && bundle.songInfo.arrangement, getPaneUid()); // DI rewire: getPaneUid
// Only feed the Target-picker registry while the tuner is open (same
// gate as the readout). Closed → nothing is registered, so the registry
// can't grow for users who never open the panel; the key is still
// resolved below so any saved overrides keep applying.
if (window.__h3dAspectPanelOpen) aspectRegisterPane(_paneKey); // DI rewire: fn-ref rename
const _aspTune = resolveTuneFor(_paneKey); // DI rewire: fn-ref rename
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(paneAspect, _tune); // DI rewire: paneAspect
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a per-pane live readout for the tuner panel (only while it's
// open, so the steady path stays allocation-free). Keyed by pane so
// the panel can show the reading for whichever target is selected.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
_slot.aspect = paneAspect; _slot.vfov = _vfov; // DI rewire: paneAspect
_ro.__last = _paneKey;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = freeCamFor(getHighwayCanvas()); // DI rewire: fn-ref rename + getter
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && paneAspect > _startAspect) && !_dirActive; // DI rewire: paneAspect
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
// DI rewire: lerped state — read per-call into locals, mutate locally,
// write back via setters so screen.js closure vars stay in sync.
// (Never cached at factory init — buildBoard may reset these between frames.)
let curX = getCurX(); // DI rewire: local alias
curX += (getTgtX() - curX) * lerp; // DI rewire: getTgtX()
setCurX(curX); // write-back
let _fretRowFitBoost = getFretRowFitBoost(); // DI rewire: local alias
let curDist = getCurDist(); // DI rewire: local alias
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (getTgtDist() * _fretRowFitBoost - curDist) * lerp; // DI rewire: getTgtDist()
setCurDist(curDist); // write-back
const dist = curDist * getAspectScale(); // DI rewire: getAspectScale()
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
// wide (FAR, fret 1<->20) -> higher/pulled back.
const _zt = Math.max(0, Math.min(1,
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (getLeftyCached() ? -1 : 1) * 10 * K; // DI rewire: getLeftyCached()
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
// _freeCam resolved above via freeCamFor(getHighwayCanvas()): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
let curLookY = getCurLookY(); // DI rewire: local alias (read before freeCam block)
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
_vy *= _heightMul; // height
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
}
cam.position.set(_camX, _camY, _camZ);
// Self-correcting look-at Y: project the fretboard's near-edge centre
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
// toward the fretboard centre so the camera tilts to re-frame it.
// This lets the camera adapt to any panel aspect ratio automatically.
const nStr = getNStr(); // DI rewire: local alias
const _probe = getProbe(); // DI rewire: local alias
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
// The deadband width and correction strength are both blended
// between Twitchy and Calm bounds by the user's tiltSmoothing
// setting — twitchy = re-frame aggressively (narrow band, strong
// nudge); calm = let small drift ride (wide band, weak nudge).
const DESIRED_NDC_Y = -0.35;
const tiltSmoothing = getTiltSmoothing(); // DI rewire: local alias
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
let tgtLookY = getTgtLookY(); // DI rewire: local alias
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
}
setTgtLookY(tgtLookY); // write-back
curLookY += (tgtLookY - curLookY) * lerp;
setCurLookY(curLookY); // write-back
// Final look-at with the corrected Y (overrides the tentative one above).
// User tilt (pitch) + pan offsets layer on top when the free-cam is
// enabled; each is coerced to a finite number to avoid a NaN look-at.
if (_freeCam && _freeCam.enabled) {
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
setFretRowFitBoost(_fretRowFitBoost); // write-back
}
return { effectiveVfov, camUpdate };
}
+29 -2
View File
@@ -16,6 +16,9 @@ const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Since h3d-carve-1b, hwyFirstRelevantFrettedTime lives in geometry.js.
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const geoSrc = fs.readFileSync(GEOMETRY_JS, 'utf8');
// h3d-carve-9: camUpdate body moved to camera.js — extractFn retargets there.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
function extractFn(source, name) {
const start = source.indexOf('function ' + name);
@@ -209,8 +212,10 @@ test('Camera Director still layers after the bootstrapped auto-framing base', ()
'bootstrap must only initialize base framing, never mutate Camera Director state',
);
const camUpdate = extractFn(src, 'camUpdate');
const baseIndex = camUpdate.indexOf('curX += (tgtX - curX) * lerp');
// h3d-carve-9: extractFn must target cameraSrc — src holds only the tombstone.
// tgtX is DI-rewired to getTgtX() direct call in camera.js.
const camUpdate = extractFn(cameraSrc, 'camUpdate');
const baseIndex = camUpdate.indexOf('curX += (getTgtX() - curX) * lerp');
const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)');
const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)');
assert.ok(
@@ -218,3 +223,25 @@ test('Camera Director still layers after the bootstrapped auto-framing base', ()
'Camera Director transforms must remain layered after base framing and before camera placement',
);
});
// ── h3d-carve-9: setter class-killers (write-back pairs must survive DI) ────
// Severing the call turns the test RED: a silent local var replaces the
// write-back and the IIFE-scope var is never updated.
test('setCurX write-back is called in camUpdate (curX persists across frames)', () => {
// Silencing: sed 's/setCurX(curX)/\/\/ GUTTED/' → this test fails.
assert.match(
cameraSrc,
/setCurX\(\s*curX\s*\)/,
'camUpdate must write curX back via setCurX(); removing it silences the update',
);
});
test('setFretRowFitBoost write-back is called in camUpdate (boost persists across frames)', () => {
// Silencing: sed 's/setFretRowFitBoost(_fretRowFitBoost)/\/\/ GUTTED/' → RED.
assert.match(
cameraSrc,
/setFretRowFitBoost\(\s*_fretRowFitBoost\s*\)/,
'camUpdate must write _fretRowFitBoost back via setFretRowFitBoost(); removing it silences the boost',
);
});
+21 -12
View File
@@ -24,6 +24,9 @@ const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-9: camUpdate body moved here; tests that pin its internals retarget to cameraSrc.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
// ── Zoom-dependent framing ──────────────────────────────────────────────────
@@ -43,13 +46,14 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
// The base position is assigned into _camX/_camY/_camZ so the opt-in
// free-camera bridge (#771) can layer orbit/zoom/height on top before the
// single cam.position.set; the multipliers must still feed _camY/_camZ.
// h3d-carve-9: camUpdate (and these locals) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/,
'the base camera position must use the interpolated _hMul / _dMul multipliers',
);
assert.match(
src,
cameraSrc,
/cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/,
'cam.position.set must apply the computed _camX / _camY / _camZ',
);
@@ -57,18 +61,19 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
test('framing multipliers are a clamped zoom-distance interpolation', () => {
// _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR.
// h3d-carve-9: camUpdate (and these expressions) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/,
'_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]',
);
assert.match(
src,
cameraSrc,
/CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/,
'height multiplier must lerp NEAR->FAR by _zt',
);
assert.match(
src,
cameraSrc,
/CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/,
'depth multiplier must lerp NEAR->FAR by _zt',
);
@@ -146,29 +151,32 @@ test('fret-row fit guard constants are defined', () => {
test('the curDist lerp target applies the fit-guard dolly boost', () => {
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
// h3d-carve-9: camUpdate (and this expression) moved to src/camera.js.
// tgtDist is DI-rewired to getTgtDist() direct call.
assert.match(
src,
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward tgtDist * _fretRowFitBoost',
cameraSrc,
/curDist\s*\+=\s*\(\s*getTgtDist\(\)\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward getTgtDist() * _fretRowFitBoost',
);
});
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
// h3d-carve-9: camUpdate (and this logic) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
'the guard must probe the same row band the fret-number row is drawn at',
);
// Prompt pull-back when below the min, capped at BOOST_MAX.
assert.match(
src,
cameraSrc,
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
);
// Lazy relax only once past the deadband, floored at 1.
assert.match(
src,
cameraSrc,
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
'past the deadband the boost relaxes back toward 1',
);
@@ -176,8 +184,9 @@ test('the guard projects the fret-row band and adjusts the boost with hysteresis
test('the fit guard yields to the free-cam (Camera Director)', () => {
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
// h3d-carve-9: camUpdate (and this guard) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
'with the free-cam enabled the guard must drop any auto dolly back to 1',
);
+7 -3
View File
@@ -12,6 +12,8 @@ const ROOT = path.join(__dirname, '..', '..');
const HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js');
const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js');
const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md');
// h3d-carve-9: camUpdate (including shoulderOffset + _camX) moved to camera.js.
const CAMERA_JS = path.join(ROOT, 'plugins', 'highway_3d', 'src', 'camera.js');
function src(file) {
return fs.readFileSync(file, 'utf8');
@@ -69,12 +71,14 @@ test('draw(bundle) handles lefty changes by flipping camera X state and rebuildi
});
test('camera shoulder offset follows the cached lefty orientation', () => {
// h3d-carve-9: camUpdate (shoulderOffset + _camX) moved to src/camera.js;
// _leftyCached is DI-rewired to getLeftyCached() direct call.
assert.match(
src(SCREEN_JS),
src(CAMERA_JS),
// The shoulder offset now feeds the base _camX (which the opt-in
// free-camera bridge layers on top of) before cam.position.set (#771).
/const\s+shoulderOffset\s*=\s*\(\s*_leftyCached\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with _leftyCached',
/const\s+shoulderOffset\s*=\s*\(\s*getLeftyCached\(\)\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with getLeftyCached()',
);
});
@@ -130,6 +130,11 @@ function loadHighway3dStatics() {
_applyBloom() {},
_bloomEnsure() { return null; },
}),
// h3d-carve-9: W-section (camera lerp) moved to src/camera.js.
createCamera: () => ({
effectiveVfov() { return 70; },
camUpdate() {},
}),
};
vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
+18 -9
View File
@@ -21,7 +21,9 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js'); // h3d-carve-9
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8'); // h3d-carve-9: effectiveVfov/camUpdate moved here
// ── Constants ────────────────────────────────────────────────────────────────
@@ -53,16 +55,18 @@ test('the Hor+ start-aspect and min-vfov defaults exist', () => {
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
// The disabled / malformed-input guard returns `base` before any Hor+ math,
// so normal panes are unaffected when __h3dAspectTune is missing or off.
// h3d-carve-9: effectiveVfov moved to src/camera.js — retarget to cameraSrc.
assert.match(
src,
cameraSrc,
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
'effectiveVfov must short-circuit to the base fov when disabled',
);
});
test('effectiveVfov is a no-op at/under the start aspect', () => {
// h3d-carve-9: effectiveVfov moved to src/camera.js — retarget to cameraSrc.
assert.match(
src,
cameraSrc,
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
);
@@ -121,10 +125,11 @@ test('applySize caches the pane aspect for camUpdate', () => {
});
test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
// h3d-carve-9: camUpdate moved to src/camera.js; resolveTuneFor is DI-renamed.
assert.match(
src,
/const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
cameraSrc,
/const\s+_aspTune\s*=\s*resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must resolve the tune per pane via resolveTuneFor(_paneKey) and gate splitOnly',
);
});
@@ -170,7 +175,8 @@ test('a Target select and pane registry drive the per-pane picker', () => {
'the panel must build a Target <select>');
assert.match(src, /function\s+_aspectRegisterPane\s*\(/,
'_aspectRegisterPane must record live panes for the picker');
assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/,
// h3d-carve-9: camUpdate moved to camera.js; fn-ref DI-renamed (no underscore).
assert.match(cameraSrc, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*aspectRegisterPane\(\s*_paneKey\s*\)/,
'camUpdate must register its pane only while the tuner panel is open');
});
@@ -183,9 +189,11 @@ test('panes are keyed by arrangement (stable across songs, no split-API dep)', (
/function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/,
'_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>',
);
// h3d-carve-9: camUpdate moved to camera.js; fn-ref DI-renamed (no underscore),
// _paneUid replaced by getPaneUid() accessor call.
assert.match(
src,
/const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/,
cameraSrc,
/const\s+_paneKey\s*=\s*aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*getPaneUid\(\)\s*\)\s*;/,
'camUpdate must key the pane by arrangement (with the uid fallback)',
);
});
@@ -286,8 +294,9 @@ test('the panel has a dismiss (close) control', () => {
test('camUpdate only writes cam.fov when it actually changes', () => {
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
// pane and keeps the disabled path free.
// h3d-carve-9: camUpdate moved to src/camera.js — retarget to cameraSrc.
assert.match(
src,
cameraSrc,
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
'camUpdate must guard the cam.fov write behind a change check',
);