Skip to content
Closed
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.
70 changes: 70 additions & 0 deletions e2e/timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions packages/studio/src/components/editor/EditorViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
// 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道;
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
17 changes: 17 additions & 0 deletions packages/studio/src/lumora.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions packages/studio/test/camera-drive-routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
Expand Down
52 changes: 52 additions & 0 deletions packages/studio/test/camera-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading