From 92e9a653b2e7ba083d4984584a5712b881d1423c Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Sat, 5 Sep 2026 02:46:07 +0800 Subject: [PATCH 1/4] feat(studio): add mouse Y inversion control Co-authored-by: multica-agent --- .../plans/2026-09-05-invert-camera-mouse-y.md | 101 ++++++++++++++++++ e2e/timeline.spec.ts | 70 ++++++++++++ .../src/components/editor/EditorViewport.tsx | 8 +- .../src/components/editor/TimelinePanel.tsx | 12 +++ .../src/components/editor/camera-drive.ts | 20 +++- packages/studio/src/lumora.css | 17 +++ .../studio/test/camera-drive-routing.test.tsx | 29 +++++ packages/studio/test/camera-drive.test.ts | 52 +++++++++ packages/studio/test/timeline-panel.test.tsx | 18 +++- .../studio/test/use-timeline-session.test.tsx | 19 ++++ 10 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-09-05-invert-camera-mouse-y.md diff --git a/docs/plans/2026-09-05-invert-camera-mouse-y.md b/docs/plans/2026-09-05-invert-camera-mouse-y.md new file mode 100644 index 0000000..0b1da14 --- /dev/null +++ b/docs/plans/2026-09-05-invert-camera-mouse-y.md @@ -0,0 +1,101 @@ +# Invert Camera Mouse Y Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. + +**Goal:** Add a session-scoped, default-off mouse vertical inversion setting for keyboard-and-mouse camera control without affecting horizontal look or keyboard rotation. + +**Architecture:** Extend the existing normalized `CameraDriveSettings` object so the new boolean follows the same lifetime as speed, step, and sensitivity. Apply the sign when pointer Y input is queued, cancel queued look when the value changes, and make the viewport release an active right-button gesture at that boundary. Expose the value as an accessible checkbox in the existing camera-control strip. + +**Tech Stack:** React 19, TypeScript, Three.js, Vitest, Testing Library, Playwright. + +--- + +### Task 1: Core setting and input behavior + +**Files:** +- Modify: `packages/studio/test/camera-drive.test.ts` +- Modify: `packages/studio/src/components/editor/camera-drive.ts` + +**Step 1: Write the failing tests** + +Add tests that construct normal and inverted drives, send the same `look(40, 20)` input, and assert equal yaw with opposite pitch. Add a boundary test that queues pointer look, changes only `invertMouseY`, and verifies no queued rotation is applied while an already-held keyboard movement key remains active. + +**Step 2: Run tests to verify they fail** + +Run: `npm test -w @lumora/studio -- camera-drive.test.ts` + +Expected: FAIL because `CameraDriveSettings` has no `invertMouseY` field and inversion is not applied. + +**Step 3: Write the minimal implementation** + +Add `invertMouseY: boolean`, default it to `false`, normalize only explicit booleans, and queue vertical look as: + +```ts +const verticalDelta = this.settings.invertMouseY ? -deltaY : deltaY; +this.lookDelta.y = THREE.MathUtils.clamp( + this.lookDelta.y + verticalDelta, + -MAX_LOOK_DELTA, + MAX_LOOK_DELTA, +); +``` + +When `setSettings` changes inversion without changing mode, call `cancelLook()` rather than clearing keyboard input. + +**Step 4: Run tests to verify they pass** + +Run: `npm test -w @lumora/studio -- camera-drive.test.ts` + +Expected: PASS. + +### Task 2: Session state, UI, and gesture boundary + +**Files:** +- Modify: `packages/studio/test/use-timeline-session.test.tsx` +- Modify: `packages/studio/test/timeline-panel.test.tsx` +- Modify: `packages/studio/test/camera-drive-routing.test.tsx` +- Modify: `packages/studio/src/components/editor/TimelinePanel.tsx` +- Modify: `packages/studio/src/components/editor/EditorViewport.tsx` +- Modify: `packages/studio/src/lumora.css` + +**Step 1: Write the failing tests** + +Assert the session default is `false`, explicit `true` survives a project switch, explicit `false` is accepted, and a remounted session returns to the shared default. Assert the panel checkbox exposes its checked/unchecked state and calls `setCameraControlSettings({ invertMouseY: ... })`. Add a viewport routing test that changes inversion during an active right-button gesture and proves stale pointer movement is ignored until a fresh pointerdown. + +**Step 2: Run tests to verify they fail** + +Run: `npm test -w @lumora/studio -- use-timeline-session.test.tsx timeline-panel.test.tsx camera-drive-routing.test.tsx` + +Expected: FAIL because the checkbox and inversion gesture boundary do not exist. + +**Step 3: Write the minimal implementation** + +Render a checkbox labeled `垂直反转` and pass its checked value to the session setter. Keep it configurable in keyboard-only mode so users can prepare the setting before switching modes; the value only affects keyboard-mouse pointer look. In the viewport loop compare the previous and current `invertMouseY`; on change call `drive.cancelLook()` and `endLookGesture()` so the next drag requires a new pointerdown. Add compact checkbox sizing that participates in the existing responsive flex layout. + +**Step 4: Run tests to verify they pass** + +Run: `npm test -w @lumora/studio -- use-timeline-session.test.tsx timeline-panel.test.tsx camera-drive-routing.test.tsx` + +Expected: PASS. + +### Task 3: Browser regression and delivery verification + +**Files:** +- Modify: `e2e/timeline.spec.ts` + +**Step 1: Write the failing browser regression** + +Exercise normal drag, inverted drag, mid-gesture toggle, fresh drag, explicit disable, and page reload. Assert pitch direction reverses, heading/keyboard behavior remains unchanged, stale movement after the toggle does not mutate the camera, and reload restores the default-off state. + +**Step 2: Run the browser test to verify it fails** + +Run: `npx playwright test e2e/timeline.spec.ts --project=chromium --grep "vertical inversion"` + +Expected: FAIL because the new control is absent. + +**Step 3: Complete the regression and visual checks** + +Run focused tests, `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, and the focused Chromium Playwright test. Capture desktop and mobile screenshots and inspect the control for clear state, overflow, and overlap. + +**Step 4: Deliver through GitHub** + +Review the scoped diff, commit the implementation and tests, push the task branch, create one draft PR targeting `main` with `task_id: TML-813` and `run_id: 1443ad39-7c9e-4c7a-b3a2-1f8bc5959f24`, then read the PR title/body back for metadata verification. diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8684d24..7df5062 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -401,6 +401,76 @@ test('real right-drag drives only an unblocked POV and suppresses complete gestu expect(quaternionAngle(outOfViewportAfter.rotation, outOfViewportBefore.rotation)).toBeGreaterThan(0.001); }); +test('mouse vertical inversion reverses only pitch and resets an active gesture', async ({ page }) => { + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + await page.getByTestId('track-disabled-sample-track-camera-dolly').check(); + await page.getByTestId('track-disabled-sample-track-camera-focus').check(); + await expect(page.getByTestId('camera-control-status')).toHaveText('机位“主摄像机”可手动操控。'); + + const viewport = page.getByTestId('lumora-viewport'); + const bounds = await viewport.boundingBox(); + if (!bounds) throw new Error('viewport is unavailable'); + const startX = bounds.x + bounds.width * 0.5; + const startY = bounds.y + bounds.height * 0.45; + const invertMouseY = page.getByTestId('camera-control-invert-mouse-y'); + await expect(invertMouseY).not.toBeChecked(); + + const dragVertically = async () => { + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'right' }); + await page.mouse.move(startX, startY + 48, { steps: 6 }); + await page.mouse.up({ button: 'right' }); + return stableCameraPose(page); + }; + + const normalBefore = await stableCameraPose(page); + const normalAfter = await dragVertically(); + const normalPitch = normalAfter.rotation[0] - normalBefore.rotation[0]; + const normalYaw = normalAfter.rotation[1] - normalBefore.rotation[1]; + expect(Math.abs(normalPitch)).toBeGreaterThan(0.01); + expect(Math.abs(normalYaw)).toBeLessThan(0.005); + + await invertMouseY.check(); + await expect(invertMouseY).toBeChecked(); + const invertedBefore = await stableCameraPose(page); + const invertedAfter = await dragVertically(); + const invertedPitch = invertedAfter.rotation[0] - invertedBefore.rotation[0]; + const invertedYaw = invertedAfter.rotation[1] - invertedBefore.rotation[1]; + expect(Math.abs(invertedPitch)).toBeGreaterThan(0.01); + expect(invertedPitch * normalPitch).toBeLessThan(0); + expect(Math.abs(invertedYaw)).toBeLessThan(0.005); + + await invertMouseY.uncheck(); + const restoredBefore = await stableCameraPose(page); + const restoredAfter = await dragVertically(); + const restoredPitch = restoredAfter.rotation[0] - restoredBefore.rotation[0]; + expect(restoredPitch * normalPitch).toBeGreaterThan(0); + + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'right' }); + await page.mouse.move(startX, startY + 20); + const poseAtToggle = await stableCameraPose(page); + await invertMouseY.evaluate((input) => (input as HTMLInputElement).click()); + await expect(invertMouseY).toBeChecked(); + await page.waitForTimeout(300); + const afterBoundary = await cameraPose(page); + expect(quaternionAngle(afterBoundary.rotation, poseAtToggle.rotation)).toBeLessThan(0.001); + + await page.mouse.move(startX, startY + 70, { steps: 5 }); + await page.waitForTimeout(300); + const afterStaleMove = await cameraPose(page); + expect(quaternionAngle(afterStaleMove.rotation, afterBoundary.rotation)).toBeLessThan(0.001); + await page.mouse.up({ button: 'right' }); + + const freshAfter = await dragVertically(); + expect(quaternionAngle(freshAfter.rotation, afterStaleMove.rotation)).toBeGreaterThan(0.01); + + await page.reload(); + await page.getByTestId('open-sample-project').click(); + await expect(page.getByTestId('camera-control-invert-mouse-y')).not.toBeChecked(); +}); + test('suppresses an out-of-bounds release from an open ShadowRoot viewport', async ({ page }) => { await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('view-mode-select').selectOption('sample-camera'); diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 5919dba..9ea3e08 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -804,11 +804,15 @@ function useCameraDrive( } const st = sessionRef.current?.state; if (st) { - const previousMode = drive.getSettings().mode; + const previousSettings = drive.getSettings(); drive.setSettings(st.cameraControls); - if (drive.getSettings().mode !== previousMode) { + const nextSettings = drive.getSettings(); + if (nextSettings.mode !== previousSettings.mode) { heldKeys.clear(); endLookGesture(); + } else if (nextSettings.invertMouseY !== previousSettings.invertMouseY) { + drive.cancelLook(); + endLookGesture(); } } // 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道; diff --git a/packages/studio/src/components/editor/TimelinePanel.tsx b/packages/studio/src/components/editor/TimelinePanel.tsx index e4ada6b..3c70069 100644 --- a/packages/studio/src/components/editor/TimelinePanel.tsx +++ b/packages/studio/src/components/editor/TimelinePanel.tsx @@ -481,6 +481,18 @@ export function TimelinePanel({ {state.cameraControls.mouseSensitivity.toFixed(1)} + ): void { const previousMode = this.settings.mode; + const previousInvertMouseY = this.settings.invertMouseY; this.settings = normalizeCameraDriveSettings(settings, this.settings); - if (this.settings.mode !== previousMode) this.clearMotion(); + if (this.settings.mode !== previousMode) { + this.clearMotion(); + } else if (this.settings.invertMouseY !== previousInvertMouseY) { + this.cancelLook(); + } } acceptsKey(code: string): boolean { @@ -511,7 +522,12 @@ export class CameraDrive { !Number.isFinite(deltaY) ) return; this.lookDelta.x = THREE.MathUtils.clamp(this.lookDelta.x + deltaX, -MAX_LOOK_DELTA, MAX_LOOK_DELTA); - this.lookDelta.y = THREE.MathUtils.clamp(this.lookDelta.y + deltaY, -MAX_LOOK_DELTA, MAX_LOOK_DELTA); + const verticalDelta = this.settings.invertMouseY ? -deltaY : deltaY; + this.lookDelta.y = THREE.MathUtils.clamp( + this.lookDelta.y + verticalDelta, + -MAX_LOOK_DELTA, + MAX_LOOK_DELTA, + ); } /** Clear queued pointer-look momentum without interrupting held keyboard input. */ diff --git a/packages/studio/src/lumora.css b/packages/studio/src/lumora.css index 8b4cecb..093560c 100644 --- a/packages/studio/src/lumora.css +++ b/packages/studio/src/lumora.css @@ -1658,6 +1658,23 @@ opacity: 0.45; } +.lumora-camera-controls__invert { + flex: 0 0 auto; + font-size: 11px; +} + +.lumora-camera-controls__invert input { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--lumora-accent); +} + +.lumora-camera-controls__invert input:focus-visible { + outline: 2px solid var(--lumora-accent); + outline-offset: 1px; +} + .lumora-camera-controls__value { width: 28px; color: var(--lumora-text); diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx index 9d412e1..9016f0d 100644 --- a/packages/studio/test/camera-drive-routing.test.tsx +++ b/packages/studio/test/camera-drive-routing.test.tsx @@ -370,6 +370,35 @@ describe('camera drive keyboard routing', () => { expect(renderedCamera.quaternion.angleTo(beforeQuaternion)).toBeGreaterThan(0.001); }); + it('requires a fresh right-button gesture after toggling mouse vertical inversion', async () => { + const studio = await mountStudio('lumora://drive-invert-mouse-y-boundary'); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + const releasePointerCapture = vi.fn(); + Object.assign(viewport, { + setPointerCapture: vi.fn(), + releasePointerCapture, + }); + const look = vi.spyOn(CameraDrive.prototype, 'look'); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 31, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 120, clientY: 110 }); + expect(look).toHaveBeenCalledTimes(1); + + fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' })); + await act(async () => delay(40)); + expect(releasePointerCapture).toHaveBeenCalledWith(31); + const callsAtToggle = look.mock.calls.length; + + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 150, clientY: 140 }); + expect(look).toHaveBeenCalledTimes(callsAtToggle); + + fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 32, clientX: 150, clientY: 140 }); + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 32, clientX: 170, clientY: 150 }); + expect(look).toHaveBeenCalledTimes(callsAtToggle + 1); + }); + it('keyboard-mouse mode keeps translation and pointer look independent', async () => { const studio = await mountStudio('lumora://drive-independent-inputs'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); diff --git a/packages/studio/test/camera-drive.test.ts b/packages/studio/test/camera-drive.test.ts index fab6c0b..2e129ca 100644 --- a/packages/studio/test/camera-drive.test.ts +++ b/packages/studio/test/camera-drive.test.ts @@ -703,6 +703,58 @@ describe('CameraDrive:键鼠驾驶积分器', () => { expect(drive.hasInput).toBe(true); }); + it('鼠标垂直反转默认为关闭,开启后只反转俯仰方向', () => { + const normal = new CameraDrive({ + mode: 'keyboard-mouse', + invertMouseY: false, + mouseSensitivity: 1, + smoothing: 30, + }); + const inverted = new CameraDrive({ + mode: 'keyboard-mouse', + invertMouseY: true, + mouseSensitivity: 1, + smoothing: 30, + }); + const normalNode = makeCameraNode(); + const invertedNode = makeCameraNode(); + normal.attach(normalNode); + inverted.attach(invertedNode); + + expect(new CameraDrive().getSettings().invertMouseY).toBe(false); + normal.look(40, 20); + inverted.look(40, 20); + normal.update(1 / 60); + inverted.update(1 / 60); + + expect(normalNode.rotation.y).toBeCloseTo(invertedNode.rotation.y, 8); + expect(normalNode.rotation.x).toBeCloseTo(-invertedNode.rotation.x, 8); + expect(Math.abs(normalNode.rotation.x)).toBeGreaterThan(0.001); + }); + + it('切换鼠标垂直反转会清除残余视角,但保留已按下的移动键', () => { + const drive = new CameraDrive({ + mode: 'keyboard-mouse', + invertMouseY: false, + speed: 3, + smoothing: 30, + }); + const node = makeCameraNode(); + drive.attach(node); + drive.press('KeyW'); + drive.update(0.2); + drive.look(80, 40); + const rotationAtToggle = node.quaternion.clone(); + const positionAtToggle = node.position.clone(); + + drive.setSettings({ invertMouseY: true }); + drive.update(0.1); + + expect(node.quaternion.angleTo(rotationAtToggle)).toBeLessThan(1e-9); + expect(node.position.distanceTo(positionAtToggle)).toBeGreaterThan(0.05); + expect(drive.hasInput).toBe(true); + }); + it('纯键盘模式忽略鼠标视角,切换模式会清除残余视角输入', () => { const drive = new CameraDrive({ mode: 'keyboard-mouse', mouseSensitivity: 1 }); const node = makeCameraNode(); diff --git a/packages/studio/test/timeline-panel.test.tsx b/packages/studio/test/timeline-panel.test.tsx index 88e76ad..03e09ed 100644 --- a/packages/studio/test/timeline-panel.test.tsx +++ b/packages/studio/test/timeline-panel.test.tsx @@ -125,7 +125,7 @@ describe('TimelinePanel:运输控制、标尺、泳道与分镜', () => { expect(second.session.startRecording).toHaveBeenCalledWith('cam'); }); - it('录制前可选择操控模式并调整速度、短按步长和鼠标灵敏度', () => { + it('录制前可选择操控模式并调整速度、短按步长、鼠标灵敏度和垂直反转', () => { const view = mountPanel({}, ['cam']); const keyboardMouse = screen.getByRole('button', { name: '键盘移动 + 鼠标视角' }); const keyboardOnly = screen.getByRole('button', { name: '纯键盘操控' }); @@ -138,16 +138,32 @@ describe('TimelinePanel:运输控制、标尺、泳道与分镜', () => { const speed = screen.getByLabelText('连续移动速度'); const tapStep = screen.getByLabelText('短按移动步长'); const sensitivity = screen.getByLabelText('鼠标视角灵敏度'); + const invertMouseY = screen.getByRole('checkbox', { name: '鼠标垂直反转' }); expect(speed).toHaveAttribute('type', 'range'); expect(tapStep).toHaveAttribute('type', 'range'); expect(sensitivity).toHaveAttribute('type', 'range'); + expect(invertMouseY).not.toBeChecked(); fireEvent.change(speed, { target: { value: '4.5' } }); fireEvent.change(tapStep, { target: { value: '0.2' } }); fireEvent.change(sensitivity, { target: { value: '1.4' } }); + fireEvent.click(invertMouseY); expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ speed: 4.5 }); expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ tapStep: 0.2 }); expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ mouseSensitivity: 1.4 }); + expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ invertMouseY: true }); + view.unmount(); + + const inverted = mountPanel({ + state: { + ...baseState(), + cameraControls: { ...DEFAULT_CAMERA_DRIVE_SETTINGS, invertMouseY: true }, + }, + }, ['cam']); + const checkedInvertMouseY = screen.getByRole('checkbox', { name: '鼠标垂直反转' }); + expect(checkedInvertMouseY).toBeChecked(); + fireEvent.click(checkedInvertMouseY); + expect(inverted.session.setCameraControlSettings).toHaveBeenCalledWith({ invertMouseY: false }); }); it('录制中:播放键显示 ■,点击停止录制;录制暂停态点击继续', () => { diff --git a/packages/studio/test/use-timeline-session.test.tsx b/packages/studio/test/use-timeline-session.test.tsx index 3df9e8a..032483e 100644 --- a/packages/studio/test/use-timeline-session.test.tsx +++ b/packages/studio/test/use-timeline-session.test.tsx @@ -51,6 +51,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 expect(live().state.snapEnabled).toBe(true); expect(live().state.loopEnabled).toBe(true); expect(live().state.cameraControls.mode).toBe('keyboard-mouse'); + expect(live().state.cameraControls.invertMouseY).toBe(false); }); it('机位操控参数按会话保存、过滤非有限值并夹取范围,切换项目后保持', () => { @@ -60,6 +61,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 speed: 99, tapStep: -1, mouseSensitivity: Number.NaN, + invertMouseY: true, })); expect(live().state.cameraControls).toMatchObject({ @@ -67,6 +69,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 speed: CAMERA_DRIVE_LIMITS.speed.max, tapStep: CAMERA_DRIVE_LIMITS.tapStep.min, mouseSensitivity: 1, + invertMouseY: true, }); act(() => editor.openProject({ ...createSampleProject(), uri: 'lumora://camera-controls-next' })); @@ -75,7 +78,23 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 speed: CAMERA_DRIVE_LIMITS.speed.max, tapStep: CAMERA_DRIVE_LIMITS.tapStep.min, mouseSensitivity: 1, + invertMouseY: true, }); + + act(() => live().setCameraControlSettings({ invertMouseY: false })); + expect(live().state.cameraControls.invertMouseY).toBe(false); + }); + + it('重新挂载编辑器会话后,鼠标垂直反转随其他机位参数恢复默认值', () => { + mount(); + act(() => live().setCameraControlSettings({ invertMouseY: true })); + expect(live().state.cameraControls.invertMouseY).toBe(true); + + unmount?.(); + unmount = null; + mount(); + + expect(live().state.cameraControls.invertMouseY).toBe(false); }); it('togglePlay 切换播放状态', () => { From 70223f461b615621ef2e74dc5123829a5184a869 Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Sat, 5 Sep 2026 03:41:45 +0800 Subject: [PATCH 2/4] fix(studio): close camera inversion gesture race Multica-Task: TML-813 Multica-Run: 4aed1ede-0dbb-45aa-993a-3d5e6839462c Co-authored-by: multica-agent --- e2e/timeline.spec.ts | 14 +++++++++ .../src/components/editor/EditorViewport.tsx | 31 +++++++++++-------- packages/studio/src/lumora.css | 8 +++++ .../studio/test/camera-drive-routing.test.tsx | 10 +++++- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 7df5062..0cf992a 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -471,6 +471,20 @@ test('mouse vertical inversion reverses only pitch and resets an active gesture' await expect(page.getByTestId('camera-control-invert-mouse-y')).not.toBeChecked(); }); +test('mouse vertical inversion keeps a touch-sized target on narrow screens', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + const invertMouseY = page.getByTestId('camera-control-invert-mouse-y'); + const hitTarget = invertMouseY.locator('..'); + await expect(invertMouseY).toBeVisible(); + + const bounds = await hitTarget.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.height).toBeGreaterThanOrEqual(44); + + await hitTarget.click({ position: { x: bounds!.width - 2, y: bounds!.height - 2 } }); + await expect(invertMouseY).toBeChecked(); +}); + test('suppresses an out-of-bounds release from an open ShadowRoot viewport', async ({ page }) => { await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('view-mode-select').selectOption('sample-camera'); diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 9ea3e08..19bd50f 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -553,6 +553,21 @@ function useCameraDrive( } }; + const syncDriveSettings = () => { + const cameraControls = sessionRef.current?.state.cameraControls; + if (!cameraControls) return; + const previousSettings = drive.getSettings(); + drive.setSettings(cameraControls); + const nextSettings = drive.getSettings(); + if (nextSettings.mode !== previousSettings.mode) { + heldKeys.clear(); + endLookGesture(); + } else if (nextSettings.invertMouseY !== previousSettings.invertMouseY) { + drive.cancelLook(); + endLookGesture(); + } + }; + const clearDrive = () => { endLookGesture(); heldKeys.clear(); @@ -650,8 +665,7 @@ function useCameraDrive( const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || !driveEnabledRef.current) return; - const liveSession = sessionRef.current; - if (liveSession) drive.setSettings(liveSession.state.cameraControls); + syncDriveSettings(); const keyboardRoot = keyboardScopeRef?.current; if (keyboardRoot && !isKeyboardEventForStudio(keyboardRoot, event)) return; if ((event.ctrlKey || event.metaKey || event.altKey) && heldKeys.size > 0) { @@ -689,7 +703,7 @@ function useCameraDrive( ) return; const liveSession = sessionRef.current; if (!liveSession) return; - drive.setSettings(liveSession.state.cameraControls); + syncDriveSettings(); if (drive.getSettings().mode !== 'keyboard-mouse' || !canDriveCurrentCamera()) return; if (!attachCurrentCamera()) return; drive.cancelTranslationMomentum(); @@ -804,16 +818,7 @@ function useCameraDrive( } const st = sessionRef.current?.state; if (st) { - const previousSettings = drive.getSettings(); - drive.setSettings(st.cameraControls); - const nextSettings = drive.getSettings(); - if (nextSettings.mode !== previousSettings.mode) { - heldKeys.clear(); - endLookGesture(); - } else if (nextSettings.invertMouseY !== previousSettings.invertMouseY) { - drive.cancelLook(); - endLookGesture(); - } + syncDriveSettings(); } // 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道; // 禁用轨道不阻止驾驶 —— 禁用 = 该通道暂不参与回放) diff --git a/packages/studio/src/lumora.css b/packages/studio/src/lumora.css index 093560c..02e5dc5 100644 --- a/packages/studio/src/lumora.css +++ b/packages/studio/src/lumora.css @@ -1660,6 +1660,8 @@ .lumora-camera-controls__invert { flex: 0 0 auto; + min-height: 24px; + padding: 0 4px; font-size: 11px; } @@ -1675,6 +1677,12 @@ outline-offset: 1px; } +@media (max-width: 520px) { + .lumora-camera-controls__invert { + min-height: 44px; + } +} + .lumora-camera-controls__value { width: 28px; color: var(--lumora-text); diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx index 9016f0d..f8b7df7 100644 --- a/packages/studio/test/camera-drive-routing.test.tsx +++ b/packages/studio/test/camera-drive-routing.test.tsx @@ -373,6 +373,7 @@ describe('camera drive keyboard routing', () => { it('requires a fresh right-button gesture after toggling mouse vertical inversion', async () => { const studio = await mountStudio('lumora://drive-invert-mouse-y-boundary'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const camera = findNode(studio.scene, 'sample-camera')!; const viewport = within(studio.root).getByTestId('lumora-viewport'); const releasePointerCapture = vi.fn(); Object.assign(viewport, { @@ -386,17 +387,24 @@ describe('camera drive keyboard routing', () => { fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 120, clientY: 110 }); expect(look).toHaveBeenCalledTimes(1); + const rotationAtToggle = camera.quaternion.clone(); fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' })); - await act(async () => delay(40)); + fireEvent.keyDown(studio.root, { key: 'w', code: 'KeyW', repeat: true }); expect(releasePointerCapture).toHaveBeenCalledWith(31); const callsAtToggle = look.mock.calls.length; fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 150, clientY: 140 }); + await act(async () => delay(40)); expect(look).toHaveBeenCalledTimes(callsAtToggle); + expect(camera.quaternion.angleTo(rotationAtToggle)).toBeLessThan(1e-9); + fireEvent.keyUp(studio.root, { key: 'w', code: 'KeyW' }); + const rotationBeforeFreshGesture = camera.quaternion.clone(); fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 32, clientX: 150, clientY: 140 }); fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 32, clientX: 170, clientY: 150 }); expect(look).toHaveBeenCalledTimes(callsAtToggle + 1); + await act(async () => delay(40)); + expect(camera.quaternion.angleTo(rotationBeforeFreshGesture)).toBeGreaterThan(0.001); }); it('keyboard-mouse mode keeps translation and pointer look independent', async () => { From b822b03db1642afc06e922074e28086d5171d31c Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Sat, 5 Sep 2026 07:28:43 +0800 Subject: [PATCH 3/4] fix(studio): TML-813 preserve inversion gesture boundary Co-authored-by: multica-agent --- e2e/timeline.spec.ts | 37 +++++++++++++++ .../src/components/editor/EditorViewport.tsx | 15 ++++-- .../studio/src/hooks/use-timeline-session.ts | 25 ++++++++-- packages/studio/src/lumora.css | 6 +++ .../studio/test/camera-drive-routing.test.tsx | 47 ++++++++++++++++++- packages/studio/test/playback-driver.test.tsx | 4 ++ packages/studio/test/timeline-panel.test.tsx | 4 ++ 7 files changed, 128 insertions(+), 10 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 0cf992a..f6f8483 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -466,6 +466,21 @@ test('mouse vertical inversion reverses only pitch and resets an active gesture' const freshAfter = await dragVertically(); expect(quaternionAngle(freshAfter.rotation, afterStaleMove.rotation)).toBeGreaterThan(0.01); + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'right' }); + await page.mouse.move(startX, startY + 20); + const poseAtDoubleToggle = await stableCameraPose(page); + await invertMouseY.evaluate((input) => { + (input as HTMLInputElement).click(); + (input as HTMLInputElement).click(); + }); + await expect(invertMouseY).toBeChecked(); + await page.mouse.move(startX, startY + 70, { steps: 5 }); + await page.waitForTimeout(300); + const afterDoubleToggle = await cameraPose(page); + expect(quaternionAngle(afterDoubleToggle.rotation, poseAtDoubleToggle.rotation)).toBeLessThan(0.001); + await page.mouse.up({ button: 'right' }); + await page.reload(); await page.getByTestId('open-sample-project').click(); await expect(page.getByTestId('camera-control-invert-mouse-y')).not.toBeChecked(); @@ -485,6 +500,28 @@ test('mouse vertical inversion keeps a touch-sized target on narrow screens', as await expect(invertMouseY).toBeChecked(); }); +test('mouse vertical inversion keeps a touch-sized target in a narrow embedded host', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const studio = page.getByTestId('lumora-studio'); + await studio.evaluate((element) => { + element.style.width = '400px'; + element.style.maxWidth = '400px'; + element.style.flex = '0 0 400px'; + }); + + const stage = studio.locator('.lumora-studio__stage'); + const stageBounds = await stage.boundingBox(); + expect(stageBounds).not.toBeNull(); + expect(stageBounds!.width).toBeLessThanOrEqual(520); + + const invertMouseY = page.getByTestId('camera-control-invert-mouse-y'); + const hitTarget = invertMouseY.locator('..'); + await expect(invertMouseY).toBeVisible(); + const bounds = await hitTarget.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.height).toBeGreaterThanOrEqual(44); +}); + test('suppresses an out-of-bounds release from an open ShadowRoot viewport', async ({ page }) => { await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('view-mode-select').selectOption('sample-camera'); diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 19bd50f..f2de8b0 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -521,7 +521,9 @@ function useCameraDrive( useEffect(() => { if (!session) return; - const drive = new CameraDrive(); + const initialSettingsSnapshot = session.getCameraControlSettingsSnapshot(); + const drive = new CameraDrive(initialSettingsSnapshot.settings); + let syncedInvertMouseYRevision = initialSettingsSnapshot.invertMouseYRevision; let raf = 0; let last = performance.now(); let attachedId: string | null = null; @@ -554,15 +556,17 @@ function useCameraDrive( }; const syncDriveSettings = () => { - const cameraControls = sessionRef.current?.state.cameraControls; - if (!cameraControls) return; + const snapshot = sessionRef.current?.getCameraControlSettingsSnapshot(); + if (!snapshot) return; const previousSettings = drive.getSettings(); - drive.setSettings(cameraControls); + const invertMouseYChanged = snapshot.invertMouseYRevision !== syncedInvertMouseYRevision; + drive.setSettings(snapshot.settings); const nextSettings = drive.getSettings(); + syncedInvertMouseYRevision = snapshot.invertMouseYRevision; if (nextSettings.mode !== previousSettings.mode) { heldKeys.clear(); endLookGesture(); - } else if (nextSettings.invertMouseY !== previousSettings.invertMouseY) { + } else if (invertMouseYChanged) { drive.cancelLook(); endLookGesture(); } @@ -721,6 +725,7 @@ function useCameraDrive( } }; const onPointerMove = (event: PointerEvent) => { + syncDriveSettings(); if (lookPointerId === null || event.pointerId !== lookPointerId) return; if ((event.buttons & 2) === 0) { endLookGesture(); diff --git a/packages/studio/src/hooks/use-timeline-session.ts b/packages/studio/src/hooks/use-timeline-session.ts index fd8d496..cf985a6 100644 --- a/packages/studio/src/hooks/use-timeline-session.ts +++ b/packages/studio/src/hooks/use-timeline-session.ts @@ -52,6 +52,11 @@ export interface TimelineSessionState { export type StopRecordingResult = { ok: true } | { ok: false; message: string }; +export interface CameraControlSettingsSnapshot { + settings: CameraDriveSettings; + invertMouseYRevision: number; +} + export interface TimelineSession { timeline: TimelineController; recorder: TimelineRecorder; @@ -65,6 +70,7 @@ export interface TimelineSession { setLoop(enabled: boolean): void; setCaptureSource(source: CaptureSource | null): void; setCameraControlSettings(settings: Partial): void; + getCameraControlSettingsSnapshot(): CameraControlSettingsSnapshot; /** 开始录制指定机位;已有录制轨道时进入覆盖确认 */ startRecording(cameraObjectId: string): void; confirmOverwrite(): void; @@ -82,6 +88,8 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const recorderRef = useRef(null); if (!recorderRef.current) recorderRef.current = new TimelineRecorder(); const recorder = recorderRef.current; + const cameraControlSettingsRef = useRef({ ...DEFAULT_CAMERA_DRIVE_SETTINGS }); + const invertMouseYRevisionRef = useRef(0); const [state, setState] = useState(() => ({ playing: timeline.isPlaying(), @@ -335,12 +343,20 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { ); const setCameraControlSettings = useCallback((settings: Partial) => { - setState((current) => ({ - ...current, - cameraControls: normalizeCameraDriveSettings(settings, current.cameraControls), - })); + const previous = cameraControlSettingsRef.current; + const next = normalizeCameraDriveSettings(settings, previous); + if (next.invertMouseY !== previous.invertMouseY) { + invertMouseYRevisionRef.current += 1; + } + cameraControlSettingsRef.current = next; + setState((current) => ({ ...current, cameraControls: next })); }, []); + const getCameraControlSettingsSnapshot = useCallback((): CameraControlSettingsSnapshot => ({ + settings: { ...cameraControlSettingsRef.current }, + invertMouseYRevision: invertMouseYRevisionRef.current, + }), []); + const beginRecording = useCallback( (cameraObjectId: string) => { const project = editor.getProject(); @@ -509,6 +525,7 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { setLoop, setCaptureSource, setCameraControlSettings, + getCameraControlSettingsSnapshot, startRecording, confirmOverwrite, cancelOverwrite, diff --git a/packages/studio/src/lumora.css b/packages/studio/src/lumora.css index 02e5dc5..5123526 100644 --- a/packages/studio/src/lumora.css +++ b/packages/studio/src/lumora.css @@ -1683,6 +1683,12 @@ } } +@container (max-width: 520px) { + .lumora-camera-controls__invert { + min-height: 44px; + } +} + .lumora-camera-controls__value { width: 28px; color: var(--lumora-text); diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx index f8b7df7..eb87005 100644 --- a/packages/studio/test/camera-drive-routing.test.tsx +++ b/packages/studio/test/camera-drive-routing.test.tsx @@ -407,6 +407,34 @@ describe('camera drive keyboard routing', () => { expect(camera.quaternion.angleTo(rotationBeforeFreshGesture)).toBeGreaterThan(0.001); }); + it('requires a fresh right-button gesture after mouse inversion is toggled twice in one batch', async () => { + const studio = await mountStudio('lumora://drive-invert-mouse-y-double-toggle'); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + const releasePointerCapture = vi.fn(); + Object.assign(viewport, { + setPointerCapture: vi.fn(), + releasePointerCapture, + }); + const look = vi.spyOn(CameraDrive.prototype, 'look'); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 41, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 41, clientX: 120, clientY: 110 }); + expect(look).toHaveBeenCalledTimes(1); + + const invertMouseY = within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' }); + act(() => { + invertMouseY.click(); + invertMouseY.click(); + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 41, clientX: 150, clientY: 140 }); + }); + + expect(invertMouseY).not.toBeChecked(); + expect(releasePointerCapture).toHaveBeenCalledWith(41); + expect(look).toHaveBeenCalledTimes(1); + }); + it('keyboard-mouse mode keeps translation and pointer look independent', async () => { const studio = await mountStudio('lumora://drive-independent-inputs'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); @@ -454,9 +482,10 @@ describe('camera drive keyboard routing', () => { expect(otherCamera.position.distanceTo(otherStart)).toBeLessThan(1e-9); }); - it('keyboard-only mode ignores pointer look and rotates with arrow keys', async () => { + it('keyboard-only mode ignores inverted pointer look and rotates with arrow keys', async () => { const studio = await mountStudio('lumora://drive-keyboard-only'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' })); fireEvent.click(within(studio.root).getByRole('button', { name: '纯键盘操控' })); const camera = findNode(studio.scene, 'sample-camera')!; const beforePointer = camera.quaternion.clone(); @@ -475,6 +504,22 @@ describe('camera drive keyboard routing', () => { expect(camera.quaternion.angleTo(beforePointer)).toBeGreaterThan(0.001); }); + it('keeps the settled camera attached when switching to keyboard-only mode', async () => { + const studio = await mountStudio('lumora://drive-keyboard-only-settled-attachment'); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const camera = findNode(studio.scene, 'sample-camera')!; + await act(async () => delay(60)); + + fireEvent.click(within(studio.root).getByRole('button', { name: '纯键盘操控' })); + await act(async () => delay(40)); + const beforeArrow = camera.quaternion.clone(); + fireEvent.keyDown(studio.root, { key: 'ArrowLeft', code: 'ArrowLeft' }); + await act(async () => delay(100)); + fireEvent.keyUp(studio.root, { key: 'ArrowLeft', code: 'ArrowLeft' }); + + expect(camera.quaternion.angleTo(beforeArrow)).toBeGreaterThan(0.001); + }); + it('suppresses the viewport context menu while keeping pointer capture scoped to camera look', async () => { const studio = await mountStudio('lumora://drive-pointer-lifecycle'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); diff --git a/packages/studio/test/playback-driver.test.tsx b/packages/studio/test/playback-driver.test.tsx index b74f62f..11a086b 100644 --- a/packages/studio/test/playback-driver.test.tsx +++ b/packages/studio/test/playback-driver.test.tsx @@ -81,6 +81,10 @@ function makeSession(timeline: TimelineController, recorder: TimelineRecorder): setLoop: () => {}, setCaptureSource: () => {}, setCameraControlSettings: () => {}, + getCameraControlSettingsSnapshot: () => ({ + settings: { ...DEFAULT_CAMERA_DRIVE_SETTINGS }, + invertMouseYRevision: 0, + }), startRecording: () => {}, confirmOverwrite: () => {}, cancelOverwrite: () => {}, diff --git a/packages/studio/test/timeline-panel.test.tsx b/packages/studio/test/timeline-panel.test.tsx index 03e09ed..2c15277 100644 --- a/packages/studio/test/timeline-panel.test.tsx +++ b/packages/studio/test/timeline-panel.test.tsx @@ -80,6 +80,10 @@ function mountPanel( setLoop: vi.fn(), setCaptureSource: vi.fn(), setCameraControlSettings: vi.fn(), + getCameraControlSettingsSnapshot: vi.fn(() => ({ + settings: { ...session.state.cameraControls }, + invertMouseYRevision: 0, + })), startRecording: vi.fn(), confirmOverwrite: vi.fn(), cancelOverwrite: vi.fn(), From 332f693b3430284d4a930fc5219c97227a7b745c Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Sat, 5 Sep 2026 00:19:39 +0000 Subject: [PATCH 4/4] test(studio): TML-878 cover camera input interleavings Multica-Task: TML-878 Multica-Run: 7122b134-db40-4ed3-8fbb-3b4cb7387c44 Co-authored-by: multica-agent --- .../studio/test/camera-drive-routing.test.tsx | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx index eb87005..dd1e7bd 100644 --- a/packages/studio/test/camera-drive-routing.test.tsx +++ b/packages/studio/test/camera-drive-routing.test.tsx @@ -389,7 +389,7 @@ describe('camera drive keyboard routing', () => { const rotationAtToggle = camera.quaternion.clone(); fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' })); - fireEvent.keyDown(studio.root, { key: 'w', code: 'KeyW', repeat: true }); + await act(async () => delay(40)); expect(releasePointerCapture).toHaveBeenCalledWith(31); const callsAtToggle = look.mock.calls.length; @@ -407,32 +407,65 @@ describe('camera drive keyboard routing', () => { expect(camera.quaternion.angleTo(rotationBeforeFreshGesture)).toBeGreaterThan(0.001); }); - it('requires a fresh right-button gesture after mouse inversion is toggled twice in one batch', async () => { + it('clears queued look and requires a fresh pointer after a same-batch double inversion toggle', async () => { const studio = await mountStudio('lumora://drive-invert-mouse-y-double-toggle'); act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const camera = findNode(studio.scene, 'sample-camera')!; const viewport = within(studio.root).getByTestId('lumora-viewport'); + const setPointerCapture = vi.fn(); const releasePointerCapture = vi.fn(); Object.assign(viewport, { - setPointerCapture: vi.fn(), + setPointerCapture, releasePointerCapture, }); - const look = vi.spyOn(CameraDrive.prototype, 'look'); await act(async () => delay(60)); + const rotationBeforePendingLook = camera.quaternion.clone(); fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 41, clientX: 100, clientY: 100 }); fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 41, clientX: 120, clientY: 110 }); - expect(look).toHaveBeenCalledTimes(1); + expect(camera.quaternion.angleTo(rotationBeforePendingLook)).toBeLessThan(1e-9); const invertMouseY = within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' }); act(() => { invertMouseY.click(); invertMouseY.click(); - fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 41, clientX: 150, clientY: 140 }); }); + await act(async () => delay(40)); expect(invertMouseY).not.toBeChecked(); + expect(setPointerCapture).toHaveBeenNthCalledWith(1, 41); expect(releasePointerCapture).toHaveBeenCalledWith(41); - expect(look).toHaveBeenCalledTimes(1); + expect(camera.quaternion.angleTo(rotationBeforePendingLook)).toBeLessThan(1e-9); + + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 41, clientX: 150, clientY: 140 }); + await act(async () => delay(40)); + expect(camera.quaternion.angleTo(rotationBeforePendingLook)).toBeLessThan(1e-9); + + fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 42, clientX: 150, clientY: 140 }); + fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 42, clientX: 180, clientY: 155 }); + expect(setPointerCapture).toHaveBeenNthCalledWith(2, 42); + await act(async () => delay(40)); + expect(camera.quaternion.angleTo(rotationBeforePendingLook)).toBeGreaterThan(0.001); + }); + + it('keeps a held W key active across an inversion toggle and repeat keydown', async () => { + const studio = await mountStudio('lumora://drive-invert-mouse-y-held-key'); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const camera = findNode(studio.scene, 'sample-camera')!; + await act(async () => delay(60)); + + const positionBeforeHold = camera.position.clone(); + fireEvent.keyDown(studio.root, { key: 'w', code: 'KeyW' }); + await act(async () => delay(180)); + const positionAtToggle = camera.position.clone(); + expect(positionAtToggle.distanceTo(positionBeforeHold)).toBeGreaterThan(0.01); + + fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' })); + fireEvent.keyDown(studio.root, { key: 'w', code: 'KeyW', repeat: true }); + await act(async () => delay(180)); + fireEvent.keyUp(studio.root, { key: 'w', code: 'KeyW' }); + + expect(camera.position.distanceTo(positionAtToggle)).toBeGreaterThan(0.01); }); it('keyboard-mouse mode keeps translation and pointer look independent', async () => {