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
125 changes: 107 additions & 18 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<PendingFitScene | null>(null)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE

Expand Down Expand Up @@ -515,10 +523,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
}
Expand All @@ -528,20 +535,37 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
}
}, [])

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
Comment thread
cursor[bot] marked this conversation as resolved.
}
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 Expand Up @@ -1235,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)
Expand All @@ -1271,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])
Expand Down
176 changes: 176 additions & 0 deletions packages/editor/src/components/editor/fit-scene-framing.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
Loading
Loading