@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
@@ -34,11 +34,18 @@ import type { PickableMesh } from "../pickableMesh";
34
34
  import { GlyphBuffers } from "./glyphBuffers";
35
35
  import { GlyphSlotAllocator, type GlyphRun } from "./glyphSlots";
36
36
  import { LabelDataTexture, LabelRow } from "./labelData";
37
+ import {
38
+ createAnchorVisibilityState,
39
+ isAnchorPotentiallyVisible,
40
+ syncAnchorVisibilityState,
41
+ } from "./labelVisibility";
37
42
  import { ALIGN_FACTORS, buildLabelLayout, type LayoutOptions } from "./layout";
43
+ import { PendingSettlement } from "./pendingSettlement";
38
44
 
39
45
  /** Reusable scratch to avoid per-frame / per-write allocations. */
40
46
  const _tmpSize = new Vector2();
41
47
  const _tmpColor = new Color();
48
+ const _visibility = createAnchorVisibilityState();
42
49
 
43
50
  type PositionsInfoBase = {
44
51
  batchIDs: Float32Array<ArrayBufferLike> | null;
@@ -80,10 +87,19 @@ type LabelRecord = {
80
87
  text: string;
81
88
  /**
82
89
  * The most recent text asked for. Differs from {@link text} only while an
83
- * async font preparation is in flight; the callback compares against it so a
90
+ * async font preparation is in flight or parked (see
91
+ * {@link prepareDeferred}); the prepare callback compares against it so a
84
92
  * slow prepare can't clobber a newer text that landed meanwhile.
85
93
  */
86
94
  requestedText: string;
95
+ /**
96
+ * True while {@link requestedText} awaits font preparation parked on anchor
97
+ * visibility: unprepared text is not shaped — and its font faces not
98
+ * fetched — until a placement pass finds the anchor potentially visible
99
+ * (inside the frustum and not behind the globe's horizon). See
100
+ * `prepareDeferredLabels`.
101
+ */
102
+ prepareDeferred: boolean;
87
103
  /** Glyph-instance slots this label owns, or null when it has no text. */
88
104
  run: GlyphRun | null;
89
105
  /** Unique atlas glyphs the current text renders. */
@@ -166,6 +182,9 @@ export class BatchedSdfTextMesh
166
182
  /** Sparse batchIndex → label; labels are created on first per-feature touch. */
167
183
  private _labelByIndex: (LabelRecord | undefined)[] = [];
168
184
 
185
+ /** In-flight per-feature text preparations; see {@link whenLabelsSettled}. */
186
+ private _pendingTextPrepares = new PendingSettlement();
187
+
169
188
  private _glyphs: GlyphBuffers;
170
189
  private _slots = new GlyphSlotAllocator();
171
190
  private _labelData: LabelDataTexture;
@@ -175,6 +194,10 @@ export class BatchedSdfTextMesh
175
194
  private _lineHeight: number;
176
195
  private _textAlign: number;
177
196
 
197
+ /** Labels currently parked in `prepareDeferred`, so the per-pass promotion
198
+ * scan can bail without touching `_labels` when nothing is parked. */
199
+ private _deferredCount = 0;
200
+
178
201
  /** Layer-level declutter settings, mirrored from the material. */
179
202
  private _declutter: boolean;
180
203
  private _declutterPriority: number;
@@ -357,6 +380,7 @@ export class BatchedSdfTextMesh
357
380
  batchId: info.batchIDs ? info.batchIDs[batchIndex * info.batchIDSize] : 0,
358
381
  text: "",
359
382
  requestedText: "",
383
+ prepareDeferred: false,
360
384
  run: null,
361
385
  glyphKeys: [],
362
386
  retainedKeys: null,
@@ -539,8 +563,11 @@ export class BatchedSdfTextMesh
539
563
  * text is already prepared in the font worker.
540
564
  */
541
565
  private _applyText(record: LabelRecord, text: string): void {
566
+ // `requestedText` is deliberately not touched here: it belongs to the
567
+ // intent-setting paths (`setTextByBatchIndex`, the material-text update).
568
+ // A re-layout of the *current* text (font/layout change) must not clobber
569
+ // a newer intent that is still in flight or parked on visibility.
542
570
  record.text = text;
543
- record.requestedText = text;
544
571
 
545
572
  if (!text) {
546
573
  this._releaseRun(record);
@@ -781,6 +808,7 @@ export class BatchedSdfTextMesh
781
808
  isShown: record.declutterTarget === 0,
782
809
  owner: this,
783
810
  handle: record.slot,
811
+ contentKey: record.text,
784
812
  });
785
813
  }
786
814
  }
