From 513eeae59647660d69dba2423c2927081dc77432 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:23:40 +0000 Subject: [PATCH 1/6] feat(highway_3d): add a general per-panel zoom that works in every camera mode The only zoom control was "Locked zoom", which applies only while "Lock camera at frets 1-12" is on and the visible notes are at or below fret 12. In a small split-screen panel the auto-framed view could not be brought closer. Add a cameraZoom setting (0..1, 0.5 = unchanged) that scales the final camera distance in every mode, expose it first in panelControls so Split Screen's Viz popover shows it, and add window.h3dBgSetCameraZoom. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- CHANGELOG.md | 1 + plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 30 +++++++++++++++++++--- tests/js/highway_3d_camera_framing.test.js | 4 +-- tests/js/highway_3d_panel_controls.test.js | 4 +-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68949647..9d2db8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **3D Highway: general per-panel zoom.** A new `cameraZoom` setting (Zoom In ↔ Out, 0.5 = unchanged) scales the camera distance in every camera mode and appears first in Split Screen's Viz ⚙ popover. Previously the only zoom control was "Locked zoom", which does nothing unless "Lock camera at frets 1–12" is on and the visible notes are at or below fret 12, so a small split panel could not be brought closer in the normal auto-framed view. Default is neutral; existing layouts are unchanged. `window.h3dBgSetCameraZoom(v)` sets the global value. - **Host compatibility matrix (`docs/compatibility.md`).** A maintained table of the host APIs current plugins depend on — `dlc_paths._resolve_dlc_path`, `sloppak.read_member_bytes`, the `chart-transform` capability, the `/ws/sync/{session_id}` relay, `renderFrameAt`/`renderFrame`/`setExternalFrameDriver`, `playerContexts`/`player-difficulty.v1`, and phrase-tier `getPhrases().top_difficulty` — each row pinned to an exact commit, the plugins and features that need it, whether the need is required or optional, and whether the commit is upstream or fork-only. Written because the version string cannot express any of it: `VERSION` has read `0.3.0-alpha.2` since 2026-08-10 across 83 commits, so a version comparison cannot separate a build that has `renderFrameAt` from one that does not. Documentation only — no plugin loading, capability, or `minHost` behaviour changes. Every row is source-audited, not runtime-certified; the document says so, and records the host and plugin revisions it was verified against (#131). - **Keys Highway 3D: per-panel hand filter.** The plugin declares a `handFilter` setting (Both / LH / RH) in `capabilities.visualization.settings`, and each renderer instance implements `applySetting` / `getSetting`. Split Screen can now show a different hand in each panel (feedBack-plugin-splitscreen#66). An instance with no override follows the global Settings value as before. Re-saving the *same* global hand value no longer rebuilds the notes or resets the current score mid-song. - **Player identity capability for concurrent local play.** The Host now owns stable session/player contexts, profile readiness, redacted diagnostics, lifecycle events, private highway bindings, and player-scoped difficulty routing for Split Screen, karaoke, Note Detection, and Difficulty Ladder integrations. diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 7d97f246..e97321e4 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.34.2", + "version": "3.35.0", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 358cd45d..d607d595 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1225,6 +1225,10 @@ // 1.0× at slider=0.5 so the previous locked view is the midpoint. const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest + // General view-distance range (cameraZoom). Same span as the locked + // zoom so the two sliders feel alike; 0.5 is neutral (1.0×). + const CAM_VIEW_ZOOM_MIN = 0.55; + const CAM_VIEW_ZOOM_MAX = 1.45; const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) // ── 3D preview: lookahead fret bounds + smoothed focal X / span ───────── @@ -2414,7 +2418,7 @@ return _bgBandsCache; } - const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true, hitFx: 0.7, sparks: true, cinematic: true, verdictMarks: true, timingFx: true, streakFx: true, bloom: true }; + const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true, hitFx: 0.7, sparks: true, cinematic: true, verdictMarks: true, timingFx: true, streakFx: true, bloom: true }; // User-selectable, persistable bg styles — must mirror settings.html's // VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective // style reached only via _venueSceneOverride (the viz-picker Venue flow), so @@ -2834,7 +2838,7 @@ // hysteresis; zoomSmoothing the zoom dead zone; tiltSmoothing the // vertical-tilt deadband + correction strength. All three slider- // shaped settings share the same parse + clamp behaviour. - const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize', 'hitFx']); + const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'cameraZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize', 'hitFx']); function _bgCoerce(key, val) { if (_BG_FLOAT_KEYS.has(key)) { const n = parseFloat(val); @@ -2956,6 +2960,7 @@ window.h3dBgSetTiltSmoothing = (v) => _bgWriteGlobal('tiltSmoothing', v); window.h3dBgSetCameraLockLow = (v) => _bgWriteGlobal('cameraLockLow', !!v); window.h3dBgSetCameraLockZoom = (v) => _bgWriteGlobal('cameraLockZoom', v); + window.h3dBgSetCameraZoom = (v) => _bgWriteGlobal('cameraZoom', v); window.h3dBgSetCameraMode = (v) => { let s = String(v); if (s === 'classic') s = 'steady'; @@ -4859,6 +4864,12 @@ // fretboard), 0.5 → 1.0× (the default locked view), 1 → CAM_LOCK_ZOOM_MAX // (furthest). Inactive when the lock isn't engaged. let cameraLockZoom = 0.5; + // General view distance, applied in every camera mode (locked or + // not). Slider 0..1 maps to a multiplier on the final camera + // distance: 0 → CAM_VIEW_ZOOM_MIN (closest), 0.5 → 1.0× (default, + // unchanged framing), 1 → CAM_VIEW_ZOOM_MAX. Mainly for small + // splitscreen panels, where the auto-framed view reads as too far. + let cameraZoom = 0.5; /** 'steady' = recency-weighted centroid + hysteresis (#34); 'lookahead' = wide preview window + smooth focal. */ let cameraMode = BG_DEFAULTS.cameraMode; // Global text-size multiplier for in-scene text sprites (chord @@ -8488,7 +8499,7 @@ changedKey === 'fretNumberGhostScope' || changedKey === 'cameraSmoothing' || changedKey === 'zoomSmoothing' || changedKey === 'tiltSmoothing' || changedKey === 'cameraLockLow' || - changedKey === 'cameraLockZoom' || changedKey === 'cameraMode' || + changedKey === 'cameraLockZoom' || changedKey === 'cameraZoom' || changedKey === 'cameraMode' || changedKey === 'textSize' || changedKey === 'chordDiagramSize' || changedKey === 'chordDiagramPosition' || changedKey === 'fretColumnMarkerCadence' || @@ -8793,6 +8804,7 @@ : cameraSmoothing; cameraLockLow = _bgReadSetting(panelKey, 'cameraLockLow'); cameraLockZoom = _bgReadSetting(panelKey, 'cameraLockZoom'); + cameraZoom = _bgReadSetting(panelKey, 'cameraZoom'); cameraMode = _bgReadSetting(panelKey, 'cameraMode'); textSize = _bgReadSetting(panelKey, 'textSize'); vibrancy = _bgReadSetting(panelKey, 'vibrancy'); @@ -15685,7 +15697,8 @@ 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 _viewZoomMul = CAM_VIEW_ZOOM_MIN + (CAM_VIEW_ZOOM_MAX - CAM_VIEW_ZOOM_MIN) * cameraZoom; + curDist += (tgtDist * _fretRowFitBoost * _viewZoomMul - curDist) * lerp; const dist = curDist * aspectScale; const h = CAM_H_BASE * (dist / CAM_DIST_BASE); @@ -16628,6 +16641,15 @@ // removed — per-string colors are set via the core "Highway String Colors" // UI, which drives both highways by named string. window.feedBackViz_highway_3d.panelControls = [ + { + key: 'cameraZoom', + label: 'Zoom (In ↔ Out)', + type: 'range', + min: 0, + max: 1, + step: 0.05, + default: BG_DEFAULTS.cameraZoom, + }, { key: 'cameraSmoothing', label: 'Camera smoothing (X-pan)', diff --git a/tests/js/highway_3d_camera_framing.test.js b/tests/js/highway_3d_camera_framing.test.js index 48bcd618..7b66bad8 100644 --- a/tests/js/highway_3d_camera_framing.test.js +++ b/tests/js/highway_3d_camera_framing.test.js @@ -148,8 +148,8 @@ test('the curDist lerp target applies the fit-guard dolly boost', () => { // The span-driven tgtDist still owns zooming in; the boost only pulls back. assert.match( src, - /curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/, - 'curDist must lerp toward tgtDist * _fretRowFitBoost', + /curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*\*\s*_viewZoomMul\s*-\s*curDist\s*\)\s*\*\s*lerp/, + 'curDist must lerp toward tgtDist * _fretRowFitBoost * _viewZoomMul', ); }); diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index 3c80d638..f4f7723b 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -12,7 +12,7 @@ const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'scr // 'palette' was removed — per-string colors are now set via the core // "Highway String Colors" UI, which drives both highways by named string. -const REQUIRED_KEYS = ['cameraSmoothing', 'cameraLockLow', 'cameraLockZoom']; +const REQUIRED_KEYS = ['cameraZoom', 'cameraSmoothing', 'cameraLockLow', 'cameraLockZoom']; const FORBIDDEN_KEYS = ['customImageDataUrl', 'customImageName', 'customVideoName']; const VALID_TYPES = new Set(['select', 'range', 'toggle']); @@ -91,7 +91,7 @@ test('3D Highway exposes static panelControls descriptors for per-panel hosts', const controls = cloneJson(factory.panelControls); const defaults = cloneJson(window.__h3dTestExports.BG_DEFAULTS); const keys = controls.map((control) => control && control.key); - assert.deepEqual(keys, REQUIRED_KEYS, 'panelControls must expose exactly the issue #247 control set'); + assert.deepEqual(keys, REQUIRED_KEYS, 'panelControls must expose exactly the issue #247 control set plus the general cameraZoom'); const duplicateKeys = keys.filter((key, index) => keys.indexOf(key) !== index); assert.deepEqual(duplicateKeys, [], 'panelControls keys must be unique'); From 26a5b1b75c93535da48ede3310dc9ce18468c69c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:28:25 +0000 Subject: [PATCH 2/6] fix(highway_3d): stop the view zoom closing in past the fret-number row At the closest slider setting (0.55x camera distance) the outer fret numbers and strings were clipped by the panel edge, because the fret-row guard only probes the camera's centre column. Limit the near end to 0.7x, which keeps every fret number through 24 on screen. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- plugins/highway_3d/screen.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index d607d595..7e0b065c 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1225,9 +1225,11 @@ // 1.0× at slider=0.5 so the previous locked view is the midpoint. const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest - // General view-distance range (cameraZoom). Same span as the locked - // zoom so the two sliders feel alike; 0.5 is neutral (1.0×). - const CAM_VIEW_ZOOM_MIN = 0.55; + // General view-distance range (cameraZoom); 0.5 is neutral (1.0×). The + // near end stops at 0.7 because closer than that pushes the outer fret + // numbers and strings off the panel edge (the fret-row guard only probes + // the camera's centre column). + const CAM_VIEW_ZOOM_MIN = 0.7; const CAM_VIEW_ZOOM_MAX = 1.45; const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) From 7c55a4a7e9c589882ff772db53dcbe5fe1f7a73b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:36:33 +0000 Subject: [PATCH 3/6] feat(highway_3d): mark Locked zoom as depending on the camera lock Declare dependsOn: 'cameraLockLow' on the cameraLockZoom panel control so hosts such as Split Screen can grey it out until the lock is enabled. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- plugins/highway_3d/screen.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 7e0b065c..8f6fe600 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -16675,6 +16675,8 @@ max: 1, step: 0.05, default: BG_DEFAULTS.cameraLockZoom, + // Only affects the locked view; hosts grey it out until the lock is on. + dependsOn: 'cameraLockLow', }, ]; // Static metadata exposed on the factory: From 126769a3d27964ce938b2b1b97847fb132674835 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:14:18 +0000 Subject: [PATCH 4/6] fix(highway_3d): make cameraZoom neutral at 0.5 and bound it against Locked zoom Raising the near limit to 0.7 left a single linear blend whose midpoint was 1.075x, so the default slider position moved the camera. Map the slider piecewise (0 -> 0.7, 0.5 -> exactly 1.0, 1 -> 1.45). While the lock is engaged cameraLockZoom has already scaled the distance and the view zoom multiplied on top, reaching 0.385x and 2.1x. Bound the view zoom by the lock multiplier so the product stays within the limits either slider documents. Document the panelControls dependsOn field. Add tests for the neutral default, the monotonic mapping and the product bound. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- plugins/highway_3d/CLAUDE.md | 4 +- plugins/highway_3d/screen.js | 31 +++++++++++++-- tests/js/highway_3d_panel_controls.test.js | 44 +++++++++++++++++++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/plugins/highway_3d/CLAUDE.md b/plugins/highway_3d/CLAUDE.md index c1975e11..5be3b63b 100644 --- a/plugins/highway_3d/CLAUDE.md +++ b/plugins/highway_3d/CLAUDE.md @@ -137,7 +137,9 @@ Each entry names the function or banner you should grep for, plus key sub-blocks ### Splitscreen panel controls/settings - Per-panel background overrides use `localStorage` keys shaped as `h3d_bg_panel_`. When present, they override the global `h3d_bg_` value for panel `N`; when absent, the global value still applies. - Keep per-panel keys to `BG_DEFAULTS` entries that `_bgLoadSettings()` reads. Do not add panel-only keys outside that load path. -- `panelControls` is a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through `_bgLoadSettings()`. +- `panelControls` is a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through `_bgLoadSettings()`. A descriptor may also set `dependsOn: ''` (e.g. `cameraLockZoom` depends on `cameraLockLow`, because Locked zoom only acts while the lock is engaged). Split Screen's Viz popover consumes it (feedBack-plugin-splitscreen#78): it greys out and disables the control until that toggle is on, and ignores a missing, non-toggle, self-referencing or cyclic target. It is honoured only on the `panelControls` path; core's declared-settings whitelist drops it, so don't rely on it if the plugin ever moves to `capabilities.visualization.settings`. A host that ignores the field still works, with the control simply always enabled. + +`cameraZoom` is the general view distance, applied in every camera mode: slider 0.5 is exactly 1.0× (unchanged framing), 0 → 0.7×, 1 → 1.45×. While the lock is engaged it multiplies on top of `cameraLockZoom`, so `camBoundViewZoom` keeps the product within the limits either slider documents. - Asset/background image keys remain global-only. Do not make uploaded or selected asset references panel-scoped unless that contract is explicitly widened. - Host refresh nudges that call toggle setters must pass real booleans, not strings such as `'false'`, so setters can distinguish `true` from `false`. diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 8f6fe600..c9d854aa 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1225,12 +1225,32 @@ // 1.0× at slider=0.5 so the previous locked view is the midpoint. const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest - // General view-distance range (cameraZoom); 0.5 is neutral (1.0×). The - // near end stops at 0.7 because closer than that pushes the outer fret + // General view-distance range (cameraZoom): slider 0 → MIN (closest), + // 0.5 → exactly 1.0× (neutral, unchanged framing), 1 → MAX (furthest). + // The near end stops at 0.7 because closer than that pushes the outer fret // numbers and strings off the panel edge (the fret-row guard only probes // the camera's centre column). const CAM_VIEW_ZOOM_MIN = 0.7; const CAM_VIEW_ZOOM_MAX = 1.45; + // Piecewise-linear so the midpoint is 1.0× whatever MIN/MAX are (a single + // linear blend is only neutral when MIN + MAX === 2). + function camViewZoomMul(zoom) { + const z = Math.max(0, Math.min(1, Number.isFinite(zoom) ? zoom : 0.5)); + return z <= 0.5 + ? CAM_VIEW_ZOOM_MIN + (1 - CAM_VIEW_ZOOM_MIN) * (z / 0.5) + : 1 + (CAM_VIEW_ZOOM_MAX - 1) * ((z - 0.5) / 0.5); + } + // While the lock is engaged cameraLockZoom has already scaled the camera + // distance by `lockMul`, and the view zoom multiplies on top of it. Bound + // the view zoom so the product never gets closer than min(lockMul, MIN) or + // further than max(lockMul, MAX): the two sliders must not stack past the + // limits either one documents. `lockMul` is 1 when the lock is not engaged. + function camBoundViewZoom(viewMul, lockMul) { + const lm = lockMul > 0 ? lockMul : 1; + const lo = Math.min(1, CAM_VIEW_ZOOM_MIN / lm); + const hi = Math.max(1, CAM_VIEW_ZOOM_MAX / lm); + return Math.max(lo, Math.min(hi, viewMul)); + } const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) // ── 3D preview: lookahead fret bounds + smoothed focal X / span ───────── @@ -15699,7 +15719,12 @@ 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. - const _viewZoomMul = CAM_VIEW_ZOOM_MIN + (CAM_VIEW_ZOOM_MAX - CAM_VIEW_ZOOM_MIN) * cameraZoom; + // prevLockActive: the lock view was applied this frame, so its own + // zoom is already inside tgtDist and the view zoom is bounded by it. + const _lockMulNow = prevLockActive + ? CAM_LOCK_ZOOM_MIN + (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom + : 1; + const _viewZoomMul = camBoundViewZoom(camViewZoomMul(cameraZoom), _lockMulNow); curDist += (tgtDist * _fretRowFitBoost * _viewZoomMul - curDist) * lerp; const dist = curDist * aspectScale; const h = CAM_H_BASE * (dist / CAM_DIST_BASE); diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index f4f7723b..cadc21e8 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -30,7 +30,7 @@ function loadHighway3dStatics() { ); const instrumented = src.replace( ANCHOR, - `${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`, + `${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS, camViewZoomMul, camBoundViewZoom };`, ); assert.notEqual(instrumented, src, 'test export injection anchor not found in screen.js'); @@ -176,3 +176,45 @@ test('3D Highway exposes static panelControls descriptors for per-panel hosts', assert.equal(cameraLockZoom.max, 1); assert.equal(cameraLockZoom.default, defaults.cameraLockZoom); }); + +// ── cameraZoom: neutral default and bounded combination with Locked zoom ──── + +test('cameraZoom maps 0 / 0.5 / 1 to 0.7x / exactly 1.0x / 1.45x and is monotonic', () => { + const { camViewZoomMul } = loadHighway3dStatics().__h3dTestExports; + assert.equal(camViewZoomMul(0.5), 1, 'the default slider position must leave the framing unchanged'); + assert.equal(camViewZoomMul(0), 0.7); + assert.equal(camViewZoomMul(1), 1.45); + let prev = -Infinity; + for (let z = 0; z <= 1.0001; z += 0.05) { + const m = camViewZoomMul(z); + assert.ok(m >= prev, `non-decreasing at ${z}`); + prev = m; + } + assert.equal(camViewZoomMul(NaN), 1, 'a bad value falls back to neutral'); + assert.equal(camViewZoomMul(-3), 0.7); + assert.equal(camViewZoomMul(9), 1.45); +}); + +test('cameraZoom default in BG_DEFAULTS is the neutral slider position', () => { + const { BG_DEFAULTS, camViewZoomMul } = loadHighway3dStatics().__h3dTestExports; + assert.equal(camViewZoomMul(BG_DEFAULTS.cameraZoom), 1); +}); + +test('with the lock engaged the two zooms cannot stack past either one\'s limits', () => { + const { camViewZoomMul, camBoundViewZoom } = loadHighway3dStatics().__h3dTestExports; + const total = (lockMul, zoom) => lockMul * camBoundViewZoom(camViewZoomMul(zoom), lockMul); + for (const lockMul of [0.55, 0.8, 1, 1.2, 1.45]) { + for (const zoom of [0, 0.25, 0.5, 0.75, 1]) { + const t = total(lockMul, zoom); + assert.ok(t >= Math.min(lockMul, 0.7) - 1e-9, `lock ${lockMul} zoom ${zoom}: ${t} is closer than allowed`); + assert.ok(t <= Math.max(lockMul, 1.45) + 1e-9, `lock ${lockMul} zoom ${zoom}: ${t} is further than allowed`); + } + } + // the exact case the review flagged: both sliders at the near end + assert.ok(total(0.55, 0) >= 0.55 - 1e-9, 'must not reach 0.55 * 0.7 = 0.385'); + // and both at the far end + assert.ok(total(1.45, 1) <= 1.45 + 1e-9, 'must not reach 1.45 * 1.45 = 2.1'); + // without the lock (lockMul 1) the view zoom keeps its full range + assert.equal(total(1, 0), 0.7); + assert.equal(total(1, 1), 1.45); +}); From a1e653ba4561470723061573d2a4281b83d9e65a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:33:50 +0000 Subject: [PATCH 5/6] test(highway_3d): use a named tolerance instead of 1e-9 literals Codacy flagged the 1e-9 literals in the cameraZoom bound test. Use a named TOLERANCE constant with no exponent literal; the products under test differ from their limits by far more than float slack, so behaviour is unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- tests/js/highway_3d_panel_controls.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index cadc21e8..2f3c5411 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -202,18 +202,20 @@ test('cameraZoom default in BG_DEFAULTS is the neutral slider position', () => { test('with the lock engaged the two zooms cannot stack past either one\'s limits', () => { const { camViewZoomMul, camBoundViewZoom } = loadHighway3dStatics().__h3dTestExports; + // float slack only; the products involved differ from their limits by far more + const TOLERANCE = 1 / 1000000; const total = (lockMul, zoom) => lockMul * camBoundViewZoom(camViewZoomMul(zoom), lockMul); for (const lockMul of [0.55, 0.8, 1, 1.2, 1.45]) { for (const zoom of [0, 0.25, 0.5, 0.75, 1]) { const t = total(lockMul, zoom); - assert.ok(t >= Math.min(lockMul, 0.7) - 1e-9, `lock ${lockMul} zoom ${zoom}: ${t} is closer than allowed`); - assert.ok(t <= Math.max(lockMul, 1.45) + 1e-9, `lock ${lockMul} zoom ${zoom}: ${t} is further than allowed`); + assert.ok(t >= Math.min(lockMul, 0.7) - TOLERANCE, `lock ${lockMul} zoom ${zoom}: ${t} is closer than allowed`); + assert.ok(t <= Math.max(lockMul, 1.45) + TOLERANCE, `lock ${lockMul} zoom ${zoom}: ${t} is further than allowed`); } } // the exact case the review flagged: both sliders at the near end - assert.ok(total(0.55, 0) >= 0.55 - 1e-9, 'must not reach 0.55 * 0.7 = 0.385'); + assert.ok(total(0.55, 0) >= 0.55 - TOLERANCE, 'must not reach 0.55 * 0.7 = 0.385'); // and both at the far end - assert.ok(total(1.45, 1) <= 1.45 + 1e-9, 'must not reach 1.45 * 1.45 = 2.1'); + assert.ok(total(1.45, 1) <= 1.45 + TOLERANCE, 'must not reach 1.45 * 1.45 = 2.1'); // without the lock (lockMul 1) the view zoom keeps its full range assert.equal(total(1, 0), 0.7); assert.equal(total(1, 1), 1.45); From eb9ba961ca9c9568ea5ad12da6202c557a93e8f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:38:55 +0000 Subject: [PATCH 6/6] refactor(highway_3d): one Locked-zoom mapping; document the zoom interaction Fold the four copies of the cameraLockZoom -> multiplier expression (the three locked tgtDist sites and the cameraZoom bound) into camLockZoomMul(), so retuning it cannot desync them. Identical arithmetic; a test pins the mapping (0 -> 0.55, 0.5 -> 1.0, 1 -> 1.45) and that only the helper computes it. Move the cameraZoom paragraph from the Splitscreen controls list to the Camera section and add that Locked zoom and View zoom interact: the further Locked zoom is from its default, the less of View zoom's travel does anything. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G99dXhcfEFmVR7R4jLpYRH --- plugins/highway_3d/CLAUDE.md | 4 ++-- plugins/highway_3d/screen.js | 17 ++++++++++------- tests/js/highway_3d_panel_controls.test.js | 17 ++++++++++++++++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/plugins/highway_3d/CLAUDE.md b/plugins/highway_3d/CLAUDE.md index 5be3b63b..17cddb51 100644 --- a/plugins/highway_3d/CLAUDE.md +++ b/plugins/highway_3d/CLAUDE.md @@ -105,6 +105,8 @@ Each entry names the function or banner you should grep for, plus key sub-blocks - **Smooth lerp + look-at** → `camUpdate()`. BPM-scaled lerp speed (`CAM_LERP_BASE * bpm/120`). - **Self-correcting framing** → bottom half of `camUpdate()`. Projects the fretboard mid-Y to NDC, nudges `tgtLookY` until that point sits at NDC Y ≈ `DESIRED_NDC_Y` (lower third of frame). This is what lets the camera adapt automatically to ultra-wide split-screen panels. - **Aspect compensation** → `aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5))` in `applySize()`. Clamped to ≥ 1 so wide panels keep baseline depth (don't dolly in flat). Removing the `Math.max(1, …)` is the bug we already fixed; don't reintroduce it. +- **View zoom (`cameraZoom`)** → `camViewZoomMul()` / `camBoundViewZoom()` / the `curDist` lerp target in `camUpdate()`. The general view distance, applied in every camera mode: slider 0.5 is exactly 1.0× (unchanged framing), 0 → 0.7×, 1 → 1.45× (piecewise, so the midpoint stays neutral whatever the endpoints are). It is a per-panel key like the others (read through `_bgLoadSettings()`, shown first in Split Screen's Viz popover). +- **Locked zoom and View zoom interact.** `cameraLockZoom` (via `camLockZoomMul()`, the single mapping every locked `tgtDist` site uses) already scales the distance while the lock is engaged, and View zoom multiplies on top, so `camBoundViewZoom` bounds View zoom to keep the product within min(lockMul, 0.7) … max(lockMul, 1.45). Consequence: the further Locked zoom is from its 0.5 default, the less of View zoom's travel does anything. At Locked zoom 0 the near half of View zoom is inert, at 1 the far half is, and in between a growing slice of each end is clamped. At the default Locked zoom, or with the lock off, View zoom has its full range. `cameraLockZoom` itself only acts while the lock is engaged (hence `dependsOn: 'cameraLockLow'` on its descriptor). ### Beats and sections - **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats. @@ -138,8 +140,6 @@ Each entry names the function or banner you should grep for, plus key sub-blocks - Per-panel background overrides use `localStorage` keys shaped as `h3d_bg_panel_`. When present, they override the global `h3d_bg_` value for panel `N`; when absent, the global value still applies. - Keep per-panel keys to `BG_DEFAULTS` entries that `_bgLoadSettings()` reads. Do not add panel-only keys outside that load path. - `panelControls` is a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through `_bgLoadSettings()`. A descriptor may also set `dependsOn: ''` (e.g. `cameraLockZoom` depends on `cameraLockLow`, because Locked zoom only acts while the lock is engaged). Split Screen's Viz popover consumes it (feedBack-plugin-splitscreen#78): it greys out and disables the control until that toggle is on, and ignores a missing, non-toggle, self-referencing or cyclic target. It is honoured only on the `panelControls` path; core's declared-settings whitelist drops it, so don't rely on it if the plugin ever moves to `capabilities.visualization.settings`. A host that ignores the field still works, with the control simply always enabled. - -`cameraZoom` is the general view distance, applied in every camera mode: slider 0.5 is exactly 1.0× (unchanged framing), 0 → 0.7×, 1 → 1.45×. While the lock is engaged it multiplies on top of `cameraLockZoom`, so `camBoundViewZoom` keeps the product within the limits either slider documents. - Asset/background image keys remain global-only. Do not make uploaded or selected asset references panel-scoped unless that contract is explicitly widened. - Host refresh nudges that call toggle setters must pass real booleans, not strings such as `'false'`, so setters can distinguish `true` from `false`. diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index c9d854aa..696dc4b7 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1251,6 +1251,12 @@ const hi = Math.max(1, CAM_VIEW_ZOOM_MAX / lm); return Math.max(lo, Math.min(hi, viewMul)); } + // The Locked-zoom multiplier (cameraLockZoom 0..1 -> MIN..MAX; 0.5 -> 1.0x). + // Every place that folds it into the locked tgtDist, and the bound above, + // must use this one mapping so retuning it cannot desync them. + function camLockZoomMul(zoom) { + return CAM_LOCK_ZOOM_MIN + (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * zoom; + } const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) // ── 3D preview: lookahead fret bounds + smoothed focal X / span ───────── @@ -9868,8 +9874,7 @@ // cameraLockZoom slider 0..1 blends between MIN (closest) // and MAX (furthest). Default 0.5 maps to ~1.0× so existing // users see the same locked view as before this slider. - const lockZoomMul = CAM_LOCK_ZOOM_MIN + - (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom; + const lockZoomMul = camLockZoomMul(cameraLockZoom); tgtX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul; prevLowFretBonus = lockedBonusU; @@ -11767,8 +11772,7 @@ if (lockSnapEl) { const lockedBaseU = camBaseDistU(12); const lockedBonusU = camLowFretPullbackU(1); - const lockZoomMul = CAM_LOCK_ZOOM_MIN + - (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom; + const lockZoomMul = camLockZoomMul(cameraLockZoom); tgtX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul; prevLowFretBonus = lockedBonusU; @@ -13871,8 +13875,7 @@ if (lookaheadLockLowEligible) { const lockedBaseU = camBaseDistU(12); - const lockZoomMul = CAM_LOCK_ZOOM_MIN + - (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom; + const lockZoomMul = camLockZoomMul(cameraLockZoom); lookaheadSmoothCamStep(dtSec, xFretMid(CAM_LOCK_CENTER_FRET), 12); tgtX = _lookaheadCamX; tgtDist = (lockedBaseU + _lookaheadLowBonusU) * K * lockZoomMul; @@ -15722,7 +15725,7 @@ // prevLockActive: the lock view was applied this frame, so its own // zoom is already inside tgtDist and the view zoom is bounded by it. const _lockMulNow = prevLockActive - ? CAM_LOCK_ZOOM_MIN + (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom + ? camLockZoomMul(cameraLockZoom) : 1; const _viewZoomMul = camBoundViewZoom(camViewZoomMul(cameraZoom), _lockMulNow); curDist += (tgtDist * _fretRowFitBoost * _viewZoomMul - curDist) * lerp; diff --git a/tests/js/highway_3d_panel_controls.test.js b/tests/js/highway_3d_panel_controls.test.js index 2f3c5411..0ca9fb79 100644 --- a/tests/js/highway_3d_panel_controls.test.js +++ b/tests/js/highway_3d_panel_controls.test.js @@ -30,7 +30,7 @@ function loadHighway3dStatics() { ); const instrumented = src.replace( ANCHOR, - `${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS, camViewZoomMul, camBoundViewZoom };`, + `${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS, camViewZoomMul, camBoundViewZoom, camLockZoomMul };`, ); assert.notEqual(instrumented, src, 'test export injection anchor not found in screen.js'); @@ -220,3 +220,18 @@ test('with the lock engaged the two zooms cannot stack past either one\'s limits assert.equal(total(1, 0), 0.7); assert.equal(total(1, 1), 1.45); }); + +test('Locked zoom has one multiplier mapping: neutral at 0.5 and used by every locked-camera site', () => { + const { camLockZoomMul, BG_DEFAULTS } = loadHighway3dStatics().__h3dTestExports; + assert.equal(camLockZoomMul(0.5), 1, 'default Locked zoom must keep the previous locked view'); + assert.equal(camLockZoomMul(0), 0.55); + assert.equal(camLockZoomMul(1), 1.45); + assert.equal(camLockZoomMul(BG_DEFAULTS.cameraLockZoom), 1); + // The mapping lives in exactly one place. A second copy of the expression + // would let the cameraZoom bound and the locked tgtDist drift apart. + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + const copies = src.match(/CAM_LOCK_ZOOM_MIN\s*\+/g) || []; + assert.equal(copies.length, 1, 'only camLockZoomMul may compute CAM_LOCK_ZOOM_MIN + (...) * zoom'); + assert.ok((src.match(/camLockZoomMul\(cameraLockZoom\)/g) || []).length >= 4, + 'the three locked tgtDist sites and the cameraZoom bound must all call camLockZoomMul'); +});