File size: 6,752 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | /**
* three.js OrbitControls (≤ r183) position-tracks ONLY touch pointers
* (`_trackPointer` runs solely in the touch branches), yet reads the tracked
* position of arbitrary pointers in two places:
* - onPointerUp case 1: the surviving pointer of a multi-pointer gesture
* - _getSecondPointerPosition: the other pointer of a two-pointer
* rotate/dolly/pan gesture
* A concurrent mouse|pen + touch gesture (touchscreen laptops) therefore
* crashes with `Cannot read properties of undefined (reading 'x')`
* (Sentry WORLDMONITOR-QD).
*
* The handlers are stored as bound instance fields that three re-reads at
* dispatch time (`this._onTouchStart(...)`, and the document listeners are
* registered per-pointerdown), so wrapping the fields on the live instance
* intercepts every path — including the DOM pointermove → `_onTouchMove` one.
* tests/orbit-controls-pointer-guard.test.mjs pins that against real three, so
* an upgrade that switches to closure dispatch fails loudly instead of
* silently un-guarding.
*
* Every pointer's real position is recorded from the events three already
* hands us (pointerdown/pointermove fire for mouse and pen too, not just
* touch), so a seeded entry carries that pointer's ACTUAL last-known
* coordinates. Seeding from the triggering event instead would re-anchor the
* gesture at the wrong pointer's position and snap the camera.
*/
interface SeededPosition {
x: number;
y: number;
set(x: number, y: number): SeededPosition;
}
interface PointerTrackingInternals {
_pointers?: unknown;
_pointerPositions?: Record<number, SeededPosition | undefined>;
_onPointerDown?: unknown;
_onPointerMove?: unknown;
_onPointerUp?: unknown;
_onTouchStart?: unknown;
_onTouchMove?: unknown;
domElement?: unknown;
connect?: unknown;
disconnect?: unknown;
}
type PointerLikeEvent = {
pointerId?: number;
pageX?: number;
pageY?: number;
};
// Handlers three dispatches through instance fields. The seeding ones read a
// tracked position (directly or via _getSecondPointerPosition); the rest are
// wrapped only to observe pointer positions.
const SEEDING_HANDLERS = ['_onPointerUp', '_onTouchStart', '_onTouchMove'] as const;
const OBSERVING_HANDLERS = ['_onPointerDown', '_onPointerMove'] as const;
// Mimics the THREE.Vector2 surface OrbitControls uses on stored positions
// (.x/.y reads plus _trackPointer's position.set()).
function createSeededPosition(x: number, y: number): SeededPosition {
return {
x,
y,
set(nx: number, ny: number) {
this.x = nx;
this.y = ny;
return this;
},
};
}
/**
* Returns true when the guard was installed; false when the instance doesn't
* expose the expected internals (e.g. a future three upgrade renames them), in
* which case it is left untouched — the guard must never break controls that
* no longer have the bug.
*/
export function guardOrbitControlsPointerTracking(controls: object): boolean {
const c = controls as PointerTrackingInternals;
if (!Array.isArray(c._pointers)) return false;
if (typeof c._pointerPositions !== 'object' || c._pointerPositions === null) return false;
// OrbitControls stores pointer IDs; TrackballControls stores whole events.
// Only the ID shape is understood here — bail rather than mis-seed.
if (c._pointers.some((id) => typeof id !== 'number')) return false;
if (!SEEDING_HANDLERS.some((name) => typeof c[name] === 'function')) return false;
// Last-known page coordinates per pointer ID, from every event three routes
// through a wrapped handler.
const lastKnown = new Map<number, { x: number; y: number }>();
const record = (event: PointerLikeEvent | undefined): void => {
const id = event?.pointerId;
if (typeof id !== 'number') return;
const { pageX, pageY } = event as { pageX?: number; pageY?: number };
if (!Number.isFinite(pageX) || !Number.isFinite(pageY)) return;
lastKnown.set(id, { x: pageX as number, y: pageY as number });
};
const seedUntrackedPointers = (event: PointerLikeEvent | undefined): void => {
const pointers = c._pointers;
const positions = c._pointerPositions;
if (!Array.isArray(pointers) || !positions) return;
for (const id of pointers) {
if (typeof id !== 'number' || positions[id] !== undefined) continue;
// Fall back to the triggering event only if this pointer was never seen
// (it always has been — every pointer enters through pointerdown).
const seen = lastKnown.get(id);
positions[id] = createSeededPosition(
seen?.x ?? (event?.pageX ?? 0),
seen?.y ?? (event?.pageY ?? 0),
);
}
};
const pruneLiftedPointers = (): void => {
const pointers = c._pointers;
if (!Array.isArray(pointers)) return;
for (const id of lastKnown.keys()) {
if (!pointers.includes(id)) lastKnown.delete(id);
}
};
// connect() registered the `pointerdown` and `pointercancel` DOM listeners
// with the ORIGINAL bound functions back in the constructor, so wrapping the
// fields alone never reaches them: pointercancel (palm rejection, system
// gesture) would still hit the unguarded onPointerUp, and pointerdown would
// never record positions. Re-register through three's own API.
//
// Order matters: disconnect() removes listeners by identity
// (removeEventListener(type, this._onPointerUp)), so it MUST run while the
// fields still hold the originals. Disconnecting after wrapping would fail to
// match — leaving the original listeners attached AND adding the wrapped ones,
// so the unguarded handler would keep firing first.
const { connect, disconnect, domElement } = c;
const canRebind =
typeof connect === 'function' && typeof disconnect === 'function' && !!domElement;
if (canRebind) (disconnect as () => void).call(c);
for (const name of [...OBSERVING_HANDLERS, ...SEEDING_HANDLERS]) {
const original = c[name];
if (typeof original !== 'function') continue;
const seeds = (SEEDING_HANDLERS as readonly string[]).includes(name);
c[name] = (event: PointerLikeEvent) => {
record(event);
if (seeds) seedUntrackedPointers(event);
try {
return original(event);
} finally {
// A lifted pointer is gone from _pointers by the time onPointerUp
// returns; drop it so a long session can't accumulate stale IDs.
if (name === '_onPointerUp') pruneLiftedPointers();
}
};
}
// Re-registers pointerdown/pointercancel against the wrapped fields.
// (pointermove/pointerup are registered per-pointerdown and so already read
// the fields after wrapping.)
if (canRebind) (connect as (el: unknown) => void).call(c, domElement);
return true;
}
|