@navaramap/three 0.0.2 → 0.0.4

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 (45) hide show
  1. package/README.md +7 -7
  2. package/dist/assets/{fontWorker-8oWvyvwL.js → fontWorker-wz61edjR.js} +2 -2
  3. package/dist/assets/{fontWorker-8oWvyvwL.js.map → fontWorker-wz61edjR.js.map} +1 -1
  4. package/dist/assets/{index-D9G7kIEj.js → index-ywHSGImj.js} +3 -3
  5. package/dist/assets/{index-D9G7kIEj.js.map → index-ywHSGImj.js.map} +1 -1
  6. package/dist/assets/{navara_wasm_font_worker_bg-CkZP9l2i.wasm → navara_wasm_font_worker_bg-Du9nb2Pn.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-Q8zrkLCR.wasm → navara_wasm_worker_bg-Xadu67fw.wasm} +0 -0
  8. package/dist/index.d.ts +135 -4
  9. package/dist/index.js +4309 -4288
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.umd.cjs +223 -210
  12. package/dist/index.umd.cjs.map +1 -1
  13. package/package.json +12 -12
  14. package/src/ShadowMapViewers.ts +1 -1
  15. package/src/atmosphere.ts +43 -1
  16. package/src/declutter/DeclutterManager.ts +1 -1
  17. package/src/event/feature.ts +43 -13
  18. package/src/event/features/model.ts +1 -1
  19. package/src/event/features/polygon.ts +2 -1
  20. package/src/event/index.ts +1 -1
  21. package/src/index.iife.ts +3 -3
  22. package/src/index.ts +3 -3
  23. package/src/input.ts +48 -15
  24. package/src/layers/effect/README.md +1 -1
  25. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +3 -2
  26. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +2 -0
  27. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/types.ts +2 -0
  28. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +2 -0
  29. package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +5 -1
  30. package/src/material/enhancer/polygon/polygonBaseEnhancer/types.ts +2 -0
  31. package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +2 -0
  32. package/src/material/enhancer/polyline/polylineBaseEnhancer/types.ts +2 -0
  33. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +2 -0
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +2 -0
  35. package/src/material/overrideMaterialsForMRT.ts +7 -2
  36. package/src/mesh/batchedSdfText.ts +234 -70
  37. package/src/mesh/instanced.ts +1 -1
  38. package/src/mesh/polygon.ts +11 -4
  39. package/src/mesh/polyline.ts +10 -2
  40. package/src/mesh/rtcRteHelper.ts +1 -1
  41. package/src/mesh/sdfText.ts +7 -2
  42. package/src/mesh/sprite/instancedSprite.ts +1 -1
  43. package/src/solar.test.ts +33 -0
  44. package/src/solar.ts +31 -0
  45. package/src/utils/screen.ts +1 -1
@@ -3,7 +3,7 @@ import type {
3
3
  TextMaterial as NavaraTextMaterial,
4
4
  } from "@navaramap/engine";
5
5
  import type { FontManager } from "@navaramap/font";
6
- import { type Color } from "three";
6
+ import { Color } from "three";
7
7
  import invariant from "tiny-invariant";
8
8
 
9
9
  import type {
@@ -38,6 +38,22 @@ type PositionsInfo = PositionsInfoBase &
38
38
  }
39
39
  );
40
40
 
41
+ /**
42
+ * Per-feature evaluator state received before the feature's `SDFTextMesh`
43
+ * exists (meshes are created lazily on the first non-empty text — see
44
+ * `initMeshes`). Replayed onto the mesh at creation. Colors are kept as hex
45
+ * so a shared evaluator `Color` instance mutated later can't leak in.
46
+ */
47
+ type PendingFeatureState = {
48
+ colorHex?: number;
49
+ show?: boolean;
50
+ height?: number;
51
+ size?: number;
52
+ opacity?: number;
53
+ declutterPriority?: number | undefined;
54
+ hasDeclutterPriority?: boolean;
55
+ };
56
+
41
57
  export class BatchedSdfTextMesh
42
58
  extends InstancedMesh<SDFTextMesh>
43
59
  implements PickableMesh, DeclutterParticipant
@@ -59,6 +75,15 @@ export class BatchedSdfTextMesh
59
75
  * and must be balanced with unloadFont() on dispose or font change.
60
76
  */
61
77
  private _loadedFaceUrls: Set<string>;
78
+ /** Geometry-extracted anchor data; per-index meshes slice it lazily. */
79
+ private _positions: PositionsInfo | null = null;
80
+ /** Sparse batchIndex → mesh; `allMeshes` holds only created meshes. */
81
+ private _meshByIndex: (SDFTextMesh | undefined)[] = [];
82
+ /** Evaluator state received before a feature's mesh exists. */
83
+ private _pending = new Map<number, PendingFeatureState>();
84
+ /** Material/transform used when creating meshes lazily. */
85
+ private _material: NavaraTextMaterial;
86
+ private _transform: NavaraTextMesh["transform"];
62
87
 
63
88
  constructor(
64
89
  ctx: EventContext,
@@ -74,7 +99,12 @@ export class BatchedSdfTextMesh
74
99
  invariant(ctx.fontManager);
75
100
  this._fontManager = ctx.fontManager;
76
101
  this._loadedFaceUrls = loadedFaceUrls ?? new Set();
77
- this.initMeshes(m);
102
+ // One getter call each: wasm getters clone, so these snapshots are owned
103
+ // by this batch and safe to use after the event object is freed.
104
+ this._material = m.material;
105
+ this._transform = m.transform;
106
+ this._positions = this.extractPositions(m);
107
+ this.initMeshes();
78
108
  // When the shared atlas evicts glyphs, a still-in-flight glyph this batch
79
109
  // already baked into a visible mesh may have had its rect reused. Rebuild
80
110
  // any such stale mesh so its UVs and retains refresh.
@@ -167,73 +197,149 @@ export class BatchedSdfTextMesh
167
197
  return this._highQuality;
168
198
  }
169
199
 
170
- private initMeshes(m: NavaraTextMesh) {
171
- const positionInfo = this.extractPositions(m);
200
+ /**
201
+ * A mesh per anchor is expensive (geometry + ShaderMaterial + enhancer
202
+ * mount), and with per-feature texts (MVT) most features never receive a
203
+ * non-empty text — they'd sit invisible while still costing construction and
204
+ * every per-frame loop. So meshes are created lazily by `_ensureMeshAt` on
205
+ * the first non-empty `setTextByBatchIndex`. Only a non-empty material-level
206
+ * default text (single-label sources) renders without the evaluator ever
207
+ * setting text, so only that case creates all meshes eagerly.
208
+ */
209
+ private initMeshes() {
210
+ const positionInfo = this._positions;
172
211
  if (!positionInfo) {
173
212
  return;
174
213
  }
175
214
 
176
- const { position, nPositions, positionSize, batchIDs, batchIDSize, RTE } =
177
- positionInfo;
215
+ if (this._material.text) {
216
+ // The atlas can't be reallocated within this synchronous loop, so fetch
217
+ // the shared textures once and hand them to every creation.
218
+ const shared = this._sharedTextures();
219
+ for (let i = 0; i < positionInfo.nPositions; i++) {
220
+ this._ensureMeshAt(i, shared);
221
+ }
222
+ }
223
+ }
178
224
 
179
- const material = m.material;
180
- const transform = m.transform;
225
+ /** The font-level shared atlas textures for this batch's font+quality (the
226
+ * color atlas is `null` for monochrome fonts). */
227
+ private _sharedTextures() {
228
+ return {
229
+ tex: this._fontManager.getAtlasTexture(
230
+ this._fontIdentifier,
231
+ this._highQuality,
232
+ ),
233
+ colorTex: this._fontManager.getColorAtlasTexture(
234
+ this._fontIdentifier,
235
+ this._highQuality,
236
+ ),
237
+ };
238
+ }
181
239
 
182
- // Get the font-level shared atlas textures (one DataTexture per font,
183
- // shared across all per-feature meshes). The color atlas is `null` for
184
- // monochrome fonts.
185
- const sharedTex = this._fontManager.getAtlasTexture(
240
+ /**
241
+ * The feature's mesh, created on first need (see `initMeshes`). `shared`
242
+ * lets a batch creation pass hoist the atlas lookups; when omitted (a single
243
+ * lazy creation) they are fetched here, since the atlas may have been
244
+ * (re)allocated since this batch was constructed.
245
+ */
246
+ private _ensureMeshAt(
247
+ batchIndex: number,
248
+ shared?: ReturnType<BatchedSdfTextMesh["_sharedTextures"]>,
249
+ ): SDFTextMesh | undefined {
250
+ const existing = this._meshByIndex[batchIndex];
251
+ if (existing) return existing;
252
+
253
+ const info = this._positions;
254
+ if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
255
+ return undefined;
256
+ }
257
+
258
+ const { position, positionSize, batchIDs, batchIDSize, RTE } = info;
259
+ const batchId = batchIDs ? batchIDs[batchIndex * batchIDSize] : undefined;
260
+ const posIdx = batchIndex * positionSize;
261
+ const pos = RTE
262
+ ? {
263
+ high: position.high.subarray(posIdx, posIdx + positionSize),
264
+ low: position.low.subarray(posIdx, posIdx + positionSize),
265
+ }
266
+ : position.subarray(posIdx, posIdx + positionSize);
267
+
268
+ const mesh = new SDFTextMesh(
269
+ pos,
270
+ this._material,
271
+ this._transform,
272
+ this._fontManager,
186
273
  this._fontIdentifier,
274
+ batchId,
275
+ RTE,
276
+ // Pin quality to the batch, not `this._material.highQuality`, which a
277
+ // later `_update` may have changed (quality is immutable per batch).
187
278
  this._highQuality,
188
279
  );
189
- const sharedColorTex = this._fontManager.getColorAtlasTexture(
190
- this._fontIdentifier,
191
- this._highQuality,
280
+ mesh.renderOrder = this.renderOrder;
281
+ // Re-prepare evicted glyphs through the batch so font-family faces and
282
+ // refcounts stay consistent; request a render once they're rebuilt.
283
+ mesh.setGlyphLifecycleHandlers(
284
+ (text) =>
285
+ this._fontManager.prepareText(
286
+ this._fontIdentifier,
287
+ text,
288
+ this._highQuality,
289
+ this._loadedFaceUrls,
290
+ ),
291
+ () => this._needRender?.(),
192
292
  );
193
293
 
194
- for (let i = 0; i < nPositions; i++) {
195
- const batchIdIdx = i * batchIDSize;
196
- const batchId = batchIDs ? batchIDs[batchIdIdx] : undefined;
197
- const posIdx = i * positionSize;
198
- const pos = RTE
199
- ? {
200
- high: position.high.subarray(posIdx, posIdx + positionSize),
201
- low: position.low.subarray(posIdx, posIdx + positionSize),
202
- }
203
- : position.subarray(posIdx, posIdx + positionSize);
204
-
205
- const mesh = new SDFTextMesh(
206
- pos,
207
- material,
208
- transform,
209
- this._fontManager,
210
- this._fontIdentifier,
211
- batchId,
212
- RTE,
213
- );
214
- mesh.renderOrder = this.renderOrder;
215
- // Re-prepare evicted glyphs through the batch so font-family faces and
216
- // refcounts stay consistent; request a render once they're rebuilt.
217
- mesh.setGlyphLifecycleHandlers(
218
- (text) =>
219
- this._fontManager.prepareText(
220
- this._fontIdentifier,
221
- text,
222
- this._highQuality,
223
- this._loadedFaceUrls,
224
- ),
225
- () => this._needRender?.(),
226
- );
294
+ const { tex, colorTex } = shared ?? this._sharedTextures();
295
+ if (tex) {
296
+ mesh.setAtlasTexture(tex);
297
+ }
298
+ mesh.setColorAtlasTexture(colorTex);
227
299
 
228
- if (sharedTex) {
229
- mesh.setAtlasTexture(sharedTex);
300
+ mesh.update(this._material);
301
+
302
+ // Replay evaluator state that arrived before the mesh existed.
303
+ const pending = this._pending.get(batchIndex);
304
+ if (pending) {
305
+ this._pending.delete(batchIndex);
306
+ if (pending.colorHex !== undefined) {
307
+ mesh.setColor(new Color(pending.colorHex));
230
308
  }
231
- mesh.setColorAtlasTexture(sharedColorTex);
309
+ if (pending.show !== undefined) {
310
+ mesh._setFeatureShow(pending.show);
311
+ }
312
+ if (pending.height !== undefined) {
313
+ mesh.setHeight(pending.height);
314
+ }
315
+ if (pending.size !== undefined) {
316
+ mesh.setSize(pending.size);
317
+ }
318
+ if (pending.opacity !== undefined) {
319
+ mesh.setOpacity(pending.opacity);
320
+ }
321
+ if (pending.hasDeclutterPriority) {
322
+ mesh.setDeclutterPriority(pending.declutterPriority);
323
+ }
324
+ }
232
325
 
233
- mesh.update(material);
326
+ this._meshByIndex[batchIndex] = mesh;
327
+ this.addWithBatchIndex(mesh, batchIndex);
328
+ return mesh;
329
+ }
234
330
 
235
- this.addWithBatchIndex(mesh, i);
331
+ /** Pending-state slot for a feature whose mesh doesn't exist yet. */
332
+ private _pendingAt(batchIndex: number): PendingFeatureState | undefined {
333
+ const info = this._positions;
334
+ if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
335
+ return undefined;
336
+ }
337
+ let state = this._pending.get(batchIndex);
338
+ if (!state) {
339
+ state = {};
340
+ this._pending.set(batchIndex, state);
236
341
  }
342
+ return state;
237
343
  }
238
344
 
239
345
  async _update(m: NavaraTextMesh, needRender?: () => void) {
@@ -241,31 +347,40 @@ export class BatchedSdfTextMesh
241
347
 
242
348
  const material = m.material;
243
349
  const text = material.text ?? "";
350
+ // Later lazy creations must see the updated material/transform too.
351
+ this._material = material;
352
+ this._transform = m.transform;
244
353
 
245
354
  const positionInfo = this.extractPositions(m);
246
355
  if (positionInfo) {
247
- const { position, nPositions, positionSize, RTE } = positionInfo;
248
-
249
356
  invariant(
250
- nPositions === this.meshes().length,
251
- "Number of positions in the updated geometry must match the number of existing meshes",
357
+ this._positions === null ||
358
+ positionInfo.nPositions === this._positions.nPositions,
359
+ "Number of positions in the updated geometry must match the initial geometry",
252
360
  );
361
+ this._positions = positionInfo;
253
362
 
363
+ const { position, positionSize, RTE } = positionInfo;
254
364
  const transform = m.transform;
255
- for (let i = 0; i < nPositions; i++) {
256
- const posIdx = i * positionSize;
365
+ for (const mesh of this.meshes()) {
366
+ const posIdx = (mesh.userData.batchIndex as number) * positionSize;
257
367
  const pos = RTE
258
368
  ? {
259
369
  high: position.high.subarray(posIdx, posIdx + positionSize),
260
370
  low: position.low.subarray(posIdx, posIdx + positionSize),
261
371
  }
262
372
  : position.subarray(posIdx, posIdx + positionSize);
263
- this.meshes()[i].setPosition(pos, RTE, transform);
373
+ mesh.setPosition(pos, RTE, transform);
264
374
  }
265
375
  // Anchors moved (e.g. terrain height resolution) — re-place labels.
266
376
  this._markDeclutterDirty();
267
377
  }
268
378
 
379
+ // A non-empty default text renders without per-feature setText calls, so
380
+ // any lazily-skipped meshes must exist before `_applyUpdate` (no-op for
381
+ // already-created ones).
382
+ if (text) this.initMeshes();
383
+
269
384
  const fontIdentifier = m.material.font ?? this._fontIdentifier;
270
385
  const needFontUpdate = fontIdentifier !== this._fontIdentifier;
271
386
 
@@ -411,7 +526,12 @@ export class BatchedSdfTextMesh
411
526
  }
412
527
 
413
528
  setTextByBatchIndex(batchIndex: number, text: string) {
414
- const mesh = this.meshes()[batchIndex];
529
+ // An empty text on a not-yet-created mesh changes nothing (a lazily
530
+ // skipped mesh is exactly an invisible empty-text label); only a
531
+ // non-empty text forces the mesh into existence.
532
+ const mesh = text
533
+ ? this._ensureMeshAt(batchIndex)
534
+ : this.getMeshByBatchIndex(batchIndex);
415
535
 
416
536
  if (mesh) {
417
537
  // If the text hasn't been prepared in the worker yet, schedule async preparation
@@ -471,57 +591,101 @@ export class BatchedSdfTextMesh
471
591
  }
472
592
  }
473
593
 
594
+ /**
595
+ * The mesh for a feature's `batchIndex`, or `undefined` if not created yet.
596
+ * Meshes are created lazily and pushed onto `allMeshes` in creation order, so
597
+ * `allMeshes[batchIndex]` (the base-class mapping) does NOT hold here; the
598
+ * batchIndex→mesh mapping lives in `_meshByIndex`. Overriding this one
599
+ * accessor keeps the base-class setters and any external caller (picking,
600
+ * plugins) correct without patching each call site.
601
+ */
602
+ override getMeshByBatchIndex(batchIndex: number): SDFTextMesh | undefined {
603
+ return this._meshByIndex[batchIndex];
604
+ }
605
+
474
606
  setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
475
- const mesh = this.meshes()[batchIndex];
607
+ const mesh = this.getMeshByBatchIndex(batchIndex);
476
608
  if (mesh) {
477
609
  mesh.setColor(color);
610
+ return;
478
611
  }
612
+ const pending = this._pendingAt(batchIndex);
613
+ if (pending) pending.colorHex = color.getHex();
479
614
  }
480
615
 
481
616
  setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
482
- const mesh = this.meshes()[batchIndex];
617
+ const mesh = this.getMeshByBatchIndex(batchIndex);
483
618
  if (mesh) {
484
619
  mesh._setFeatureShow(rawVisible);
485
620
  this.markVisibility(mesh);
486
621
  this._markDeclutterDirty();
622
+ return;
623
+ }
624
+ // A lazily-created mesh starts hidden (empty text ⇒ `visible=false`), so a
625
+ // mesh-less feature is already effectively `show:false`. Only `show:true`
626
+ // needs buffering; buffering `show:false` would allocate a pending entry
627
+ // per hidden feature — the common MVT case (most features stay hidden and
628
+ // never get a mesh) and exactly the allocation lazy creation avoids. For
629
+ // `show:false` just clear any stale buffered `show`, never allocate.
630
+ if (rawVisible) {
631
+ const pending = this._pendingAt(batchIndex);
632
+ if (pending) pending.show = true;
633
+ } else {
634
+ const pending = this._pending.get(batchIndex);
635
+ if (pending) pending.show = undefined;
487
636
  }
488
637
  }
489
638
 
490
639
  setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
491
- const mesh = this.meshes()[batchIndex];
640
+ const mesh = this.getMeshByBatchIndex(batchIndex);
492
641
  if (mesh) {
493
642
  mesh.setHeight(height);
494
643
  this._markDeclutterDirty();
644
+ return;
495
645
  }
646
+ const pending = this._pendingAt(batchIndex);
647
+ if (pending) pending.height = height;
496
648
  }
497
649
 
498
650
  setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
499
- const mesh = this.meshes()[batchIndex];
651
+ const mesh = this.getMeshByBatchIndex(batchIndex);
500
652
  if (mesh) {
501
653
  mesh.setSize(size);
502
654
  this._markDeclutterDirty();
655
+ return;
503
656
  }
657
+ const pending = this._pendingAt(batchIndex);
658
+ if (pending) pending.size = size;
504
659
  }
505
660
 
506
661
  setFeatureDeclutterPriorityByBatchIndex(
507
662
  batchIndex: number,
508
663
  priority: number,
509
664
  ) {
510
- const mesh = this.meshes()[batchIndex];
665
+ const mesh = this.getMeshByBatchIndex(batchIndex);
511
666
  if (mesh) {
512
667
  mesh.setDeclutterPriority(priority);
513
668
  this._markDeclutterDirty();
669
+ return;
670
+ }
671
+ const pending = this._pendingAt(batchIndex);
672
+ if (pending) {
673
+ pending.declutterPriority = priority;
674
+ pending.hasDeclutterPriority = true;
514
675
  }
515
676
  }
516
677
 
517
678
  setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
518
- const mesh = this.meshes()[batchIndex];
679
+ const clampedOpacity = Number.isFinite(opacity)
680
+ ? Math.max(0, Math.min(1, opacity))
681
+ : 1.0;
682
+ const mesh = this.getMeshByBatchIndex(batchIndex);
519
683
  if (mesh) {
520
- const clampedOpacity = Number.isFinite(opacity)
521
- ? Math.max(0, Math.min(1, opacity))
522
- : 1.0;
523
684
  mesh.setOpacity(clampedOpacity);
685
+ return;
524
686
  }
687
+ const pending = this._pendingAt(batchIndex);
688
+ if (pending) pending.opacity = clampedOpacity;
525
689
  }
526
690
 
527
691
  dispose() {
@@ -53,7 +53,7 @@ export class InstancedMesh<M extends Object3D>
53
53
  return this.allMeshes;
54
54
  }
55
55
 
56
- getMeshByBatchIndex(batchIndex: number) {
56
+ getMeshByBatchIndex(batchIndex: number): M | undefined {
57
57
  return this.meshes()[batchIndex];
58
58
  }
59
59
 
@@ -282,7 +282,8 @@ export class PolygonMesh extends BatchedFeatureMesh<
282
282
  this.receiveShadow = !!meshMaterial.receiveShadow;
283
283
 
284
284
  // This mesh is texturized if it has a tile handle (terrain attachment).
285
- const isTexturized = !!tileHandle;
285
+ // `!= null`, not truthiness: the root vector tile's handle is 0.
286
+ const isTexturized = tileHandle != null;
286
287
  const material = this.material;
287
288
 
288
289
  material.vertexColors = false;
@@ -390,9 +391,15 @@ export class PolygonMesh extends BatchedFeatureMesh<
390
391
  _update(material: PolygonMaterial, active: boolean, isTexturized: boolean) {
391
392
  const enhancer = this.getEnhancer();
392
393
 
393
- // Update mesh properties (not handled by enhancer)
394
- this.visible =
395
- (material.show ?? true) && (material.surfaceShow ?? true) && active;
394
+ // Update mesh properties (not handled by enhancer).
395
+ // Draped (clamp-to-ground) polygons bake offscreen only — never the main
396
+ // scene — and the Rust drape resolve, not this LOD `active` flag, picks which
397
+ // baked tile composites per terrain region. Tying bake visibility to `active`
398
+ // makes the drape flicker on LOD swaps; keep a draped mesh bakeable whenever
399
+ // shown so every built tile stays a stable drape source (see
400
+ // PolylineMesh._update).
401
+ const shown = (material.show ?? true) && (material.surfaceShow ?? true);
402
+ this.visible = isTexturized ? shown : shown && active;
396
403
  if (this._debugBoundingSphereMesh) {
397
404
  this._debugBoundingSphereMesh.visible = this.visible;
398
405
  }
@@ -404,8 +404,16 @@ export class PolylineMesh extends BatchedFeatureMesh<
404
404
 
405
405
  const enhancer = this.getEnhancer();
406
406
 
407
- // Update mesh properties (not handled by enhancer)
408
- this.visible = (material.show ?? true) && active;
407
+ // Update mesh properties (not handled by enhancer).
408
+ // Draped polylines render only through the offscreen bake, never the main
409
+ // scene, and the Rust drape resolve — not this LOD `active` flag — decides
410
+ // which baked tile is composited per terrain region. Tying bake visibility
411
+ // to `active` makes the drape flicker on every LOD swap (the tile goes
412
+ // invisible → bakes empty before its replacement is ready). Keep a draped
413
+ // mesh bakeable whenever its material is shown, so every built tile stays a
414
+ // stable drape source — the vector twin of a loaded raster texture.
415
+ const shown = material.show ?? true;
416
+ this.visible = enhancer.states().isTexturized ? shown : shown && active;
409
417
  this.castShadow = !!material.castShadow;
410
418
  this.receiveShadow = !!material.receiveShadow;
411
419
 
@@ -1,5 +1,5 @@
1
1
  import type { Transform } from "@navaramap/engine";
2
- import { calcCameraPosition, calcModelMatrixRTE } from "@navaramap/three_api";
2
+ import { calcCameraPosition, calcModelMatrixRTE } from "@navaramap/three-api";
3
3
  import { Camera, Matrix4, Object3D, Vector3 } from "three";
4
4
 
5
5
  import { setTransform } from "../event";
@@ -12,7 +12,7 @@ import {
12
12
  type ShapedGlyph,
13
13
  type ShapeTextResult,
14
14
  } from "@navaramap/font";
15
- import { degreeToRadian } from "@navaramap/three_api";
15
+ import { degreeToRadian } from "@navaramap/three-api";
16
16
  import {
17
17
  BufferAttribute,
18
18
  type Color,
@@ -261,12 +261,17 @@ export class SDFTextMesh
261
261
  fontUrl: string,
262
262
  batchId: number | undefined,
263
263
  RTE: boolean,
264
+ // Quality is fixed by the owning batch (flipping it needs a new batch), so
265
+ // it is passed in rather than re-derived from `material.highQuality` — the
266
+ // stored material can carry a newer, mismatched value by the time a mesh is
267
+ // created lazily, which must not diverge from the batch's atlas/shaping.
268
+ highQuality: boolean,
264
269
  ) {
265
270
  super();
266
271
 
267
272
  this._fontManager = fontManager;
268
273
  this._fontUrl = fontUrl;
269
- this._highQuality = material.highQuality ?? false;
274
+ this._highQuality = highQuality;
270
275
  this._maxWidth = material.maxWidth ?? 0;
271
276
  this._lineHeight = material.lineHeight ?? 1.0;
272
277
  this._textAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
@@ -2,7 +2,7 @@ import {
2
2
  PointMesh as NavaraPointMesh,
3
3
  BillboardMesh as NavaraBillboardMesh,
4
4
  } from "@navaramap/engine";
5
- import { degreeToRadian } from "@navaramap/three_api";
5
+ import { degreeToRadian } from "@navaramap/three-api";
6
6
  import {
7
7
  InstancedBufferAttribute,
8
8
  InstancedBufferGeometry,
package/src/solar.test.ts CHANGED
@@ -2,6 +2,8 @@ import { AstroTime, Body, HourAngle, Observer } from "astronomy-engine";
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
4
  import {
5
+ dateForSolarTime,
6
+ getSolarTime,
5
7
  getSunElevation,
6
8
  shiftDateToElevation,
7
9
  shiftDateToHourAngle,
@@ -66,6 +68,37 @@ describe("shiftDateToElevation", () => {
66
68
  });
67
69
  });
68
70
 
71
+ describe("getSolarTime / dateForSolarTime", () => {
72
+ it("reads ~12 (solar noon) at Tokyo solar noon", () => {
73
+ // Tokyo solar noon ≈ 02:43 UTC on 2024-06-21
74
+ const date = new Date("2024-06-21T02:43:00Z");
75
+ expect(getSolarTime(date, 139.69)).toBeCloseTo(12, 1);
76
+ });
77
+
78
+ it("reads ~0 (solar midnight) at Tokyo solar midnight", () => {
79
+ // Tokyo solar midnight ≈ 14:43 UTC on 2024-06-21
80
+ const date = new Date("2024-06-21T14:43:00Z");
81
+ const t = getSolarTime(date, 139.69);
82
+ expect(Math.min(t, 24 - t)).toBeCloseTo(0, 1);
83
+ });
84
+
85
+ it("round-trips: dateForSolarTime then getSolarTime returns the input", () => {
86
+ for (const hours of [0, 6.3, 12, 18.75]) {
87
+ const date = dateForSolarTime(BASE_DATE, 139.69, hours);
88
+ // Compare on the 24h circle so 0 and 24 count as equal.
89
+ const diff = Math.abs(getSolarTime(date, 139.69) - hours);
90
+ expect(Math.min(diff, 24 - diff)).toBeCloseTo(0, 2);
91
+ }
92
+ });
93
+
94
+ it("puts the sun above the horizon at midday and below at midnight", () => {
95
+ const noon = dateForSolarTime(BASE_DATE, 139.69, 12);
96
+ expect(getSunElevation(noon, 35.68, 139.69)).toBeGreaterThan(0);
97
+ const midnight = dateForSolarTime(BASE_DATE, 139.69, 0);
98
+ expect(getSunElevation(midnight, 35.68, 139.69)).toBeLessThan(0);
99
+ });
100
+ });
101
+
69
102
  describe("getSunElevation", () => {
70
103
  it("returns positive elevation at solar noon", () => {
71
104
  // Tokyo solar noon ≈ 02:43 UTC on 2024-06-21
package/src/solar.ts CHANGED
@@ -20,6 +20,37 @@ export function getSunElevation(date: Date, lat: number, lng: number): number {
20
20
  return Horizon(time, observer, eq.ra, eq.dec, "normal").altitude;
21
21
  }
22
22
 
23
+ /**
24
+ * Returns the local apparent solar time at `lng` for `date`, in hours in the
25
+ * range [0, 24) where 12 is solar noon. Derived from the sun's hour angle, so
26
+ * it accounts for the equation of time.
27
+ */
28
+ export function getSolarTime(date: Date, lng: number): number {
29
+ const ha = HourAngle(Body.Sun, new AstroTime(date), new Observer(0, lng, 0));
30
+ return (ha + 12) % 24;
31
+ }
32
+
33
+ /**
34
+ * Returns a new Date on the same UTC-anchored solar day such that the local
35
+ * apparent solar time at `lng` equals `hours` (0–24). Inverse of
36
+ * {@link getSolarTime}.
37
+ */
38
+ export function dateForSolarTime(date: Date, lng: number, hours: number): Date {
39
+ const targetHourAngle = (((hours - 12) % 24) + 24) % 24;
40
+ const dayStart = new AstroTime(
41
+ new Date(
42
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
43
+ ),
44
+ );
45
+ return SearchHourAngle(
46
+ Body.Sun,
47
+ new Observer(0, lng, 0),
48
+ targetHourAngle,
49
+ dayStart,
50
+ 1,
51
+ ).time.date;
52
+ }
53
+
23
54
  /**
24
55
  * Returns a new Date such that the sun's hour angle at `targetLng` matches the
25
56
  * hour angle currently at `fromLng`.
@@ -1,5 +1,5 @@
1
1
  import type { Nullable, XYZ } from "@navaramap/core";
2
- import { convertScreenToWorld } from "@navaramap/three_api";
2
+ import { convertScreenToWorld } from "@navaramap/three-api";
3
3
  import { Vector2 } from "three";
4
4
 
5
5
  import type ThreeView from "../index";