From 3dce76eb2cac6059d263754d473b5db53ce87f24 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/5] 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 b50630e19..f0bf41a93 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -515,10 +515,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = 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 } @@ -528,11 +527,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } }, []) + 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) @@ -540,8 +542,16 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = 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 786f28299..8192a643f 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, @@ -34,6 +35,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 { useSaveShortcut } from '../../hooks/use-save-shortcut' @@ -42,6 +44,7 @@ import { type LocalProjectPresentationPersistence, } from '../../lib/local-project-presentation-persistence' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' +import { computeSceneBoundsXZ } from '../../lib/scene-bounds' import { applySceneGraphToEditor, loadSceneFromLocalStorage, @@ -1290,6 +1293,8 @@ function EditorContent({ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) + useAutoFrame() + const { isLoadingSceneRef, saveNow } = useAutoSave({ onSave, onDirty, @@ -1447,6 +1452,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 cf98c5d9cf1c1a4c806992a5cafb3818d74f7fbc 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/5] 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 8192a643f..bf59d4927 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -44,13 +44,13 @@ import { type LocalProjectPresentationPersistence, } from '../../lib/local-project-presentation-persistence' 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 000000000..86d3f95ee --- /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 0f41c854cdd524180aa9f50d822322939cb7f392 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/5] 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 f0bf41a93..bb274a880 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -528,10 +528,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = }, []) 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 @@ -549,9 +553,18 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } 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 86d3f95ee..96002bf38 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 66a55209bdda6c1da5c68c0f1171d9ab87e43661 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/5] 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 bb274a880..0ffe37598 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -529,7 +529,6 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = const previousLevelIdRef = useRef(null) const previousLevelModeRef = useRef(levelMode) - const skippedInitialLevelSelectRef = useRef(false) useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return const previousLevelId = previousLevelIdRef.current @@ -547,23 +546,17 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = 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 96002bf38..8e8b2bf8f 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', () => { From e7adddc0bd42fbf42afac955e701411a3c417f3c Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:52:20 +0400 Subject: [PATCH 5/5] fix(editor): keep scene fit pending across first-person Queue fit-scene while orbit controls are unmounted (first-person or pre-ref), flush on orbit resume, and cover the FP->orbit transition with a behavioral helper test for Aymericr's #766 review. --- .../editor/custom-camera-controls.tsx | 97 ++++++++-- .../editor/fit-scene-framing.test.ts | 176 ++++++++++++++++++ .../components/editor/fit-scene-framing.ts | 94 ++++++++++ .../src/hooks/use-auto-frame.wiring.test.ts | 12 +- 4 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 packages/editor/src/components/editor/fit-scene-framing.test.ts create mode 100644 packages/editor/src/components/editor/fit-scene-framing.ts diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0ffe37598..6cd8abf15 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -40,6 +40,11 @@ import { useMovingNode, } from '../../store/use-interaction-scope' import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' +import { + type PendingFitScene, + planFitSceneOnEvent, + planFitSceneOnOrbitResume, +} from './fit-scene-framing' const currentTarget = new Vector3() const tempBox = new Box3() @@ -376,6 +381,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = ) const currentLevelId = selection.levelId const firstLoad = useRef(true) + // Survives first-person (orbit unmounted) so scene-ready fit still applies + // once CameraControls remount. + const pendingFitSceneRef = useRef(null) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE @@ -1251,21 +1259,57 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = focusNode(nodeId) } + const applyFitLookAt = (lookAt: { + eyeX: number + eyeY: number + eyeZ: number + targetX: number + targetY: number + targetZ: number + }) => { + if (!controls.current) return false + controls.current.setLookAt( + lookAt.eyeX, + lookAt.eyeY, + lookAt.eyeZ, + lookAt.targetX, + lookAt.targetY, + lookAt.targetZ, + true, + ) + return true + } + + const flushPendingFitScene = () => { + const plan = planFitSceneOnOrbitResume({ + isPreviewMode, + isFirstPersonMode: useEditor.getState().isFirstPersonMode, + hasControls: !!controls.current, + pending: pendingFitSceneRef.current, + }) + if (plan.action !== 'apply') return + if (!applyFitLookAt(plan.lookAt)) return + pendingFitSceneRef.current = null + } + const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { - if (isFirstPersonMode || !controls.current || isPreviewMode) return - if (!bounds) { - // Restore default framing pose when no bounds were computed. - controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) + const plan = planFitSceneOnEvent({ + isPreviewMode, + isFirstPersonMode, + hasControls: !!controls.current, + bounds: bounds ?? null, + }) + if (plan.action === 'ignore') return + if (plan.action === 'queue') { + pendingFitSceneRef.current = plan.pending + // Orbit path with a not-yet-attached ref: retry next frame. + if (!isFirstPersonMode && !isPreviewMode) { + requestAnimationFrame(flushPendingFitScene) + } return } - const [cx, cz] = bounds.center - const [w, d] = bounds.size - // Use the longer horizontal extent to size the orbit radius so the whole - // footprint sits in view regardless of aspect ratio. - const maxExtent = Math.max(w, d) - const distance = Math.max(maxExtent * 1.4, 15) - const height = Math.max(maxExtent * 0.8, 10) - controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true) + pendingFitSceneRef.current = null + applyFitLookAt(plan.lookAt) } emitter.on('camera-controls:capture', handleNodeCapture) @@ -1287,6 +1331,35 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } }, [focusNode, isPreviewMode, isFirstPersonMode]) + // Apply a fit that arrived while first-person (orbit unmounted). Wait one + // frame so CameraControls can remount and attach its ref. + useEffect(() => { + if (isFirstPersonMode || isPreviewMode || !pendingFitSceneRef.current) return + + const frame = requestAnimationFrame(() => { + const plan = planFitSceneOnOrbitResume({ + isPreviewMode, + isFirstPersonMode: useEditor.getState().isFirstPersonMode, + hasControls: !!controls.current, + pending: pendingFitSceneRef.current, + }) + if (plan.action !== 'apply' || !controls.current) return + pendingFitSceneRef.current = null + const { lookAt } = plan + controls.current.setLookAt( + lookAt.eyeX, + lookAt.eyeY, + lookAt.eyeZ, + lookAt.targetX, + lookAt.targetY, + lookAt.targetZ, + true, + ) + }) + + return () => cancelAnimationFrame(frame) + }, [isFirstPersonMode, isPreviewMode]) + const onTransitionStart = useCallback(() => { cameraDraggingLifecycle.begin() }, [cameraDraggingLifecycle]) diff --git a/packages/editor/src/components/editor/fit-scene-framing.test.ts b/packages/editor/src/components/editor/fit-scene-framing.test.ts new file mode 100644 index 000000000..056309ce4 --- /dev/null +++ b/packages/editor/src/components/editor/fit-scene-framing.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + computeFitSceneLookAt, + type PendingFitScene, + planFitSceneOnEvent, + planFitSceneOnOrbitResume, +} from './fit-scene-framing' + +const sampleBounds = { + center: [10, 20] as [number, number], + size: [30, 40] as [number, number], +} + +describe('fit-scene framing', () => { + test('computes default look-at when bounds are null', () => { + expect(computeFitSceneLookAt(null)).toEqual({ + eyeX: 20, + eyeY: 20, + eyeZ: 20, + targetX: 0, + targetY: 0, + targetZ: 0, + }) + }) + + test('computes orbit look-at from scene bounds', () => { + const lookAt = computeFitSceneLookAt(sampleBounds) + // Longer extent is depth=40 → distance=56, height=32 + expect(lookAt).toEqual({ + eyeX: 10 + 56 * 0.7, + eyeY: 32, + eyeZ: 20 + 56 * 0.7, + targetX: 10, + targetY: 0, + targetZ: 20, + }) + }) + + test('applies immediately when orbit controls are live', () => { + const plan = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: false, + hasControls: true, + bounds: sampleBounds, + }) + + expect(plan).toEqual({ + action: 'apply', + lookAt: computeFitSceneLookAt(sampleBounds), + }) + }) + + test('ignores fit-scene while preview mode is active', () => { + const plan = planFitSceneOnEvent({ + isPreviewMode: true, + isFirstPersonMode: false, + hasControls: true, + bounds: sampleBounds, + }) + expect(plan).toEqual({ action: 'ignore' }) + }) + + test('queues fit-scene while first-person is active (scene-ready during FP)', () => { + const plan = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: true, + hasControls: false, + bounds: sampleBounds, + }) + + expect(plan).toEqual({ + action: 'queue', + pending: { bounds: sampleBounds }, + }) + }) + + test('queues fit-scene when orbit controls are not mounted yet', () => { + const plan = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: false, + hasControls: false, + bounds: sampleBounds, + }) + + expect(plan).toEqual({ + action: 'queue', + pending: { bounds: sampleBounds }, + }) + }) + + test('scene-ready during first-person then return to orbit applies the pending frame', () => { + // Mirrors the blocking lifecycle: fit arrives while FP is active, then + // orbit remounts and the queued frame must still apply. + let pending: PendingFitScene | null = null + + const duringFirstPerson = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: true, + hasControls: false, + bounds: sampleBounds, + }) + expect(duringFirstPerson.action).toBe('queue') + if (duringFirstPerson.action === 'queue') { + pending = duringFirstPerson.pending + } + + // Still in first-person: controls are not ready, keep the pending frame. + expect( + planFitSceneOnOrbitResume({ + isPreviewMode: false, + isFirstPersonMode: true, + hasControls: false, + pending, + }), + ).toEqual({ action: 'noop' }) + + // Leave first-person; orbit controls remount and can apply the frame. + const afterOrbitResume = planFitSceneOnOrbitResume({ + isPreviewMode: false, + isFirstPersonMode: false, + hasControls: true, + pending, + }) + + expect(afterOrbitResume).toEqual({ + action: 'apply', + lookAt: computeFitSceneLookAt(sampleBounds), + }) + }) + + test('latest fit-scene while first-person wins when orbit resumes', () => { + let pending: PendingFitScene | null = null + const first = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: true, + hasControls: false, + bounds: null, + }) + if (first.action === 'queue') pending = first.pending + + const secondBounds = { + center: [0, 0] as [number, number], + size: [10, 10] as [number, number], + } + const second = planFitSceneOnEvent({ + isPreviewMode: false, + isFirstPersonMode: true, + hasControls: false, + bounds: secondBounds, + }) + if (second.action === 'queue') pending = second.pending + + expect( + planFitSceneOnOrbitResume({ + isPreviewMode: false, + isFirstPersonMode: false, + hasControls: true, + pending, + }), + ).toEqual({ + action: 'apply', + lookAt: computeFitSceneLookAt(secondBounds), + }) + }) + + test('does not flush a pending frame into preview mode', () => { + expect( + planFitSceneOnOrbitResume({ + isPreviewMode: true, + isFirstPersonMode: false, + hasControls: true, + pending: { bounds: sampleBounds }, + }), + ).toEqual({ action: 'noop' }) + }) +}) diff --git a/packages/editor/src/components/editor/fit-scene-framing.ts b/packages/editor/src/components/editor/fit-scene-framing.ts new file mode 100644 index 000000000..9a12e6de8 --- /dev/null +++ b/packages/editor/src/components/editor/fit-scene-framing.ts @@ -0,0 +1,94 @@ +export type FitSceneBounds = { + center: [number, number] + size: [number, number] +} | null + +export type FitSceneLookAt = { + eyeX: number + eyeY: number + eyeZ: number + targetX: number + targetY: number + targetZ: number +} + +export type PendingFitScene = { + bounds: FitSceneBounds +} + +export type FitSceneEventPlan = + | { action: 'ignore' } + | { action: 'apply'; lookAt: FitSceneLookAt } + | { action: 'queue'; pending: PendingFitScene } + +export type FitSceneResumePlan = { action: 'noop' } | { action: 'apply'; lookAt: FitSceneLookAt } + +/** + * Compute the orbit look-at used by `camera-controls:fit-scene`. + * Null bounds restore the empty-editor default pose. + */ +export function computeFitSceneLookAt(bounds: FitSceneBounds): FitSceneLookAt { + if (!bounds) { + return { + eyeX: 20, + eyeY: 20, + eyeZ: 20, + targetX: 0, + targetY: 0, + targetZ: 0, + } + } + + const [cx, cz] = bounds.center + const [w, d] = bounds.size + // Use the longer horizontal extent to size the orbit radius so the whole + // footprint sits in view regardless of aspect ratio. + const maxExtent = Math.max(w, d) + const distance = Math.max(maxExtent * 1.4, 15) + const height = Math.max(maxExtent * 0.8, 10) + return { + eyeX: cx + distance * 0.7, + eyeY: height, + eyeZ: cz + distance * 0.7, + targetX: cx, + targetY: 0, + targetZ: cz, + } +} + +/** + * Decide what to do when a fit-scene event arrives. + * First-person (and briefly-unmounted orbit controls) must queue the frame + * so it can apply once orbit is back. + */ +export function planFitSceneOnEvent(input: { + isPreviewMode: boolean + isFirstPersonMode: boolean + hasControls: boolean + bounds: FitSceneBounds +}): FitSceneEventPlan { + if (input.isPreviewMode) return { action: 'ignore' } + + const pending: PendingFitScene = { bounds: input.bounds } + if (input.isFirstPersonMode || !input.hasControls) { + return { action: 'queue', pending } + } + + return { action: 'apply', lookAt: computeFitSceneLookAt(input.bounds) } +} + +/** + * Flush a queued load frame once orbit controls can apply it. + */ +export function planFitSceneOnOrbitResume(input: { + isPreviewMode: boolean + isFirstPersonMode: boolean + hasControls: boolean + pending: PendingFitScene | null +}): FitSceneResumePlan { + if (!input.pending || input.isPreviewMode || input.isFirstPersonMode || !input.hasControls) { + return { action: 'noop' } + } + + return { action: 'apply', lookAt: computeFitSceneLookAt(input.pending.bounds) } +} 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 8e8b2bf8f..a3c43c2e8 100644 --- a/packages/editor/src/hooks/use-auto-frame.wiring.test.ts +++ b/packages/editor/src/hooks/use-auto-frame.wiring.test.ts @@ -32,13 +32,23 @@ describe('camera auto-frame wiring (shipped sources)', () => { 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).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('fit-scene queues while first-person and flushes on orbit resume', () => { + const controls = readShipped('components/editor/custom-camera-controls.tsx') + expect(controls).toContain('pendingFitSceneRef') + expect(controls).toContain('planFitSceneOnEvent') + expect(controls).toContain('planFitSceneOnOrbitResume') + expect(controls).toContain("from './fit-scene-framing'") + }) + 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')