From 11c7aa979d7d6962bcf8a4da9c871e261e489fbe Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 7 Jul 2026 15:23:50 -0700 Subject: [PATCH] Scene Builder fixes: record-mode camera lock, Blender default framing, auto-keyframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record mode is now fully immutable: gate MouseControls mouse-look + focus() on the viewport lock (new MouseControlsDeps.isViewportLocked → cameraController .locked), matching the existing FreeCam/tick gates. - Default main (viewport) camera pulled back + elevated to a Blender-style 3/4 view so a blank scene shows the full render-camera frustum. - Auto-keyframe: mutating an already-keyframed object (gizmo drag-end or panel commit) replaces/creates a keyframe at the playhead via TimelineController.autoKeyIfTracked. - loadTimeline backfills fps/duration/tracks defensively (legacy save safety). --- frontend/libs/components/pagescene/CLAUDE.md | 14 +++++++++++ .../pagescene/src/lib/PageSceneStore.ts | 6 +++-- .../src/lib/actions/transformObject.ts | 3 +++ .../pagescene/src/lib/engine/editor.ts | 6 +++++ .../lib/engine/editor/TimelineController.ts | 24 ++++++++++++++++++- .../src/lib/engine/keybinds_controls.ts | 6 +++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/frontend/libs/components/pagescene/CLAUDE.md b/frontend/libs/components/pagescene/CLAUDE.md index 7b4c0d61b4..a1324fcbfb 100644 --- a/frontend/libs/components/pagescene/CLAUDE.md +++ b/frontend/libs/components/pagescene/CLAUDE.md @@ -12,6 +12,20 @@ components that host these canvases** (`SceneContainer`, `EditorCanvas`, `Previe for mode/chrome gating — hide them with CSS (`hidden`) instead. (Regression fixed: record mode had gated `PreviewEngineCamera` behind `!isRecord`, wiping the scene on record→build toggle.) +## Behavior notes (fixes) + +- **Record mode is fully immutable.** The viewport lock (`CameraController.locked`, set by the + record effect) gates every camera-move path: FreeCam drag/wheel/keyboard (`useFreeCam`), + FreeCam integration (`tickPerFrame`), AND `MouseControls` mouse-look + `focus()` (gated via + `MouseControlsDeps.isViewportLocked`). If you add a new camera-move path, gate it on `locked`. +- **Default camera framing** (`DEFAULT_CAMERAS.main`): a Blender-style pulled-back/elevated 3/4 + view (`pos (-4.5,4,6)`, `lookAt (0,0.5,0.6)`) so the whole render-camera (`cam2`) frustum is + visible on a blank scene. +- **Auto-keyframe**: mutating an **already-keyframed** object (gizmo drag-end or inspector commit) + calls `TimelineController.autoKeyIfTracked(uuid)` → `addKeyframe` at the playhead, which + replace-or-creates a keyframe at the current scrub point. No-op for never-keyframed objects + (first keyframe is always explicit). + ## Architecture (existing, do not rebuild) Strict one-way data flow. Do not violate it. diff --git a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts index 620f377493..b42e4dece2 100644 --- a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts +++ b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts @@ -131,11 +131,13 @@ export interface EditorLoader { const DEFAULT_CAMERAS: Camera[] = [ { id: "main", + // Blender-style default: viewport pulled back + elevated at a 3/4 angle so + // the whole render-camera frustum (cam2, below) is visible in the scene. label: "Main View", focalLength: 17, - position: { x: -2.5, y: 2.5, z: 2.5 }, + position: { x: -4.5, y: 4, z: 6 }, rotation: { x: 0, y: 0, z: 0 }, - lookAt: { x: 0, y: 0, z: 0 }, + lookAt: { x: 0, y: 0.5, z: 0.6 }, }, { id: "cam2", diff --git a/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts b/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts index 4cdf449645..32452cc2f5 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts @@ -29,6 +29,9 @@ export function beginTransformSession( commit() { if (action.commit()) { editor.history.record(action); + // Auto-key: an already-keyframed object edited via the panel updates + // its keyframe at the playhead (or creates one at the scrub point). + editor.timelineController.autoKeyIfTracked(uuid); } // Push the engine's post-edit transform back into the Zustand // objectPanel slice so the panel's currentSceneObject vectors diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor.ts b/frontend/libs/components/pagescene/src/lib/engine/editor.ts index bf7559426a..698e8dd16c 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor.ts @@ -526,6 +526,11 @@ class Editor { } else if (this.activeTransform) { if (this.activeTransform.commit()) { this.history.record(this.activeTransform); + // Auto-key: if the dragged object is already keyframed, record + // its new transform at the playhead (replaces the keyframe there + // or creates one at the current scrub point). + const uuid = this.sceneManager?.selected_objects?.[0]?.uuid; + if (uuid) this.timelineController.autoKeyIfTracked(uuid); } this.activeTransform = null; } @@ -575,6 +580,7 @@ class Editor { isHotkeyDisabled: () => usePageSceneStore.getState().hotkeyStatus.disabled, getTransformSpace: () => usePageSceneStore.getState().transformSpace, + isViewportLocked: () => this.cameraController.locked, }); this.sceneManager = new SceneManager( diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts index 8fcd2fbf98..a9cdebf857 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts @@ -72,7 +72,16 @@ export class TimelineController { // Replace the live timeline wholesale (used by undo/redo of a save). loadTimeline(data: TimelineData): void { this.timeline = cloneTimeline(data); - this.saved = cloneTimeline(data); + // Backfill defensively so legacy/partial saved timelines don't break the + // frame-quantized UI (which assumes a valid fps) or produce a 0 duration. + if (!this.timeline.fps || this.timeline.fps <= 0) { + this.timeline.fps = DEFAULT_TIMELINE_FPS; + } + if (!this.timeline.duration || this.timeline.duration <= 0) { + this.timeline.duration = DEFAULT_TIMELINE_DURATION; + } + if (!Array.isArray(this.timeline.tracks)) this.timeline.tracks = []; + this.saved = cloneTimeline(this.timeline); this.playhead = Math.min(this.playhead, this.timeline.duration); this.evaluate(); this.emitChanged(); @@ -126,6 +135,19 @@ export class TimelineController { // ─── keyframe editing ────────────────────────────────────────────────── + // Auto-key: when an ALREADY-keyframed object is mutated, capture its current + // transform at the playhead — replacing the keyframe there or creating one at + // the current scrub point. No-op for objects that have never been keyframed + // (the first keyframe is always added explicitly). + autoKeyIfTracked(objectUuid: string): void { + if (!this.timeline) return; + const track = this.timeline.tracks.find( + (t) => t.objectUuid === objectUuid, + ); + if (!track || track.keyframes.length === 0) return; + this.addKeyframe(objectUuid, this.playhead); + } + addKeyframe(objectUuid: string, atTime?: number): void { if (!this.timeline) return; const obj = this.editor.activeScene.scene.getObjectByProperty( diff --git a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts index 72367da64a..489523f012 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts @@ -95,6 +95,9 @@ export type MouseControlsDeps = { getPoseMode: () => PoseMode; isHotkeyDisabled: () => boolean; getTransformSpace: () => TransformSpace; + // True when the viewport is locked (record mode) — camera look / focus + // must be fully inert so record playback is immutable. + isViewportLocked: () => boolean; }; export class MouseControls { @@ -189,6 +192,7 @@ export class MouseControls { } focus() { + if (this.deps.isViewportLocked()) return; if (this.lockControls && this.selected) { this.lockControls.camera.lookAt(this.selected[0].position); this.lockControls.camera.position.copy(this.selected[0].position); @@ -499,6 +503,8 @@ export class MouseControls { } handleMouseManualLock(event: MouseEvent) { + // Record mode locks the viewport — no mouse-look camera rotation. + if (this.deps.isViewportLocked()) return; if (this.isMouseClicked && this.lockControls) { // If the mouse is clicked and the lockControls is not locked, lock it if (this.lockControls.isLocked == false) {