GenerAI / worldmonitor /src /utils /orbit-controls-pointer-guard.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 8)
dbb1bf9 verified
Raw
History Blame Contribute Delete
6.75 kB
/**
* 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;
}