From 8566b56c550d4262f34706d776047b9ec3c3e41f Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:34:00 +0400 Subject: [PATCH 1/4] fix(editor): restore scene auto-framing and stop level-follow from clobbering it useAutoFrame was accidentally removed in e688792c, so nothing emitted camera-controls:fit-scene on load; the level-follow effect's first-run default pose then reset the camera after framing on fast client-side navigations. Restore the hook, gate the default pose to scene-less editors, skip the initial null->level transition, and re-emit fit-scene once the viewer signals scene-ready. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../editor/custom-camera-controls.tsx | 22 ++++++++++++++----- .../editor/src/components/editor/index.tsx | 16 ++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 25fb4670f0..dc4d19c33a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -514,10 +514,9 @@ export const CustomCameraControls = () => { useEffect(() => cancelPoseApplication, [cancelPoseApplication]) useEffect(() => { - // Dev-only: deterministic camera poses for screenshot/automation tooling. - // A getter, not a snapshot — drei recreates the impl when the default - // camera changes, so a captured instance goes stale. - if (process.env.NODE_ENV !== 'development') return + // Deterministic camera poses for screenshot/automation tooling. + // No NODE_ENV gate: process is undefined client-side (Turbopack + // does not replace it in source-aliased packages), so gating throws. const w = window as typeof window & { __pascalCameraControls?: (() => CameraControlsImpl | null) | null } @@ -527,11 +526,14 @@ export const CustomCameraControls = () => { } }, []) + const previousLevelIdRef = useRef(null) useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return + const previousLevelId = previousLevelIdRef.current + previousLevelIdRef.current = currentLevelId // Analytic destination, not `sceneRegistry` mesh position: a level created // this frame still sits at y=0 (LevelSystem lerps it later), and a mode - // switch leaves every level mid-lerp — the camera must pan to where the + // switch leaves every level mid-lerp - the camera must pan to where the // level will settle, in the CURRENT presentation mode. const targetY = currentLevelId ? getLevelPresentationY(currentLevelId, useScene.getState().nodes, levelMode) @@ -539,8 +541,16 @@ export const CustomCameraControls = () => { if (!controls.current) return if (firstLoad.current) { firstLoad.current = false - controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) + // A freshly applied scene is framed by the auto-frame emit; only a + // scene-less editor gets the default pose. + if (Object.keys(useScene.getState().nodes).length === 0) { + controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) + } + return } + // null → level is the initial scene load; only real level switches move + // the camera, or they would clobber the auto-framed pose. + if (!previousLevelId || previousLevelId === currentLevelId) return controls.current.getTarget(currentTarget) // Idempotence guard: skip when already there — also swallows the thumbnail // generator's synchronous stacked→restore levelMode round-trip. diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index aafb678a0d..f06bf74269 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -3,6 +3,7 @@ import { Icon } from '@iconify/react' import { acquireSceneReadOnlyLease, + emitter, getCatalogMaterialById, getLibraryMaterialIdFromRef, getSceneMaterialIdFromRef, @@ -32,9 +33,11 @@ import { } from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' +import { useAutoFrame } from '../../hooks/use-auto-frame' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' +import { computeSceneBoundsXZ } from '../../lib/scene-bounds' import { applySceneGraphToEditor, loadSceneFromLocalStorage, @@ -1246,6 +1249,8 @@ function EditorContent({ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) + useAutoFrame() + const { isLoadingSceneRef } = useAutoSave({ onSave, onDirty, @@ -1396,6 +1401,17 @@ function EditorContent({ return () => window.clearTimeout(timer) }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey]) + // The useAutoFrame emit can be clobbered by the level-follow effect's + // first-run default pose on fast (client-side navigation) loads. Re-emitting + // here is the last word after every load-driven camera effect has run. + useEffect(() => { + if (!isViewerSceneReady) return + const nodes = useScene.getState().nodes + if (Object.keys(nodes).length === 0) return + const bounds = computeSceneBoundsXZ(nodes) + emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {}) + }, [isViewerSceneReady, sceneReadyKey]) + const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady const visibleLoader = showLoader && From 045694bbcf6781a9b08e49a368759d57f80ad63a Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:22:04 +0400 Subject: [PATCH 2/4] test(editor): lock camera auto-frame wiring against regressions Assert EditorContent mounts useAutoFrame and re-emits fit-scene on viewer scene-ready, and that CustomCameraControls keeps the __pascalCameraControls helper ungated by NODE_ENV. --- .../editor/src/components/editor/index.tsx | 2 +- .../src/hooks/use-auto-frame.wiring.test.ts | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/hooks/use-auto-frame.wiring.test.ts diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index f06bf74269..5eaaa452ce 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -37,13 +37,13 @@ import { useAutoFrame } from '../../hooks/use-auto-frame' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' -import { computeSceneBoundsXZ } from '../../lib/scene-bounds' import { applySceneGraphToEditor, loadSceneFromLocalStorage, type SceneGraph, writePersistedSelection, } from '../../lib/scene' +import { computeSceneBoundsXZ } from '../../lib/scene-bounds' import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus' import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus' import useEditor from '../../store/use-editor' diff --git a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts new file mode 100644 index 0000000000..86d3f95eea --- /dev/null +++ b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const editorRoot = join(import.meta.dir, '..') + +function readShipped(relativeFromSrc: string): string { + return readFileSync(join(editorRoot, relativeFromSrc), 'utf8') +} + +describe('camera auto-frame wiring (shipped sources)', () => { + test('EditorContent mounts useAutoFrame and re-emits fit-scene on viewer scene-ready', () => { + const index = readShipped('components/editor/index.tsx') + expect(index).toContain("import { useAutoFrame } from '../../hooks/use-auto-frame'") + expect(index).toContain('useAutoFrame()') + expect(index).toContain("emitter.emit('camera-controls:fit-scene'") + expect(index).toContain('isViewerSceneReady') + expect(index).toContain('computeSceneBoundsXZ') + }) + + test('CustomCameraControls exposes __pascalCameraControls without NODE_ENV gate', () => { + const controls = readShipped('components/editor/custom-camera-controls.tsx') + expect(controls).toContain('__pascalCameraControls') + expect(controls).not.toContain("process.env.NODE_ENV !== 'development'") + expect(controls).not.toContain('process.env.NODE_ENV ===') + }) + + test('level-follow skips first null->level transition and default pose only when scene empty', () => { + const controls = readShipped('components/editor/custom-camera-controls.tsx') + expect(controls).toContain('previousLevelIdRef') + expect(controls).toContain('Object.keys(useScene.getState().nodes).length === 0') + expect(controls).toContain('if (!previousLevelId || previousLevelId === currentLevelId) return') + }) + + test('useAutoFrame hook still emits fit-scene on empty->non-empty edge', () => { + const hook = readShipped('hooks/use-auto-frame.ts') + expect(hook).toContain('export function useAutoFrame') + expect(hook).toContain("emitter.emit('camera-controls:fit-scene'") + expect(hook).toContain('computeSceneBoundsXZ') + }) +}) From 0901fcbc78dc9d995da13ae75cc41bda52167334 Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:10:22 +0400 Subject: [PATCH 3/4] fix(editor): pan on levelMode change and re-pick after clearing a level Bugbot on #766: the level-follow early return treated every same-id rerun and every null->level as a no-op. Keep the first auto-select skip so auto-frame still owns load, but follow exploded/stacked Y changes and a level pick after building/breadcrumb/resetSelection. --- .../editor/custom-camera-controls.tsx | 19 ++++++++++++++++--- .../src/hooks/use-auto-frame.wiring.test.ts | 10 +++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index dc4d19c33a..85e3a28747 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -527,10 +527,14 @@ export const CustomCameraControls = () => { }, []) const previousLevelIdRef = useRef(null) + const previousLevelModeRef = useRef(levelMode) + const skippedInitialLevelSelectRef = useRef(false) useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return const previousLevelId = previousLevelIdRef.current + const previousLevelMode = previousLevelModeRef.current previousLevelIdRef.current = currentLevelId + previousLevelModeRef.current = levelMode // Analytic destination, not `sceneRegistry` mesh position: a level created // this frame still sits at y=0 (LevelSystem lerps it later), and a mode // switch leaves every level mid-lerp - the camera must pan to where the @@ -548,9 +552,18 @@ export const CustomCameraControls = () => { } return } - // null → level is the initial scene load; only real level switches move - // the camera, or they would clobber the auto-framed pose. - if (!previousLevelId || previousLevelId === currentLevelId) return + if (previousLevelId) skippedInitialLevelSelectRef.current = true + const levelChanged = previousLevelId !== currentLevelId + const modeChanged = previousLevelMode !== levelMode + if (!levelChanged && !modeChanged) return + // First null → level is scene auto-select; auto-frame owns that pose. + // After the user has followed a level, clearing it (building click, + // breadcrumb, resetSelection) and picking one again must pan. + if (!previousLevelId && currentLevelId && !skippedInitialLevelSelectRef.current) { + skippedInitialLevelSelectRef.current = true + return + } + if (!currentLevelId) return controls.current.getTarget(currentTarget) // Idempotence guard: skip when already there — also swallows the thumbnail // generator's synchronous stacked→restore levelMode round-trip. diff --git a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts index 86d3f95eea..96002bf386 100644 --- a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts +++ b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts @@ -28,8 +28,16 @@ describe('camera auto-frame wiring (shipped sources)', () => { test('level-follow skips first null->level transition and default pose only when scene empty', () => { const controls = readShipped('components/editor/custom-camera-controls.tsx') expect(controls).toContain('previousLevelIdRef') + expect(controls).toContain('previousLevelModeRef') + expect(controls).toContain('skippedInitialLevelSelectRef') expect(controls).toContain('Object.keys(useScene.getState().nodes).length === 0') - expect(controls).toContain('if (!previousLevelId || previousLevelId === currentLevelId) return') + expect(controls).toContain( + 'if (!previousLevelId && currentLevelId && !skippedInitialLevelSelectRef.current)', + ) + expect(controls).toContain('if (!levelChanged && !modeChanged) return') + expect(controls).not.toContain( + 'if (!previousLevelId || previousLevelId === currentLevelId) return', + ) }) test('useAutoFrame hook still emits fit-scene on empty->non-empty edge', () => { From 38f224f0955d38cfc8e90dece7661cf7c9f0cc9f Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:20:31 +0400 Subject: [PATCH 4/4] fix(editor): follow the first level pick after a site-phase load Bugbot on #766: skipping the first null->level in the controls lifetime also dropped a real pick when load restored site phase (levelId null). Keep first-load auto-frame / empty-scene default pose; after that, pan on level or levelMode changes. Y-idempotence still swallows no-ops. --- .../components/editor/custom-camera-controls.tsx | 13 +++---------- .../editor/src/hooks/use-auto-frame.wiring.test.ts | 9 ++++----- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 85e3a28747..27f1fa3d5a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -528,7 +528,6 @@ export const CustomCameraControls = () => { const previousLevelIdRef = useRef(null) const previousLevelModeRef = useRef(levelMode) - const skippedInitialLevelSelectRef = useRef(false) useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return const previousLevelId = previousLevelIdRef.current @@ -546,23 +545,17 @@ export const CustomCameraControls = () => { if (firstLoad.current) { firstLoad.current = false // A freshly applied scene is framed by the auto-frame emit; only a - // scene-less editor gets the default pose. + // scene-less editor gets the default pose. Do not skip later + // null → level here: a site-phase load starts with no level, and the + // first pick (or a delayed auto-select) still has to pan. if (Object.keys(useScene.getState().nodes).length === 0) { controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) } return } - if (previousLevelId) skippedInitialLevelSelectRef.current = true const levelChanged = previousLevelId !== currentLevelId const modeChanged = previousLevelMode !== levelMode if (!levelChanged && !modeChanged) return - // First null → level is scene auto-select; auto-frame owns that pose. - // After the user has followed a level, clearing it (building click, - // breadcrumb, resetSelection) and picking one again must pan. - if (!previousLevelId && currentLevelId && !skippedInitialLevelSelectRef.current) { - skippedInitialLevelSelectRef.current = true - return - } if (!currentLevelId) return controls.current.getTarget(currentTarget) // Idempotence guard: skip when already there — also swallows the thumbnail diff --git a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts index 96002bf386..8e8b2bf8ff 100644 --- a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts +++ b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts @@ -25,19 +25,18 @@ describe('camera auto-frame wiring (shipped sources)', () => { expect(controls).not.toContain('process.env.NODE_ENV ===') }) - test('level-follow skips first null->level transition and default pose only when scene empty', () => { + test('level-follow pans on level or mode change after first load, default pose only when scene empty', () => { const controls = readShipped('components/editor/custom-camera-controls.tsx') expect(controls).toContain('previousLevelIdRef') expect(controls).toContain('previousLevelModeRef') - expect(controls).toContain('skippedInitialLevelSelectRef') expect(controls).toContain('Object.keys(useScene.getState().nodes).length === 0') - expect(controls).toContain( - 'if (!previousLevelId && currentLevelId && !skippedInitialLevelSelectRef.current)', - ) expect(controls).toContain('if (!levelChanged && !modeChanged) return') + expect(controls).toContain('if (!currentLevelId) return') + expect(controls).toContain('controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)') expect(controls).not.toContain( 'if (!previousLevelId || previousLevelId === currentLevelId) return', ) + expect(controls).not.toContain('skippedInitialLevelSelectRef') }) test('useAutoFrame hook still emits fit-scene on empty->non-empty edge', () => {