@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.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Renderer-neutral ray and pointer event handling for the Three host.
3
+ *
4
+ * The picking, bubbling, hover, miss, propagation, and capture behavior follows
5
+ * React Three Fiber 9.6.1. Listener invocation goes through Octane's universal
6
+ * event scope so one native event observes one accepted listener table and
7
+ * schedules at most one outer flush.
8
+ */
9
+ import * as THREE from 'three';
10
+ import type { UniversalEventListenerDescriptor, UniversalEventPriority } from 'octane/universal';
11
+ import {
12
+ dispatchThreeEvent,
13
+ getThreeHostEventPriority,
14
+ getThreeEventListener,
15
+ getThreeEventStore,
16
+ hasThreeEventListeners,
17
+ isThreeEventHitLive,
18
+ runThreeEventScope,
19
+ } from './driver.js';
20
+ import {
21
+ clearInternalPointerCaptures,
22
+ createPointerCaptureIdentity,
23
+ makeIntersectionId,
24
+ registerPointerCaptureFacade,
25
+ releaseInternalPointerCapture,
26
+ setInternalPointerCapture,
27
+ type RootState,
28
+ type RootStore,
29
+ } from './store.js';
30
+
31
+ type NonFunctionKeys<T> = {
32
+ [K in keyof T]-?: T[K] extends (...args: any[]) => any ? never : K;
33
+ }[keyof T];
34
+ type Properties<T> = Pick<T, NonFunctionKeys<T>>;
35
+
36
+ export interface Intersection extends THREE.Intersection<THREE.Object3D> {
37
+ /** The object which registered the handler receiving this intersection. */
38
+ eventObject: THREE.Object3D;
39
+ }
40
+
41
+ export interface IntersectionEvent<TSourceEvent> extends Intersection {
42
+ /** All intersections participating in this dispatch, in event order. */
43
+ intersections: Intersection[];
44
+ /** `vec3.set(pointer.x, pointer.y, 0).unproject(camera)`. */
45
+ unprojectedPoint: THREE.Vector3;
46
+ /** Normalized device coordinates for the event. */
47
+ pointer: THREE.Vector2;
48
+ /** Rounded pixel distance from the most recent pointer down. */
49
+ delta: number;
50
+ /** The ray which produced the intersection. */
51
+ ray: THREE.Ray;
52
+ /** The camera used by the raycaster. */
53
+ camera: Camera;
54
+ /** Stop deeper intersections and ancestors from receiving this event. */
55
+ stopPropagation(): void;
56
+ /** The original host event. */
57
+ nativeEvent: TSourceEvent;
58
+ /** Whether a handler stopped this dispatch. */
59
+ stopped: boolean;
60
+ /** Pointer-capture facade for the listener-owning Three object. */
61
+ target: {
62
+ hasPointerCapture(pointerId: number): boolean;
63
+ setPointerCapture(pointerId: number): void;
64
+ releasePointerCapture(pointerId: number): void;
65
+ };
66
+ /** Pointer-capture facade for the current listener-owning Three object. */
67
+ currentTarget: {
68
+ hasPointerCapture(pointerId: number): boolean;
69
+ setPointerCapture(pointerId: number): void;
70
+ releasePointerCapture(pointerId: number): void;
71
+ };
72
+ }
73
+
74
+ export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera;
75
+ export type ThreeEvent<TEvent> = IntersectionEvent<TEvent> & Properties<TEvent>;
76
+ export type DomEvent = PointerEvent | MouseEvent | WheelEvent;
77
+
78
+ export interface Events {
79
+ onClick: EventListener;
80
+ onContextMenu: EventListener;
81
+ onDoubleClick: EventListener;
82
+ onWheel: EventListener;
83
+ onPointerDown: EventListener;
84
+ onPointerUp: EventListener;
85
+ onPointerLeave: EventListener;
86
+ onPointerMove: EventListener;
87
+ onPointerCancel: EventListener;
88
+ onLostPointerCapture: EventListener;
89
+ }
90
+
91
+ export interface EventHandlers {
92
+ onClick?: (event: ThreeEvent<MouseEvent>) => void;
93
+ onContextMenu?: (event: ThreeEvent<MouseEvent>) => void;
94
+ onDoubleClick?: (event: ThreeEvent<MouseEvent>) => void;
95
+ onPointerUp?: (event: ThreeEvent<PointerEvent>) => void;
96
+ onPointerDown?: (event: ThreeEvent<PointerEvent>) => void;
97
+ onPointerOver?: (event: ThreeEvent<PointerEvent>) => void;
98
+ onPointerOut?: (event: ThreeEvent<PointerEvent>) => void;
99
+ onPointerEnter?: (event: ThreeEvent<PointerEvent>) => void;
100
+ onPointerLeave?: (event: ThreeEvent<PointerEvent>) => void;
101
+ onPointerMove?: (event: ThreeEvent<PointerEvent>) => void;
102
+ onPointerMissed?: (event: MouseEvent) => void;
103
+ onPointerCancel?: (event: ThreeEvent<PointerEvent>) => void;
104
+ onWheel?: (event: ThreeEvent<WheelEvent>) => void;
105
+ onLostPointerCapture?: (event: ThreeEvent<PointerEvent>) => void;
106
+ }
107
+
108
+ export type FilterFunction = (
109
+ items: THREE.Intersection<THREE.Object3D>[],
110
+ state: RootState,
111
+ ) => THREE.Intersection<THREE.Object3D>[];
112
+ export type ComputeFunction = (event: DomEvent, root: RootState, previous?: RootState) => void;
113
+
114
+ export interface EventManager<TTarget = unknown> {
115
+ /** Whether this event layer participates in picking. */
116
+ enabled: boolean;
117
+ /** Higher-priority event layers receive intersections first. */
118
+ priority: number;
119
+ /** Normalize the native event and configure the layer's raycaster. */
120
+ compute?: ComputeFunction;
121
+ /** Reorder or restructure the root layer's raw intersections. */
122
+ filter?: FilterFunction;
123
+ /** The host target currently connected to this manager. */
124
+ connected?: TTarget;
125
+ /** Native event forwarding handlers. */
126
+ handlers?: Events;
127
+ connect?(target: TTarget): void;
128
+ disconnect?(): void;
129
+ /** Re-run the most recent pointer move, for example after a camera move. */
130
+ update?(): void;
131
+ }
132
+
133
+ export interface PointerCaptureTarget {
134
+ intersection: Intersection;
135
+ target: Element;
136
+ }
137
+
138
+ const POINTER_MOVE_LISTENERS = [
139
+ 'onPointerMove',
140
+ 'onPointerOver',
141
+ 'onPointerEnter',
142
+ 'onPointerOut',
143
+ 'onPointerLeave',
144
+ ] as const;
145
+
146
+ const NATIVE_EVENT_NAMES: Readonly<Record<keyof Events, true>> = Object.freeze({
147
+ onClick: true,
148
+ onContextMenu: true,
149
+ onDoubleClick: true,
150
+ onWheel: true,
151
+ onPointerDown: true,
152
+ onPointerUp: true,
153
+ onPointerLeave: true,
154
+ onPointerMove: true,
155
+ onPointerCancel: true,
156
+ onLostPointerCapture: true,
157
+ });
158
+
159
+ /** Event priority shared by the host classifier and native event manager. */
160
+ export function getThreeEventPriority(name: keyof Events): UniversalEventPriority {
161
+ const priority = getThreeHostEventPriority(name);
162
+ if (priority === undefined)
163
+ throw new TypeError(`Unsupported Three event name ${JSON.stringify(name)}`);
164
+ return priority;
165
+ }
166
+
167
+ function isThreeEventName(name: string): name is keyof Events {
168
+ return Object.prototype.hasOwnProperty.call(NATIVE_EVENT_NAMES, name);
169
+ }
170
+
171
+ function raycastLiveObject(
172
+ raycaster: THREE.Raycaster,
173
+ object: THREE.Object3D,
174
+ intersections: THREE.Intersection<THREE.Object3D>[],
175
+ ): void {
176
+ if (!isThreeEventHitLive(object)) return;
177
+
178
+ let propagate = true;
179
+ if (object.layers.test(raycaster.layers)) {
180
+ const raycast = object.raycast as unknown;
181
+ if (typeof raycast === 'function') {
182
+ const result = raycast.call(object, raycaster, intersections);
183
+ if (result === false) propagate = false;
184
+ }
185
+ }
186
+
187
+ if (!propagate) return;
188
+ for (const child of object.children) {
189
+ raycastLiveObject(raycaster, child, intersections);
190
+ }
191
+ }
192
+
193
+ function findEventStore(object: THREE.Object3D): RootStore | undefined {
194
+ let candidate: THREE.Object3D | null = object;
195
+ while (candidate !== null) {
196
+ const store = getThreeEventStore(candidate);
197
+ if (store !== undefined) return store;
198
+ candidate = candidate.parent;
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function invokeListener(
204
+ store: RootStore,
205
+ listener: UniversalEventListenerDescriptor | undefined,
206
+ payload: unknown,
207
+ ): void {
208
+ if (listener !== undefined) dispatchThreeEvent(store, listener.id, payload);
209
+ }
210
+
211
+ /** Create the framework-neutral event dispatcher for one Three root store. */
212
+ export function createEvents(store: RootStore): {
213
+ handlePointer(name: string): EventListener;
214
+ } {
215
+ function calculateDistance(event: DomEvent): number {
216
+ const { internal } = store.getState();
217
+ const dx = event.offsetX - internal.initialClick[0];
218
+ const dy = event.offsetY - internal.initialClick[1];
219
+ return Math.round(Math.sqrt(dx * dx + dy * dy));
220
+ }
221
+
222
+ function filterPointerEvents(objects: THREE.Object3D[]): THREE.Object3D[] {
223
+ return objects.filter((object) =>
224
+ POINTER_MOVE_LISTENERS.some((name) => getThreeEventListener(object, name) !== undefined),
225
+ );
226
+ }
227
+
228
+ function intersect(
229
+ event: DomEvent,
230
+ filter?: (objects: THREE.Object3D[]) => THREE.Object3D[],
231
+ ): Intersection[] {
232
+ const rootState = store.getState();
233
+ const duplicates = new Set<string>();
234
+ const intersections: Intersection[] = [];
235
+ const eventObjects = filter
236
+ ? filter(rootState.internal.interaction)
237
+ : rootState.internal.interaction;
238
+
239
+ // Each event layer computes its ray lazily, once, for this native event.
240
+ for (const object of eventObjects) {
241
+ const objectStore = getThreeEventStore(object);
242
+ if (objectStore !== undefined) objectStore.getState().raycaster.camera = undefined!;
243
+ }
244
+
245
+ if (!rootState.previousRoot) {
246
+ rootState.events.compute?.(event, rootState);
247
+ }
248
+
249
+ function handleRaycast(object: THREE.Object3D): THREE.Intersection<THREE.Object3D>[] {
250
+ const objectStore = getThreeEventStore(object);
251
+ if (objectStore === undefined) return [];
252
+ const state = objectStore.getState();
253
+ if (!state.events.enabled || state.raycaster.camera === null) return [];
254
+
255
+ if (state.raycaster.camera === undefined) {
256
+ state.events.compute?.(event, state, state.previousRoot?.getState());
257
+ if (state.raycaster.camera === undefined) state.raycaster.camera = null!;
258
+ }
259
+
260
+ if (!state.raycaster.camera) return [];
261
+ const intersections: THREE.Intersection<THREE.Object3D>[] = [];
262
+ raycastLiveObject(state.raycaster, object, intersections);
263
+ return intersections.filter((intersection) => isThreeEventHitLive(intersection.object));
264
+ }
265
+
266
+ let hits = eventObjects
267
+ .flatMap(handleRaycast)
268
+ .sort((left, right) => {
269
+ const leftStore = findEventStore(left.object);
270
+ const rightStore = findEventStore(right.object);
271
+ if (leftStore === undefined || rightStore === undefined) {
272
+ return left.distance - right.distance;
273
+ }
274
+ return (
275
+ rightStore.getState().events.priority - leftStore.getState().events.priority ||
276
+ left.distance - right.distance
277
+ );
278
+ })
279
+ .filter((item) => {
280
+ const id = makeIntersectionId(item as Intersection);
281
+ if (duplicates.has(id)) return false;
282
+ duplicates.add(id);
283
+ return true;
284
+ });
285
+
286
+ if (rootState.events.filter !== undefined) {
287
+ hits = rootState.events.filter(hits, rootState);
288
+ }
289
+
290
+ // Bubble every physical hit through managed Three ancestors.
291
+ for (const hit of hits) {
292
+ let eventObject: THREE.Object3D | null = hit.object;
293
+ while (eventObject !== null) {
294
+ if (hasThreeEventListeners(eventObject)) {
295
+ intersections.push({ ...hit, eventObject });
296
+ }
297
+ eventObject = eventObject.parent;
298
+ }
299
+ }
300
+
301
+ // Pointer capture supplements ordinary intersections; it does not replace them.
302
+ if ('pointerId' in event) {
303
+ const captures = rootState.internal.capturedMap.get(event.pointerId);
304
+ if (captures !== undefined) {
305
+ for (const capture of captures.values()) {
306
+ if (!duplicates.has(makeIntersectionId(capture.intersection))) {
307
+ intersections.push(capture.intersection);
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ return intersections;
314
+ }
315
+
316
+ function cancelPointer(intersections: Intersection[]): void {
317
+ const { internal } = store.getState();
318
+ for (const hovered of internal.hovered.values()) {
319
+ const stillHovered = intersections.some(
320
+ (hit) =>
321
+ hit.object === hovered.object &&
322
+ hit.index === hovered.index &&
323
+ hit.instanceId === hovered.instanceId,
324
+ );
325
+ if (stillHovered) continue;
326
+
327
+ internal.hovered.delete(makeIntersectionId(hovered));
328
+ const eventStore = findEventStore(hovered.eventObject) ?? store;
329
+ const data = { ...hovered, intersections } as ThreeEvent<PointerEvent>;
330
+ invokeListener(eventStore, getThreeEventListener(hovered.eventObject, 'onPointerOut'), data);
331
+ invokeListener(
332
+ eventStore,
333
+ getThreeEventListener(hovered.eventObject, 'onPointerLeave'),
334
+ data,
335
+ );
336
+ }
337
+ }
338
+
339
+ function pointerMissed(event: MouseEvent, objects: THREE.Object3D[]): void {
340
+ for (const object of objects) {
341
+ const eventStore = getThreeEventStore(object) ?? store;
342
+ invokeListener(eventStore, getThreeEventListener(object, 'onPointerMissed'), event);
343
+ }
344
+ }
345
+
346
+ function handleIntersects(
347
+ intersections: Intersection[],
348
+ event: DomEvent,
349
+ delta: number,
350
+ callback: (event: ThreeEvent<DomEvent>) => void,
351
+ ): Intersection[] {
352
+ if (intersections.length === 0) return intersections;
353
+ const localState = { stopped: false };
354
+
355
+ for (const hit of intersections) {
356
+ const eventStore = findEventStore(hit.object);
357
+ if (eventStore === undefined) continue;
358
+ const state = eventStore.getState();
359
+ const { raycaster, pointer, camera, internal } = state;
360
+ const unprojectedPoint = new THREE.Vector3(pointer.x, pointer.y, 0).unproject(camera);
361
+
362
+ const captureIdentity = createPointerCaptureIdentity(hit);
363
+ const hasPointerCapture = (pointerId: number): boolean =>
364
+ internal.capturedMap.get(pointerId)?.has(captureIdentity.eventObject) ?? false;
365
+
366
+ const setPointerCapture = (pointerId: number): void => {
367
+ const target = event.target as Element;
368
+ const capture = { intersection: captureIdentity.intersection, target };
369
+ setInternalPointerCapture(
370
+ internal.capturedMap,
371
+ captureIdentity.eventObject,
372
+ capture,
373
+ captureIdentity,
374
+ pointerId,
375
+ );
376
+ target.setPointerCapture(pointerId);
377
+ };
378
+
379
+ const releasePointerCapture = (pointerId: number): void => {
380
+ const captures = internal.capturedMap.get(pointerId);
381
+ if (captures !== undefined) {
382
+ releaseInternalPointerCapture(
383
+ internal.capturedMap,
384
+ captureIdentity.eventObject,
385
+ captures,
386
+ pointerId,
387
+ );
388
+ }
389
+ };
390
+
391
+ const nativeProperties: Record<string, unknown> = {};
392
+ // Native event values commonly live on the prototype; enumerate both own
393
+ // and inherited atomics while leaving methods on nativeEvent.
394
+ for (const property in event) {
395
+ const value = event[property as keyof DomEvent];
396
+ if (typeof value !== 'function') nativeProperties[property] = value;
397
+ }
398
+
399
+ const target = { hasPointerCapture, setPointerCapture, releasePointerCapture };
400
+ const currentTarget = { hasPointerCapture, setPointerCapture, releasePointerCapture };
401
+ registerPointerCaptureFacade(target, captureIdentity);
402
+ registerPointerCaptureFacade(currentTarget, captureIdentity);
403
+
404
+ const raycastEvent = {
405
+ ...hit,
406
+ ...nativeProperties,
407
+ pointer,
408
+ intersections,
409
+ stopped: localState.stopped,
410
+ delta,
411
+ unprojectedPoint,
412
+ ray: raycaster.ray,
413
+ camera,
414
+ stopPropagation() {
415
+ const captures =
416
+ 'pointerId' in event ? internal.capturedMap.get(event.pointerId) : undefined;
417
+ if (captures === undefined || captures.has(hit.eventObject)) {
418
+ raycastEvent.stopped = localState.stopped = true;
419
+ const isHovered = Array.from(internal.hovered.values()).some(
420
+ (hovered) => hovered.eventObject === hit.eventObject,
421
+ );
422
+ if (internal.hovered.size > 0 && isHovered) {
423
+ const higher = intersections.slice(0, intersections.indexOf(hit));
424
+ cancelPointer([...higher, hit]);
425
+ }
426
+ }
427
+ },
428
+ target,
429
+ currentTarget,
430
+ nativeEvent: event,
431
+ } as unknown as ThreeEvent<DomEvent>;
432
+
433
+ callback(raycastEvent);
434
+ if (localState.stopped) break;
435
+ }
436
+ return intersections;
437
+ }
438
+
439
+ function handleEvent(name: keyof Events, event: DomEvent): void {
440
+ const { onPointerMissed, internal } = store.getState();
441
+ internal.lastEvent.current = event;
442
+
443
+ const isPointerMove = name === 'onPointerMove';
444
+ const isClickEvent = name === 'onClick' || name === 'onContextMenu' || name === 'onDoubleClick';
445
+ const hits = intersect(event, isPointerMove ? filterPointerEvents : undefined);
446
+ const delta = isClickEvent ? calculateDistance(event) : 0;
447
+
448
+ if (name === 'onPointerDown') {
449
+ internal.initialClick = [event.offsetX, event.offsetY];
450
+ internal.initialHits = hits.map((hit) => hit.eventObject);
451
+ }
452
+
453
+ // Misses run before click handlers so user cleanup precedes the hit action.
454
+ if (isClickEvent && hits.length === 0 && delta <= 2) {
455
+ pointerMissed(event as MouseEvent, internal.interaction);
456
+ onPointerMissed?.(event as MouseEvent);
457
+ }
458
+
459
+ if (isPointerMove) cancelPointer(hits);
460
+
461
+ function onIntersect(data: ThreeEvent<DomEvent>): void {
462
+ const { eventObject } = data;
463
+ const eventStore = findEventStore(eventObject) ?? store;
464
+
465
+ if (isPointerMove) {
466
+ const hasHoverHandler =
467
+ getThreeEventListener(eventObject, 'onPointerOver') !== undefined ||
468
+ getThreeEventListener(eventObject, 'onPointerEnter') !== undefined ||
469
+ getThreeEventListener(eventObject, 'onPointerOut') !== undefined ||
470
+ getThreeEventListener(eventObject, 'onPointerLeave') !== undefined;
471
+ if (hasHoverHandler) {
472
+ const id = makeIntersectionId(data);
473
+ const hovered = internal.hovered.get(id);
474
+ if (hovered === undefined) {
475
+ internal.hovered.set(id, data);
476
+ invokeListener(eventStore, getThreeEventListener(eventObject, 'onPointerOver'), data);
477
+ invokeListener(eventStore, getThreeEventListener(eventObject, 'onPointerEnter'), data);
478
+ } else if (hovered.stopped) {
479
+ data.stopPropagation();
480
+ }
481
+ }
482
+ invokeListener(eventStore, getThreeEventListener(eventObject, 'onPointerMove'), data);
483
+ return;
484
+ }
485
+
486
+ const listener = getThreeEventListener(eventObject, name);
487
+ if (listener !== undefined) {
488
+ if (!isClickEvent || internal.initialHits.includes(eventObject)) {
489
+ pointerMissed(
490
+ event as MouseEvent,
491
+ internal.interaction.filter((object) => !internal.initialHits.includes(object)),
492
+ );
493
+ invokeListener(eventStore, listener, data);
494
+ }
495
+ } else if (isClickEvent && internal.initialHits.includes(eventObject)) {
496
+ pointerMissed(
497
+ event as MouseEvent,
498
+ internal.interaction.filter((object) => !internal.initialHits.includes(object)),
499
+ );
500
+ }
501
+ }
502
+
503
+ handleIntersects(hits, event, delta, onIntersect);
504
+ }
505
+
506
+ function handlePointer(name: string): EventListener {
507
+ if (!isThreeEventName(name)) {
508
+ throw new TypeError(`Unsupported Three event name "${name}"`);
509
+ }
510
+ const priority = getThreeEventPriority(name);
511
+ if (name === 'onPointerLeave' || name === 'onPointerCancel') {
512
+ return () => runThreeEventScope(store, priority, () => cancelPointer([]));
513
+ }
514
+ if (name === 'onLostPointerCapture') {
515
+ return (nativeEvent) => {
516
+ const event = nativeEvent as DomEvent;
517
+ runThreeEventScope(store, priority, () => {
518
+ if (!('pointerId' in event)) return;
519
+ const { internal } = store.getState();
520
+ if (!internal.capturedMap.has(event.pointerId)) return;
521
+ requestAnimationFrame(() => {
522
+ // Lost capture fires before pointerup. Defer cleanup so pointerup can
523
+ // still reach every capturing object, then open a fresh event scope.
524
+ runThreeEventScope(store, 'continuous', () => {
525
+ if (internal.capturedMap.has(event.pointerId)) {
526
+ clearInternalPointerCaptures(internal.capturedMap, event.pointerId);
527
+ cancelPointer([]);
528
+ }
529
+ });
530
+ });
531
+ });
532
+ };
533
+ }
534
+ return (nativeEvent) =>
535
+ runThreeEventScope(store, priority, () => handleEvent(name, nativeEvent as DomEvent));
536
+ }
537
+
538
+ return { handlePointer };
539
+ }