Skip to content

Commit e7adddc

Browse files
committed
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.
1 parent 66a5520 commit e7adddc

4 files changed

Lines changed: 366 additions & 13 deletions

File tree

packages/editor/src/components/editor/custom-camera-controls.tsx

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ import {
4040
useMovingNode,
4141
} from '../../store/use-interaction-scope'
4242
import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
43+
import {
44+
type PendingFitScene,
45+
planFitSceneOnEvent,
46+
planFitSceneOnOrbitResume,
47+
} from './fit-scene-framing'
4348

4449
const currentTarget = new Vector3()
4550
const tempBox = new Box3()
@@ -376,6 +381,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
376381
)
377382
const currentLevelId = selection.levelId
378383
const firstLoad = useRef(true)
384+
// Survives first-person (orbit unmounted) so scene-ready fit still applies
385+
// once CameraControls remount.
386+
const pendingFitSceneRef = useRef<PendingFitScene | null>(null)
379387
const maxPolarAngle =
380388
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
381389

@@ -1251,21 +1259,57 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
12511259
focusNode(nodeId)
12521260
}
12531261

1262+
const applyFitLookAt = (lookAt: {
1263+
eyeX: number
1264+
eyeY: number
1265+
eyeZ: number
1266+
targetX: number
1267+
targetY: number
1268+
targetZ: number
1269+
}) => {
1270+
if (!controls.current) return false
1271+
controls.current.setLookAt(
1272+
lookAt.eyeX,
1273+
lookAt.eyeY,
1274+
lookAt.eyeZ,
1275+
lookAt.targetX,
1276+
lookAt.targetY,
1277+
lookAt.targetZ,
1278+
true,
1279+
)
1280+
return true
1281+
}
1282+
1283+
const flushPendingFitScene = () => {
1284+
const plan = planFitSceneOnOrbitResume({
1285+
isPreviewMode,
1286+
isFirstPersonMode: useEditor.getState().isFirstPersonMode,
1287+
hasControls: !!controls.current,
1288+
pending: pendingFitSceneRef.current,
1289+
})
1290+
if (plan.action !== 'apply') return
1291+
if (!applyFitLookAt(plan.lookAt)) return
1292+
pendingFitSceneRef.current = null
1293+
}
1294+
12541295
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
1255-
if (isFirstPersonMode || !controls.current || isPreviewMode) return
1256-
if (!bounds) {
1257-
// Restore default framing pose when no bounds were computed.
1258-
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
1296+
const plan = planFitSceneOnEvent({
1297+
isPreviewMode,
1298+
isFirstPersonMode,
1299+
hasControls: !!controls.current,
1300+
bounds: bounds ?? null,
1301+
})
1302+
if (plan.action === 'ignore') return
1303+
if (plan.action === 'queue') {
1304+
pendingFitSceneRef.current = plan.pending
1305+
// Orbit path with a not-yet-attached ref: retry next frame.
1306+
if (!isFirstPersonMode && !isPreviewMode) {
1307+
requestAnimationFrame(flushPendingFitScene)
1308+
}
12591309
return
12601310
}
1261-
const [cx, cz] = bounds.center
1262-
const [w, d] = bounds.size
1263-
// Use the longer horizontal extent to size the orbit radius so the whole
1264-
// footprint sits in view regardless of aspect ratio.
1265-
const maxExtent = Math.max(w, d)
1266-
const distance = Math.max(maxExtent * 1.4, 15)
1267-
const height = Math.max(maxExtent * 0.8, 10)
1268-
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
1311+
pendingFitSceneRef.current = null
1312+
applyFitLookAt(plan.lookAt)
12691313
}
12701314

12711315
emitter.on('camera-controls:capture', handleNodeCapture)
@@ -1287,6 +1331,35 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
12871331
}
12881332
}, [focusNode, isPreviewMode, isFirstPersonMode])
12891333

