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;
}