@@ -822,10 +850,52 @@ export class BatchedSdfTextMesh
822
850
  }
823
851
 
824
852
  override setActive(active: boolean) {
853
+ const activating = active && !this.active;
825
854
  super.setActive(active);
855
+ // Tile-swap handoff: this batch replaces another (its parent tile is
856
+ // hidden in the same swap), so any label whose content the previous
857
+ // declutter pass already showed nearby starts granted instead of fading
858
+ // in from hidden — otherwise every swap blinks the whole tile's labels
859
+ // out for a throttled pass plus a fade-in. Genuinely new labels keep the
860
+ // fade; the next pass re-places everything and corrects any misseed.
861
+ if (activating && this._declutter) {
862
+ const declutter = this.ctx.declutter;
863
+ if (declutter) {
864
+ for (const record of this._labels) {
865
+ if (!record.show || record.declutterHide === 0) continue;
866
+ const a = record.anchor;
867
+ if (declutter.wasRecentlyShown(record.text, a[0], a[1], a[2])) {
868
+ record.declutterHide = 0;
869
+ record.declutterTarget = 0;
870
+ this._writeDeclutterHide(record);
871
+ }
872
+ }
873
+ }
874
+ }
826
875
  this._markDeclutterDirty();
827
876
  }
828
877
 
878
+ /**
879
+ * Resolves once every per-feature text preparation currently in flight has
880
+ * landed (glyph runs written), bounded by `timeoutMs`.
881
+ *
882
+ * The tile-LOD swap on the Rust side hides a parent tile the moment its
883
+ * children report rendered, so the caller must not report this batch
884
+ * rendered while labels are still shaping — the swap would show a batch
885
+ * that draws nothing (a tile-shaped blank).
886
+ *
887
+ * Only preparations already started are awaited. With declutter enabled,
888
+ * `setTextByBatchIndex` parks unprepared text until a placement pass
889
+ * confirms the anchor is on screen, so those labels are not yet in flight
890
+ * and this resolves immediately — cached text (the common case for a child
891
+ * tile repeating its parent's strings) applies synchronously and needs no
892
+ * wait either. The gate therefore only bites on the non-decluttered path;
893
+ * decluttered swaps rely on the placement handoff in {@link setActive}.
894
+ */
895
+ whenLabelsSettled(timeoutMs: number): Promise<void> {
896
+ return this._pendingTextPrepares.whenSettled(timeoutMs);
897
+ }
898
+
829
899
  // --- Picking ---
830
900
 
831
901
  override onBeforePicking() {
@@ -906,6 +976,11 @@ export class BatchedSdfTextMesh
906
976
 
907
977
  const material = m.material;
908
978
  const text = material.text ?? "";
979
+ // Kept so _applyUpdate can tell which material fields actually changed:
980
+ // engine change events re-send the full material even when only geometry
981
+ // or activation moved, and only genuine changes may overwrite per-feature
982
+ // (evaluator-set) values.
983
+ const prevMaterial = this._material;
909
984
  this._material = material;
910
985
  this._transform = m.transform;
911
986
 
@@ -964,7 +1039,7 @@ export class BatchedSdfTextMesh
964
1039
  this._fontManager
965
1040
  .prepareText(this._fontIdentifier, text, q, this._loadedFaceUrls)
966
1041
  .then(() => {
967
- this._applyUpdate(material, needRender, needFontUpdate);
1042
+ this._applyUpdate(material, prevMaterial, needRender, needFontUpdate);
968
1043
  })
969
1044
  .catch((err: unknown) => {
970
1045
  console.error("Failed to prepare text:", err);
@@ -973,11 +1048,12 @@ export class BatchedSdfTextMesh
973
1048
  return;
974
1049
  }
975
1050
 
976
- this._applyUpdate(material, needRender, needFontUpdate);
1051
+ this._applyUpdate(material, prevMaterial, needRender, needFontUpdate);
977
1052
  }
978
1053
 
979
1054
  private _applyUpdate(
980
1055
  material: NavaraTextMaterial,
1056
+ prevMaterial: NavaraTextMaterial,
981
1057
  needRender?: () => void,
982
1058
  forceUpdate = false,
983
1059
  ) {
@@ -1029,27 +1105,48 @@ export class BatchedSdfTextMesh
1029
1105
  },
1030
1106
  });
1031
1107
 
