Scene Builder fixes: record-mode camera lock, Blender default framing, auto-keyframe

- 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).
This commit is contained in:
Tanish Manku committed 2026-07-07 15:23:50 -07:00
1 parent 26aa3d3839
commit 11c7aa979d
6 files changed
+56 -3

No files matched your search

@@ -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.
@@ -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",
@@ -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
@@ -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(
@@ -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(
@@ -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) {