@navaramap/three 0.1.0 → 0.1.1

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.
@@ -19,22 +19,40 @@ export type PickHelperOptions = {
19
19
  };
20
20
 
21
21
  /**
22
- * Travel tolerance in CSS pixels between mousedown and mouseup to count as a click.
22
+ * Travel tolerance in CSS pixels between pointerdown and pointerup to count as a click.
23
23
  * Follows Cesium's `_clickPixelTolerance` (MapLibre uses 3px).
24
24
  * ref: https://github.com/CesiumGS/cesium/blob/9fda7ab97a762e40c74b1d0e1814c98a2de43337/packages/engine/Source/Core/ScreenSpaceEventHandler.js#L1012
25
25
  * ref: https://github.com/maplibre/maplibre-gl-js/blob/3a0a4f795fef5b2a29034d71833475589c344eaf/src/ui/map.ts#L542
26
26
  */
27
- const CLICK_PIXEL_TOLERANCE = 5;
27
+ export const CLICK_PIXEL_TOLERANCE = 5;
28
+
29
+ /**
30
+ * Travel tolerance for touch taps. Finger contact jitters far more than a
31
+ * mouse, so follows MapLibre's tap `MAX_DIST` instead.
32
+ * ref: https://github.com/maplibre/maplibre-gl-js/blob/3a0a4f795fef5b2a29034d71833475589c344eaf/src/ui/handler/tap_recognizer.ts
33
+ */
34
+ export const TAP_PIXEL_TOLERANCE = 30;
28
35
 
29
36
  export function isClickGesture(
30
37
  downPosition: Vector2,
31
38
  upPosition: Vector2,
39
+ tolerance: number = CLICK_PIXEL_TOLERANCE,
32
40
  ): boolean {
33
- return downPosition.distanceTo(upPosition) < CLICK_PIXEL_TOLERANCE;
41
+ return downPosition.distanceTo(upPosition) < tolerance;
34
42
  }
35
43
 
36
44
  /**
37
- * GPU picking using a dedicated single-pixel render pass.
45
+ * Pick search radius in CSS pixels around the pointer. The pick reads a
46
+ * (2r+1)² window and takes the non-zero batch id closest to its center, so
47
+ * small features (points, thin lines) don't demand pixel-perfect aim.
48
+ */
49
+ export const PICK_RADIUS = 3;
50
+
51
+ /** Pick search radius for touch: fingertips land less precisely than a cursor. */
52
+ export const TOUCH_PICK_RADIUS = 10;
53
+
54
+ /**
55
+ * GPU picking using a dedicated render pass over a small search window.
38
56
  *
39
57
  * The pick pass does NOT reuse the main render pipeline (globe / MRT /
40
58
  * draped / copy passes). Instead, every pickable raw — regardless of
@@ -48,11 +66,14 @@ export function isClickGesture(
48
66
  * transforms, so world matrices of the re-parented objects are
49
67
  * unaffected.
50
68
  *
51
- * Two gestures trigger a pick: a click (mousedown/mouseup within
52
- * {@link CLICK_PIXEL_TOLERANCE}) and hovering, throttled to at most one
53
- * pick per animation frame. Both are lazy: a pick runs only while the
54
- * matching `shouldClickPick` / `shouldHoverPick` gate returns true
55
- * (i.e. someone is listening for the result).
69
+ * Two gestures trigger a pick: a click or touch tap (pointerdown/pointerup
70
+ * within {@link CLICK_PIXEL_TOLERANCE} / {@link TAP_PIXEL_TOLERANCE}) and
71
+ * hovering, throttled to at most one pick per animation frame. Both are
72
+ * lazy: a pick runs only while the matching `shouldClickPick` /
73
+ * `shouldHoverPick` gate returns true (i.e. someone is listening for the
74
+ * result). The gestures are detected from pointer events, which fire for
75
+ * touch as well. The camera-input handlers call `preventDefault()` on
76
+ * touch events, so the compatibility mouse events never do.
56
77
  */