1032
- // Per-label values the material supplies defaults for. This mirrors the
1033
- // pre-batching behaviour where a material update overwrote whatever the
1034
- // evaluator had set per feature.
1108
+ // Per-label values the material supplies defaults for. A *changed*
1109
+ // material value overwrites whatever the evaluator had set per feature
1110
+ // (mirroring the pre-batching behaviour for real `layer.update` calls) —
1111
+ // but engine change events re-send the whole material for geometry or
1112
+ // activation updates too, and stomping evaluator values with an unchanged
1113
+ // material made labels visibly pulse: every terrain-height event reset
1114
+ // per-feature sizes to the default for a few frames until the app's
1115
+ // evaluator ran again.
1035
1116
  const materialText = material.text;
1036
1117
  const materialShow = material.show ?? true;
1037
1118
  const colorHex = material.color ?? 0xffffff;
1038
1119
  const opacity = clamp01(material.opacity ?? 1.0);
1039
1120
  const fontSize = material.size ?? 16.0;
1040
1121
  const addHeight = material.height ?? 0;
1122
+ const showChanged = materialShow !== (prevMaterial.show ?? true);
1123
+ const styleChanged =
1124
+ colorHex !== (prevMaterial.color ?? 0xffffff) ||
1125
+ opacity !== clamp01(prevMaterial.opacity ?? 1.0);
1126
+ const fontSizeChanged = fontSize !== (prevMaterial.size ?? 16.0);
1127
+ const addHeightChanged = addHeight !== (prevMaterial.height ?? 0);
1041
1128
 
1042
1129
  for (const record of this._labels) {
1043
- record.colorHex = colorHex;
1044
- record.opacity = opacity;
1045
- record.fontSize = fontSize;
1046
- record.addHeight = addHeight;
1047
- this._writeStyle(record);
1048
- this._writeFontSize(record);
1049
- this._writeAddHeight(record);
1050
-
1051
- record.requestedShow = materialShow;
1130
+ if (styleChanged) {
1131
+ record.colorHex = colorHex;
1132
+ record.opacity = opacity;
1133
+ this._writeStyle(record);
1134
+ }
1135
+ if (fontSizeChanged) {
1136
+ record.fontSize = fontSize;
1137
+ this._writeFontSize(record);
1138
+ }
1139
+ if (addHeightChanged) {
1140
+ record.addHeight = addHeight;
1141
+ this._writeAddHeight(record);
1142
+ }
1143
+
1144
+ if (showChanged) record.requestedShow = materialShow;
1052
1145
  if (materialText !== undefined && materialText !== "") {
1146
+ // The material text overrides whatever the evaluator asked for —
1147
+ // including a prepare that is in flight or parked on visibility.
1148
+ record.requestedText = materialText;
1149
+ this._clearDeferred(record);
1053
1150
  this._applyText(record, materialText);
1054
1151
  } else if (forceUpdate || layoutChanged) {
1055
1152
  // Font or layout changed — re-lay-out existing text.
@@ -1085,6 +1182,43 @@ export class BatchedSdfTextMesh
1085
1182
  this._highQuality,
1086
1183
  )
1087
1184
  ) {
1185
+ // Unprepared text costs a worker round-trip and possibly font-face
1186
+ // fetches, and low-zoom tiles span far more world than the screen shows
1187
+ // (a z0 tile carries every country's name). Park preparation until a
1188
+ // placement pass confirms the anchor can actually appear on screen
1189
+ // (`prepareDeferredLabels`). Deciding here instead would race camera
1190
+ // initialization: early tiles evaluate before the first render commits
1191
+ // the camera pose to `matrixWorld`, wrongly passing far-side labels.
1192
+ // The pass runs with the render camera on the next frame, so visible
1193
+ // labels start preparing at most one throttle window later.
1194
+ if (this.ctx.declutter) {
1195
+ if (!record.prepareDeferred) {
1196
+ record.prepareDeferred = true;
1197
+ this._deferredCount++;
1198
+ }
1199
+ this._markDeclutterDirty();
1200
+ this._needRender?.();
1201
+ return;
1202
+ }
1203
+ this._prepareAndApply(record, text);
1204
+ return;
1205
+ }
1206
+
1207
+ this._clearDeferred(record);
1208
+ this._applyText(record, text);
1209
+ this._markDeclutterDirty();
1210
+ this._needRender?.();
1211
+ }
1212
+
1213
+ /** Kick off async font preparation for `text`, then lay it out — unless a
1214
+ * newer text supersedes it while the fonts load. */
1215
+ private _prepareAndApply(record: LabelRecord, text: string): void {
1216
+ // Tracked so `whenLabelsSettled` can gate the tile's render-completion
1217
+ // report on the glyphs actually being written (this chain ends after
1218
+ // `_applyText`), not merely on the font round-trip finishing. This is the
1219
+ // single point where preparation starts — both the inline path above and
1220
+ // the promotion of a parked label in `prepareDeferredLabels` route here.
1221
+ this._pendingTextPrepares.track(
1088
1222
  this._fontManager
1089
1223
  .prepareText(
1090
1224
  this._fontIdentifier,
@@ -1103,13 +1237,50 @@ export class BatchedSdfTextMesh
1103
1237
  .catch((err: unknown) => {
1104
1238
  console.error("Failed to prepare text:", err);
1105
1239
  this._needRender?.();
1106
- });
1107
- return;
1240
+ }),
1241
+ );
1242
+ }
1243
+
1244
+ private _clearDeferred(record: LabelRecord): void {
1245
+ if (record.prepareDeferred) {
1246
+ record.prepareDeferred = false;
1247
+ this._deferredCount--;
1108
1248
  }
1249
+ }
1109
1250
 
