@octanejs/three 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -16
- package/UPSTREAM.md +52 -3
- package/package.json +16 -8
- package/src/config.ts +5 -0
- package/src/core/catalogue.ts +31 -2
- package/src/core/driver.ts +299 -15
- package/src/core/hooks.ts +4 -1
- package/src/core/index.ts +16 -0
- package/src/core/portal.ts +187 -82
- package/src/core/root.ts +180 -10
- package/src/core/store.ts +34 -2
- package/src/index.ts +25 -0
- package/src/intrinsics.ts +20 -0
- package/src/scheduling.ts +83 -0
- package/src/web/DOMRegion.ts +58 -0
- package/src/web/dom-region.ts +104 -0
package/src/core/portal.ts
CHANGED
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
import type { ComputeFunction, EventManager } from './events.js';
|
|
26
26
|
import {
|
|
27
27
|
createPortalStore,
|
|
28
|
+
readRootStoreRenderSnapshot,
|
|
28
29
|
RootStoreContext,
|
|
30
|
+
RootStoreRenderSnapshotContext,
|
|
29
31
|
updateCamera,
|
|
30
32
|
type RootState,
|
|
31
33
|
type RootStore,
|
|
34
|
+
type RootStoreRenderSnapshot,
|
|
32
35
|
type Size,
|
|
33
36
|
} from './store.js';
|
|
34
37
|
|
|
@@ -49,24 +52,56 @@ interface PortalProps {
|
|
|
49
52
|
readonly state?: InjectState;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
interface
|
|
53
|
-
readonly
|
|
54
|
-
readonly
|
|
55
|
+
interface PortalPlan {
|
|
56
|
+
readonly parent: RootState;
|
|
57
|
+
readonly inject: InjectState;
|
|
58
|
+
readonly state: RootState;
|
|
59
|
+
readonly view: RootStoreRenderSnapshot;
|
|
60
|
+
readonly injectedKeys: ReadonlySet<string>;
|
|
61
|
+
readonly injectedEventKeys: ReadonlySet<string>;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
interface PortalLayer {
|
|
58
65
|
readonly store: RootStore;
|
|
59
66
|
readonly target: ThreePortalTargetBinding;
|
|
60
|
-
readonly
|
|
61
|
-
readonly
|
|
67
|
+
readonly stage: (parent: RootState, state: InjectState) => PortalPlan;
|
|
68
|
+
readonly commit: (plan: PortalPlan) => void;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
const
|
|
65
|
-
const
|
|
71
|
+
const EMPTY_INJECT_STATE = Object.freeze({}) as InjectState;
|
|
72
|
+
const PORTAL_STATE = Symbol('octane.three.portal.state');
|
|
73
|
+
const PORTAL_STATE_COMMIT = Symbol('octane.three.portal.state-commit');
|
|
66
74
|
const PORTAL_LAYER = Symbol('octane.three.portal.layer');
|
|
67
|
-
const PORTAL_CAMERA_COMMIT = Symbol('octane.three.portal.camera-commit');
|
|
68
75
|
const PORTAL_SUBSCRIPTION = Symbol('octane.three.portal.subscription');
|
|
69
76
|
|
|
77
|
+
function shallowEqual(left: object, right: object): boolean {
|
|
78
|
+
const leftKeys = Object.keys(left);
|
|
79
|
+
const rightKeys = Object.keys(right);
|
|
80
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
81
|
+
return leftKeys.every(
|
|
82
|
+
(key) =>
|
|
83
|
+
Object.prototype.hasOwnProperty.call(right, key) &&
|
|
84
|
+
Object.is((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reuseShallow<T extends object>(previous: T, next: T): T {
|
|
89
|
+
return shallowEqual(previous, next) ? previous : next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resetRemovedKeys(
|
|
93
|
+
target: Record<string, unknown>,
|
|
94
|
+
previousKeys: ReadonlySet<string>,
|
|
95
|
+
next: Readonly<Record<string, unknown>>,
|
|
96
|
+
fallback: Readonly<Record<string, unknown>>,
|
|
97
|
+
): void {
|
|
98
|
+
for (const key of previousKeys) {
|
|
99
|
+
if (Object.prototype.hasOwnProperty.call(next, key)) continue;
|
|
100
|
+
if (Object.prototype.hasOwnProperty.call(fallback, key)) target[key] = fallback[key];
|
|
101
|
+
else delete target[key];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
70
105
|
function getWorldMatrixSnapshot(object: THREE.Object3D): THREE.Matrix4 {
|
|
71
106
|
if (object.matrixWorldAutoUpdate === false) return object.matrixWorld.clone();
|
|
72
107
|
const local = object.matrixAutoUpdate
|
|
@@ -98,82 +133,154 @@ function snapshotCamera<T extends THREE.Camera>(camera: T): T {
|
|
|
98
133
|
function createLayer(
|
|
99
134
|
previousRoot: RootStore,
|
|
100
135
|
container: THREE.Object3D,
|
|
136
|
+
parentState: RootState,
|
|
101
137
|
state: InjectState,
|
|
102
|
-
options: PortalOptions,
|
|
103
138
|
): PortalLayer {
|
|
104
|
-
const { events: _events, size: _size, ...
|
|
139
|
+
const { events: _events, size: _size, ...initialRest } = state;
|
|
105
140
|
const raycaster = new THREE.Raycaster();
|
|
106
141
|
const pointer = new THREE.Vector2();
|
|
107
142
|
const target = createThreePortalTargetBinding(container);
|
|
108
143
|
const store = createPortalStore({
|
|
109
|
-
...
|
|
110
|
-
...
|
|
144
|
+
...parentState,
|
|
145
|
+
...initialRest,
|
|
111
146
|
// A managed portal target may be reconstructed without changing the
|
|
112
147
|
// authored target handle. The driver advances the shared binding to the
|
|
113
148
|
// accepted replacement, and subsequent parent-state mirrors retain it.
|
|
114
149
|
scene: target.current as THREE.Scene,
|
|
115
150
|
} as RootState);
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
let injectedKeys = new Set<string>();
|
|
152
|
+
let injectedEventKeys = new Set<string>();
|
|
153
|
+
let cameraCommit:
|
|
154
|
+
| {
|
|
155
|
+
readonly camera: RootState['camera'];
|
|
156
|
+
readonly width: number;
|
|
157
|
+
readonly height: number;
|
|
158
|
+
readonly manual: boolean | undefined;
|
|
159
|
+
}
|
|
160
|
+
| undefined;
|
|
161
|
+
const setEvents = (events: Partial<EventManager<any>>) => {
|
|
118
162
|
store.setState((local) => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const size = {
|
|
122
|
-
...parent.size,
|
|
123
|
-
...currentOptions.size,
|
|
124
|
-
} as Size;
|
|
125
|
-
viewport = parent.viewport.getCurrentViewport(
|
|
126
|
-
snapshotCamera(local.camera),
|
|
127
|
-
new THREE.Vector3(),
|
|
128
|
-
size,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
return {
|
|
132
|
-
...parent,
|
|
133
|
-
...local,
|
|
134
|
-
scene: target.current as THREE.Scene,
|
|
135
|
-
raycaster,
|
|
136
|
-
pointer,
|
|
137
|
-
mouse: pointer,
|
|
138
|
-
previousRoot,
|
|
139
|
-
events: {
|
|
140
|
-
...parent.events,
|
|
141
|
-
...local.events,
|
|
142
|
-
...currentOptions.events,
|
|
143
|
-
} as EventManager<any>,
|
|
144
|
-
size: {
|
|
145
|
-
...parent.size,
|
|
146
|
-
...currentOptions.size,
|
|
147
|
-
},
|
|
148
|
-
viewport: {
|
|
149
|
-
...parent.viewport,
|
|
150
|
-
...viewport,
|
|
151
|
-
},
|
|
152
|
-
setEvents(events: Partial<EventManager<any>>) {
|
|
153
|
-
store.setState((value) => ({
|
|
154
|
-
events: { ...value.events, ...events },
|
|
155
|
-
}));
|
|
156
|
-
},
|
|
157
|
-
};
|
|
163
|
+
const next = { ...local.events, ...events } as EventManager<any>;
|
|
164
|
+
return shallowEqual(local.events, next) ? local : { events: next };
|
|
158
165
|
});
|
|
159
166
|
};
|
|
160
|
-
|
|
167
|
+
|
|
168
|
+
const stage = (parent: RootState, currentState: InjectState): PortalPlan => {
|
|
169
|
+
const { events, size, ...rest } = currentState;
|
|
170
|
+
const nextInjectedKeys = new Set(Object.keys(rest));
|
|
171
|
+
const nextInjectedEventKeys = new Set(Object.keys(events ?? {}));
|
|
161
172
|
const local = store.getState();
|
|
173
|
+
const inherited = { ...parent, ...local } as RootState & Record<string, unknown>;
|
|
174
|
+
resetRemovedKeys(
|
|
175
|
+
inherited,
|
|
176
|
+
injectedKeys,
|
|
177
|
+
rest as Readonly<Record<string, unknown>>,
|
|
178
|
+
parent as unknown as Readonly<Record<string, unknown>>,
|
|
179
|
+
);
|
|
180
|
+
Object.assign(inherited, rest);
|
|
181
|
+
|
|
182
|
+
const localEvents = { ...local.events } as Record<string, unknown>;
|
|
183
|
+
resetRemovedKeys(
|
|
184
|
+
localEvents,
|
|
185
|
+
injectedEventKeys,
|
|
186
|
+
(events ?? {}) as Readonly<Record<string, unknown>>,
|
|
187
|
+
parent.events as unknown as Readonly<Record<string, unknown>>,
|
|
188
|
+
);
|
|
189
|
+
const nextEvents = reuseShallow(local.events, {
|
|
190
|
+
...parent.events,
|
|
191
|
+
...localEvents,
|
|
192
|
+
...events,
|
|
193
|
+
} as EventManager<any>);
|
|
194
|
+
const nextSize = reuseShallow(local.size, { ...parent.size, ...size } as Size);
|
|
195
|
+
let viewport: Partial<RootState['viewport']> | undefined;
|
|
196
|
+
if (inherited.camera != null && size !== undefined) {
|
|
197
|
+
viewport = parent.viewport.getCurrentViewport(
|
|
198
|
+
snapshotCamera(inherited.camera),
|
|
199
|
+
new THREE.Vector3(),
|
|
200
|
+
nextSize,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const nextViewport = reuseShallow(local.viewport, {
|
|
204
|
+
...parent.viewport,
|
|
205
|
+
...viewport,
|
|
206
|
+
});
|
|
207
|
+
const candidate = {
|
|
208
|
+
...inherited,
|
|
209
|
+
set: local.set,
|
|
210
|
+
get: local.get,
|
|
211
|
+
scene: target.current as THREE.Scene,
|
|
212
|
+
raycaster,
|
|
213
|
+
pointer,
|
|
214
|
+
mouse: pointer,
|
|
215
|
+
previousRoot,
|
|
216
|
+
events: nextEvents,
|
|
217
|
+
size: nextSize,
|
|
218
|
+
viewport: nextViewport,
|
|
219
|
+
setEvents,
|
|
220
|
+
} as RootState;
|
|
221
|
+
const next = shallowEqual(local, candidate) ? local : candidate;
|
|
222
|
+
return {
|
|
223
|
+
parent,
|
|
224
|
+
inject: currentState,
|
|
225
|
+
state: next,
|
|
226
|
+
view: { store, current: next },
|
|
227
|
+
injectedKeys: nextInjectedKeys,
|
|
228
|
+
injectedEventKeys: nextInjectedEventKeys,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
const commitCamera = (plan: PortalPlan) => {
|
|
232
|
+
const camera = plan.state.camera;
|
|
233
|
+
if (camera == null || plan.inject.size === undefined || camera === plan.parent.camera) {
|
|
234
|
+
cameraCommit = undefined;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const size = {
|
|
238
|
+
...plan.parent.size,
|
|
239
|
+
...plan.inject.size,
|
|
240
|
+
} as Size;
|
|
162
241
|
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
242
|
+
cameraCommit?.camera === camera &&
|
|
243
|
+
cameraCommit.width === size.width &&
|
|
244
|
+
cameraCommit.height === size.height &&
|
|
245
|
+
cameraCommit.manual === camera.manual
|
|
166
246
|
) {
|
|
167
247
|
return;
|
|
168
248
|
}
|
|
169
|
-
updateCamera(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
249
|
+
updateCamera(camera, size);
|
|
250
|
+
cameraCommit = {
|
|
251
|
+
camera,
|
|
252
|
+
width: size.width,
|
|
253
|
+
height: size.height,
|
|
254
|
+
manual: camera.manual,
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
const commit = (plan: PortalPlan) => {
|
|
258
|
+
try {
|
|
259
|
+
// The driver advances the binding during the accepted host commit.
|
|
260
|
+
// The staged state is attempt-owned, so reasserting that target here
|
|
261
|
+
// cannot leak through a render that preparation rejects.
|
|
262
|
+
plan.state.scene = target.current as THREE.Scene;
|
|
263
|
+
commitCamera(plan);
|
|
264
|
+
if (store.getState() !== plan.state) store.setState(plan.state, true);
|
|
265
|
+
injectedKeys = new Set(plan.injectedKeys);
|
|
266
|
+
injectedEventKeys = new Set(plan.injectedEventKeys);
|
|
267
|
+
} finally {
|
|
268
|
+
plan.view.current = null;
|
|
269
|
+
}
|
|
173
270
|
};
|
|
174
271
|
|
|
175
|
-
|
|
176
|
-
|
|
272
|
+
// A new layer has no previously accepted state that can be exposed. Seed its
|
|
273
|
+
// private store during construction so imperative `useStore().getState()`
|
|
274
|
+
// reads are complete even before the first accepted insertion commit. Camera
|
|
275
|
+
// mutations remain deferred until acceptance.
|
|
276
|
+
const initialPlan = stage(parentState, state);
|
|
277
|
+
initialPlan.state.scene = target.current as THREE.Scene;
|
|
278
|
+
store.setState(initialPlan.state, true);
|
|
279
|
+
injectedKeys = new Set(initialPlan.injectedKeys);
|
|
280
|
+
injectedEventKeys = new Set(initialPlan.injectedEventKeys);
|
|
281
|
+
initialPlan.view.current = null;
|
|
282
|
+
|
|
283
|
+
return { store, target, stage, commit };
|
|
177
284
|
}
|
|
178
285
|
|
|
179
286
|
const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
|
|
@@ -186,32 +293,28 @@ const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
|
|
|
186
293
|
if (activeStore === null) {
|
|
187
294
|
throw new Error('R3F: createPortal can only be used within the Canvas component!');
|
|
188
295
|
}
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
optionsRef.current = options;
|
|
194
|
-
},
|
|
195
|
-
[options.events, options.size],
|
|
196
|
-
PORTAL_OPTIONS_COMMIT,
|
|
197
|
-
);
|
|
296
|
+
const parentView = useContext(RootStoreRenderSnapshotContext);
|
|
297
|
+
const parentState = readRootStoreRenderSnapshot(activeStore, parentView);
|
|
298
|
+
const state = props.state ?? EMPTY_INJECT_STATE;
|
|
299
|
+
const stateRef = useRef<InjectState>(state, PORTAL_STATE);
|
|
198
300
|
const layer = useMemo(
|
|
199
|
-
() => createLayer(activeStore, props.container,
|
|
301
|
+
() => createLayer(activeStore, props.container, parentState, state),
|
|
200
302
|
[activeStore, props.container],
|
|
201
303
|
PORTAL_LAYER,
|
|
202
304
|
);
|
|
305
|
+
const plan = layer.stage(parentState, state);
|
|
203
306
|
useInsertionEffect(
|
|
204
307
|
() => {
|
|
205
|
-
layer.
|
|
308
|
+
layer.commit(plan);
|
|
309
|
+
stateRef.current = state;
|
|
206
310
|
},
|
|
207
|
-
|
|
208
|
-
|
|
311
|
+
null,
|
|
312
|
+
PORTAL_STATE_COMMIT,
|
|
209
313
|
);
|
|
210
314
|
useLayoutEffect(
|
|
211
315
|
() => {
|
|
212
|
-
return activeStore.subscribe((
|
|
213
|
-
layer.
|
|
214
|
-
layer.commitCamera(state, optionsRef.current);
|
|
316
|
+
return activeStore.subscribe((parent) => {
|
|
317
|
+
layer.commit(layer.stage(parent, stateRef.current));
|
|
215
318
|
});
|
|
216
319
|
},
|
|
217
320
|
[activeStore, layer],
|
|
@@ -219,9 +322,11 @@ const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
|
|
|
219
322
|
);
|
|
220
323
|
|
|
221
324
|
return universalContext(RootStoreContext, layer.store, () =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
325
|
+
universalContext(RootStoreRenderSnapshotContext, plan.view, () =>
|
|
326
|
+
createUniversalPortal(
|
|
327
|
+
props.children,
|
|
328
|
+
createThreePortalTarget(props.container, layer.store, layer.target),
|
|
329
|
+
),
|
|
225
330
|
),
|
|
226
331
|
);
|
|
227
332
|
});
|
package/src/core/root.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type RootState,
|
|
39
39
|
type RootStore,
|
|
40
40
|
type Size,
|
|
41
|
+
type XRManager,
|
|
41
42
|
} from './store.js';
|
|
42
43
|
import { createUniversalRoot } from 'octane/universal';
|
|
43
44
|
|
|
@@ -132,6 +133,10 @@ interface ThreeRootInternals<TCanvas extends CanvasLike> {
|
|
|
132
133
|
pendingRender: PendingRender | null;
|
|
133
134
|
lastConfiguredCamera?: CameraProps;
|
|
134
135
|
onCreated?: (state: RootState) => void;
|
|
136
|
+
contextLifecycleInitialized: boolean;
|
|
137
|
+
contextLifecycleCleanup: (() => void) | null;
|
|
138
|
+
xrInitialized: boolean;
|
|
139
|
+
xrManager: XRManager | null;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
interface RootRecord<TCanvas extends CanvasLike = CanvasLike> {
|
|
@@ -204,6 +209,147 @@ function disposeRenderer(renderer: Renderer | null | undefined): void {
|
|
|
204
209
|
}
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
interface ContextLifecycleTarget {
|
|
213
|
+
addEventListener(type: string, listener: (event: Event) => void): void;
|
|
214
|
+
removeEventListener(type: string, listener: (event: Event) => void): void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isContextLifecycleTarget(value: unknown): value is ContextLifecycleTarget {
|
|
218
|
+
const target = value as Partial<ContextLifecycleTarget> | null;
|
|
219
|
+
return (
|
|
220
|
+
target !== null &&
|
|
221
|
+
typeof target === 'object' &&
|
|
222
|
+
typeof target.addEventListener === 'function' &&
|
|
223
|
+
typeof target.removeEventListener === 'function'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function initializeContextLifecycle(internals: ThreeRootInternals<any>, renderer: Renderer): void {
|
|
228
|
+
if (internals.contextLifecycleInitialized) return;
|
|
229
|
+
const target = isContextLifecycleTarget(renderer.domElement)
|
|
230
|
+
? renderer.domElement
|
|
231
|
+
: isContextLifecycleTarget(internals.canvas)
|
|
232
|
+
? internals.canvas
|
|
233
|
+
: null;
|
|
234
|
+
if (target === null) {
|
|
235
|
+
internals.contextLifecycleInitialized = true;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const handleContextLoss = (event: Event) => {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
};
|
|
242
|
+
const handleContextRestore = () => {
|
|
243
|
+
if (!internals.disposed) internals.store.getState().invalidate();
|
|
244
|
+
};
|
|
245
|
+
target.addEventListener('webglcontextlost', handleContextLoss);
|
|
246
|
+
try {
|
|
247
|
+
target.addEventListener('webglcontextrestored', handleContextRestore);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
target.removeEventListener('webglcontextlost', handleContextLoss);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
internals.contextLifecycleCleanup = () => {
|
|
253
|
+
try {
|
|
254
|
+
target.removeEventListener('webglcontextlost', handleContextLoss);
|
|
255
|
+
} finally {
|
|
256
|
+
target.removeEventListener('webglcontextrestored', handleContextRestore);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
internals.contextLifecycleInitialized = true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function createXRManager(internals: ThreeRootInternals<any>, renderer: Renderer): XRManager {
|
|
263
|
+
let connected = false;
|
|
264
|
+
let frameCallback: ((timestamp: number, frame?: XRFrame) => void) | null = null;
|
|
265
|
+
|
|
266
|
+
const handleSessionChange = () => {
|
|
267
|
+
if (!connected || internals.disposed) return;
|
|
268
|
+
const xr = renderer.xr;
|
|
269
|
+
if (xr === undefined) return;
|
|
270
|
+
const presenting = xr.isPresenting === true;
|
|
271
|
+
xr.enabled = presenting;
|
|
272
|
+
if (presenting) {
|
|
273
|
+
if (frameCallback === null) {
|
|
274
|
+
let currentFrame!: (timestamp: number, frame?: XRFrame) => void;
|
|
275
|
+
currentFrame = (timestamp, frame) => {
|
|
276
|
+
if (!connected || internals.disposed || frameCallback !== currentFrame) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const state = internals.store.getState();
|
|
280
|
+
if (state.frameloop !== 'never') advance(timestamp, true, state, frame);
|
|
281
|
+
};
|
|
282
|
+
frameCallback = currentFrame;
|
|
283
|
+
}
|
|
284
|
+
xr.setAnimationLoop?.(frameCallback);
|
|
285
|
+
} else {
|
|
286
|
+
frameCallback = null;
|
|
287
|
+
xr.setAnimationLoop?.(null);
|
|
288
|
+
internals.store.getState().invalidate();
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
connect() {
|
|
294
|
+
if (connected || internals.disposed) return;
|
|
295
|
+
const xr = renderer.xr;
|
|
296
|
+
if (
|
|
297
|
+
xr === undefined ||
|
|
298
|
+
typeof xr.addEventListener !== 'function' ||
|
|
299
|
+
typeof xr.removeEventListener !== 'function'
|
|
300
|
+
) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
xr.addEventListener('sessionstart', handleSessionChange);
|
|
304
|
+
try {
|
|
305
|
+
xr.addEventListener('sessionend', handleSessionChange);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
xr.removeEventListener('sessionstart', handleSessionChange);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
connected = true;
|
|
311
|
+
},
|
|
312
|
+
disconnect() {
|
|
313
|
+
const xr = renderer.xr;
|
|
314
|
+
const wasConnected = connected;
|
|
315
|
+
connected = false;
|
|
316
|
+
frameCallback = null;
|
|
317
|
+
if (xr === undefined) return;
|
|
318
|
+
try {
|
|
319
|
+
if (wasConnected && typeof xr.removeEventListener === 'function') {
|
|
320
|
+
xr.removeEventListener('sessionstart', handleSessionChange);
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
try {
|
|
324
|
+
if (wasConnected && typeof xr.removeEventListener === 'function') {
|
|
325
|
+
xr.removeEventListener('sessionend', handleSessionChange);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
try {
|
|
329
|
+
xr.setAnimationLoop?.(null);
|
|
330
|
+
} finally {
|
|
331
|
+
xr.enabled = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function initializeXR(internals: ThreeRootInternals<any>, renderer: Renderer): void {
|
|
340
|
+
if (internals.xrInitialized) return;
|
|
341
|
+
const xr = createXRManager(internals, renderer);
|
|
342
|
+
xr.connect();
|
|
343
|
+
try {
|
|
344
|
+
internals.store.setState({ xr });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
xr.disconnect();
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
internals.xrManager = xr;
|
|
350
|
+
internals.xrInitialized = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
207
353
|
function computeInitialSize(canvas: CanvasLike, size?: RenderProps<any>['size']): Size {
|
|
208
354
|
if (size !== undefined) {
|
|
209
355
|
return { width: size.width, height: size.height, top: size.top ?? 0, left: size.left ?? 0 };
|
|
@@ -246,9 +392,12 @@ function applyRaycasterOptions(
|
|
|
246
392
|
...raycaster.params,
|
|
247
393
|
...params,
|
|
248
394
|
Mesh: { ...raycaster.params.Mesh, ...params.Mesh },
|
|
249
|
-
Line: { ...raycaster.params.Line, ...params.Line },
|
|
395
|
+
Line: { ...raycaster.params.Line, ...params.Line } as THREE.Raycaster['params']['Line'],
|
|
250
396
|
LOD: { ...raycaster.params.LOD, ...params.LOD },
|
|
251
|
-
Points: {
|
|
397
|
+
Points: {
|
|
398
|
+
...raycaster.params.Points,
|
|
399
|
+
...params.Points,
|
|
400
|
+
} as THREE.Raycaster['params']['Points'],
|
|
252
401
|
Sprite: { ...raycaster.params.Sprite, ...params.Sprite },
|
|
253
402
|
};
|
|
254
403
|
}
|
|
@@ -402,6 +551,8 @@ async function applyConfiguration<TCanvas extends CanvasLike>(
|
|
|
402
551
|
} = props;
|
|
403
552
|
const renderer = await ensureRenderer(internals, glConfig, generation);
|
|
404
553
|
if (renderer === null || internals.disposed || internals.generation !== generation) return;
|
|
554
|
+
initializeContextLifecycle(internals, renderer);
|
|
555
|
+
initializeXR(internals, renderer);
|
|
405
556
|
|
|
406
557
|
let state = internals.store.getState();
|
|
407
558
|
if (state.raycaster == null) internals.store.setState({ raycaster: new THREE.Raycaster() });
|
|
@@ -579,14 +730,23 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
|
|
|
579
730
|
try {
|
|
580
731
|
internals.hostRoot?.unmount();
|
|
581
732
|
} finally {
|
|
582
|
-
internals.container?.flushDisposals();
|
|
583
733
|
try {
|
|
584
|
-
|
|
734
|
+
internals.container?.flushDisposals();
|
|
585
735
|
} finally {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
736
|
+
try {
|
|
737
|
+
internals.contextLifecycleCleanup?.();
|
|
738
|
+
} finally {
|
|
739
|
+
internals.contextLifecycleCleanup = null;
|
|
740
|
+
try {
|
|
741
|
+
internals.xrManager?.disconnect();
|
|
742
|
+
} finally {
|
|
743
|
+
internals.xrManager = null;
|
|
744
|
+
disposeRenderer(state.gl);
|
|
745
|
+
destroyRootStore(store);
|
|
746
|
+
roots.delete(canvas);
|
|
747
|
+
rootInternals.delete(controller);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
590
750
|
}
|
|
591
751
|
}
|
|
592
752
|
}
|
|
@@ -610,6 +770,10 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
|
|
|
610
770
|
generation: 0,
|
|
611
771
|
pendingRender: null,
|
|
612
772
|
lastConfiguredCamera: undefined,
|
|
773
|
+
contextLifecycleInitialized: false,
|
|
774
|
+
contextLifecycleCleanup: null,
|
|
775
|
+
xrInitialized: false,
|
|
776
|
+
xrManager: null,
|
|
613
777
|
};
|
|
614
778
|
rootInternals.set(controller, internals);
|
|
615
779
|
roots.set(canvas, { store, root: controller });
|
|
@@ -617,8 +781,14 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
|
|
|
617
781
|
return controller;
|
|
618
782
|
}
|
|
619
783
|
|
|
620
|
-
export function unmountComponentAtNode<TCanvas extends CanvasLike>(
|
|
621
|
-
|
|
784
|
+
export function unmountComponentAtNode<TCanvas extends CanvasLike>(
|
|
785
|
+
canvas: TCanvas,
|
|
786
|
+
callback?: (canvas: TCanvas) => void,
|
|
787
|
+
): void {
|
|
788
|
+
const record = roots.get(canvas);
|
|
789
|
+
if (record === undefined) return;
|
|
790
|
+
record.root.unmount();
|
|
791
|
+
callback?.(canvas);
|
|
622
792
|
}
|
|
623
793
|
|
|
624
794
|
export interface ThreeBoundaryMount {
|
package/src/core/store.ts
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
* components. Zustand remains the framework-neutral storage primitive.
|
|
7
7
|
*/
|
|
8
8
|
import * as THREE from 'three';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
useContext,
|
|
12
|
+
useRef,
|
|
13
|
+
useSyncExternalStore,
|
|
14
|
+
withSlot,
|
|
15
|
+
} from 'octane/universal';
|
|
10
16
|
import { createStore as createVanillaStore, type StoreApi } from 'zustand/vanilla';
|
|
11
17
|
import type {
|
|
12
18
|
DomEvent,
|
|
@@ -453,6 +459,31 @@ interface SelectionCell<T> {
|
|
|
453
459
|
value: T;
|
|
454
460
|
}
|
|
455
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Render-attempt-local state exposed by a portal without mutating its live
|
|
464
|
+
* store. An accepted portal commit clears `current`, after which consumers
|
|
465
|
+
* resume reading the store itself.
|
|
466
|
+
*
|
|
467
|
+
* This is package-private plumbing for the Three renderer. It is exported from
|
|
468
|
+
* this module so the portal and hook implementations can share it, but it is
|
|
469
|
+
* intentionally absent from the package's public barrel.
|
|
470
|
+
*/
|
|
471
|
+
export interface RootStoreRenderSnapshot {
|
|
472
|
+
readonly store: RootStore;
|
|
473
|
+
current: RootState | null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export const RootStoreRenderSnapshotContext = createContext<RootStoreRenderSnapshot | null>(null);
|
|
477
|
+
|
|
478
|
+
export function readRootStoreRenderSnapshot(
|
|
479
|
+
store: RootStore,
|
|
480
|
+
snapshot: RootStoreRenderSnapshot | null,
|
|
481
|
+
): RootState {
|
|
482
|
+
return snapshot?.store === store && snapshot.current !== null
|
|
483
|
+
? snapshot.current
|
|
484
|
+
: store.getState();
|
|
485
|
+
}
|
|
486
|
+
|
|
456
487
|
/** Universal-hook selector used by the callable store and `useThree`. */
|
|
457
488
|
export function useRootStoreSelector<T>(
|
|
458
489
|
store: RootStore,
|
|
@@ -460,12 +491,13 @@ export function useRootStoreSelector<T>(
|
|
|
460
491
|
equalityFn: (previous: T, next: T) => boolean = Object.is,
|
|
461
492
|
slot?: unknown,
|
|
462
493
|
): T {
|
|
494
|
+
const renderSnapshot = useContext(RootStoreRenderSnapshotContext);
|
|
463
495
|
const run = (nested: boolean): T => {
|
|
464
496
|
const cell = nested
|
|
465
497
|
? useRef<SelectionCell<T>>({ initialized: false, value: undefined as T }, 'selection')
|
|
466
498
|
: useRef<SelectionCell<T>>({ initialized: false, value: undefined as T });
|
|
467
499
|
const snapshot = () => {
|
|
468
|
-
const next = selector(store
|
|
500
|
+
const next = selector(readRootStoreRenderSnapshot(store, renderSnapshot));
|
|
469
501
|
if (!cell.current.initialized || !equalityFn(cell.current.value, next)) {
|
|
470
502
|
cell.current = { initialized: true, value: next };
|
|
471
503
|
}
|