@navaramap/three 0.0.8 → 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.
Files changed (39) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +20 -8
  3. package/dist/assets/fontWorker-C5cHhkWQ.js +2 -0
  4. package/dist/assets/fontWorker-C5cHhkWQ.js.map +1 -0
  5. package/dist/assets/{index-DaejBdUz.js → index-VfUncKuS.js} +2 -2
  6. package/dist/assets/index-VfUncKuS.js.map +1 -0
  7. package/dist/assets/{navara_wasm_font_worker_bg-CSmoDs_Q.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
  8. package/dist/assets/{navara_wasm_worker_bg-C1dUd_Nk.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
  9. package/dist/index.d.ts +298 -54
  10. package/dist/index.js +1569 -1396
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +90 -69
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +12 -12
  15. package/src/core/BaseDesc.ts +25 -1
  16. package/src/core/MeshDesc.test.ts +6 -8
  17. package/src/core/MeshDesc.ts +4 -6
  18. package/src/evaluations/FeatureEvaluator.ts +17 -10
  19. package/src/event/feature.ts +15 -2
  20. package/src/event/features/billboard.test.ts +62 -0
  21. package/src/event/features/billboard.ts +4 -2
  22. package/src/index.ts +266 -98
  23. package/src/material/enhancer/model/modelBaseEnhancer/shader.ts +2 -2
  24. package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +2 -2
  25. package/src/material/macro/tileShader.ts +12 -2
  26. package/src/mesh/polyline.ts +32 -4
  27. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  28. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  29. package/src/mesh/sprite/instancedSprite.ts +173 -65
  30. package/src/pick/pickHelper.test.ts +389 -4
  31. package/src/pick/pickHelper.ts +212 -37
  32. package/src/plugins/AttributionPlugin/index.ts +1 -1
  33. package/src/renderOrder.ts +10 -0
  34. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  35. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  36. package/src/type/index.ts +22 -1
  37. package/dist/assets/fontWorker-CkLGtrXf.js +0 -2
  38. package/dist/assets/fontWorker-CkLGtrXf.js.map +0 -1
  39. package/dist/assets/index-DaejBdUz.js.map +0 -1
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  type FontWorkerMemoryStats,
24
24
  } from "@navaramap/font";
25
25
  import FontWorkerURL from "@navaramap/font/fontWorker?worker&url";
26
- import { initNavaraApi } from "@navaramap/three-api";
26
+ import { initNavaraApi, degreeToRadian } from "@navaramap/three-api";
27
27
  import {
28
28
  initializeWorkerPool,
29
29
  probeWorkerPoolHeap,
@@ -118,7 +118,12 @@ import {
118
118
  } from "./material";
119
119
  import type { TileMesh } from "./mesh/tile";
120
120
  import { RenderPassOrchestrator } from "./orchestrators/RenderPassOrchestrator";
121
- import { PickHelper } from "./pick/pickHelper";
121
+ import {
122
+ CLICK_PIXEL_TOLERANCE,
123
+ isClickGesture,
124
+ PickHelper,
125
+ TAP_PIXEL_TOLERANCE,
126
+ } from "./pick/pickHelper";
122
127
  import { TerrainPicker } from "./pick/pickTerrain";
123
128
  import { AttributionPlugin, type AttributionPluginOptions } from "./plugins";
124
129
  import { TexturizedSceneByTileCoordinates, type Scenes } from "./scene";
@@ -219,6 +224,12 @@ export * from "./layers";
219
224
  export * from "./passes";
220
225
  export * from "./evaluations";
221
226
  export { SKY_RENDER_ORDER, STARS_RENDER_ORDER } from "./renderOrder";
227
+ export {
228
+ CLICK_PIXEL_TOLERANCE,
229
+ TAP_PIXEL_TOLERANCE,
230
+ PICK_RADIUS,
231
+ TOUCH_PICK_RADIUS,
232
+ } from "./pick/pickHelper";
222
233
  export * from "@navaramap/three-api";
223
234
  export * from "./Color";
224
235
  export { type BlendMode, blendFunction, createReplacer } from "./utils";
@@ -309,7 +320,11 @@ export type Options = {
309
320
  atmosphere?: AtmosphereOptions;
310
321
  /** Background color of the scene. Defaults to dark color (0x0a0a0f). */
311
322
  backgroundColor?: CoreColor;
312
- /** Feature picking configuration. */
323
+ /**
324
+ * Feature picking configuration (default: true). Enables the `featureClick`
325
+ * event (click or touch tap) and the `featureHover` / `featureEnter` /
326
+ * `featureLeave` events (pointer hover).
327
+ */
313
328
  picking?: boolean;
314
329
  /** When true, renders every frame. When false, renders only on changes or when forceUpdate() is called. */
315
330
  animation?: boolean;
@@ -400,12 +415,13 @@ export type Options = {
400
415
  } & GlobeOptions;
401
416
 
402
417
  /**
403
- * Mouse event extended with map coordinates at the event location.
418
+ * Pointer event extended with map coordinates at the event location.
419
+ * Fired for every input type; `pointerType` tells them apart.
404
420
  */
405
- export type MapMouseEvent = {
406
- /** World coordinates (ECEF) at the mouse position on the globe surface. */
421
+ export type MapPointerEvent = {
422
+ /** World coordinates (ECEF) at the pointer position on the globe surface. */
407
423
  map: XYZ;
408
- } & MouseEvent;
424
+ } & PointerEvent;
409
425
 
410
426
  /**
411
427
  * Event types emitted by ThreeView. Subscribe using `view.on(eventName, callback)`.
@@ -413,8 +429,24 @@ export type MapMouseEvent = {
413
429
  export type ViewEvents = {
414
430
  /** Emitted when the view is resized. Receives width and height in pixels. */
415
431
  resize: (w: number, h: number) => void;
416
- /** Emitted when a feature is picked. Receives picked feature info or null. */
417
- pick: (info: Nullable<PickedFeature>) => void;
432
+ /**
433
+ * Emitted when a feature is clicked or tapped (click-gesture GPU pick).
434
+ * Receives the clicked feature, or null when empty space was clicked. For
435
+ * raw click coordinates use the `click` event instead.
436
+ */
437
+ featureClick: (info: Nullable<PickedFeature>) => void;
438
+ /**
439
+ * Emitted when the hovered feature changes (hover picking). Receives
440
+ * the newly hovered feature, or null when the pointer leaves all pickable
441
+ * features. Hover picking runs a GPU pick per frame while the pointer
442
+ * moves, and is active only while at least one `featureHover` /
443
+ * `featureEnter` / `featureLeave` listener is registered.
444
+ */
445
+ featureHover: (info: Nullable<PickedFeature>) => void;
446
+ /** Emitted when the pointer starts hovering a pickable feature. See `featureHover` for activation. */
447
+ featureEnter: (info: PickedFeature) => void;
448
+ /** Emitted when the pointer stops hovering the previously hovered feature. See `featureHover` for activation. */
449
+ featureLeave: (info: PickedFeature) => void;
418
450
  /** Emitted when a layer event occurs. Receives event type, layer ID, and event arguments. */
419
451
  layer: <K extends keyof LayerEvent>(
420
452
  k: K,
@@ -431,18 +463,26 @@ export type ViewEvents = {
431
463
  postRender: (t: number) => void;
432
464
  /** @private Emitted when terrain height sampling completes. */
433
465
  _sample_terrain_height_received: (ev: TerrainHeightUpdatedEvent) => void;
434
- /** Emitted on mouse down with map coordinates. */
435
- mousedown: (event: MapMouseEvent) => void;
436
- /** Emitted when mouse enters the canvas with map coordinates. */
437
- mouseenter: (event: MapMouseEvent) => void;
438
- /** Emitted when mouse leaves the canvas with map coordinates. */
439
- mouseleave: (event: MapMouseEvent) => void;
440
- /** Emitted on mouse move with map coordinates. */
441
- mousemove: (event: MapMouseEvent) => void;
442
- /** Emitted on mouse up with map coordinates. */
443
- mouseup: (event: MapMouseEvent) => void;
444
- /** Emitted on click with map coordinates. */
445
- click: (event: MapMouseEvent) => void;
466
+ /** Emitted on pointerdown with map coordinates, for every input type. */
467
+ pointerdown: (event: MapPointerEvent) => void;
468
+ /** Emitted when a pointer enters the canvas with map coordinates, for every input type. */
469
+ pointerenter: (event: MapPointerEvent) => void;
470
+ /** Emitted when a pointer leaves the canvas with map coordinates, for every input type. */
471
+ pointerleave: (event: MapPointerEvent) => void;
472
+ /** Emitted on pointermove with map coordinates, for every input type. */
473
+ pointermove: (event: MapPointerEvent) => void;
474
+ /** Emitted on pointerup with map coordinates, for every input type. */
475
+ pointerup: (event: MapPointerEvent) => void;
476
+ /** Emitted when the browser cancels an active pointer (e.g. a system gesture takes over). */
477
+ pointercancel: (event: PointerEvent) => void;
478
+ /**
479
+ * Emitted on a click or touch tap with map coordinates: a primary-pointer
480
+ * press and release within the travel tolerance
481
+ * ({@link CLICK_PIXEL_TOLERANCE}, or {@link TAP_PIXEL_TOLERANCE} for
482
+ * touch). Camera drags and pinches never fire it. `pointerType` tells the
483
+ * input kinds apart.
484
+ */
485
+ click: (event: MapPointerEvent) => void;
446
486
  /** Emitted when the engine becomes idle (no Rust-side events for `idleThreshold` ms). */
447
487
  idle: () => void;
448
488
  };
@@ -503,6 +543,9 @@ export default class ThreeView<
503
543
  * `camera_flight_ended` events from the engine. */
504
544
  private _pendingFlights = new Map<number, (completed: boolean) => void>();
505
545
 
546
+ /** Currently hovered feature, used to diff-synthesize hover events. */
547
+ private _hoveredFeature: Nullable<PickedFeature> = null;
548
+
506
549
  private _meshes: MeshCache = new Map();
507
550
  private _abortControllers: AbortControllers = new Map();
508
551
  private _workerPoolPromises: WorkerPoolPromises = new Map();
@@ -1150,9 +1193,9 @@ export default class ThreeView<
1150
1193
  }
1151
1194
 
1152
1195
  /**
1153
- * Convert a mouse event to a MapMouseEvent by adding map coordinates
1196
+ * Extend a pointer event with map coordinates at its location
1154
1197
  */
1155
- private convertMouseEventToMapEvent(event: MouseEvent): MapMouseEvent | null {
1198
+ private convertToMapEvent(event: PointerEvent): MapPointerEvent | null {
1156
1199
  const rect = this._renderer.domElement.getBoundingClientRect();
1157
1200
  const x = event.clientX - rect.left;
1158
1201
  const y = event.clientY - rect.top;
@@ -1165,7 +1208,7 @@ export default class ThreeView<
1165
1208
  y: xyz.y,
1166
1209
  z: xyz.z,
1167
1210
  },
1168
- }) as MapMouseEvent;
1211
+ });
1169
1212
  }
