@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
@@ -0,0 +1,404 @@
1
+ import type { TileHandle } from "@navaramap/core";
2
+ import {
3
+ Color,
4
+ DataTexture,
5
+ RGBAFormat,
6
+ WebGLRenderTarget,
7
+ type Object3D,
8
+ type Vector2,
9
+ } from "three";
10
+
11
+ import type { TileHandler } from "../../event/context";
12
+ import type { TexturizedSceneByTileCoordinates } from "../../scene";
13
+ import type { TextureOptions } from "../../textures";
14
+ import type { TileTextureCompositor } from "../../tileTexture";
15
+ import { PolygonMesh } from "../polygon";
16
+ import { PolylineMesh } from "../polyline";
17
+
18
+ import {
19
+ configureTexturizedTexture,
20
+ type DrapeResolver,
21
+ } from "./drapeResolver";
22
+
23
+ import type { TileMaterial } from ".";
24
+
25
+ /**
26
+ * One WebMercator vector source tile baked into a layer's render target.
27
+ * `tileHandle` keys the cached offscreen scene; `uvOffset`/`uvScale` are the
28
+ * **mercator** affine framing the terrain tile's sub-region of that (possibly
29
+ * coarser, ancestor-resolved) source tile, driving the bake camera (identity for
30
+ * an exact same-tile drape).
31
+ */
32
+ type VectorSource = {
33
+ tileHandle: TileHandle;
34
+ uvOffset: [number, number];
35
+ uvScale: [number, number];
36
+ };
37
+
38
+ /**
39
+ * One clamp-to-ground vector layer draped on this terrain tile, resolved by Rust.
40
+ * A layer is backed by one source on WebMercator terrain, but by several when the
41
+ * terrain is Geographic (N:M): every overlapping WM vector tile is baked into the
42
+ * layer's single render target, each framed by its own `uvOffset`/`uvScale`, so
43
+ * the RT ends up spanning the terrain tile's extent. `reproject` carries the
44
+ * terrain `[south, north]` band on Geographic terrain (undefined on WM) so the
45
+ * composite paste can remap the latitude axis WebMercator→Geographic, exactly
46
+ * like a raster slot.
47
+ */
48
+ type VectorSlot = {
49
+ layerId: string;
50
+ sources: VectorSource[];
51
+ reproject?: [number, number];
52
+ };
53
+
54
+ /** Shared 1×1 transparent texture bound to texturized sampler slots that have
55
+ * no drape render target yet (they are allocated lazily). WebGL requires every
56
+ * sampler to reference a valid texture; the shader gates sampling with `shows`,
57
+ * so this placeholder's content is never visible. */
58
+ const EMPTY_TEXTURIZED_TEXTURE = new DataTexture(
59
+ new Uint8Array([0, 0, 0, 0]),
60
+ 1,
61
+ 1,
62
+ RGBAFormat,
63
+ );
64
+ EMPTY_TEXTURIZED_TEXTURE.needsUpdate = true;
65
+
66
+ /** The slice of TileMesh the vector resolver works against. `material` is an
67
+ * accessor because the tile's material instance can be swapped over its life
68
+ * (Basic ↔ Lambert). */
69
+ export type VectorDrapeHost = {
70
+ handle: TileHandle;
71
+ tileHandler: TileHandler;
72
+ compositor: TileTextureCompositor;
73
+ /** Offscreen per-(tile, layer) vector scenes the bake rasterizes. */
74
+ texturizedScenes: TexturizedSceneByTileCoordinates;
75
+ textureOptions: TextureOptions;
76
+ /** Drape render target side length in texels. */
77
+ drapeRtSize: number;
78
+ /** Texturized-vector slot budget (the region's width). */
79
+ numTexturizedVector: number;
80
+ /** First texturized-vector slot index (the region is `[from, maxTextures)`). */
81
+ texturizedSceneIndexFrom: number;
82
+ material: () => TileMaterial;
83
+ /** Re-report the tile's drape GPU footprint after the RT pool changed. */
84
+ reportDrapeGpuBytes: () => void;
85
+ };
86
+
87
+ /**
88
+ * Clamp-to-ground vector layers: always the baked path, on every tiling scheme.
89
+ * Vector content is an offscreen three.js scene, so rasterizing into a
90
+ * per-layer render target is intrinsic — WebMercator terrain is just the
91
+ * degenerate case (one source, identity affine, no reprojection band) of the
92
+ * Geographic N:M mosaic. That is why, unlike the raster side, there is no
93
+ * direct/baked split here.
94
+ */
95
+ export class VectorDrapeResolver implements DrapeResolver {
96
+ // Per-layer texturized-vector slots resolved by Rust: one slot per
97
+ // clamp-to-ground layer, keyed by the WM vector tile whose offscreen scene
98
+ // backs it. Ancestor fallback is already resolved in Rust, so this side just
99
+ // caches + bakes.
100
+ private vectorSlots: VectorSlot[] = [];
101
+ private renderTargets: WebGLRenderTarget[] = [];
102
+ // Signature of the last baked slot set; a change drives a re-bake + re-bind.
103
+ private prevSignature = "";
104
+ // The vector resolution revision at which `vectorSlots` was last fetched.
105
+ // Re-fetching is skipped while the global revision is unchanged (the resolve
106
+ // result can only change when a traverse runs or a scene becomes ready).
107
+ // `-1` forces a fetch on the first frame.
108
+ private lastRevision = -1;
109
+ private warnedExceededTextures = false;
110
+
111
+ constructor(private readonly host: VectorDrapeHost) {}
112
+
113
+ update(): void {
114
+ // Re-fetch the Rust-resolved slots only when the global vector resolution
115
+ // revision changed; otherwise reuse the cached slots. This skips a per-tile
116
+ // WASM-boundary resolve every frame, the dominant steady-state cost when
117
+ // many terrain tiles are visible.
118
+ const revision = this.host.tileHandler.vectorRevision();
119
+ if (revision !== this.lastRevision) {
120
+ this.lastRevision = revision;
121
+ this.refreshSlots();
122
+ }
123
+
124
+ // Re-bake the offscreen vector scenes only when the resolved slots or their
125
+ // backing scenes changed; otherwise the existing render targets stay valid.
126
+ const signature = this.signature();
127
+ if (signature !== this.prevSignature) {
128
+ this.prevSignature = signature;
129
+ this.host.compositor.renderVectorScenes(
130
+ this.vectorSlots,
131
+ this.renderTargets,
132
+ );
133
+ this.bindSlots();
134
+ this.host.compositor.markDirty(this.host.handle, "vector-revision");
135
+ }
136
+ }
137
+
138
+ syncMaterialSlots(): void {
139
+ const m = this.host.material();
140
+ const textures = m.userData.textures.value;
141
+ for (let i = 0; i < this.host.numTexturizedVector; i++) {
142
+ // Every texturized sampler slot must reference a valid texture (GLSL/WebGL
143
+ // requires it even for a sampler never read). Drape render targets are
144
+ // allocated lazily, so a slot without one binds the shared empty texture;
145
+ // `bindSlots` swaps in the real RT texture once a layer drapes here.
146
+ const lastIndex = this.host.texturizedSceneIndexFrom + i;
147
+ const rt = this.renderTargets[i];
148
+ textures[lastIndex] = rt
149
+ ? configureTexturizedTexture(rt.texture, this.host.textureOptions)
150
+ : EMPTY_TEXTURIZED_TEXTURE;
151
+
152
+ // Per-slot visibility + material attributes are bound by bindSlots once
153
+ // the Rust-resolved slots are known; default to hidden here.
154
+ m.userData.shows.value[lastIndex] = 0;
155
+ m.userData.colors.value[lastIndex] = new Color(0xffffff);
156
+ m.userData.opacities.value[lastIndex] = 1.0;
157
+ }
158
+ // Re-bind vector slots on the next frame now that render targets were rebound.
159
+ this.prevSignature = "";
160
+ }
161
+
162
+ slotReproject(absSlot: number): [number, number] | undefined {
163
+ // Baked-vector slots (whose RT spans the terrain extent) carry the
164
+ // Rust-resolved terrain `[south, north]` band directly on the slot;
165
+ // undefined on WebMercator terrain.
166
+ return this.vectorSlots[absSlot - this.host.texturizedSceneIndexFrom]
167
+ ?.reproject;
168
+ }
169
+
170
+ invalidate(): void {
171
+ this.prevSignature = "";
172
+ }
173
+
174
+ liveRenderTargetCount(): number {
175
+ return this.renderTargets.length;
176
+ }
177
+
178
+ dispose(): void {
179
+ for (const rt of this.renderTargets) {
180
+ rt.dispose();
181
+ }
182
+ this.renderTargets.length = 0;
183
+ }
184
+
185
+ /**
186
+ * Pull the Rust-resolved texturized-vector slots for this terrain tile into
187
+ * {@link vectorSlots}: one bake slot per clamp-to-ground layer, keyed by the
188
+ * rendered WM vector tile that backs it (ancestor fallback already resolved in
189
+ * Rust). Only layers with an actual draped scene take a slot — Rust also returns
190
+ * non-draped layers (rendered in the MRT scene), which must not consume the budget.
191
+ * Capped to the GPU slot budget; warns once when more draped layers are requested.
192
+ */
193
+ private refreshSlots() {
194
+ const states =
195
+ this.host.tileHandler.getVectorTileStates(this.host.handle) ?? [];
196
+
197
+ // Group the flattened per-(layer, source) states back into one slot per
198
+ // layer, each carrying every overlapping WM source tile (N:M on Geographic
199
+ // terrain). First-seen layer order assigns the render-target index, kept
200
+ // consistent by renderVectorScenes / bindSlots / buildCompositeLayers.
201
+ const byLayer = new Map<string, VectorSlot>();
202
+ const droppedLayers = new Set<string>();
203
+ for (const state of states) {
204
+ // Read each wasm getter once — every access crosses the boundary and the
205
+ // uv getters allocate a fresh Float32Array.
206
+ const layerId = state.layer_id;
207
+ const tileHandle = state.tile_handle;
208
+ let slot = byLayer.get(layerId);
209
+ if (!slot) {
210
+ // Rust returns a state for every vector layer with a rendered tile, including
211
+ // non-draped layers (those render in the MRT scene and never create a texturized
212
+ // scene). Only a layer that actually has a draped scene may consume a bake slot;
213
+ // otherwise non-draped layers would occupy the limited slot budget and crowd out
214
+ // genuinely-draped layers past the cap. A draped layer whose scene isn't ready yet
215
+ // simply gets no slot this pass and is picked up once its scene exists.
216
+ const hasScene =
217
+ this.host.texturizedScenes.findSceneByLayerId(tileHandle, layerId) !=
218
+ null;
219
+ if (!hasScene) {
220
+ state.free();
221
+ continue;
222
+ }
223
+ // Cap the layer fan-out to the GPU slot budget; extra layers are dropped.
224
+ if (byLayer.size >= this.host.numTexturizedVector) {
225
+ droppedLayers.add(layerId);
226
+ state.free();
227
+ continue;
228
+ }
229
+ const reproject = state.reproject_terrain_lat;
230
+ slot = {
231
+ layerId,
232
+ sources: [],
233
+ reproject:
234
+ reproject.length >= 2 ? [reproject[0], reproject[1]] : undefined,
235
+ };
236
+ byLayer.set(layerId, slot);
237
+ }
238
+ const uvOffset = state.uv_offset;
239
+ const uvScale = state.uv_scale;
240
+ slot.sources.push({
241
+ tileHandle,
242
+ uvOffset: [uvOffset[0] ?? 0, uvOffset[1] ?? 0],
243
+ uvScale: [uvScale[0] ?? 1, uvScale[1] ?? 1],
244
+ });
245
+ // wasm-bindgen objects hold Rust-heap memory; release it deterministically
246
+ // instead of waiting for the GC's finalizer (see guide/WASM_API_POLICY.md).
247
+ state.free();
248
+ }
249
+
250
+ if (droppedLayers.size > 0) {
251
+ if (!this.warnedExceededTextures) {
252
+ this.warnedExceededTextures = true;
253
+ console.warn(
254
+ `[TileMesh] Exceeded maximum MVT texture slots: ${byLayer.size + droppedLayers.size} layers requested, ` +
255
+ `but only ${this.host.numTexturizedVector} slots available. ` +
256
+ `Some MVT layers will not be rendered.`,
257
+ );
258
+ }
259
+ } else {
260
+ this.warnedExceededTextures = false;
261
+ }
262
+
263
+ this.vectorSlots = [...byLayer.values()];
264
+ this.syncRenderTargets();
265
+ }
266
+
267
+ /**
268
+ * Size the drape render-target pool to the live slot count (already capped to
269
+ * `numTexturizedVector` by {@link refreshSlots}): allocate the ones a
270
+ * newly-draped layer needs, dispose the ones a vanished layer freed. Then
271
+ * report the pool's GPU footprint to the memory ledger so it tracks drape
272
+ * memory that scales with terrain subdivision past the vector maxZoom — a cost
273
+ * per-vector-tile accounting cannot see. Reports only when the count changes.
274
+ */
275
+ private syncRenderTargets() {
276
+ const target = this.vectorSlots.length;
277
+ const rts = this.renderTargets;
278
+ while (rts.length < target) {
279
+ const rt = new WebGLRenderTarget(
280
+ this.host.drapeRtSize,
281
+ this.host.drapeRtSize,
282
+ {
283
+ format: RGBAFormat,
284
+ },
285
+ );
286
+ // Match the sampler settings syncMaterialSlots applies to rebound RTs,
287
+ // so the lazily-grown one binds identically when bindSlots picks it up.
288
+ configureTexturizedTexture(rt.texture, this.host.textureOptions);
289
+ rts.push(rt);
290
+ }
291
+ while (rts.length > target) {
292
+ rts.pop()?.dispose();
293
+ }
294
+
295
+ this.host.reportDrapeGpuBytes();
296
+ }
297
+
298
+ /**
299
+ * Identity of the current slot set + the backing scenes' content. A change means
300
+ * the offscreen scenes must be re-baked and the per-slot material state re-bound.
301
+ */
302
+ private signature(): string {
303
+ return this.vectorSlots
304
+ .map((slot) => {
305
+ const sourceSig = slot.sources
306
+ .map((s) => {
307
+ const scene = this.host.texturizedScenes.findSceneByLayerId(
308
+ s.tileHandle,
309
+ slot.layerId,
310
+ );
311
+ return `${s.tileHandle}@${s.uvOffset[0]},${s.uvOffset[1]},${s.uvScale[0]},${s.uvScale[1]}#${scene?.revision ?? -1}:${scene?.children.length ?? 0}`;
312
+ })
313
+ .join(",");
314
+ return `${slot.layerId}[${sourceSig}]`;
315
+ })
316
+ .join("|");
317
+ }
318
+
319
+ /**
320
+ * Drive each vector slot's main-shader state: texture, per-slot UV transform,
321
+ * visibility and the draped mesh's material attributes. One slot per layer,
322
+ * sourced from the WM vector tile the Rust resolve picked. The resolve walks
323
+ * up to the nearest rendered tile (readiness derived from ECS activation /
324
+ * the Rust resolve, not a JS-side scene_ready flag), so the slot's scene is
325
+ * always bakeable (its own render target was framed by the bake) and the UV
326
+ * is identity — the ancestor LOD fallback lives entirely in Rust.
327
+ */
328
+ private bindSlots() {
329
+ const m = this.host.material();
330
+ if (!m || !m.userData || !m.userData.textures?.value) return;
331
+ const textures = m.userData.textures.value;
332
+ const uvOffsets: Vector2[] = m.userData.layerUvOffset?.value ?? [];
333
+ const uvScales: Vector2[] = m.userData.layerUvScale?.value ?? [];
334
+
335
+ for (let i = 0; i < this.host.numTexturizedVector; i++) {
336
+ const lastIdx = this.host.texturizedSceneIndexFrom + i;
337
+ const ownRt = this.renderTargets[i];
338
+ // Slots past the live pool (lazily sized to the draped-layer count) have
339
+ // no render target: hide the slot and rebind the shared empty texture so
340
+ // the shader never samples a stale / disposed texture from a layer that
341
+ // is no longer draped here.
342
+ if (!ownRt) {
343
+ m.userData.shows.value[lastIdx] = 0;
344
+ textures[lastIdx] = EMPTY_TEXTURIZED_TEXTURE;
345
+ continue;
346
+ }
347
+
348
+ textures[lastIdx] = ownRt.texture;
349
+ // The bake framed every source into this RT so it spans the terrain tile's
350
+ // extent: the paste samples it 1:1 in longitude (identity UV here); the
351
+ // latitude axis is reprojected WebMercator→Geographic in the composite
352
+ // shader via the slot's reproject band (see `slotReproject`).
353
+ uvOffsets[lastIdx]?.set(0, 0);
354
+ uvScales[lastIdx]?.set(1, 1);
355
+
356
+ const slot = this.vectorSlots[i];
357
+ const mesh = slot ? this.representativeMesh(slot) : undefined;
358
+ m.userData.shows.value[lastIdx] = mesh ? 1 : 0;
359
+ if (mesh) this.copyMeshAttrs(lastIdx, mesh);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * First available draped mesh backing any of a layer's sources. A clamp-to-ground
365
+ * layer's material attributes (water/specular/emissive/…) are uniform across its
366
+ * tiles, so any source's mesh is a faithful representative for the slot.
367
+ */
368
+ private representativeMesh(slot: VectorSlot): Object3D | undefined {
369
+ for (const source of slot.sources) {
370
+ const scene = this.host.texturizedScenes.findSceneByLayerId(
371
+ source.tileHandle,
372
+ slot.layerId,
373
+ );
374
+ if (scene && !scene.removed && scene.children.length) {
375
+ return scene.children[0];
376
+ }
377
+ }
378
+ return undefined;
379
+ }
380
+
381
+ /** Copy a draped mesh's material attributes into the main shader's slot `lastIdx`. */
382
+ private copyMeshAttrs(lastIdx: number, mesh: Object3D) {
383
+ const m = this.host.material();
384
+ if (mesh instanceof PolygonMesh) {
385
+ // Use PolygonMesh getters that expose material enhancer state
386
+ m.userData.reflectivities.value[lastIdx] = mesh.reflectivity;
387
+ m.userData.roughnesses.value[lastIdx] = mesh.roughness;
388
+ m.userData.waters.value[lastIdx] = mesh.water;
389
+ m.userData.waterScaleNormals.value[lastIdx] = mesh.waterScaleNormal;
390
+ m.userData.waterSpeeds.value[lastIdx] = mesh.waterSpeed;
391
+ m.userData.shininesses.value[lastIdx] = mesh.shininess;
392
+ m.userData.specularStrengths.value[lastIdx] = mesh.specularStrength;
393
+ m.userData.applyWaterNormals.value[lastIdx] = mesh.applyWaterNormal;
394
+ m.userData.speculars.value[lastIdx] = mesh.specular;
395
+ m.userData.emissiveIntensities.value[lastIdx] = mesh.emissiveIntensity;
396
+ m.userData.emissiveColors.value[lastIdx].set(mesh.emissiveColor);
397
+ m.userData.effectIdsMasks.value[lastIdx] = mesh.effectIdsMask;
398
+ } else if (mesh instanceof PolylineMesh) {
399
+ m.userData.emissiveIntensities.value[lastIdx] = mesh.emissiveIntensity;
400
+ m.userData.emissiveColors.value[lastIdx].set(mesh.emissiveColor);
401
+ m.userData.effectIdsMasks.value[lastIdx] = mesh.effectIdsMask;
402
+ }
403
+ }
404
+ }
@@ -46,6 +46,7 @@ describe("TileTextureCache.acquire", () => {
46
46
  expect(reasons?.has("material")).toBe(true);
47
47
  expect(reasons?.has("texture-binding")).toBe(true);
48
48
  expect(reasons?.has("vector-revision")).toBe(true);
49
+ expect(reasons?.has("raster-revision")).toBe(true);
49
50
  expect(reasons?.has("hillshade")).toBe(true);
50
51
  });
51
52
  });
