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/CLAUDE.md b/plugins/highway_3d/CLAUDE.md index c1975e11..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. @@ -137,7 +139,7 @@ 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. - 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/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..696dc4b7 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1225,6 +1225,38 @@ // 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): 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)); + } + // 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 ───────── @@ -2414,7 +2446,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 +2866,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 +2988,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 +4892,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 +8527,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 +8832,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'); @@ -9834,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; @@ -11733,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; @@ -13837,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; @@ -15685,7 +15722,13 @@ 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; + // 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 + ? camLockZoomMul(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); @@ -16628,6 +16671,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)', @@ -16651,6 +16703,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: 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..0ca9fb79 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']); @@ -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, camLockZoomMul };`, ); assert.notEqual(instrumented, src, 'test export injection anchor not found in screen.js'); @@ -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'); @@ -176,3 +176,62 @@ 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; + // 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) - 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 - TOLERANCE, 'must not reach 0.55 * 0.7 = 0.385'); + // and both at the far end + 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); +}); + +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'); +});