1170
1213
 
1171
1214
  return null;
@@ -1562,6 +1605,17 @@ export default class ThreeView<
1562
1605
  this._camera.raw,
1563
1606
  this._meshes,
1564
1607
  this.onPick.bind(this),
1608
+ this.onHoverPick.bind(this),
1609
+ // Picks cost a GPU render (plus a re-render to restore draped
1610
+ // atlases), so both gestures stay off until someone actually
1611
+ // listens for the result.
1612
+ () => !!this.size("featureClick"),
1613
+ () =>
1614
+ !!(
1615
+ this.size("featureHover") ||
1616
+ this.size("featureEnter") ||
1617
+ this.size("featureLeave")
1618
+ ),
1565
1619
  // {
1566
1620
  // debug: true,
1567
1621
  // },
@@ -1590,47 +1644,75 @@ export default class ThreeView<
1590
1644
 
1591
1645
  this._camera.core = this._core;
1592
1646
 
1593
- this._renderer.domElement.addEventListener("mousedown", (event) => {
1594
- const mapEvent = this.convertMouseEventToMapEvent(event);
1647
+ // The map events are fed from pointer events; touch produces no
1648
+ // compatibility mouse events because the camera-input handlers call
1649
+ // preventDefault() on touch events. `click` is a gesture, not the DOM
1650
+ // event: a primary-pointer press and release within the travel
1651
+ // tolerance, with no pinch and no cancellation. Camera drags never
1652
+ // fire it.
1653
+ let clickStart: Vector2 | undefined;
1654
+
1655
+ this._renderer.domElement.addEventListener("pointerdown", (event) => {
1656
+ // A second finger turns the gesture into a pinch, not a tap.
1657
+ clickStart =
1658
+ event.isPrimary && event.button === 0
1659
+ ? new Vector2(event.clientX, event.clientY)
1660
+ : undefined;
1661
+ const mapEvent = this.convertToMapEvent(event);
1595
1662
  if (mapEvent) {
1596
- this.emit("mousedown", mapEvent);
1663
+ this.emit("pointerdown", mapEvent);
1597
1664
  }
1598
1665
  });
1599
1666
 
1600
- this._renderer.domElement.addEventListener("mouseenter", (event) => {
1601
- const mapEvent = this.convertMouseEventToMapEvent(event);
1667
+ this._renderer.domElement.addEventListener("pointerenter", (event) => {
1668
+ const mapEvent = this.convertToMapEvent(event);
1602
1669
  if (mapEvent) {
1603
- this.emit("mouseenter", mapEvent);
1670
+ this.emit("pointerenter", mapEvent);
1604
1671
  }
1605
1672
  });
1606
1673
 
1607
- this._renderer.domElement.addEventListener("mouseleave", (event) => {
1608
- const mapEvent = this.convertMouseEventToMapEvent(event);
1674
+ this._renderer.domElement.addEventListener("pointerleave", (event) => {
1675
+ const mapEvent = this.convertToMapEvent(event);
1609
1676
  if (mapEvent) {
1610
- this.emit("mouseleave", mapEvent);
1677
+ this.emit("pointerleave", mapEvent);
1611
1678
  }
1612
1679
  });
1613
1680
 
1614
- this._renderer.domElement.addEventListener("mousemove", (event) => {
1615
- const mapEvent = this.convertMouseEventToMapEvent(event);
1681
+ this._renderer.domElement.addEventListener("pointermove", (event) => {
1682
+ const mapEvent = this.convertToMapEvent(event);
1616
1683
  if (mapEvent) {
1617
- this.emit("mousemove", mapEvent);
1684
+ this.emit("pointermove", mapEvent);
1618
1685
  }
1619
1686
  });
1620
1687
 
1621
- this._renderer.domElement.addEventListener("mouseup", (event) => {
1622
- const mapEvent = this.convertMouseEventToMapEvent(event);
1688
+ this._renderer.domElement.addEventListener("pointerup", (event) => {
1689
+ const mapEvent = this.convertToMapEvent(event);
1623
1690
  if (mapEvent) {
1624
- this.emit("mouseup", mapEvent);
1691
+ this.emit("pointerup", mapEvent);
1625
1692
  }
1626
- });
1627
-
1628
- this._renderer.domElement.addEventListener("click", (event) => {
1629
- const mapEvent = this.convertMouseEventToMapEvent(event);
1630
- if (mapEvent) {
1693
+ if (!event.isPrimary || event.button !== 0) return;
1694
+ const start = clickStart;
1695
+ clickStart = undefined;
1696
+ if (!start || !mapEvent) return;
1697
+ const tolerance =
1698
+ event.pointerType === "touch"
1699
+ ? TAP_PIXEL_TOLERANCE
1700
+ : CLICK_PIXEL_TOLERANCE;
1701
+ if (
1702
+ isClickGesture(
1703
+ start,
1704
+ new Vector2(event.clientX, event.clientY),
1705
+ tolerance,
1706
+ )
1707
+ ) {
1631
1708
  this.emit("click", mapEvent);
1632
1709
  }
1633
1710
  });
1711
+
1712
+ this._renderer.domElement.addEventListener("pointercancel", (event) => {
1713
+ clickStart = undefined;
1714
+ this.emit("pointercancel", event);
1715
+ });
1634
1716
  }
1635
1717
 
1636
1718
  /**
@@ -2040,6 +2122,33 @@ export default class ThreeView<
2040
2122
  return new Source(sourceId, s.type, this._core);
2041
2123
  }
2042
2124
 
2125
+ /**
2126
+ * Tracks a possibly-async `onCreate()` (see `BaseDesc.onCreate`): the
2127
+ * `add*` methods still return their handle synchronously, the descriptor's
2128
+ * instance appears when the promise settles, and a rejection is logged and
2129
+ * reported through the descriptor's `error` event (when it declares one).
2130
+ *
2131
+ * `wire` binds the descriptor's lifecycle hooks (per-frame `update`,
2132
+ * `onResize`, `needsUpdate`). It runs immediately for a synchronous
2133
+ * creation, and only after the promise resolves for an async one — the
2134
+ * hooks must not fire while the descriptor has no instance yet.
2135
+ */
2136
+ private _trackAsyncCreate(
2137
+ kind: "mesh" | "light" | "effect",
2138
+ desc: { emit: (event: never, ...args: never[]) => void },
2139
+ result: void | Promise<void>,
2140
+ wire: () => void,
2141
+ ): void {
2142
+ if (!(result instanceof Promise)) {
2143
+ wire();
2144
+ return;
2145
+ }
2146
+ result.then(wire, (error: unknown) => {
2147
+ console.error(`Failed to create ${kind} descriptor:`, error);
2148
+ (desc.emit as (event: string, payload: unknown) => void)("error", error);
2149
+ });
2150
+ }
2151
+
2043
2152
  /**
2044
2153
  * Adds a 3D mesh to the scene.
2045
2154
  * The mesh kind is determined by the nested key (e.g., `{ box: { width: 200 } }`).
@@ -2058,28 +2167,35 @@ export default class ThreeView<
2058
2167
  // Create mesh descriptor instance
2059
2168
  const meshDesc = this.registries.mesh.create(meshType, config);
2060
2169
 
2061
- // Initialize the mesh
2062
- meshDesc.onCreate();
2170
+ // Initialize the mesh; hooks are wired once the instance exists.
2171
+ this._trackAsyncCreate("mesh", meshDesc, meshDesc.onCreate(), () => {
2172
+ // If deleted while an async creation was loading, nothing to wire.
2173
+ if (meshDesc.destroyed) return;
2063
2174
 
2064
- // Set up update listener
2065
- if (meshDesc.update) {
2066
- this.on("preRender", meshDesc.update.bind(meshDesc));
2067
- }
2175
+ // Set up update listener
2176
+ if (meshDesc.update) {
2177
+ this.on("preRender", meshDesc.update.bind(meshDesc));
2178
+ }
2068
2179
 
2069
- if (meshDesc.onResize) {
2070
- this.on("resize", meshDesc.onResize.bind(meshDesc));
2180
+ if (meshDesc.onResize) {
2181
+ this.on("resize", meshDesc.onResize.bind(meshDesc));
2071
2182
 
2072
- const canvasSize = this._getCanvasSize();
2073
- if (canvasSize) {
2074
- meshDesc.onResize(canvasSize.width, canvasSize.height);
2183
+ const canvasSize = this._getCanvasSize();
2184
+ if (canvasSize) {
2185
+ meshDesc.onResize(canvasSize.width, canvasSize.height);
2186
+ }
2075
2187
  }
2076
- }
2077
2188
 
2078
- // Trigger re-render
2079
- meshDesc.on("needsUpdate", this.forceUpdate);
2189
+ // Trigger re-render
2190
+ meshDesc.on("needsUpdate", this.forceUpdate);
2191
+
2192
+ this.forceUpdate();
2193
+ });
2080
2194
 
2081
2195
  const l = new MeshHandle(meshDesc);
2082
2196
 
2197
+ l.on("deleted", this.forceUpdate);
2198
+
2083
2199
  // Store the mesh descriptor
2084
2200
  this.layersManager.add(l);
2085
2201
 
@@ -2105,19 +2221,26 @@ export default class ThreeView<
2105
2221
  // Create light descriptor instance
2106
2222
  const lightDesc = this.registries.light.create(lightType, config);
2107
2223
 
2108
- // Initialize the light
2109
- lightDesc.onCreate();
2224
+ // Initialize the light; hooks are wired once the instance exists.
2225
+ this._trackAsyncCreate("light", lightDesc, lightDesc.onCreate(), () => {
2226
+ // Deleted while an async creation was loading — nothing to wire.
2227
+ if (lightDesc.destroyed) return;
2110
2228
 
2111
- // Set up update listener if the descriptor has an update method
2112
- if (lightDesc.update) {
2113
- this.on("preRender", lightDesc.update.bind(lightDesc));
2114
- }
2229
+ // Set up update listener if the descriptor has an update method
2230
+ if (lightDesc.update) {
2231
+ this.on("preRender", lightDesc.update.bind(lightDesc));
2232
+ }
2233
+
2234
+ // Trigger re-render
2235
+ lightDesc.on("needsUpdate", this.forceUpdate);
2115
2236
 
2116
- // Trigger re-render
2117
- lightDesc.on("needsUpdate", this.forceUpdate);
2237
+ this.forceUpdate();
2238
+ });
2118
2239
 
2119
2240
  const l = new LightHandle(lightDesc);
2120
2241
 
2242
+ l.on("deleted", this.forceUpdate);
2243
+
2121
2244
  // Store the light descriptor
2122
2245
  this.layersManager.add(l);
2123
2246
 
@@ -2160,18 +2283,26 @@ export default class ThreeView<
2160
2283
  // and recompiles shaders when the derived configuration actually changes,
2161
2284
  // so tune effects via `update()` rather than re-adding them.
2162
2285
  this._syncGBuffers();
2163
- l.on("deleted", this._syncGBuffers);
2286
+ l.on("deleted", () => {
2287
+ this._syncGBuffers();
2288
+ this.forceUpdate();
2289
+ });
2164
2290
 
2165
- // Initialize the effect
2166
- effectDesc.onCreate();
2291
+ // Initialize the effect; hooks are wired once the instance exists.
2292
+ this._trackAsyncCreate("effect", effectDesc, effectDesc.onCreate(), () => {
2293
+ // Deleted while an async creation was loading — nothing to wire.
2294
+ if (effectDesc.destroyed) return;
2167
2295
 
2168
- // Set up update listener if the descriptor has an update method
2169
- if (effectDesc.update) {
2170
- this.on("preRender", effectDesc.update.bind(effectDesc));
2171
- }
2296
+ // Set up update listener if the descriptor has an update method
2297
+ if (effectDesc.update) {
2298
+ this.on("preRender", effectDesc.update.bind(effectDesc));
2299
+ }
2300
+
2301
+ // Trigger re-render
2302
+ effectDesc.on("needsUpdate", this.forceUpdate);
2172
2303
 
2173
- // Trigger re-render
2174
- effectDesc.on("needsUpdate", this.forceUpdate);
2304
+ this.forceUpdate();
2305
+ });
2175
2306
 
2176
2307
  // Return handle for imperative access
2177
2308
  return l as EffectHandle<L>;
@@ -2621,11 +2752,11 @@ export default class ThreeView<
2621
2752
 
2622
2753
  /**
2623
2754
  * Samples the terrain height at a given geodetic position synchronously.
2624
- * @param pos - Geodetic position (lat in radians, lng in radians)
2755
+ * @param pos - Geodetic position (lat in degrees, lng in degrees)
2625
2756
  * @returns Terrain height in meters, or undefined if terrain data not loaded
2626
2757
  */
2627
2758
  sampleTerrainHeight(pos: LatLng): number | undefined {
2628
- const lle = new LLE(pos.lat, pos.lng, 0);
2759
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2629
2760
  return this._core?.sampleTerrainHeight(lle);
2630
2761
  }
2631
2762
 
@@ -2643,7 +2774,7 @@ export default class ThreeView<
2643
2774
  * sampled.
2644
2775
  *
2645
2776
  * @param source - A registered terrain source (handle or id)
2646
- * @param positions - Geodetic positions (lat/lng in radians)
2777
+ * @param positions - Geodetic positions (lat/lng in degrees)
2647
2778
  * @param options - Optional abort signal and fixed-level sampling
2648
2779
  * @returns One result per input position, in the same order
2649
2780
  */
@@ -2669,7 +2800,7 @@ export default class ThreeView<
2669
2800
 
2670
2801
  /**
2671
2802
  * Observes terrain height changes at a position. Callback is invoked each time terrain data updates.
2672
- * @param pos - Geodetic position to observe (lat in radians, lng in radians)
2803
+ * @param pos - Geodetic position to observe (lat in degrees, lng in degrees)
2673
2804
  * @param cb - Callback function receiving the terrain height in meters
2674
2805
  * @returns Cleanup function to stop observing
2675
2806
  */
@@ -2681,7 +2812,7 @@ export default class ThreeView<
2681
2812
  return () => {};
2682
2813
  }
2683
2814
 
2684
- const lle = new LLE(pos.lat, pos.lng, 0);
2815
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2685
2816
  const entityBits = this._core.registerSampleTerrainHeightEvent(lle);
2686
2817
 
2687
2818
  const callFunc = (ev: TerrainHeightUpdatedEvent) => {
@@ -2778,6 +2909,29 @@ export default class ThreeView<
2778
2909
  this.resize(width, height, pixelRatio);
2779
2910
  };
2780
2911
 
2912
+ /**
2913
+ * Resolves a picked batch ID into feature information via the engine's
2914
+ * property store.
2915
+ */
2916
+ private resolvePickedFeature(batchId: number): PickedFeature {
2917
+ const prop = this._core?.readPropertyByGlobalBatchId(batchId);
2918
+ if (prop) {
2919
+ return {
2920
+ properties: prop.properties,
2921
+ // The GPU pick returns the clicked instance's id; report the
2922
+ // feature's canonical batch id instead so it matches the id the
2923
+ // evaluator exposes (a MultiPoint feature spans many instances).
2924
+ batchId: prop.batchId ?? batchId,
2925
+ layerId: prop.layerId,
2926
+ };
2927
+ }
2928
+ return {
2929
+ properties: {},
2930
+ batchId,
2931
+ layerId: undefined,
2932
+ };
2933
+ }
2934
+
2781
2935
  /**
2782
2936
  * Handles pick events and emits the picked feature information.
2783
2937
  * @param pickArr - Array of picked batch IDs
@@ -2786,28 +2940,34 @@ export default class ThreeView<
2786
2940
  this._renderFlag.forceUpdate = true;
2787
2941
 
2788
2942
  if (pickArr.length > 0) {
2789
- const batchId = pickArr[0];
2790
- const prop = this._core?.readPropertyByGlobalBatchId(batchId);
2791
- if (prop) {
2792
- const pickedFeature: PickedFeature = {
2793
- properties: prop.properties,
2794
- batchId,
2795
- layerId: prop.layerId,
2796
- };
2797
- this.emit("pick", pickedFeature);
2798
- } else {
2799
- const emptyFeature: PickedFeature = {
2800
- properties: {},
2801
- batchId,
2802
- layerId: undefined,
2803
- };
2804
- this.emit("pick", emptyFeature);
2805
- }
2943
+ this.emit("featureClick", this.resolvePickedFeature(pickArr[0]));
2806
2944
  } else {
2807
- this.emit("pick", null);
2945
+ this.emit("featureClick", null);
2808
2946
  }
2809
2947
  }
2810
2948
 
2949
+ /**
2950
+ * Handles hover pick results and synthesizes `featureHover` / `featureEnter` /
2951
+ * `featureLeave` events by diffing against the previously hovered feature.
2952
+ * @param pickArr - Array of picked batch IDs
2953
+ */
2954
+ private onHoverPick(pickArr: number[]) {
2955
+ // The pick render re-bakes draped vector atlases in pick mode; a main
2956
+ // render is needed to restore their normal colors.
2957
+ this._renderFlag.forceUpdate = true;
2958
+
2959
+ const batchId = pickArr.length > 0 ? pickArr[0] : 0;
2960
+ const prev = this._hoveredFeature;
2961
+ if ((prev?.batchId ?? 0) === batchId) return;
2962
+
2963
+ const feature = batchId > 0 ? this.resolvePickedFeature(batchId) : null;
2964
+ this._hoveredFeature = feature;
2965
+
2966
+ if (prev) this.emit("featureLeave", prev);
2967
+ if (feature) this.emit("featureEnter", feature);
2968
+ this.emit("featureHover", feature);
2969
+ }
2970
+
2811
2971
  /**
2812
2972
  * Gets whether continuous animation mode is enabled.
2813
2973
  */
@@ -2821,6 +2981,14 @@ export default class ThreeView<
2821
2981
  this._renderFlag.animation = v;
2822
2982
  }
2823
2983
 
2984
+ /**
2985
+ * Gets the canvas element the view renders into. Useful for styling,
2986
+ * e.g. switching the cursor from a `featureHover` event listener.
2987
+ */
2988
+ get canvas(): HTMLCanvasElement {
2989
+ return this._renderer.domElement;
2990
+ }
2991
+
2824
2992
  /**
2825
2993
  * Gets the current screen size in pixels.
2826
2994
  */
@@ -52,7 +52,7 @@ export const transformShader = (
52
52
  #include <packing>
53
53
 
54
54
  in float batchId;
55
- out float nvr_vBatchId;
55
+ flat out float nvr_vBatchId;
56
56
  out vec3 vPosition;
57
57
 
58
58
  ${ShowParsVertex}
@@ -93,7 +93,7 @@ uniform float nvr_uPickable;
93
93
  // uEffectIdsMask is declared by overrideMaterialsForMRT (#ifdef USE_SELECTIVE_EFFECT block)
94
94
  ${MODEL_BASE_SHADER_MARKERS.fragment.UNIFORM_END}
95
95
 
96
- in float nvr_vBatchId;
96
+ flat in float nvr_vBatchId;
97
97
 
98
98
  ${ShowParsFragment}
99
99
 
@@ -68,7 +68,7 @@ in float attrBatchId;
68
68
  in vec4 scaleNormalAndCap;
69
69
 
70
70
  uniform vec2 uMinMaxHeight;
71
- out float nvr_vBatchId;
71
+ flat out float nvr_vBatchId;
72
72
 
73
73
  ${useRTE ? RteParsVertex : ""}
74
74
  ${ShowParsVertex}
@@ -162,7 +162,7 @@ uniform float nvr_uPickable;
162
162
  uniform bool uIsTexturized;
163
163
  ${POLYGON_BASE_SHADER_MARKERS.fragment.UNIFORM_END}
164
164
 
165
- in float nvr_vBatchId;
165
+ flat in float nvr_vBatchId;
166
166
 
167
167
  ${ShowParsFragment}
168
168
 
@@ -97,8 +97,18 @@ export function generateTileMapFragment(
97
97
  }`
98
98
  : "";
99
99
  return `
100
- vec4 atlasColor = texture2D(uColorAtlas, vOrigUv);
101
- vec4 atlasAttr = texture2D(uAttrAtlas, vOrigUv);
100
+ // The atlases are linear-filtered for display, but the pick pass reads
101
+ // encoded batch ids out of atlasColor: interpolating id colors at feature
102
+ // edges decodes to batch ids that don't exist (a thin draped polyline is
103
+ // almost all edge pixels). Snap to the texel center (nearest sampling)
104
+ // while picking.
105
+ vec2 nvr_atlasUv = vOrigUv;
106
+ if (uPickable > 0.) {
107
+ vec2 nvr_atlasSize = vec2(textureSize(uColorAtlas, 0));
108
+ nvr_atlasUv = (floor(vOrigUv * nvr_atlasSize) + 0.5) / nvr_atlasSize;
109
+ }
110
+ vec4 atlasColor = texture2D(uColorAtlas, nvr_atlasUv);
111
+ vec4 atlasAttr = texture2D(uAttrAtlas, nvr_atlasUv);
102
112
 
103
113
  // attr.a encodes (winningSlot + 1) / 255 — 0 means no contributing slot.
104
114
  int winIdx = int(round(atlasAttr.a * 255.0)) - 1;