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..f6f8483 100644
--- a/e2e/timeline.spec.ts
+++ b/e2e/timeline.spec.ts
@@ -401,6 +401,127 @@ 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.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();
+});
+
+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('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 5919dba..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;
@@ -553,6 +555,23 @@ function useCameraDrive(
}
};
+ const syncDriveSettings = () => {
+ const snapshot = sessionRef.current?.getCameraControlSettingsSnapshot();
+ if (!snapshot) return;
+ const previousSettings = drive.getSettings();
+ 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 (invertMouseYChanged) {
+ drive.cancelLook();
+ endLookGesture();
+ }
+ };
+
const clearDrive = () => {
endLookGesture();
heldKeys.clear();
@@ -650,8 +669,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 +707,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();
@@ -707,6 +725,7 @@ function useCameraDrive(
}
};
const onPointerMove = (event: PointerEvent) => {
+ syncDriveSettings();
if (lookPointerId === null || event.pointerId !== lookPointerId) return;
if ((event.buttons & 2) === 0) {
endLookGesture();
@@ -804,12 +823,7 @@ function useCameraDrive(
}
const st = sessionRef.current?.state;
if (st) {
- const previousMode = drive.getSettings().mode;
- drive.setSettings(st.cameraControls);
- if (drive.getSettings().mode !== previousMode) {
- heldKeys.clear();
- endLookGesture();
- }
+ syncDriveSettings();
}
// 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道;
// 禁用轨道不阻止驾驶 —— 禁用 = 该通道暂不参与回放)
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/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 8b4cecb..5123526 100644
--- a/packages/studio/src/lumora.css
+++ b/packages/studio/src/lumora.css
@@ -1658,6 +1658,37 @@
opacity: 0.45;
}
+.lumora-camera-controls__invert {
+ flex: 0 0 auto;
+ min-height: 24px;
+ padding: 0 4px;
+ 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;
+}
+
+@media (max-width: 520px) {
+ .lumora-camera-controls__invert {
+ min-height: 44px;
+ }
+}
+
+@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 9d412e1..dd1e7bd 100644
--- a/packages/studio/test/camera-drive-routing.test.tsx
+++ b/packages/studio/test/camera-drive-routing.test.tsx
@@ -370,6 +370,104 @@ 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 camera = findNode(studio.scene, '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);
+
+ const rotationAtToggle = camera.quaternion.clone();
+ 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 });
+ 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('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,
+ releasePointerCapture,
+ });
+ 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(camera.quaternion.angleTo(rotationBeforePendingLook)).toBeLessThan(1e-9);
+
+ const invertMouseY = within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' });
+ act(() => {
+ invertMouseY.click();
+ invertMouseY.click();
+ });
+ await act(async () => delay(40));
+
+ expect(invertMouseY).not.toBeChecked();
+ expect(setPointerCapture).toHaveBeenNthCalledWith(1, 41);
+ expect(releasePointerCapture).toHaveBeenCalledWith(41);
+ 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 () => {
const studio = await mountStudio('lumora://drive-independent-inputs');
act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera']));
@@ -417,9 +515,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();
@@ -438,6 +537,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/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/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 88e76ad..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(),
@@ -125,7 +129,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 +142,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 切换播放状态', () => {