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 切换播放状态', () => {