@navaramap/three 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/assets/fontWorker-CCWL5pbx.js +2 -0
  2. package/dist/assets/fontWorker-CCWL5pbx.js.map +1 -0
  3. package/dist/assets/index-BdtHOOT9.js +12 -0
  4. package/dist/assets/index-BdtHOOT9.js.map +1 -0
  5. package/dist/assets/{navara_wasm_font_worker_bg-665OLxjy.wasm → navara_wasm_font_worker_bg-Bnck8OGf.wasm} +0 -0
  6. package/dist/assets/{navara_wasm_worker_bg-BXhKC5bE.wasm → navara_wasm_worker_bg-DG9ORAmj.wasm} +0 -0
  7. package/dist/index.d.ts +142 -51
  8. package/dist/index.js +4772 -4510
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.umd.cjs +226 -218
  11. package/dist/index.umd.cjs.map +1 -1
  12. package/package.json +13 -13
  13. package/src/event/context.ts +5 -0
  14. package/src/index.ts +10 -2
  15. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +6 -1
  16. package/src/material/enhancer/tileComposite/tileElevationHeatmapEnhancer/shader.ts +9 -2
  17. package/src/mesh/tile/drapeResolver.ts +76 -0
  18. package/src/mesh/{tile.test.ts → tile/index.test.ts} +16 -6
  19. package/src/mesh/{tile.ts → tile/index.ts} +124 -392
  20. package/src/mesh/tile/rasterDrapeResolver.ts +423 -0
  21. package/src/mesh/tile/vectorDrapeResolver.ts +404 -0
  22. package/src/tileTexture/TileTextureCache.test.ts +2 -0
  23. package/src/tileTexture/TileTextureCache.ts +1 -0
  24. package/src/tileTexture/TileTextureCompositor.test.ts +105 -0
  25. package/src/tileTexture/TileTextureCompositor.ts +241 -31
  26. package/src/tileTexture/__snapshots__/CompositeShader.snapshot.test.ts.snap +36 -16
  27. package/src/tileTexture/types.ts +7 -0
  28. package/src/utils/demNoDataColor.test.ts +47 -0
  29. package/src/utils/demNoDataColor.ts +48 -0
  30. package/dist/assets/fontWorker-CEJEfRpb.js +0 -2
  31. package/dist/assets/fontWorker-CEJEfRpb.js.map +0 -1
  32. package/dist/assets/index-BUB-ijMO.js +0 -12
  33. package/dist/assets/index-BUB-ijMO.js.map +0 -1
