mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 03:14:29 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
4a45ed8782
commit
10c7ec8a9d
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user