fix(highway_3d): dolly back so the fret-number row can't clip off the bottom (#633)

The heat-coloured fret-number row is drawn as a band BELOW the board
(sY(lowest) - S_GAP*1.4), but camUpdate's self-correcting framing only
anchors the board CENTRE to the lower third of the screen and reserves no
headroom for that row. So a tight zoom on a centred active span (worst
mid-neck; fine at either end of the neck) pushes the numbers past the
bottom edge -- which is why testers saw it "only when centered" and "not
every song." Tilt can't fix it (it would only trade a bottom clip for a
top clip); the vertical-extent problem at tight zoom needs camera distance.

Add a fret-row fit guard: project the row band with the final camera and,
when it falls below FRET_ROW_FIT_NDC_MIN, raise a capped, hysteretic
_fretRowFitBoost applied to the curDist lerp target (the span-driven
tgtDist still owns zooming IN). The boost rises promptly (proportional to
the deficit), relaxes lazily past a deadband, and is capped at
FRET_ROW_FIT_BOOST_MAX (+60%) so the zoom can't pop or hunt. It cooperates
with the tilt loop (pull-back shrinks the scene, tilt keeps the centre
anchored) and yields entirely to the Camera Director free-cam. Surgical:
passages where the row is already visible never trigger it, so framing is
unchanged everywhere it already worked.

plugin.json 3.30.0 -> 3.30.2 (screen.js cache-buster; 3.30.1 is taken by the
FPS-counter PR). Tests: tests/js/highway_3d_camera_framing.test.js
(guard constants, the boosted curDist lerp, the projected-row hysteresis,
free-cam yield).

Fixes #632


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-06-29 15:31:24 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 199550e5fb
commit 3120ae3e71
4 changed files with 106 additions and 3 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.30.1",
"version": "3.30.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+51 -2
View File
@@ -1095,6 +1095,16 @@
const CAM_FRAME_H_FAR = 1.00;
const CAM_FRAME_D_NEAR = 0.575;
const CAM_FRAME_D_FAR = 0.60;
// Fret-row fit guard. The heat-coloured fret-number row is a band drawn
// BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing
// anchors the board CENTRE, not that row, so a tight zoom on a centred span
// (worst mid-neck — fine pushed to either end of the neck) drops the row off
// the bottom edge. Tilt can't add vertical room there (it would only trade a
// bottom clip for a top clip), so camUpdate dollies the camera back just
// enough to bring the row back into frame — auto-sized, capped, hysteretic.
const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen)
const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt)
const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%)
// Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is
// far too coarse for picking where the camera should sit — a single
@@ -4091,6 +4101,11 @@
let tgtX = xFretMid(CAM_LOCK_CENTER_FRET), curX = xFretMid(CAM_LOCK_CENTER_FRET);
let tgtDist = CAM_DIST_BASE, curDist = CAM_DIST_BASE;
// Dolly-back multiplier applied to the curDist lerp target by camUpdate's
// fret-row fit guard. 1 = no extra pull-back (the common case); rises
// toward FRET_ROW_FIT_BOOST_MAX only when a tight, centred zoom would push
// the fret-number row past the bottom edge, then relaxes back to 1.
let _fretRowFitBoost = 1;
// Last committed lowFretBonus contribution baked into tgtDist
// (see candidateDist block — bonus is applied on top of the
// hysteresis-gated base).
@@ -13906,7 +13921,9 @@
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
curX += (tgtX - curX) * lerp;
curDist += (tgtDist - curDist) * 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);
@@ -13978,6 +13995,38 @@
} else {
cam.lookAt(curX, curLookY, _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);
}
}
}
/* ── Resize helper ───────────────────────────────────────────────── */
@@ -14234,7 +14283,7 @@
pFretColMarker = null;
_fretMarkerWaveCache.clear();
gNote = gSus = gBeat = gTapChevron = null;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; nStr = NSTR; _oobStringWarned = false;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; _fretRowFitBoost = 1; nStr = NSTR; _oobStringWarned = false;
_lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
_lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
_lookaheadCamPrevNow = null;