1110
- this._applyText(record, text);
1111
- this._markDeclutterDirty();
1112
- this._needRender?.();
1251
+ /**
1252
+ * Promote parked labels whose anchor became potentially visible: start
1253
+ * their font preparation and apply the text when it lands. Runs at the
1254
+ * start of every placement pass (see {@link DeclutterParticipant}) — the
1255
+ * cadence at which visibility can actually change.
1256
+ */
1257
+ prepareDeferredLabels(camera: PerspectiveCamera): void {
1258
+ if (this._deferredCount === 0 || !this.visible) return;
1259
+ const state = syncAnchorVisibilityState(camera, _visibility);
1260
+ for (const record of this._labels) {
1261
+ if (!record.prepareDeferred) continue;
1262
+ const a = record.anchor;
1263
+ if (!isAnchorPotentiallyVisible(state, a[0], a[1], a[2])) continue;
1264
+ this._clearDeferred(record);
1265
+
1266
+ const text = record.requestedText;
1267
+ // Parked intent may have gone stale: cleared, or applied via another
1268
+ // path (e.g. a material-level text update).
1269
+ if (!text || text === record.text) continue;
1270
+ if (
1271
+ this._fontManager.isTextPrepared(
1272
+ this._fontIdentifier,
1273
+ text,
1274
+ this._highQuality,
1275
+ )
1276
+ ) {
1277
+ this._applyText(record, text);
1278
+ this._markDeclutterDirty();
1279
+ this._needRender?.();
1280
+ continue;
1281
+ }
1282
+ this._prepareAndApply(record, text);
1283
+ }
1113
1284
  }
1114
1285
 
1115
1286
  override setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
