From 0bb7301232517952e2c9d158178b485b29e0ec19 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] 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 | 26 ++++++++++++++----- .../editor/src/components/editor/index.tsx | 16 ++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index aa0d694e8f..20d5337da2 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -348,7 +348,8 @@ function useFirstPersonCameraPoseRestore( } export const CustomCameraControls = () => { - const controls = useRef(null) + // eslint-disable-next-line no-console + const controls = useRef(null) const pendingAppliedPose = useRef(null) const activePoseInterpolation = useRef<{ camera: Camera @@ -513,21 +514,24 @@ 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 + // Dev-only diagnostic: 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 } - w.__pascalCameraControls = () => controls.current + // eslint-disable-next-line no-console + w.__pascalCameraControls = () => controls.current return () => { w.__pascalCameraControls = null } }, []) + const previousLevelIdRef = useRef(null) useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return + const previousLevelId = previousLevelIdRef.current + previousLevelIdRef.current = currentLevelId let targetY = 0 if (currentLevelId) { const levelMesh = sceneRegistry.nodes.get(currentLevelId) @@ -538,8 +542,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) controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 7f4cfc879a..503090f0a2 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, @@ -21,9 +22,11 @@ import { import { memo, type ReactNode, useCallback, useEffect, useRef, useState } 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, @@ -1214,6 +1217,8 @@ export default function Editor({ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) + useAutoFrame() + const { isLoadingSceneRef } = useAutoSave({ onSave, onDirty, @@ -1349,6 +1354,17 @@ export default function Editor({ 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 &&