@navaramap/three 0.0.7 → 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 (46) 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-VfUncKuS.js +25 -0
  6. package/dist/assets/index-VfUncKuS.js.map +1 -0
  7. package/dist/assets/{navara_wasm_font_worker_bg-XvE_oYPr.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
  8. package/dist/assets/{navara_wasm_worker_bg-Dg9fnGLP.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
  9. package/dist/index.d.ts +460 -43
  10. package/dist/index.js +3732 -3584
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +230 -209
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +17 -17
  15. package/src/camera.ts +11 -4
  16. package/src/core/InstancedMeshDesc.ts +1 -1
  17. package/src/core/MeshDesc.test.ts +396 -0
  18. package/src/core/MeshDesc.ts +219 -8
  19. package/src/core/errors.ts +14 -0
  20. package/src/core/index.ts +2 -0
  21. package/src/evaluations/FeatureEvaluator.ts +17 -10
  22. package/src/event/context.ts +6 -0
  23. package/src/event/feature.ts +15 -2
  24. package/src/event/features/billboard.test.ts +62 -0
  25. package/src/event/features/billboard.ts +4 -2
  26. package/src/event/index.ts +7 -0
  27. package/src/index.ts +174 -40
  28. package/src/material/macro/tileShader.ts +12 -2
  29. package/src/mesh/polyline.ts +32 -4
  30. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  31. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  32. package/src/mesh/sprite/billboardAtlas.ts +8 -1
  33. package/src/mesh/sprite/instancedSprite.ts +173 -65
  34. package/src/pick/pickHelper.test.ts +302 -0
  35. package/src/pick/pickHelper.ts +119 -20
  36. package/src/plugins/AttributionPlugin/index.ts +1 -1
  37. package/src/renderOrder.ts +10 -0
  38. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  39. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  40. package/src/terrain/terrainHeightEvent.test.ts +32 -0
  41. package/src/terrain/terrainHeightEvent.ts +22 -0
  42. package/src/type/index.ts +22 -1
  43. package/dist/assets/fontWorker-DaqfXmZf.js +0 -2
  44. package/dist/assets/fontWorker-DaqfXmZf.js.map +0 -1
  45. package/dist/assets/index-Dydat1h1.js +0 -25
  46. package/dist/assets/index-Dydat1h1.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,
@@ -131,6 +131,7 @@ import {
131
131
  type SampledTerrainPosition,
132
132
  type TerrainSampleSource,
133
133
  } from "./terrain/sampleTerrainMostDetailed";
134
+ import { hasObservedTerrainHeight } from "./terrain/terrainHeightEvent";
134
135
  import type { TextureOptions } from "./textures";
135
136
  import { TileTextureCompositor } from "./tileTexture";
136
137
  import {
@@ -156,6 +157,30 @@ import WorkerURL from "./worker?url&worker";
156
157
 
157
158
  export type { CameraOptions, CameraEvent } from "./camera";
158
159
 
160
+ /** Easing presets applied to a `flyTo` flight's normalized time. */
161
+ export type FlyToEasing =
162
+ "linear" | "quadInOut" | "cubicInOut" | "cubicOut" | "quinticInOut";
163
+
164
+ export type FlyToOptions = {
165
+ /** Animation duration in milliseconds. Defaults to a duration derived from
166
+ * the flight distance (2–3 s). Pass `0` to jump instantly. */
167
+ duration?: number;
168
+ /** Maximum height during the flight arc in meters. */
169
+ maxHeight?: number;
170
+ /** Easing preset. Defaults to `"quinticInOut"`, or `"cubicOut"` when
171
+ * descending from above 11,500 m. */
172
+ easing?: FlyToEasing;
173
+ };
174
+
175
+ // Must mirror `FlyToEasing`'s `TryFrom<u8>` mapping on the Rust side.
176
+ const FLY_TO_EASING_CODE: Record<FlyToEasing, number> = {
177
+ linear: 0,
178
+ quadInOut: 1,
179
+ cubicInOut: 2,
180
+ cubicOut: 3,
181
+ quinticInOut: 4,
182
+ };
183
+
159
184
  export { ColorMap, EventHandler, Plugin } from "@navaramap/core";
160
185
  export type {
161
186
  Nullable,
@@ -284,7 +309,11 @@ export type Options = {
284
309
  atmosphere?: AtmosphereOptions;
285
310
  /** Background color of the scene. Defaults to dark color (0x0a0a0f). */
286
311
  backgroundColor?: CoreColor;
287
- /** 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
+ */
288
317
  picking?: boolean;
289
318
  /** When true, renders every frame. When false, renders only on changes or when forceUpdate() is called. */
290
319
  animation?: boolean;
@@ -388,8 +417,24 @@ export type MapMouseEvent = {
388
417
  export type ViewEvents = {
389
418
  /** Emitted when the view is resized. Receives width and height in pixels. */
390
419
  resize: (w: number, h: number) => void;
391
- /** Emitted when a feature is picked. Receives picked feature info or null. */
392
- 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;
393
438
  /** Emitted when a layer event occurs. Receives event type, layer ID, and event arguments. */
394
439
  layer: <K extends keyof LayerEvent>(
395
440
  k: K,
@@ -474,6 +519,12 @@ export default class ThreeView<
474
519
  private _declutterRetryDelay = 0;
475
520
  private _isIdle = false;
476
521
  private _uniforms: CommonUniforms;
522
+ /** Pending `flyTo` promises keyed by flight id, settled by
523
+ * `camera_flight_ended` events from the engine. */
524
+ private _pendingFlights = new Map<number, (completed: boolean) => void>();
525
+
526
+ /** Currently hovered feature, used to diff-synthesize hover events. */
527
+ private _hoveredFeature: Nullable<PickedFeature> = null;
477
528
 
478
529
  private _meshes: MeshCache = new Map();
479
530
  private _abortControllers: AbortControllers = new Map();
@@ -1055,7 +1106,9 @@ export default class ThreeView<
1055
1106
  tGlobeNormal: { value: null },
1056
1107
  tSkyEnvMap: { value: null },
1057
1108
  inverseProjectionMatrix: { value: null },
1058
- // TODO: Need to sync `fov` with WASM side
1109
+ // Vertical fov in radians; synced with the engine on each frustum event.
1110
+ // Plain arithmetic: the WASM-backed degreeToRadian is not available
1111
+ // until init().
1059
1112
  fov: { value: (this._camera.raw.fov * Math.PI) / 180 },
1060
1113
  screenHeightPx: { value: height },
1061
1114
  time: { value: 0 },
@@ -1493,6 +1546,13 @@ export default class ThreeView<
1493
1546
  viewEvents: this,
1494
1547
  layersManager: this.layersManager,
1495
1548
  viewContext: this.viewContext,
1549
+ flightHandler: (id, completed) => {
1550
+ const resolve = this._pendingFlights.get(id);
1551
+ if (resolve) {
1552
+ this._pendingFlights.delete(id);
1553
+ resolve(completed);
1554
+ }
1555
+ },
1496
1556
  layerHandler: this._layerHandler,
1497
1557
  fontManager: this._fontManager,
1498
1558
  textureFragmentIndex: this._textureFragmentIndex,
@@ -1525,6 +1585,17 @@ export default class ThreeView<
1525
1585
  this._camera.raw,
1526
1586
  this._meshes,
1527
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
+ ),
1528
1599
  // {
1529
1600
  // debug: true,
1530
1601
  // },
@@ -1603,6 +1674,11 @@ export default class ThreeView<
1603
1674
  dispose() {
1604
1675
  this._disposed = true;
1605
1676
  this._initialized = false;
1677
+ // Settle pending flyTo promises so callers never hang on a disposed view.
1678
+ for (const resolve of this._pendingFlights.values()) {
1679
+ resolve(false);
1680
+ }
1681
+ this._pendingFlights.clear();
1606
1682
  if (this._declutterRetry !== undefined) {
1607
1683
  clearTimeout(this._declutterRetry);
1608
1684
  this._declutterRetry = undefined;
@@ -1768,7 +1844,10 @@ export default class ThreeView<
1768
1844
  const pixelRatio = this._renderer.getPixelRatio();
1769
1845
 
1770
1846
  // Ref: https://github.com/CesiumGS/cesium/blob/2cf09cb06e4f7ea767da39befabcfc3444b02c49/packages/engine/Source/Core/PerspectiveFrustum.js#L208-L218
1771
- const fovY = this._camera.fovy ?? 1;
1847
+ // raw.fov mirrors the engine's vertical fov (degrees) via the frustum
1848
+ // event pump. Plain arithmetic: this must not depend on the WASM-backed
1849
+ // degreeToRadian helper.
1850
+ const fovY = (this._camera.raw.fov * Math.PI) / 180;
1772
1851
  const top = this._camera.raw.near * Math.tan(0.5 * fovY);
1773
1852
  const bottom = -top;
1774
1853
  const right = this._camera.raw.aspect * top;
@@ -1793,8 +1872,8 @@ export default class ThreeView<
1793
1872
  this._uniforms.inverseProjectionMatrix.value =
1794
1873
  this._camera.raw.projectionMatrixInverse;
1795
1874
 
1796
- // TODO: Need to sync `fov` with WASM side
1797
- this._uniforms.fov.value = (this._camera.raw.fov * Math.PI) / 180;
1875
+ // Vertical fov in radians (raw.fov is kept in sync with the engine).
1876
+ this._uniforms.fov.value = fovY;
1798
1877
  this._uniforms.screenHeightPx.value = viewport?.height ?? 0;
1799
1878
  }
1800
1879
 
@@ -2486,30 +2565,48 @@ export default class ThreeView<
2486
2565
 
2487
2566
  /**
2488
2567
  * Animates the camera to fly to a target position.
2568
+ *
2569
+ * The flight follows an arced path: the default duration is derived from
2570
+ * the flight distance (2–3 s), the default easing is `quinticInOut`
2571
+ * (`cubicOut` when descending from high altitude), and longitudes take the
2572
+ * short way across the antimeridian.
2573
+ *
2489
2574
  * @param camPos - Target position with required lng/lat (degrees). Provide either `height` (meters above the ellipsoid)
2490
2575
  * or `distance` (meters from the target point along the camera forward direction). Optional pitch, heading, roll (degrees).
2491
- * @param duration - Animation duration in milliseconds
2492
- * @param maxHeight - Maximum height during the flight arc in meters
2576
+ * @param options - Flight options (`duration`, `maxHeight`, `easing`)
2577
+ * @returns A promise resolving to `true` when the flight completes, or
2578
+ * `false` when it is interrupted by another camera operation, such as a
2579
+ * newer `flyTo` or a `setCamera` call.
2493
2580
  */
2494
2581
  flyTo(
2495
2582
  camPos: CameraPosition &
2496
2583
  Required<Pick<CameraPosition, "lng" | "lat">> &
2497
2584
  ({ height: number } | { distance: number }),
2498
- duration?: number,
2499
- maxHeight?: number,
2500
- ) {
2585
+ options: FlyToOptions = {},
2586
+ ): Promise<boolean> {
2501
2587
  const pos = camPos as CameraPosition & { lng: number; lat: number };
2502
2588
  const position = new Float64Array([pos.lng, pos.lat, pos.height ?? 0]);
2503
2589
 
2504
- this._core?.flyTo(
2590
+ const id = this._core?.flyTo(
2505
2591
  position,
2506
2592
  pos.pitch,
2507
2593
  pos.heading,
2508
2594
  pos.roll,
2509
- duration,
2510
- maxHeight,
2595
+ options.duration,
2596
+ options.maxHeight,
2511
2597
  pos.distance,
2598
+ options.easing === undefined
2599
+ ? undefined
2600
+ : FLY_TO_EASING_CODE[options.easing],
2512
2601
  );
2602
+
2603
+ if (id === undefined) {
2604
+ return Promise.resolve(false);
2605
+ }
2606
+
2607
+ return new Promise<boolean>((resolve) => {
2608
+ this._pendingFlights.set(id, resolve);
2609
+ });
2513
2610
  }
2514
2611
 
2515
2612
  /**
@@ -2558,11 +2655,11 @@ export default class ThreeView<
2558
2655
 
2559
2656
  /**
2560
2657
  * Samples the terrain height at a given geodetic position synchronously.
2561
- * @param pos - Geodetic position (lat in radians, lng in radians)
2658
+ * @param pos - Geodetic position (lat in degrees, lng in degrees)
2562
2659
  * @returns Terrain height in meters, or undefined if terrain data not loaded
2563
2660
  */
2564
2661
  sampleTerrainHeight(pos: LatLng): number | undefined {
2565
- const lle = new LLE(pos.lat, pos.lng, 0);
2662
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2566
2663
  return this._core?.sampleTerrainHeight(lle);
2567
2664
  }
2568
2665
 
@@ -2580,7 +2677,7 @@ export default class ThreeView<
2580
2677
  * sampled.
2581
2678
  *
2582
2679
  * @param source - A registered terrain source (handle or id)
2583
- * @param positions - Geodetic positions (lat/lng in radians)
2680
+ * @param positions - Geodetic positions (lat/lng in degrees)
2584
2681
  * @param options - Optional abort signal and fixed-level sampling
2585
2682
  * @returns One result per input position, in the same order
2586
2683
  */
@@ -2606,7 +2703,7 @@ export default class ThreeView<
2606
2703
 
2607
2704
  /**
2608
2705
  * Observes terrain height changes at a position. Callback is invoked each time terrain data updates.
2609
- * @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)
2610
2707
  * @param cb - Callback function receiving the terrain height in meters
2611
2708
  * @returns Cleanup function to stop observing
2612
2709
  */
@@ -2618,11 +2715,11 @@ export default class ThreeView<
2618
2715
  return () => {};
2619
2716
  }
2620
2717
 
2621
- const lle = new LLE(pos.lat, pos.lng, 0);
2718
+ const lle = new LLE(degreeToRadian(pos.lat), degreeToRadian(pos.lng), 0);
2622
2719
  const entityBits = this._core.registerSampleTerrainHeightEvent(lle);
2623
2720
 
2624
2721
  const callFunc = (ev: TerrainHeightUpdatedEvent) => {
2625
- if (ev.bits === entityBits && ev.height) {
2722
+ if (hasObservedTerrainHeight(ev, entityBits)) {
2626
2723
  cb(ev.height);
2627
2724
  }
2628
2725
  };
@@ -2715,6 +2812,29 @@ export default class ThreeView<
2715
2812
  this.resize(width, height, pixelRatio);
2716
2813
  };
2717
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
+
2718
2838
  /**
2719
2839
  * Handles pick events and emits the picked feature information.
2720
2840
  * @param pickArr - Array of picked batch IDs
@@ -2723,28 +2843,34 @@ export default class ThreeView<
2723
2843
  this._renderFlag.forceUpdate = true;
2724
2844
 
2725
2845
  if (pickArr.length > 0) {
2726
- const batchId = pickArr[0];
2727
- const prop = this._core?.readPropertyByGlobalBatchId(batchId);
2728
- if (prop) {
2729
- const pickedFeature: PickedFeature = {
2730
- properties: prop.properties,
2731
- batchId,
2732
- layerId: prop.layerId,
2733
- };
2734
- this.emit("pick", pickedFeature);
2735
- } else {
2736
- const emptyFeature: PickedFeature = {
2737
- properties: {},
2738
- batchId,
2739
- layerId: undefined,
2740
- };
2741
- this.emit("pick", emptyFeature);
2742
- }
2846
+ this.emit("featureClick", this.resolvePickedFeature(pickArr[0]));
2743
2847
  } else {
2744
- this.emit("pick", null);
2848
+ this.emit("featureClick", null);
2745
2849
  }
2746
2850
  }
2747
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
+
2748
2874
  /**
2749
2875
  * Gets whether continuous animation mode is enabled.
2750
2876
  */
@@ -2758,6 +2884,14 @@ export default class ThreeView<
2758
2884
  this._renderFlag.animation = v;
2759
2885
  }
2760
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
+
2761
2895
  /**
2762
2896
  * Gets the current screen size in pixels.
2763
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