Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -527,20 +526,37 @@ export const CustomCameraControls = () => {
}
}, [])

const previousLevelIdRef = useRef<AnyNodeId | null>(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)
: 0
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load framing skipped after first-person

Medium Severity

A scene that becomes ready while first-person is active never receives the restored orbit frame. The new fit-scene re-emit is ignored in that mode, and the rewritten firstLoad path then skips both the default pose and the level-follow pan when first-person ends, so the remounted orbit camera stays on the unframed default.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 38f224f. Configure here.

}
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.
Expand Down
16 changes: 16 additions & 0 deletions packages/editor/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Icon } from '@iconify/react'
import {
acquireSceneReadOnlyLease,
emitter,
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getSceneMaterialIdFromRef,
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -1246,6 +1249,8 @@ function EditorContent({

useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })

useAutoFrame()

const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
Expand Down Expand Up @@ -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 &&
Expand Down
48 changes: 48 additions & 0 deletions packages/editor/src/hooks/use-auto-frame.wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading