@navaramap/three 0.0.6 → 0.0.7

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 (35) hide show
  1. package/dist/assets/fontWorker-DaqfXmZf.js +2 -0
  2. package/dist/assets/fontWorker-DaqfXmZf.js.map +1 -0
  3. package/dist/assets/index-Dydat1h1.js +25 -0
  4. package/dist/assets/index-Dydat1h1.js.map +1 -0
  5. package/dist/assets/{navara_wasm_font_worker_bg-DympKYVX.wasm → navara_wasm_font_worker_bg-XvE_oYPr.wasm} +0 -0
  6. package/dist/assets/{navara_wasm_worker_bg-Bx_l45cW.wasm → navara_wasm_worker_bg-Dg9fnGLP.wasm} +0 -0
  7. package/dist/index.d.ts +140 -4
  8. package/dist/index.js +5385 -5022
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.umd.cjs +164 -86
  11. package/dist/index.umd.cjs.map +1 -1
  12. package/package.json +12 -12
  13. package/src/declutter/DeclutterManager.test.ts +65 -0
  14. package/src/declutter/DeclutterManager.ts +60 -1
  15. package/src/declutter/types.ts +17 -0
  16. package/src/event/feature.ts +45 -2
  17. package/src/event/worker.ts +18 -0
  18. package/src/index.ts +51 -2
  19. package/src/layers/effect/TransparentPassEffectDesc.ts +1 -1
  20. package/src/mesh/nvLineGeometry.test.ts +88 -0
  21. package/src/mesh/nvLineGeometry.ts +148 -0
  22. package/src/mesh/polygonOutline.ts +126 -42
  23. package/src/mesh/sdfText/batchedSdfText.test.ts +377 -0
  24. package/src/mesh/sdfText/batchedSdfText.ts +192 -21
  25. package/src/mesh/sdfText/labelVisibility.test.ts +106 -0
  26. package/src/mesh/sdfText/labelVisibility.ts +131 -0
  27. package/src/mesh/sdfText/pendingSettlement.test.ts +151 -0
  28. package/src/mesh/sdfText/pendingSettlement.ts +68 -0
  29. package/src/pick/pickTerrain.ts +22 -18
  30. package/src/terrain/sampleTerrainMostDetailed.test.ts +236 -0
  31. package/src/terrain/sampleTerrainMostDetailed.ts +447 -0
  32. package/dist/assets/fontWorker-JfTCpd38.js +0 -2
  33. package/dist/assets/fontWorker-JfTCpd38.js.map +0 -1
  34. package/dist/assets/index-B3U_DvcP.js +0 -25
  35. package/dist/assets/index-B3U_DvcP.js.map +0 -1
@@ -0,0 +1,377 @@
1
+ import type {
2
+ TextMesh as NavaraTextMesh,
3
+ TextMaterial as NavaraTextMaterial,
4
+ } from "@navaramap/engine";
5
+ import type { ShapeTextResult } from "@navaramap/font";
6
+ import { Color, PerspectiveCamera } from "three";
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ import type { EventContext } from "../../event/context";
10
+
11
+ import { BatchedSdfTextMesh } from "./batchedSdfText";
12
+ import { LabelRow, type LabelDataTexture } from "./labelData";
13
+
14
+ // `labelVisibility` derives its Earth radius band from the WASM ellipsoid
15
+ // getters, which only answer after `view.init()`. Keep the rest of the module
16
+ // real and stub just those two with the engine's f32 values.
17
+ vi.mock("@navaramap/three-api", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("@navaramap/three-api")>()),
19
+ getWGS84SemiMajorAxis: () => 6_378_137,
20
+ getWGS84SemiMinorAxis: () => 6_356_752.5,
21
+ }));
22
+
23
+ /** Drains chained `.then` callbacks of the async font-preparation path. */
24
+ const flushMicrotasks = async () => {
25
+ for (let i = 0; i < 10; i++) await Promise.resolve();
26
+ };
27
+
28
+ /** One square atlas glyph per character — enough for a non-empty layout. */
29
+ function shapeResult(text: string): ShapeTextResult {
30
+ return {
31
+ glyphs: [...text].map((_, i) => ({
32
+ glyphId: i + 1,
33
+ fontIndex: 0,
34
+ compositeKey: BigInt(i + 1),
35
+ xAdvance: 500,
36
+ yAdvance: 0,
37
+ xOffset: 0,
38
+ yOffset: 0,
39
+ charClass: 0,
40
+ })),
41
+ metrics: [...text].map((_, i) => ({
42
+ glyphId: i + 1,
43
+ fontIndex: 0,
44
+ compositeKey: BigInt(i + 1),
45
+ atlasX: i * 32,
46
+ atlasY: 0,
47
+ atlasW: 32,
48
+ atlasH: 32,
49
+ bearingX: 0,
50
+ bearingY: 32,
51
+ isColor: false,
52
+ })),
53
+ unitsPerEm: 1000,
54
+ ascender: 800,
55
+ descender: -200,
56
+ lineGap: 0,
57
+ };
58
+ }
59
+
60
+ function makeFontManager() {
61
+ return {
62
+ onAtlasEvicted: vi.fn(() => () => {}),
63
+ isTextPrepared: vi.fn(() => true),
64
+ prepareText: vi.fn(async () => {}),
65
+ shapeText: vi.fn((_font: string, text: string) => shapeResult(text)),
66
+ retainGlyphs: vi.fn(),
67
+ releaseGlyphs: vi.fn(),
68
+ getAtlasTexture: vi.fn(() => null),
69
+ getColorAtlasTexture: vi.fn(() => null),
70
+ isFamily: vi.fn(() => true),
71
+ loadFont: vi.fn(async () => {}),
72
+ unloadFont: vi.fn(async () => {}),
73
+ };
74
+ }
75
+
76
+ /** A layer material with the fields this fix gates on spelled out. */
77
+ function material(over: Partial<NavaraTextMaterial> = {}): NavaraTextMaterial {
78
+ return {
79
+ size: 16,
80
+ color: 0xffffff,
81
+ opacity: 1,
82
+ height: 0,
83
+ show: true,
84
+ ...over,
85
+ } as unknown as NavaraTextMaterial;
86
+ }
87
+
88
+ /** A two-feature text mesh event, as the engine hands it over. */
89
+ function textMeshEvent(mat: NavaraTextMaterial): NavaraTextMesh {
90
+ return {
91
+ material: mat,
92
+ transform: { tx: 0, ty: 0, tz: 0 },
93
+ geometry: {
94
+ batch_ids: { data: new Float32Array([0, 1]), size: 1 },
95
+ position: { data: new Float32Array([0, 0, 0, 10, 0, 0]), size: 3 },
96
+ },
97
+ } as unknown as NavaraTextMesh;
98
+ }
99
+
100
+ function makeMesh(mat = material()) {
101
+ const fontManager = makeFontManager();
102
+ const ctx = {
103
+ buf: { removeF32: (d: Float32Array) => d },
104
+ fontManager,
105
+ renderFlag: { forceUpdate: false },
106
+ } as unknown as EventContext;
107
+ const mesh = new BatchedSdfTextMesh(ctx, textMeshEvent(mat), "font", {
108
+ layerId: "layer",
109
+ });
110
+ return { mesh, fontManager };
111
+ }
112
+
113
+ // Read back what the vertex shader would sample, rather than the private
114
+ // LabelRecord — these are the writes the fix is about.
115
+ const store = (mesh: BatchedSdfTextMesh) =>
116
+ (mesh as unknown as { _labelData: LabelDataTexture })._labelData;
117
+ const sizeOf = (mesh: BatchedSdfTextMesh, slot: number) =>
118
+ store(mesh).getComponent(slot, LabelRow.POSITION_HIGH_SIZE, 3);
119
+ const heightOf = (mesh: BatchedSdfTextMesh, slot: number) =>
120
+ store(mesh).getComponent(slot, LabelRow.POSITION_LOW_HEIGHT, 3);
121
+ const opacityOf = (mesh: BatchedSdfTextMesh, slot: number) =>
122
+ store(mesh).getComponent(slot, LabelRow.COLOR_OPACITY, 3);
123
+ const redOf = (mesh: BatchedSdfTextMesh, slot: number) =>
124
+ store(mesh).getComponent(slot, LabelRow.COLOR_OPACITY, 0);
125
+ const showOf = (mesh: BatchedSdfTextMesh, slot: number) =>
126
+ store(mesh).getComponent(slot, LabelRow.STATE, 2);
127
+
128
+ // The engine re-sends the whole material on any change event — including ones
129
+ // that only moved geometry (terrain heights resolving) or flipped activation.
130
+ // Overwriting per-feature values from an unchanged material made every such
131
+ // event reset evaluator-set styling to the layer default for a few frames,
132
+ // pulsing label sizes until the app's evaluator ran again.
133
+ describe("BatchedSdfTextMesh material updates vs per-feature values", () => {
134
+ it("leaves evaluator-set values alone when nothing in the material changed", async () => {
135
+ const { mesh } = makeMesh();
136
+ mesh.setFeatureSizeByBatchIndex(0, 32);
137
+ mesh.setFeatureHeightByBatchIndex(0, 50);
138
+ mesh.setFeatureOpacityByBatchIndex(0, 0.25);
139
+ mesh.setFeatureColorByBatchIndex(0, new Color(0xff0000));
140
+
141
+ // A fresh material object carrying identical values (a terrain-height event).
142
+ await mesh._update(textMeshEvent(material()));
143
+
144
+ expect(sizeOf(mesh, 0)).toBe(32);
145
+ expect(heightOf(mesh, 0)).toBe(50);
146
+ expect(opacityOf(mesh, 0)).toBe(0.25);
147
+ expect(redOf(mesh, 0)).toBe(1);
148
+ });
149
+
150
+ it("keeps an evaluator-set show through an unchanged material", async () => {
151
+ const { mesh } = makeMesh();
152
+ mesh.setTextByBatchIndex(0, "AB");
153
+ expect(showOf(mesh, 0)).toBe(1);
154
+ mesh.setFeatureShowByBatchIndex(0, false);
155
+ expect(showOf(mesh, 0)).toBe(0);
156
+
157
+ await mesh._update(textMeshEvent(material()));
158
+
159
+ expect(showOf(mesh, 0)).toBe(0);
160
+ });
161
+
162
+ it("still applies a genuinely changed material value to every label", async () => {
163
+ const { mesh } = makeMesh();
164
+ mesh.setFeatureSizeByBatchIndex(0, 32);
165
+ mesh.setFeatureSizeByBatchIndex(1, 8);
166
+
167
+ // A real `layer.update`: the new default overrides per-feature sizes.
168
+ await mesh._update(textMeshEvent(material({ size: 24 })));
169
+
170
+ expect(sizeOf(mesh, 0)).toBe(24);
171
+ expect(sizeOf(mesh, 1)).toBe(24);
172
+ });
173
+
174
+ it("applies a changed material show to every label", async () => {
175
+ const { mesh } = makeMesh();
176
+ mesh.setTextByBatchIndex(0, "AB");
177
+ mesh.setFeatureShowByBatchIndex(0, false);
178
+
179
+ await mesh._update(textMeshEvent(material({ show: false })));
180
+ expect(showOf(mesh, 0)).toBe(0);
181
+
182
+ await mesh._update(textMeshEvent(material({ show: true })));
183
+ expect(showOf(mesh, 0)).toBe(1);
184
+ });
185
+
186
+ it("does not reset unrelated per-feature values when one field changes", async () => {
187
+ const { mesh } = makeMesh();
188
+ mesh.setFeatureSizeByBatchIndex(0, 32);
189
+ mesh.setFeatureHeightByBatchIndex(0, 50);
190
+
191
+ // Only the colour moved; size and height must survive it.
192
+ await mesh._update(textMeshEvent(material({ color: 0x00ff00 })));
193
+
194
+ expect(sizeOf(mesh, 0)).toBe(32);
195
+ expect(heightOf(mesh, 0)).toBe(50);
196
+ expect(redOf(mesh, 0)).toBe(0);
197
+ });
198
+
199
+ it("compares against the pre-update material on the async font path", async () => {
200
+ const { mesh, fontManager } = makeMesh();
201
+ mesh.setFeatureSizeByBatchIndex(0, 32);
202
+ // Force the branch that defers _applyUpdate behind prepareText: the
203
+ // comparison must use the material captured before `this._material` was
204
+ // reassigned, or the size change is lost by the time the promise lands.
205
+ fontManager.isTextPrepared.mockReturnValueOnce(false);
206
+
207
+ await mesh._update(textMeshEvent(material({ text: "AB", size: 24 })));
208
+ await flushMicrotasks();
209
+
210
+ expect(fontManager.prepareText).toHaveBeenCalled();
211
+ expect(sizeOf(mesh, 0)).toBe(24);
212
+ });
213
+ });
214
+
215
+ // Unprepared text costs a worker round-trip and font-face fetches, and a
216
+ // low-zoom tile spans far more world than the screen shows. With declutter on,
217
+ // `setTextByBatchIndex` therefore parks preparation until a placement pass
218
+ // confirms the anchor could actually appear on screen.
219
+ describe("BatchedSdfTextMesh deferred font preparation", () => {
220
+ /** Camera framing the origin, so anchors near it count as in view. */
221
+ function makeCamera(lookAtZ = 0): PerspectiveCamera {
222
+ const cam = new PerspectiveCamera(60, 1, 1, 1e9);
223
+ cam.position.set(0, 0, 100);
224
+ cam.lookAt(0, 0, lookAtZ);
225
+ cam.updateMatrixWorld(true);
226
+ cam.updateProjectionMatrix();
227
+ return cam;
228
+ }
229
+
230
+ /**
231
+ * Font manager whose `isTextPrepared` starts false and flips once
232
+ * `prepareText` resolves, as the real one does — a fake that answers false
233
+ * forever would make the re-prepare-on-show path loop.
234
+ */
235
+ function makeLazyFontManager() {
236
+ const prepared = new Set<string>();
237
+ const fontManager = {
238
+ ...makeFontManager(),
239
+ isTextPrepared: vi.fn((_font: string, text: string) =>
240
+ prepared.has(text),
241
+ ),
242
+ prepareText: vi.fn(async (_font: string, text: string) => {
243
+ prepared.add(text);
244
+ }),
245
+ };
246
+ return { fontManager, prepared };
247
+ }
248
+
249
+ function makeCtx(fontManager: unknown, declutter?: unknown) {
250
+ return {
251
+ buf: { removeF32: (d: Float32Array) => d },
252
+ fontManager,
253
+ declutter,
254
+ renderFlag: { forceUpdate: false },
255
+ } as unknown as EventContext;
256
+ }
257
+
258
+ /** As `makeMesh`, but with a declutter manager present so parking engages. */
259
+ function makeDeclutteredMesh() {
260
+ const { fontManager, prepared } = makeLazyFontManager();
261
+ const declutter = {
262
+ register: vi.fn(),
263
+ unregister: vi.fn(),
264
+ markDirty: vi.fn(),
265
+ wasRecentlyShown: vi.fn(() => false),
266
+ };
267
+ const mesh = new BatchedSdfTextMesh(
268
+ makeCtx(fontManager, declutter),
269
+ textMeshEvent(material()),
270
+ "font",
271
+ { layerId: "layer" },
272
+ );
273
+ return { mesh, fontManager, prepared };
274
+ }
275
+
276
+ const deferredCount = (mesh: BatchedSdfTextMesh) =>
277
+ (mesh as unknown as { _deferredCount: number })._deferredCount;
278
+
279
+ it("does not start preparation when declutter is enabled", async () => {
280
+ const { mesh, fontManager } = makeDeclutteredMesh();
281
+
282
+ mesh.setTextByBatchIndex(0, "Tokyo");
283
+ await flushMicrotasks();
284
+
285
+ expect(fontManager.prepareText).not.toHaveBeenCalled();
286
+ expect(deferredCount(mesh)).toBe(1);
287
+ // Nothing drawable yet, so the label stays hidden.
288
+ expect(showOf(mesh, 0)).toBe(0);
289
+ });
290
+
291
+ it("prepares immediately when there is no declutter pass to park on", async () => {
292
+ const { fontManager } = makeLazyFontManager();
293
+ const mesh = new BatchedSdfTextMesh(
294
+ makeCtx(fontManager),
295
+ textMeshEvent(material()),
296
+ "font",
297
+ { layerId: "layer" },
298
+ );
299
+
300
+ mesh.setTextByBatchIndex(0, "Tokyo");
301
+ await flushMicrotasks();
302
+
303
+ expect(fontManager.prepareText).toHaveBeenCalledTimes(1);
304
+ });
305
+
306
+ it("promotes a parked label once its anchor is potentially visible", async () => {
307
+ const { mesh, fontManager } = makeDeclutteredMesh();
308
+ mesh.setActive(true);
309
+ mesh.setTextByBatchIndex(0, "Tokyo");
310
+ await flushMicrotasks();
311
+ expect(fontManager.prepareText).not.toHaveBeenCalled();
312
+
313
+ // Anchor 0 sits at the origin; this camera frames it.
314
+ mesh.prepareDeferredLabels(makeCamera());
315
+ await flushMicrotasks();
316
+
317
+ expect(fontManager.prepareText).toHaveBeenCalledTimes(1);
318
+ expect(deferredCount(mesh)).toBe(0);
319
+ // The promoted text is applied, so the label becomes drawable.
320
+ expect(showOf(mesh, 0)).toBe(1);
321
+ });
322
+
323
+ it("leaves a parked label alone while its anchor is out of view", async () => {
324
+ const { mesh, fontManager } = makeDeclutteredMesh();
325
+ mesh.setActive(true);
326
+ mesh.setTextByBatchIndex(0, "Tokyo");
327
+ await flushMicrotasks();
328
+
329
+ // Looking away puts the anchor behind the camera.
330
+ mesh.prepareDeferredLabels(makeCamera(500));
331
+ await flushMicrotasks();
332
+
333
+ expect(fontManager.prepareText).not.toHaveBeenCalled();
334
+ // Still parked, so a later pass can promote it.
335
+ expect(deferredCount(mesh)).toBe(1);
336
+ });
337
+
338
+ // Promotion requires `this.visible`, which the tile-LOD swap only sets after
339
+ // the batch reports rendered — and that report waits on `whenLabelsSettled`.
340
+ // Counting parked labels as "in flight" would therefore deadlock that cycle
341
+ // until the timeout, stalling every text tile's swap. The gate deliberately
342
+ // covers only started preparations; see whenLabelsSettled's docs.
343
+ it("does not let parked labels hold the render-completion gate", async () => {
344
+ const { mesh } = makeDeclutteredMesh();
345
+ mesh.setTextByBatchIndex(0, "Tokyo");
346
+ await flushMicrotasks();
347
+
348
+ let settled = false;
349
+ void mesh.whenLabelsSettled(2000).then(() => (settled = true));
350
+ await flushMicrotasks();
351
+
352
+ expect(settled).toBe(true);
353
+ });
354
+
355
+ it("holds the gate while a promoted preparation is in flight", async () => {
356
+ const { mesh, fontManager } = makeDeclutteredMesh();
357
+ let finishPrepare!: () => void;
358
+ fontManager.prepareText = vi.fn(
359
+ (_font: string, _text: string) =>
360
+ new Promise<void>((r) => (finishPrepare = r)),
361
+ );
362
+
363
+ mesh.setActive(true);
364
+ mesh.setTextByBatchIndex(0, "Tokyo");
365
+ mesh.prepareDeferredLabels(makeCamera());
366
+ await flushMicrotasks();
367
+
368
+ let settled = false;
369
+ void mesh.whenLabelsSettled(2000).then(() => (settled = true));
370
+ await flushMicrotasks();
371
+ expect(settled).toBe(false);
372
+
373
+ finishPrepare();
374
+ await flushMicrotasks();
375
+ expect(settled).toBe(true);
376
+ });
377
+ });