@@ -3,12 +3,18 @@ import {
3
3
  Color,
4
4
  DataTexture,
5
5
  GLSL3,
6
+ LinearFilter,
6
7
  Mesh,
8
+ MeshBasicMaterial,
9
+ NearestFilter,
10
+ NoBlending,
11
+ NoColorSpace,
7
12
  OrthographicCamera,
8
13
  PlaneGeometry,
9
14
  RGBAFormat,
10
15
  Scene,
11
16
  ShaderMaterial,
17
+ SRGBColorSpace,
12
18
  type Texture,
13
19
  UnsignedByteType,
14
20
  WebGLRenderTarget,
@@ -89,6 +95,30 @@ type CachedMaterial = {
89
95
  placeholderTexture: Texture;
90
96
  };
91
97
 
98
+ /** One WebMercator raster tile texture to bake into a layer's render target,
99
+ * framed by the Rust-resolved mercator affine (terrain `[0,1]` UV → source UV). */
100
+ export type RasterBakeSource = {
101
+ texture: Texture;
102
+ uvOffset: [number, number];
103
+ uvScale: [number, number];
104
+ };
105
+
106
+ /** One baked raster layer: its overlapping WM tile textures, mosaicked into one
107
+ * render target. Heatmap layers carry encoded DEM, copied raw (nearest, no
108
+ * color conversion) so the composite decode stays valid. */
109
+ export type RasterBakeSlot = {
110
+ isElevationHeatmap: boolean;
111
+ /**
112
+ * The decoder's no-data (boundary) color for heatmap slots: painted across
113
+ * the render target before the sources so uncovered regions decode as
114
+ * "no elevation" — the no-data story lives entirely in the encoded-elevation
115
+ * domain, keeping the alpha channel free for future RGBA encodings. Absent
116
+ * for color slots and for decoders with no byte-representable boundary.
117
+ */
118
+ noDataColor?: [number, number, number];
119
+ sources: RasterBakeSource[];
120
+ };
121
+
92
122
  /**
93
123
  * Integration layer for per-tile texture composition.
94
124
  *
@@ -112,9 +142,10 @@ export class TileTextureCompositor {
112
142
  * per-tile GPU byte cost it reports to the memory ledger. */
113
143
  readonly size: number;
114
144
  private readonly texturizedScenes: TexturizedSceneByTileCoordinates;
115
- // Single fixed [-1, 1] camera shared by every vector-scene bake. Ancestor
116
- // fallback is resolved in Rust, so no per-tile camera transform is needed.
117
- private readonly vectorCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
145
+ // Single camera shared by every drape bake (vector and raster), re-framed
146
+ // per source by `frameBakeCamera`. Ancestor fallback is resolved in Rust,
147
+ // so no per-tile camera transform is needed.
148
+ private readonly bakeCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
118
149
 
119
150
  // Composite-pass machinery (lazily created on first use so tests that
120
151
  // don't render don't pay the allocation).
@@ -122,11 +153,38 @@ export class TileTextureCompositor {
122
153
  private readonly quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
123
154
  private readonly quadMesh: Mesh;
124
155
  private readonly materialCache = new Map<string, CachedMaterial>();
125
- // Vector render targets this compositor has ever rendered to. three.js
156
+ // Drape render targets this compositor has ever rendered to. three.js
126
157
  // allocates a render target's GL framebuffer on first setRenderTarget, so
127
158
  // an empty slot whose target was never touched must be skipped entirely —
128
159
  // clearing it would allocate size² × 4 bytes of GPU memory for nothing.
129
- private readonly touchedVectorTargets = new WeakSet<WebGLRenderTarget>();
160
+ private readonly touchedTargets = new WeakSet<WebGLRenderTarget>();
161
+
162
+ // Raster-bake machinery (lazily used): a textured quad drawn once per source
163
+ // through the same windowing camera as the vector bake. `toneMapped: false` +
164
+ // per-kind texture color spaces keep the bake a value-preserving copy.
165
+ //
166
+ // `transparent: true` + `blending: NoBlending` carries the source ALPHA into
167
+ // the render target: an opaque material (transparent=false + NormalBlending)
168
+ // gets three.js's `OPAQUE` define, whose shader forces `diffuseColor.a = 1.0`
169
+ // and destroys the tile's transparency. NoBlending keeps replace semantics —
170
+ // sources are disjoint WM tiles (plus a coarse-first ancestor underlay), so
171
+ // each draw must overwrite, not blend, and straight (r, g, b, a) texels land
172
+ // in the target for the composite's own alpha blend.
173
+ private readonly rasterBakeScene = new Scene();
174
+ private readonly rasterBakeMaterial = new MeshBasicMaterial({
175
+ toneMapped: false,
176
+ depthTest: false,
177
+ depthWrite: false,
178
+ transparent: true,
179
+ blending: NoBlending,
180
+ });
181
+ // 1×1 no-data underlay for baked heatmap targets, drawn through the same
182
+ // value-preserving bake pipeline as the sources — a texture upload is
183
+ // byte-exact, whereas a clear color would pass through the renderer's color
184
+ // management and could land one byte off (fatal for positional encodings,
185
+ // where the high byte weighs 65536 in decoded x). Cached per color.
186
+ private demNoDataTexture: DataTexture | null = null;
187
+ private demNoDataTextureKey = "";
130
188
 
131
189
  constructor(opts: TileTextureCompositorOptions) {
132
190
  this.renderer = opts.renderer;
@@ -141,7 +199,15 @@ export class TileTextureCompositor {
141
199
  this.quadMesh = new Mesh(new PlaneGeometry(2, 2));
142
200
  this.quadScene.add(this.quadMesh);
143
201
  this.quadCamera.position.z = 1;
144
- this.vectorCamera.position.z = 1;
202
+ this.bakeCamera.position.z = 1;
203
+
204
+ // Raster-bake quad: spans the source tile's NDC frame like a vector scene,
205
+ // so the same windowing camera math frames it. Shares the fullscreen quad
206
+ // geometry with `quadMesh` (both are static [-1, 1] planes; the windowing
207
+ // lives in the camera), so dispose() frees one geometry for both.
208
+ this.rasterBakeScene.add(
209
+ new Mesh(this.quadMesh.geometry, this.rasterBakeMaterial),
210
+ );
145
211
  }
146
212
 
147
213
  // ---------------------------------------------------------------------
@@ -204,7 +270,88 @@ export class TileTextureCompositor {
204
270
  }[],
205
271
  renderTargets: WebGLRenderTarget[],
206
272
  ): void {
207
- const camera = this.vectorCamera;
273
+ this.bakeSlotTargets(slots, renderTargets, (slot) => {
274
+ for (const source of slot.sources) {
275
+ const scene = this.texturizedScenes.findSceneByLayerId(
276
+ source.tileHandle,
277
+ slot.layerId,
278
+ );
279
+ if (!scene || scene.removed || !scene.children.length) continue;
280
+ this.renderer.render(
281
+ scene,
282
+ this.frameBakeCamera(source.uvOffset, source.uvScale),
283
+ );
284
+ }
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Bake each raster layer's overlapping WM tile textures into ONE render
290
+ * target (slot `i` → `renderTargets[i]`), mirroring {@link renderVectorScenes}:
291
+ * every source is a textured quad framed by the Rust-supplied mercator affine
292
+ * `uvOffset`/`uvScale`, so the render target ends up spanning the terrain
293
+ * tile's (Mercator-projected) extent and the composite treats the whole layer
294
+ * as one slot with the terrain-band reprojection — instead of one composite
295
+ * slot per overlapping tile, which overflowed the GPU slot budget on
296
+ * Geographic terrain once 3+ layers were draped.
297
+ *
298
+ * Sources are drawn coarse-first (ascending `uvScale`): an ancestor fallback
299
+ * covers the whole terrain rect, so painting it first lets the finer tiles
300
+ * overwrite it instead of being buried under coarse texels. The bake is a
301
+ * value-preserving copy (`toneMapped: false`; per-kind color spaces below), so
302
+ * heatmap DEM texels survive it bit-faithfully:
303
+ * - color imagery: source + target declared sRGB — decode at fetch, encode at
304
+ * write, decode again at the composite fetch: the same linear values the
305
+ * direct (non-baked) path produced.
306
+ * - elevation heatmap: everything `NoColorSpace` + nearest — raw texels, no
307
+ * interpolation, so the composite's decode-then-interpolate keeps working.
308
+ */
309
+ renderRasterTiles(
310
+ slots: (RasterBakeSlot | undefined)[],
311
+ renderTargets: (WebGLRenderTarget | undefined)[],
312
+ ): void {
313
+ this.bakeSlotTargets(slots, renderTargets, (slot) => {
314
+ // Heatmap targets: paint the decoder's no-data color across the whole
315
+ // target first, so regions no source covers decode as "no elevation"
316
+ // (the composite renders them transparent) instead of decoding the
317
+ // cleared black as a height.
318
+ if (slot.isElevationHeatmap && slot.noDataColor) {
319
+ this.drawRasterBakeQuad(
320
+ this.demNoDataUnderlay(slot.noDataColor),
321
+ true,
322
+ [0, 0],
323
+ [1, 1],
324
+ );
325
+ }
326
+
327
+ // Coarse-first painter's order: a shared ancestor spans every finer
328
+ // source's rect, so it must not be drawn over them.
329
+ const sources = [...slot.sources].sort(
330
+ (a, b) => a.uvScale[0] - b.uvScale[0],
331
+ );
332
+ for (const source of sources) {
333
+ this.drawRasterBakeQuad(
334
+ source.texture,
335
+ slot.isElevationHeatmap,
336
+ source.uvOffset,
337
+ source.uvScale,
338
+ );
339
+ }
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Shared bake loop over per-layer drape render targets (slot `i` →
345
+ * `renderTargets[i]`): saves/restores the renderer state, clears each live
346
+ * target once and lets `drawSlot` accumulate that slot's sources into it —
347
+ * autoClear stays off so the sources mosaic instead of overwriting one
348
+ * another.
349
+ */
350
+ private bakeSlotTargets<S>(
351
+ slots: readonly (S | undefined)[],
352
+ renderTargets: readonly (WebGLRenderTarget | undefined)[],
353
+ drawSlot: (slot: S) => void,
354
+ ): void {
208
355
  const prevTarget = this.renderer.getRenderTarget();
209
356
  const prevClear = this.renderer.getClearColor(PREV_CLEAR_COLOR);
210
357
  const prevClearAlpha = this.renderer.getClearAlpha();
@@ -220,35 +367,14 @@ export class TileTextureCompositor {
220
367
  // An empty slot only needs a clear when the target holds stale content
221
368
  // from a previous bake; a never-touched target has no GL storage yet
222
369
  // and must not be render-targeted (that would allocate it).
223
- if (!slot && !this.touchedVectorTargets.has(renderTarget)) continue;
224
- this.touchedVectorTargets.add(renderTarget);
370
+ if (!slot && !this.touchedTargets.has(renderTarget)) continue;
371
+ this.touchedTargets.add(renderTarget);
225
372
 
226
373
  this.renderer.setRenderTarget(renderTarget);
227
374
  this.renderer.setClearColor(0x000, 0);
228
375
  this.renderer.clear();
229
376
 
230
- if (slot) {
231
- for (const source of slot.sources) {
232
- const scene = this.texturizedScenes.findSceneByLayerId(
233
- source.tileHandle,
234
- slot.layerId,
235
- );
236
- if (!scene || scene.removed || !scene.children.length) continue;
237
- // Frame the terrain tile's sub-rect of this source tile from the
238
- // mercator uvOffset/uvScale: meshUv ∈ [0,1] → vectorUv = uvOffset +
239
- // meshUv·uvScale, source scene NDC = 2·vectorUv − 1. A source finer than
240
- // the terrain tile yields a camera wider than [-1, 1], so it draws into
241
- // only its sub-rect and leaves the rest of the RT transparent.
242
- const [ox, oy] = source.uvOffset;
243
- const [sx, sy] = source.uvScale;
244
- camera.left = 2 * ox - 1;
245
- camera.right = 2 * (ox + sx) - 1;
246
- camera.bottom = 2 * oy - 1;
247
- camera.top = 2 * (oy + sy) - 1;
248
- camera.updateProjectionMatrix();
249
- this.renderer.render(scene, camera);
250
- }
251
- }
377
+ if (slot) drawSlot(slot);
252
378
 
253
379
  renderTarget.texture.needsUpdate = true;
254
380
  }
@@ -258,6 +384,86 @@ export class TileTextureCompositor {
258
384
  this.renderer.setClearColor(prevClear, prevClearAlpha);
259
385
  }
260
386
 
387
+ /**
388
+ * Frame the shared bake camera on the terrain tile's sub-rect of a source
389
+ * from the mercator affine: meshUv ∈ [0,1] → sourceUv = uvOffset +
390
+ * meshUv·uvScale, source NDC = 2·sourceUv − 1. A source finer than the
391
+ * terrain tile yields a camera wider than [-1, 1], so it draws into only
392
+ * its sub-rect and leaves the rest of the render target transparent.
393
+ */
394
+ private frameBakeCamera(
395
+ uvOffset: [number, number],
396
+ uvScale: [number, number],
397
+ ): OrthographicCamera {
398
+ const [ox, oy] = uvOffset;
399
+ const [sx, sy] = uvScale;
400
+ const camera = this.bakeCamera;
401
+ camera.left = 2 * ox - 1;
402
+ camera.right = 2 * (ox + sx) - 1;
403
+ camera.bottom = 2 * oy - 1;
404
+ camera.top = 2 * (oy + sy) - 1;
405
+ camera.updateProjectionMatrix();
406
+ return camera;
407
+ }
408
+
409
+ /** Draw one textured quad into the current render target through the bake
410
+ * camera window — the value-preserving copy described on
411
+ * {@link renderRasterTiles}. */
412
+ private drawRasterBakeQuad(
413
+ texture: Texture,
414
+ isElevationHeatmap: boolean,
415
+ uvOffset: [number, number],
416
+ uvScale: [number, number],
417
+ ): void {
418
+ const material = this.rasterBakeMaterial;
419
+ this.configureRasterBakeSource(texture, isElevationHeatmap);
420
+ // First map assignment flips USE_MAP; later swaps are uniform-only.
421
+ if (material.map === null) material.needsUpdate = true;
422
+ material.map = texture;
423
+ this.renderer.render(
424
+ this.rasterBakeScene,
425
+ this.frameBakeCamera(uvOffset, uvScale),
426
+ );
427
+ }
428
+
429
+ /** The cached 1×1 no-data texture, (re)created when the color changes. */
430
+ private demNoDataUnderlay(color: [number, number, number]): DataTexture {
431
+ const key = color.join(",");
432
+ if (!this.demNoDataTexture || this.demNoDataTextureKey !== key) {
433
+ this.demNoDataTexture?.dispose();
434
+ const texture = new DataTexture(
435
+ new Uint8Array([...color, 255]),
436
+ 1,
437
+ 1,
438
+ RGBAFormat,
439
+ UnsignedByteType,
440
+ );
441
+ texture.needsUpdate = true;
442
+ this.demNoDataTexture = texture;
443
+ this.demNoDataTextureKey = key;
444
+ }
445
+ return this.demNoDataTexture;
446
+ }
447
+
448
+ /** Value-preserving sampler settings for a bake source texture (see
449
+ * {@link renderRasterTiles}); idempotent so re-bakes don't re-upload. */
450
+ private configureRasterBakeSource(tex: Texture, isElevationHeatmap: boolean) {
451
+ const colorSpace = isElevationHeatmap ? NoColorSpace : SRGBColorSpace;
452
+ const filter = isElevationHeatmap ? NearestFilter : LinearFilter;
453
+ if (
454
+ tex.colorSpace !== colorSpace ||
455
+ tex.minFilter !== filter ||
456
+ tex.magFilter !== filter ||
457
+ tex.generateMipmaps
458
+ ) {
459
+ tex.colorSpace = colorSpace;
460
+ tex.minFilter = filter;
461
+ tex.magFilter = filter;
462
+ tex.generateMipmaps = false;
463
+ tex.needsUpdate = true;
464
+ }
465
+ }
466
+
261
467
  // ---------------------------------------------------------------------
262
468
  // MRT composite pass
263
469
  // ---------------------------------------------------------------------
@@ -416,6 +622,10 @@ export class TileTextureCompositor {
416
622
  m.placeholderTexture.dispose();
417
623
  }
418
624
  this.materialCache.clear();
625
+ // Also frees the raster-bake quad, which shares this geometry.
419
626
  this.quadMesh.geometry.dispose();
627
+ this.rasterBakeMaterial.dispose();
628
+ this.demNoDataTexture?.dispose();
629
+ this.demNoDataTexture = null;
420
630
  }
421
631
  }
@@ -234,7 +234,13 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
234
234
 
235
235
 
236
236
 
237
- float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize) {
237
+
238
+
239
+
240
+
241
+
242
+
243
+ float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize, out bool valid) {
238
244
 
239
245
  DEMBilinearData data = prepareDEMBilinear(texSize, uv);
240
246
 
@@ -251,7 +257,8 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
251
257
  h = interpolateDEMHeights(h00, h10, h01, h11, data.frac);
252
258
  }
253
259
 
254
- if (!isValidHeight(h)) {
260
+ valid = isValidHeight(h);
261
+ if (!valid) {
255
262
  return 0.0;
256
263
  }
257
264
 
@@ -329,8 +336,9 @@ void main() {
329
336
  vec4 texColor0;
330
337
  if (uIsElevationHeatmaps[0]) {
331
338
  ivec2 demTexSize = textureSize(uTextures[0], 0);
332
- float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize);
333
- texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
339
+ bool demValid0;
340
+ float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize, demValid0);
341
+ texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid0 ? 1.0 : 0.0);
334
342
  } else {
335
343
  texColor0 = texture2D(uTextures[0], texUv0) * vec4(uColors[0], 1.0);
336
344
  }
@@ -397,8 +405,9 @@ void main() {
397
405
  vec4 texColor1;
398
406
  if (uIsElevationHeatmaps[1]) {
399
407
  ivec2 demTexSize = textureSize(uTextures[1], 0);
400
- float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize);
401
- texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
408
+ bool demValid1;
409
+ float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize, demValid1);
410
+ texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid1 ? 1.0 : 0.0);
402
411
  } else {
403
412
  texColor1 = texture2D(uTextures[1], texUv1) * vec4(uColors[1], 1.0);
404
413
  }
@@ -465,8 +474,9 @@ void main() {
465
474
  vec4 texColor2;
466
475
  if (uIsElevationHeatmaps[2]) {
467
476
  ivec2 demTexSize = textureSize(uTextures[2], 0);
468
- float normalized_h = sampleElevationBilinear(uTextures[2], texUv2, demTexSize);
469
- texColor2 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
477
+ bool demValid2;
478
+ float normalized_h = sampleElevationBilinear(uTextures[2], texUv2, demTexSize, demValid2);
479
+ texColor2 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid2 ? 1.0 : 0.0);
470
480
  } else {
471
481
  texColor2 = texture2D(uTextures[2], texUv2) * vec4(uColors[2], 1.0);
472
482
  }
@@ -533,8 +543,9 @@ void main() {
533
543
  vec4 texColor3;
534
544
  if (uIsElevationHeatmaps[3]) {
535
545
  ivec2 demTexSize = textureSize(uTextures[3], 0);
536
- float normalized_h = sampleElevationBilinear(uTextures[3], texUv3, demTexSize);
537
- texColor3 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
546
+ bool demValid3;
547
+ float normalized_h = sampleElevationBilinear(uTextures[3], texUv3, demTexSize, demValid3);
548
+ texColor3 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid3 ? 1.0 : 0.0);
538
549
  } else {
539
550
  texColor3 = texture2D(uTextures[3], texUv3) * vec4(uColors[3], 1.0);
540
551
  }
@@ -857,7 +868,13 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
857
868
 
858
869
 
859
870
 
860
- float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize) {
871
+
872
+
873
+
874
+
875
+
876
+
877
+ float sampleElevationBilinear(sampler2D demTexture, vec2 uv, ivec2 texSize, out bool valid) {
861
878
 
862
879
  DEMBilinearData data = prepareDEMBilinear(texSize, uv);
863
880
 
@@ -874,7 +891,8 @@ float interpolateDEMHeights(float h00, float h10, float h01, float h11, vec2 fra
874
891
  h = interpolateDEMHeights(h00, h10, h01, h11, data.frac);
875
892
  }
876
893
 
877
- if (!isValidHeight(h)) {
894
+ valid = isValidHeight(h);
895
+ if (!valid) {
878
896
  return 0.0;
879
897
  }
880
898
 
@@ -952,8 +970,9 @@ void main() {
952
970
  vec4 texColor0;
953
971
  if (uIsElevationHeatmaps[0]) {
954
972
  ivec2 demTexSize = textureSize(uTextures[0], 0);
955
- float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize);
956
- texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
973
+ bool demValid0;
974
+ float normalized_h = sampleElevationBilinear(uTextures[0], texUv0, demTexSize, demValid0);
975
+ texColor0 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid0 ? 1.0 : 0.0);
957
976
  } else {
958
977
  texColor0 = texture2D(uTextures[0], texUv0) * vec4(uColors[0], 1.0);
959
978
  }
@@ -1016,8 +1035,9 @@ void main() {
1016
1035
  vec4 texColor1;
1017
1036
  if (uIsElevationHeatmaps[1]) {
1018
1037
  ivec2 demTexSize = textureSize(uTextures[1], 0);
1019
- float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize);
1020
- texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
1038
+ bool demValid1;
1039
+ float normalized_h = sampleElevationBilinear(uTextures[1], texUv1, demTexSize, demValid1);
1040
+ texColor1 = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid1 ? 1.0 : 0.0);
1021
1041
  } else {
1022
1042
  texColor1 = texture2D(uTextures[1], texUv1) * vec4(uColors[1], 1.0);
1023
1043
  }
@@ -16,6 +16,7 @@ export type DirtyReason =
16
16
  | "material"
17
17
  | "texture-binding"
18
18
  | "vector-revision"
19
+ | "raster-revision"
19
20
  | "hillshade";
20
21
 
21
22
  /**
@@ -121,6 +122,12 @@ export type HillshadeCompositeLayer = CompositeLayerBase & {
121
122
  export type ElevationHeatmapCompositeLayer = CompositeLayerBase & {
122
123
  kind: "elevationHeatmap";
123
124
  opacity: number;
125
+ /**
126
+ * Same Mercator latitude reprojection band as {@link RasterCompositeLayer}:
127
+ * the heatmap decode samples the reprojected per-slot UV, so a DEM draped on
128
+ * Geographic terrain (baked or direct) remaps identically to color imagery.
129
+ */
130
+ reproject?: [number, number];
124
131
  };
125
132
 
126
133
  export type CompositeLayer =
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { demNoDataColorBytes } from "./demNoDataColor";
4
+
5
+ describe("demNoDataColorBytes", () => {
6
+ it("solves the Japan GSI boundary (2^23) to (128, 0, 0)", () => {
7
+ expect(demNoDataColorBytes({ x: 65536, y: 256, z: 1 }, 8388608)).toEqual([
8
+ 128, 0, 0,
9
+ ]);
10
+ });
11
+
12
+ it("solves the Mapbox boundary (10000) to (0, 39, 16)", () => {
13
+ expect(demNoDataColorBytes({ x: 65536, y: 256, z: 1 }, 10000)).toEqual([
14
+ 0, 39, 16,
15
+ ]);
16
+ });
17
+
18
+ it("solves the Terrarium boundary (0) to black", () => {
19
+ expect(demNoDataColorBytes({ x: 256, y: 1, z: 1 / 256 }, 0)).toEqual([
20
+ 0, 0, 0,
21
+ ]);
22
+ });
23
+
24
+ it("keeps channel order independent of scaler magnitude order", () => {
25
+ // Same positional system with permuted channels: G is the coarse digit.
26
+ expect(demNoDataColorBytes({ x: 256, y: 65536, z: 1 }, 8388608)).toEqual([
27
+ 0, 128, 0,
28
+ ]);
29
+ });
30
+
31
+ it("accepts a non-exact color inside the shader's no-data band", () => {
32
+ // 0.75 has no exact byte decomposition with unit scalers, but black
33
+ // decodes to 0 and |0 - 0.75| <= 1, so the shader still flags it no-data.
34
+ expect(demNoDataColorBytes({ x: 1, y: 1, z: 1 }, 0.75)).toEqual([0, 0, 0]);
35
+ // Band edge: the closest representable value is exactly 1 away
36
+ // ((1,0,0) decodes to 2, |2 - 3| == 1), which the shader's
37
+ // `abs(x - boundary) <= 1` check still classifies as no-data.
38
+ expect(demNoDataColorBytes({ x: 2, y: 0, z: 0 }, 3)).toEqual([1, 0, 0]);
39
+ });
40
+
41
+ it("returns null when no byte color decodes inside the no-data band", () => {
42
+ // Boundary beyond the encodable range (max dot product is 765).
43
+ expect(demNoDataColorBytes({ x: 1, y: 1, z: 1 }, 100000)).toBeNull();
44
+ // Coarse scalers leave the nearest representable value a full step away.
45
+ expect(demNoDataColorBytes({ x: 4, y: 0, z: 0 }, 6)).toBeNull();
46
+ });
47
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Solve `dot(rgb, scaler) == boundary` for RGB bytes: the color a DEM texel
3
+ * must hold so the shader's `decodeDEMHeight` classifies it as no-data. The
4
+ * raster bake paints a baked heatmap render target with this color before
5
+ * drawing sources, so uncovered regions decode as "no elevation here" —
6
+ * entirely inside the encoded-elevation domain, leaving the alpha channel
7
+ * untouched (it is not part of any encoding and stays reserved for future
8
+ * RGBA ones).
9
+ *
10
+ * Solved by greedy positional decomposition (largest scaler first), which is
11
+ * exact for the positional presets (GSI, Mapbox, Terrarium); a non-exact
12
+ * decomposition is still accepted when it lands inside the shader's no-data
13
+ * tolerance band (`decodeDEMHeight` flags |x − boundary| <= 1). Returns null
14
+ * only when no byte color decodes within that band (an exotic custom decoder
15
+ * whose encoding cannot express no-data at all) — callers then skip the
16
+ * no-data underlay and a baked target's uncovered regions decode as whatever
17
+ * black decodes to, which the shader may colormap as a valid height.
18
+ */
19
+ export function demNoDataColorBytes(
20
+ scaler: { x: number; y: number; z: number },
21
+ boundary: number,
22
+ ): [number, number, number] | null {
23
+ const channels: { scaler: number; index: number }[] = [
24
+ { scaler: scaler.x, index: 0 },
25
+ { scaler: scaler.y, index: 1 },
26
+ { scaler: scaler.z, index: 2 },
27
+ ]
28
+ .filter((c) => c.scaler > 0)
29
+ .sort((a, b) => b.scaler - a.scaler);
30
+
31
+ const rgb: [number, number, number] = [0, 0, 0];
32
+ let rest = boundary;
33
+ for (const { scaler: s, index } of channels) {
34
+ // The epsilon guards float division just below an integer (e.g. 0.999…).
35
+ const v = Math.min(255, Math.max(0, Math.floor(rest / s + 1e-9)));
36
+ rgb[index] = v;
37
+ rest -= v * s;
38
+ }
39
+
40
+ // The shader flags |x - boundary| <= 1 as no-data (dem_util.glsl,
41
+ // `epsilon_cmp`): accept exactly that band, boundary included — a color a
42
+ // full 1 away still classifies as no-data, so rejecting it would skip the
43
+ // underlay and let uncovered baked regions decode black as a valid height.
44
+ // No safety margin is subtracted: painting a band-edge color that float32
45
+ // drift pushes out of the band decodes as a near-boundary height, which is
46
+ // no worse than the black the skipped underlay would leave behind.
47
+ return Math.abs(rest) <= 1 ? rgb : null;
48
+ }