From 7fa2bfe52d72d39aaebfde5021440f671dafcc87 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 26 Aug 2026 16:45:05 +0300 Subject: [PATCH] fix(simulation): reset camera input on layer switch --- .../simulation/PlayCanvasRuntime.ts | 45 +++++++++++++++++++ .../test/simulationWorkspace.test.mjs | 3 ++ 2 files changed, 48 insertions(+) diff --git a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts index d9629d8..e18c7ac 100644 --- a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts +++ b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts @@ -78,10 +78,21 @@ export interface SimulationRuntime { type DesktopCameraInput = { read(): { mouse: number[] } & Record; + _pointerId?: number; + _button?: number[]; + _keyNow?: number[]; + _keyPrev?: number[]; }; type CameraController = ScriptType & Pick & { _desktopInput?: DesktopCameraInput; + _state?: { + axis: Vec3; + shift: number; + ctrl: number; + mouse: number[]; + touches: number; + }; }; const HOME_POSITION = new Vec3(0, 1, 0); @@ -243,6 +254,7 @@ export class PlayCanvasRuntime implements SimulationRuntime { } async setViewMode(mode: SimulationViewMode): Promise { + this.resetCameraInteraction(); this.viewMode = mode; this.applyViewMode(); try { @@ -277,6 +289,7 @@ export class PlayCanvasRuntime implements SimulationRuntime { } home(): void { + this.resetCameraInteraction(); if (this.cameraController) { this.cameraController.reset(HOME_FOCUS, HOME_POSITION); } else if (this.camera) { @@ -287,6 +300,7 @@ export class PlayCanvasRuntime implements SimulationRuntime { } focusBounds(): void { + this.resetCameraInteraction(); const bounds = this.visualEntity?.gsplat?.customAabb; const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0); const radius = bounds?.halfExtents.length() ?? 3; @@ -356,6 +370,37 @@ export class PlayCanvasRuntime implements SimulationRuntime { } } + private resetCameraInteraction(): void { + const controller = this.cameraController; + const canvas = this.canvas; + if (!controller || !canvas) return; + const input = controller._desktopInput; + const pointerId = input?._pointerId ?? -1; + if (pointerId >= 0 && canvas.hasPointerCapture(pointerId)) { + try { + canvas.releasePointerCapture(pointerId); + } catch { + // The browser may already have released capture after a trackpad gesture. + } + } + if (input) { + input._pointerId = -1; + input._button?.fill(0); + input._keyNow?.fill(0); + input._keyPrev?.fill(0); + input.read(); + } + controller.fire("state"); + const state = controller._state; + if (state) { + state.axis.set(0, 0, 0); + state.shift = 0; + state.ctrl = 0; + state.mouse.fill(0); + state.touches = 0; + } + } + private applyQuality(): void { const gsplat = this.visualEntity?.gsplat; const profile = QUALITY_PROFILES[this.quality]; diff --git a/apps/control-station/test/simulationWorkspace.test.mjs b/apps/control-station/test/simulationWorkspace.test.mjs index 0c3c13a..06c67a1 100644 --- a/apps/control-station/test/simulationWorkspace.test.mjs +++ b/apps/control-station/test/simulationWorkspace.test.mjs @@ -76,6 +76,9 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity assert.match(runtime, /selected\.horizontal[^\n]+frame\.mouse\[0\] \*= -1/); assert.match(runtime, /selected\.vertical[^\n]+frame\.mouse\[1\] \*= -1/); assert.match(runtime, /setCameraInversion/); + assert.match(runtime, /async setViewMode\([\s\S]*this\.resetCameraInteraction\(\);[\s\S]*this\.viewMode = mode/); + assert.match(runtime, /input\._pointerId = -1/); + assert.match(runtime, /state\.mouse\.fill\(0\)/); assert.match(runtime, /new Asset\([\s\S]*"container"/); assert.match(runtime, /instantiateRenderEntity/); assert.match(runtime, /applyWorldTransform/);