@octanejs/three 0.1.0
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/LICENSE +49 -0
- package/README.md +215 -0
- package/UPSTREAM.md +58 -0
- package/package.json +77 -0
- package/src/config.ts +50 -0
- package/src/core/attach.ts +117 -0
- package/src/core/catalogue.ts +245 -0
- package/src/core/driver.ts +1609 -0
- package/src/core/events.ts +539 -0
- package/src/core/hooks.ts +203 -0
- package/src/core/index.ts +86 -0
- package/src/core/loader.ts +210 -0
- package/src/core/loop.ts +210 -0
- package/src/core/portal.ts +236 -0
- package/src/core/props.ts +645 -0
- package/src/core/root.ts +652 -0
- package/src/core/store.ts +741 -0
- package/src/index.ts +86 -0
- package/src/intrinsics.ts +18 -0
- package/src/renderer.ts +12 -0
- package/src/testing.ts +259 -0
- package/src/web/Canvas.tsrx +295 -0
- package/src/web/Canvas.tsrx.d.ts +23 -0
- package/src/web/events.ts +79 -0
- package/src/web/measure.ts +96 -0
|
@@ -0,0 +1,1609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Octane universal-host driver for real Three objects.
|
|
3
|
+
*
|
|
4
|
+
* Physical placement, reconstruction, and disposal behavior follows React
|
|
5
|
+
* Three Fiber v9.6.1's public host semantics while keeping Octane's universal
|
|
6
|
+
* commit protocol transactional:
|
|
7
|
+
* https://github.com/pmndrs/react-three-fiber/blob/2a528745e9aa7c9e6cca41e404b59d45cf0d0cc7/packages/fiber/src/core/reconciler.tsx#L218-L448
|
|
8
|
+
*/
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
import type {
|
|
11
|
+
UniversalEventListenerDescriptor,
|
|
12
|
+
UniversalEventPriority,
|
|
13
|
+
UniversalHostBatch,
|
|
14
|
+
UniversalHostDriver,
|
|
15
|
+
UniversalListenerDescriptor,
|
|
16
|
+
UniversalPortalTargetHandle,
|
|
17
|
+
} from 'octane/universal';
|
|
18
|
+
import {
|
|
19
|
+
getInitialRootStore,
|
|
20
|
+
getRootObjectStore,
|
|
21
|
+
removeInteractivity,
|
|
22
|
+
swapInteractivity,
|
|
23
|
+
type RootStore,
|
|
24
|
+
} from './store.js';
|
|
25
|
+
import { createThreeObject, registerThreeNamespace, THREE_RENDERER_ID } from './catalogue.js';
|
|
26
|
+
import {
|
|
27
|
+
attachString,
|
|
28
|
+
detachAttachment,
|
|
29
|
+
getEffectiveAttachment,
|
|
30
|
+
validateStringAttachment,
|
|
31
|
+
type AttachmentState,
|
|
32
|
+
} from './attach.js';
|
|
33
|
+
import { applyThreeProps, diffThreeProps } from './props.js';
|
|
34
|
+
|
|
35
|
+
const THREE_DRIVER_STATE = Symbol('octane.three.driver.state');
|
|
36
|
+
const OBJECT_INSTANCES = new WeakMap<object, ThreeHostInstance>();
|
|
37
|
+
const PUBLIC_INSTANCES = new WeakMap<ThreeHostInstance, Instance>();
|
|
38
|
+
const STORE_CONTAINERS = new WeakMap<RootStore, ThreeHostContainer>();
|
|
39
|
+
const ACTIVE_EVENT_SCOPES = new WeakSet<RootStore>();
|
|
40
|
+
const THREE_PORTAL_TARGET = Symbol('octane.three.portal-target');
|
|
41
|
+
const EXTERNAL_PORTAL_TARGET_LEASES = new WeakMap<THREE.Object3D, ExternalTargetLease>();
|
|
42
|
+
|
|
43
|
+
const THREE_EVENT_PRIORITIES: Readonly<Record<string, UniversalEventPriority>> = Object.freeze({
|
|
44
|
+
onClick: 'discrete',
|
|
45
|
+
onContextMenu: 'discrete',
|
|
46
|
+
onDoubleClick: 'discrete',
|
|
47
|
+
onPointerDown: 'discrete',
|
|
48
|
+
onPointerUp: 'discrete',
|
|
49
|
+
onPointerCancel: 'discrete',
|
|
50
|
+
onPointerMissed: 'discrete',
|
|
51
|
+
onLostPointerCapture: 'discrete',
|
|
52
|
+
onWheel: 'continuous',
|
|
53
|
+
onPointerMove: 'continuous',
|
|
54
|
+
onPointerOver: 'continuous',
|
|
55
|
+
onPointerOut: 'continuous',
|
|
56
|
+
onPointerEnter: 'continuous',
|
|
57
|
+
onPointerLeave: 'continuous',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Canonical priority lookup shared by host descriptors and native dispatch. */
|
|
61
|
+
export function getThreeHostEventPriority(name: string): UniversalEventPriority | undefined {
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(THREE_EVENT_PRIORITIES, name)
|
|
63
|
+
? THREE_EVENT_PRIORITIES[name]
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ParentId = number | UniversalPortalTargetHandle | null | undefined;
|
|
68
|
+
|
|
69
|
+
interface ThreePortalTargetInput {
|
|
70
|
+
readonly [THREE_PORTAL_TARGET]: true;
|
|
71
|
+
readonly object: THREE.Object3D;
|
|
72
|
+
readonly store: RootStore;
|
|
73
|
+
readonly binding: ThreePortalTargetBinding;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ThreePortalTargetBinding {
|
|
77
|
+
current: THREE.Object3D;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ExternalTargetLease {
|
|
81
|
+
readonly container: ThreeHostContainer;
|
|
82
|
+
count: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ThreePortalTargetDomain {
|
|
86
|
+
readonly handle: UniversalPortalTargetHandle;
|
|
87
|
+
readonly store: RootStore;
|
|
88
|
+
readonly binding: ThreePortalTargetBinding;
|
|
89
|
+
readonly source:
|
|
90
|
+
| { readonly kind: 'managed'; readonly id: number }
|
|
91
|
+
| { readonly kind: 'external'; readonly object: THREE.Object3D };
|
|
92
|
+
refCount: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type PhysicalPlacement =
|
|
96
|
+
| {
|
|
97
|
+
readonly kind: 'object3d';
|
|
98
|
+
readonly object: THREE.Object3D;
|
|
99
|
+
readonly parent: THREE.Object3D;
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
readonly kind: 'attachment';
|
|
103
|
+
readonly object: unknown;
|
|
104
|
+
readonly parent: object;
|
|
105
|
+
readonly path: string;
|
|
106
|
+
readonly state: AttachmentState;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Stable, read-only logical descriptor for a managed Three host object. */
|
|
110
|
+
export interface Instance<O = any> {
|
|
111
|
+
readonly object: O;
|
|
112
|
+
readonly type: string;
|
|
113
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
114
|
+
readonly parent: Instance | null;
|
|
115
|
+
readonly children: readonly Instance[];
|
|
116
|
+
readonly root: ThreeHostContainer;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface ThreeHostInstance {
|
|
120
|
+
readonly id: number;
|
|
121
|
+
readonly container: ThreeHostContainer;
|
|
122
|
+
type: string;
|
|
123
|
+
object: any;
|
|
124
|
+
props: Readonly<Record<string, unknown>>;
|
|
125
|
+
parent: ParentId;
|
|
126
|
+
readonly children: number[];
|
|
127
|
+
owned: boolean;
|
|
128
|
+
visible: boolean;
|
|
129
|
+
readonly events: Map<string, UniversalEventListenerDescriptor>;
|
|
130
|
+
readonly lifecycles: Map<string, UniversalListenerDescriptor>;
|
|
131
|
+
readonly localCallbacks: Map<string, UniversalListenerDescriptor>;
|
|
132
|
+
readonly localCleanups: Map<string, () => void>;
|
|
133
|
+
store: RootStore | undefined;
|
|
134
|
+
physical: PhysicalPlacement | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface SimulatedInstance {
|
|
138
|
+
type: string;
|
|
139
|
+
props: Readonly<Record<string, unknown>>;
|
|
140
|
+
parent: ParentId;
|
|
141
|
+
children: number[];
|
|
142
|
+
visible: boolean;
|
|
143
|
+
events: Map<string, UniversalEventListenerDescriptor>;
|
|
144
|
+
lifecycles: Map<string, UniversalListenerDescriptor>;
|
|
145
|
+
localCallbacks: Map<string, UniversalListenerDescriptor>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface StagedObject {
|
|
149
|
+
readonly object: any;
|
|
150
|
+
readonly owned: boolean;
|
|
151
|
+
readonly type: string;
|
|
152
|
+
readonly propsApplied: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ThreeDriverState {
|
|
156
|
+
readonly instances: Map<number, ThreeHostInstance>;
|
|
157
|
+
readonly rootChildren: number[];
|
|
158
|
+
readonly portalChildren: Map<string | number, number[]>;
|
|
159
|
+
readonly portalTargets: Map<string | number, ThreePortalTargetDomain>;
|
|
160
|
+
readonly portalTargetCache: WeakMap<RootStore, WeakMap<THREE.Object3D, ThreePortalTargetDomain>>;
|
|
161
|
+
readonly disposalQueue: Array<() => void>;
|
|
162
|
+
nextPortalTarget: number;
|
|
163
|
+
disposalScheduled: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface InteractionSnapshot {
|
|
167
|
+
readonly object: THREE.Object3D;
|
|
168
|
+
readonly store: RootStore;
|
|
169
|
+
readonly live: boolean;
|
|
170
|
+
readonly eligible: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface LogicalInteractionInstance {
|
|
174
|
+
readonly parent: ParentId;
|
|
175
|
+
readonly visible: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Package-private adapter consumed by the universal portal target capability. */
|
|
179
|
+
export function createThreePortalTargetBinding(object: THREE.Object3D): ThreePortalTargetBinding {
|
|
180
|
+
return { current: object };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Package-private adapter consumed by the universal portal target capability. */
|
|
184
|
+
export function createThreePortalTarget(
|
|
185
|
+
object: THREE.Object3D,
|
|
186
|
+
store: RootStore,
|
|
187
|
+
binding: ThreePortalTargetBinding,
|
|
188
|
+
): ThreePortalTargetInput {
|
|
189
|
+
return Object.freeze({ [THREE_PORTAL_TARGET]: true as const, object, store, binding });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ThreeHostEnvironment {
|
|
193
|
+
/** Called once after an accepted host batch, without requiring WebGL. */
|
|
194
|
+
invalidate?(): void;
|
|
195
|
+
/** Root state associated with a configured managed scene. */
|
|
196
|
+
readonly store?: RootStore;
|
|
197
|
+
/** Run all Three handlers from one platform event in one universal scope. */
|
|
198
|
+
eventScope?<T>(priority: UniversalEventPriority, run: () => T): T;
|
|
199
|
+
/** Dispatch a committed Three listener through its universal owner. */
|
|
200
|
+
dispatchEvent?(listener: number, payload: unknown): unknown;
|
|
201
|
+
/** Disable the default managed-root sRGB texture conversion. */
|
|
202
|
+
linear?: boolean;
|
|
203
|
+
/** Schedule accepted-object disposal after refs and layout cleanup. */
|
|
204
|
+
scheduleDispose?(flush: () => void): void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ThreeHostContainer {
|
|
208
|
+
readonly renderer: string;
|
|
209
|
+
readonly scene: THREE.Scene;
|
|
210
|
+
readonly commits: UniversalHostBatch[];
|
|
211
|
+
readonly environment: ThreeHostEnvironment;
|
|
212
|
+
readonly instanceCount: number;
|
|
213
|
+
/** Deterministic test/headless drain for accepted disposal work. */
|
|
214
|
+
flushDisposals(): void;
|
|
215
|
+
readonly [THREE_DRIVER_STATE]: ThreeDriverState;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface CreateThreeContainerOptions {
|
|
219
|
+
renderer?: string;
|
|
220
|
+
scene?: THREE.Scene;
|
|
221
|
+
environment?: ThreeHostEnvironment;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function runAll(tasks: Iterable<() => void>): void {
|
|
225
|
+
let failed = false;
|
|
226
|
+
let firstError: unknown;
|
|
227
|
+
for (const task of tasks) {
|
|
228
|
+
try {
|
|
229
|
+
task();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (!failed) {
|
|
232
|
+
failed = true;
|
|
233
|
+
firstError = error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (failed) throw firstError;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function disposeOwnedNow(object: any): void {
|
|
241
|
+
if (object?.type === 'Scene' || typeof object?.dispose !== 'function') return;
|
|
242
|
+
try {
|
|
243
|
+
object.dispose();
|
|
244
|
+
} catch {
|
|
245
|
+
// R3F intentionally treats user/Three disposal faults as best-effort cleanup.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function defaultScheduleDispose(flush: () => void): void {
|
|
250
|
+
setTimeout(flush, 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createThreeContainer(
|
|
254
|
+
options: CreateThreeContainerOptions = {},
|
|
255
|
+
): ThreeHostContainer {
|
|
256
|
+
const renderer = options.renderer ?? THREE_RENDERER_ID;
|
|
257
|
+
const state: ThreeDriverState = {
|
|
258
|
+
instances: new Map(),
|
|
259
|
+
rootChildren: [],
|
|
260
|
+
portalChildren: new Map(),
|
|
261
|
+
portalTargets: new Map(),
|
|
262
|
+
portalTargetCache: new WeakMap(),
|
|
263
|
+
disposalQueue: [],
|
|
264
|
+
nextPortalTarget: 1,
|
|
265
|
+
disposalScheduled: false,
|
|
266
|
+
};
|
|
267
|
+
const environment = options.environment ?? {};
|
|
268
|
+
const container: ThreeHostContainer = {
|
|
269
|
+
renderer,
|
|
270
|
+
scene: options.scene ?? new THREE.Scene(),
|
|
271
|
+
commits: [],
|
|
272
|
+
environment,
|
|
273
|
+
get instanceCount() {
|
|
274
|
+
return state.instances.size;
|
|
275
|
+
},
|
|
276
|
+
flushDisposals() {
|
|
277
|
+
state.disposalScheduled = false;
|
|
278
|
+
const queue = state.disposalQueue.splice(0);
|
|
279
|
+
runAll(queue);
|
|
280
|
+
},
|
|
281
|
+
[THREE_DRIVER_STATE]: state,
|
|
282
|
+
};
|
|
283
|
+
if (environment.store !== undefined) STORE_CONTAINERS.set(environment.store, container);
|
|
284
|
+
return container;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function enqueueDisposal(container: ThreeHostContainer, object: any): void {
|
|
288
|
+
const state = container[THREE_DRIVER_STATE];
|
|
289
|
+
state.disposalQueue.push(() => disposeOwnedNow(object));
|
|
290
|
+
if (state.disposalScheduled) return;
|
|
291
|
+
state.disposalScheduled = true;
|
|
292
|
+
const schedule = container.environment.scheduleDispose ?? defaultScheduleDispose;
|
|
293
|
+
schedule(() => container.flushDisposals());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cloneSimulation(state: ThreeDriverState): Map<number, SimulatedInstance> {
|
|
297
|
+
const simulation = new Map<number, SimulatedInstance>();
|
|
298
|
+
for (const [id, instance] of state.instances) {
|
|
299
|
+
simulation.set(id, {
|
|
300
|
+
type: instance.type,
|
|
301
|
+
props: instance.props,
|
|
302
|
+
parent: instance.parent,
|
|
303
|
+
children: [...instance.children],
|
|
304
|
+
visible: instance.visible,
|
|
305
|
+
events: new Map(instance.events),
|
|
306
|
+
lifecycles: new Map(instance.lifecycles),
|
|
307
|
+
localCallbacks: new Map(instance.localCallbacks),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return simulation;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isPortalParent(parent: ParentId): parent is UniversalPortalTargetHandle {
|
|
314
|
+
return (
|
|
315
|
+
parent !== null &&
|
|
316
|
+
parent !== undefined &&
|
|
317
|
+
typeof parent === 'object' &&
|
|
318
|
+
parent.$$kind === 'octane.universal.portal-target'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function sameParent(left: ParentId, right: ParentId): boolean {
|
|
323
|
+
if (left === right) return true;
|
|
324
|
+
return (
|
|
325
|
+
isPortalParent(left) &&
|
|
326
|
+
isPortalParent(right) &&
|
|
327
|
+
left.renderer === right.renderer &&
|
|
328
|
+
left.root === right.root &&
|
|
329
|
+
Object.is(left.id, right.id)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function clonePortalChildren(state: ThreeDriverState): Map<string | number, number[]> {
|
|
334
|
+
return new Map(
|
|
335
|
+
[...state.portalChildren].map(([target, children]) => [target, [...children]] as const),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function portalRegistration(
|
|
340
|
+
state: ThreeDriverState,
|
|
341
|
+
handle: UniversalPortalTargetHandle,
|
|
342
|
+
): ThreePortalTargetDomain {
|
|
343
|
+
const registration = state.portalTargets.get(handle.id);
|
|
344
|
+
if (
|
|
345
|
+
registration === undefined ||
|
|
346
|
+
registration.refCount === 0 ||
|
|
347
|
+
registration.handle.renderer !== handle.renderer ||
|
|
348
|
+
registration.handle.root !== handle.root ||
|
|
349
|
+
!Object.is(registration.handle.id, handle.id)
|
|
350
|
+
) {
|
|
351
|
+
throw new Error('@octanejs/three: Unknown, stale, or foreign portal target handle.');
|
|
352
|
+
}
|
|
353
|
+
return registration;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function simulatedChildren(
|
|
357
|
+
rootChildren: number[],
|
|
358
|
+
portalChildren: Map<string | number, number[]>,
|
|
359
|
+
portalTargets: ReadonlyMap<string | number, ThreePortalTargetDomain>,
|
|
360
|
+
simulation: Map<number, SimulatedInstance>,
|
|
361
|
+
parent: Exclude<ParentId, undefined>,
|
|
362
|
+
): number[] {
|
|
363
|
+
if (parent === null) return rootChildren;
|
|
364
|
+
if (isPortalParent(parent)) {
|
|
365
|
+
const registration = portalTargets.get(parent.id);
|
|
366
|
+
if (
|
|
367
|
+
registration === undefined ||
|
|
368
|
+
registration.refCount === 0 ||
|
|
369
|
+
registration.handle.renderer !== parent.renderer ||
|
|
370
|
+
registration.handle.root !== parent.root
|
|
371
|
+
) {
|
|
372
|
+
throw new Error('@octanejs/three: Unknown, stale, or foreign portal parent.');
|
|
373
|
+
}
|
|
374
|
+
let children = portalChildren.get(parent.id);
|
|
375
|
+
if (children === undefined) {
|
|
376
|
+
children = [];
|
|
377
|
+
portalChildren.set(parent.id, children);
|
|
378
|
+
}
|
|
379
|
+
return children;
|
|
380
|
+
}
|
|
381
|
+
const instance = simulation.get(parent);
|
|
382
|
+
if (instance === undefined) throw new Error(`@octanejs/three: Unknown parent ${parent}.`);
|
|
383
|
+
return instance.children;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function detachSimulatedChild(
|
|
387
|
+
rootChildren: number[],
|
|
388
|
+
portalChildren: Map<string | number, number[]>,
|
|
389
|
+
portalTargets: ReadonlyMap<string | number, ThreePortalTargetDomain>,
|
|
390
|
+
simulation: Map<number, SimulatedInstance>,
|
|
391
|
+
id: number,
|
|
392
|
+
): void {
|
|
393
|
+
const child = simulation.get(id);
|
|
394
|
+
if (child === undefined) return;
|
|
395
|
+
if (child.parent !== undefined) {
|
|
396
|
+
const siblings = simulatedChildren(
|
|
397
|
+
rootChildren,
|
|
398
|
+
portalChildren,
|
|
399
|
+
portalTargets,
|
|
400
|
+
simulation,
|
|
401
|
+
child.parent,
|
|
402
|
+
);
|
|
403
|
+
const index = siblings.indexOf(id);
|
|
404
|
+
if (index !== -1) siblings.splice(index, 1);
|
|
405
|
+
}
|
|
406
|
+
child.parent = undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function keyFor(id: number, type: string): string {
|
|
410
|
+
return `${id}:${type}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseKey(key: string): readonly [number, string] {
|
|
414
|
+
const separator = key.indexOf(':');
|
|
415
|
+
return [Number(key.slice(0, separator)), key.slice(separator + 1)];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isObject3D(value: unknown): value is THREE.Object3D {
|
|
419
|
+
return (value as THREE.Object3D | null)?.isObject3D === true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function resolvePortalDomainObject(
|
|
423
|
+
state: ThreeDriverState,
|
|
424
|
+
registration: ThreePortalTargetDomain,
|
|
425
|
+
): THREE.Object3D {
|
|
426
|
+
if (registration.source.kind === 'external') return registration.source.object;
|
|
427
|
+
const object = state.instances.get(registration.source.id)?.object;
|
|
428
|
+
if (!isObject3D(object)) {
|
|
429
|
+
throw new Error('@octanejs/three: A managed portal target is no longer mounted.');
|
|
430
|
+
}
|
|
431
|
+
return object;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function resolvePortalTargetObject(
|
|
435
|
+
state: ThreeDriverState,
|
|
436
|
+
handle: UniversalPortalTargetHandle,
|
|
437
|
+
): THREE.Object3D {
|
|
438
|
+
return resolvePortalDomainObject(state, portalRegistration(state, handle));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function storeForInstance(
|
|
442
|
+
id: number,
|
|
443
|
+
instances: ReadonlyMap<number, LogicalInteractionInstance>,
|
|
444
|
+
portalTargets: ReadonlyMap<string | number, ThreePortalTargetDomain>,
|
|
445
|
+
rootStore: RootStore | undefined,
|
|
446
|
+
): RootStore | undefined {
|
|
447
|
+
const seen = new Set<number>();
|
|
448
|
+
let currentId = id;
|
|
449
|
+
while (true) {
|
|
450
|
+
if (seen.has(currentId)) return undefined;
|
|
451
|
+
seen.add(currentId);
|
|
452
|
+
const instance = instances.get(currentId);
|
|
453
|
+
if (instance === undefined) return undefined;
|
|
454
|
+
if (instance.parent === null) return rootStore;
|
|
455
|
+
if (instance.parent === undefined) return undefined;
|
|
456
|
+
if (isPortalParent(instance.parent)) {
|
|
457
|
+
return portalTargets.get(instance.parent.id)?.store;
|
|
458
|
+
}
|
|
459
|
+
currentId = instance.parent;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function hasLiveRootConnection(
|
|
464
|
+
id: number,
|
|
465
|
+
instances: ReadonlyMap<number, LogicalInteractionInstance>,
|
|
466
|
+
portalTargets: ReadonlyMap<string | number, ThreePortalTargetDomain>,
|
|
467
|
+
): boolean {
|
|
468
|
+
const seen = new Set<number>();
|
|
469
|
+
let currentId = id;
|
|
470
|
+
while (true) {
|
|
471
|
+
if (seen.has(currentId)) return false;
|
|
472
|
+
seen.add(currentId);
|
|
473
|
+
const instance = instances.get(currentId);
|
|
474
|
+
if (instance === undefined || !instance.visible) return false;
|
|
475
|
+
if (instance.parent === null) return true;
|
|
476
|
+
if (instance.parent === undefined) return false;
|
|
477
|
+
if (isPortalParent(instance.parent)) {
|
|
478
|
+
const target = portalTargets.get(instance.parent.id);
|
|
479
|
+
if (target === undefined || target.refCount === 0) return false;
|
|
480
|
+
if (target.source.kind === 'managed') {
|
|
481
|
+
currentId = target.source.id;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
let ancestor: THREE.Object3D | null = target.source.object;
|
|
485
|
+
while (ancestor !== null) {
|
|
486
|
+
const managed = OBJECT_INSTANCES.get(ancestor);
|
|
487
|
+
if (managed !== undefined) {
|
|
488
|
+
return hasLiveRootConnection(managed.id, instances, portalTargets);
|
|
489
|
+
}
|
|
490
|
+
ancestor = ancestor.parent;
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
currentId = instance.parent;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function interactionSnapshot(
|
|
499
|
+
id: number,
|
|
500
|
+
object: unknown,
|
|
501
|
+
eventCount: number,
|
|
502
|
+
instances: ReadonlyMap<number, LogicalInteractionInstance>,
|
|
503
|
+
portalTargets: ReadonlyMap<string | number, ThreePortalTargetDomain>,
|
|
504
|
+
rootStore: RootStore | undefined,
|
|
505
|
+
): InteractionSnapshot | undefined {
|
|
506
|
+
if (!isObject3D(object)) return undefined;
|
|
507
|
+
const store = storeForInstance(id, instances, portalTargets, rootStore);
|
|
508
|
+
if (store === undefined) return undefined;
|
|
509
|
+
const live = hasLiveRootConnection(id, instances, portalTargets);
|
|
510
|
+
return {
|
|
511
|
+
object,
|
|
512
|
+
store,
|
|
513
|
+
live,
|
|
514
|
+
eligible: eventCount > 0 && object.raycast !== null && live,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function appendInteractivity(store: RootStore, object: THREE.Object3D): void {
|
|
519
|
+
const interaction = store.getState().internal.interaction;
|
|
520
|
+
if (!interaction.includes(object)) interaction.push(object);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function removeInteractionMembership(store: RootStore, object: THREE.Object3D): void {
|
|
524
|
+
const internal = store.getState().internal;
|
|
525
|
+
internal.interaction = internal.interaction.filter((candidate) => candidate !== object);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function reconcileInteractivity(
|
|
529
|
+
previous: InteractionSnapshot | undefined,
|
|
530
|
+
next: InteractionSnapshot | undefined,
|
|
531
|
+
replacement: boolean,
|
|
532
|
+
): void {
|
|
533
|
+
const previousStore = previous === undefined ? undefined : getInitialRootStore(previous.store);
|
|
534
|
+
const nextStore = next === undefined ? undefined : getInitialRootStore(next.store);
|
|
535
|
+
const store = nextStore ?? previousStore;
|
|
536
|
+
if (store === undefined) return;
|
|
537
|
+
if (
|
|
538
|
+
previous !== undefined &&
|
|
539
|
+
next !== undefined &&
|
|
540
|
+
previousStore !== undefined &&
|
|
541
|
+
nextStore !== undefined &&
|
|
542
|
+
previousStore !== nextStore
|
|
543
|
+
) {
|
|
544
|
+
removeInteractivity(previousStore, previous.object);
|
|
545
|
+
if (next.eligible) appendInteractivity(nextStore, next.object);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const interaction = store.getState().internal.interaction;
|
|
549
|
+
const wasTracked = previous !== undefined && interaction.includes(previous.object);
|
|
550
|
+
const preservesPosition = wasTracked && previous.eligible && next?.eligible === true;
|
|
551
|
+
const transfersIdentity =
|
|
552
|
+
replacement &&
|
|
553
|
+
previous !== undefined &&
|
|
554
|
+
next !== undefined &&
|
|
555
|
+
next.live &&
|
|
556
|
+
previous.object !== next.object;
|
|
557
|
+
if (transfersIdentity) swapInteractivity(store, previous.object, next.object);
|
|
558
|
+
|
|
559
|
+
if (preservesPosition) {
|
|
560
|
+
appendInteractivity(store, next.object);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (previous !== undefined) {
|
|
564
|
+
const previousObject = transfersIdentity ? next!.object : previous.object;
|
|
565
|
+
const lostEligibility = previous.eligible && next?.live === true && !next.eligible;
|
|
566
|
+
if (next?.live === true && !lostEligibility) {
|
|
567
|
+
removeInteractionMembership(store, previousObject);
|
|
568
|
+
} else {
|
|
569
|
+
removeInteractivity(store, previousObject);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (next?.eligible === true) appendInteractivity(store, next.object);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function objectForParent(
|
|
576
|
+
container: ThreeHostContainer,
|
|
577
|
+
state: ThreeDriverState,
|
|
578
|
+
parent: Exclude<ParentId, undefined>,
|
|
579
|
+
): any {
|
|
580
|
+
if (parent === null) return container.scene;
|
|
581
|
+
if (isPortalParent(parent)) return resolvePortalTargetObject(state, parent);
|
|
582
|
+
return state.instances.get(parent)?.object;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function desiredAttachment(
|
|
586
|
+
container: ThreeHostContainer,
|
|
587
|
+
state: ThreeDriverState,
|
|
588
|
+
instance: ThreeHostInstance,
|
|
589
|
+
destroyed: ReadonlySet<number>,
|
|
590
|
+
):
|
|
591
|
+
| { kind: 'none' }
|
|
592
|
+
| { kind: 'object3d'; parent: THREE.Object3D }
|
|
593
|
+
| { kind: 'attachment'; parent: object; path: string } {
|
|
594
|
+
if (destroyed.has(instance.id) || instance.parent === undefined) return { kind: 'none' };
|
|
595
|
+
if (instance.localCallbacks.has('attach')) return { kind: 'none' };
|
|
596
|
+
const parent = objectForParent(container, state, instance.parent);
|
|
597
|
+
if (parent == null) return { kind: 'none' };
|
|
598
|
+
const path = getEffectiveAttachment(
|
|
599
|
+
instance.object,
|
|
600
|
+
instance.props.attach as string | null | undefined,
|
|
601
|
+
);
|
|
602
|
+
if (typeof path === 'string') {
|
|
603
|
+
return instance.visible ? { kind: 'attachment', parent, path } : { kind: 'none' };
|
|
604
|
+
}
|
|
605
|
+
if (isObject3D(parent) && isObject3D(instance.object)) {
|
|
606
|
+
return { kind: 'object3d', parent };
|
|
607
|
+
}
|
|
608
|
+
return { kind: 'none' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function placementMatches(
|
|
612
|
+
placement: PhysicalPlacement,
|
|
613
|
+
desired:
|
|
614
|
+
| { kind: 'none' }
|
|
615
|
+
| { kind: 'object3d'; parent: THREE.Object3D }
|
|
616
|
+
| { kind: 'attachment'; parent: object; path: string },
|
|
617
|
+
object: unknown,
|
|
618
|
+
): boolean {
|
|
619
|
+
if (desired.kind === 'none') return false;
|
|
620
|
+
if (placement.object !== object || placement.parent !== desired.parent) return false;
|
|
621
|
+
if (placement.kind === 'object3d') return desired.kind === 'object3d';
|
|
622
|
+
return desired.kind === 'attachment' && placement.path === desired.path;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function detachPhysical(instance: ThreeHostInstance): void {
|
|
626
|
+
const placement = instance.physical;
|
|
627
|
+
if (placement === null) return;
|
|
628
|
+
instance.physical = null;
|
|
629
|
+
if (placement.kind === 'attachment') {
|
|
630
|
+
detachAttachment(placement.state);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
placement.parent.remove(placement.object);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function receivesVisibilityOverlay(instance: ThreeHostInstance, state: ThreeDriverState): boolean {
|
|
637
|
+
if (instance.visible) return false;
|
|
638
|
+
if (
|
|
639
|
+
instance.parent === null ||
|
|
640
|
+
instance.parent === undefined ||
|
|
641
|
+
isPortalParent(instance.parent)
|
|
642
|
+
) {
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
return state.instances.get(instance.parent)?.visible !== false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function reorderManagedChildren(parent: THREE.Object3D, desired: readonly THREE.Object3D[]): void {
|
|
649
|
+
if (desired.length < 2) return;
|
|
650
|
+
const desiredSet = new Set(desired);
|
|
651
|
+
const slots: number[] = [];
|
|
652
|
+
for (let index = 0; index < parent.children.length; index++) {
|
|
653
|
+
if (desiredSet.has(parent.children[index])) slots.push(index);
|
|
654
|
+
}
|
|
655
|
+
for (let index = 0; index < slots.length; index++) parent.children[slots[index]] = desired[index];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function synchronizePhysicalTree(
|
|
659
|
+
container: ThreeHostContainer,
|
|
660
|
+
state: ThreeDriverState,
|
|
661
|
+
destroyed: ReadonlySet<number>,
|
|
662
|
+
): void {
|
|
663
|
+
for (const registration of state.portalTargets.values()) {
|
|
664
|
+
if (registration.refCount === 0) continue;
|
|
665
|
+
const target = resolvePortalDomainObject(state, registration);
|
|
666
|
+
registration.binding.current = target;
|
|
667
|
+
if (registration.store.getState().scene !== target) {
|
|
668
|
+
registration.store.setState({ scene: target as THREE.Scene });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const desired = new Map<
|
|
672
|
+
number,
|
|
673
|
+
| { kind: 'none' }
|
|
674
|
+
| { kind: 'object3d'; parent: THREE.Object3D }
|
|
675
|
+
| { kind: 'attachment'; parent: object; path: string }
|
|
676
|
+
>();
|
|
677
|
+
for (const instance of state.instances.values()) {
|
|
678
|
+
desired.set(instance.id, desiredAttachment(container, state, instance, destroyed));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const detachTasks: Array<() => void> = [];
|
|
682
|
+
for (const instance of state.instances.values()) {
|
|
683
|
+
const placement = instance.physical;
|
|
684
|
+
if (
|
|
685
|
+
placement !== null &&
|
|
686
|
+
!placementMatches(placement, desired.get(instance.id)!, instance.object)
|
|
687
|
+
) {
|
|
688
|
+
detachTasks.push(() => detachPhysical(instance));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
runAll(detachTasks);
|
|
692
|
+
|
|
693
|
+
const attachTasks: Array<() => void> = [];
|
|
694
|
+
for (const instance of state.instances.values()) {
|
|
695
|
+
if (destroyed.has(instance.id)) continue;
|
|
696
|
+
const target = desired.get(instance.id)!;
|
|
697
|
+
if (target.kind === 'object3d') {
|
|
698
|
+
if (instance.physical === null) {
|
|
699
|
+
attachTasks.push(() => {
|
|
700
|
+
target.parent.add(instance.object);
|
|
701
|
+
instance.physical = {
|
|
702
|
+
kind: 'object3d',
|
|
703
|
+
object: instance.object,
|
|
704
|
+
parent: target.parent,
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
attachTasks.push(() => {
|
|
709
|
+
// React hides only the first host objects in a retained range. Their
|
|
710
|
+
// descendants remain authored as-is and are culled by the hidden parent.
|
|
711
|
+
// Logical visibility still remains false throughout the range so events,
|
|
712
|
+
// effects, and local callbacks stay disconnected while it is retained.
|
|
713
|
+
instance.object.visible =
|
|
714
|
+
!receivesVisibilityOverlay(instance, state) && instance.props.visible !== false;
|
|
715
|
+
});
|
|
716
|
+
} else if (target.kind === 'attachment' && instance.physical === null) {
|
|
717
|
+
attachTasks.push(() => {
|
|
718
|
+
const attachment = attachString(target.parent, instance.object, target.path);
|
|
719
|
+
instance.physical = {
|
|
720
|
+
kind: 'attachment',
|
|
721
|
+
object: instance.object,
|
|
722
|
+
parent: target.parent,
|
|
723
|
+
path: target.path,
|
|
724
|
+
state: attachment,
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
runAll(attachTasks);
|
|
730
|
+
|
|
731
|
+
const orderTasks: Array<() => void> = [];
|
|
732
|
+
const orderParent = (parentId: Exclude<ParentId, undefined>, children: readonly number[]) => {
|
|
733
|
+
const parent = objectForParent(container, state, parentId);
|
|
734
|
+
if (!isObject3D(parent)) return;
|
|
735
|
+
const ordered = children.flatMap((id) => {
|
|
736
|
+
const child = state.instances.get(id);
|
|
737
|
+
const placement = child?.physical;
|
|
738
|
+
return placement?.kind === 'object3d' && placement.parent === parent
|
|
739
|
+
? [placement.object]
|
|
740
|
+
: [];
|
|
741
|
+
});
|
|
742
|
+
orderTasks.push(() => reorderManagedChildren(parent, ordered));
|
|
743
|
+
};
|
|
744
|
+
orderParent(null, state.rootChildren);
|
|
745
|
+
for (const instance of state.instances.values()) orderParent(instance.id, instance.children);
|
|
746
|
+
for (const registration of state.portalTargets.values()) {
|
|
747
|
+
if (registration.refCount === 0) continue;
|
|
748
|
+
orderParent(registration.handle, state.portalChildren.get(registration.handle.id) ?? []);
|
|
749
|
+
}
|
|
750
|
+
runAll(orderTasks);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function shouldDisposeRemoved(
|
|
754
|
+
instance: ThreeHostInstance,
|
|
755
|
+
state: ThreeDriverState,
|
|
756
|
+
destroyed: ReadonlySet<number>,
|
|
757
|
+
): boolean {
|
|
758
|
+
if (!instance.owned || instance.type === 'primitive' || instance.object?.type === 'Scene') {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
let current: ThreeHostInstance | undefined = instance;
|
|
762
|
+
while (current !== undefined && destroyed.has(current.id)) {
|
|
763
|
+
if (current.props.dispose === null) return false;
|
|
764
|
+
current =
|
|
765
|
+
current.parent === null || current.parent === undefined || isPortalParent(current.parent)
|
|
766
|
+
? undefined
|
|
767
|
+
: state.instances.get(current.parent);
|
|
768
|
+
}
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function createHostInstance(
|
|
773
|
+
container: ThreeHostContainer,
|
|
774
|
+
id: number,
|
|
775
|
+
staged: StagedObject,
|
|
776
|
+
props: Readonly<Record<string, unknown>>,
|
|
777
|
+
): ThreeHostInstance {
|
|
778
|
+
return {
|
|
779
|
+
id,
|
|
780
|
+
container,
|
|
781
|
+
type: staged.type,
|
|
782
|
+
object: staged.object,
|
|
783
|
+
props,
|
|
784
|
+
parent: undefined,
|
|
785
|
+
children: [],
|
|
786
|
+
owned: staged.owned,
|
|
787
|
+
visible: true,
|
|
788
|
+
events: new Map(),
|
|
789
|
+
lifecycles: new Map(),
|
|
790
|
+
localCallbacks: new Map(),
|
|
791
|
+
localCleanups: new Map(),
|
|
792
|
+
store: container.environment.store,
|
|
793
|
+
physical: null,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function stageObject(
|
|
798
|
+
container: ThreeHostContainer,
|
|
799
|
+
type: string,
|
|
800
|
+
props: Readonly<Record<string, unknown>>,
|
|
801
|
+
): StagedObject {
|
|
802
|
+
const created = createThreeObject(type, props);
|
|
803
|
+
try {
|
|
804
|
+
if (created.owned) {
|
|
805
|
+
applyThreeProps(created.object, props, undefined, {
|
|
806
|
+
colorSpace: container.environment.linear !== true,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
} catch (error) {
|
|
810
|
+
if (created.owned) disposeOwnedNow(created.object);
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
813
|
+
return { ...created, propsApplied: created.owned };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function getPublicInstance<O = any>(instance: ThreeHostInstance): Instance<O> {
|
|
817
|
+
let descriptor = PUBLIC_INSTANCES.get(instance);
|
|
818
|
+
if (descriptor !== undefined) return descriptor as Instance<O>;
|
|
819
|
+
|
|
820
|
+
let propsSource: Readonly<Record<string, unknown>> | undefined;
|
|
821
|
+
let publicProps: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
822
|
+
descriptor = Object.freeze({
|
|
823
|
+
get object() {
|
|
824
|
+
return instance.object;
|
|
825
|
+
},
|
|
826
|
+
get type() {
|
|
827
|
+
return instance.type;
|
|
828
|
+
},
|
|
829
|
+
get props() {
|
|
830
|
+
if (propsSource !== instance.props) {
|
|
831
|
+
propsSource = instance.props;
|
|
832
|
+
publicProps = Object.freeze({ ...instance.props });
|
|
833
|
+
}
|
|
834
|
+
return publicProps;
|
|
835
|
+
},
|
|
836
|
+
get parent() {
|
|
837
|
+
if (
|
|
838
|
+
instance.parent === null ||
|
|
839
|
+
instance.parent === undefined ||
|
|
840
|
+
isPortalParent(instance.parent)
|
|
841
|
+
) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const parent = instance.container[THREE_DRIVER_STATE].instances.get(instance.parent);
|
|
845
|
+
return parent === undefined ? null : getPublicInstance(parent);
|
|
846
|
+
},
|
|
847
|
+
get children() {
|
|
848
|
+
const state = instance.container[THREE_DRIVER_STATE];
|
|
849
|
+
return Object.freeze(
|
|
850
|
+
instance.children.flatMap((id) => {
|
|
851
|
+
const child = state.instances.get(id);
|
|
852
|
+
return child === undefined ? [] : [getPublicInstance(child)];
|
|
853
|
+
}),
|
|
854
|
+
);
|
|
855
|
+
},
|
|
856
|
+
root: instance.container,
|
|
857
|
+
}) as Instance;
|
|
858
|
+
PUBLIC_INSTANCES.set(instance, descriptor);
|
|
859
|
+
return descriptor as Instance<O>;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function getThreeInstance<O extends object>(object: O): Instance<O> | null {
|
|
863
|
+
const instance = OBJECT_INSTANCES.get(object);
|
|
864
|
+
return instance === undefined ? null : getPublicInstance<O>(instance);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Return the committed universal listener descriptor for a managed Three object. */
|
|
868
|
+
export function getThreeEventListener(
|
|
869
|
+
object: object,
|
|
870
|
+
type: string,
|
|
871
|
+
): UniversalEventListenerDescriptor | undefined {
|
|
872
|
+
return OBJECT_INSTANCES.get(object)?.events.get(type);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Test whether a managed Three object has any, or any selected, event listeners. */
|
|
876
|
+
export function hasThreeEventListeners(object: object, types?: readonly string[]): boolean {
|
|
877
|
+
const events = OBJECT_INSTANCES.get(object)?.events;
|
|
878
|
+
if (events === undefined) return false;
|
|
879
|
+
if (types === undefined) return events.size > 0;
|
|
880
|
+
return types.some((type) => events.has(type));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Return the configured root store that owns a managed Three object. */
|
|
884
|
+
export function getThreeEventStore(object: object): RootStore | undefined {
|
|
885
|
+
return OBJECT_INSTANCES.get(object)?.store;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Whether a raycast hit is connected through a visible managed Three path. */
|
|
889
|
+
export function isThreeEventHitLive(object: THREE.Object3D): boolean {
|
|
890
|
+
let candidate: THREE.Object3D | null = object;
|
|
891
|
+
while (candidate !== null) {
|
|
892
|
+
const instance = OBJECT_INSTANCES.get(candidate);
|
|
893
|
+
if (instance !== undefined) {
|
|
894
|
+
const state = instance.container[THREE_DRIVER_STATE];
|
|
895
|
+
return hasLiveRootConnection(instance.id, state.instances, state.portalTargets);
|
|
896
|
+
}
|
|
897
|
+
candidate = candidate.parent;
|
|
898
|
+
}
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Keep all Three handlers for one platform event in one universal event scope. */
|
|
903
|
+
export function runThreeEventScope<T>(
|
|
904
|
+
store: RootStore,
|
|
905
|
+
priority: UniversalEventPriority,
|
|
906
|
+
run: () => T,
|
|
907
|
+
): T {
|
|
908
|
+
const initialStore = getInitialRootStore(store);
|
|
909
|
+
if (ACTIVE_EVENT_SCOPES.has(initialStore)) return run();
|
|
910
|
+
const eventScope = STORE_CONTAINERS.get(initialStore)?.environment.eventScope;
|
|
911
|
+
if (eventScope === undefined) {
|
|
912
|
+
throw new Error('@octanejs/three: The configured root has no universal event scope.');
|
|
913
|
+
}
|
|
914
|
+
ACTIVE_EVENT_SCOPES.add(initialStore);
|
|
915
|
+
try {
|
|
916
|
+
return eventScope(priority, run);
|
|
917
|
+
} finally {
|
|
918
|
+
ACTIVE_EVENT_SCOPES.delete(initialStore);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Dispatch a committed Three listener through its universal owner and scheduler. */
|
|
923
|
+
export function dispatchThreeEvent(store: RootStore, listener: number, payload: unknown): unknown {
|
|
924
|
+
const dispatchEvent = STORE_CONTAINERS.get(getInitialRootStore(store))?.environment.dispatchEvent;
|
|
925
|
+
if (dispatchEvent === undefined) {
|
|
926
|
+
throw new Error('@octanejs/three: The configured root has no universal event dispatcher.');
|
|
927
|
+
}
|
|
928
|
+
return dispatchEvent(listener, payload);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Apply imperative Three props and invalidate the owning root when managed. */
|
|
932
|
+
export function applyProps<T extends object>(
|
|
933
|
+
object: T,
|
|
934
|
+
props: Readonly<Record<string, unknown>>,
|
|
935
|
+
): T {
|
|
936
|
+
const instance = OBJECT_INSTANCES.get(object);
|
|
937
|
+
const result = applyThreeProps(object, props, undefined, {
|
|
938
|
+
colorSpace: instance !== undefined && instance.container.environment.linear !== true,
|
|
939
|
+
});
|
|
940
|
+
instance?.container.environment.invalidate?.();
|
|
941
|
+
return result;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export function createThreeDriver(
|
|
945
|
+
renderer = THREE_RENDERER_ID,
|
|
946
|
+
): UniversalHostDriver<ThreeHostContainer, object> {
|
|
947
|
+
registerThreeNamespace();
|
|
948
|
+
return {
|
|
949
|
+
id: renderer,
|
|
950
|
+
capabilities: { text: 'ignore', localHostCallbacks: true, visibility: true },
|
|
951
|
+
events: {
|
|
952
|
+
classify(name) {
|
|
953
|
+
const priority = getThreeHostEventPriority(name);
|
|
954
|
+
return priority === undefined ? null : { type: name, priority };
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
lifecycles: {
|
|
958
|
+
classify(name) {
|
|
959
|
+
return name === 'onUpdate' ? { type: 'update' } : null;
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
localCallbacks: {
|
|
963
|
+
classify(name, value) {
|
|
964
|
+
// Keep null/undefined in the ordinary prop snapshot: explicit null
|
|
965
|
+
// suppresses geometry/material auto-attachment, while an omitted value
|
|
966
|
+
// enables it. Only functions need the post-accept callback channel.
|
|
967
|
+
return name === 'attach' && typeof value === 'function' ? { type: 'attach' } : null;
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
updates: {
|
|
971
|
+
classify(type, previous, next) {
|
|
972
|
+
if (type === 'primitive' && previous.object !== next.object) return 'recreate';
|
|
973
|
+
const oldArgs = previous.args as readonly unknown[] | undefined;
|
|
974
|
+
const newArgs = next.args as readonly unknown[] | undefined;
|
|
975
|
+
if (oldArgs?.length !== newArgs?.length) return 'recreate';
|
|
976
|
+
if (newArgs?.some((value, index) => value !== oldArgs?.[index])) return 'recreate';
|
|
977
|
+
return 'update';
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
portals: {
|
|
981
|
+
prepareTarget(context) {
|
|
982
|
+
if (context.transported) {
|
|
983
|
+
throw new Error(
|
|
984
|
+
'@octanejs/three: Local Object3D portal targets cannot cross a commit transport.',
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
const target = context.target as Partial<ThreePortalTargetInput> | null;
|
|
988
|
+
if (target?.[THREE_PORTAL_TARGET] !== true || !isObject3D(target.object)) {
|
|
989
|
+
throw new TypeError('@octanejs/three: createPortal target must be a Three Object3D.');
|
|
990
|
+
}
|
|
991
|
+
if (target.store === undefined || typeof target.store.getState !== 'function') {
|
|
992
|
+
throw new TypeError('@octanejs/three: Portal target is missing its state enclave.');
|
|
993
|
+
}
|
|
994
|
+
if (
|
|
995
|
+
target.binding === undefined ||
|
|
996
|
+
target.binding === null ||
|
|
997
|
+
!isObject3D(target.binding.current)
|
|
998
|
+
) {
|
|
999
|
+
throw new TypeError('@octanejs/three: Portal target is missing its physical binding.');
|
|
1000
|
+
}
|
|
1001
|
+
const rootStore = context.container.environment.store;
|
|
1002
|
+
if (rootStore === undefined || getInitialRootStore(target.store) !== rootStore) {
|
|
1003
|
+
throw new Error('@octanejs/three: Portal target store belongs to another root.');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const state = context.container[THREE_DRIVER_STATE];
|
|
1007
|
+
let byObject = state.portalTargetCache.get(target.store);
|
|
1008
|
+
if (byObject === undefined) {
|
|
1009
|
+
byObject = new WeakMap();
|
|
1010
|
+
state.portalTargetCache.set(target.store, byObject);
|
|
1011
|
+
}
|
|
1012
|
+
let domain = byObject.get(target.object);
|
|
1013
|
+
if (domain !== undefined && domain.binding !== target.binding) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
'@octanejs/three: Portal target binding does not match its state enclave.',
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
const effectiveTarget =
|
|
1019
|
+
domain === undefined ? target.object : resolvePortalDomainObject(state, domain);
|
|
1020
|
+
|
|
1021
|
+
const assertTargetScope = (object: THREE.Object3D) => {
|
|
1022
|
+
const instance = OBJECT_INSTANCES.get(object);
|
|
1023
|
+
if (instance !== undefined && instance.container !== context.container) {
|
|
1024
|
+
throw new Error('@octanejs/three: Cannot portal into an object owned by another root.');
|
|
1025
|
+
}
|
|
1026
|
+
const objectRootStore = getRootObjectStore(object);
|
|
1027
|
+
if (objectRootStore !== undefined && getInitialRootStore(objectRootStore) !== rootStore) {
|
|
1028
|
+
throw new Error('@octanejs/three: Cannot portal into a scene owned by another root.');
|
|
1029
|
+
}
|
|
1030
|
+
const objectLease = EXTERNAL_PORTAL_TARGET_LEASES.get(object);
|
|
1031
|
+
if (objectLease !== undefined && objectLease.container !== context.container) {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
'@octanejs/three: External portal target is already leased by another root.',
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const targetAncestors = new Set<THREE.Object3D>();
|
|
1039
|
+
for (
|
|
1040
|
+
let ancestor: THREE.Object3D | null = effectiveTarget;
|
|
1041
|
+
ancestor !== null;
|
|
1042
|
+
ancestor = ancestor.parent
|
|
1043
|
+
) {
|
|
1044
|
+
if (targetAncestors.has(ancestor)) {
|
|
1045
|
+
throw new Error('@octanejs/three: Portal target has cyclic Object3D ancestry.');
|
|
1046
|
+
}
|
|
1047
|
+
targetAncestors.add(ancestor);
|
|
1048
|
+
assertTargetScope(ancestor);
|
|
1049
|
+
}
|
|
1050
|
+
const descendants = [...effectiveTarget.children];
|
|
1051
|
+
const visited = new Set<THREE.Object3D>([effectiveTarget]);
|
|
1052
|
+
while (descendants.length > 0) {
|
|
1053
|
+
const descendant = descendants.pop()!;
|
|
1054
|
+
if (visited.has(descendant)) continue;
|
|
1055
|
+
visited.add(descendant);
|
|
1056
|
+
assertTargetScope(descendant);
|
|
1057
|
+
descendants.push(...descendant.children);
|
|
1058
|
+
}
|
|
1059
|
+
if (domain === undefined) {
|
|
1060
|
+
const managed = OBJECT_INSTANCES.get(target.object);
|
|
1061
|
+
const id = state.nextPortalTarget++;
|
|
1062
|
+
domain = {
|
|
1063
|
+
handle: context.createPortalTargetHandle(id),
|
|
1064
|
+
store: target.store,
|
|
1065
|
+
binding: target.binding,
|
|
1066
|
+
source:
|
|
1067
|
+
managed === undefined
|
|
1068
|
+
? { kind: 'external', object: target.object }
|
|
1069
|
+
: { kind: 'managed', id: managed.id },
|
|
1070
|
+
refCount: 0,
|
|
1071
|
+
};
|
|
1072
|
+
byObject.set(target.object, domain);
|
|
1073
|
+
state.portalTargets.set(id, domain);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
let lease: ExternalTargetLease | undefined;
|
|
1077
|
+
if (domain.source.kind === 'external') {
|
|
1078
|
+
const externalObject = domain.source.object;
|
|
1079
|
+
lease = EXTERNAL_PORTAL_TARGET_LEASES.get(externalObject);
|
|
1080
|
+
if (lease !== undefined && lease.container !== context.container) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
'@octanejs/three: External portal target is already leased by another root.',
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (lease === undefined) {
|
|
1086
|
+
lease = { container: context.container, count: 0 };
|
|
1087
|
+
EXTERNAL_PORTAL_TARGET_LEASES.set(externalObject, lease);
|
|
1088
|
+
}
|
|
1089
|
+
lease.count++;
|
|
1090
|
+
}
|
|
1091
|
+
domain.refCount++;
|
|
1092
|
+
let released = false;
|
|
1093
|
+
return {
|
|
1094
|
+
handle: domain.handle,
|
|
1095
|
+
release() {
|
|
1096
|
+
if (released) return;
|
|
1097
|
+
released = true;
|
|
1098
|
+
domain!.refCount--;
|
|
1099
|
+
if (domain!.source.kind === 'external') {
|
|
1100
|
+
const activeLease = EXTERNAL_PORTAL_TARGET_LEASES.get(domain!.source.object);
|
|
1101
|
+
if (activeLease !== undefined && activeLease === lease) {
|
|
1102
|
+
activeLease.count--;
|
|
1103
|
+
if (activeLease.count === 0) {
|
|
1104
|
+
EXTERNAL_PORTAL_TARGET_LEASES.delete(domain!.source.object);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (domain!.refCount === 0) {
|
|
1109
|
+
state.portalTargets.delete(domain!.handle.id);
|
|
1110
|
+
state.portalChildren.delete(domain!.handle.id);
|
|
1111
|
+
byObject!.delete(target.object!);
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
prepareBatch(container, batch, context) {
|
|
1118
|
+
if (container.renderer !== renderer || batch.renderer !== renderer) {
|
|
1119
|
+
throw new Error(
|
|
1120
|
+
`@octanejs/three: Renderer mismatch between driver ${JSON.stringify(renderer)}, container ${JSON.stringify(container.renderer)}, and batch ${JSON.stringify(batch.renderer)}.`,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
const state = container[THREE_DRIVER_STATE];
|
|
1124
|
+
const simulation = cloneSimulation(state);
|
|
1125
|
+
const rootChildren = [...state.rootChildren];
|
|
1126
|
+
const portalChildren = clonePortalChildren(state);
|
|
1127
|
+
const stagedCreates = new Map<
|
|
1128
|
+
number,
|
|
1129
|
+
{ instance: ThreeHostInstance; staged: StagedObject }
|
|
1130
|
+
>();
|
|
1131
|
+
const stagedReplacements = new Map<number, StagedObject>();
|
|
1132
|
+
const unpublishedOwned = new Set<any>();
|
|
1133
|
+
const cleanupKeys = new Set<string>();
|
|
1134
|
+
const invokeKeys = new Set<string>();
|
|
1135
|
+
const destroyed = new Set<number>();
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
for (const command of batch.commands) {
|
|
1139
|
+
if (command.op === 'create') {
|
|
1140
|
+
if (simulation.has(command.id)) {
|
|
1141
|
+
throw new Error(`@octanejs/three: Duplicate instance id ${command.id}.`);
|
|
1142
|
+
}
|
|
1143
|
+
const staged = stageObject(container, command.type, command.props);
|
|
1144
|
+
if (staged.owned) unpublishedOwned.add(staged.object);
|
|
1145
|
+
const instance = createHostInstance(container, command.id, staged, command.props);
|
|
1146
|
+
stagedCreates.set(command.id, { instance, staged });
|
|
1147
|
+
simulation.set(command.id, {
|
|
1148
|
+
type: staged.type,
|
|
1149
|
+
props: command.props,
|
|
1150
|
+
parent: undefined,
|
|
1151
|
+
children: [],
|
|
1152
|
+
visible: true,
|
|
1153
|
+
events: new Map(),
|
|
1154
|
+
lifecycles: new Map(),
|
|
1155
|
+
localCallbacks: new Map(),
|
|
1156
|
+
});
|
|
1157
|
+
} else if (command.op === 'update') {
|
|
1158
|
+
const instance = simulation.get(command.id);
|
|
1159
|
+
if (instance === undefined) {
|
|
1160
|
+
throw new Error(`@octanejs/three: Unknown update target ${command.id}.`);
|
|
1161
|
+
}
|
|
1162
|
+
instance.props = command.props;
|
|
1163
|
+
} else if (command.op === 'recreate') {
|
|
1164
|
+
const instance = simulation.get(command.id);
|
|
1165
|
+
if (instance === undefined || !state.instances.has(command.id)) {
|
|
1166
|
+
throw new Error(`@octanejs/three: Unknown recreate target ${command.id}.`);
|
|
1167
|
+
}
|
|
1168
|
+
const staged = stageObject(container, command.type, command.props);
|
|
1169
|
+
if (staged.owned) unpublishedOwned.add(staged.object);
|
|
1170
|
+
if (staged.type !== instance.type) {
|
|
1171
|
+
throw new Error(`@octanejs/three: Recreate type mismatch for ${command.id}.`);
|
|
1172
|
+
}
|
|
1173
|
+
stagedReplacements.set(command.id, staged);
|
|
1174
|
+
instance.props = command.props;
|
|
1175
|
+
for (const type of instance.localCallbacks.keys()) {
|
|
1176
|
+
const callbackKey = keyFor(command.id, type);
|
|
1177
|
+
cleanupKeys.add(callbackKey);
|
|
1178
|
+
if (instance.visible) invokeKeys.add(callbackKey);
|
|
1179
|
+
else invokeKeys.delete(callbackKey);
|
|
1180
|
+
}
|
|
1181
|
+
} else if (command.op === 'event') {
|
|
1182
|
+
const instance = simulation.get(command.id);
|
|
1183
|
+
if (instance === undefined) {
|
|
1184
|
+
throw new Error(`@octanejs/three: Unknown event target ${command.id}.`);
|
|
1185
|
+
}
|
|
1186
|
+
if (command.listener === null) instance.events.delete(command.type);
|
|
1187
|
+
else instance.events.set(command.type, command.listener);
|
|
1188
|
+
} else if (command.op === 'lifecycle') {
|
|
1189
|
+
const instance = simulation.get(command.id);
|
|
1190
|
+
if (instance === undefined) {
|
|
1191
|
+
throw new Error(`@octanejs/three: Unknown lifecycle target ${command.id}.`);
|
|
1192
|
+
}
|
|
1193
|
+
if (command.listener === null) instance.lifecycles.delete(command.type);
|
|
1194
|
+
else instance.lifecycles.set(command.type, command.listener);
|
|
1195
|
+
} else if (command.op === 'local-callback') {
|
|
1196
|
+
const instance = simulation.get(command.id);
|
|
1197
|
+
if (instance === undefined) {
|
|
1198
|
+
throw new Error(`@octanejs/three: Unknown local callback target ${command.id}.`);
|
|
1199
|
+
}
|
|
1200
|
+
const callbackKey = keyFor(command.id, command.type);
|
|
1201
|
+
cleanupKeys.add(callbackKey);
|
|
1202
|
+
if (command.listener === null) {
|
|
1203
|
+
instance.localCallbacks.delete(command.type);
|
|
1204
|
+
invokeKeys.delete(callbackKey);
|
|
1205
|
+
} else {
|
|
1206
|
+
instance.localCallbacks.set(command.type, command.listener);
|
|
1207
|
+
if (instance.visible) invokeKeys.add(callbackKey);
|
|
1208
|
+
else invokeKeys.delete(callbackKey);
|
|
1209
|
+
}
|
|
1210
|
+
} else if (command.op === 'remove') {
|
|
1211
|
+
const instance = simulation.get(command.id);
|
|
1212
|
+
if (instance === undefined || !sameParent(instance.parent, command.parent)) {
|
|
1213
|
+
throw new Error(`@octanejs/three: Instance ${command.id} is not attached there.`);
|
|
1214
|
+
}
|
|
1215
|
+
detachSimulatedChild(
|
|
1216
|
+
rootChildren,
|
|
1217
|
+
portalChildren,
|
|
1218
|
+
state.portalTargets,
|
|
1219
|
+
simulation,
|
|
1220
|
+
command.id,
|
|
1221
|
+
);
|
|
1222
|
+
for (const type of instance.localCallbacks.keys()) {
|
|
1223
|
+
cleanupKeys.add(keyFor(command.id, type));
|
|
1224
|
+
}
|
|
1225
|
+
} else if (command.op === 'insert' || command.op === 'move') {
|
|
1226
|
+
const instance = simulation.get(command.id);
|
|
1227
|
+
if (instance === undefined) {
|
|
1228
|
+
throw new Error(`@octanejs/three: Unknown placement target ${command.id}.`);
|
|
1229
|
+
}
|
|
1230
|
+
detachSimulatedChild(
|
|
1231
|
+
rootChildren,
|
|
1232
|
+
portalChildren,
|
|
1233
|
+
state.portalTargets,
|
|
1234
|
+
simulation,
|
|
1235
|
+
command.id,
|
|
1236
|
+
);
|
|
1237
|
+
const siblings = simulatedChildren(
|
|
1238
|
+
rootChildren,
|
|
1239
|
+
portalChildren,
|
|
1240
|
+
state.portalTargets,
|
|
1241
|
+
simulation,
|
|
1242
|
+
command.parent,
|
|
1243
|
+
);
|
|
1244
|
+
const before =
|
|
1245
|
+
command.before === null ? siblings.length : siblings.indexOf(command.before);
|
|
1246
|
+
if (before === -1) {
|
|
1247
|
+
throw new Error(`@octanejs/three: Unknown before target ${command.before}.`);
|
|
1248
|
+
}
|
|
1249
|
+
siblings.splice(before, 0, command.id);
|
|
1250
|
+
instance.parent = command.parent;
|
|
1251
|
+
if (command.op === 'move') {
|
|
1252
|
+
for (const type of instance.localCallbacks.keys()) {
|
|
1253
|
+
const callbackKey = keyFor(command.id, type);
|
|
1254
|
+
cleanupKeys.add(callbackKey);
|
|
1255
|
+
if (instance.visible) invokeKeys.add(callbackKey);
|
|
1256
|
+
else invokeKeys.delete(callbackKey);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
} else if (command.op === 'visibility') {
|
|
1260
|
+
const instance = simulation.get(command.id);
|
|
1261
|
+
if (instance === undefined) {
|
|
1262
|
+
throw new Error(`@octanejs/three: Unknown visibility target ${command.id}.`);
|
|
1263
|
+
}
|
|
1264
|
+
instance.visible = command.state === 'visible';
|
|
1265
|
+
for (const type of instance.localCallbacks.keys()) {
|
|
1266
|
+
const callbackKey = keyFor(command.id, type);
|
|
1267
|
+
cleanupKeys.add(callbackKey);
|
|
1268
|
+
if (instance.visible) invokeKeys.add(callbackKey);
|
|
1269
|
+
else invokeKeys.delete(callbackKey);
|
|
1270
|
+
}
|
|
1271
|
+
} else if (command.op === 'destroy') {
|
|
1272
|
+
const instance = simulation.get(command.id);
|
|
1273
|
+
if (instance === undefined) {
|
|
1274
|
+
throw new Error(`@octanejs/three: Unknown destroy target ${command.id}.`);
|
|
1275
|
+
}
|
|
1276
|
+
detachSimulatedChild(
|
|
1277
|
+
rootChildren,
|
|
1278
|
+
portalChildren,
|
|
1279
|
+
state.portalTargets,
|
|
1280
|
+
simulation,
|
|
1281
|
+
command.id,
|
|
1282
|
+
);
|
|
1283
|
+
instance.children.length = 0;
|
|
1284
|
+
simulation.delete(command.id);
|
|
1285
|
+
destroyed.add(command.id);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const finalObject = (id: number): any => {
|
|
1290
|
+
const created = stagedCreates.get(id)?.staged;
|
|
1291
|
+
if (created !== undefined) return created.object;
|
|
1292
|
+
const replacement = stagedReplacements.get(id);
|
|
1293
|
+
if (replacement !== undefined) return replacement.object;
|
|
1294
|
+
const committed = state.instances.get(id);
|
|
1295
|
+
if (committed === undefined) {
|
|
1296
|
+
throw new Error(`@octanejs/three: Unknown final instance ${id}.`);
|
|
1297
|
+
}
|
|
1298
|
+
return committed.object;
|
|
1299
|
+
};
|
|
1300
|
+
const finalPortalObject = (handle: UniversalPortalTargetHandle): THREE.Object3D => {
|
|
1301
|
+
const registration = portalRegistration(state, handle);
|
|
1302
|
+
if (registration.source.kind === 'external') return registration.source.object;
|
|
1303
|
+
if (!simulation.has(registration.source.id)) {
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
'@octanejs/three: Cannot retain a portal whose managed target is unmounting.',
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
const object = finalObject(registration.source.id);
|
|
1309
|
+
if (!isObject3D(object)) {
|
|
1310
|
+
throw new Error('@octanejs/three: Managed portal target is not an Object3D.');
|
|
1311
|
+
}
|
|
1312
|
+
return object;
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
const finalObjectParents = new Map<THREE.Object3D, THREE.Object3D>();
|
|
1316
|
+
for (const [id, instance] of simulation) {
|
|
1317
|
+
if (instance.parent === undefined || instance.localCallbacks.has('attach')) continue;
|
|
1318
|
+
const object = finalObject(id);
|
|
1319
|
+
if (!isObject3D(object)) continue;
|
|
1320
|
+
const path = getEffectiveAttachment(
|
|
1321
|
+
object,
|
|
1322
|
+
instance.props.attach as string | null | undefined,
|
|
1323
|
+
);
|
|
1324
|
+
if (typeof path === 'string') continue;
|
|
1325
|
+
|
|
1326
|
+
const parent =
|
|
1327
|
+
instance.parent === null
|
|
1328
|
+
? container.scene
|
|
1329
|
+
: isPortalParent(instance.parent)
|
|
1330
|
+
? finalPortalObject(instance.parent)
|
|
1331
|
+
: finalObject(instance.parent);
|
|
1332
|
+
if (isObject3D(parent)) finalObjectParents.set(object, parent);
|
|
1333
|
+
}
|
|
1334
|
+
for (const [object, parent] of finalObjectParents) {
|
|
1335
|
+
const seen = new Set<THREE.Object3D>([object]);
|
|
1336
|
+
let ancestor: THREE.Object3D | null = parent;
|
|
1337
|
+
while (ancestor !== null) {
|
|
1338
|
+
if (seen.has(ancestor)) {
|
|
1339
|
+
throw new Error('@octanejs/three: Portal placement would create an Object3D cycle.');
|
|
1340
|
+
}
|
|
1341
|
+
seen.add(ancestor);
|
|
1342
|
+
ancestor = finalObjectParents.get(ancestor) ?? ancestor.parent;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// String attachments are physical mutations, so validate their final
|
|
1347
|
+
// parent path while the batch is still rejectable. Parent prop patches
|
|
1348
|
+
// are modeled as a pure overlay instead of touching committed objects.
|
|
1349
|
+
for (const [id, instance] of simulation) {
|
|
1350
|
+
if (instance.parent === undefined || instance.localCallbacks.has('attach')) continue;
|
|
1351
|
+
const object = finalObject(id);
|
|
1352
|
+
const path = getEffectiveAttachment(
|
|
1353
|
+
object,
|
|
1354
|
+
instance.props.attach as string | null | undefined,
|
|
1355
|
+
);
|
|
1356
|
+
if (typeof path !== 'string') continue;
|
|
1357
|
+
|
|
1358
|
+
let parent: object;
|
|
1359
|
+
let overrides: Readonly<Record<string, unknown>> = {};
|
|
1360
|
+
if (instance.parent === null) {
|
|
1361
|
+
parent = container.scene;
|
|
1362
|
+
} else if (isPortalParent(instance.parent)) {
|
|
1363
|
+
parent = finalPortalObject(instance.parent);
|
|
1364
|
+
} else {
|
|
1365
|
+
const parentId = instance.parent;
|
|
1366
|
+
const parentSimulation = simulation.get(parentId);
|
|
1367
|
+
if (parentSimulation === undefined) {
|
|
1368
|
+
throw new Error(`@octanejs/three: Unknown final parent ${parentId}.`);
|
|
1369
|
+
}
|
|
1370
|
+
parent = finalObject(parentId);
|
|
1371
|
+
const stagedParent =
|
|
1372
|
+
stagedCreates.get(parentId)?.staged ?? stagedReplacements.get(parentId);
|
|
1373
|
+
if (stagedParent?.propsApplied !== true) {
|
|
1374
|
+
const previous =
|
|
1375
|
+
stagedParent === undefined ? (state.instances.get(parentId)?.props ?? {}) : {};
|
|
1376
|
+
overrides = diffThreeProps(parent, parentSimulation.props, previous);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
validateStringAttachment(parent, path, overrides);
|
|
1380
|
+
}
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
for (const object of unpublishedOwned) disposeOwnedNow(object);
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1385
|
+
const previousInteractions = new Map<number, InteractionSnapshot | undefined>();
|
|
1386
|
+
for (const [id, instance] of state.instances) {
|
|
1387
|
+
previousInteractions.set(
|
|
1388
|
+
id,
|
|
1389
|
+
interactionSnapshot(
|
|
1390
|
+
id,
|
|
1391
|
+
instance.object,
|
|
1392
|
+
instance.events.size,
|
|
1393
|
+
state.instances,
|
|
1394
|
+
state.portalTargets,
|
|
1395
|
+
container.environment.store,
|
|
1396
|
+
),
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
let status: 'prepared' | 'applied' | 'aborted' = 'prepared';
|
|
1401
|
+
let callbacksRan = false;
|
|
1402
|
+
return {
|
|
1403
|
+
apply() {
|
|
1404
|
+
if (status !== 'prepared') return;
|
|
1405
|
+
status = 'applied';
|
|
1406
|
+
const tasks: Array<() => void> = [];
|
|
1407
|
+
|
|
1408
|
+
for (const callbackKey of cleanupKeys) {
|
|
1409
|
+
const [id, type] = parseKey(callbackKey);
|
|
1410
|
+
tasks.push(() => {
|
|
1411
|
+
const cleanups = state.instances.get(id)?.localCleanups;
|
|
1412
|
+
const cleanup = cleanups?.get(type);
|
|
1413
|
+
if (cleanup === undefined) return;
|
|
1414
|
+
cleanups!.delete(type);
|
|
1415
|
+
cleanup();
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
for (const [id, { instance, staged }] of stagedCreates) {
|
|
1420
|
+
tasks.push(() => {
|
|
1421
|
+
state.instances.set(id, instance);
|
|
1422
|
+
OBJECT_INSTANCES.set(instance.object, instance);
|
|
1423
|
+
unpublishedOwned.delete(staged.object);
|
|
1424
|
+
if (!staged.propsApplied) {
|
|
1425
|
+
applyThreeProps(instance.object, instance.props, undefined, {
|
|
1426
|
+
colorSpace: container.environment.linear !== true,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
for (const command of batch.commands) {
|
|
1433
|
+
if (command.op === 'update') {
|
|
1434
|
+
tasks.push(() => {
|
|
1435
|
+
const instance = state.instances.get(command.id)!;
|
|
1436
|
+
const previous = instance.props;
|
|
1437
|
+
instance.props = command.props;
|
|
1438
|
+
applyThreeProps(instance.object, command.props, previous, {
|
|
1439
|
+
colorSpace: container.environment.linear !== true,
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
} else if (command.op === 'recreate') {
|
|
1443
|
+
const replacement = stagedReplacements.get(command.id)!;
|
|
1444
|
+
if (!replacement.propsApplied) {
|
|
1445
|
+
tasks.push(() => {
|
|
1446
|
+
applyThreeProps(replacement.object, command.props, undefined, {
|
|
1447
|
+
colorSpace: container.environment.linear !== true,
|
|
1448
|
+
});
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
tasks.push(() => {
|
|
1452
|
+
const previous = previousInteractions.get(command.id);
|
|
1453
|
+
const simulated = simulation.get(command.id);
|
|
1454
|
+
const next =
|
|
1455
|
+
simulated === undefined
|
|
1456
|
+
? undefined
|
|
1457
|
+
: interactionSnapshot(
|
|
1458
|
+
command.id,
|
|
1459
|
+
replacement.object,
|
|
1460
|
+
simulated.events.size,
|
|
1461
|
+
simulation,
|
|
1462
|
+
state.portalTargets,
|
|
1463
|
+
container.environment.store,
|
|
1464
|
+
);
|
|
1465
|
+
reconcileInteractivity(previous, next, true);
|
|
1466
|
+
});
|
|
1467
|
+
tasks.push(() => {
|
|
1468
|
+
const instance = state.instances.get(command.id)!;
|
|
1469
|
+
const previousObject = instance.object;
|
|
1470
|
+
const previousOwned = instance.owned;
|
|
1471
|
+
const previousProps = instance.props;
|
|
1472
|
+
OBJECT_INSTANCES.delete(previousObject);
|
|
1473
|
+
instance.object = replacement.object;
|
|
1474
|
+
instance.owned = replacement.owned;
|
|
1475
|
+
instance.props = command.props;
|
|
1476
|
+
instance.type = replacement.type;
|
|
1477
|
+
OBJECT_INSTANCES.set(instance.object, instance);
|
|
1478
|
+
unpublishedOwned.delete(replacement.object);
|
|
1479
|
+
if (isObject3D(previousObject)) {
|
|
1480
|
+
previousObject.visible = previousProps.visible !== false;
|
|
1481
|
+
}
|
|
1482
|
+
if (previousOwned && instance.type !== 'primitive') {
|
|
1483
|
+
enqueueDisposal(container, previousObject);
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
tasks.push(() => {
|
|
1490
|
+
state.rootChildren.splice(0, state.rootChildren.length, ...rootChildren);
|
|
1491
|
+
state.portalChildren.clear();
|
|
1492
|
+
for (const [target, children] of portalChildren) {
|
|
1493
|
+
state.portalChildren.set(target, [...children]);
|
|
1494
|
+
}
|
|
1495
|
+
for (const [id, simulated] of simulation) {
|
|
1496
|
+
const instance = state.instances.get(id)!;
|
|
1497
|
+
instance.type = simulated.type;
|
|
1498
|
+
instance.props = simulated.props;
|
|
1499
|
+
instance.parent = simulated.parent;
|
|
1500
|
+
instance.children.splice(0, instance.children.length, ...simulated.children);
|
|
1501
|
+
instance.visible = simulated.visible;
|
|
1502
|
+
instance.events.clear();
|
|
1503
|
+
for (const entry of simulated.events) instance.events.set(...entry);
|
|
1504
|
+
instance.lifecycles.clear();
|
|
1505
|
+
for (const entry of simulated.lifecycles) instance.lifecycles.set(...entry);
|
|
1506
|
+
instance.localCallbacks.clear();
|
|
1507
|
+
for (const entry of simulated.localCallbacks) instance.localCallbacks.set(...entry);
|
|
1508
|
+
instance.store = storeForInstance(
|
|
1509
|
+
id,
|
|
1510
|
+
simulation,
|
|
1511
|
+
state.portalTargets,
|
|
1512
|
+
container.environment.store,
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
tasks.push(() => {
|
|
1518
|
+
for (const [id, simulated] of simulation) {
|
|
1519
|
+
if (stagedReplacements.has(id)) continue;
|
|
1520
|
+
const instance = state.instances.get(id)!;
|
|
1521
|
+
const interaction = interactionSnapshot(
|
|
1522
|
+
id,
|
|
1523
|
+
instance.object,
|
|
1524
|
+
simulated.events.size,
|
|
1525
|
+
simulation,
|
|
1526
|
+
state.portalTargets,
|
|
1527
|
+
container.environment.store,
|
|
1528
|
+
);
|
|
1529
|
+
reconcileInteractivity(previousInteractions.get(id), interaction, false);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
tasks.push(() => synchronizePhysicalTree(container, state, destroyed));
|
|
1534
|
+
|
|
1535
|
+
for (const id of destroyed) {
|
|
1536
|
+
tasks.push(() => {
|
|
1537
|
+
const instance = state.instances.get(id);
|
|
1538
|
+
if (instance === undefined) return;
|
|
1539
|
+
if (instance.store !== undefined && isObject3D(instance.object)) {
|
|
1540
|
+
removeInteractivity(getInitialRootStore(instance.store), instance.object);
|
|
1541
|
+
}
|
|
1542
|
+
// Visibility is a renderer-owned retention overlay. Do not leak a
|
|
1543
|
+
// suspense/activity-hidden flag onto an object after ownership ends;
|
|
1544
|
+
// restore the authored value before refs and consumers can observe it.
|
|
1545
|
+
if (isObject3D(instance.object)) {
|
|
1546
|
+
instance.object.visible = instance.props.visible !== false;
|
|
1547
|
+
}
|
|
1548
|
+
if (shouldDisposeRemoved(instance, state, destroyed)) {
|
|
1549
|
+
enqueueDisposal(container, instance.object);
|
|
1550
|
+
}
|
|
1551
|
+
OBJECT_INSTANCES.delete(instance.object);
|
|
1552
|
+
instance.localCleanups.clear();
|
|
1553
|
+
instance.events.clear();
|
|
1554
|
+
instance.lifecycles.clear();
|
|
1555
|
+
instance.localCallbacks.clear();
|
|
1556
|
+
instance.store = undefined;
|
|
1557
|
+
instance.parent = undefined;
|
|
1558
|
+
instance.children.length = 0;
|
|
1559
|
+
state.instances.delete(id);
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
tasks.push(() => container.commits.push(batch));
|
|
1564
|
+
tasks.push(() => container.environment.invalidate?.());
|
|
1565
|
+
runAll(tasks);
|
|
1566
|
+
},
|
|
1567
|
+
afterAccept() {
|
|
1568
|
+
if (status !== 'applied' || callbacksRan) return;
|
|
1569
|
+
callbacksRan = true;
|
|
1570
|
+
const tasks: Array<() => void> = [];
|
|
1571
|
+
for (const callbackKey of invokeKeys) {
|
|
1572
|
+
const [id, type] = parseKey(callbackKey);
|
|
1573
|
+
tasks.push(() => {
|
|
1574
|
+
const instance = state.instances.get(id);
|
|
1575
|
+
const listener = instance?.localCallbacks.get(type);
|
|
1576
|
+
if (
|
|
1577
|
+
instance === undefined ||
|
|
1578
|
+
listener === undefined ||
|
|
1579
|
+
instance.parent === undefined ||
|
|
1580
|
+
!instance.visible
|
|
1581
|
+
) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const parent = objectForParent(container, state, instance.parent);
|
|
1585
|
+
const cleanup = context.invokeLocalCallback(listener.id, [parent, instance.object]);
|
|
1586
|
+
if (cleanup == null) return;
|
|
1587
|
+
if (typeof cleanup !== 'function') {
|
|
1588
|
+
throw new TypeError(
|
|
1589
|
+
'@octanejs/three: A function attachment must return a cleanup or nothing.',
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
instance.localCleanups.set(type, cleanup as () => void);
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
runAll(tasks);
|
|
1596
|
+
},
|
|
1597
|
+
abort() {
|
|
1598
|
+
if (status !== 'prepared') return;
|
|
1599
|
+
status = 'aborted';
|
|
1600
|
+
for (const object of unpublishedOwned) disposeOwnedNow(object);
|
|
1601
|
+
unpublishedOwned.clear();
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
},
|
|
1605
|
+
getPublicInstance(container, id) {
|
|
1606
|
+
return container[THREE_DRIVER_STATE].instances.get(id)?.object ?? null;
|
|
1607
|
+
},
|
|
1608
|
+
};
|
|
1609
|
+
}
|