From c1d4b561043553d258281b5b8b06cb8669c40e26 Mon Sep 17 00:00:00 2001 From: saleemk Date: Thu, 23 Jul 2026 11:31:14 +0200 Subject: [PATCH] feat(highway_3d): extend the camera bridge for touch controls Add projection zoom, normalized view offsets, and focal board anchoring to the existing opt-in camera bridge. This enables natural touch pinch and pan behavior while preserving stock framing when the bridge is inactive. Signed-off-by: saleemk --- plugins/highway_3d/FREECAM_BRIDGE.md | 89 +++++-- plugins/highway_3d/screen.js | 334 ++++++++++++++++++++++++++- tests/js/highway_3d_wide_fov.test.js | 201 +++++++++++++++- 3 files changed, 583 insertions(+), 41 deletions(-) diff --git a/plugins/highway_3d/FREECAM_BRIDGE.md b/plugins/highway_3d/FREECAM_BRIDGE.md index 954825fb..bd4fb941 100644 --- a/plugins/highway_3d/FREECAM_BRIDGE.md +++ b/plugins/highway_3d/FREECAM_BRIDGE.md @@ -8,7 +8,7 @@ This change adds a small, **opt-in** hook inside `camUpdate()` in [`screen.js`](./screen.js) that lets an external plugin drive the 3D Highway camera (orbit, height, zoom, tilt, pan) **without forking the renderer**. -The renderer reads a single shared object once per frame: +The renderer resolves one bridge object per renderer frame: ```js window.__h3dCamCtl = { @@ -17,22 +17,46 @@ window.__h3dCamCtl = { distMul, // dolly / zoom multiplier yaw, // orbit around the look target (radians) pitch, // tilt offset (highway K-units) - panX, panY // look-target pan (highway K-units) + panX, panY, // look-target pan (highway K-units) + projectionZoom, + viewOffsetX, viewOffsetY, + boardAnchor, + boardAnchorReadout }; ``` **Safety / backward compatibility** - The bridge object is read **once** (`_freeCam`) and reused for both the position and the look-at transforms. -- Every field is coerced with `Number.isFinite` to a safe default +- Each original pose field is validated with `Number.isFinite` and falls back to a safe default (`heightMul`/`distMul → 1`, everything else → `0`) before use, so a malformed bridge object can **never** feed `NaN` into `cam.position.set` / `cam.lookAt`. -- When `window.__h3dCamCtl` is absent or `enabled === false`, behaviour is - **byte-for-byte identical** to before: the `if` is skipped and `lookAt` uses - the existing `else` path. +- When the resolved bridge is absent or its `enabled` value is falsy, bridge + controls are inactive and stock framing remains in control. The shared `-FOCUS_D * 0.35` look-at Z is computed once (`_lookAtZ`) and reused. +### Optional projection fields + +`projectionZoom` is a positive `PerspectiveCamera.zoom` value (`1` is +neutral). `viewOffsetX/Y` are normalized canvas fractions (`0` is neutral); +positive X moves content left and positive Y moves it up. + +`boardAnchor` has `enabled`, a finite `requestId`, desired CSS viewport +`clientX/Y`, and a `capture` object containing its own `clientX/Y`, +`projectionZoom`, and `viewOffsetX/Y`. It retains that point on the highway +board plane until the request ID, canvas size, or bridge object changes. Invalid +or unavailable captures fail inactive. + +`boardAnchorReadout` is optional caller-owned storage with `active`, +`requestId`, and normalized `viewOffsetDeltaX/Y`. Add active deltas to the +base offsets before releasing the anchor to avoid a snap. The renderer clears +the retained owner's readout when the anchor becomes inactive or ownership +changes. + +A valid per-panel `window.__h3dCamCtlPanels[panelIndex]` entry takes precedence +over the global bridge. Do not share anchor or readout objects between panels. + ## The plugin that uses this bridge **Camera Director** — a floating, bilingual (EN/ES) control panel to author, @@ -40,9 +64,11 @@ save and share highway camera views: ➡️ **https://github.com/nimuart/cameradirector_feedback** -Camera Director creates and writes `window.__h3dCamCtl`; this renderer only -reads it. That one-object contract is the entire integration surface — no other -globals, no patching of the renderer's internals. +Camera Director creates and writes `window.__h3dCamCtl`. The renderer reads that +bridge or a panel-specific `window.__h3dCamCtlPanels[panelIndex]` entry, and +writes only to an optional caller-provided `boardAnchorReadout`. Those bridge +objects are the entire integration surface; the renderer's internals are not +patched. --- @@ -53,7 +79,7 @@ Este cambio agrega un hook pequeño y **opcional** dentro de `camUpdate()` en 3D Highway (órbita, altura, zoom, inclinación, paneo) **sin tener que forkear el renderer**. -El renderer lee un único objeto compartido una vez por frame: +El renderer resuelve un objeto del puente por cada frame: ```js window.__h3dCamCtl = { @@ -62,18 +88,43 @@ window.__h3dCamCtl = { distMul, // multiplicador de dolly / zoom yaw, // órbita alrededor del objetivo (radianes) pitch, // inclinación (unidades K del highway) - panX, panY // paneo del objetivo (unidades K del highway) + panX, panY, // paneo del objetivo (unidades K del highway) + projectionZoom, + viewOffsetX, viewOffsetY, + boardAnchor, + boardAnchorReadout }; ``` **Seguridad / compatibilidad** - El objeto se lee **una sola vez** (`_freeCam`) y se reutiliza para la posición y para el look-at. -- Cada campo se valida con `Number.isFinite` y cae a un default seguro - (`heightMul`/`distMul → 1`, el resto → `0`), así un objeto mal formado **nunca** - mete `NaN` en `cam.position.set` / `cam.lookAt`. -- Si `window.__h3dCamCtl` no existe o `enabled === false`, el comportamiento es - **idéntico** al de antes. +- Cada campo original de pose se valida con `Number.isFinite` y cae a un default + seguro (`heightMul`/`distMul → 1`, el resto → `0`), así un objeto mal formado + **nunca** mete `NaN` en `cam.position.set` / `cam.lookAt`. +- Si el puente resuelto no existe o su valor `enabled` es falsy, los controles + del puente quedan inactivos y el encuadre original mantiene el control. + +### Campos opcionales de proyección + +`projectionZoom` es un valor positivo de `PerspectiveCamera.zoom` (`1` es +neutro). `viewOffsetX/Y` son fracciones normalizadas del canvas (`0` es +neutro); X positivo mueve el contenido a la izquierda e Y positivo hacia arriba. + +`boardAnchor` contiene `enabled`, un `requestId` finito, `clientX/Y` +deseados en coordenadas CSS del viewport y un objeto `capture` con sus propios +`clientX/Y`, `projectionZoom` y `viewOffsetX/Y`. Retiene ese punto del plano +del tablero hasta que cambia la solicitud, el tamaño del canvas o el objeto del +puente. Las capturas inválidas o no disponibles quedan inactivas. + +`boardAnchorReadout` es almacenamiento opcional del caller con `active`, +`requestId` y `viewOffsetDeltaX/Y` normalizados. Suma los deltas activos a +los offsets base antes de soltar el ancla para evitar un salto. El renderer +limpia el readout del dueño retenido cuando el ancla queda inactiva o cambia el +dueño. + +Una entrada válida en `window.__h3dCamCtlPanels[panelIndex]` tiene prioridad +sobre el puente global. No compartas objetos de ancla o readout entre paneles. ## El plugin que usa este puente @@ -82,5 +133,7 @@ compartir vistas de cámara del highway: ➡️ **https://github.com/nimuart/cameradirector_feedback** -Camera Director crea y escribe `window.__h3dCamCtl`; este renderer solo lo lee. -Ese contrato de un solo objeto es toda la superficie de integración. +Camera Director crea y escribe `window.__h3dCamCtl`. El renderer lee ese puente +o una entrada específica de panel en `window.__h3dCamCtlPanels[panelIndex]`, y +solo escribe en un `boardAnchorReadout` opcional proporcionado por el caller. +Esos objetos del puente son toda la superficie de integración. diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 44b6775b..dfae00d3 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -5418,6 +5418,12 @@ let _camPreScanned = false; let _camBootstrapHolding = false; let _camBootstrapMode = null; + let _freeCamBoardAnchorState = null; + let _freeCamBoardAnchorNear = null; + let _freeCamBoardAnchorFar = null; + let _freeCamBoardAnchorProject = null; + const _freeCamBoardAnchorOffset = { active: true, requestId: 0, x: 0, y: 0 }; + const _FREE_CAM_NO_BOARD_ANCHOR_OFFSET = Object.freeze({ active: false, requestId: 0, x: 0, y: 0 }); let _songKey = null; // Smooth lookahead camera: fused world-X and displayed fret-span. let _lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET); @@ -11663,6 +11669,7 @@ _camPreScanned = false; _camBootstrapHolding = false; _camBootstrapMode = null; + _resetFreeCamViewOffsetState(false); tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; prevLowFretBonus = 0; @@ -15636,10 +15643,16 @@ && !(_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(); - } + // Resolve the Camera Director bridge once (per-panel under splitscreen, + // else global). Used for projection zoom, the wide-pane gate, and transforms below. + const _freeCam = _freeCamFor(highwayCanvas); + const _projectionZoom = _freeCamProjectionZoom(_freeCam); + _applyFreeCamProjection(_vfov, _projectionZoom); + const _freeCamUsesViewOffset = _freeCamShouldApplyViewOffset(_freeCam); + if (!_freeCamUsesViewOffset) { + _resetFreeCamViewOffsetState(false); + _publishFreeCamBoardAnchorReadout(_freeCam, null, 1, 1); + } // 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. @@ -15654,9 +15667,6 @@ // 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; @@ -15718,6 +15728,7 @@ cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project() cam.updateMatrixWorld(); _probe.project(cam); // _probe.y → NDC in [-1, 1] + const _tiltProbeY = _freeCamNeutralProjectionNdcY(_probe.y, _projectionZoom); // Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35). // The deadband width and correction strength are both blended @@ -15727,10 +15738,10 @@ 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) { + if (_tiltProbeY < DESIRED_NDC_Y - tiltBand || _tiltProbeY > 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; + const correction = (DESIRED_NDC_Y - _tiltProbeY) * fretMidY * tiltStr; tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction)); } curLookY += (tgtLookY - curLookY) * lerp; @@ -15738,14 +15749,18 @@ // 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. + let _lookX = curX, _lookY = curLookY + _poseLookYAdd, _lookZ = _lookAtZ; 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); + _lookX = curX + _panX * K; + _lookY = curLookY + (_pitch + _panY) * K; } + cam.lookAt(_lookX, _lookY, _lookZ); + if (_freeCamUsesViewOffset) { + _applyFreeCamViewOffset(_freeCam, _projectionZoom); + } // ── Fret-row fit guard ──────────────────────────────────────────── // Project the fret-number-row band (just below the lowest string, at @@ -15780,6 +15795,299 @@ } } + function _freeCamProjectionZoom(freeCam) { + if (!freeCam || !freeCam.enabled) return 1; + return _coerceFreeCamProjectionZoom(freeCam.projectionZoom); + } + + function _coerceFreeCamProjectionZoom(value) { + return Number.isFinite(value) && value > 0 ? value : 1; + } + + function _freeCamNeutralProjectionNdcY(ndcY, projectionZoom) { + if (!Number.isFinite(ndcY)) return ndcY; + const zoom = _coerceFreeCamProjectionZoom(projectionZoom); + const view = cam && cam.view && cam.view.enabled ? cam.view : null; + const viewOffsetY = view && view.fullHeight > 0 && Number.isFinite(view.offsetY) + ? (2 * view.offsetY) / view.fullHeight + : 0; + return (ndcY - viewOffsetY) / zoom; + } + + function _applyFreeCamProjection(vfov, projectionZoom) { + if (!cam) return; + let changed = false; + const desiredZoom = _coerceFreeCamProjectionZoom(projectionZoom); + if (Number.isFinite(vfov) && Math.abs(vfov - cam.fov) > 1e-4) { + cam.fov = vfov; + changed = true; + } + if (Math.abs(desiredZoom - (Number.isFinite(cam.zoom) ? cam.zoom : 1)) > 1e-4) { + cam.zoom = desiredZoom; + changed = true; + } + if (changed) cam.updateProjectionMatrix(); + } + + function _clearFreeCamViewOffset() { + if (cam && cam.view && cam.view.enabled === true && + typeof cam.clearViewOffset === 'function') { + cam.clearViewOffset(); + } + } + + function _resetFreeCamViewOffsetState(dropScratch) { + _clearFreeCamViewOffset(); + _clearFreeCamBoardAnchorState(); + if (dropScratch) { + _freeCamBoardAnchorNear = null; + _freeCamBoardAnchorFar = null; + _freeCamBoardAnchorProject = null; + } + } + + function _clearFreeCamBoardAnchorState() { + const retainedBridge = _freeCamBoardAnchorState && _freeCamBoardAnchorState.bridge; + _freeCamBoardAnchorState = null; + _publishFreeCamBoardAnchorReadout(retainedBridge, null, 1, 1); + } + + function _resetFreeCamProjectionState() { + if (!cam) return; + let changed = false; + if (cam.fov !== BASE_VFOV) { + cam.fov = BASE_VFOV; + changed = true; + } + if (cam.zoom !== 1) { + cam.zoom = 1; + changed = true; + } + if (changed) cam.updateProjectionMatrix(); + } + + function _freeCamShouldApplyViewOffset(freeCam) { + if (!freeCam || !freeCam.enabled) return false; + if (Number.isFinite(freeCam.viewOffsetX) && freeCam.viewOffsetX !== 0) return true; + if (Number.isFinite(freeCam.viewOffsetY) && freeCam.viewOffsetY !== 0) return true; + if (freeCam.boardAnchor && freeCam.boardAnchor.enabled === true) return true; + return !!(freeCam.boardAnchorReadout && freeCam.boardAnchorReadout.active === true); + } + + function _applyFreeCamViewOffset(freeCam, projectionZoom) { + if (!cam || typeof cam.setViewOffset !== 'function') { + _clearFreeCamBoardAnchorState(); + _publishFreeCamBoardAnchorReadout(freeCam, null, 1, 1); + return; + } + + const canvas = ren && ren.domElement ? ren.domElement : highwayCanvas; + const viewW = Math.max(1, Math.round(canvas && canvas.width ? canvas.width : 0)); + const viewH = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 0)); + const baseX = _freeCamViewOffsetToPixels( + Number.isFinite(freeCam.viewOffsetX) ? freeCam.viewOffsetX : 0, viewW); + const baseY = _freeCamViewOffsetToPixels( + Number.isFinite(freeCam.viewOffsetY) ? freeCam.viewOffsetY : 0, viewH); + const anchor = _getFreeCamBoardAnchorOffset( + freeCam, canvas, viewW, viewH, baseX, baseY, projectionZoom); + + _setFreeCamViewOffsetPixels(viewW, viewH, baseX + anchor.x, baseY + anchor.y); + _publishFreeCamBoardAnchorReadout(freeCam, anchor, viewW, viewH); + } + + function _freeCamViewOffsetToPixels(value, size) { + return Number.isFinite(value) && Number.isFinite(size) && size > 0 ? value * size : 0; + } + + function _freeCamPixelsToViewOffset(value, size) { + return Number.isFinite(value) && Number.isFinite(size) && size > 0 ? value / size : 0; + } + + function _setFreeCamViewOffsetPixels(viewW, viewH, offsetX, offsetY) { + if (!cam || typeof cam.setViewOffset !== 'function') return false; + const width = Math.max(1, Math.round(Number(viewW) || 0)); + const height = Math.max(1, Math.round(Number(viewH) || 0)); + const x = Number.isFinite(offsetX) ? offsetX : 0; + const y = Number.isFinite(offsetY) ? offsetY : 0; + if (Math.abs(x) < 1e-6 && Math.abs(y) < 1e-6) { + _clearFreeCamViewOffset(); + return true; + } + + const view = cam.view; + if (view && view.enabled === true && + view.fullWidth === width && view.fullHeight === height && + view.width === width && view.height === height && + Math.abs(view.offsetX - x) < 1e-4 && + Math.abs(view.offsetY - y) < 1e-4) return true; + + cam.setViewOffset(width, height, x, y, width, height); + return true; + } + + function _getFreeCamBoardAnchorOffset( + freeCam, canvas, viewW, viewH, baseX, baseY, projectionZoom + ) { + const request = _getFreeCamBoardAnchorRequest(freeCam); + if (!request || !canvas || typeof canvas.getBoundingClientRect !== 'function') { + _clearFreeCamBoardAnchorState(); + return _FREE_CAM_NO_BOARD_ANCHOR_OFFSET; + } + + const requestId = request.requestId; + let retained = _freeCamBoardAnchorState; + if (retained && retained.bridge !== freeCam) { + _clearFreeCamBoardAnchorState(); + retained = null; + } + const recapture = !retained || retained.requestId !== requestId || + retained.viewW !== viewW || retained.viewH !== viewH; + if (recapture) { + const rawRect = canvas.getBoundingClientRect(); + const rect = { + left: Number(rawRect && rawRect.left), + top: Number(rawRect && rawRect.top), + width: Number(rawRect && rawRect.width), + height: Number(rawRect && rawRect.height), + }; + if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top) || + !Number.isFinite(rect.width) || rect.width <= 0 || + !Number.isFinite(rect.height) || rect.height <= 0) { + _clearFreeCamBoardAnchorState(); + return _FREE_CAM_NO_BOARD_ANCHOR_OFFSET; + } + + const capture = request.capture; + const captureX = Number.isFinite(capture.viewOffsetX) + ? _freeCamViewOffsetToPixels(capture.viewOffsetX, viewW) : baseX; + const captureY = Number.isFinite(capture.viewOffsetY) + ? _freeCamViewOffsetToPixels(capture.viewOffsetY, viewH) : baseY; + const savedZoom = Number.isFinite(cam.zoom) ? cam.zoom : 1; + const captureZoom = Number.isFinite(capture.projectionZoom) && + capture.projectionZoom > 0 ? capture.projectionZoom : projectionZoom; + try { + _setFreeCamViewOffsetPixels(viewW, viewH, captureX, captureY); + if (Math.abs(captureZoom - savedZoom) >= 1e-6) { + cam.zoom = captureZoom; + cam.updateProjectionMatrix(); + } + const point = _captureFreeCamBoardAnchorWorldPoint(capture, rect); + if (point) { + point.bridge = freeCam; + point.requestId = requestId; + point.rect = rect; + point.viewW = viewW; + point.viewH = viewH; + _freeCamBoardAnchorState = point; + } else { + _freeCamBoardAnchorState = { + bridge: freeCam, requestId, unavailable: true, rect, viewW, viewH + }; + } + } finally { + if (Math.abs(savedZoom - (Number.isFinite(cam.zoom) ? cam.zoom : 1)) >= 1e-6) { + cam.zoom = savedZoom; + cam.updateProjectionMatrix(); + } + _setFreeCamViewOffsetPixels(viewW, viewH, baseX, baseY); + } + } else { + _setFreeCamViewOffsetPixels(viewW, viewH, baseX, baseY); + } + + const state = _freeCamBoardAnchorState; + if (!state || state.unavailable) return _FREE_CAM_NO_BOARD_ANCHOR_OFFSET; + const point = _freeCamBoardAnchorProject || + (_freeCamBoardAnchorProject = new T.Vector3()); + point.set(state.x, state.y, state.z); + cam.updateMatrixWorld(); + point.project(cam); + if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || + !Number.isFinite(point.z)) return _FREE_CAM_NO_BOARD_ANCHOR_OFFSET; + + const rect = state.rect; + const clientX = rect.left + (point.x * 0.5 + 0.5) * rect.width; + const clientY = rect.top + (-point.y * 0.5 + 0.5) * rect.height; + _freeCamBoardAnchorOffset.active = true; + _freeCamBoardAnchorOffset.requestId = requestId; + _freeCamBoardAnchorOffset.x = + (clientX - request.clientX) * (viewW / rect.width); + _freeCamBoardAnchorOffset.y = + (clientY - request.clientY) * (viewH / rect.height); + return _freeCamBoardAnchorOffset; + } + + function _getFreeCamBoardAnchorRequest(freeCam) { + const anchor = freeCam && freeCam.enabled ? freeCam.boardAnchor : null; + const capture = anchor && anchor.capture; + if (!anchor || anchor.enabled !== true || + !Number.isFinite(anchor.requestId) || + !Number.isFinite(anchor.clientX) || + !Number.isFinite(anchor.clientY) || + !capture || typeof capture !== 'object' || + !Number.isFinite(capture.clientX) || + !Number.isFinite(capture.clientY)) { + _clearFreeCamBoardAnchorState(); + return null; + } + return anchor; + } + + function _captureFreeCamBoardAnchorWorldPoint(capture, rect) { + if (!cam || !T) return null; + const ndcX = ((capture.clientX - rect.left) / rect.width) * 2 - 1; + const ndcY = -(((capture.clientY - rect.top) / rect.height) * 2 - 1); + const near = _freeCamBoardAnchorNear || + (_freeCamBoardAnchorNear = new T.Vector3()); + const far = _freeCamBoardAnchorFar || + (_freeCamBoardAnchorFar = new T.Vector3()); + cam.updateMatrixWorld(); + near.set(ndcX, ndcY, -1).unproject(cam); + far.set(ndcX, ndcY, 1).unproject(cam); + + const y = S_BASE - NH / 2 - 2 * K; + const dy = far.y - near.y; + if (!Number.isFinite(dy) || Math.abs(dy) < 1e-6) return null; + const t = (y - near.y) / dy; + if (!Number.isFinite(t) || t < 0 || t > 1) return null; + + const span = boardSpanX(); + const x = near.x + (far.x - near.x) * t; + const z = near.z + (far.z - near.z) * t; + if (!Number.isFinite(x) || !Number.isFinite(z)) return null; + return { + x: Math.max(span.min - 2 * K, Math.min(span.max + 2 * K, x)), + y, + z: Math.max(-TS * AHEAD, Math.min(0, z)), + }; + } + + function _publishFreeCamBoardAnchorReadout(freeCam, offset, viewW, viewH) { + const readout = freeCam && typeof freeCam === 'object' && + freeCam.boardAnchorReadout && typeof freeCam.boardAnchorReadout === 'object' + ? freeCam.boardAnchorReadout : null; + if (!readout) return; + + const active = !!(offset && offset.active === true); + const requestId = active && Number.isFinite(offset.requestId) + ? offset.requestId : 0; + const deltaX = active ? _freeCamPixelsToViewOffset(offset.x, viewW) : 0; + const deltaY = active ? _freeCamPixelsToViewOffset(offset.y, viewH) : 0; + _writeFreeCamBoardAnchorReadout(readout, 'active', active); + _writeFreeCamBoardAnchorReadout(readout, 'requestId', requestId); + _writeFreeCamBoardAnchorReadout(readout, 'viewOffsetDeltaX', + Number.isFinite(deltaX) ? deltaX : 0); + _writeFreeCamBoardAnchorReadout(readout, 'viewOffsetDeltaY', + Number.isFinite(deltaY) ? deltaY : 0); + } + + function _writeFreeCamBoardAnchorReadout(readout, key, value) { + try { + if (!Object.is(readout[key], value)) readout[key] = value; + } catch (_) { + /* optional readout must never affect rendering */ + } + } /* ── Resize helper ───────────────────────────────────────────────── */ function applySize(w, h) { if (!ren || !cam || !wrap) return; @@ -15863,6 +16171,8 @@ } _onCtxLost = _onCtxRestored = null; _ctxLost = false; + _resetFreeCamViewOffsetState(true); + _resetFreeCamProjectionState(); // Notedetect listeners (issue #9). Remove on destroy so a // panel that stops doesn't keep accumulating marks. Marks // arrays are cleared too — they hold stale chart positions diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index d5688603..f6a5810f 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -23,6 +23,17 @@ const path = require('node:path'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const src = fs.readFileSync(SCREEN_JS, 'utf8'); +function sourceBetween(startText, endText) { + const start = src.indexOf(startText); + const end = src.indexOf(endText, start); + assert.ok(start >= 0 && end > start, 'missing source range'); + return src.slice(start, end); +} + +function standaloneFunction(startText, endText) { + return Function('return (' + sourceBetween(startText, endText).trim() + ')')(); +} + // ── Constants ──────────────────────────────────────────────────────────────── test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => { @@ -284,13 +295,31 @@ test('the panel has a dismiss (close) control', () => { }); test('camUpdate only writes cam.fov when it actually changes', () => { - // Guarding the write avoids a per-frame updateProjectionMatrix on a steady - // pane and keeps the disabled path free. - assert.match( - src, - /Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/, - 'camUpdate must guard the cam.fov write behind a change check', - ); + const camUpdate = sourceBetween('function camUpdate(bundle)', + ' function _freeCamProjectionZoom'); + assert.equal((camUpdate.match(/const\s+_freeCam\s*=\s*_freeCamFor\(highwayCanvas\)/g) || []).length, 1, + 'camUpdate must resolve the bridge once'); + assert.match(camUpdate, /_applyFreeCamProjection\(_vfov,\s*_projectionZoom\)/); + + const projection = sourceBetween('function _freeCamProjectionZoom', + ' function _clearFreeCamViewOffset'); + assert.match(projection, + /if\s*\(\s*!freeCam\s*\|\|\s*!freeCam\.enabled\s*\)\s*return\s+1/, + 'a disabled bridge must keep zoom neutral'); + const coerceZoom = standaloneFunction('function _coerceFreeCamProjectionZoom', + ' function _freeCamNeutralProjectionNdcY'); + assert.deepEqual( + [coerceZoom(2), coerceZoom('2'), coerceZoom(Symbol('bad'))], + [2, 1, 1], + 'projection zoom accepts only finite numeric values without coercion'); + assert.match(projection, + /Number\.isFinite\(vfov\)\s*&&\s*Math\.abs\(vfov\s*-\s*cam\.fov\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*vfov/, + 'the original finite and changed fov guards must remain'); + assert.match(projection, + /Math\.abs\(desiredZoom[\s\S]*?1e-4[\s\S]*?cam\.zoom\s*=\s*desiredZoom/, + 'the zoom write must be guarded'); + assert.match(projection, /if\s*\(\s*changed\s*\)\s*cam\.updateProjectionMatrix\(\)/, + 'fov and zoom must share one projection update'); }); // ── Shortcut (open/close) + lifecycle reset ─────────────────────────────────── @@ -308,9 +337,159 @@ test('the shortcut opens/closes the tuner panel', () => { test('destroy() resets the pane aspect and restores the base fov', () => { assert.match(src, /_paneAspect\s*=\s*0\s*;/, 'destroy() must reset _paneAspect to 0'); - assert.match( - src, - /cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/, - 'destroy() must restore cam.fov to BASE_VFOV for instance reuse', + assert.match(src, + /destroy\(\)\s*\{[\s\S]*?_unsubscribeFocus\(\);\s*teardown\(\);[\s\S]*?highwayCanvas\s*=\s*null/, + 'destroy() must delegate cleanup through teardown'); + + const teardown = sourceBetween('function teardown()', ' function canvasSize(canvas)'); + assert.match(teardown, + /_resetFreeCamViewOffsetState\(true\)[\s\S]*?_resetFreeCamProjectionState\(\)/); + const reset = sourceBetween('function _resetFreeCamProjectionState', + ' function _freeCamShouldApplyViewOffset'); + assert.match(reset, + /cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV/, + 'destroy() cleanup must strictly restore the base fov'); + assert.match(reset, + /cam\.zoom\s*!==\s*1[\s\S]*?cam\.zoom\s*=\s*1/, + 'destroy() cleanup must strictly restore neutral zoom'); + assert.match(reset, /if\s*\(\s*changed\s*\)\s*cam\.updateProjectionMatrix\(\)/, + 'fov and zoom cleanup must share one projection update'); +}); + +test('free-camera view offsets are normalized and cached', () => { + const toPixels = standaloneFunction('function _freeCamViewOffsetToPixels', + ' function _freeCamPixelsToViewOffset'); + const toOffset = standaloneFunction('function _freeCamPixelsToViewOffset', + ' function _setFreeCamViewOffsetPixels'); + assert.deepEqual( + [toPixels(0.25, 800), toPixels(-0.1, 600), toOffset(200, 800), toOffset(-60, 600)], + [200, -60, 0.25, -0.1], ); + const view = sourceBetween('function _setFreeCamViewOffsetPixels', + ' function _getFreeCamBoardAnchorOffset'); + assert.match(view, + /const\s+view\s*=\s*cam\.view[\s\S]*?view\.offsetX[\s\S]*?view\.offsetY[\s\S]*?return\s+true[\s\S]*?cam\.setViewOffset\(/, + 'steady offsets must skip setViewOffset'); +}); + +test('free-camera projection controls do not feed adaptive tilt', () => { + const update = sourceBetween('function camUpdate(bundle)', + ' function _freeCamProjectionZoom'); + assert.match(update, + /_probe\.project\(cam\)[\s\S]*?_freeCamNeutralProjectionNdcY\(_probe\.y,\s*_projectionZoom\)[\s\S]*?if\s*\(\s*_tiltProbeY\s*<\s*DESIRED_NDC_Y/); + const neutral = sourceBetween('function _freeCamNeutralProjectionNdcY', + ' function _applyFreeCamProjection'); + assert.match(neutral, /return\s+\(ndcY\s*-\s*viewOffsetY\)\s*\/\s*zoom/, + 'tilt must remove projection zoom and vertical view offset'); +}); + +test('board anchor validates requests and captures safely', () => { + const request = sourceBetween('function _getFreeCamBoardAnchorRequest', + ' function _captureFreeCamBoardAnchorWorldPoint'); + assert.match(request, /const\s+capture\s*=\s*anchor\s*&&\s*anchor\.capture[\s\S]*?capture\.clientX[\s\S]*?capture\.clientY/); + assert.match(request, + /!Number\.isFinite\(anchor\.requestId\)[\s\S]*?_clearFreeCamBoardAnchorState\(\)[\s\S]*?return\s+null/, + 'malformed requests must fail safely'); + + let clearCount = 0; + const getRequest = Function('_clearFreeCamBoardAnchorState', + 'return (' + request.trim() + ')')(() => { clearCount += 1; }); + const validAnchor = { + enabled: true, + requestId: 7, + clientX: 80, + clientY: 60, + capture: { clientX: 40, clientY: 30 }, + }; + assert.equal(getRequest({ enabled: 1, boardAnchor: validAnchor }), validAnchor, + 'truthy bridge enablement must accept a valid numeric request'); + assert.equal(getRequest({ enabled: false, boardAnchor: validAnchor }), null); + assert.equal(getRequest({ enabled: true, boardAnchor: { + ...validAnchor, + clientX: '80', + } }), null, 'numeric strings must not be coerced'); + assert.doesNotThrow(() => getRequest({ enabled: true, boardAnchor: { + ...validAnchor, + requestId: Symbol('request'), + } })); + assert.equal(clearCount, 3, 'disabled and malformed requests must clear state'); + + const offset = sourceBetween('function _getFreeCamBoardAnchorOffset', + ' function _getFreeCamBoardAnchorRequest'); + assert.match(offset, + /canvas\.getBoundingClientRect\(\)[\s\S]*?_captureFreeCamBoardAnchorWorldPoint\(capture,\s*rect\)/); + assert.match(offset, + /unavailable:\s*true[\s\S]*?finally\s*\{[\s\S]*?cam\.zoom\s*=\s*savedZoom[\s\S]*?_setFreeCamViewOffsetPixels\(viewW,\s*viewH,\s*baseX,\s*baseY\)/, + 'unavailable captures must stay safe and projection state must be restored'); +}); + +test('board anchor publishes the public correction contract', () => { + const publish = sourceBetween('function _publishFreeCamBoardAnchorReadout', + ' function _writeFreeCamBoardAnchorReadout'); + assert.match(publish, + /freeCam\.boardAnchorReadout[\s\S]*?_freeCamPixelsToViewOffset\(offset\.x,\s*viewW\)[\s\S]*?_freeCamPixelsToViewOffset\(offset\.y,\s*viewH\)/, + 'readout corrections must use caller-owned normalized offsets'); + + const toOffset = standaloneFunction('function _freeCamPixelsToViewOffset', + ' function _setFreeCamViewOffsetPixels'); + const write = standaloneFunction('function _writeFreeCamBoardAnchorReadout', + ' /* ── Resize helper'); + const publishReadout = Function('_freeCamPixelsToViewOffset', + '_writeFreeCamBoardAnchorReadout', + 'return (' + publish.trim() + ')')(toOffset, write); + const readout = {}; + publishReadout({ boardAnchorReadout: readout }, { + active: true, + requestId: 7, + x: 80, + y: -60, + }, 800, 600); + assert.deepEqual(readout, { + active: true, + requestId: 7, + viewOffsetDeltaX: 0.1, + viewOffsetDeltaY: -0.1, + }); + publishReadout({ boardAnchorReadout: readout }, null, 800, 600); + assert.deepEqual(readout, { + active: false, + requestId: 0, + viewOffsetDeltaX: 0, + viewOffsetDeltaY: 0, + }); + assert.doesNotThrow(() => publishReadout({ + boardAnchorReadout: Object.freeze({}), + }, { active: true, requestId: 8, x: 1, y: 1 }, 1, 1)); +}); + +test('board anchor recaptures for owner request or canvas changes', () => { + const ownership = sourceBetween('function _getFreeCamBoardAnchorOffset', + ' function _getFreeCamBoardAnchorRequest'); + assert.match(ownership, + /retained\.bridge\s*!==\s*freeCam[\s\S]*?_clearFreeCamBoardAnchorState\(\)[\s\S]*?retained\s*=\s*null/, + 'switching bridge objects must clear the previous owner'); + assert.match(ownership, + /!retained\s*\|\|\s*retained\.requestId\s*!==\s*requestId[\s\S]*?retained\.viewW\s*!==\s*viewW[\s\S]*?retained\.viewH\s*!==\s*viewH/); +}); + +test('board anchor steady work reuses scratch state and retained ownership', () => { + const offset = sourceBetween('function _getFreeCamBoardAnchorOffset', + ' function _getFreeCamBoardAnchorRequest'); + assert.match(offset, + /_freeCamBoardAnchorOffset\.active\s*=\s*true[\s\S]*?return\s+_freeCamBoardAnchorOffset/); + assert.doesNotMatch(offset, /return\s*\{\s*active:\s*true/); + assert.match(offset, + /_freeCamBoardAnchorProject[\s\S]*?point\.project\(cam\)/, + 'projection must reuse the renderer-owned vector'); + + const readout = sourceBetween('function _writeFreeCamBoardAnchorReadout', + ' /* ── Resize helper'); + assert.match(readout, + /if\s*\(\s*!Object\.is\(readout\[key\],\s*value\)\s*\)\s*readout\[key\]\s*=\s*value/); + const cleanup = sourceBetween('function _clearFreeCamBoardAnchorState', + ' function _resetFreeCamProjectionState'); + assert.match(cleanup, + /_publishFreeCamBoardAnchorReadout\(retainedBridge,\s*null,\s*1,\s*1\)/); + assert.doesNotMatch(cleanup, /window\.__h3dCamCtl|__h3dCamCtlPanels|_freeCamFor|document/, + 'cleanup must stay scoped to the retained owner'); });