From 10c7ec8a9d6068e4710a999afd3130b8ac910370 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 5 Sep 2026 16:37:20 +0200 Subject: [PATCH] =?UTF-8?q?refactor(h3d-carve-9):=20extract=20W-section=20?= =?UTF-8?q?(camera=20lerp)=20=E2=86=92=20src/camera.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW --- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 232 +++------------- plugins/highway_3d/src/camera.js | 276 +++++++++++++++++++ tests/js/highway_3d_camera_bootstrap.test.js | 31 ++- tests/js/highway_3d_camera_framing.test.js | 33 ++- tests/js/highway_3d_lefty.test.js | 10 +- tests/js/highway_3d_panel_controls.test.js | 5 + tests/js/highway_3d_wide_fov.test.js | 27 +- 8 files changed, 390 insertions(+), 226 deletions(-) create mode 100644 plugins/highway_3d/src/camera.js diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index c312281..10b0182 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.44.0", + "version": "3.45.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 7212aae..9162fe3 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -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) { diff --git a/plugins/highway_3d/src/camera.js b/plugins/highway_3d/src/camera.js new file mode 100644 index 0000000..5aa5c4c --- /dev/null +++ b/plugins/highway_3d/src/camera.js @@ -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 }; +} diff --git a/tests/js/highway_3d_camera_bootstrap.test.js b/tests/js/highway_3d_camera_bootstrap.test.js index f68e0d2..2bdd176 100644 --- a/tests/js/highway_3d_camera_bootstrap.test.js +++ b/tests/js/highway_3d_camera_bootstrap.test.js @@ -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', + ); +}); diff --git a/tests/js/highway_3d_camera_framing.test.js b/tests/js/highway_3d_camera_framing.test.js index 48bcd61..e89b116 100644 --- a/tests/js/highway_3d_camera_framing.test.js +++ b/tests/js/highway_3d_camera_framing.test.js @@ -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', ); diff --git a/tests/js/highway_3d_lefty.test.js b/tests/js/highway_3d_lefty.test.js index 6f0c0ef..1d4c774 100644 --- a/tests/js/highway_3d_lefty.test.js +++ b/tests/js/highway_3d_lefty.test.js @@ -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()', ); }); diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index 2dc007b..16cd93b 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -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 }); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index d568860..2d2178c 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.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