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
26 changes: 19 additions & 7 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ function useFirstPersonCameraPoseRestore(
}

export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl | null>(null)
// eslint-disable-next-line no-console
const controls = useRef<CameraControlsImpl | null>(null)
const pendingAppliedPose = useRef<CameraPoseApplicationPlan | null>(null)
const activePoseInterpolation = useRef<{
camera: Camera
Expand Down Expand Up @@ -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<AnyNodeId | null>(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)
Expand All @@ -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])
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 All @@ -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,
Expand Down Expand Up @@ -1214,6 +1217,8 @@ export default function Editor({

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

useAutoFrame()

const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
Expand Down Expand Up @@ -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 &&
Expand Down