diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 25fb4670f0..27f1fa3d5a 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,17 @@ export const CustomCameraControls = () => { } }, []) + const previousLevelIdRef = useRef(null) + const previousLevelModeRef = useRef(levelMode) 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 + // 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 +544,19 @@ 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. 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 } + const levelChanged = previousLevelId !== currentLevelId + const modeChanged = previousLevelMode !== levelMode + if (!levelChanged && !modeChanged) 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/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index aafb678a0d..5eaaa452ce 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,6 +33,7 @@ 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' @@ -41,6 +43,7 @@ import { 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' @@ -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 && 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..8e8b2bf8ff --- /dev/null +++ b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts @@ -0,0 +1,48 @@ +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 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('Object.keys(useScene.getState().nodes).length === 0') + 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', () => { + 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') + }) +})