@octanejs/three 0.1.0 → 0.1.2

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.
@@ -11,9 +11,11 @@ import type {
11
11
  UniversalEventListenerDescriptor,
12
12
  UniversalEventPriority,
13
13
  UniversalHostBatch,
14
+ UniversalHostCommand,
14
15
  UniversalHostDriver,
15
16
  UniversalListenerDescriptor,
16
17
  UniversalPortalTargetHandle,
18
+ UniversalPreparedHostBatch,
17
19
  } from 'octane/universal';
18
20
  import {
19
21
  getInitialRootStore,
@@ -22,7 +24,12 @@ import {
22
24
  swapInteractivity,
23
25
  type RootStore,
24
26
  } from './store.js';
25
- import { createThreeObject, registerThreeNamespace, THREE_RENDERER_ID } from './catalogue.js';
27
+ import {
28
+ createThreeObject,
29
+ registerThreeNamespace,
30
+ resolveThreeConstructor,
31
+ THREE_RENDERER_ID,
32
+ } from './catalogue.js';
26
33
  import {
27
34
  attachString,
28
35
  detachAttachment,
@@ -39,6 +46,9 @@ const STORE_CONTAINERS = new WeakMap<RootStore, ThreeHostContainer>();
39
46
  const ACTIVE_EVENT_SCOPES = new WeakSet<RootStore>();
40
47
  const THREE_PORTAL_TARGET = Symbol('octane.three.portal-target');
41
48
  const EXTERNAL_PORTAL_TARGET_LEASES = new WeakMap<THREE.Object3D, ExternalTargetLease>();
49
+ const EMPTY_THREE_EVENTS = new Map<string, UniversalEventListenerDescriptor>();
50
+ const EMPTY_THREE_CALLBACKS = new Map<string, UniversalListenerDescriptor>();
51
+ const THREE_VECTOR3_FROM_ARRAY = THREE.Vector3.prototype.fromArray;
42
52
 
43
53
  const THREE_EVENT_PRIORITIES: Readonly<Record<string, UniversalEventPriority>> = Object.freeze({
44
54
  onClick: 'discrete',
@@ -126,12 +136,13 @@ interface ThreeHostInstance {
126
136
  readonly children: number[];
127
137
  owned: boolean;
128
138
  visible: boolean;
129
- readonly events: Map<string, UniversalEventListenerDescriptor>;
130
- readonly lifecycles: Map<string, UniversalListenerDescriptor>;
131
- readonly localCallbacks: Map<string, UniversalListenerDescriptor>;
139
+ events: Map<string, UniversalEventListenerDescriptor>;
140
+ lifecycles: Map<string, UniversalListenerDescriptor>;
141
+ localCallbacks: Map<string, UniversalListenerDescriptor>;
132
142
  readonly localCleanups: Map<string, () => void>;
133
143
  store: RootStore | undefined;
134
144
  physical: PhysicalPlacement | null;
145
+ directLeaf: boolean;
135
146
  }
136
147
 
137
148
  interface SimulatedInstance {
@@ -155,6 +166,7 @@ interface StagedObject {
155
166
  interface ThreeDriverState {
156
167
  readonly instances: Map<number, ThreeHostInstance>;
157
168
  readonly rootChildren: number[];
169
+ directInstances: readonly ThreeHostInstance[] | null;
158
170
  readonly portalChildren: Map<string | number, number[]>;
159
171
  readonly portalTargets: Map<string | number, ThreePortalTargetDomain>;
160
172
  readonly portalTargetCache: WeakMap<RootStore, WeakMap<THREE.Object3D, ThreePortalTargetDomain>>;
@@ -192,6 +204,8 @@ export function createThreePortalTarget(
192
204
  export interface ThreeHostEnvironment {
193
205
  /** Called once after an accepted host batch, without requiring WebGL. */
194
206
  invalidate?(): void;
207
+ /** Set false to omit accepted batches from the public diagnostic history. */
208
+ recordCommits?: boolean;
195
209
  /** Root state associated with a configured managed scene. */
196
210
  readonly store?: RootStore;
197
211
  /** Run all Three handlers from one platform event in one universal scope. */
@@ -257,6 +271,7 @@ export function createThreeContainer(
257
271
  const state: ThreeDriverState = {
258
272
  instances: new Map(),
259
273
  rootChildren: [],
274
+ directInstances: null,
260
275
  portalChildren: new Map(),
261
276
  portalTargets: new Map(),
262
277
  portalTargetCache: new WeakMap(),
@@ -310,6 +325,254 @@ function cloneSimulation(state: ThreeDriverState): Map<number, SimulatedInstance
310
325
  return simulation;
311
326
  }
312
327
 
328
+ /**
329
+ * Retained prop patches do not change the logical or physical Three graph. Keep
330
+ * them transactional by validating every target up front, but avoid cloning and
331
+ * republishing the entire scene merely to apply ordinary object properties.
332
+ *
333
+ * String attachments are deliberately excluded: a parent prop patch can replace
334
+ * any segment of an attached child's path, which requires the general shadow
335
+ * validation and physical synchronization below. Interactive targets are also
336
+ * excluded because changing `raycast` can alter their root-store membership.
337
+ */
338
+ interface DirectMeshProps extends Readonly<Record<string, unknown>> {
339
+ readonly name: string;
340
+ readonly position: readonly [number, number, number];
341
+ }
342
+
343
+ function isDirectMeshProps(props: Readonly<Record<string, unknown>>): props is DirectMeshProps {
344
+ let nextName = 'name';
345
+ for (const name in props) {
346
+ if (!Object.prototype.hasOwnProperty.call(props, name)) continue;
347
+ if (name !== nextName) return false;
348
+ nextName = name === 'name' ? 'position' : '';
349
+ }
350
+ const position = props.position;
351
+ // Eligibility must not read authored array indices: the general driver does
352
+ // not observe those values until an accepted batch is applied.
353
+ return nextName === '' && typeof props.name === 'string' && Array.isArray(position);
354
+ }
355
+
356
+ function prepareDirectLeafMountBatch(
357
+ container: ThreeHostContainer,
358
+ batch: UniversalHostBatch,
359
+ ): UniversalPreparedHostBatch | null {
360
+ const state = container[THREE_DRIVER_STATE];
361
+ if (
362
+ state.instances.size !== 0 ||
363
+ state.rootChildren.length !== 0 ||
364
+ state.portalTargets.size !== 0 ||
365
+ state.portalChildren.size !== 0 ||
366
+ container.scene.parent !== null ||
367
+ container.scene.children.length !== 0 ||
368
+ resolveThreeConstructor('mesh') !== THREE.Mesh
369
+ ) {
370
+ return null;
371
+ }
372
+
373
+ let createCount = 0;
374
+ while (batch.commands[createCount]?.op === 'create') createCount++;
375
+ if (createCount === 0 || batch.commands.length !== createCount * 2) return null;
376
+ const ids = new Set<number>();
377
+ for (let index = 0; index < createCount; index++) {
378
+ const create = batch.commands[index];
379
+ const placement = batch.commands[createCount + index];
380
+ if (
381
+ create.op !== 'create' ||
382
+ create.type !== 'mesh' ||
383
+ ids.has(create.id) ||
384
+ !isDirectMeshProps(create.props) ||
385
+ placement.op !== 'insert' ||
386
+ placement.id !== create.id ||
387
+ placement.parent !== null ||
388
+ placement.before !== null
389
+ ) {
390
+ return null;
391
+ }
392
+ ids.add(create.id);
393
+ }
394
+
395
+ const staged: ThreeHostInstance[] = [];
396
+ try {
397
+ for (let index = 0; index < createCount; index++) {
398
+ const command = batch.commands[index] as Extract<
399
+ UniversalHostCommand,
400
+ { readonly op: 'create' }
401
+ >;
402
+ const object = new THREE.Mesh();
403
+ staged.push(
404
+ createHostInstance(
405
+ container,
406
+ command.id,
407
+ { object, owned: true, type: 'mesh', propsApplied: true },
408
+ command.props,
409
+ true,
410
+ ),
411
+ );
412
+ applyThreeProps(object, command.props, undefined, {
413
+ colorSpace: container.environment.linear !== true,
414
+ });
415
+ }
416
+ } catch (error) {
417
+ for (const instance of staged) disposeOwnedNow(instance.object);
418
+ throw error;
419
+ }
420
+
421
+ let status: 'prepared' | 'applied' | 'aborted' = 'prepared';
422
+ return {
423
+ apply() {
424
+ if (status !== 'prepared') return;
425
+ status = 'applied';
426
+ state.directInstances = staged;
427
+ let failed = false;
428
+ let firstError: unknown;
429
+ for (const instance of staged) {
430
+ instance.directLeaf = true;
431
+ state.instances.set(instance.id, instance);
432
+ state.rootChildren.push(instance.id);
433
+ instance.parent = null;
434
+ OBJECT_INSTANCES.set(instance.object, instance);
435
+ try {
436
+ container.scene.add(instance.object);
437
+ instance.physical = {
438
+ kind: 'object3d',
439
+ object: instance.object,
440
+ parent: container.scene,
441
+ };
442
+ } catch (error) {
443
+ if (!failed) {
444
+ failed = true;
445
+ firstError = error;
446
+ }
447
+ }
448
+ }
449
+ if (container.environment.recordCommits !== false) {
450
+ try {
451
+ container.commits.push(batch);
452
+ } catch (error) {
453
+ if (!failed) {
454
+ failed = true;
455
+ firstError = error;
456
+ }
457
+ }
458
+ }
459
+ try {
460
+ container.environment.invalidate?.();
461
+ } catch (error) {
462
+ if (!failed) {
463
+ failed = true;
464
+ firstError = error;
465
+ }
466
+ }
467
+ if (failed) throw firstError;
468
+ },
469
+ abort() {
470
+ if (status !== 'prepared') return;
471
+ status = 'aborted';
472
+ for (const instance of staged) disposeOwnedNow(instance.object);
473
+ },
474
+ };
475
+ }
476
+
477
+ function prepareUpdateOnlyBatch(
478
+ container: ThreeHostContainer,
479
+ batch: UniversalHostBatch,
480
+ ): UniversalPreparedHostBatch | null {
481
+ if (batch.commands.length === 0) return null;
482
+
483
+ const state = container[THREE_DRIVER_STATE];
484
+ const instances = state.directInstances;
485
+ if (
486
+ instances === null ||
487
+ batch.commands.length !== state.rootChildren.length ||
488
+ batch.commands.length !== instances.length ||
489
+ state.rootChildren.length !== state.instances.size ||
490
+ container.scene.children.length !== state.rootChildren.length ||
491
+ container.scene.parent !== null
492
+ ) {
493
+ return null;
494
+ }
495
+ for (let index = 0; index < batch.commands.length; index++) {
496
+ const command = batch.commands[index];
497
+ if (command.op !== 'update') return null;
498
+ const instance = instances[index];
499
+ if (command.id !== instance.id) return null;
500
+ const nameDescriptor = Object.getOwnPropertyDescriptor(instance.object, 'name');
501
+ if (
502
+ !instance.directLeaf ||
503
+ instance.physical?.kind !== 'object3d' ||
504
+ instance.physical.parent !== container.scene ||
505
+ instance.object.parent !== container.scene ||
506
+ container.scene.children[index] !== instance.object ||
507
+ instance.object.visible !== true ||
508
+ nameDescriptor === undefined ||
509
+ !('value' in nameDescriptor) ||
510
+ nameDescriptor.writable !== true ||
511
+ instance.object.position.fromArray !== THREE_VECTOR3_FROM_ARRAY
512
+ ) {
513
+ return null;
514
+ }
515
+ if (!isDirectMeshProps(command.props)) return null;
516
+ }
517
+
518
+ let status: 'prepared' | 'applied' | 'aborted' = 'prepared';
519
+ return {
520
+ apply() {
521
+ if (status !== 'prepared') return;
522
+ status = 'applied';
523
+ let failed = false;
524
+ let firstError: unknown;
525
+ for (let index = 0; index < instances.length; index++) {
526
+ try {
527
+ const instance = instances[index];
528
+ const previous = instance.props as DirectMeshProps;
529
+ const next = (
530
+ batch.commands[index] as Extract<UniversalHostCommand, { readonly op: 'update' }>
531
+ ).props as DirectMeshProps;
532
+ instance.props = next;
533
+ if (previous.name !== next.name) instance.object.name = next.name;
534
+ const previousPosition = previous.position;
535
+ const nextPosition = next.position;
536
+ if (
537
+ previousPosition[0] !== nextPosition[0] ||
538
+ previousPosition[1] !== nextPosition[1] ||
539
+ previousPosition[2] !== nextPosition[2]
540
+ ) {
541
+ instance.object.position.fromArray(nextPosition);
542
+ }
543
+ } catch (error) {
544
+ if (!failed) {
545
+ failed = true;
546
+ firstError = error;
547
+ }
548
+ }
549
+ }
550
+ if (container.environment.recordCommits !== false) {
551
+ try {
552
+ container.commits.push(batch);
553
+ } catch (error) {
554
+ if (!failed) {
555
+ failed = true;
556
+ firstError = error;
557
+ }
558
+ }
559
+ }
560
+ try {
561
+ container.environment.invalidate?.();
562
+ } catch (error) {
563
+ if (!failed) {
564
+ failed = true;
565
+ firstError = error;
566
+ }
567
+ }
568
+ if (failed) throw firstError;
569
+ },
570
+ abort() {
571
+ if (status === 'prepared') status = 'aborted';
572
+ },
573
+ };
574
+ }
575
+
313
576
  function isPortalParent(parent: ParentId): parent is UniversalPortalTargetHandle {
314
577
  return (
315
578
  parent !== null &&
@@ -774,6 +1037,7 @@ function createHostInstance(
774
1037
  id: number,
775
1038
  staged: StagedObject,
776
1039
  props: Readonly<Record<string, unknown>>,
1040
+ directLeaf = false,
777
1041
  ): ThreeHostInstance {
778
1042
  return {
779
1043
  id,
@@ -785,12 +1049,13 @@ function createHostInstance(
785
1049
  children: [],
786
1050
  owned: staged.owned,
787
1051
  visible: true,
788
- events: new Map(),
789
- lifecycles: new Map(),
790
- localCallbacks: new Map(),
1052
+ events: directLeaf ? EMPTY_THREE_EVENTS : new Map(),
1053
+ lifecycles: directLeaf ? EMPTY_THREE_CALLBACKS : new Map(),
1054
+ localCallbacks: directLeaf ? EMPTY_THREE_CALLBACKS : new Map(),
791
1055
  localCleanups: new Map(),
792
1056
  store: container.environment.store,
793
1057
  physical: null,
1058
+ directLeaf,
794
1059
  };
795
1060
  }
796
1061
 
@@ -947,7 +1212,12 @@ export function createThreeDriver(
947
1212
  registerThreeNamespace();
948
1213
  return {
949
1214
  id: renderer,
950
- capabilities: { text: 'ignore', localHostCallbacks: true, visibility: true },
1215
+ capabilities: {
1216
+ text: 'ignore',
1217
+ localHostCallbacks: true,
1218
+ visibility: true,
1219
+ compilerLeafProps: true,
1220
+ },
951
1221
  events: {
952
1222
  classify(name) {
953
1223
  const priority = getThreeHostEventPriority(name);
@@ -1121,6 +1391,10 @@ export function createThreeDriver(
1121
1391
  );
1122
1392
  }
1123
1393
  const state = container[THREE_DRIVER_STATE];
1394
+ const directLeafMount = prepareDirectLeafMountBatch(container, batch);
1395
+ if (directLeafMount !== null) return directLeafMount;
1396
+ const updateOnly = prepareUpdateOnlyBatch(container, batch);
1397
+ if (updateOnly !== null) return updateOnly;
1124
1398
  const simulation = cloneSimulation(state);
1125
1399
  const rootChildren = [...state.rootChildren];
1126
1400
  const portalChildren = clonePortalChildren(state);
@@ -1487,6 +1761,7 @@ export function createThreeDriver(
1487
1761
  }
1488
1762
 
1489
1763
  tasks.push(() => {
1764
+ state.directInstances = null;
1490
1765
  state.rootChildren.splice(0, state.rootChildren.length, ...rootChildren);
1491
1766
  state.portalChildren.clear();
1492
1767
  for (const [target, children] of portalChildren) {
@@ -1499,12 +1774,19 @@ export function createThreeDriver(
1499
1774
  instance.parent = simulated.parent;
1500
1775
  instance.children.splice(0, instance.children.length, ...simulated.children);
1501
1776
  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);
1777
+ if (instance.directLeaf) {
1778
+ instance.events = new Map(simulated.events);
1779
+ instance.lifecycles = new Map(simulated.lifecycles);
1780
+ instance.localCallbacks = new Map(simulated.localCallbacks);
1781
+ } else {
1782
+ instance.events.clear();
1783
+ for (const entry of simulated.events) instance.events.set(...entry);
1784
+ instance.lifecycles.clear();
1785
+ for (const entry of simulated.lifecycles) instance.lifecycles.set(...entry);
1786
+ instance.localCallbacks.clear();
1787
+ for (const entry of simulated.localCallbacks) instance.localCallbacks.set(...entry);
1788
+ }
1789
+ instance.directLeaf = false;
1508
1790
  instance.store = storeForInstance(
1509
1791
  id,
1510
1792
  simulation,
@@ -1560,7 +1842,9 @@ export function createThreeDriver(
1560
1842
  });
1561
1843
  }
1562
1844
 
1563
- tasks.push(() => container.commits.push(batch));
1845
+ if (container.environment.recordCommits !== false) {
1846
+ tasks.push(() => container.commits.push(batch));
1847
+ }
1564
1848
  tasks.push(() => container.environment.invalidate?.());
1565
1849
  runAll(tasks);
1566
1850
  },
package/src/core/hooks.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  import { getThreeEventStore, getThreeInstance, type Instance } from './driver.js';
18
18
  import {
19
19
  getRootObjectStore,
20
+ readRootStoreRenderSnapshot,
20
21
  RootStoreContext,
22
+ RootStoreRenderSnapshotContext,
21
23
  useRootStoreSelector,
22
24
  type RenderCallback,
23
25
  type RootState,
@@ -86,6 +88,7 @@ export function useThree<T = RootState>(...args: unknown[]): T {
86
88
  const selector = (typeof userArgs[0] === 'function' ? userArgs[0] : identity) as Selector<T>;
87
89
  const equalityFn = (typeof userArgs[1] === 'function' ? userArgs[1] : Object.is) as EqualityFn<T>;
88
90
  const store = useStore();
91
+ const renderSnapshot = useContext(RootStoreRenderSnapshotContext);
89
92
  const cache = useRef<
90
93
  | {
91
94
  readonly state: RootState;
@@ -96,7 +99,7 @@ export function useThree<T = RootState>(...args: unknown[]): T {
96
99
  >(undefined, subSlot(slot, 'useThree:cache'));
97
100
 
98
101
  const getSnapshot = (): T => {
99
- const state = store.getState();
102
+ const state = readRootStoreRenderSnapshot(store, renderSnapshot);
100
103
  const previous = cache.current;
101
104
  if (
102
105
  previous !== undefined &&
package/src/core/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  export { useLoader } from './loader.js';
13
13
  export { createPortal } from './portal.js';
14
14
  export { dispose } from './props.js';
15
+ export type { Disposable } from './props.js';
15
16
  export {
16
17
  addAfterEffect,
17
18
  addEffect,
@@ -26,15 +27,26 @@ export { createEvents } from './events.js';
26
27
  export type {
27
28
  Args,
28
29
  Attach,
30
+ AttachFnType,
31
+ AttachType,
29
32
  Catalogue,
33
+ Color,
30
34
  ConstructorRepresentation,
35
+ ElementProps,
36
+ Euler,
31
37
  EventProps,
32
38
  InstanceProps,
39
+ Layers,
33
40
  MathRepresentation,
41
+ MathProps,
34
42
  MathType,
35
43
  MathTypes,
44
+ Matrix3,
45
+ Matrix4,
36
46
  PrimitiveProps,
47
+ Quaternion,
37
48
  RaycastableRepresentation,
49
+ ReactProps,
38
50
  ThreeElement,
39
51
  ThreeElements,
40
52
  ThreeInstanceProps,
@@ -42,6 +54,9 @@ export type {
42
54
  ThreeRef,
43
55
  ThreeToElements,
44
56
  ThreeToJSXElements,
57
+ Vector2,
58
+ Vector3,
59
+ Vector4,
45
60
  VectorRepresentation,
46
61
  } from './catalogue.js';
47
62
  export type {
@@ -66,6 +81,7 @@ export type {
66
81
  CanvasLike,
67
82
  DefaultGLProps,
68
83
  GLProps,
84
+ ThreeRoot as ReconcilerRoot,
69
85
  RenderProps,
70
86
  ThreeRoot,
71
87
  } from './root.js';