@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
@@ -1,4 +1,8 @@
1
1
  import type { BaseEventMap, XYZ } from "@navaramap/core";
2
+ import {
3
+ headingPitchRollToFixedFrame,
4
+ type GeodeticPlacement,
5
+ } from "@navaramap/three-api";
2
6
  import { Euler, Matrix4, Object3D, Vector3, type Mesh } from "three";
3
7
  import invariant from "tiny-invariant";
4
8
 
@@ -12,6 +16,7 @@ import {
12
16
  type BaseDescConfig,
13
17
  type BaseDescConfigUpdate,
14
18
  } from "./BaseDesc";
19
+ import { ConflictingTransformError } from "./errors";
15
20
  import type { ViewContext } from "./ViewContext";
16
21
 
17
22
  export type MeshConfig = {
@@ -35,6 +40,9 @@ export type MeshConfig = {
35
40
  * Local frame. When combined with `position` / `rotation` / `scale`, the
36
41
  * effective local matrix is `matrix · T(position) · R(rotation) · S(scale)`.
37
42
  * Disables Three.js's auto matrix update.
43
+ *
44
+ * Mutually exclusive with `matrixWorld` and `geodetic`: all three define the
45
+ * object's placement.
38
46
  */
39
47
  matrix?: Matrix4;
40
48
  /**
@@ -43,8 +51,25 @@ export type MeshConfig = {
43
51
  * matrix is `matrixWorld · T(position) · R(rotation) · S(scale)`, so the
44
52
  * offset fields are interpreted in the frame's local coordinates.
45
53
  * Disables Three.js's auto matrix-world update.
54
+ *
55
+ * Mutually exclusive with `matrix` and `geodetic`: all three define the
56
+ * object's placement.
46
57
  */
47
58
  matrixWorld?: Matrix4;
59
+ /**
60
+ * High-level geographic placement in degrees and metres. Builds a
61
+ * West-Up-North tangent frame at `lng`/`lat`/`height` and applies
62
+ * `heading`/`pitch`/`roll`/`scale`, then occupies the same slot as
63
+ * `matrixWorld` — so `position` / `rotation` / `scale` remain offsets
64
+ * *inside* the resulting frame.
65
+ *
66
+ * `heading` is the compass bearing the asset's front (glTF `+Z`) faces, the
67
+ * same convention as `setCamera`. Because the frame is Y-up with `+Z` north,
68
+ * an unmodified glTF asset needs no up-axis correction.
69
+ *
70
+ * Mutually exclusive with `matrix` and `matrixWorld`.
71
+ */
72
+ geodetic?: GeodeticPlacement;
48
73
  /**
49
74
  * Lighting override for every material of the mesh. `false` outputs albedo
50
75
  * only, while normals and the shadow G-buffer keep being written.
@@ -57,8 +82,14 @@ export type MeshConfig = {
57
82
  export type MeshUpdate = Pick<
58
83
  MeshConfig,
59
84
  "position" | "scale" | "rotation" | "matrix" | "matrixWorld" | "lit"
60
- > &
61
- BaseDescConfigUpdate;
85
+ > & {
86
+ /**
87
+ * Partial geographic placement, merged into the stored placement — so
88
+ * `update({ geodetic: { heading } })` keeps the existing lng/lat/height.
89
+ * `lng` and `lat` are required only when the object has no placement yet.
90
+ */
91
+ geodetic?: Partial<GeodeticPlacement>;
92
+ } & BaseDescConfigUpdate;
62
93
 
63
94
  export type PassKey = keyof Pick<
64
95
  Scenes,
@@ -74,6 +105,55 @@ export type MeshBaseInstance<Instance extends object = object> =
74
105
  ? Instance & { raw: Raw } & BaseInstance
75
106
  : Instance & BaseInstance;
76
107
 
108
+ /**
109
+ * `geodetic`, `matrix` and `matrixWorld` all claim the object's placement, so
110
+ * they share a single slot and at most one may be set. Every consumer resolves
111
+ * that slot with a priority chain (`applyTransform`, `applyRTETransform`), so
112
+ * allowing two would silently drop the lower-priority one — notably `matrix`
113
+ * under `matrixWorld`, which cannot be honoured at all once
114
+ * `matrixWorldAutoUpdate` is off. Takes the values rather than reading `this`,
115
+ * so a caller can check a prospective state before committing it. Exported so
116
+ * subclasses that override `onUpdateConfig` (e.g. RTE Descriptors like
117
+ * `GLTFModelDesc`) reuse this single check instead of hand-rolling their own.
118
+ */
119
+ export function assertNoTransformConflict(
120
+ geodetic: GeodeticPlacement | undefined,
121
+ matrix: Matrix4 | undefined,
122
+ matrixWorld: Matrix4 | undefined,
123
+ ): void {
124
+ if (geodetic) {
125
+ if (matrixWorld) {
126
+ throw new ConflictingTransformError("geodetic", "matrixWorld");
127
+ }
128
+ if (matrix) {
129
+ throw new ConflictingTransformError("geodetic", "matrix");
130
+ }
131
+ }
132
+ if (matrix && matrixWorld) {
133
+ throw new ConflictingTransformError("matrixWorld", "matrix");
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Merges a partial placement onto an existing one and checks it is complete.
139
+ * Pure — returns the merged value instead of assigning it, so the caller can
140
+ * validate before committing. Exported so subclasses that manage their own
141
+ * `onUpdateConfig` (e.g. `GLTFModelDesc`'s RTE-split transform) can merge a
142
+ * `geodetic` update with the same semantics instead of re-deriving them.
143
+ */
144
+ export function mergeGeodetic(
145
+ current: GeodeticPlacement | undefined,
146
+ update: Partial<GeodeticPlacement>,
147
+ ): GeodeticPlacement {
148
+ const merged = { ...current, ...update };
149
+ if (merged.lng === undefined || merged.lat === undefined) {
150
+ throw new Error(
151
+ "`geodetic` update requires `lng` and `lat` when the object has no geodetic placement yet.",
152
+ );
153
+ }
154
+ return merged as GeodeticPlacement;
155
+ }
156
+
77
157
  /**
78
158
  * Abstract base class for creating custom mesh descriptors.
79
159
  *
@@ -192,7 +272,7 @@ export type MeshBaseInstance<Instance extends object = object> =
192
272
  * The base class applies position/scale/rotation and adds it to the scene
193
273
  * determined by {@link getPassKey}.
194
274
  * 3. **{@link onUpdateConfig}** - Called when `handle.update()` is invoked. The base class
195
- * handles `visible`, `matrix`, `matrixWorld`, `position`, `scale`, and `rotation`; override to handle your
275
+ * handles `visible`, `matrix`, `matrixWorld`, `position`, `scale`, `rotation`, and `geodetic`; override to handle your
196
276
  * custom properties. Always call `super.onUpdateConfig(updates)`.
197
277
  * 4. **{@link update}** - Optional per-frame callback for animation.
198
278
  * 5. **{@link onResize}** - Optional callback when the viewport is resized.
@@ -222,6 +302,10 @@ export abstract class MeshDesc<
222
302
  public rotation?: XYZ;
223
303
  public matrix?: Matrix4;
224
304
  public matrixWorld?: Matrix4;
305
+ public geodetic?: GeodeticPlacement;
306
+ /** Terrain height under `geodetic`, when `heightReference` is `"terrain"`. */
307
+ protected terrainHeight?: number;
308
+ private terrainUnsubscribe?: () => void;
225
309
  /** Three-state `lit` override (see {@link MeshConfig.lit}). */
226
310
  public lit?: boolean;
227
311
  private prevPassKey?: PassKey;
@@ -234,9 +318,88 @@ export abstract class MeshDesc<
234
318
  this.rotation = resolvedConfig.rotation;
235
319
  this.matrix = resolvedConfig.matrix;
236
320
  this.matrixWorld = resolvedConfig.matrixWorld;
321
+ this.geodetic = resolvedConfig.geodetic;
322
+ assertNoTransformConflict(this.geodetic, this.matrix, this.matrixWorld);
237
323
  this.lit = resolvedConfig.lit;
238
324
  }
239
325
 
326
+ /**
327
+ * Builds the world frame for a {@link GeodeticPlacement}, adding
328
+ * {@link terrainHeight} when one has been observed. This method does not
329
+ * gate on `heightReference` itself — `terrainHeight` is only ever populated
330
+ * for a terrain-referenced placement, and is cleared otherwise.
331
+ *
332
+ * Fields are passed explicitly rather than spread: `headingPitchRollToFixedFrame`
333
+ * rejects `heightReference`, which this class resolves itself.
334
+ */
335
+ protected resolveGeodeticFrame(g: GeodeticPlacement): Matrix4 {
336
+ return headingPitchRollToFixedFrame({
337
+ lng: g.lng,
338
+ lat: g.lat,
339
+ height: (g.height ?? 0) + (this.terrainHeight ?? 0),
340
+ heading: g.heading,
341
+ pitch: g.pitch,
342
+ roll: g.roll,
343
+ scale: g.scale,
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Subscribes to terrain height under a terrain-referenced `geodetic`
349
+ * placement.
350
+ *
351
+ * `sampleTerrainHeight` seeds synchronously from tiles already resident, so
352
+ * the object does not sit at the wrong altitude while tiles stream in; the
353
+ * observer then re-places it on every terrain update. With no terrain layer
354
+ * added, the sample is `undefined` and the observer does not fire until a
355
+ * terrain layer is added, so the placement degrades to ellipsoid-relative
356
+ * rather than failing — terrain layers may be added after the object.
357
+ *
358
+ * Protected (not private) so a subclass that manages its own world-matrix
359
+ * representation for `geodetic` (e.g. `GLTFModelDesc`'s RTE position split)
360
+ * can call it directly from its own `onCreate`/`onUpdateConfig` instead of
361
+ * routing through this class's `onCreate`.
362
+ */
363
+ protected subscribeTerrainHeight(): void {
364
+ this.unsubscribeTerrainHeight();
365
+
366
+ const g = this.geodetic;
367
+ if (!g || g.heightReference !== "terrain") {
368
+ this.terrainHeight = undefined;
369
+ return;
370
+ }
371
+
372
+ const pos = { lat: g.lat, lng: g.lng };
373
+ this.terrainHeight = this.view.sampleTerrainHeight(pos) ?? 0;
374
+ this.terrainUnsubscribe = this.view.observeTerrainHeightAt(
375
+ pos,
376
+ (height) => {
377
+ if (this.terrainHeight === height) return;
378
+ this.terrainHeight = height;
379
+ if (!this.raw) return;
380
+ this.reapplyGeodeticFrame();
381
+ this.requestUpdate();
382
+ },
383
+ );
384
+ }
385
+
386
+ /**
387
+ * Re-applies the effective transform after `geodetic`'s resolved frame
388
+ * changes outside of `onUpdateConfig` — currently only a terrain-height
389
+ * observation firing asynchronously. Subclasses that override
390
+ * `applyTransform`'s effect with their own representation (e.g.
391
+ * `GLTFModelDesc`'s RTE position/rotation split, which needs the *encoded*
392
+ * position refreshed too) override this instead of relying on the default.
393
+ */
394
+ protected reapplyGeodeticFrame(): void {
395
+ this.applyTransform();
396
+ }
397
+
398
+ private unsubscribeTerrainHeight(): void {
399
+ this.terrainUnsubscribe?.();
400
+ this.terrainUnsubscribe = undefined;
401
+ }
402
+
240
403
  /**
241
404
  * Applies {@link lit} to every material under the mesh. Subclasses that
242
405
  * attach materials asynchronously (glTF) call it again once they exist.
@@ -300,6 +463,7 @@ export abstract class MeshDesc<
300
463
  this._instance = this.createMesh();
301
464
  invariant(this.raw);
302
465
 
466
+ this.subscribeTerrainHeight();
303
467
  this.applyTransform();
304
468
  this.applyLit();
305
469
 
@@ -339,28 +503,39 @@ export abstract class MeshDesc<
339
503
  /**
340
504
  * Applies the configured transform to the underlying `Object3D`.
341
505
  *
342
- * When `matrixWorld` (or `matrix`) is set together with any of
506
+ * When `matrixWorld` / `geodetic` (or `matrix`) is set together with any of
343
507
  * `position` / `rotation` / `scale`, the base/frame is left-multiplied
344
508
  * by the local `T · R · S`: e.g. `effective = matrixWorld · T · R · S`.
345
509
  * This lets callers pass a frame (such as an NUE-to-ECEF matrix) as
346
510
  * `matrixWorld` and express offsets inside that frame via
347
511
  * `position` / `rotation` / `scale`.
512
+ *
513
+ * The branches below are a priority chain, which is only safe because
514
+ * `assertNoTransformConflict` has already rejected any state with more than
515
+ * one of `geodetic` / `matrixWorld` / `matrix` set — so an earlier branch
516
+ * can never shadow a configured field.
348
517
  */
349
518
  private applyTransform(): void {
350
519
  invariant(this.raw);
351
520
  const hasLocal =
352
521
  this.position != null || this.rotation != null || this.scale != null;
353
522
 
354
- if (this.matrixWorld) {
523
+ // `geodetic` takes the `matrixWorld` slot; the composition rule
524
+ // `world · T · R · S` is identical either way.
525
+ const worldMatrix = this.geodetic
526
+ ? this.resolveGeodeticFrame(this.geodetic)
527
+ : this.matrixWorld;
528
+
529
+ if (worldMatrix) {
355
530
  this.raw.matrixAutoUpdate = false;
356
531
  this.raw.matrixWorldAutoUpdate = false;
357
532
  if (hasLocal) {
358
533
  this.raw.matrixWorld.multiplyMatrices(
359
- this.matrixWorld,
534
+ worldMatrix,
360
535
  this.composeLocalTransform(),
361
536
  );
362
537
  } else {
363
- this.raw.matrixWorld.copy(this.matrixWorld);
538
+ this.raw.matrixWorld.copy(worldMatrix);
364
539
  }
365
540
  this.raw.updateMatrixWorld();
366
541
  return;
@@ -404,16 +579,50 @@ export abstract class MeshDesc<
404
579
  }
405
580
 
406
581
  onUpdateConfig(updates: UpdateConfig): void {
582
+ // Validate the PROSPECTIVE transform state before mutating anything: a
583
+ // throw has to leave the Descriptor exactly as it was. Assigning first
584
+ // and checking after would brick the instance — the guard would then fire
585
+ // on every later call, including unrelated ones like `{ visible }`, and
586
+ // the `!== undefined` update convention can never reset a field back to
587
+ // `undefined`, so there would be no way to recover.
588
+ const nextGeodetic =
589
+ updates.geodetic !== undefined
590
+ ? mergeGeodetic(this.geodetic, updates.geodetic)
591
+ : this.geodetic;
592
+ const nextMatrix =
593
+ updates.matrix !== undefined ? updates.matrix : this.matrix;
594
+ const nextMatrixWorld =
595
+ updates.matrixWorld !== undefined
596
+ ? updates.matrixWorld
597
+ : this.matrixWorld;
598
+ assertNoTransformConflict(nextGeodetic, nextMatrix, nextMatrixWorld);
599
+
407
600
  super.onUpdateConfig(updates);
408
601
  invariant(this.raw);
409
602
 
410
603
  const spatialChanged =
411
604
  updates.matrix !== undefined ||
412
605
  updates.matrixWorld !== undefined ||
606
+ updates.geodetic !== undefined ||
413
607
  updates.position !== undefined ||
414
608
  updates.scale !== undefined ||
415
609
  updates.rotation !== undefined;
416
610
 
611
+ if (updates.geodetic !== undefined) {
612
+ const prevGeodetic = this.geodetic;
613
+ this.geodetic = nextGeodetic;
614
+
615
+ // Only these three change what we sample; heading/pitch/roll/scale
616
+ // leave the sampled position and the reference untouched.
617
+ const resubscribe =
618
+ nextGeodetic?.lng !== prevGeodetic?.lng ||
619
+ nextGeodetic?.lat !== prevGeodetic?.lat ||
620
+ nextGeodetic?.heightReference !== prevGeodetic?.heightReference;
621
+ if (resubscribe) {
622
+ this.subscribeTerrainHeight();
623
+ }
624
+ }
625
+
417
626
  if (updates.matrix !== undefined) this.matrix = updates.matrix;
418
627
  if (updates.matrixWorld !== undefined)
419
628
  this.matrixWorld = updates.matrixWorld;
@@ -434,7 +643,7 @@ export abstract class MeshDesc<
434
643
  // so subclasses that strip a field from `updates` (e.g. GLTFModelLayer
435
644
  // keeping `raw.position` at 0 for its RTE shader) can still opt out
436
645
  // of having the base class copy it onto `raw`.
437
- if (this.matrixWorld || this.matrix) {
646
+ if (this.geodetic || this.matrixWorld || this.matrix) {
438
647
  this.applyTransform();
439
648
  } else {
440
649
  if (updates.position !== undefined) {
@@ -468,6 +677,8 @@ export abstract class MeshDesc<
468
677
  }
469
678
 
470
679
  onDestroy(): void {
680
+ this.unsubscribeTerrainHeight();
681
+
471
682
  if (this.raw && this.raw.parent) {
472
683
  this.raw.parent.remove(this.raw);
473
684
  }
@@ -14,3 +14,17 @@ export class UnknownTypeError extends Error {
14
14
  );
15
15
  }
16
16
  }
17
+
18
+ /**
19
+ * Raised when a config or update names more than one of `geodetic` / `matrix` /
20
+ * `matrixWorld`. They share a single placement slot, so the message is
21
+ * deliberately symmetric — neither argument order implies a winner.
22
+ */
23
+ export class ConflictingTransformError extends Error {
24
+ constructor(field: string, conflictsWith: string) {
25
+ super(
26
+ `\`${field}\` cannot be combined with \`${conflictsWith}\`: both define the object's placement. ` +
27
+ `Set only one of them — use \`geodetic\` for geographic placement, or \`matrix\` / \`matrixWorld\` to supply a frame yourself.`,
28
+ );
29
+ }
30
+ }
package/src/core/index.ts CHANGED
@@ -8,6 +8,8 @@ export {
8
8
  } from "./BaseDesc";
9
9
  export {
10
10
  MeshDesc,
11
+ assertNoTransformConflict,
12
+ mergeGeodetic,
11
13
  type MeshUpdate,
12
14
  type MeshConfig,
13
15
  type PassKey,
@@ -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
  }
@@ -189,6 +189,9 @@ export type MeshHandler = {
189
189
  setTileMeshPrepared: (handle: bigint) => void;
190
190
  };
191
191
 
192
+ /** Settles the pending `flyTo` promise registered under `id`. */
193
+ export type FlightHandler = (id: number, completed: boolean) => void;
194
+
192
195
  export type LayerHandler = {
193
196
  getLayerIndex: (layerId: string) => number | undefined;
194
197
  };
@@ -216,6 +219,7 @@ type EventContextArgs = {
216
219
  viewEvents: EventHandler<ViewEvents>;
217
220
  layersManager: LayersManager;
218
221
  viewContext: ViewContext;
222
+ flightHandler: FlightHandler;
219
223
  layerHandler?: LayerHandler;
220
224
  fontManager?: FontManager;
221
225
  textureFragmentIndex?: Map<string, Set<TextureSlot>>;
@@ -251,6 +255,7 @@ export class EventContext {
251
255
  readonly viewEvents: EventHandler<ViewEvents>;
252
256
  readonly layersManager: LayersManager;
253
257
  readonly viewContext: ViewContext;
258
+ readonly flightHandler: FlightHandler;
254
259
  readonly layerHandler?: LayerHandler;
255
260
  readonly fontManager?: FontManager;
256
261
  readonly textureFragmentIndex?: Map<string, Set<TextureSlot>>;
@@ -286,6 +291,7 @@ export class EventContext {
286
291
  this.viewEvents = args.viewEvents;
287
292
  this.layersManager = args.layersManager;
288
293
  this.viewContext = args.viewContext;
294
+ this.flightHandler = args.flightHandler;
289
295
  this.layerHandler = args.layerHandler;
290
296
  this.fontManager = args.fontManager;
291
297
  this.textureFragmentIndex = args.textureFragmentIndex;
@@ -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,
@@ -49,6 +49,7 @@ export type {
49
49
  FeatureHandler,
50
50
  MeshHandler,
51
51
  LayerHandler,
52
+ FlightHandler,
52
53
  } from "./context";
53
54
  export { EventContext } from "./context";
54
55
  export { HillshadeContext } from "./HillshadeContext";
@@ -73,6 +74,10 @@ export function processEvent(ctx: EventContext, event: Events | undefined) {
73
74
  processCameraFrustumUpdated(ctx, ev),
74
75
  );
75
76
 
77
+ eventManager.forEachStack("camera_flight_ended", (ev) =>
78
+ ctx.flightHandler(ev.id, ev.completed),
79
+ );
80
+
76
81
  eventManager.forEachStack("object_transform_updated", (ev) =>
77
82
  processObjectTransformUpdated(ctx, ev),
78
83
  );
@@ -318,6 +323,8 @@ function processCameraFrustumUpdated(
318
323
 
319
324
  ctx.camera.raw.near = frustum.near;
320
325
  ctx.camera.raw.far = frustum.far;
326
+ // The engine's `fov` is the vertical fov, matching what
327
+ // `PerspectiveCamera.fov` expects.
321
328
  ctx.camera.raw.fov = radianToDegree(frustum.fov);
322
329
  ctx.camera.raw.updateProjectionMatrix();
323
330
  ctx.camera.emit("frustumChanged");