1334+
// Apply a fit that arrived while first-person (orbit unmounted). Wait one
1335+
// frame so CameraControls can remount and attach its ref.
1336+
useEffect(() => {
1337+
if (isFirstPersonMode || isPreviewMode || !pendingFitSceneRef.current) return
1338+
1339+
const frame = requestAnimationFrame(() => {
1340+
const plan = planFitSceneOnOrbitResume({
1341+
isPreviewMode,
1342+
isFirstPersonMode: useEditor.getState().isFirstPersonMode,
1343+
hasControls: !!controls.current,
1344+
pending: pendingFitSceneRef.current,
1345+
})
1346+
if (plan.action !== 'apply' || !controls.current) return
1347+
pendingFitSceneRef.current = null
1348+
const { lookAt } = plan
1349+
controls.current.setLookAt(
1350+
lookAt.eyeX,
1351+
lookAt.eyeY,
1352+
lookAt.eyeZ,
1353+
lookAt.targetX,
1354+
lookAt.targetY,
1355+
lookAt.targetZ,
1356+
true,
1357+
)
1358+
})
1359+
1360+
return () => cancelAnimationFrame(frame)
1361+
}, [isFirstPersonMode, isPreviewMode])
1362+
12901363
const onTransitionStart = useCallback(() => {
12911364
cameraDraggingLifecycle.begin()
12921365
}, [cameraDraggingLifecycle])
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import {
3+
computeFitSceneLookAt,
4+
type PendingFitScene,
5+
planFitSceneOnEvent,
6+
planFitSceneOnOrbitResume,
7+
} from './fit-scene-framing'
8+
9+
const sampleBounds = {
10+
center: [10, 20] as [number, number],
11+
size: [30, 40] as [number, number],
12+
}
13+
14+
describe('fit-scene framing', () => {
15+
test('computes default look-at when bounds are null', () => {
16+
expect(computeFitSceneLookAt(null)).toEqual({
17+
eyeX: 20,
18+
eyeY: 20,
19+
eyeZ: 20,
20+
targetX: 0,
21+
targetY: 0,
22+
targetZ: 0,
23+
})
24+
})
25+
26+
test('computes orbit look-at from scene bounds', () => {
27+
const lookAt = computeFitSceneLookAt(sampleBounds)
28+
// Longer extent is depth=40 → distance=56, height=32
29+
expect(lookAt).toEqual({
30+
eyeX: 10 + 56 * 0.7,
31+
eyeY: 32,
32+
eyeZ: 20 + 56 * 0.7,
33+
targetX: 10,
34+
targetY: 0,
35+
targetZ: 20,
36+
})
37+
})
38+
39+
test('applies immediately when orbit controls are live', () => {
40+
const plan = planFitSceneOnEvent({
41+
isPreviewMode: false,
42+
isFirstPersonMode: false,
43+
hasControls: true,
44+
bounds: sampleBounds,
45+
})
46+
47+
expect(plan).toEqual({
48+
action: 'apply',
49+
lookAt: computeFitSceneLookAt(sampleBounds),
50+
})
51+
})
52+
53+
test('ignores fit-scene while preview mode is active', () => {
54+
const plan = planFitSceneOnEvent({
55+
isPreviewMode: true,
56+
isFirstPersonMode: false,
57+
hasControls: true,
58+
bounds: sampleBounds,
59+
})
60+
expect(plan).toEqual({ action: 'ignore' })
61+
})
62+
63+
test('queues fit-scene while first-person is active (scene-ready during FP)', () => {
64+
const plan = planFitSceneOnEvent({
65+
isPreviewMode: false,
66+
isFirstPersonMode: true,
67+
hasControls: false,
68+
bounds: sampleBounds,
69+
})
70+
71+
expect(plan).toEqual({
72+
action: 'queue',
73+
pending: { bounds: sampleBounds },
74+
})
75+
})
76+
77+
test('queues fit-scene when orbit controls are not mounted yet', () => {
78+
const plan = planFitSceneOnEvent({
79+
isPreviewMode: false,
80+
isFirstPersonMode: false,
81+
hasControls: false,
82+
bounds: sampleBounds,
83+
})
84+
85+
expect(plan).toEqual({
86+
action: 'queue',
87+
pending: { bounds: sampleBounds },
88+
})
89+
})
90+
91+
test('scene-ready during first-person then return to orbit applies the pending frame', () => {
92+
// Mirrors the blocking lifecycle: fit arrives while FP is active, then
93+
// orbit remounts and the queued frame must still apply.
94+
let pending: PendingFitScene | null = null
95+
96+
const duringFirstPerson = planFitSceneOnEvent({
97+
isPreviewMode: false,
98+
isFirstPersonMode: true,
99+
hasControls: false,
100+
bounds: sampleBounds,
101+
})
102+
expect(duringFirstPerson.action).toBe('queue')
103+
if (duringFirstPerson.action === 'queue') {
104+
pending = duringFirstPerson.pending
105+
}
106+
107+
// Still in first-person: controls are not ready, keep the pending frame.
108+
expect(
109+
planFitSceneOnOrbitResume({
110+
isPreviewMode: false,
111+
isFirstPersonMode: true,
112+
hasControls: false,
113+
pending,
114+
}),
115+
).toEqual({ action: 'noop' })
116+
117+
// Leave first-person; orbit controls remount and can apply the frame.
118+
const afterOrbitResume = planFitSceneOnOrbitResume({
119+
isPreviewMode: false,
120+
isFirstPersonMode: false,
121+
hasControls: true,
122+
pending,
123+
})
124+
125+
expect(afterOrbitResume).toEqual({
126+
action: 'apply',
127+
lookAt: computeFitSceneLookAt(sampleBounds),
128+
})
129+
})
130+
131+
test('latest fit-scene while first-person wins when orbit resumes', () => {
132+
let pending: PendingFitScene | null = null
133+
const first = planFitSceneOnEvent({
134+
isPreviewMode: false,
135+
isFirstPersonMode: true,
136+
hasControls: false,
137+
bounds: null,
138+
})
139+
if (first.action === 'queue') pending = first.pending
140+
141+
const secondBounds = {
142+
center: [0, 0] as [number, number],
143+
size: [10, 10] as [number, number],
144+
}
145+
const second = planFitSceneOnEvent({
146+
isPreviewMode: false,
147+
isFirstPersonMode: true,
148+
hasControls: false,
149+
bounds: secondBounds,
150+
})
151+
if (second.action === 'queue') pending = second.pending
152+
153+
expect(
154+
planFitSceneOnOrbitResume({
155+
isPreviewMode: false,
156+
isFirstPersonMode: false,
157+
hasControls: true,
158+
pending,
159+
}),
160+
).toEqual({
161+
action: 'apply',
162+
lookAt: computeFitSceneLookAt(secondBounds),
163+
})
164+
})
165+
166+
test('does not flush a pending frame into preview mode', () => {
167+
expect(
168+
planFitSceneOnOrbitResume({
169+
isPreviewMode: true,
170+
isFirstPersonMode: false,
171+
hasControls: true,
172+
pending: { bounds: sampleBounds },
173+
}),
174+
).toEqual({ action: 'noop' })
175+
})
176+
})
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export type FitSceneBounds = {
2+
center: [number, number]
3+
size: [number, number]
4+
} | null
5+
6+
export type FitSceneLookAt = {
7+
eyeX: number
8+
eyeY: number
9+
eyeZ: number
10+
targetX: number
11+
targetY: number
12+
targetZ: number
13+
}
14+
15+
export type PendingFitScene = {
16+
bounds: FitSceneBounds
17+
}
18+
19+
export type FitSceneEventPlan =
20+
| { action: 'ignore' }
21+
| { action: 'apply'; lookAt: FitSceneLookAt }
22+
| { action: 'queue'; pending: PendingFitScene }
23+
24+
export type FitSceneResumePlan = { action: 'noop' } | { action: 'apply'; lookAt: FitSceneLookAt }
25+
26+
/**
27+
* Compute the orbit look-at used by `camera-controls:fit-scene`.
28+
* Null bounds restore the empty-editor default pose.
29+
*/
30+
export function computeFitSceneLookAt(bounds: FitSceneBounds): FitSceneLookAt {
31+
if (!bounds) {
32+
return {
33+
eyeX: 20,
34+
eyeY: 20,
35+
eyeZ: 20,
36+
targetX: 0,
37+
targetY: 0,
38+
targetZ: 0,
39+
}
40+
}
41+
42+
const [cx, cz] = bounds.center
43+
const [w, d] = bounds.size
44+
// Use the longer horizontal extent to size the orbit radius so the whole
45+
// footprint sits in view regardless of aspect ratio.
46+
const maxExtent = Math.max(w, d)
47+
const distance = Math.max(maxExtent * 1.4, 15)
48+
const height = Math.max(maxExtent * 0.8, 10)
49+
return {
50+
eyeX: cx + distance * 0.7,
51+
eyeY: height,
52+
eyeZ: cz + distance * 0.7,
53+
targetX: cx,
54+
targetY: 0,
55+
targetZ: cz,
56+
}
57+
}
58+
59+
/**
60+
* Decide what to do when a fit-scene event arrives.
61+
* First-person (and briefly-unmounted orbit controls) must queue the frame
62+
* so it can apply once orbit is back.
63+
*/
64+
export function planFitSceneOnEvent(input: {
65+
isPreviewMode: boolean
66+
isFirstPersonMode: boolean
67+
hasControls: boolean
68+
bounds: FitSceneBounds
69+
}): FitSceneEventPlan {
70+
if (input.isPreviewMode) return { action: 'ignore' }
71+
72+
const pending: PendingFitScene = { bounds: input.bounds }
73+
if (input.isFirstPersonMode || !input.hasControls) {
74+
return { action: 'queue', pending }
75+
}
76+
77+
return { action: 'apply', lookAt: computeFitSceneLookAt(input.bounds) }
78+
}
79+
80+
/**
81+
* Flush a queued load frame once orbit controls can apply it.
82+
*/
83+
export function planFitSceneOnOrbitResume(input: {
84+
isPreviewMode: boolean
85+
isFirstPersonMode: boolean
86+
hasControls: boolean
87+
pending: PendingFitScene | null
88+
}): FitSceneResumePlan {
89+
if (!input.pending || input.isPreviewMode || input.isFirstPersonMode || !input.hasControls) {
90+
return { action: 'noop' }
91+
}
92+
93+
return { action: 'apply', lookAt: computeFitSceneLookAt(input.pending.bounds) }
94+
}

0 commit comments

Comments
 (0)