Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/plans/2026-09-05-invert-camera-mouse-y.md
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 121 additions & 0 deletions e2e/timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
34 changes: 24 additions & 10 deletions packages/studio/src/components/editor/EditorViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -707,6 +725,7 @@ function useCameraDrive(
}
};
const onPointerMove = (event: PointerEvent) => {
syncDriveSettings();
if (lookPointerId === null || event.pointerId !== lookPointerId) return;
if ((event.buttons & 2) === 0) {
endLookGesture();
Expand Down Expand Up @@ -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();
}
// 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道;
// 禁用轨道不阻止驾驶 —— 禁用 = 该通道暂不参与回放)
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/components/editor/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,18 @@ export function TimelinePanel({
{state.cameraControls.mouseSensitivity.toFixed(1)}
</span>
</label>
<label className="lumora-check lumora-camera-controls__invert">
<input
type="checkbox"
aria-label="鼠标垂直反转"
data-testid="camera-control-invert-mouse-y"
checked={state.cameraControls.invertMouseY}
onChange={(event) => session.setCameraControlSettings({
invertMouseY: event.target.checked,
})}
/>
垂直反转
</label>
<span
className={`lumora-camera-controls__status${driveBlocked ? ' lumora-camera-controls__status--blocked' : ''}`}
data-testid="camera-control-status"
Expand Down
20 changes: 18 additions & 2 deletions packages/studio/src/components/editor/camera-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface CameraDriveSettings {
rotateSpeed: number;
/** 鼠标视角灵敏度倍率 */
mouseSensitivity: number;
/** 仅反转鼠标拖动对应的垂直俯仰方向 */
invertMouseY: boolean;
/** 平滑系数(1/秒):速度向目标逼近的指数速率,越大越跟手 */
smoothing: number;
}
Expand All @@ -35,6 +37,7 @@ export const DEFAULT_CAMERA_DRIVE_SETTINGS: CameraDriveSettings = {
holdDelay: 0.12,
rotateSpeed: 1.2,
mouseSensitivity: 1,
invertMouseY: false,
smoothing: 8,
};

Expand Down Expand Up @@ -155,6 +158,9 @@ export function normalizeCameraDriveSettings(
CAMERA_DRIVE_LIMITS.mouseSensitivity.min,
CAMERA_DRIVE_LIMITS.mouseSensitivity.max,
),
invertMouseY: typeof settings.invertMouseY === 'boolean'
? settings.invertMouseY
: base.invertMouseY,
smoothing: bounded(
settings.smoothing,
base.smoothing,
Expand Down Expand Up @@ -452,8 +458,13 @@ export class CameraDrive {

setSettings(settings: Partial<CameraDriveSettings>): 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 {
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading