@navaramap/three 0.0.8 → 0.1.0

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 (35) 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 +213 -34
  10. package/dist/index.js +1476 -1351
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +79 -58
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +12 -12
  15. package/src/core/MeshDesc.test.ts +6 -8
  16. package/src/core/MeshDesc.ts +2 -5
  17. package/src/evaluations/FeatureEvaluator.ts +17 -10
  18. package/src/event/feature.ts +15 -2
  19. package/src/event/features/billboard.test.ts +62 -0
  20. package/src/event/features/billboard.ts +4 -2
  21. package/src/index.ts +98 -27
  22. package/src/material/macro/tileShader.ts +12 -2
  23. package/src/mesh/polyline.ts +32 -4
  24. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  25. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  26. package/src/mesh/sprite/instancedSprite.ts +173 -65
  27. package/src/pick/pickHelper.test.ts +261 -3
  28. package/src/pick/pickHelper.ts +94 -7
  29. package/src/renderOrder.ts +10 -0
  30. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  31. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  32. package/src/type/index.ts +22 -1
  33. package/dist/assets/fontWorker-CkLGtrXf.js +0 -2
  34. package/dist/assets/fontWorker-CkLGtrXf.js.map +0 -1
  35. package/dist/assets/index-DaejBdUz.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@navaramap/three",
3
3
  "type": "module",
4
- "version": "0.0.8",
4
+ "version": "0.1.0",
5
5
  "main": "dist/index.umd.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -30,13 +30,13 @@
30
30
  "astronomy-engine": "2.1.19",
31
31
  "stats.js": "0.17.0",
32
32
  "three-stdlib": "2.36.1",
33
- "@navaramap/core": "0.0.8",
34
- "@navaramap/engine-api": "0.0.8",
35
- "@navaramap/three-api": "0.0.8",
36
- "@navaramap/font": "0.0.8",
37
- "@navaramap/three-csm": "0.0.8",
38
- "@navaramap/worker": "0.0.8",
39
- "@navaramap/engine": "0.0.8"
33
+ "@navaramap/engine": "0.1.0",
34
+ "@navaramap/engine-api": "0.1.0",
35
+ "@navaramap/core": "0.1.0",
36
+ "@navaramap/three-csm": "0.1.0",
37
+ "@navaramap/font": "0.1.0",
38
+ "@navaramap/worker": "0.1.0",
39
+ "@navaramap/three-api": "0.1.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@playwright/test": "1.62.1",
@@ -71,10 +71,10 @@
71
71
  "tweakpane": "4.0.5",
72
72
  "vite-plugin-glsl": "1.6.0",
73
73
  "vite-plugin-static-copy": "4.1.1",
74
- "@navaramap/three-default-descs": "0.0.8",
75
- "@navaramap/three-default-plugin": "0.0.8",
76
- "@navaramap/three-plugins": "0.0.8",
77
- "@navaramap/three-react": "0.0.8"
74
+ "@navaramap/three-default-plugin": "0.1.0",
75
+ "@navaramap/three-plugins": "0.1.0",
76
+ "@navaramap/three-default-descs": "0.1.0",
77
+ "@navaramap/three-react": "0.1.0"
78
78
  },
79
79
  "scripts": {
80
80
  "start": "vite",
@@ -274,15 +274,15 @@ describe("MeshDesc geodetic placement", () => {
274
274
  });
275
275
 
276
276
  describe("MeshDesc terrain-referenced placement", () => {
277
- type LatLngRad = { lat: number; lng: number };
277
+ type LatLngDeg = { lat: number; lng: number };
278
278
 
279
279
  const terrainView = (height: number | undefined) => {
280
280
  const listeners: ((h: number) => void)[] = [];
281
281
  const unsubscribe = vi.fn();
282
282
  const spy = {
283
- sampleTerrainHeight: vi.fn((_pos: LatLngRad) => height),
283
+ sampleTerrainHeight: vi.fn((_pos: LatLngDeg) => height),
284
284
  observeTerrainHeightAt: vi.fn(
285
- (_pos: LatLngRad, cb: (h: number) => void) => {
285
+ (_pos: LatLngDeg, cb: (h: number) => void) => {
286
286
  listeners.push(cb);
287
287
  return unsubscribe;
288
288
  },
@@ -297,8 +297,6 @@ describe("MeshDesc terrain-referenced placement", () => {
297
297
  return desc;
298
298
  };
299
299
 
300
- const rad = (deg: number) => (deg * Math.PI) / 180;
301
-
302
300
  it("seeds from the resident-tile sample and subscribes", () => {
303
301
  const { view, spy } = terrainView(300);
304
302
  createWith(view, {
@@ -308,10 +306,10 @@ describe("MeshDesc terrain-referenced placement", () => {
308
306
  expect(spy.sampleTerrainHeight).toHaveBeenCalled();
309
307
  expect(spy.observeTerrainHeightAt).toHaveBeenCalled();
310
308
 
311
- // Both engine methods take RADIANS, while `geodetic` is degrees.
309
+ // Both terrain methods take the same degrees `geodetic` carries.
312
310
  const pos = spy.sampleTerrainHeight.mock.calls[0][0];
313
- expect(pos.lat).toBeCloseTo(rad(TOKYO.lat), 9);
314
- expect(pos.lng).toBeCloseTo(rad(TOKYO.lng), 9);
311
+ expect(pos.lat).toBeCloseTo(TOKYO.lat, 9);
312
+ expect(pos.lng).toBeCloseTo(TOKYO.lng, 9);
315
313
 
316
314
  // terrain 300 + requested 10
317
315
  expect(lastPlacement()?.height).toBeCloseTo(310, 9);
@@ -3,7 +3,7 @@ import {
3
3
  headingPitchRollToFixedFrame,
4
4
  type GeodeticPlacement,
5
5
  } from "@navaramap/three-api";
6
- import { Euler, MathUtils, Matrix4, Object3D, Vector3, type Mesh } from "three";
6
+ import { Euler, Matrix4, Object3D, Vector3, type Mesh } from "three";
7
7
  import invariant from "tiny-invariant";
8
8
 
9
9
  import type ThreeView from "../index";
@@ -369,10 +369,7 @@ export abstract class MeshDesc<
369
369
  return;
370
370
  }
371
371
 
372
- const pos = {
373
- lat: MathUtils.degToRad(g.lat),
374
- lng: MathUtils.degToRad(g.lng),
375
- };
372
+ const pos = { lat: g.lat, lng: g.lng };
376
373
  this.terrainHeight = this.view.sampleTerrainHeight(pos) ?? 0;
377
374
  this.terrainUnsubscribe = this.view.observeTerrainHeightAt(
378
375
  pos,
@@ -99,6 +99,11 @@ export type { GeometryType };
99
99
  * Information about a feature batch, passed to evaluation callbacks.
100
100
  */
101
101
  export type FeatureInfo = {
102
+ /**
103
+ * The feature's canonical global batch id (its first instance's id when a
104
+ * feature spans several instances, e.g. MultiPoint). Pick events report the
105
+ * same canonical id, so picks and evaluations correlate through this field.
106
+ */
102
107
  batchId: number;
103
108
  properties: Record<string, unknown> | undefined;
104
109
  layerId: LayerId | undefined;
@@ -431,7 +436,7 @@ export class FeatureEvaluator {
431
436
  ) {
432
437
  const evaluate = (info: FeatureInfo, batchIndex: number) => {
433
438
  const evaluated = f(info);
434
- this.applyEvaluatedValues(batchIndex, info.batchId, evaluated);
439
+ this.applyEvaluatedValues(batchIndex, evaluated);
435
440
  };
436
441
 
437
442
  if (options?.filters) {
@@ -443,30 +448,32 @@ export class FeatureEvaluator {
443
448
 
444
449
  private applyEvaluatedValues(
445
450
  batchIndex: number,
446
- batchId: number,
447
451
  evaluated: Partial<EvaluatedValue>,
448
452
  ) {
449
453
  const obj = this.obj;
450
454
 
455
+ // Sprites apply by batch index: a feature can own several instances
456
+ // (MultiPoint, points derived from line/polygon vertices), so a
457
+ // batch-id keyed lookup would style only one of them.
451
458
  if (obj instanceof InstancedSpriteMesh) {
452
459
  if (evaluated.color != null) {
453
- obj.setFeatureColorByBatchId(batchId, evaluated.color.raw);
460
+ obj.setFeatureColorByBatchIndex(batchIndex, evaluated.color.raw);
454
461
  }
455
462
  if (evaluated.show != null) {
456
- obj.setFeatureShowByBatchId(batchId, evaluated.show);
463
+ obj.setFeatureShowByBatchIndex(batchIndex, evaluated.show);
457
464
  }
458
465
  if (evaluated.height != null) {
459
- obj.setFeatureHeightByBatchId(batchId, evaluated.height);
466
+ obj.setFeatureHeightByBatchIndex(batchIndex, evaluated.height);
460
467
  }
461
468
  if (evaluated.size != null) {
462
- obj.setFeatureSizeByBatchId(batchId, evaluated.size);
469
+ obj.setFeatureSizeByBatchIndex(batchIndex, evaluated.size);
463
470
  }
464
471
  if (evaluated.opacity != null) {
465
- obj.setFeatureOpacityByBatchId(batchId, evaluated.opacity);
472
+ obj.setFeatureOpacityByBatchIndex(batchIndex, evaluated.opacity);
466
473
  }
467
474
  if (evaluated.declutterPriority != null) {
468
- obj.setFeatureDeclutterPriorityByBatchId(
469
- batchId,
475
+ obj.setFeatureDeclutterPriorityByBatchIndex(
476
+ batchIndex,
470
477
  evaluated.declutterPriority,
471
478
  );
472
479
  }
@@ -474,7 +481,7 @@ export class FeatureEvaluator {
474
481
  // Async by nature (the image may need fetching); the atlas dedupes
475
482
  // loads by URL so evaluating many features costs one fetch per image.
476
483
  // A nullish url clears the override back to the material default.
477
- void obj.setFeatureImageByBatchId(batchId, evaluated.image);
484
+ void obj.setFeatureImageByBatchIndex(batchIndex, evaluated.image);
478
485
  }
479
486
  return;
480
487
  }
@@ -14,7 +14,7 @@ import {
14
14
  PolygonMesh,
15
15
  PolylineMesh,
16
16
  } from "../mesh";
17
- import { FEATURE_RENDER_ORDER } from "../renderOrder";
17
+ import { FEATURE_RENDER_ORDER, STROKE_RENDER_ORDER } from "../renderOrder";
18
18
  import type { Scenes } from "../scene";
19
19
 
20
20
  import type { EventContext } from "./context";
@@ -190,7 +190,20 @@ export async function processRenderableFeatureAdded(
190
190
  setTransform(obj, transform);
191
191
  }
192
192
 
193
- obj.renderOrder = FEATURE_RENDER_ORDER;
193
+ // Strokes draw after fills: sibling meshes are created asynchronously in
194
+ // arbitrary order, and a fill drawn after a coplanar stroke either paints
195
+ // over it (bake scene) or z-fights against its depth (MRT scene), so the
196
+ // stacking must come from renderOrder, not insertion order. Only polylines
197
+ // stroking polygon rings sit on a sibling fill; standalone line strokes
198
+ // keep the base order — elevated, they would deterministically composite
199
+ // over every marker and outline they merely cross (PolylineMesh skips the
200
+ // depth test in the MRT scene).
201
+ const strokesSiblingFills =
202
+ polyline?.material.geometryTypes?.includes("polygon") ?? false;
203
+ obj.renderOrder =
204
+ obj instanceof PolylineMesh && strokesSiblingFills
205
+ ? STROKE_RENDER_ORDER
206
+ : FEATURE_RENDER_ORDER;
194
207
 
195
208
  // Add to MRT scene if not draped (draped features render to texturized scene)
196
209
  const isDraped =
@@ -0,0 +1,62 @@
1
+ import type { BillboardMesh as NavaraBillboardMesh } from "@navaramap/engine";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { EventContext } from "../context";
5
+
6
+ import { renderBillboard } from "./billboard";
7
+
8
+ const init = vi.fn().mockResolvedValue(undefined);
9
+ const setActive = vi.fn();
10
+
11
+ vi.mock("../../mesh", () => ({
12
+ InstancedSpriteMesh: class {
13
+ _init = init;
14
+ setActive = setActive;
15
+ },
16
+ }));
17
+
18
+ describe("renderBillboard", () => {
19
+ const ctx = {} as EventContext;
20
+
21
+ // `url` is omitted from the material entirely when not passed, mirroring the
22
+ // engine's `string | undefined` accessor. The engine itself currently always
23
+ // sends a string (`BillboardMaterial::url` is a `String` defaulting to `""`,
24
+ // converted as `Some(url)`), so `""` is the shape a real url-less layer
25
+ // produces — both are covered below.
26
+ const createMesh = (url?: string) =>
27
+ ({
28
+ material: url === undefined ? {} : { url },
29
+ active: true,
30
+ }) as unknown as NavaraBillboardMesh;
31
+
32
+ beforeEach(() => {
33
+ init.mockClear();
34
+ setActive.mockClear();
35
+ });
36
+
37
+ it("creates a mesh when the material carries a default image", async () => {
38
+ const obj = await renderBillboard(ctx, createMesh("marker.png"));
39
+
40
+ expect(obj).toBeDefined();
41
+ expect(init).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ // Regression: the mesh used to be skipped when the material had no `url`,
45
+ // so a layer that styles every feature through a FeatureEvaluator
46
+ // (`{ image }`) never got a mesh — and never a `featureCreated` event to
47
+ // attach the evaluator to. Empty atlas rects keep such instances invisible
48
+ // until an image is packed, so the mesh is safe to create eagerly.
49
+ it("creates a mesh when the material's default image is empty", async () => {
50
+ const obj = await renderBillboard(ctx, createMesh(""));
51
+
52
+ expect(obj).toBeDefined();
53
+ expect(init).toHaveBeenCalledOnce();
54
+ });
55
+
56
+ it("creates a mesh when the material has no `url` at all", async () => {
57
+ const obj = await renderBillboard(ctx, createMesh());
58
+
59
+ expect(obj).toBeDefined();
60
+ expect(init).toHaveBeenCalledOnce();
61
+ });
62
+ });
@@ -8,8 +8,10 @@ export async function renderBillboard(
8
8
  ctx: EventContext,
9
9
  m: NavaraBillboardMesh,
10
10
  ) {
11
- if (!m.material.url) return;
12
-
11
+ // No `url` check: a billboard layer may carry no material-level image and
12
+ // get every image per-feature from a FeatureEvaluator (`{ image }`). The
13
+ // mesh starts with empty atlas rects, which the shader culls, so instances
14
+ // stay invisible until an image is packed for them.
13
15
  const mesh = new InstancedSpriteMesh({
14
16
  renderOrder: FEATURE_RENDER_ORDER,
15
17
  ctx,
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,
@@ -309,7 +309,11 @@ export type Options = {
309
309
  atmosphere?: AtmosphereOptions;
310
310
  /** Background color of the scene. Defaults to dark color (0x0a0a0f). */
311
311
  backgroundColor?: CoreColor;
312
- /** Feature picking configuration. */
312
+ /**
313
+ * Feature picking configuration (default: true). Enables the `featureClick`
314
+ * event (click) and the `featureHover` / `featureEnter` / `featureLeave`
315
+ * events (mousemove).
316
+ */
313
317
  picking?: boolean;
314
318
  /** When true, renders every frame. When false, renders only on changes or when forceUpdate() is called. */
315
319
  animation?: boolean;
@@ -413,8 +417,24 @@ export type MapMouseEvent = {
413
417
  export type ViewEvents = {
414
418
  /** Emitted when the view is resized. Receives width and height in pixels. */
415
419
  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;
420
+ /**
421
+ * Emitted when a feature is clicked (click-gesture GPU pick). Receives the
422
+ * clicked feature, or null when empty space was clicked. For raw click
423
+ * coordinates use the `click` event instead.
424
+ */
425
+ featureClick: (info: Nullable<PickedFeature>) => void;
426
+ /**
427
+ * Emitted when the hovered feature changes (mousemove picking). Receives
428
+ * the newly hovered feature, or null when the pointer leaves all pickable
429
+ * features. Hover picking runs a GPU pick per frame while the pointer
430
+ * moves, and is active only while at least one `featureHover` /
431
+ * `featureEnter` / `featureLeave` listener is registered.
432
+ */
433
+ featureHover: (info: Nullable<PickedFeature>) => void;
434
+ /** Emitted when the pointer starts hovering a pickable feature. See `featureHover` for activation. */
435
+ featureEnter: (info: PickedFeature) => void;
436
+ /** Emitted when the pointer stops hovering the previously hovered feature. See `featureHover` for activation. */
437
+ featureLeave: (info: PickedFeature) => void;
418
438
  /** Emitted when a layer event occurs. Receives event type, layer ID, and event arguments. */
419
439
  layer: <K extends keyof LayerEvent>(
420
440
  k: K,
@@ -503,6 +523,9 @@ export default class ThreeView<
503
523
  * `camera_flight_ended` events from the engine. */
504
524
  private _pendingFlights = new Map<number, (completed: boolean) => void>();
505
525
 
526
+ /** Currently hovered feature, used to diff-synthesize hover events. */
527
+ private _hoveredFeature: Nullable<PickedFeature> = null;
528
+
506
529
  private _meshes: MeshCache = new Map();
507
530
  private _abortControllers: AbortControllers = new Map();
508
531
  private _workerPoolPromises: WorkerPoolPromises = new Map();
@@ -1562,6 +1585,17 @@ export default class ThreeView<
1562
1585
  this._camera.raw,
1563
1586
  this._meshes,
1564
1587
  this.onPick.bind(this),
1588
+ this.onHoverPick.bind(this),
1589
+ // Picks cost a GPU render (plus a re-render to restore draped
1590
+ // atlases), so both gestures stay off until someone actually
1591
+ // listens for the result.
1592
+ () => !!this.size("featureClick"),
1593
+ () =>
1594
+ !!(
1595
+ this.size("featureHover") ||
1596
+ this.size("featureEnter") ||
1597
+ this.size("featureLeave")
1598
+ ),
1565
1599
  // {
1566
1600
  // debug: true,
1567
1601
  // },
@@ -2621,11 +2655,11 @@ export default class ThreeView<
2621
2655
 
2622
2656
  /**
2623
2657
  * Samples the terrain height at a given geodetic position synchronously.
2624
- * @param pos - Geodetic position (lat in radians, lng in radians)
2658
+ * @param pos - Geodetic position (lat in degrees, lng in degrees)
2625
2659
  * @returns Terrain height in meters, or undefined if terrain data not loaded
2626
2660
  */
2627
2661
  sampleTerrainHeight(pos: LatLng): number | undefined {
2628
- const lle = new LLE(pos.lat, pos.lng, 0);
2662
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2629
2663
  return this._core?.sampleTerrainHeight(lle);
2630
2664
  }
2631
2665
 
@@ -2643,7 +2677,7 @@ export default class ThreeView<
2643
2677
  * sampled.
2644
2678
  *
2645
2679
  * @param source - A registered terrain source (handle or id)
2646
- * @param positions - Geodetic positions (lat/lng in radians)
2680
+ * @param positions - Geodetic positions (lat/lng in degrees)
2647
2681
  * @param options - Optional abort signal and fixed-level sampling
2648
2682
  * @returns One result per input position, in the same order
2649
2683
  */
@@ -2669,7 +2703,7 @@ export default class ThreeView<
2669
2703
 
2670
2704
  /**
2671
2705
  * 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)
2706
+ * @param pos - Geodetic position to observe (lat in degrees, lng in degrees)
2673
2707
  * @param cb - Callback function receiving the terrain height in meters
2674
2708
  * @returns Cleanup function to stop observing
2675
2709
  */
@@ -2681,7 +2715,7 @@ export default class ThreeView<
2681
2715
  return () => {};
2682
2716
  }
2683
2717
 
2684
- const lle = new LLE(pos.lat, pos.lng, 0);
2718
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2685
2719
  const entityBits = this._core.registerSampleTerrainHeightEvent(lle);
2686
2720
 
2687
2721
  const callFunc = (ev: TerrainHeightUpdatedEvent) => {
@@ -2778,6 +2812,29 @@ export default class ThreeView<
2778
2812
  this.resize(width, height, pixelRatio);
2779
2813
  };
2780
2814
 
2815
+ /**
2816
+ * Resolves a picked batch ID into feature information via the engine's
2817
+ * property store.
2818
+ */
2819
+ private resolvePickedFeature(batchId: number): PickedFeature {
2820
+ const prop = this._core?.readPropertyByGlobalBatchId(batchId);
2821
+ if (prop) {
2822
+ return {
2823
+ properties: prop.properties,
2824
+ // The GPU pick returns the clicked instance's id; report the
2825
+ // feature's canonical batch id instead so it matches the id the
2826
+ // evaluator exposes (a MultiPoint feature spans many instances).
2827
+ batchId: prop.batchId ?? batchId,
2828
+ layerId: prop.layerId,
2829
+ };
2830
+ }
2831
+ return {
2832
+ properties: {},
2833
+ batchId,
2834
+ layerId: undefined,
2835
+ };
2836
+ }
2837
+
2781
2838
  /**
2782
2839
  * Handles pick events and emits the picked feature information.
2783
2840
  * @param pickArr - Array of picked batch IDs
@@ -2786,28 +2843,34 @@ export default class ThreeView<
2786
2843
  this._renderFlag.forceUpdate = true;
2787
2844
 
2788
2845
  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
- }
2846
+ this.emit("featureClick", this.resolvePickedFeature(pickArr[0]));
2806
2847
  } else {
2807
- this.emit("pick", null);
2848
+ this.emit("featureClick", null);
2808
2849
  }
2809
2850
  }
2810
2851
 
2852
+ /**
2853
+ * Handles hover pick results and synthesizes `featureHover` / `featureEnter` /
2854
+ * `featureLeave` events by diffing against the previously hovered feature.
2855
+ * @param pickArr - Array of picked batch IDs
2856
+ */
2857
+ private onHoverPick(pickArr: number[]) {
2858
+ // The pick render re-bakes draped vector atlases in pick mode; a main
2859
+ // render is needed to restore their normal colors.
2860
+ this._renderFlag.forceUpdate = true;
2861
+
2862
+ const batchId = pickArr.length > 0 ? pickArr[0] : 0;
2863
+ const prev = this._hoveredFeature;
2864
+ if ((prev?.batchId ?? 0) === batchId) return;
2865
+
2866
+ const feature = batchId > 0 ? this.resolvePickedFeature(batchId) : null;
2867
+ this._hoveredFeature = feature;
2868
+
2869
+ if (prev) this.emit("featureLeave", prev);
2870
+ if (feature) this.emit("featureEnter", feature);
2871
+ this.emit("featureHover", feature);
2872
+ }
2873
+
2811
2874
  /**
2812
2875
  * Gets whether continuous animation mode is enabled.
2813
2876
  */
@@ -2821,6 +2884,14 @@ export default class ThreeView<
2821
2884
  this._renderFlag.animation = v;
2822
2885
  }
2823
2886
 
2887
+ /**
2888
+ * Gets the canvas element the view renders into. Useful for styling,
2889
+ * e.g. switching the cursor from a `featureHover` event listener.
2890
+ */
2891
+ get canvas(): HTMLCanvasElement {
2892
+ return this._renderer.domElement;
2893
+ }
2894
+
2824
2895
  /**
2825
2896
  * Gets the current screen size in pixels.
2826
2897
  */
@@ -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;
@@ -32,6 +32,16 @@ import { setupRTECallback } from "./rtcRteHelper";
32
32
  // Sentinel value for picking coordinate when not picking (reused to avoid allocations)
33
33
  const PICKING_COORD_SENTINEL = new Vector2(-1, -1);
34
34
 
35
+ /**
36
+ * Minimum stroke width used while rendering the pick pass. A hairline stroke
37
+ * covers too few pick-buffer texels to click reliably — on the draped path the
38
+ * 512px tile atlas resolves a thin line to scattered partial texels, so the
39
+ * decoded batch id is background almost everywhere the line is visible.
40
+ * Fattening the stroke only for the pick render gives lines the same "click
41
+ * tolerance" pointing devices get elsewhere; the visible width is untouched.
42
+ */
43
+ const MIN_PICK_WIDTH = 10;
44
+
35
45
  type Attributes = BatchedFeatureAttributes<{
36
46
  position: BufferAttribute;
37
47
  // RTE mode attributes (only present when useRTE=true)
@@ -490,11 +500,21 @@ export class PolylineMesh extends BatchedFeatureMesh<
490
500
  return this.getEnhancer().states().effectIdsMask;
491
501
  }
492
502
 
503
+ /** Visible stroke width to restore after a pick-pass width bump. */
504
+ private _pickSavedWidth?: number;
505
+
493
506
  onBeforePicking(pickingCoord?: Vector2) {
494
- this.getEnhancer().update({ base: { pickable: true } });
507
+ const enhancer = this.getEnhancer();
508
+ const width = enhancer.states().width;
509
+ if (width < MIN_PICK_WIDTH) {
510
+ this._pickSavedWidth = width;
511
+ enhancer.update({ base: { pickable: true, width: MIN_PICK_WIDTH } });
512
+ } else {
513
+ enhancer.update({ base: { pickable: true } });
514
+ }
495
515
  this.needsUpdate();
496
516
 
497
- const mutates = this.getEnhancer().mutates();
517
+ const mutates = enhancer.mutates();
498
518
  if (pickingCoord) {
499
519
  mutates.setPickingCoord(pickingCoord);
500
520
  } else {
@@ -503,9 +523,17 @@ export class PolylineMesh extends BatchedFeatureMesh<
503
523
  }
504
524
 
505
525
  onAfterPicking() {
506
- this.getEnhancer().update({ base: { pickable: false } });
526
+ const enhancer = this.getEnhancer();
527
+ if (this._pickSavedWidth !== undefined) {
528
+ enhancer.update({
529
+ base: { pickable: false, width: this._pickSavedWidth },
530
+ });
531
+ this._pickSavedWidth = undefined;
532
+ } else {
533
+ enhancer.update({ base: { pickable: false } });
534
+ }
507
535
  this.needsUpdate();
508
- this.getEnhancer().mutates().setPickingCoord(PICKING_COORD_SENTINEL);
536
+ enhancer.mutates().setPickingCoord(PICKING_COORD_SENTINEL);
509
537
  }
510
538
 
511
539
  _getDefaultBatchAttributeValues(): DefaultBatchAttributeValues {
@@ -212,6 +212,71 @@ describe("BatchedSdfTextMesh material updates vs per-feature values", () => {
212
212
  });
213
213
  });
214
214
 
215
+ // A feature owns several anchors for MultiPoint geometry and for labels
216
+ // derived from line/polygon vertices via `geometryTypes`. Per-feature setters
217
+ // address features (batch indices), so they must fan out to every anchor the
218
+ // feature owns via the geometry's per-anchor `batch_index` buffer.
219
+ describe("BatchedSdfTextMesh multi-instance fan-out", () => {
220
+ /** Feature 0 owns anchors 0 and 1 (MultiPoint); feature 1 owns anchor 2. */
221
+ function multiInstanceMeshEvent(mat: NavaraTextMaterial): NavaraTextMesh {
222
+ return {
223
+ material: mat,
224
+ transform: { tx: 0, ty: 0, tz: 0 },
225
+ geometry: {
226
+ batch_ids: { data: new Float32Array([7, 8, 9]), size: 1 },
227
+ batch_index: { data: new Uint32Array([0, 0, 1]), size: 1 },
228
+ position: {
229
+ data: new Float32Array([0, 0, 0, 10, 0, 0, 20, 0, 0]),
230
+ size: 3,
231
+ },
232
+ },
233
+ } as unknown as NavaraTextMesh;
234
+ }
235
+
236
+ function makeMultiInstanceMesh() {
237
+ const fontManager = makeFontManager();
238
+ const ctx = {
239
+ buf: {
240
+ removeF32: (d: Float32Array) => d,
241
+ u32: (d: Uint32Array) => d,
242
+ },
243
+ fontManager,
244
+ renderFlag: { forceUpdate: false },
245
+ } as unknown as EventContext;
246
+ const mesh = new BatchedSdfTextMesh(
247
+ ctx,
248
+ multiInstanceMeshEvent(material()),
249
+ "font",
250
+ { layerId: "layer" },
251
+ );
252
+ return { mesh, fontManager };
253
+ }
254
+
255
+ it("applies text and styles to every anchor the feature owns", () => {
256
+ const { mesh } = makeMultiInstanceMesh();
257
+ mesh.setTextByBatchIndex(0, "AB");
258
+ mesh.setFeatureSizeByBatchIndex(0, 32);
259
+
260
+ // Feature 0's two anchors got labels (slots 0 and 1), both styled.
261
+ expect(showOf(mesh, 0)).toBe(1);
262
+ expect(showOf(mesh, 1)).toBe(1);
263
+ expect(sizeOf(mesh, 0)).toBe(32);
264
+ expect(sizeOf(mesh, 1)).toBe(32);
265
+ });
266
+
267
+ it("does not leak styling into other features' anchors", () => {
268
+ const { mesh } = makeMultiInstanceMesh();
269
+ mesh.setTextByBatchIndex(0, "AB");
270
+ mesh.setTextByBatchIndex(1, "CD");
271
+ mesh.setFeatureShowByBatchIndex(1, false);
272
+
273
+ expect(showOf(mesh, 0)).toBe(1);
274
+ expect(showOf(mesh, 1)).toBe(1);
275
+ // Feature 1 = anchor 2 = the third label slot.
276
+ expect(showOf(mesh, 2)).toBe(0);
277
+ });
278
+ });
279
+
215
280
  // Unprepared text costs a worker round-trip and font-face fetches, and a
216
281
  // low-zoom tile spans far more world than the screen shows. With declutter on,
217
282
  // `setTextByBatchIndex` therefore parks preparation until a placement pass