@@ -0,0 +1,106 @@
1
+ import { PerspectiveCamera } from "three";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ createAnchorVisibilityState,
6
+ isAnchorPotentiallyVisible,
7
+ syncAnchorVisibilityState,
8
+ } from "./labelVisibility";
9
+
10
+ // The ellipsoid getters cross into WASM, which is only initialized by
11
+ // `view.init()`. Stub them with the f32 values the engine returns (navara_core
12
+ // `WGS84_32`) so the radius band is exercised without a live view.
13
+ vi.mock("@navaramap/three-api", () => ({
14
+ getWGS84SemiMajorAxis: () => 6_378_137,
15
+ getWGS84SemiMinorAxis: () => 6_356_752.5,
16
+ }));
17
+
18
+ /** Equatorial Earth radius (m); anchors at this length get the horizon test. */
19
+ const R = 6_378_137;
20
+
21
+ function makeCamera(
22
+ x: number,
23
+ y: number,
24
+ z: number,
25
+ fov = 60,
26
+ ): PerspectiveCamera {
27
+ const cam = new PerspectiveCamera(fov, 1, 1, 1e9);
28
+ cam.position.set(x, y, z);
29
+ cam.lookAt(0, 0, 0);
30
+ cam.updateMatrixWorld(true);
31
+ cam.updateProjectionMatrix();
32
+ return cam;
33
+ }
34
+
35
+ function visible(
36
+ cam: PerspectiveCamera,
37
+ x: number,
38
+ y: number,
39
+ z: number,
40
+ ): boolean {
41
+ const state = syncAnchorVisibilityState(cam, createAnchorVisibilityState());
42
+ return isAnchorPotentiallyVisible(state, x, y, z);
43
+ }
44
+
45
+ /** Surface anchor at `deg` degrees (around +Z) away from the sub-camera
46
+ * point of a camera on the +X axis. */
47
+ const surfaceAnchor = (deg: number): [number, number, number] => {
48
+ const rad = (deg * Math.PI) / 180;
49
+ return [R * Math.cos(rad), R * Math.sin(rad), 0];
50
+ };
51
+
52
+ describe("isAnchorPotentiallyVisible", () => {
53
+ describe("globe horizon", () => {
54
+ // Camera at 2R from the center: the geometric horizon sits at
55
+ // acos(R / 2R) = 60° from the sub-camera point; HORIZON_MARGIN (0.95)
56
+ // relaxes the cutoff to acos(0.95 / 2) ≈ 61.7°.
57
+ const cam = makeCamera(2 * R, 0, 0);
58
+
59
+ it("keeps the sub-camera point", () => {
60
+ expect(visible(cam, ...surfaceAnchor(0))).toBe(true);
61
+ });
62
+
63
+ it("keeps anchors just past the horizon (margin)", () => {
64
+ expect(visible(cam, ...surfaceAnchor(61))).toBe(true);
65
+ });
66
+
67
+ it("culls anchors clearly beyond the horizon", () => {
68
+ expect(visible(cam, ...surfaceAnchor(63))).toBe(false);
69
+ });
70
+
71
+ it("culls the antipode", () => {
72
+ expect(visible(cam, -R, 0, 0)).toBe(false);
73
+ });
74
+
75
+ it("never applies the horizon test outside the Earth-surface band", () => {
76
+ // Same direction as the antipode but at non-globe scale: only the
77
+ // frustum decides, and this point sits in front of the camera.
78
+ expect(visible(cam, -1000, 0, 0)).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("frustum", () => {
83
+ // Camera at +Z looking at the origin; fov 60 and aspect 1 put the exact
84
+ // frustum edge at |NDC| = 1 and the NDC_MARGIN (1.4) cutoff beyond it.
85
+ const cam = makeCamera(0, 0, 1000);
86
+
87
+ it("keeps anchors in front of the camera", () => {
88
+ expect(visible(cam, 0, 0, 0)).toBe(true);
89
+ });
90
+
91
+ it("culls anchors behind the camera", () => {
92
+ expect(visible(cam, 0, 0, 2000)).toBe(false);
93
+ });
94
+
95
+ it("keeps anchors slightly outside the viewport (margin)", () => {
96
+ // At depth 1000 the frustum half-extent is tan(30°)·1000 ≈ 577; x=700
97
+ // is ~1.2 in NDC — outside the screen but inside the 1.4 margin.
98
+ expect(visible(cam, 700, 0, 0)).toBe(true);
99
+ });
100
+
101
+ it("culls anchors well outside the viewport", () => {
102
+ // x=900 is ~1.56 in NDC, past the 1.4 margin.
103
+ expect(visible(cam, 900, 0, 0)).toBe(false);
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ getWGS84SemiMajorAxis,
3
+ getWGS84SemiMinorAxis,
4
+ } from "@navaramap/three-api";
5
+ import { Matrix4, type PerspectiveCamera } from "three";
6
+
7
+ /**
8
+ * Camera-derived state for testing whether a label anchor could appear on
9
+ * screen. Snapshot it once per scan with {@link syncAnchorVisibilityState},
10
+ * then test any number of anchors with {@link isAnchorPotentiallyVisible}.
11
+ */
12
+ export type AnchorVisibilityState = {
13
+ /** Column-major view-projection matrix. */
14
+ vp: Float64Array;
15
+ /** Camera world position (ECEF meters). */
16
+ camX: number;
17
+ camY: number;
18
+ camZ: number;
19
+ };
20
+
21
+ export const createAnchorVisibilityState = (): AnchorVisibilityState => ({
22
+ vp: new Float64Array(16),
23
+ camX: 0,
24
+ camY: 0,
25
+ camZ: 0,
26
+ });
27
+
28
+ /**
29
+ * Fractional tolerance on the Earth-surface radius band. An anchor whose
30
+ * distance from the origin lands within this fraction of the WGS84 radii is
31
+ * treated as sitting on the globe and gets the horizon test; anchors outside
32
+ * the band (planar/local scenes, points far above the surface) rely on the
33
+ * frustum test alone. 5% reaches from well below the polar radius up to the
34
+ * equatorial radius plus high terrain and label height offsets — the band is a
35
+ * "is this a globe anchor at all" filter, so it is deliberately loose.
36
+ */
37
+ const EARTH_BAND_TOLERANCE = 0.05;
38
+
39
+ /**
40
+ * Squared band bounds, derived from the engine's WGS84 ellipsoid (Rust side)
41
+ * rather than from literals, so the band follows the engine's definition.
42
+ * Resolved on first use, not at module scope: `@navaramap/three-api` only
43
+ * answers once `view.init()` has initialized the WASM module. Memoized because
44
+ * the bounds are tested once per anchor in the label scan.
45
+ */
46
+ let bandMin2 = 0;
47
+ let bandMax2 = 0;
48
+
49
+ const resolveEarthBand = () => {
50
+ const min = getWGS84SemiMinorAxis() * (1 - EARTH_BAND_TOLERANCE);
51
+ const max = getWGS84SemiMajorAxis() * (1 + EARTH_BAND_TOLERANCE);
52
+ bandMin2 = min * min;
53
+ bandMax2 = max * max;
54
+ };
55
+
56
+ /**
57
+ * Relaxation factor for the horizon test. `1` is the exact tangent-plane
58
+ * cutoff; lowering it admits anchors slightly beyond the horizon so labels
59
+ * near the limb start preparing (fonts fetching, glyphs shaping) before the
60
+ * camera fully reveals them.
61
+ */
62
+ const HORIZON_MARGIN = 0.95;
63
+
64
+ /**
65
+ * Clip-space margin factor for the frustum test. `1` is the exact viewport;
66
+ * `1.4` treats anchors within 40% beyond each edge as potentially visible, so
67
+ * a label whose box pokes into view, or that is about to pan in, is prepared
68
+ * slightly ahead of need.
69
+ */
70
+ const NDC_MARGIN = 1.4;
71
+
72
+ const _view = new Matrix4();
73
+ const _viewProj = new Matrix4();
74
+
75
+ /**
76
+ * Snapshot the camera into `out`. Derives the view matrix from `matrixWorld`
77
+ * rather than trusting `matrixWorldInverse`, which can be stale outside the
78
+ * render pass (same reasoning as `DeclutterManager._run`).
79
+ */
80
+ export function syncAnchorVisibilityState(
81
+ camera: PerspectiveCamera,
82
+ out: AnchorVisibilityState,
83
+ ): AnchorVisibilityState {
84
+ _view.copy(camera.matrixWorld).invert();
85
+ _viewProj.multiplyMatrices(camera.projectionMatrix, _view);
86
+ out.vp.set(_viewProj.elements);
87
+ const e = camera.matrixWorld.elements;
88
+ out.camX = e[12];
89
+ out.camY = e[13];
90
+ out.camZ = e[14];
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Whether a world-space (ECEF meters) anchor could appear on screen —
96
+ * deliberately optimistic: `false` is only returned when the anchor is
97
+ * provably out of view (behind the globe's horizon or well outside the
98
+ * frustum), so callers can safely skip work for hidden anchors and never
99
+ * miss a visible one.
100
+ */
101
+ export function isAnchorPotentiallyVisible(
102
+ state: AnchorVisibilityState,
103
+ x: number,
104
+ y: number,
105
+ z: number,
106
+ ): boolean {
107
+ // Horizon: an anchor on the globe is beyond the horizon when the camera
108
+ // sits below the anchor's tangent plane — dot(C, P) < |P|². Only applied
109
+ // inside the Earth-surface band so non-globe anchors are never culled by it.
110
+ if (bandMax2 === 0) resolveEarthBand();
111
+ const r2 = x * x + y * y + z * z;
112
+ if (
113
+ r2 > bandMin2 &&
114
+ r2 < bandMax2 &&
115
+ x * state.camX + y * state.camY + z * state.camZ < r2 * HORIZON_MARGIN
116
+ ) {
117
+ return false;
118
+ }
119
+
120
+ // Frustum with margin. Near/far are ignored on purpose: an anchor beyond
121
+ // the far plane is still "potentially visible" the moment the camera pulls
122
+ // back, and being optimistic there costs at most one early preparation.
123
+ const vp = state.vp;
124
+ const w = vp[3] * x + vp[7] * y + vp[11] * z + vp[15];
125
+ if (w <= 0) return false; // behind the camera
126
+ const m = w * NDC_MARGIN;
127
+ const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12];
128
+ if (cx < -m || cx > m) return false;
129
+ const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13];
130
+ return cy >= -m && cy <= m;
131
+ }