@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.
- package/dist/assets/fontWorker-DaqfXmZf.js +2 -0
- package/dist/assets/fontWorker-DaqfXmZf.js.map +1 -0
- package/dist/assets/index-Dydat1h1.js +25 -0
- package/dist/assets/index-Dydat1h1.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-DympKYVX.wasm → navara_wasm_font_worker_bg-XvE_oYPr.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Bx_l45cW.wasm → navara_wasm_worker_bg-Dg9fnGLP.wasm} +0 -0
- package/dist/index.d.ts +140 -4
- package/dist/index.js +5385 -5022
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +164 -86
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +12 -12
- package/src/declutter/DeclutterManager.test.ts +65 -0
- package/src/declutter/DeclutterManager.ts +60 -1
- package/src/declutter/types.ts +17 -0
- package/src/event/feature.ts +45 -2
- package/src/event/worker.ts +18 -0
- package/src/index.ts +51 -2
- package/src/layers/effect/TransparentPassEffectDesc.ts +1 -1
- package/src/mesh/nvLineGeometry.test.ts +88 -0
- package/src/mesh/nvLineGeometry.ts +148 -0
- package/src/mesh/polygonOutline.ts +126 -42
- package/src/mesh/sdfText/batchedSdfText.test.ts +377 -0
- package/src/mesh/sdfText/batchedSdfText.ts +192 -21
- package/src/mesh/sdfText/labelVisibility.test.ts +106 -0
- package/src/mesh/sdfText/labelVisibility.ts +131 -0
- package/src/mesh/sdfText/pendingSettlement.test.ts +151 -0
- package/src/mesh/sdfText/pendingSettlement.ts +68 -0
- package/src/pick/pickTerrain.ts +22 -18
- package/src/terrain/sampleTerrainMostDetailed.test.ts +236 -0
- package/src/terrain/sampleTerrainMostDetailed.ts +447 -0
- package/dist/assets/fontWorker-JfTCpd38.js +0 -2
- package/dist/assets/fontWorker-JfTCpd38.js.map +0 -1
- package/dist/assets/index-B3U_DvcP.js +0 -25
- 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
|
|
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.
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
+
}
|