@navaramap/three 0.0.3 → 0.0.5
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/README.md +7 -7
- package/dist/assets/fontWorker-OwvKiQ8o.js +2 -0
- package/dist/assets/fontWorker-OwvKiQ8o.js.map +1 -0
- package/dist/assets/{index-ywHSGImj.js → index-TKhRxurv.js} +2 -2
- package/dist/assets/index-TKhRxurv.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-Du9nb2Pn.wasm → navara_wasm_font_worker_bg-Kk06ArRB.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Xadu67fw.wasm → navara_wasm_worker_bg-BZjLKf4i.wasm} +0 -0
- package/dist/index.d.ts +351 -284
- package/dist/index.js +5394 -5470
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +287 -235
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +12 -12
- package/src/ShadowMapViewers.ts +1 -1
- package/src/atmosphere.test.ts +227 -28
- package/src/atmosphere.ts +219 -55
- package/src/constants/assets.ts +63 -13
- package/src/declutter/DeclutterManager.ts +1 -1
- package/src/declutter/types.ts +1 -1
- package/src/evaluations/FeatureEvaluator.test.ts +152 -0
- package/src/evaluations/FeatureEvaluator.ts +73 -2
- package/src/event/features/billboard.ts +1 -0
- package/src/event/features/model.ts +1 -1
- package/src/event/features/point.ts +1 -0
- package/src/event/index.ts +1 -1
- package/src/index.iife.ts +3 -3
- package/src/index.ts +10 -12
- package/src/layers/effect/README.md +1 -1
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +2 -2
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/index.ts +10 -7
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.test.ts +74 -81
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +19 -86
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +77 -0
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +9 -1
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.test.ts +15 -19
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.ts +0 -13
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +46 -48
- package/src/mesh/constants.ts +22 -0
- package/src/mesh/index.ts +1 -1
- package/src/mesh/model.ts +6 -0
- package/src/mesh/polygon.ts +6 -0
- package/src/mesh/polyline.ts +6 -0
- package/src/mesh/rtcRteHelper.ts +1 -1
- package/src/mesh/sdfText/batchedSdfText.ts +1223 -0
- package/src/mesh/sdfText/glyphBuffers.ts +222 -0
- package/src/mesh/sdfText/glyphSlots.test.ts +151 -0
- package/src/mesh/sdfText/glyphSlots.ts +98 -0
- package/src/mesh/sdfText/index.ts +10 -0
- package/src/mesh/sdfText/labelData.test.ts +178 -0
- package/src/mesh/sdfText/labelData.ts +187 -0
- package/src/mesh/{sdfText.test.ts → sdfText/layout.test.ts} +1 -1
- package/src/mesh/sdfText/layout.ts +306 -0
- package/src/mesh/sprite/instancedSprite.ts +33 -1
- package/src/utils/screen.ts +1 -1
- package/dist/assets/fontWorker-wz61edjR.js +0 -2
- package/dist/assets/fontWorker-wz61edjR.js.map +0 -1
- package/dist/assets/index-ywHSGImj.js.map +0 -1
- package/src/mesh/batchedSdfText.ts +0 -708
- package/src/mesh/sdfText.ts +0 -1189
- /package/dist/{draco_decoder.wasm → assets/draco_decoder-C32yEggz.wasm} +0 -0
- /package/dist/{draco_decoder2.wasm → assets/draco_decoder-Z1_iN-Ht.wasm} +0 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TextMesh as NavaraTextMesh,
|
|
3
|
+
TextMaterial as NavaraTextMaterial,
|
|
4
|
+
Transform,
|
|
5
|
+
} from "@navaramap/engine";
|
|
6
|
+
import type { FontManager } from "@navaramap/font";
|
|
7
|
+
import { degreeToRadian } from "@navaramap/three-api";
|
|
8
|
+
import {
|
|
9
|
+
Color,
|
|
10
|
+
type PerspectiveCamera,
|
|
11
|
+
Object3D,
|
|
12
|
+
ShaderMaterial,
|
|
13
|
+
Vector2,
|
|
14
|
+
} from "three";
|
|
15
|
+
import invariant from "tiny-invariant";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DECLUTTER_FADE_MS,
|
|
19
|
+
type DeclutterCandidate,
|
|
20
|
+
type DeclutterParticipant,
|
|
21
|
+
} from "../../declutter/types";
|
|
22
|
+
import type { EventContext } from "../../event/context";
|
|
23
|
+
import type { MaterialEnhancer } from "../../material/enhancer/MaterialEnhancer";
|
|
24
|
+
import {
|
|
25
|
+
createSdfTextMaterialEnhancer,
|
|
26
|
+
type SdfTextBaseMutates,
|
|
27
|
+
type SdfTextBaseProps,
|
|
28
|
+
type SdfTextBaseState,
|
|
29
|
+
} from "../../material/enhancer/sdfText";
|
|
30
|
+
import { GEOMETRY_TYPES } from "../constants";
|
|
31
|
+
import { InstancedMesh, type InstancedMeshOptions } from "../instanced";
|
|
32
|
+
import type { PickableMesh } from "../pickableMesh";
|
|
33
|
+
|
|
34
|
+
import { GlyphBuffers } from "./glyphBuffers";
|
|
35
|
+
import { GlyphSlotAllocator, type GlyphRun } from "./glyphSlots";
|
|
36
|
+
import { LabelDataTexture, LabelRow } from "./labelData";
|
|
37
|
+
import { ALIGN_FACTORS, buildLabelLayout, type LayoutOptions } from "./layout";
|
|
38
|
+
|
|
39
|
+
/** Reusable scratch to avoid per-frame / per-write allocations. */
|
|
40
|
+
const _tmpSize = new Vector2();
|
|
41
|
+
const _tmpColor = new Color();
|
|
42
|
+
|
|
43
|
+
type PositionsInfoBase = {
|
|
44
|
+
batchIDs: Float32Array<ArrayBufferLike> | null;
|
|
45
|
+
positionSize: number;
|
|
46
|
+
batchIDSize: number;
|
|
47
|
+
nPositions: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type PositionsInfo = PositionsInfoBase &
|
|
51
|
+
(
|
|
52
|
+
| {
|
|
53
|
+
RTE: true;
|
|
54
|
+
position: {
|
|
55
|
+
high: Float32Array<ArrayBufferLike>;
|
|
56
|
+
low: Float32Array<ArrayBufferLike>;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
RTE: false;
|
|
61
|
+
position: Float32Array<ArrayBufferLike>;
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One label in the batch.
|
|
67
|
+
*
|
|
68
|
+
* This replaces what used to be a whole `Mesh` + `ShaderMaterial` +
|
|
69
|
+
* `InstancedBufferGeometry` per label. Fields that the shader reads are
|
|
70
|
+
* mirrored into the label data texture on write; they are kept here too
|
|
71
|
+
* because the declutter pass and the glyph-retain bookkeeping need them on the
|
|
72
|
+
* CPU every frame.
|
|
73
|
+
*/
|
|
74
|
+
type LabelRecord = {
|
|
75
|
+
/** Row block in the label data texture, and this label's declutter handle. */
|
|
76
|
+
slot: number;
|
|
77
|
+
batchIndex: number;
|
|
78
|
+
batchId: number;
|
|
79
|
+
/** The text currently laid out into the glyph run. */
|
|
80
|
+
text: string;
|
|
81
|
+
/**
|
|
82
|
+
* 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
|
|
84
|
+
* slow prepare can't clobber a newer text that landed meanwhile.
|
|
85
|
+
*/
|
|
86
|
+
requestedText: string;
|
|
87
|
+
/** Glyph-instance slots this label owns, or null when it has no text. */
|
|
88
|
+
run: GlyphRun | null;
|
|
89
|
+
/** Unique atlas glyphs the current text renders. */
|
|
90
|
+
glyphKeys: bigint[];
|
|
91
|
+
/** The set currently retained in the atlas, or null when holding none. */
|
|
92
|
+
retainedKeys: bigint[] | null;
|
|
93
|
+
/**
|
|
94
|
+
* Visibility asked for by the material's `show` or the evaluator, held
|
|
95
|
+
* separately from {@link show} because it routinely arrives *before* the
|
|
96
|
+
* label has any text (the evaluator emits `show` ahead of `text`). Folding
|
|
97
|
+
* the two together would drop it.
|
|
98
|
+
*/
|
|
99
|
+
requestedShow: boolean;
|
|
100
|
+
/** Effective visibility: `requestedShow && text !== ""`. Mirrors the STATE
|
|
101
|
+
* row's `show` channel. */
|
|
102
|
+
show: boolean;
|
|
103
|
+
fontSize: number;
|
|
104
|
+
addHeight: number;
|
|
105
|
+
colorHex: number;
|
|
106
|
+
opacity: number;
|
|
107
|
+
/** Block metrics in ems; `widthEm === 0` means "no collision box". */
|
|
108
|
+
widthEm: number;
|
|
109
|
+
heightEm: number;
|
|
110
|
+
minYEm: number;
|
|
111
|
+
maxYEm: number;
|
|
112
|
+
/** Current animated hide factor and the placement target it fades toward. */
|
|
113
|
+
declutterHide: number;
|
|
114
|
+
declutterTarget: number;
|
|
115
|
+
/** Per-feature placement priority; overrides the layer value when defined. */
|
|
116
|
+
priorityOverride: number | undefined;
|
|
117
|
+
/** World-space anchor in ECEF meters (f64), for the declutter pass. */
|
|
118
|
+
anchor: Float64Array;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every text label in a tile-layer, drawn in a single call.
|
|
123
|
+
*
|
|
124
|
+
* Instances are glyphs, not labels: each label owns a contiguous run of glyph
|
|
125
|
+
* slots ({@link GlyphSlotAllocator}) and one row block in a float texture
|
|
126
|
+
* ({@link LabelDataTexture}) that the vertex shader reads through the
|
|
127
|
+
* `labelIndex` attribute. A label changing its text rewrites only its own run
|
|
128
|
+
* — and not even that when the new glyph count stays inside the run's
|
|
129
|
+
* power-of-two size class.
|
|
130
|
+
*
|
|
131
|
+
* Still extends {@link InstancedMesh} because `FeatureEvaluator` dispatches
|
|
132
|
+
* per-feature styling on `instanceof InstancedMesh`; the inherited child-mesh
|
|
133
|
+
* machinery (`allMeshes`, `markVisibility`) is deliberately unused.
|
|
134
|
+
*/
|
|
135
|
+
export class BatchedSdfTextMesh
|
|
136
|
+
extends InstancedMesh<Object3D>
|
|
137
|
+
implements PickableMesh, DeclutterParticipant
|
|
138
|
+
{
|
|
139
|
+
/**
|
|
140
|
+
* Geometry type of this mesh.
|
|
141
|
+
*/
|
|
142
|
+
readonly geometryType = GEOMETRY_TYPES.Text;
|
|
143
|
+
|
|
144
|
+
readonly ctx: EventContext;
|
|
145
|
+
/** The font identifier from material — may be a family name or a URL. */
|
|
146
|
+
private _fontIdentifier: string;
|
|
147
|
+
/** Per-batch text quality. All labels in the batch share it because they
|
|
148
|
+
* sample the same atlas texture; flipping quality requires a new batch. */
|
|
149
|
+
private _highQuality: boolean;
|
|
150
|
+
private _fontManager: FontManager;
|
|
151
|
+
private _needRender?: () => void;
|
|
152
|
+
/** Unsubscribe from the font manager's atlas-eviction notifications. */
|
|
153
|
+
private _unsubscribeEvict?: () => void;
|
|
154
|
+
/**
|
|
155
|
+
* Face URLs loaded by this mesh for font-family fonts.
|
|
156
|
+
* Each URL in this set has had loadFont() called exactly once by this mesh
|
|
157
|
+
* and must be balanced with unloadFont() on dispose or font change.
|
|
158
|
+
*/
|
|
159
|
+
private _loadedFaceUrls: Set<string>;
|
|
160
|
+
private _positions: PositionsInfo | null = null;
|
|
161
|
+
private _material: NavaraTextMaterial;
|
|
162
|
+
private _transform: Transform;
|
|
163
|
+
|
|
164
|
+
/** Labels in slot order — `_labels[slot]` is also the declutter handle map. */
|
|
165
|
+
private _labels: LabelRecord[] = [];
|
|
166
|
+
/** Sparse batchIndex → label; labels are created on first per-feature touch. */
|
|
167
|
+
private _labelByIndex: (LabelRecord | undefined)[] = [];
|
|
168
|
+
|
|
169
|
+
private _glyphs: GlyphBuffers;
|
|
170
|
+
private _slots = new GlyphSlotAllocator();
|
|
171
|
+
private _labelData: LabelDataTexture;
|
|
172
|
+
|
|
173
|
+
/** Layout inputs baked into glyph quads; a change forces a re-layout. */
|
|
174
|
+
private _maxWidth: number;
|
|
175
|
+
private _lineHeight: number;
|
|
176
|
+
private _textAlign: number;
|
|
177
|
+
|
|
178
|
+
/** Layer-level declutter settings, mirrored from the material. */
|
|
179
|
+
private _declutter: boolean;
|
|
180
|
+
private _declutterPriority: number;
|
|
181
|
+
/** Skips the per-frame fade walk entirely once everything has settled. */
|
|
182
|
+
private _declutterAnimating = false;
|
|
183
|
+
|
|
184
|
+
private _enhancer: MaterialEnhancer<
|
|
185
|
+
ShaderMaterial,
|
|
186
|
+
{ base?: SdfTextBaseProps },
|
|
187
|
+
SdfTextBaseState,
|
|
188
|
+
SdfTextBaseMutates,
|
|
189
|
+
readonly ["shader"]
|
|
190
|
+
>;
|
|
191
|
+
|
|
192
|
+
constructor(
|
|
193
|
+
ctx: EventContext,
|
|
194
|
+
m: NavaraTextMesh,
|
|
195
|
+
fontIdentifier: string,
|
|
196
|
+
options: InstancedMeshOptions,
|
|
197
|
+
loadedFaceUrls?: Set<string>,
|
|
198
|
+
) {
|
|
199
|
+
super(options);
|
|
200
|
+
this.ctx = ctx;
|
|
201
|
+
this._fontIdentifier = fontIdentifier;
|
|
202
|
+
invariant(ctx.fontManager);
|
|
203
|
+
this._fontManager = ctx.fontManager;
|
|
204
|
+
this._loadedFaceUrls = loadedFaceUrls ?? new Set();
|
|
205
|
+
|
|
206
|
+
// One getter call each: wasm getters clone, so these snapshots are owned
|
|
207
|
+
// by this batch and safe to use after the event object is freed.
|
|
208
|
+
const material = m.material;
|
|
209
|
+
this._material = material;
|
|
210
|
+
this._transform = m.transform;
|
|
211
|
+
this._highQuality = material.highQuality ?? false;
|
|
212
|
+
|
|
213
|
+
this._maxWidth = material.maxWidth ?? 0;
|
|
214
|
+
this._lineHeight = material.lineHeight ?? 1.0;
|
|
215
|
+
this._textAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
|
|
216
|
+
this._declutter = material.declutter ?? true;
|
|
217
|
+
this._declutterPriority = material.declutterPriority ?? 0;
|
|
218
|
+
|
|
219
|
+
this._positions = this.extractPositions(m);
|
|
220
|
+
this._glyphs = new GlyphBuffers();
|
|
221
|
+
this._labelData = new LabelDataTexture();
|
|
222
|
+
|
|
223
|
+
this.geometry = this._glyphs.geometry;
|
|
224
|
+
const mat = new ShaderMaterial({
|
|
225
|
+
transparent: true,
|
|
226
|
+
// depthWrite must stay enabled: the fragment shader's per-pixel outline
|
|
227
|
+
// depth offset (sdfText.frag.glsl) relies on fills writing a nearer depth
|
|
228
|
+
// so a neighbouring glyph's fill occludes this glyph's outline at overlaps
|
|
229
|
+
// — without depth writes that outline-seam fix becomes a no-op.
|
|
230
|
+
depthWrite: true,
|
|
231
|
+
});
|
|
232
|
+
this._enhancer = createSdfTextMaterialEnhancer(mat);
|
|
233
|
+
this._setupMaterial(mat, material);
|
|
234
|
+
this.material = mat;
|
|
235
|
+
this.renderOrder = options.renderOrder ?? this.renderOrder;
|
|
236
|
+
// Geometry positions are unit quads; the real transform happens in the
|
|
237
|
+
// shader, so a three.js bounding sphere would be meaningless here.
|
|
238
|
+
this.frustumCulled = false;
|
|
239
|
+
|
|
240
|
+
this._syncLabelDataUniform();
|
|
241
|
+
this._initLabels();
|
|
242
|
+
|
|
243
|
+
// When the shared atlas evicts glyphs, a still-in-flight glyph this batch
|
|
244
|
+
// already baked into a visible label may have had its rect reused. Rebuild
|
|
245
|
+
// any such stale label so its UVs and retains refresh.
|
|
246
|
+
this._unsubscribeEvict = this._fontManager.onAtlasEvicted(
|
|
247
|
+
this._fontIdentifier,
|
|
248
|
+
this._highQuality,
|
|
249
|
+
() => this._revalidateStaleLabels(),
|
|
250
|
+
);
|
|
251
|
+
ctx.declutter?.register(this);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
get fontIdentifier(): string {
|
|
255
|
+
return this._fontIdentifier;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
get highQuality(): boolean {
|
|
259
|
+
return this._highQuality;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Material ---
|
|
263
|
+
|
|
264
|
+
/** Mount the enhancer and install the batch's single per-frame hook. */
|
|
265
|
+
private _setupMaterial(
|
|
266
|
+
mat: ShaderMaterial,
|
|
267
|
+
material: NavaraTextMaterial,
|
|
268
|
+
): void {
|
|
269
|
+
this._enhancer.mount({
|
|
270
|
+
base: {
|
|
271
|
+
useRTE: this._positions?.RTE ?? false,
|
|
272
|
+
useMsdf: this._highQuality,
|
|
273
|
+
center: material.center
|
|
274
|
+
? [material.center.x, material.center.y]
|
|
275
|
+
: undefined,
|
|
276
|
+
sizeInMeters: material.sizeInMeters ?? true,
|
|
277
|
+
offsetDepth: material.offsetDepth ?? true,
|
|
278
|
+
outlineWidth: material.outlineWidth ?? 0,
|
|
279
|
+
outlineColor: material.outlineColor ?? 0x000000,
|
|
280
|
+
outlineOpacity: clamp01(material.outlineOpacity ?? 1.0),
|
|
281
|
+
showBackground: material.backgroundColor !== undefined,
|
|
282
|
+
backgroundColor: material.backgroundColor,
|
|
283
|
+
backgroundOutlineColor: material.borderColor ?? 0x000000,
|
|
284
|
+
backgroundOutlineWidth: material.borderWidth ?? 0.1,
|
|
285
|
+
depthTest: material.depthTest ?? true,
|
|
286
|
+
transparent: material.transparent ?? true,
|
|
287
|
+
rtcCenter: [this._transform.tx, this._transform.ty, this._transform.tz],
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Populate uniforms early (before onBeforeCompile fires).
|
|
292
|
+
const mutates = this._enhancer.mutates();
|
|
293
|
+
mutates.updateUniforms(mat.uniforms, this._enhancer.states());
|
|
294
|
+
|
|
295
|
+
mat.onBeforeCompile = this._enhancer.transformShader;
|
|
296
|
+
mat.customProgramCacheKey = this._enhancer.programCacheKey;
|
|
297
|
+
|
|
298
|
+
// One closure for the whole batch, where there used to be one per label.
|
|
299
|
+
const state = this._enhancer.states();
|
|
300
|
+
mat.onBeforeRender = (renderer, _scene, camera) => {
|
|
301
|
+
const pCam = camera as PerspectiveCamera;
|
|
302
|
+
mutates.updatePerFrame(
|
|
303
|
+
degreeToRadian(pCam.fov),
|
|
304
|
+
renderer.getDrawingBufferSize(_tmpSize).y / renderer.getPixelRatio(),
|
|
305
|
+
pCam.far,
|
|
306
|
+
camera.position.x,
|
|
307
|
+
camera.position.y,
|
|
308
|
+
camera.position.z,
|
|
309
|
+
camera.matrixWorldInverse,
|
|
310
|
+
state,
|
|
311
|
+
);
|
|
312
|
+
// Keep atlas-size uniforms in sync with the (possibly resized) shared
|
|
313
|
+
// DataTexture so glyph pixel rects always normalize to the right UV.
|
|
314
|
+
mutates.updateAtlasSizes();
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Re-point the shader at the label texture (it is swapped on grow). */
|
|
319
|
+
private _syncLabelDataUniform(): void {
|
|
320
|
+
const { texture, size } = this._labelData;
|
|
321
|
+
this._enhancer.mutates().setLabelDataTexture(texture, size.x, size.y);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- Label lifecycle ---
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A material-level text renders without the evaluator ever setting text, so
|
|
328
|
+
* that case needs a label per anchor up front. With per-feature texts (MVT)
|
|
329
|
+
* most features never receive one, so labels are otherwise created lazily on
|
|
330
|
+
* the first per-feature setter — a label slot is just an index plus a few
|
|
331
|
+
* float writes, unlike the mesh-per-label this replaced.
|
|
332
|
+
*/
|
|
333
|
+
private _initLabels(): void {
|
|
334
|
+
const info = this._positions;
|
|
335
|
+
if (!info || !this._material.text) return;
|
|
336
|
+
for (let i = 0; i < info.nPositions; i++) this._ensureLabel(i);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private _ensureLabel(batchIndex: number): LabelRecord | undefined {
|
|
340
|
+
const existing = this._labelByIndex[batchIndex];
|
|
341
|
+
if (existing) return existing;
|
|
342
|
+
|
|
343
|
+
const info = this._positions;
|
|
344
|
+
if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const material = this._material;
|
|
349
|
+
const slot = this._labels.length;
|
|
350
|
+
if (this._labelData.ensureCapacity(slot + 1)) {
|
|
351
|
+
this._syncLabelDataUniform();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const record: LabelRecord = {
|
|
355
|
+
slot,
|
|
356
|
+
batchIndex,
|
|
357
|
+
batchId: info.batchIDs ? info.batchIDs[batchIndex * info.batchIDSize] : 0,
|
|
358
|
+
text: "",
|
|
359
|
+
requestedText: "",
|
|
360
|
+
run: null,
|
|
361
|
+
glyphKeys: [],
|
|
362
|
+
retainedKeys: null,
|
|
363
|
+
requestedShow: material.show ?? true,
|
|
364
|
+
// No text yet, so nothing is shown regardless of `requestedShow`.
|
|
365
|
+
show: false,
|
|
366
|
+
fontSize: material.size ?? 16.0,
|
|
367
|
+
addHeight: material.height ?? 0.0,
|
|
368
|
+
colorHex: material.color ?? 0xffffff,
|
|
369
|
+
opacity: clamp01(material.opacity ?? 1.0),
|
|
370
|
+
widthEm: 0,
|
|
371
|
+
heightEm: 0,
|
|
372
|
+
minYEm: 0,
|
|
373
|
+
maxYEm: 1,
|
|
374
|
+
// Decluttered labels start hidden and fade in once the placement pass
|
|
375
|
+
// grants them space — otherwise dense tiles flash their full clutter for
|
|
376
|
+
// a frame before the first pass runs.
|
|
377
|
+
declutterHide: this._declutter ? 1 : 0,
|
|
378
|
+
declutterTarget: this._declutter ? 1 : 0,
|
|
379
|
+
priorityOverride: undefined,
|
|
380
|
+
anchor: new Float64Array(3),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
this._labels.push(record);
|
|
384
|
+
this._labelByIndex[batchIndex] = record;
|
|
385
|
+
|
|
386
|
+
this._writeAnchor(record);
|
|
387
|
+
this._writeStyle(record);
|
|
388
|
+
this._writeBox(record);
|
|
389
|
+
this._writeState(record);
|
|
390
|
+
return record;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --- Label data texture writes ---
|
|
394
|
+
|
|
395
|
+
private _writeAnchor(record: LabelRecord): void {
|
|
396
|
+
const info = this._positions;
|
|
397
|
+
if (!info) return;
|
|
398
|
+
const idx = record.batchIndex * info.positionSize;
|
|
399
|
+
const anchor = record.anchor;
|
|
400
|
+
|
|
401
|
+
if (info.RTE) {
|
|
402
|
+
const { high, low } = info.position;
|
|
403
|
+
const hx = high[idx];
|
|
404
|
+
const hy = high[idx + 1];
|
|
405
|
+
const hz = high[idx + 2] ?? 0;
|
|
406
|
+
const lx = low[idx];
|
|
407
|
+
const ly = low[idx + 1];
|
|
408
|
+
const lz = low[idx + 2] ?? 0;
|
|
409
|
+
this._labelData.setRow(
|
|
410
|
+
record.slot,
|
|
411
|
+
LabelRow.POSITION_HIGH_SIZE,
|
|
412
|
+
hx,
|
|
413
|
+
hy,
|
|
414
|
+
hz,
|
|
415
|
+
record.fontSize,
|
|
416
|
+
);
|
|
417
|
+
this._labelData.setRow(
|
|
418
|
+
record.slot,
|
|
419
|
+
LabelRow.POSITION_LOW_HEIGHT,
|
|
420
|
+
lx,
|
|
421
|
+
ly,
|
|
422
|
+
lz,
|
|
423
|
+
record.addHeight,
|
|
424
|
+
);
|
|
425
|
+
anchor[0] = hx + lx;
|
|
426
|
+
anchor[1] = hy + ly;
|
|
427
|
+
anchor[2] = hz + lz;
|
|
428
|
+
} else {
|
|
429
|
+
const p = info.position;
|
|
430
|
+
const px = p[idx];
|
|
431
|
+
const py = p[idx + 1];
|
|
432
|
+
const pz = p[idx + 2] ?? 0;
|
|
433
|
+
const { tx, ty, tz } = this._transform;
|
|
434
|
+
this._labelData.setRow(
|
|
435
|
+
record.slot,
|
|
436
|
+
LabelRow.POSITION_HIGH_SIZE,
|
|
437
|
+
px,
|
|
438
|
+
py,
|
|
439
|
+
pz,
|
|
440
|
+
record.fontSize,
|
|
441
|
+
);
|
|
442
|
+
this._labelData.setRow(
|
|
443
|
+
record.slot,
|
|
444
|
+
LabelRow.POSITION_LOW_HEIGHT,
|
|
445
|
+
0,
|
|
446
|
+
0,
|
|
447
|
+
0,
|
|
448
|
+
record.addHeight,
|
|
449
|
+
);
|
|
450
|
+
anchor[0] = px + tx;
|
|
451
|
+
anchor[1] = py + ty;
|
|
452
|
+
anchor[2] = pz + tz;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private _writeStyle(record: LabelRecord): void {
|
|
457
|
+
_tmpColor.setHex(record.colorHex);
|
|
458
|
+
this._labelData.setRow(
|
|
459
|
+
record.slot,
|
|
460
|
+
LabelRow.COLOR_OPACITY,
|
|
461
|
+
_tmpColor.r,
|
|
462
|
+
_tmpColor.g,
|
|
463
|
+
_tmpColor.b,
|
|
464
|
+
record.opacity,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private _writeBox(record: LabelRecord): void {
|
|
469
|
+
this._labelData.setRow(
|
|
470
|
+
record.slot,
|
|
471
|
+
LabelRow.BOX,
|
|
472
|
+
record.widthEm,
|
|
473
|
+
record.heightEm,
|
|
474
|
+
record.minYEm,
|
|
475
|
+
record.maxYEm,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private _writeState(record: LabelRecord): void {
|
|
480
|
+
this._labelData.setRow(
|
|
481
|
+
record.slot,
|
|
482
|
+
LabelRow.STATE,
|
|
483
|
+
record.declutterHide,
|
|
484
|
+
record.batchId,
|
|
485
|
+
record.show ? 1 : 0,
|
|
486
|
+
0,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private _writeFontSize(record: LabelRecord): void {
|
|
491
|
+
this._labelData.setComponent(
|
|
492
|
+
record.slot,
|
|
493
|
+
LabelRow.POSITION_HIGH_SIZE,
|
|
494
|
+
3,
|
|
495
|
+
record.fontSize,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private _writeAddHeight(record: LabelRecord): void {
|
|
500
|
+
this._labelData.setComponent(
|
|
501
|
+
record.slot,
|
|
502
|
+
LabelRow.POSITION_LOW_HEIGHT,
|
|
503
|
+
3,
|
|
504
|
+
record.addHeight,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private _writeShow(record: LabelRecord): void {
|
|
509
|
+
this._labelData.setComponent(
|
|
510
|
+
record.slot,
|
|
511
|
+
LabelRow.STATE,
|
|
512
|
+
2,
|
|
513
|
+
record.show ? 1 : 0,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private _writeDeclutterHide(record: LabelRecord): void {
|
|
518
|
+
this._labelData.setComponent(
|
|
519
|
+
record.slot,
|
|
520
|
+
LabelRow.STATE,
|
|
521
|
+
0,
|
|
522
|
+
record.declutterHide,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// --- Glyph runs ---
|
|
527
|
+
|
|
528
|
+
private _layoutOptions(text: string): LayoutOptions {
|
|
529
|
+
return {
|
|
530
|
+
text,
|
|
531
|
+
maxWidth: this._maxWidth,
|
|
532
|
+
lineHeight: this._lineHeight,
|
|
533
|
+
textAlign: this._textAlign,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Shape `text`, lay it out, and write the label's glyph run. Assumes the
|
|
539
|
+
* text is already prepared in the font worker.
|
|
540
|
+
*/
|
|
541
|
+
private _applyText(record: LabelRecord, text: string): void {
|
|
542
|
+
record.text = text;
|
|
543
|
+
record.requestedText = text;
|
|
544
|
+
|
|
545
|
+
if (!text) {
|
|
546
|
+
this._releaseRun(record);
|
|
547
|
+
record.widthEm = 0;
|
|
548
|
+
record.heightEm = 0;
|
|
549
|
+
this._writeBox(record);
|
|
550
|
+
this._setGlyphKeys(record, []);
|
|
551
|
+
this._recomputeShow(record);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const shapeResult = this._fontManager.shapeText(
|
|
556
|
+
this._fontIdentifier,
|
|
557
|
+
text,
|
|
558
|
+
this._highQuality,
|
|
559
|
+
);
|
|
560
|
+
if (!shapeResult) {
|
|
561
|
+
this._releaseRun(record);
|
|
562
|
+
record.widthEm = 0;
|
|
563
|
+
this._writeBox(record);
|
|
564
|
+
this._setGlyphKeys(record, []);
|
|
565
|
+
this._recomputeShow(record);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const layout = buildLabelLayout(shapeResult, this._layoutOptions(text));
|
|
570
|
+
|
|
571
|
+
record.widthEm = layout.widthEm;
|
|
572
|
+
record.heightEm = layout.heightEm;
|
|
573
|
+
record.minYEm = layout.minYEm;
|
|
574
|
+
record.maxYEm = layout.maxYEm;
|
|
575
|
+
this._writeBox(record);
|
|
576
|
+
|
|
577
|
+
if (layout.quads.length === 0) {
|
|
578
|
+
this._releaseRun(record);
|
|
579
|
+
this._setGlyphKeys(record, layout.glyphKeys);
|
|
580
|
+
this._recomputeShow(record);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// One extra slot for the background quad, which always leads the run so it
|
|
585
|
+
// draws before the label's glyphs.
|
|
586
|
+
const needed = layout.quads.length + 1;
|
|
587
|
+
const previous = record.run;
|
|
588
|
+
const run = this._slots.realloc(previous, needed);
|
|
589
|
+
record.run = run;
|
|
590
|
+
|
|
591
|
+
this._glyphs.ensureCapacity(this._slots.highWater);
|
|
592
|
+
this._glyphs.setInstanceCount(this._slots.highWater);
|
|
593
|
+
|
|
594
|
+
// A relocated run leaves its old slots live in the buffer; blank them so
|
|
595
|
+
// they don't keep drawing the previous text.
|
|
596
|
+
if (previous && previous.start !== run.start) {
|
|
597
|
+
this._glyphs.clearRun(previous.start, previous.capacity);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
this._glyphs.writeRun(
|
|
601
|
+
run.start,
|
|
602
|
+
run.capacity,
|
|
603
|
+
record.slot,
|
|
604
|
+
layout.quads,
|
|
605
|
+
true,
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
this._setGlyphKeys(record, layout.glyphKeys);
|
|
609
|
+
this._recomputeShow(record);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Return a label's glyph slots and blank them. */
|
|
613
|
+
private _releaseRun(record: LabelRecord): void {
|
|
614
|
+
if (!record.run) return;
|
|
615
|
+
this._glyphs.clearRun(record.run.start, record.run.capacity);
|
|
616
|
+
this._slots.free(record.run);
|
|
617
|
+
record.run = null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- Atlas glyph references ---
|
|
621
|
+
|
|
622
|
+
/** Replace the retained glyph set, releasing the old references and (if
|
|
623
|
+
* shown) retaining the new ones. */
|
|
624
|
+
private _setGlyphKeys(record: LabelRecord, keys: bigint[]): void {
|
|
625
|
+
if (record.retainedKeys) {
|
|
626
|
+
this._fontManager.releaseGlyphs(
|
|
627
|
+
this._fontIdentifier,
|
|
628
|
+
this._highQuality,
|
|
629
|
+
record.retainedKeys,
|
|
630
|
+
);
|
|
631
|
+
record.retainedKeys = null;
|
|
632
|
+
}
|
|
633
|
+
record.glyphKeys = keys;
|
|
634
|
+
this._syncGlyphRefs(record);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reconcile atlas references with visibility: retain glyphs while the label
|
|
639
|
+
* is shown, release them when hidden. If shown after the glyphs were evicted
|
|
640
|
+
* (cache no longer prepared), re-prepare to re-rasterize them and rebuild
|
|
641
|
+
* with fresh metrics.
|
|
642
|
+
*/
|
|
643
|
+
private _syncGlyphRefs(record: LabelRecord): void {
|
|
644
|
+
const visible = record.show && record.glyphKeys.length > 0;
|
|
645
|
+
|
|
646
|
+
if (visible && !record.retainedKeys) {
|
|
647
|
+
if (
|
|
648
|
+
!this._fontManager.isTextPrepared(
|
|
649
|
+
this._fontIdentifier,
|
|
650
|
+
record.text,
|
|
651
|
+
this._highQuality,
|
|
652
|
+
)
|
|
653
|
+
) {
|
|
654
|
+
// Glyphs were evicted while hidden; re-rasterize then rebuild (which
|
|
655
|
+
// re-enters here with the fresh set and retains it).
|
|
656
|
+
const text = record.text;
|
|
657
|
+
this._fontManager
|
|
658
|
+
.prepareText(
|
|
659
|
+
this._fontIdentifier,
|
|
660
|
+
text,
|
|
661
|
+
this._highQuality,
|
|
662
|
+
this._loadedFaceUrls,
|
|
663
|
+
)
|
|
664
|
+
.then(() => {
|
|
665
|
+
if (record.text !== text) return;
|
|
666
|
+
this._applyText(record, text);
|
|
667
|
+
this._markDeclutterDirty();
|
|
668
|
+
this._needRender?.();
|
|
669
|
+
})
|
|
670
|
+
.catch((err: unknown) => {
|
|
671
|
+
console.error("SDF text: re-prepare on show failed:", err);
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
this._fontManager.retainGlyphs(
|
|
676
|
+
this._fontIdentifier,
|
|
677
|
+
this._highQuality,
|
|
678
|
+
record.glyphKeys,
|
|
679
|
+
);
|
|
680
|
+
record.retainedKeys = record.glyphKeys;
|
|
681
|
+
} else if (!visible && record.retainedKeys) {
|
|
682
|
+
this._fontManager.releaseGlyphs(
|
|
683
|
+
this._fontIdentifier,
|
|
684
|
+
this._highQuality,
|
|
685
|
+
record.retainedKeys,
|
|
686
|
+
);
|
|
687
|
+
record.retainedKeys = null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Fold `requestedShow` and "has text" into the effective visibility, pushing
|
|
693
|
+
* it to the shader and reconciling atlas retains when it flips.
|
|
694
|
+
*/
|
|
695
|
+
private _recomputeShow(record: LabelRecord): void {
|
|
696
|
+
const next = record.requestedShow && !!record.text;
|
|
697
|
+
if (record.show === next) return;
|
|
698
|
+
record.show = next;
|
|
699
|
+
this._writeShow(record);
|
|
700
|
+
this._syncGlyphRefs(record);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* After an atlas eviction, re-lay-out any shown label whose text is now
|
|
705
|
+
* stale (its pinned glyphs may have been evicted before the retain landed
|
|
706
|
+
* and its rect reused). Fresh labels short-circuit on the cheap
|
|
707
|
+
* `isTextPrepared` check, so this is inexpensive to run per eviction.
|
|
708
|
+
*/
|
|
709
|
+
private _revalidateStaleLabels(): void {
|
|
710
|
+
const q = this._highQuality;
|
|
711
|
+
for (const record of this._labels) {
|
|
712
|
+
const text = record.text;
|
|
713
|
+
if (!record.show || !text) continue;
|
|
714
|
+
if (this._fontManager.isTextPrepared(this._fontIdentifier, text, q)) {
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
this._fontManager
|
|
718
|
+
.prepareText(this._fontIdentifier, text, q, this._loadedFaceUrls)
|
|
719
|
+
.then(() => {
|
|
720
|
+
// Text may have changed (or the label hidden) while re-preparing.
|
|
721
|
+
if (record.text !== text || !record.show) return;
|
|
722
|
+
this._refreshAtlasTextures();
|
|
723
|
+
// Force a rebuild even though the text string is unchanged: the
|
|
724
|
+
// baked atlas rects and glyph retains must refresh.
|
|
725
|
+
this._applyText(record, text);
|
|
726
|
+
this._markDeclutterDirty();
|
|
727
|
+
this._needRender?.();
|
|
728
|
+
})
|
|
729
|
+
.catch((err: unknown) => {
|
|
730
|
+
console.error("Failed to revalidate text after eviction:", err);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private _refreshAtlasTextures(): void {
|
|
736
|
+
const mutates = this._enhancer.mutates();
|
|
737
|
+
const tex = this._fontManager.getAtlasTexture(
|
|
738
|
+
this._fontIdentifier,
|
|
739
|
+
this._highQuality,
|
|
740
|
+
);
|
|
741
|
+
if (tex) mutates.setAtlasTexture({ value: tex });
|
|
742
|
+
mutates.setColorAtlasTexture({
|
|
743
|
+
value: this._fontManager.getColorAtlasTexture(
|
|
744
|
+
this._fontIdentifier,
|
|
745
|
+
this._highQuality,
|
|
746
|
+
),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// --- DeclutterParticipant ---
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Build each shown label's collision box. The local box mirrors the vertex
|
|
754
|
+
* shader's layout: glyphs span [0, textWidth] x [bgMinY, bgMaxY] in em
|
|
755
|
+
* units, shifted by the `center` anchor and scaled by the font size.
|
|
756
|
+
*/
|
|
757
|
+
collectDeclutterCandidates(out: DeclutterCandidate[]): void {
|
|
758
|
+
if (!this.visible || !this._declutter) return;
|
|
759
|
+
const state = this._enhancer.states();
|
|
760
|
+
const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
|
|
761
|
+
const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
|
|
762
|
+
|
|
763
|
+
for (const record of this._labels) {
|
|
764
|
+
if (!record.show || record.widthEm <= 0) continue;
|
|
765
|
+
const size = record.fontSize;
|
|
766
|
+
if (size <= 0) continue;
|
|
767
|
+
|
|
768
|
+
const w = record.widthEm;
|
|
769
|
+
const h = record.heightEm;
|
|
770
|
+
out.push({
|
|
771
|
+
anchorX: record.anchor[0],
|
|
772
|
+
anchorY: record.anchor[1],
|
|
773
|
+
anchorZ: record.anchor[2],
|
|
774
|
+
addHeight: record.addHeight,
|
|
775
|
+
minX: (0 - cx * w) * size,
|
|
776
|
+
maxX: (w - cx * w) * size,
|
|
777
|
+
minY: (record.minYEm - cy * h) * size,
|
|
778
|
+
maxY: (record.maxYEm - cy * h) * size,
|
|
779
|
+
sizeInMeters: state.sizeInMeters,
|
|
780
|
+
priority: record.priorityOverride ?? this._declutterPriority,
|
|
781
|
+
isShown: record.declutterTarget === 0,
|
|
782
|
+
owner: this,
|
|
783
|
+
handle: record.slot,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
applyDeclutter(handle: number, hidden: boolean): void {
|
|
789
|
+
const record = this._labels[handle];
|
|
790
|
+
if (!record) return;
|
|
791
|
+
const target = hidden ? 1 : 0;
|
|
792
|
+
if (record.declutterTarget === target) return;
|
|
793
|
+
record.declutterTarget = target;
|
|
794
|
+
if (record.declutterHide !== target) this._declutterAnimating = true;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
stepDeclutterFade(deltaMs: number): boolean {
|
|
798
|
+
if (!this._declutterAnimating) return false;
|
|
799
|
+
|
|
800
|
+
const step = deltaMs / DECLUTTER_FADE_MS;
|
|
801
|
+
let stillAnimating = false;
|
|
802
|
+
for (const record of this._labels) {
|
|
803
|
+
const target = record.declutterTarget;
|
|
804
|
+
let value = record.declutterHide;
|
|
805
|
+
if (value === target) continue;
|
|
806
|
+
value =
|
|
807
|
+
value < target
|
|
808
|
+
? Math.min(value + step, target)
|
|
809
|
+
: Math.max(value - step, target);
|
|
810
|
+
record.declutterHide = value;
|
|
811
|
+
this._writeDeclutterHide(record);
|
|
812
|
+
if (value !== target) stillAnimating = true;
|
|
813
|
+
}
|
|
814
|
+
this._declutterAnimating = stillAnimating;
|
|
815
|
+
return stillAnimating;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Candidates changed (text, style, position, visibility): ask the shared
|
|
819
|
+
* declutter pass to re-place on its next update. */
|
|
820
|
+
private _markDeclutterDirty(): void {
|
|
821
|
+
this.ctx.declutter?.markDirty();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
override setActive(active: boolean) {
|
|
825
|
+
super.setActive(active);
|
|
826
|
+
this._markDeclutterDirty();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// --- Picking ---
|
|
830
|
+
|
|
831
|
+
override onBeforePicking() {
|
|
832
|
+
this._enhancer.update({ base: { pickable: true } });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
override onAfterPicking() {
|
|
836
|
+
this._enhancer.update({ base: { pickable: false } });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
override getRenderable(): Object3D {
|
|
840
|
+
return this;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// --- Geometry / material updates from the engine ---
|
|
844
|
+
|
|
845
|
+
private extractPositions(m: NavaraTextMesh): PositionsInfo | null {
|
|
846
|
+
const { buf } = this.ctx;
|
|
847
|
+
const g = m.geometry;
|
|
848
|
+
|
|
849
|
+
const batchIdsData = g.batch_ids;
|
|
850
|
+
const batchIDs = buf.removeF32(batchIdsData.data);
|
|
851
|
+
const batchIDSize = batchIdsData.size;
|
|
852
|
+
|
|
853
|
+
const positionData = g.position;
|
|
854
|
+
const position = positionData
|
|
855
|
+
? buf.removeF32(positionData.data)
|
|
856
|
+
: undefined;
|
|
857
|
+
|
|
858
|
+
if (position && positionData) {
|
|
859
|
+
const positionSize = positionData.size;
|
|
860
|
+
const nPositions = position.length / positionSize;
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
position,
|
|
864
|
+
batchIDs,
|
|
865
|
+
batchIDSize,
|
|
866
|
+
positionSize,
|
|
867
|
+
nPositions,
|
|
868
|
+
RTE: false,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const positionHighData = g.position_3d_high;
|
|
873
|
+
const positionLowData = g.position_3d_low;
|
|
874
|
+
const positionHigh = positionHighData
|
|
875
|
+
? buf.removeF32(positionHighData.data)
|
|
876
|
+
: undefined;
|
|
877
|
+
const positionLow = positionLowData
|
|
878
|
+
? buf.removeF32(positionLowData.data)
|
|
879
|
+
: undefined;
|
|
880
|
+
|
|
881
|
+
if (positionHigh && positionLow && positionHighData && positionLowData) {
|
|
882
|
+
const positionLowSize = positionLowData.size;
|
|
883
|
+
const positionHighSize = positionHighData.size;
|
|
884
|
+
invariant(
|
|
885
|
+
positionLowSize === positionHighSize,
|
|
886
|
+
"Position high and low size mismatch",
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
const nPositions = positionHigh.length / positionHighSize;
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
position: { high: positionHigh, low: positionLow },
|
|
893
|
+
batchIDs,
|
|
894
|
+
batchIDSize,
|
|
895
|
+
positionSize: positionHighSize,
|
|
896
|
+
nPositions,
|
|
897
|
+
RTE: true,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async _update(m: NavaraTextMesh, needRender?: () => void) {
|
|
905
|
+
if (needRender) this._needRender = needRender;
|
|
906
|
+
|
|
907
|
+
const material = m.material;
|
|
908
|
+
const text = material.text ?? "";
|
|
909
|
+
this._material = material;
|
|
910
|
+
this._transform = m.transform;
|
|
911
|
+
|
|
912
|
+
const positionInfo = this.extractPositions(m);
|
|
913
|
+
if (positionInfo) {
|
|
914
|
+
invariant(
|
|
915
|
+
this._positions === null ||
|
|
916
|
+
positionInfo.nPositions === this._positions.nPositions,
|
|
917
|
+
"Number of positions in the updated geometry must match the initial geometry",
|
|
918
|
+
);
|
|
919
|
+
this._positions = positionInfo;
|
|
920
|
+
this._enhancer
|
|
921
|
+
.mutates()
|
|
922
|
+
.setRtcCenter([m.transform.tx, m.transform.ty, m.transform.tz]);
|
|
923
|
+
for (const record of this._labels) this._writeAnchor(record);
|
|
924
|
+
// Anchors moved (e.g. terrain height resolution) — re-place labels.
|
|
925
|
+
this._markDeclutterDirty();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// A non-empty default text renders without per-feature setText calls, so
|
|
929
|
+
// any lazily-skipped labels must exist before `_applyUpdate`.
|
|
930
|
+
if (text) this._initLabels();
|
|
931
|
+
|
|
932
|
+
const fontIdentifier = m.material.font ?? this._fontIdentifier;
|
|
933
|
+
const needFontUpdate = fontIdentifier !== this._fontIdentifier;
|
|
934
|
+
|
|
935
|
+
// Quality is immutable per batch (see _highQuality docs); use the batch's
|
|
936
|
+
// quality everywhere, ignoring `m.material.highQuality` on updates.
|
|
937
|
+
const q = this._highQuality;
|
|
938
|
+
|
|
939
|
+
if (needFontUpdate) {
|
|
940
|
+
// Unload old font resources.
|
|
941
|
+
if (this._loadedFaceUrls.size > 0) {
|
|
942
|
+
await Promise.all(
|
|
943
|
+
[...this._loadedFaceUrls].map((url) =>
|
|
944
|
+
this._fontManager.unloadFont(url, q),
|
|
945
|
+
),
|
|
946
|
+
);
|
|
947
|
+
this._loadedFaceUrls.clear();
|
|
948
|
+
} else if (!this._fontManager.isFamily(this._fontIdentifier)) {
|
|
949
|
+
await this._fontManager.unloadFont(this._fontIdentifier, q);
|
|
950
|
+
}
|
|
951
|
+
// For standalone new fonts load upfront; family faces are loaded lazily
|
|
952
|
+
// by prepareText below.
|
|
953
|
+
if (!this._fontManager.isFamily(fontIdentifier)) {
|
|
954
|
+
await this._fontManager.loadFont(fontIdentifier, q);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
this._fontIdentifier = fontIdentifier;
|
|
958
|
+
|
|
959
|
+
// If the text hasn't been prepared in the worker yet, schedule async preparation
|
|
960
|
+
if (
|
|
961
|
+
(needFontUpdate || text) &&
|
|
962
|
+
!this._fontManager.isTextPrepared(this._fontIdentifier, text, q)
|
|
963
|
+
) {
|
|
964
|
+
this._fontManager
|
|
965
|
+
.prepareText(this._fontIdentifier, text, q, this._loadedFaceUrls)
|
|
966
|
+
.then(() => {
|
|
967
|
+
this._applyUpdate(material, needRender, needFontUpdate);
|
|
968
|
+
})
|
|
969
|
+
.catch((err: unknown) => {
|
|
970
|
+
console.error("Failed to prepare text:", err);
|
|
971
|
+
needRender?.();
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
this._applyUpdate(material, needRender, needFontUpdate);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private _applyUpdate(
|
|
980
|
+
material: NavaraTextMaterial,
|
|
981
|
+
needRender?: () => void,
|
|
982
|
+
forceUpdate = false,
|
|
983
|
+
) {
|
|
984
|
+
this._refreshAtlasTextures();
|
|
985
|
+
|
|
986
|
+
// Layout properties are baked into the glyph instances, so a change forces
|
|
987
|
+
// a re-layout even when the text itself is unchanged.
|
|
988
|
+
const nextMaxWidth = material.maxWidth ?? 0;
|
|
989
|
+
const nextLineHeight = material.lineHeight ?? 1.0;
|
|
990
|
+
const nextTextAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
|
|
991
|
+
const layoutChanged =
|
|
992
|
+
nextMaxWidth !== this._maxWidth ||
|
|
993
|
+
nextLineHeight !== this._lineHeight ||
|
|
994
|
+
nextTextAlign !== this._textAlign;
|
|
995
|
+
this._maxWidth = nextMaxWidth;
|
|
996
|
+
this._lineHeight = nextLineHeight;
|
|
997
|
+
this._textAlign = nextTextAlign;
|
|
998
|
+
|
|
999
|
+
// Read before applying visibility: the declutter pass consults these even
|
|
1000
|
+
// while other style state is skipped for hidden labels.
|
|
1001
|
+
const nextDeclutter = material.declutter ?? true;
|
|
1002
|
+
if (this._declutter && !nextDeclutter) {
|
|
1003
|
+
// Leaving declutter mode: clear any hide the pass applied — the batch
|
|
1004
|
+
// stops producing candidates, so nothing else would ever re-show it.
|
|
1005
|
+
for (const record of this._labels) {
|
|
1006
|
+
record.declutterTarget = 0;
|
|
1007
|
+
if (record.declutterHide !== 0) this._declutterAnimating = true;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
this._declutter = nextDeclutter;
|
|
1011
|
+
this._declutterPriority = material.declutterPriority ?? 0;
|
|
1012
|
+
|
|
1013
|
+
this._enhancer.update({
|
|
1014
|
+
base: {
|
|
1015
|
+
center: material.center
|
|
1016
|
+
? [material.center.x, material.center.y]
|
|
1017
|
+
: [0.5, 0.0],
|
|
1018
|
+
sizeInMeters: material.sizeInMeters ?? true,
|
|
1019
|
+
offsetDepth: material.offsetDepth ?? true,
|
|
1020
|
+
outlineWidth: material.outlineWidth ?? 0,
|
|
1021
|
+
outlineColor: material.outlineColor ?? 0x000000,
|
|
1022
|
+
outlineOpacity: clamp01(material.outlineOpacity ?? 1.0),
|
|
1023
|
+
showBackground: material.backgroundColor !== undefined,
|
|
1024
|
+
backgroundColor: material.backgroundColor,
|
|
1025
|
+
backgroundOutlineColor: material.borderColor ?? 0x000000,
|
|
1026
|
+
backgroundOutlineWidth: material.borderWidth ?? 0,
|
|
1027
|
+
depthTest: material.depthTest ?? true,
|
|
1028
|
+
transparent: material.transparent ?? true,
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
|
|
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.
|
|
1035
|
+
const materialText = material.text;
|
|
1036
|
+
const materialShow = material.show ?? true;
|
|
1037
|
+
const colorHex = material.color ?? 0xffffff;
|
|
1038
|
+
const opacity = clamp01(material.opacity ?? 1.0);
|
|
1039
|
+
const fontSize = material.size ?? 16.0;
|
|
1040
|
+
const addHeight = material.height ?? 0;
|
|
1041
|
+
|
|
1042
|
+
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;
|
|
1052
|
+
if (materialText !== undefined && materialText !== "") {
|
|
1053
|
+
this._applyText(record, materialText);
|
|
1054
|
+
} else if (forceUpdate || layoutChanged) {
|
|
1055
|
+
// Font or layout changed — re-lay-out existing text.
|
|
1056
|
+
this._applyText(record, record.text);
|
|
1057
|
+
}
|
|
1058
|
+
this._recomputeShow(record);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
this._markDeclutterDirty();
|
|
1062
|
+
if (needRender) needRender();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// --- Per-feature API (the FeatureEvaluator contract) ---
|
|
1066
|
+
|
|
1067
|
+
setTextByBatchIndex(batchIndex: number, text: string) {
|
|
1068
|
+
// An empty text on a label that doesn't exist yet changes nothing (a
|
|
1069
|
+
// lazily-skipped label is exactly an invisible empty-text label); only a
|
|
1070
|
+
// non-empty text forces one into existence.
|
|
1071
|
+
const record = text
|
|
1072
|
+
? this._ensureLabel(batchIndex)
|
|
1073
|
+
: this._labelByIndex[batchIndex];
|
|
1074
|
+
if (!record) return;
|
|
1075
|
+
|
|
1076
|
+
// Record the intent up front so a slower prepare for an older text can
|
|
1077
|
+
// detect that it has been superseded.
|
|
1078
|
+
record.requestedText = text;
|
|
1079
|
+
|
|
1080
|
+
if (
|
|
1081
|
+
text &&
|
|
1082
|
+
!this._fontManager.isTextPrepared(
|
|
1083
|
+
this._fontIdentifier,
|
|
1084
|
+
text,
|
|
1085
|
+
this._highQuality,
|
|
1086
|
+
)
|
|
1087
|
+
) {
|
|
1088
|
+
this._fontManager
|
|
1089
|
+
.prepareText(
|
|
1090
|
+
this._fontIdentifier,
|
|
1091
|
+
text,
|
|
1092
|
+
this._highQuality,
|
|
1093
|
+
this._loadedFaceUrls,
|
|
1094
|
+
)
|
|
1095
|
+
.then(() => {
|
|
1096
|
+
// A newer text may have landed while the font loaded.
|
|
1097
|
+
if (record.requestedText !== text) return;
|
|
1098
|
+
this._refreshAtlasTextures();
|
|
1099
|
+
this._applyText(record, text);
|
|
1100
|
+
this._markDeclutterDirty();
|
|
1101
|
+
this._needRender?.();
|
|
1102
|
+
})
|
|
1103
|
+
.catch((err: unknown) => {
|
|
1104
|
+
console.error("Failed to prepare text:", err);
|
|
1105
|
+
this._needRender?.();
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
this._applyText(record, text);
|
|
1111
|
+
this._markDeclutterDirty();
|
|
1112
|
+
this._needRender?.();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
override setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
|
|
1116
|
+
const record = this._ensureLabel(batchIndex);
|
|
1117
|
+
if (!record) return;
|
|
1118
|
+
record.colorHex = color.getHex();
|
|
1119
|
+
this._writeStyle(record);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
override setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
|
|
1123
|
+
// A label that doesn't exist yet is already effectively `show:false`, so
|
|
1124
|
+
// only `show:true` needs to force one into existence — the common MVT case
|
|
1125
|
+
// is thousands of features that stay hidden and never get a label.
|
|
1126
|
+
const record = rawVisible
|
|
1127
|
+
? this._ensureLabel(batchIndex)
|
|
1128
|
+
: this._labelByIndex[batchIndex];
|
|
1129
|
+
if (!record) return;
|
|
1130
|
+
record.requestedShow = rawVisible;
|
|
1131
|
+
this._recomputeShow(record);
|
|
1132
|
+
this._markDeclutterDirty();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
override setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
|
|
1136
|
+
const record = this._ensureLabel(batchIndex);
|
|
1137
|
+
if (!record) return;
|
|
1138
|
+
record.addHeight = height;
|
|
1139
|
+
this._writeAddHeight(record);
|
|
1140
|
+
this._markDeclutterDirty();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
|
|
1144
|
+
const record = this._ensureLabel(batchIndex);
|
|
1145
|
+
if (!record) return;
|
|
1146
|
+
record.fontSize = Number.isFinite(size)
|
|
1147
|
+
? Math.max(0.0, size)
|
|
1148
|
+
: record.fontSize;
|
|
1149
|
+
this._writeFontSize(record);
|
|
1150
|
+
this._markDeclutterDirty();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
|
|
1154
|
+
const record = this._ensureLabel(batchIndex);
|
|
1155
|
+
if (!record) return;
|
|
1156
|
+
record.opacity = clamp01(opacity);
|
|
1157
|
+
this._writeStyle(record);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
setFeatureDeclutterPriorityByBatchIndex(
|
|
1161
|
+
batchIndex: number,
|
|
1162
|
+
priority: number,
|
|
1163
|
+
) {
|
|
1164
|
+
const record = this._ensureLabel(batchIndex);
|
|
1165
|
+
if (!record) return;
|
|
1166
|
+
record.priorityOverride = priority;
|
|
1167
|
+
this._markDeclutterDirty();
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Labels are no longer three.js objects, so there is no child mesh to hand
|
|
1172
|
+
* back. Overridden to stop the base class returning an unrelated object from
|
|
1173
|
+
* its (unused) `allMeshes` array.
|
|
1174
|
+
*/
|
|
1175
|
+
override getMeshByBatchIndex(): undefined {
|
|
1176
|
+
return undefined;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// --- Cleanup ---
|
|
1180
|
+
|
|
1181
|
+
dispose() {
|
|
1182
|
+
this.ctx.declutter?.unregister(this);
|
|
1183
|
+
this._unsubscribeEvict?.();
|
|
1184
|
+
this._unsubscribeEvict = undefined;
|
|
1185
|
+
|
|
1186
|
+
const q = this._highQuality;
|
|
1187
|
+
// Release every label's atlas retains. The per-label meshes this replaced
|
|
1188
|
+
// were never disposed (they were detached from `children` while hidden),
|
|
1189
|
+
// so these references used to leak.
|
|
1190
|
+
for (const record of this._labels) {
|
|
1191
|
+
if (!record.retainedKeys) continue;
|
|
1192
|
+
this._fontManager.releaseGlyphs(
|
|
1193
|
+
this._fontIdentifier,
|
|
1194
|
+
q,
|
|
1195
|
+
record.retainedKeys,
|
|
1196
|
+
);
|
|
1197
|
+
record.retainedKeys = null;
|
|
1198
|
+
}
|
|
1199
|
+
this._labels.length = 0;
|
|
1200
|
+
this._labelByIndex.length = 0;
|
|
1201
|
+
|
|
1202
|
+
this._labelData.dispose();
|
|
1203
|
+
this._glyphs.dispose();
|
|
1204
|
+
(this.material as ShaderMaterial).dispose();
|
|
1205
|
+
|
|
1206
|
+
const unload =
|
|
1207
|
+
this._loadedFaceUrls.size > 0
|
|
1208
|
+
? Promise.all(
|
|
1209
|
+
[...this._loadedFaceUrls].map((url) =>
|
|
1210
|
+
this._fontManager.unloadFont(url, q),
|
|
1211
|
+
),
|
|
1212
|
+
)
|
|
1213
|
+
: this._fontManager.unloadFont(this._fontIdentifier, q);
|
|
1214
|
+
void unload.catch((err: unknown) => {
|
|
1215
|
+
console.error("Failed to unload font during dispose:", err);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Clamp to [0, 1], treating non-finite input as fully opaque. */
|
|
1221
|
+
function clamp01(value: number): number {
|
|
1222
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1.0;
|
|
1223
|
+
}
|