@navaramap/three 0.0.1 → 0.0.3

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 (72) hide show
  1. package/README.md +70 -0
  2. package/dist/assets/fontWorker-wz61edjR.js +2 -0
  3. package/dist/assets/fontWorker-wz61edjR.js.map +1 -0
  4. package/dist/assets/index-ywHSGImj.js +25 -0
  5. package/dist/assets/index-ywHSGImj.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Bnck8OGf.wasm → navara_wasm_font_worker_bg-Du9nb2Pn.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-DG9ORAmj.wasm → navara_wasm_worker_bg-Xadu67fw.wasm} +0 -0
  8. package/dist/draco_decoder.wasm +0 -0
  9. package/dist/draco_decoder2.wasm +0 -0
  10. package/dist/index.d.ts +578 -18
  11. package/dist/index.js +5298 -4843
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +266 -234
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +34 -33
  16. package/src/atmosphere.ts +43 -1
  17. package/src/core/EffectDesc.ts +2 -4
  18. package/src/core/MeshDesc.ts +1 -2
  19. package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
  20. package/src/declutter/DeclutterManager.test.ts +235 -0
  21. package/src/declutter/DeclutterManager.ts +256 -0
  22. package/src/declutter/index.ts +7 -0
  23. package/src/declutter/kernel.ts +46 -0
  24. package/src/declutter/types.ts +61 -0
  25. package/src/declutter/wasmKernel.ts +13 -0
  26. package/src/device.ts +18 -4
  27. package/src/evaluations/FeatureEvaluator.ts +21 -0
  28. package/src/event/context.ts +12 -0
  29. package/src/event/feature.ts +43 -13
  30. package/src/event/features/modelGpuBytes.ts +1 -3
  31. package/src/event/features/polygon.ts +2 -1
  32. package/src/event/hillshade.test.ts +7 -13
  33. package/src/event/index.ts +2 -3
  34. package/src/event/tileHandler.ts +54 -0
  35. package/src/index.ts +137 -35
  36. package/src/input.ts +48 -15
  37. package/src/material/enhancer/ShaderReplacer.ts +1 -2
  38. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +1 -0
  39. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +2 -0
  40. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/types.ts +2 -0
  41. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +2 -0
  42. package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +5 -1
  43. package/src/material/enhancer/polygon/polygonBaseEnhancer/types.ts +2 -0
  44. package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +2 -0
  45. package/src/material/enhancer/polyline/polylineBaseEnhancer/types.ts +2 -0
  46. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +8 -0
  47. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +13 -0
  48. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
  49. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +13 -3
  50. package/src/material/overrideMaterialsForMRT.ts +7 -2
  51. package/src/mesh/batchTexture.ts +2 -4
  52. package/src/mesh/batchedSdfText.ts +294 -70
  53. package/src/mesh/instanced.ts +1 -1
  54. package/src/mesh/polygon.ts +11 -4
  55. package/src/mesh/polyline.ts +10 -2
  56. package/src/mesh/sdfText.ts +176 -6
  57. package/src/mesh/sprite/instancedSprite.ts +245 -3
  58. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  59. package/src/passes/CustomRenderPass.ts +21 -5
  60. package/src/passes/RenderTargetCopyPass.ts +24 -2
  61. package/src/solar.test.ts +33 -0
  62. package/src/solar.ts +31 -0
  63. package/src/test-utils/engine.ts +53 -0
  64. package/src/tileTexture/TileTextureCompositor.test.ts +2 -0
  65. package/src/tileTexture/TileTextureCompositor.ts +7 -1
  66. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  67. package/src/utils/fixedGpuFootprint.ts +162 -0
  68. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  69. package/dist/assets/fontWorker-CCWL5pbx.js +0 -2
  70. package/dist/assets/fontWorker-CCWL5pbx.js.map +0 -1
  71. package/dist/assets/index-BdtHOOT9.js +0 -12
  72. package/dist/assets/index-BdtHOOT9.js.map +0 -1
@@ -0,0 +1,7 @@
1
+ export {
2
+ DeclutterManager,
3
+ type DeclutterUpdateResult,
4
+ } from "./DeclutterManager";
5
+ export { CANDIDATE_STRIDE, type DeclutterKernel } from "./kernel";
6
+ export { wasmDeclutterKernel } from "./wasmKernel";
7
+ export type { DeclutterCandidate, DeclutterParticipant } from "./types";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The pure numeric placement kernel used by {@link DeclutterManager}.
3
+ *
4
+ * Given a flat list of candidates and the camera, it projects each anchor to a
5
+ * screen-pixel box, sorts by priority (with placement hysteresis), and greedily
6
+ * claims space in a uniform screen-space grid — returning one `hidden` flag
7
+ * (`0` = shown, `1` = hidden) per candidate, in input order.
8
+ *
9
+ * The production implementation is the Rust `declutterPlace` in
10
+ * `navara_wasm_api` (see {@link wasmDeclutterKernel}); the projection, collision
11
+ * grid, and greedy placement all live there. Injecting it keeps the manager free
12
+ * of an import-time WASM dependency, so its orchestration is unit-tested with a
13
+ * stub. Placement correctness itself is covered by the crate's Rust tests.
14
+ */
15
+ export type DeclutterKernel = {
16
+ /**
17
+ * @param candidates Packed `f64` data, {@link CANDIDATE_STRIDE} values per
18
+ * candidate; pass a subarray of exactly `n * CANDIDATE_STRIDE`.
19
+ * @param view Column-major view matrix (inverse of `camera.matrixWorld`), 16.
20
+ * @param proj Column-major projection matrix, 16.
21
+ * @returns One `hidden` flag (`0`/`1`) per candidate, in input order.
22
+ */
23
+ place(
24
+ candidates: Float64Array,
25
+ view: Float64Array,
26
+ proj: Float64Array,
27
+ cameraX: number,
28
+ cameraY: number,
29
+ cameraZ: number,
30
+ near: number,
31
+ widthPx: number,
32
+ heightPx: number,
33
+ fovRad: number,
34
+ paddingPx: number,
35
+ hysteresisPx: number,
36
+ ): Uint8Array;
37
+ };
38
+
39
+ /**
40
+ * Number of `f64` values per candidate in the packed kernel input. Must stay in
41
+ * sync with `CANDIDATE_STRIDE` in `crates/navara_wasm_api/src/declutter.rs`.
42
+ *
43
+ * Layout: `[anchorX, anchorY, anchorZ, addHeight, minX, maxX, minY, maxY,
44
+ * sizeInMeters, priority, isShown]`.
45
+ */
46
+ export const CANDIDATE_STRIDE = 11;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One label/sprite competing for screen space in a declutter pass.
3
+ *
4
+ * The local box is expressed around the projected anchor in the shader's
5
+ * billboard-local units — CSS pixels when `sizeInMeters` is false, meters
6
+ * otherwise — with +X right and +Y up (view-space convention). The manager
7
+ * converts it to a screen-pixel AABB using the same `nvr_pxToWorld` math the
8
+ * vertex shaders use, so the collision box matches what is actually drawn.
9
+ */
10
+ export type DeclutterCandidate = {
11
+ /** World-space anchor in ECEF meters (f64), before the height offset. */
12
+ anchorX: number;
13
+ anchorY: number;
14
+ anchorZ: number;
15
+ /** Extra offset in meters along the spherical surface normal — CPU mirror
16
+ * of `mvr_getMvHeightOffset` (sprite_height_pars_vertex.glsl). */
17
+ addHeight: number;
18
+ /** Local box around the anchor (px or meters, see `sizeInMeters`). */
19
+ minX: number;
20
+ maxX: number;
21
+ minY: number;
22
+ maxY: number;
23
+ /** Unit of the local box, mirroring the material's `sizeInMeters`. */
24
+ sizeInMeters: boolean;
25
+ /** Placement priority: higher wins an overlap. */
26
+ priority: number;
27
+ /** Whether the label is currently shown (its fade target is "visible").
28
+ * Feeds placement hysteresis: shown labels win equal-priority ties and
29
+ * tolerate marginal overlaps, so slow camera drift doesn't make near-tied
30
+ * labels flicker in and out. */
31
+ isShown: boolean;
32
+ /** Participant that owns this candidate; receives the placement result. */
33
+ owner: DeclutterParticipant;
34
+ /** Participant-defined id (mesh index for text batches, instance index for
35
+ * sprites) passed back to `applyDeclutter`. */
36
+ handle: number;
37
+ };
38
+
39
+ /** Duration of the show/hide fade applied to placement results (ms). */
40
+ export const DECLUTTER_FADE_MS = 300;
41
+
42
+ /** A mesh that contributes label candidates to the shared declutter pass. */
43
+ export type DeclutterParticipant = {
44
+ /**
45
+ * Append the currently-visible, declutter-enabled candidates to `out`.
46
+ * Called once per placement pass; must not retain `out`.
47
+ */
48
+ collectDeclutterCandidates: (out: DeclutterCandidate[]) => void;
49
+ /**
50
+ * Apply a placement result as the label's fade *target*. Must be cheap and
51
+ * idempotent — the pass calls it for every candidate on every run, most
52
+ * often with an unchanged value.
53
+ */
54
+ applyDeclutter: (handle: number, hidden: boolean) => void;
55
+ /**
56
+ * Advance this participant's hide factors toward their targets by
57
+ * `deltaMs / DECLUTTER_FADE_MS`. Called by the manager every update while
58
+ * anything is fading; returns true while any label is still mid-fade.
59
+ */
60
+ stepDeclutterFade: (deltaMs: number) => boolean;
61
+ };
@@ -0,0 +1,13 @@
1
+ import { declutterPlace } from "@navaramap/engine-api";
2
+
3
+ import type { DeclutterKernel } from "./kernel";
4
+
5
+ /**
6
+ * @internal
7
+ * {@link DeclutterKernel} backed by the Rust `declutterPlace` in
8
+ * `navara_wasm_api`. The WASM module must be initialized (via `initNavaraApi()`)
9
+ * before the first placement pass runs; `ThreeView` awaits that during setup.
10
+ */
11
+ export const wasmDeclutterKernel: DeclutterKernel = {
12
+ place: declutterPlace,
13
+ };
package/src/device.ts CHANGED
@@ -50,6 +50,16 @@ export function isMobileDevice(): boolean {
50
50
  /** Maximum pixel ratio for mobile devices to balance quality vs performance */
51
51
  const MOBILE_MAX_PIXEL_RATIO = 1.0;
52
52
 
53
+ /**
54
+ * Default pixel-ratio cap applied on every mobile device (without opting into
55
+ * `mobileOptimization`). The fixed screen-sized render-target stack (composer
56
+ * buffers, gbuffer MRT, depth textures — ~100 B/px, counted by the ledger as
57
+ * `fixed_gpu_bytes`) grows quadratically with the drawing-buffer size: at
58
+ * DPR 3 it alone can exceed a mobile tile-cache budget and permanently close
59
+ * the load gate. Above DPR 2 the sharpness gain is marginal on phone screens.
60
+ */
61
+ const MOBILE_DEFAULT_MAX_PIXEL_RATIO = 2.0;
62
+
53
63
  export type DevicePixelRatioOptions = {
54
64
  /** User-specified pixel ratio override (takes precedence over all other settings) */
55
65
  override?: number;
@@ -389,7 +399,9 @@ export function getDefaultDynamicSse(): DynamicSseSettings {
389
399
 
390
400
  /**
391
401
  * Gets an appropriate pixel ratio for the current device.
392
- * Caps the ratio on mobile devices only when mobileOptimization is enabled.
402
+ * Mobile devices are capped at 2 by default (see
403
+ * {@link MOBILE_DEFAULT_MAX_PIXEL_RATIO}), and at 1 when mobileOptimization
404
+ * is enabled; an explicit `override` (the `pixelRatio` option) bypasses both.
393
405
  *
394
406
  * @param options - Configuration options for pixel ratio
395
407
  * @returns Pixel ratio appropriate for the device
@@ -405,9 +417,11 @@ export function getDevicePixelRatio(options?: DevicePixelRatioOptions): number {
405
417
 
406
418
  const deviceRatio = window.devicePixelRatio ?? 1;
407
419
 
408
- // Only cap pixel ratio on mobile when mobileOptimization is enabled
409
- if (options?.mobileOptimization && isMobileDevice()) {
410
- return Math.min(deviceRatio, MOBILE_MAX_PIXEL_RATIO);
420
+ if (isMobileDevice()) {
421
+ const cap = options?.mobileOptimization
422
+ ? MOBILE_MAX_PIXEL_RATIO
423
+ : MOBILE_DEFAULT_MAX_PIXEL_RATIO;
424
+ return Math.min(deviceRatio, cap);
411
425
  }
412
426
 
413
427
  return deviceRatio;
@@ -55,6 +55,9 @@ export type EvaluatableMaterialProperty = {
55
55
  size: AvailableMaterialProperty["size"];
56
56
  /** Opacity expression from layer configuration (for polygons/points/text where supported). */
57
57
  opacity: AvailableMaterialProperty["opacity"];
58
+ /** Per-feature declutter placement priority (for points/billboards/text);
59
+ * higher wins an overlap. Overrides the layer's `declutterPriority`. */
60
+ declutterPriority: AvailableMaterialProperty["declutterPriority"];
58
61
  /** Image URL for billboards; packed into a shared per-mesh texture atlas. */
59
62
  image: string;
60
63
  };
@@ -70,6 +73,7 @@ type EvaluatedMaterialProperty = {
70
73
  width: number;
71
74
  size: number;
72
75
  opacity: number;
76
+ declutterPriority: number;
73
77
  image: string | null;
74
78
  };
75
79
 
@@ -308,6 +312,8 @@ export class FeatureEvaluator {
308
312
  * - `width` - Line width in pixels (for polylines)
309
313
  * - `size` - Feature size in pixels (for points/text)
310
314
  * - `opacity` - Feature opacity 0-1
315
+ * - `declutterPriority` - Placement priority for decluttering; higher wins
316
+ * an overlap (for points/billboards/text with `declutter` enabled)
311
317
  * - `image` - Image URL (for billboards); loaded once per distinct URL and
312
318
  * packed into the layer's texture atlas. Return `null` to clear a
313
319
  * previous per-feature image and revert to the material's default `url`;
@@ -387,6 +393,12 @@ export class FeatureEvaluator {
387
393
  if (evaluated.opacity != null) {
388
394
  obj.setFeatureOpacityByBatchId(batchId, evaluated.opacity);
389
395
  }
396
+ if (evaluated.declutterPriority != null) {
397
+ obj.setFeatureDeclutterPriorityByBatchId(
398
+ batchId,
399
+ evaluated.declutterPriority,
400
+ );
401
+ }
390
402
  if ("image" in evaluated) {
391
403
  // Async by nature (the image may need fetching); the atlas dedupes
392
404
  // loads by URL so evaluating many features costs one fetch per image.
@@ -415,6 +427,15 @@ export class FeatureEvaluator {
415
427
  if (evaluated.opacity != null && obj instanceof BatchedSdfTextMesh) {
416
428
  obj.setFeatureOpacityByBatchIndex(batchIndex, evaluated.opacity);
417
429
  }
430
+ if (
431
+ evaluated.declutterPriority != null &&
432
+ obj instanceof BatchedSdfTextMesh
433
+ ) {
434
+ obj.setFeatureDeclutterPriorityByBatchIndex(
435
+ batchIndex,
436
+ evaluated.declutterPriority,
437
+ );
438
+ }
418
439
  return;
419
440
  }
420
441
 
@@ -22,6 +22,7 @@ import type { Texture } from "three";
22
22
  import type { ViewEvents } from "..";
23
23
  import type { ThreeViewCamera } from "../camera";
24
24
  import type { ViewContext } from "../core";
25
+ import type { DeclutterManager } from "../declutter";
25
26
  import type { LayersManager } from "../layersManager";
26
27
  import type { TileMesh } from "../mesh/tile";
27
28
  import type { Scenes, TexturizedSceneByTileCoordinates } from "../scene";
@@ -120,6 +121,12 @@ export type TileHandler = {
120
121
  textureZoom: number,
121
122
  textureWidth: number,
122
123
  ) => number;
124
+ /** Rust's clamped WebMercator northing (`navara_geometry::mercator_y`).
125
+ * The composite paste derives its per-slot reprojection constants through
126
+ * this so paste and bake (`uv_rect_from_extents_mercator`) share one
127
+ * implementation — a drifting reimplementation misaligns the pasted
128
+ * latitude band (and NaNs on the unclamped polar band). */
129
+ mercatorY: (lat: number) => number;
123
130
  };
124
131
 
125
132
  /**
@@ -214,6 +221,7 @@ type EventContextArgs = {
214
221
  textureFragmentIndex?: Map<string, Set<TextureSlot>>;
215
222
  tileMeshToFragmentIds?: Map<TileMesh, Set<string>>;
216
223
  hillshadeContext?: HillshadeContext;
224
+ declutter?: DeclutterManager;
217
225
  };
218
226
 
219
227
  /**
@@ -248,6 +256,9 @@ export class EventContext {
248
256
  readonly textureFragmentIndex?: Map<string, Set<TextureSlot>>;
249
257
  readonly tileMeshToFragmentIds?: Map<TileMesh, Set<string>>;
250
258
  readonly hillshadeContext?: HillshadeContext;
259
+ /** Shared screen-space label declutterer; text/sprite meshes register as
260
+ * participants on creation. Optional so headless contexts can skip it. */
261
+ readonly declutter?: DeclutterManager;
251
262
 
252
263
  updatedAt = 0;
253
264
 
@@ -280,5 +291,6 @@ export class EventContext {
280
291
  this.textureFragmentIndex = args.textureFragmentIndex;
281
292
  this.tileMeshToFragmentIds = args.tileMeshToFragmentIds;
282
293
  this.hillshadeContext = args.hillshadeContext;
294
+ this.declutter = args.declutter;
283
295
  }
284
296
  }
@@ -118,6 +118,20 @@ export async function processRenderableFeatureAdded(
118
118
 
119
119
  const featureLayerId = ev.layer_id;
120
120
 
121
+ // Captured once: the render-completion report is deferred until the mesh is
122
+ // in its render/bake scene (see below), by which point `ev` may be gone.
123
+ const bits = ev.bits;
124
+ const renderedType =
125
+ point || billboard || text
126
+ ? "point"
127
+ : model
128
+ ? "model"
129
+ : polyline
130
+ ? "polyline"
131
+ : polygon
132
+ ? "polygon"
133
+ : undefined;
134
+
121
135
  const useParallel = checkFeatureParallel(feature);
122
136
 
123
137
  if (useParallel) {
@@ -127,15 +141,6 @@ export async function processRenderableFeatureAdded(
127
141
 
128
142
  const obj = await renderFeature(ctx, feature, tileHandle, featureLayerId)
129
143
  ?.then((r) => {
130
- const type = (() => {
131
- if (point || billboard || text) return "point";
132
- else if (model) return "model";
133
- else if (polyline) return "polyline";
134
- else if (polygon) return "polygon";
135
- })();
136
- if (type) {
137
- featureHandler.markFeatureIsRendered(type, ev.bits);
138
- }
139
144
  // The glTF/Draco decode happened on the JS side, so report the actual
140
145
  // decoded GPU size back to the ledger (its compressed-payload estimate
141
146
  // undercounts Draco content). Draco decode inflates geometry markedly.
@@ -164,7 +169,12 @@ export async function processRenderableFeatureAdded(
164
169
  }
165
170
  });
166
171
 
167
- if (!obj) return;
172
+ if (!obj) {
173
+ // renderFeature produced no mesh (e.g. empty geometry); still report
174
+ // rendered so the tile's LOD/activation can advance, as before.
175
+ if (renderedType) featureHandler.markFeatureIsRendered(renderedType, bits);
176
+ return;
177
+ }
168
178
 
169
179
  // Sprite should be handled by mesh itself.
170
180
  const transform = (polyline ?? polygon ?? model)?.transform;
@@ -185,7 +195,10 @@ export async function processRenderableFeatureAdded(
185
195
 
186
196
  meshes.set(id, obj);
187
197
 
188
- if (isDraped && tileHandle) {
198
+ // `!= null` (not truthiness): the root vector tile's handle is 0, which is
199
+ // falsy — a truthy check silently dropped root-tile draped meshes from the
200
+ // texturized scene cache, leaving small datasets that render at z0 blank.
201
+ if (isDraped && tileHandle != null) {
189
202
  // Insert the bakeable draped mesh into the per-tile cache now (it is fully built at
190
203
  // this point). Readiness/ancestor-fallback is decided entirely on the Rust side from
191
204
  // the ECS lifecycle, so no `scene_ready` is reported. Insert even when invisible —
@@ -213,6 +226,14 @@ export async function processRenderableFeatureAdded(
213
226
  });
214
227
  }
215
228
 
229
+ // Report render-completion to Rust only now that the mesh is in its render
230
+ // (MRT) or bake (texturized) scene. For draped features this must follow the
231
+ // `texturizedSceneByTileCoordinates.add` above: the Rust drape resolve flips a
232
+ // terrain tile's vector source to this tile once its features report rendered,
233
+ // so reporting before the offscreen scene existed left the terrain tile's
234
+ // vector drape blank until a camera-move re-traverse.
235
+ if (renderedType) featureHandler.markFeatureIsRendered(renderedType, bits);
236
+
216
237
  if (obj instanceof PolygonMesh && polygon && polygon.outline_geometry) {
217
238
  const outline = await renderPolygonOutline(ctx, polygon);
218
239
  outline.renderOrder = FEATURE_RENDER_ORDER;
@@ -320,7 +341,7 @@ export async function processRenderableFeatureChanged(
320
341
  if (
321
342
  ((obj instanceof PolygonMesh && obj.clampToGround) ||
322
343
  (obj instanceof PolylineMesh && obj.draped)) &&
323
- tileHandle
344
+ tileHandle != null // the root tile's handle is 0 (falsy)
324
345
  ) {
325
346
  texturizedSceneByTileCoordinates.markDirty(tileHandle, layerId);
326
347
  }
@@ -345,7 +366,16 @@ export async function processRenderableFeatureChanged(
345
366
 
346
367
  obj.updateMatrix();
347
368
 
348
- if (obj.visible) {
369
+ // Re-emit `featureUpdated` (which re-runs the layer's registered evaluator
370
+ // over this whole feature set) only when the set is actually displayed at the
371
+ // current LOD. `obj.visible` is not that signal for draped meshes: they stay
372
+ // visible while built so they remain stable bakeable drape sources (see
373
+ // PolygonMesh._update), so gating on it alone re-evaluated every built draped
374
+ // tile on every activation flip during zoom. The evaluator's per-feature
375
+ // values persist in the batch data texture, and user-driven restyles reach
376
+ // inactive sets through `Layer.forceUpdate`, so skipping inactive sets here
377
+ // is safe.
378
+ if (obj.visible && active) {
349
379
  handleFeatureUpdatedEventByLayerId(
350
380
  viewEvents,
351
381
  layersManager,
@@ -60,9 +60,7 @@ export function sumModelGpuBytes(obj: Object3D): number {
60
60
  }
61
61
  }
62
62
  const material = (node as Mesh | Points).material as
63
- | Material
64
- | Material[]
65
- | undefined;
63
+ Material | Material[] | undefined;
66
64
  const materials = Array.isArray(material)
67
65
  ? material
68
66
  : material
@@ -19,5 +19,6 @@ export function processPolygonChanged(
19
19
  active: boolean,
20
20
  tileHandle: TileHandle | undefined,
21
21
  ) {
22
- obj._update(m.material, active, !!tileHandle);
22
+ // `!= null`, not truthiness: the root vector tile's handle is 0.
23
+ obj._update(m.material, active, tileHandle != null);
23
24
  }
@@ -494,9 +494,8 @@ describe("hillshade normal map generation", () => {
494
494
  expect(loadedTexs.has(entityId)).toBe(true);
495
495
  const normalMap = loadedTexs.get(entityId);
496
496
  expect(normalMap).toBeInstanceOf(Texture);
497
- if (normalMap) {
498
- expect((normalMap.image as { width: number }).width).toBe(4); // Content size (no padding in normal map)
499
- }
497
+ // Content size (no padding in normal map)
498
+ expect((normalMap?.image as { width: number } | undefined)?.width).toBe(4);
500
499
 
501
500
  // Verify temp DEM entry was stored for edge updates
502
501
  const tempDem = hillshadeContext.getTempDem(entityId);
@@ -584,16 +583,11 @@ describe("hillshade normal map generation", () => {
584
583
  target_entity_gen: 123,
585
584
  } as HillshadeBackfilledEvent);
586
585
 
587
- if (direction < 3) {
588
- // Not all edges received yet
589
- expect(hillshadeContext.getTempDem(entityId)).toBeDefined();
590
- expect(hillshadeContext.getTempDem(entityId)?.receivedEdges.size).toBe(
591
- direction + 1,
592
- );
593
- } else {
594
- // All 4 edges received, temp DEM should be cleaned up
595
- expect(hillshadeContext.getTempDem(entityId)).toBeUndefined();
596
- }
586
+ // The temp DEM tracks received edges until all 4 arrive, then it is
587
+ // cleaned up (so `getTempDem` returns undefined on the last iteration).
588
+ expect(hillshadeContext.getTempDem(entityId)?.receivedEdges.size).toBe(
589
+ direction < 3 ? direction + 1 : undefined,
590
+ );
597
591
  }
598
592
 
599
593
  // Verify texture still exists after cleanup
@@ -23,7 +23,7 @@ import { BatchedSdfTextMesh, Layer } from "..";
23
23
  import { disposeTexture } from "../loaders";
24
24
  import { getImageDataFromBlob } from "../tasks/getImageDataFromBlob";
25
25
 
26
- import { EventContext } from "./context";
26
+ import { type EventContext } from "./context";
27
27
  import {
28
28
  checkFeatureParallel,
29
29
  processRenderableFeatureAdded,
@@ -463,12 +463,11 @@ async function processRequestedData(ctx: EventContext, req: DataRequestEvent) {
463
463
  return;
464
464
  }
465
465
 
466
- let u8a: Uint8Array | null = new Uint8Array(data);
466
+ const u8a = new Uint8Array(data);
467
467
  buf.setU8(req.handle, req.bits, u8a);
468
468
 
469
469
  // Prevent memory leak
470
470
  u8a.set([]);
471
- u8a = null;
472
471
 
473
472
  data.set([]);
474
473
  })
@@ -0,0 +1,54 @@
1
+ import { mercatorY, type Core } from "@navaramap/engine";
2
+
3
+ import type { TileHandler } from "./context";
4
+
5
+ export type TileHandlerDeps = {
6
+ /** Accessor (not a snapshot) — the Core is created asynchronously after
7
+ * WASM init and can be dropped on dispose. */
8
+ getCore: () => Core | undefined;
9
+ /** Per-frame cached revision snapshots (refreshed once per `_render`), so
10
+ * per-tile `onBeforeRender` gating costs no WASM-boundary call. */
11
+ getVectorRevision: () => number;
12
+ getRasterRevision: () => number;
13
+ };
14
+
15
+ /**
16
+ * Build the {@link TileHandler} context over a WASM `Core`. Extracted from
17
+ * `ThreeView` so tests can assemble the same handler over a shared test engine
18
+ * (`test-utils/engine.ts`) instead of stubbing the Rust-backed functions.
19
+ */
20
+ export function createTileHandler(deps: TileHandlerDeps): TileHandler {
21
+ const { getCore } = deps;
22
+ return {
23
+ getMartini: (id) => {
24
+ return getCore()?.getMartini(id);
25
+ },
26
+ getTile: (handle) => {
27
+ return getCore()?.getTile(handle);
28
+ },
29
+ getParentTile: (handle) => {
30
+ return getCore()?.getParentTile(handle);
31
+ },
32
+ getTileElevationDecoder: (handle) => {
33
+ return getCore()?.getTileElevationDecoder(handle);
34
+ },
35
+ getVectorTileStates: (handle) => {
36
+ return getCore()?.getVectorTileStates(handle);
37
+ },
38
+ vectorRevision: deps.getVectorRevision,
39
+ getRasterTileStates: (handle) => {
40
+ return getCore()?.getRasterTileStates(handle);
41
+ },
42
+ rasterRevision: deps.getRasterRevision,
43
+ reportDrapeGpuBytes: (handle, bytes) => {
44
+ getCore()?.reportTerrainDrapeGpuBytes(handle, bytes);
45
+ },
46
+ calcMetersPerTexel: (tileHandle, textureZoom, textureWidth) => {
47
+ return (
48
+ getCore()?.calcMetersPerTexel(tileHandle, textureZoom, textureWidth) ??
49
+ 1.0
50
+ );
51
+ },
52
+ mercatorY,
53
+ };
54
+ }