57
78
  export class PickHelper {
58
79
  private element: HTMLElement;
@@ -76,12 +97,13 @@ export class PickHelper {
76
97
  /** Hover picks run only while this returns true (e.g. hover listeners exist). */
77
98
  private shouldHoverPick: () => boolean;
78
99
 
79
- private mouseDownPosition?: Vector2;
80
- private mouseDownHandler: (event: MouseEvent) => void;
81
- private mouseUpHandler: (event: MouseEvent) => void;
82
- private hoverMoveHandler: (event: MouseEvent) => void;
100
+ private pointerDownPosition?: Vector2;
101
+ private pointerDownHandler: (event: PointerEvent) => void;
102
+ private pointerUpHandler: (event: PointerEvent) => void;
103
+ private pointerCancelHandler: () => void;
104
+ private hoverMoveHandler: (event: PointerEvent) => void;
83
105
  private hoverLeaveHandler: () => void;
84
- /** Latest mousemove position awaiting a hover pick, in client coords. */
106
+ /** Latest pointermove position awaiting a hover pick, in client coords. */
85
107
  private hoverPosition?: Vector2;
86
108
  private hoverRafId?: number;
87
109
 
@@ -106,10 +128,13 @@ export class PickHelper {
106
128
  this.shouldClickPick = shouldClickPick;
107
129
  this.shouldHoverPick = shouldHoverPick;
108
130
 
109
- this.mouseDownHandler = (event: MouseEvent) => this.onMouseDown(event);
110
- this.mouseUpHandler = (event: MouseEvent) => this.onMouseUp(event);
111
- this.hoverMoveHandler = (event: MouseEvent) => this.onHoverMouseMove(event);
112
- this.hoverLeaveHandler = () => this.onHoverMouseLeave();
131
+ this.pointerDownHandler = (event: PointerEvent) =>
132
+ this.onPointerDown(event);
133
+ this.pointerUpHandler = (event: PointerEvent) => this.onPointerUp(event);
134
+ this.pointerCancelHandler = () => this.onPointerCancel();
135
+ this.hoverMoveHandler = (event: PointerEvent) =>
136
+ this.onHoverPointerMove(event);
137
+ this.hoverLeaveHandler = () => this.onHoverPointerLeave();
113
138
 
114
139
  const width = this._renderer.getContext().drawingBufferWidth;
115
140
  const height = this._renderer.getContext().drawingBufferHeight;
@@ -130,48 +155,75 @@ export class PickHelper {
130
155
  }
131
156
  }
132
157
 
133
- private onMouseDown(event: MouseEvent) {
134
- this.mouseDownPosition = new Vector2(event.clientX, event.clientY);
158
+ private onPointerDown(event: PointerEvent) {
159
+ // A second concurrent pointer (e.g. the pinch-zoom finger) turns the
160
+ // gesture into something other than a tap; abandon the pending click.
161
+ if (!event.isPrimary) {
162
+ this.pointerDownPosition = undefined;
163
+ return;
164
+ }
165
+ this.pointerDownPosition = new Vector2(event.clientX, event.clientY);
135
166
  }
136
167
 
137
- private onMouseUp(event: MouseEvent) {
138
- const downPosition = this.mouseDownPosition;
139
- this.mouseDownPosition = undefined;
168
+ private onPointerUp(event: PointerEvent) {
169
+ if (!event.isPrimary) return;
170
+
171
+ const downPosition = this.pointerDownPosition;
172
+ this.pointerDownPosition = undefined;
140
173
  if (!downPosition) return;
141
174
 
142
175
  // Skip the GPU pick entirely when nobody is listening for the result.
143
176
  if (!this.shouldClickPick()) return;
144
177
 
178
+ const tolerance =
179
+ event.pointerType === "touch"
180
+ ? TAP_PIXEL_TOLERANCE
181
+ : CLICK_PIXEL_TOLERANCE;
145
182
  if (
146
- isClickGesture(downPosition, new Vector2(event.clientX, event.clientY))
183
+ isClickGesture(
184
+ downPosition,
185
+ new Vector2(event.clientX, event.clientY),
186
+ tolerance,
187
+ )
147
188
  ) {
148
- this.onMouseClick(event);
189
+ this.onClickPick(event);
149
190
  }
150
191
  }
151
192
 
193
+ /** The browser took over the gesture (scroll, app switch): not a click. */
194
+ private onPointerCancel() {
195
+ this.pointerDownPosition = undefined;
196
+ }
197
+
152
198
  enablePick(bPick: boolean) {
153
199
  if (bPick) {
154
- this.element.addEventListener("mousedown", this.mouseDownHandler);
155
- this.element.addEventListener("mouseup", this.mouseUpHandler);
156
- this.element.addEventListener("mousemove", this.hoverMoveHandler);
157
- this.element.addEventListener("mouseleave", this.hoverLeaveHandler);
200
+ this.element.addEventListener("pointerdown", this.pointerDownHandler);
201
+ this.element.addEventListener("pointerup", this.pointerUpHandler);
202
+ this.element.addEventListener("pointercancel", this.pointerCancelHandler);
203
+ this.element.addEventListener("pointermove", this.hoverMoveHandler);
204
+ this.element.addEventListener("pointerleave", this.hoverLeaveHandler);
158
205
  } else {
159
- this.element.removeEventListener("mousedown", this.mouseDownHandler);
160
- this.element.removeEventListener("mouseup", this.mouseUpHandler);
161
- this.element.removeEventListener("mousemove", this.hoverMoveHandler);
162
- this.element.removeEventListener("mouseleave", this.hoverLeaveHandler);
206
+ this.element.removeEventListener("pointerdown", this.pointerDownHandler);
207
+ this.element.removeEventListener("pointerup", this.pointerUpHandler);
208
+ this.element.removeEventListener(
209
+ "pointercancel",
210
+ this.pointerCancelHandler,
211
+ );
212
+ this.element.removeEventListener("pointermove", this.hoverMoveHandler);
213
+ this.element.removeEventListener("pointerleave", this.hoverLeaveHandler);
163
214
  this.cancelPendingHoverPick();
164
215
  }
165
216
  }
166
217
 
167
218
  /**
168
219
  * Hover picking: schedules at most one GPU pick per animation frame from
169
- * the latest mousemove position. Suppressed while any mouse button is
170
- * pressed (camera drags would otherwise trigger a pick per frame) and
171
- * while `shouldHoverPick` returns false, so pages without hover
172
- * listeners pay nothing.
220
+ * the latest pointermove position. Suppressed while any button is pressed
221
+ * (Camera drags would otherwise trigger a pick per frame. For touch,
222
+ * contact itself sets `buttons`, so touch never hover-picks) and while
223
+ * `shouldHoverPick` returns false, so pages without hover listeners pay
224
+ * nothing.
173
225
  */
174
- private onHoverMouseMove(event: MouseEvent) {
226
+ private onHoverPointerMove(event: PointerEvent) {
175
227
  if (event.buttons !== 0 || !this.shouldHoverPick()) return;
176
228
 
177
229
  if (!this.hoverPosition) this.hoverPosition = new Vector2();
@@ -184,7 +236,7 @@ export class PickHelper {
184
236
  if (!position) return;
185
237
  this.hoverPosition = undefined;
186
238
 
187
- // Listeners may have been removed between the mousemove that
239
+ // Listeners may have been removed between the pointermove that
188
240
  // scheduled this frame and now. Re-check so no orphan pick runs.
189
241
  if (!this.shouldHoverPick()) return;
190
242
 
@@ -193,7 +245,7 @@ export class PickHelper {
193
245
  });
194
246
  }
195
247
 
196
- private onHoverMouseLeave() {
248
+ private onHoverPointerLeave() {
197
249
  this.cancelPendingHoverPick();
198
250
  // Report "nothing hovered" so diff-based enter/leave synthesis can
199
251
  // close out the current hover when the cursor exits the canvas.
@@ -303,17 +355,24 @@ export class PickHelper {
303
355
  this.debugBufferView.render(this._renderer, this.debugRenderTarget);
304
356
  }
305
357
 
306
- private onMouseClick(event: MouseEvent) {
307
- const batchId = this.pickBatchIdAt(event.clientX, event.clientY);
358
+ private onClickPick(event: PointerEvent) {
359
+ const radius =
360
+ event.pointerType === "touch" ? TOUCH_PICK_RADIUS : PICK_RADIUS;
361
+ const batchId = this.pickBatchIdAt(event.clientX, event.clientY, radius);
308
362
  const pickArr = batchId > 0 ? [batchId] : [];
309
363
  this.onPickCallback(pickArr);
310
364
  }
311
365
 
312
366
  /**
313
- * Runs the single-pixel pick render at the given client coordinates and
314
- * returns the decoded batch ID (0 when nothing pickable is hit).
367
+ * Runs the pick render in a search window around the given client
368
+ * coordinates and returns the non-zero batch ID closest to its center
369
+ * (0 when nothing pickable is inside the window).
315
370
  */
316
- private pickBatchIdAt(clientX: number, clientY: number): number {
371
+ private pickBatchIdAt(
372
+ clientX: number,
373
+ clientY: number,
374
+ radiusCss: number = PICK_RADIUS,
375
+ ): number {
317
376
  const rect = this.element.getBoundingClientRect();
318
377
  const x = clientX - rect.left;
319
378
  const y = clientY - rect.top;
@@ -338,29 +397,58 @@ export class PickHelper {
338
397
  );
339
398
  const readY = fullHeight - 1 - pixelY;
340
399
 
400
+ // The search window in device pixels, clamped to the viewport.
401
+ const radius = Math.max(0, Math.round(radiusCss * pixelRatio));
402
+ const minX = Math.max(0, pixelX - radius);
403
+ const maxX = Math.min(fullWidth - 1, pixelX + radius);
404
+ const minY = Math.max(0, readY - radius);
405
+ const maxY = Math.min(fullHeight - 1, readY + radius);
406
+ const width = maxX - minX + 1;
407
+ const height = maxY - minY + 1;
408
+
341
409
  // Keep the camera projection unchanged for wide lines / screen-space
342
- // expanded shaders, and limit fragment work to one pixel via scissor.
343
- this._renderer.setScissor(pixelX, readY, 1, 1);
410
+ // expanded shaders, and limit fragment work to the window via scissor.
411
+ this._renderer.setScissor(minX, minY, width, height);
344
412
  this._renderer.setScissorTest(true);
345
413
 
346
414
  this.processRender(this.pickRenderTarget, pickingCoord);
347
415
 
416
+ if (this.pixelBuffer.length < width * height * 4) {
417
+ this.pixelBuffer = new Uint8Array(width * height * 4);
418
+ }
348
419
  this._renderer.readRenderTargetPixels(
349
420
  this.pickRenderTarget,
350
- pixelX,
351
- readY,
352
- 1,
353
- 1,
421
+ minX,
422
+ minY,
423
+ width,
424
+ height,
354
425
  this.pixelBuffer,
355
426
  );
356
427
 
357
428
  this._renderer.setScissorTest(false);
358
429
 
359
- return (
360
- (this.pixelBuffer[0] << 16) +
361
- (this.pixelBuffer[1] << 8) +
362
- this.pixelBuffer[2]
363
- );
430
+ // The non-zero batch id nearest to the pointer wins, so a small feature
431
+ // beside a large one is still pickable by aiming at it.
432
+ let bestId = 0;
433
+ let bestDistSq = Infinity;
434
+ for (let row = 0; row < height; row++) {
435
+ for (let col = 0; col < width; col++) {
436
+ const i = (row * width + col) * 4;
437
+ const id =
438
+ (this.pixelBuffer[i] << 16) +
439
+ (this.pixelBuffer[i + 1] << 8) +
440
+ this.pixelBuffer[i + 2];
441
+ if (id === 0) continue;
442
+ const dx = minX + col - pixelX;
443
+ const dy = minY + row - readY;
444
+ const distSq = dx * dx + dy * dy;
445
+ if (distSq < bestDistSq) {
446
+ bestDistSq = distSq;
447
+ bestId = id;
448
+ }
449
+ }
450
+ }
451
+ return bestId;
364
452
  }
365
453
 
366
454
  public dispose() {
@@ -125,7 +125,7 @@ let styleRefCount = 0;
125
125
  */
126
126
  const NAVARA_CREDIT: AttributionItem = {
127
127
  attribution: "Navara",
128
- attributionUrl: "https://navara-docs.reearth.workers.dev//",
128
+ attributionUrl: "https://navara.world/",
129
129
  };
130
130
 
131
131
  /**