@@ -108,6 +109,7 @@ describe("TileTextureCache atlas pooling", () => {
108
109
  expect(reasons?.has("material")).toBe(true);
109
110
  expect(reasons?.has("texture-binding")).toBe(true);
110
111
  expect(reasons?.has("vector-revision")).toBe(true);
112
+ expect(reasons?.has("raster-revision")).toBe(true);
111
113
  expect(reasons?.has("hillshade")).toBe(true);
112
114
  });
113
115
 
@@ -68,6 +68,7 @@ export class TileTextureCache {
68
68
  "material",
69
69
  "texture-binding",
70
70
  "vector-revision",
71
+ "raster-revision",
71
72
  "hillshade",
72
73
  ]),
73
74
  };
@@ -258,3 +258,108 @@ describe("TileTextureCompositor.renderVectorScenes", () => {
258
258
  expect(renderer.clear).toHaveBeenCalledTimes(1);
259
259
  });
260
260
  });
261
+
262
+ describe("TileTextureCompositor.renderRasterTiles", () => {
263
+ const fakeRT = (): unknown => ({ texture: { needsUpdate: false } });
264
+ // A bake source texture: `configureRasterBakeSource` mutates these sampler
265
+ // fields in place, so a plain object stands in for a three.js Texture.
266
+ const fakeTex = (name: string): unknown => ({
267
+ name,
268
+ colorSpace: "",
269
+ minFilter: 0,
270
+ magFilter: 0,
271
+ generateMipmaps: false,
272
+ needsUpdate: false,
273
+ });
274
+ /** The texture bound to the bake quad at each render call, in draw order. */
275
+ const trackDrawnMaps = (renderer: ReturnType<typeof makeRenderer>) => {
276
+ const maps: unknown[] = [];
277
+ renderer.render.mockImplementation((scene) => {
278
+ const quad = (scene as { children: { material: { map: unknown } }[] })
279
+ .children[0];
280
+ maps.push(quad.material.map);
281
+ });
282
+ return maps;
283
+ };
284
+
285
+ it("paints the no-data underlay across a heatmap target before its sources", () => {
286
+ const { compositor, renderer } = setup();
287
+ const maps = trackDrawnMaps(renderer);
288
+ const src = fakeTex("dem");
289
+
290
+ compositor.renderRasterTiles(
291
+ [
292
+ {
293
+ isElevationHeatmap: true,
294
+ noDataColor: [1, 2, 3],
295
+ sources: [
296
+ { texture: src as never, uvOffset: [0, 0], uvScale: [1, 1] },
297
+ ],
298
+ },
299
+ ],
300
+ [fakeRT() as never],
301
+ );
302
+
303
+ // First draw: the 1×1 no-data texture (decoder boundary color, opaque
304
+ // alpha) spanning the whole target; the DEM source then overwrites the
305
+ // covered region, leaving uncovered regions decoding as no-data.
306
+ expect(maps).toHaveLength(2);
307
+ const underlay = maps[0] as { image: { data: Uint8Array } };
308
+ expect(Array.from(underlay.image.data)).toEqual([1, 2, 3, 255]);
309
+ expect(maps[1]).toBe(src);
310
+ // The underlay is framed over the full target (identity camera window).
311
+ const cam = renderer.render.mock.calls[0][1] as {
312
+ left: number;
313
+ right: number;
314
+ bottom: number;
315
+ top: number;
316
+ };
317
+ expect([cam.left, cam.right, cam.bottom, cam.top]).toEqual([-1, 1, -1, 1]);
318
+ // Cleared once: underlay and source accumulate into the same target.
319
+ expect(renderer.clear).toHaveBeenCalledTimes(1);
320
+ });
321
+
322
+ it("draws sources coarse-first so finer tiles overwrite the ancestor", () => {
323
+ const { compositor, renderer } = setup();
324
+ const maps = trackDrawnMaps(renderer);
325
+ // A finer-than-terrain tile (uvScale > 1) and a coarse ancestor fallback
326
+ // (uvScale < 1) covering the whole rect, listed fine-first: the bake must
327
+ // reorder by ascending uvScale or the ancestor buries the finer tile.
328
+ const fine = fakeTex("fine");
329
+ const coarse = fakeTex("coarse");
330
+
331
+ compositor.renderRasterTiles(
332
+ [
333
+ {
334
+ isElevationHeatmap: false,
335
+ sources: [
336
+ { texture: fine as never, uvOffset: [0, 0], uvScale: [2, 2] },
337
+ {
338
+ texture: coarse as never,
339
+ uvOffset: [0, 0],
340
+ uvScale: [0.5, 0.5],
341
+ },
342
+ ],
343
+ },
344
+ ],
345
+ [fakeRT() as never],
346
+ );
347
+
348
+ expect(maps).toEqual([coarse, fine]);
349
+ expect(renderer.clear).toHaveBeenCalledTimes(1);
350
+ });
351
+
352
+ it("skips a never-touched render target that has no slot (no GPU allocation)", () => {
353
+ const { compositor, renderer } = setup();
354
+
355
+ const rt = fakeRT();
356
+ compositor.renderRasterTiles([], [rt as never]);
357
+
358
+ // Render-targeting the RT would create its GL framebuffer; an empty slot
359
+ // whose target was never baked must not pay that cost (same invariant as
360
+ // the vector bake — both run through the shared bake loop).
361
+ expect(renderer.setRenderTarget).not.toHaveBeenCalledWith(rt);
362
+ expect(renderer.render).not.toHaveBeenCalled();
363
+ expect(renderer.clear).not.toHaveBeenCalled();
364
+ });
365
+ });