@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
package/src/mesh/sdfText.ts
DELETED
|
@@ -1,1189 +0,0 @@
|
|
|
1
|
-
import { Unimplemented } from "@navaramap/core";
|
|
2
|
-
import type {
|
|
3
|
-
TextMaterial as NavaraTextMaterial,
|
|
4
|
-
Transform,
|
|
5
|
-
} from "@navaramap/engine";
|
|
6
|
-
import {
|
|
7
|
-
COLOR_GLYPH_PX_SIZE,
|
|
8
|
-
GlyphCharClass,
|
|
9
|
-
createSdfAtlasTexture,
|
|
10
|
-
type FontManager,
|
|
11
|
-
type GlyphMetrics,
|
|
12
|
-
type ShapedGlyph,
|
|
13
|
-
type ShapeTextResult,
|
|
14
|
-
} from "@navaramap/font";
|
|
15
|
-
import { degreeToRadian } from "@navaramap/three_api";
|
|
16
|
-
import {
|
|
17
|
-
BufferAttribute,
|
|
18
|
-
type Color,
|
|
19
|
-
type DataTexture,
|
|
20
|
-
type PerspectiveCamera,
|
|
21
|
-
InstancedBufferAttribute,
|
|
22
|
-
InstancedBufferGeometry,
|
|
23
|
-
Mesh,
|
|
24
|
-
Object3D,
|
|
25
|
-
ShaderMaterial,
|
|
26
|
-
Vector2,
|
|
27
|
-
} from "three";
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
DECLUTTER_FADE_MS,
|
|
31
|
-
type DeclutterCandidate,
|
|
32
|
-
type DeclutterParticipant,
|
|
33
|
-
} from "../declutter/types";
|
|
34
|
-
import type { MaterialEnhancer } from "../material/enhancer/MaterialEnhancer";
|
|
35
|
-
import {
|
|
36
|
-
createSdfTextMaterialEnhancer,
|
|
37
|
-
type SdfTextBaseMutates,
|
|
38
|
-
type SdfTextBaseProps,
|
|
39
|
-
type SdfTextBaseState,
|
|
40
|
-
} from "../material/enhancer/sdfText";
|
|
41
|
-
import { sdfRadiusFor } from "../material/enhancer/sdfText/sdfTextBaseEnhancer/types";
|
|
42
|
-
|
|
43
|
-
import type { PickableMesh } from "./pickableMesh";
|
|
44
|
-
|
|
45
|
-
/** Must match Rust `SDF_PX_SIZE` in `crates/navara_wasm_font_worker/src/atlas.rs`. */
|
|
46
|
-
const SDF_PX_SIZE = 64.0;
|
|
47
|
-
|
|
48
|
-
/** Reusable Vector2 to avoid per-frame allocations in onBeforeRender. */
|
|
49
|
-
const _tmpSize = new Vector2();
|
|
50
|
-
|
|
51
|
-
/** Horizontal alignment of lines within a multi-line block, as the fraction
|
|
52
|
-
* of leftover width placed before each line. */
|
|
53
|
-
const ALIGN_FACTORS: Record<string, number> = {
|
|
54
|
-
left: 0,
|
|
55
|
-
center: 0.5,
|
|
56
|
-
right: 1,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/** Line width in font units: advances summed, trailing whitespace ignored so
|
|
60
|
-
* it never affects alignment or the block width. Exported for tests. */
|
|
61
|
-
export function lineWidthFu(line: ShapedGlyph[]): number {
|
|
62
|
-
let end = line.length;
|
|
63
|
-
while (end > 0 && line[end - 1].charClass === GlyphCharClass.Whitespace) {
|
|
64
|
-
end--;
|
|
65
|
-
}
|
|
66
|
-
let width = 0;
|
|
67
|
-
for (let i = 0; i < end; i++) width += line[i].xAdvance;
|
|
68
|
-
return width;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Strong RTL code points (Hebrew through Arabic Extended, presentation
|
|
72
|
-
* forms, and the supplementary-plane RTL blocks). Used for first-strong
|
|
73
|
-
* paragraph direction detection, mirroring the shaper's own direction guess.
|
|
74
|
-
*/
|
|
75
|
-
const STRONG_RTL_RE =
|
|
76
|
-
/[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
|
|
77
|
-
|
|
78
|
-
/** Paragraph direction from the first strong directional character (UAX #9
|
|
79
|
-
* P2/P3 approximation): the first letter decides, RTL ranges win over other
|
|
80
|
-
* letters. Exported for tests. */
|
|
81
|
-
export function isRtlText(text: string): boolean {
|
|
82
|
-
for (const ch of text) {
|
|
83
|
-
if (STRONG_RTL_RE.test(ch)) return true;
|
|
84
|
-
if (/\p{L}/u.test(ch)) return false;
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Split a shaped glyph run into lines: hard breaks at newline markers, greedy
|
|
91
|
-
* soft breaks at the last whitespace/ideographic glyph when a line would
|
|
92
|
-
* exceed `maxWidthFu` (font units; 0 disables wrapping). A word longer than
|
|
93
|
-
* the wrap width overflows rather than breaking mid-word.
|
|
94
|
-
*
|
|
95
|
-
* When `rtl` is set, glyphs are assumed to arrive in visual order (leftmost
|
|
96
|
-
* first — how the shaper emits RTL runs), i.e. reversed logical order. Each
|
|
97
|
-
* hard-break segment is wrapped in logical order so the greedy fill starts at
|
|
98
|
-
* the sentence start and lines stack top-to-bottom in reading order, then
|
|
99
|
-
* every line is flipped back to visual order for rendering. Mixed-direction
|
|
100
|
-
* lines may still break sub-optimally — same trade other map renderers make.
|
|
101
|
-
* Exported for tests.
|
|
102
|
-
*/
|
|
103
|
-
export function breakLines(
|
|
104
|
-
glyphs: ShapedGlyph[],
|
|
105
|
-
maxWidthFu: number,
|
|
106
|
-
rtl = false,
|
|
107
|
-
): ShapedGlyph[][] {
|
|
108
|
-
const lines: ShapedGlyph[][] = [];
|
|
109
|
-
|
|
110
|
-
const pushSegment = (segment: ShapedGlyph[]) => {
|
|
111
|
-
if (!rtl) {
|
|
112
|
-
lines.push(...wrapSegment(segment, maxWidthFu));
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
segment.reverse();
|
|
116
|
-
for (const line of wrapSegment(segment, maxWidthFu)) {
|
|
117
|
-
lines.push(line.reverse());
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
let segment: ShapedGlyph[] = [];
|
|
122
|
-
for (const g of glyphs) {
|
|
123
|
-
if (g.charClass === GlyphCharClass.Newline) {
|
|
124
|
-
pushSegment(segment);
|
|
125
|
-
segment = [];
|
|
126
|
-
} else {
|
|
127
|
-
segment.push(g);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
pushSegment(segment);
|
|
131
|
-
return lines;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Greedy soft-wrap of a single hard-break-free segment in logical order. */
|
|
135
|
-
function wrapSegment(
|
|
136
|
-
glyphs: ShapedGlyph[],
|
|
137
|
-
maxWidthFu: number,
|
|
138
|
-
): ShapedGlyph[][] {
|
|
139
|
-
const lines: ShapedGlyph[][] = [];
|
|
140
|
-
let line: ShapedGlyph[] = [];
|
|
141
|
-
let width = 0;
|
|
142
|
-
let breakIdx = -1; // index in `line` of the last break opportunity
|
|
143
|
-
|
|
144
|
-
for (const g of glyphs) {
|
|
145
|
-
// Trailing whitespace is invisible at a line end, so it never triggers a
|
|
146
|
-
// wrap itself — it just gets trimmed if a later glyph wraps.
|
|
147
|
-
if (
|
|
148
|
-
maxWidthFu > 0 &&
|
|
149
|
-
g.charClass !== GlyphCharClass.Whitespace &&
|
|
150
|
-
line.length > 0 &&
|
|
151
|
-
breakIdx >= 0 &&
|
|
152
|
-
width + g.xAdvance > maxWidthFu
|
|
153
|
-
) {
|
|
154
|
-
const head = line.slice(0, breakIdx + 1);
|
|
155
|
-
while (
|
|
156
|
-
head.length > 0 &&
|
|
157
|
-
head[head.length - 1].charClass === GlyphCharClass.Whitespace
|
|
158
|
-
) {
|
|
159
|
-
head.pop();
|
|
160
|
-
}
|
|
161
|
-
lines.push(head);
|
|
162
|
-
|
|
163
|
-
const tail = line.slice(breakIdx + 1);
|
|
164
|
-
const start = tail.findIndex(
|
|
165
|
-
(gg) => gg.charClass !== GlyphCharClass.Whitespace,
|
|
166
|
-
);
|
|
167
|
-
line = start === -1 ? [] : tail.slice(start);
|
|
168
|
-
|
|
169
|
-
width = 0;
|
|
170
|
-
for (const rest of line) width += rest.xAdvance;
|
|
171
|
-
breakIdx = -1;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
line.push(g);
|
|
175
|
-
width += g.xAdvance;
|
|
176
|
-
if (
|
|
177
|
-
g.charClass === GlyphCharClass.Whitespace ||
|
|
178
|
-
g.charClass === GlyphCharClass.Ideographic
|
|
179
|
-
) {
|
|
180
|
-
breakIdx = line.length - 1;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
lines.push(line);
|
|
185
|
-
return lines;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* A text mesh that renders glyphs from an SDF atlas using instanced geometry.
|
|
190
|
-
*
|
|
191
|
-
* Each glyph is an instanced unit quad positioned by shaping data,
|
|
192
|
-
* sampling from a per-font SDF atlas texture. Uses billboard rendering
|
|
193
|
-
* so text always faces the camera.
|
|
194
|
-
*/
|
|
195
|
-
export class SDFTextMesh
|
|
196
|
-
extends Mesh<InstancedBufferGeometry, ShaderMaterial>
|
|
197
|
-
implements PickableMesh
|
|
198
|
-
{
|
|
199
|
-
private _fontManager: FontManager;
|
|
200
|
-
private _fontUrl: string;
|
|
201
|
-
/** Per-material text quality. Immutable after construction — switching
|
|
202
|
-
* quality requires a separate (font, atlas) pair, so callers create a new
|
|
203
|
-
* mesh rather than mutating this one. */
|
|
204
|
-
private _highQuality: boolean;
|
|
205
|
-
private _text = "";
|
|
206
|
-
/** Wrap width in ems (multiples of font size); 0 disables soft wrapping. */
|
|
207
|
-
private _maxWidth = 0;
|
|
208
|
-
/** Multiplier on the font's natural line height (ascender − descender + gap). */
|
|
209
|
-
private _lineHeight = 1.0;
|
|
210
|
-
/** Alignment factor for multi-line blocks: 0 left, 0.5 center, 1 right. */
|
|
211
|
-
private _textAlign = 0.5;
|
|
212
|
-
private _atlasTexture: DataTexture | null = null;
|
|
213
|
-
/** Atlas texture is owned externally; do not dispose on cleanup when true. */
|
|
214
|
-
private _sharedAtlas = false;
|
|
215
|
-
/** Unique atlas glyphs this mesh currently renders (composite keys). */
|
|
216
|
-
private _glyphKeys: bigint[] = [];
|
|
217
|
-
/** The glyph set currently retained in the atlas (one visible-label
|
|
218
|
-
* reference per key), or null when this mesh holds no references. */
|
|
219
|
-
private _retainedKeys: bigint[] | null = null;
|
|
220
|
-
/** Re-prepare a text in the worker (re-rasterizing evicted glyphs) using the
|
|
221
|
-
* owner's font identity and loaded faces. Set by the parent batch. */
|
|
222
|
-
private _reprepare?: (text: string) => Promise<void>;
|
|
223
|
-
/** Ask the owner to schedule a render after an async re-prepare. */
|
|
224
|
-
private _requestRender?: () => void;
|
|
225
|
-
// The COLRv1 color atlas is always FontManager-owned; no local field needed.
|
|
226
|
-
|
|
227
|
-
/** World-space anchor in ECEF meters (f64), kept in sync with the position
|
|
228
|
-
* uniforms so the declutter pass can project it without touching WASM. */
|
|
229
|
-
private _worldAnchor = new Float64Array(3);
|
|
230
|
-
/** Whether this label participates in screen-space decluttering. */
|
|
231
|
-
private _declutter = false;
|
|
232
|
-
/** Layer-level placement priority from the material. */
|
|
233
|
-
private _declutterPriority = 0;
|
|
234
|
-
/** Per-feature priority set through the evaluator; overrides the layer
|
|
235
|
-
* value when defined. */
|
|
236
|
-
private _declutterPriorityOverride: number | undefined;
|
|
237
|
-
/** Current animated hide factor (mirrors the uDeclutterHide uniform) and
|
|
238
|
-
* the placement target it is fading toward. */
|
|
239
|
-
private _declutterHide = 0;
|
|
240
|
-
private _declutterTarget = 0;
|
|
241
|
-
/** Text block dimensions in em units, mirroring the uTextWidth/uTextHeight/
|
|
242
|
-
* uBgYBounds uniforms; 0 width means "no collision box" (empty text). */
|
|
243
|
-
private _labelWidthEm = 0;
|
|
244
|
-
private _labelHeightEm = 0;
|
|
245
|
-
private _labelMinYEm = 0;
|
|
246
|
-
private _labelMaxYEm = 0;
|
|
247
|
-
|
|
248
|
-
private _enhancer: MaterialEnhancer<
|
|
249
|
-
ShaderMaterial,
|
|
250
|
-
{ base?: SdfTextBaseProps },
|
|
251
|
-
SdfTextBaseState,
|
|
252
|
-
SdfTextBaseMutates,
|
|
253
|
-
readonly ["shader"]
|
|
254
|
-
>;
|
|
255
|
-
|
|
256
|
-
constructor(
|
|
257
|
-
position: Float32Array | { high: Float32Array; low: Float32Array },
|
|
258
|
-
material: NavaraTextMaterial,
|
|
259
|
-
transform: Transform,
|
|
260
|
-
fontManager: FontManager,
|
|
261
|
-
fontUrl: string,
|
|
262
|
-
batchId: number | undefined,
|
|
263
|
-
RTE: boolean,
|
|
264
|
-
// Quality is fixed by the owning batch (flipping it needs a new batch), so
|
|
265
|
-
// it is passed in rather than re-derived from `material.highQuality` — the
|
|
266
|
-
// stored material can carry a newer, mismatched value by the time a mesh is
|
|
267
|
-
// created lazily, which must not diverge from the batch's atlas/shaping.
|
|
268
|
-
highQuality: boolean,
|
|
269
|
-
) {
|
|
270
|
-
super();
|
|
271
|
-
|
|
272
|
-
this._fontManager = fontManager;
|
|
273
|
-
this._fontUrl = fontUrl;
|
|
274
|
-
this._highQuality = highQuality;
|
|
275
|
-
this._maxWidth = material.maxWidth ?? 0;
|
|
276
|
-
this._lineHeight = material.lineHeight ?? 1.0;
|
|
277
|
-
this._textAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
|
|
278
|
-
|
|
279
|
-
this.geometry = this._createBaseGeometry();
|
|
280
|
-
|
|
281
|
-
// Create empty ShaderMaterial — enhancer will set shaders and uniforms
|
|
282
|
-
const mat = new ShaderMaterial({
|
|
283
|
-
transparent: true,
|
|
284
|
-
// depthWrite must stay enabled: the fragment shader's per-pixel outline
|
|
285
|
-
// depth offset (sdfText.frag.glsl) relies on fills writing a nearer depth
|
|
286
|
-
// so a neighbouring glyph's fill occludes this glyph's outline at overlaps
|
|
287
|
-
// — without depth writes that outline-seam fix becomes a no-op.
|
|
288
|
-
depthWrite: true,
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
this._enhancer = createSdfTextMaterialEnhancer(mat);
|
|
292
|
-
|
|
293
|
-
// Mount enhancer with initial props
|
|
294
|
-
// Validate and clamp opacity values to prevent invalid alpha in shader
|
|
295
|
-
const initialOpacity = Number.isFinite(material.opacity ?? 1.0)
|
|
296
|
-
? Math.max(0, Math.min(1, material.opacity ?? 1.0))
|
|
297
|
-
: 1.0;
|
|
298
|
-
const initialOutlineOpacity = Number.isFinite(
|
|
299
|
-
material.outlineOpacity ?? 1.0,
|
|
300
|
-
)
|
|
301
|
-
? Math.max(0, Math.min(1, material.outlineOpacity ?? 1.0))
|
|
302
|
-
: 1.0;
|
|
303
|
-
|
|
304
|
-
this._enhancer.mount({
|
|
305
|
-
base: {
|
|
306
|
-
useRTE: RTE,
|
|
307
|
-
useMsdf: this._highQuality,
|
|
308
|
-
color: material.color ?? 0xffffff,
|
|
309
|
-
opacity: initialOpacity,
|
|
310
|
-
fontSize: material.size ?? 16.0,
|
|
311
|
-
center: material.center
|
|
312
|
-
? [material.center.x, material.center.y]
|
|
313
|
-
: undefined,
|
|
314
|
-
sizeInMeters: material.sizeInMeters ?? true,
|
|
315
|
-
addHeight: material.height ?? 0.0,
|
|
316
|
-
offsetDepth: material.offsetDepth ?? true,
|
|
317
|
-
outlineWidth: material.outlineWidth ?? 0,
|
|
318
|
-
outlineColor: material.outlineColor ?? 0x000000,
|
|
319
|
-
outlineOpacity: initialOutlineOpacity,
|
|
320
|
-
showBackground: material.backgroundColor !== undefined,
|
|
321
|
-
backgroundColor: material.backgroundColor,
|
|
322
|
-
backgroundOutlineColor: material.borderColor ?? 0x000000,
|
|
323
|
-
backgroundOutlineWidth: material.borderWidth ?? 0.1,
|
|
324
|
-
depthTest: material.depthTest ?? true,
|
|
325
|
-
transparent: material.transparent ?? true,
|
|
326
|
-
rtcCenter: [transform.tx, transform.ty, transform.tz],
|
|
327
|
-
},
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// Populate uniforms early (before onBeforeCompile fires)
|
|
331
|
-
const mutates = this._enhancer.mutates();
|
|
332
|
-
mutates.updateUniforms(mat.uniforms, this._enhancer.states());
|
|
333
|
-
|
|
334
|
-
// Set batch ID
|
|
335
|
-
if (batchId !== undefined) {
|
|
336
|
-
mutates.setBatchId(batchId);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Set position
|
|
340
|
-
mutates.setPosition(position, RTE, [
|
|
341
|
-
transform.tx,
|
|
342
|
-
transform.ty,
|
|
343
|
-
transform.tz,
|
|
344
|
-
]);
|
|
345
|
-
this._cacheWorldAnchor(position, RTE, [
|
|
346
|
-
transform.tx,
|
|
347
|
-
transform.ty,
|
|
348
|
-
transform.tz,
|
|
349
|
-
]);
|
|
350
|
-
|
|
351
|
-
// Decluttered labels start hidden and fade in once the placement pass
|
|
352
|
-
// grants them space — otherwise dense tiles flash their full clutter for
|
|
353
|
-
// a frame before the first pass runs.
|
|
354
|
-
this._declutter = material.declutter ?? true;
|
|
355
|
-
this._declutterPriority = material.declutterPriority ?? 0;
|
|
356
|
-
if (this._declutter) {
|
|
357
|
-
this._declutterHide = 1;
|
|
358
|
-
this._declutterTarget = 1;
|
|
359
|
-
mutates.setDeclutterHide(1);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Register shader hook
|
|
363
|
-
mat.onBeforeCompile = this._enhancer.transformShader;
|
|
364
|
-
mat.customProgramCacheKey = this._enhancer.programCacheKey;
|
|
365
|
-
|
|
366
|
-
// Per-frame camera updates
|
|
367
|
-
const state = this._enhancer.states();
|
|
368
|
-
mat.onBeforeRender = (renderer, _scene, camera) => {
|
|
369
|
-
const pCam = camera as PerspectiveCamera;
|
|
370
|
-
mutates.updatePerFrame(
|
|
371
|
-
degreeToRadian(pCam.fov),
|
|
372
|
-
renderer.getDrawingBufferSize(_tmpSize).y / renderer.getPixelRatio(),
|
|
373
|
-
pCam.far,
|
|
374
|
-
camera.position.x,
|
|
375
|
-
camera.position.y,
|
|
376
|
-
camera.position.z,
|
|
377
|
-
camera.matrixWorldInverse,
|
|
378
|
-
state,
|
|
379
|
-
);
|
|
380
|
-
// Keep atlas-size uniforms in sync with the (possibly resized) shared
|
|
381
|
-
// DataTexture so glyph pixel rects always normalize to the right UV.
|
|
382
|
-
mutates.updateAtlasSizes();
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
this.material = mat;
|
|
386
|
-
this.frustumCulled = false;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** The text this mesh currently renders (its baked glyph string). */
|
|
390
|
-
get text(): string {
|
|
391
|
-
return this._text;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Set a shared atlas texture. When set, setText() will skip creating its own texture.
|
|
396
|
-
* The caller is responsible for the texture lifecycle.
|
|
397
|
-
*/
|
|
398
|
-
setAtlasTexture(tex: DataTexture): void {
|
|
399
|
-
this._atlasTexture = tex;
|
|
400
|
-
this._sharedAtlas = true;
|
|
401
|
-
this._enhancer.mutates().setAtlasTexture({ value: tex });
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Set a shared color (COLRv1 RGBA) atlas texture.
|
|
406
|
-
* Pass `null` to clear when the font has no color glyphs.
|
|
407
|
-
*/
|
|
408
|
-
setColorAtlasTexture(tex: DataTexture | null): void {
|
|
409
|
-
this._enhancer.mutates().setColorAtlasTexture({ value: tex });
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Wire the callbacks the parent batch uses to re-prepare evicted glyphs and
|
|
414
|
-
* request a render. `reprepare` must apply the batch's font identity and
|
|
415
|
-
* loaded faces so font refcounts stay balanced.
|
|
416
|
-
*/
|
|
417
|
-
setGlyphLifecycleHandlers(
|
|
418
|
-
reprepare: (text: string) => Promise<void>,
|
|
419
|
-
requestRender: () => void,
|
|
420
|
-
): void {
|
|
421
|
-
this._reprepare = reprepare;
|
|
422
|
-
this._requestRender = requestRender;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Replace the retained glyph set, releasing the old references and (if
|
|
426
|
-
* visible) retaining the new ones. */
|
|
427
|
-
private _setGlyphKeys(keys: bigint[]): void {
|
|
428
|
-
if (this._retainedKeys) {
|
|
429
|
-
this._fontManager.releaseGlyphs(
|
|
430
|
-
this._fontUrl,
|
|
431
|
-
this._highQuality,
|
|
432
|
-
this._retainedKeys,
|
|
433
|
-
);
|
|
434
|
-
this._retainedKeys = null;
|
|
435
|
-
}
|
|
436
|
-
this._glyphKeys = keys;
|
|
437
|
-
this._syncGlyphRefs();
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Reconcile atlas references with current visibility: retain glyphs while
|
|
441
|
-
* on screen, release them when hidden. If shown after the glyphs were
|
|
442
|
-
* evicted (cache no longer prepared), re-prepare to re-rasterize them and
|
|
443
|
-
* rebuild with fresh metrics. */
|
|
444
|
-
private _syncGlyphRefs(): void {
|
|
445
|
-
const visible = this.visible && this._glyphKeys.length > 0;
|
|
446
|
-
|
|
447
|
-
if (visible && !this._retainedKeys) {
|
|
448
|
-
if (
|
|
449
|
-
this._reprepare &&
|
|
450
|
-
!this._fontManager.isTextPrepared(
|
|
451
|
-
this._fontUrl,
|
|
452
|
-
this._text,
|
|
453
|
-
this._highQuality,
|
|
454
|
-
)
|
|
455
|
-
) {
|
|
456
|
-
// Glyphs were evicted while hidden; re-rasterize then rebuild (which
|
|
457
|
-
// re-enters here with the fresh set and retains it).
|
|
458
|
-
const text = this._text;
|
|
459
|
-
this._reprepare(text)
|
|
460
|
-
.then(() => {
|
|
461
|
-
if (this._text === text) {
|
|
462
|
-
this.setText(text, true);
|
|
463
|
-
this._requestRender?.();
|
|
464
|
-
}
|
|
465
|
-
})
|
|
466
|
-
.catch((err: unknown) => {
|
|
467
|
-
console.error("SDFTextMesh: re-prepare on show failed:", err);
|
|
468
|
-
});
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
this._fontManager.retainGlyphs(
|
|
472
|
-
this._fontUrl,
|
|
473
|
-
this._highQuality,
|
|
474
|
-
this._glyphKeys,
|
|
475
|
-
);
|
|
476
|
-
this._retainedKeys = this._glyphKeys;
|
|
477
|
-
} else if (!visible && this._retainedKeys) {
|
|
478
|
-
this._fontManager.releaseGlyphs(
|
|
479
|
-
this._fontUrl,
|
|
480
|
-
this._highQuality,
|
|
481
|
-
this._retainedKeys,
|
|
482
|
-
);
|
|
483
|
-
this._retainedKeys = null;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Set text to render. Shapes via WASM, rebuilds instanced geometry, updates atlas texture.
|
|
489
|
-
*/
|
|
490
|
-
setText(text: string, forceUpdate = false): void {
|
|
491
|
-
if (text === this._text && !forceUpdate) return;
|
|
492
|
-
this._text = text;
|
|
493
|
-
|
|
494
|
-
if (!text) {
|
|
495
|
-
this.geometry.instanceCount = 0;
|
|
496
|
-
this._labelWidthEm = 0;
|
|
497
|
-
this._setGlyphKeys([]);
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const shapeResult = this._fontManager.shapeText(
|
|
502
|
-
this._fontUrl,
|
|
503
|
-
text,
|
|
504
|
-
this._highQuality,
|
|
505
|
-
);
|
|
506
|
-
if (!shapeResult) {
|
|
507
|
-
this.geometry.instanceCount = 0;
|
|
508
|
-
this._labelWidthEm = 0;
|
|
509
|
-
this._setGlyphKeys([]);
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const atlasData = this._fontManager.getAtlas(
|
|
514
|
-
this._fontUrl,
|
|
515
|
-
this._highQuality,
|
|
516
|
-
);
|
|
517
|
-
if (!atlasData) return;
|
|
518
|
-
|
|
519
|
-
// Glyph rects are stored in pixel space and normalized in the shader using
|
|
520
|
-
// uSdfAtlasSize / uColorAtlasSize, so atlas dimensions aren't needed here.
|
|
521
|
-
this._buildGlyphInstances(shapeResult);
|
|
522
|
-
|
|
523
|
-
// Skip texture creation if using a shared atlas from the parent container
|
|
524
|
-
if (!this._sharedAtlas) {
|
|
525
|
-
this._updateAtlasTexture(
|
|
526
|
-
atlasData.data,
|
|
527
|
-
atlasData.width,
|
|
528
|
-
atlasData.height,
|
|
529
|
-
atlasData.channels,
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Update visual properties: color, size, visibility, etc.
|
|
536
|
-
*/
|
|
537
|
-
setFont(fontUrl: string): void {
|
|
538
|
-
if (fontUrl === this._fontUrl) return;
|
|
539
|
-
|
|
540
|
-
// Release glyph references against the old font before switching; the
|
|
541
|
-
// baked instances are about to be rebuilt for the new font anyway.
|
|
542
|
-
if (this._retainedKeys) {
|
|
543
|
-
this._fontManager.releaseGlyphs(
|
|
544
|
-
this._fontUrl,
|
|
545
|
-
this._highQuality,
|
|
546
|
-
this._retainedKeys,
|
|
547
|
-
);
|
|
548
|
-
this._retainedKeys = null;
|
|
549
|
-
}
|
|
550
|
-
this._glyphKeys = [];
|
|
551
|
-
this._fontUrl = fontUrl;
|
|
552
|
-
|
|
553
|
-
// Clear current atlas since it's tied to the previous font
|
|
554
|
-
if (!this._sharedAtlas) {
|
|
555
|
-
this._atlasTexture?.dispose();
|
|
556
|
-
this._atlasTexture = null;
|
|
557
|
-
this._enhancer.mutates().setAtlasTexture({ value: null });
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
setColor(color: Color): void {
|
|
562
|
-
this._enhancer.update({
|
|
563
|
-
base: { color: color.getHex() },
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
setFontSize(size: number): void {
|
|
568
|
-
this._enhancer.update({ base: { fontSize: size } });
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
setSizeInMeters(enabled: boolean): void {
|
|
572
|
-
this._enhancer.update({ base: { sizeInMeters: enabled } });
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
setCenter(x: number, y: number): void {
|
|
576
|
-
this._enhancer.update({ base: { center: [x, y] } });
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
setHeight(height: number): void {
|
|
580
|
-
this._enhancer.update({ base: { addHeight: height } });
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
setSize(size: number): void {
|
|
584
|
-
const currentFontSize = this._enhancer.states().fontSize;
|
|
585
|
-
const sanitizedSize = Number.isFinite(size)
|
|
586
|
-
? Math.max(0.0, size)
|
|
587
|
-
: currentFontSize;
|
|
588
|
-
this._enhancer.update({ base: { fontSize: sanitizedSize } });
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
setOpacity(opacity: number): void {
|
|
592
|
-
const clampedOpacity = Number.isFinite(opacity)
|
|
593
|
-
? Math.max(0, Math.min(1, opacity))
|
|
594
|
-
: 1.0;
|
|
595
|
-
this._enhancer.update({ base: { opacity: clampedOpacity } });
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* Set this label's declutter fade target; the actual hide factor animates
|
|
600
|
-
* toward it in {@link stepDeclutterFade}. Deliberately separate from
|
|
601
|
-
* `visible`/`show`: it neither churns scene-graph membership nor releases
|
|
602
|
-
* glyph atlas retains, so the declutter pass can retarget it every
|
|
603
|
-
* placement run at no cost.
|
|
604
|
-
*/
|
|
605
|
-
setDeclutterHidden(hidden: boolean): void {
|
|
606
|
-
this._declutterTarget = hidden ? 1 : 0;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Advance the hide factor toward its target by `deltaMs / DECLUTTER_FADE_MS`
|
|
611
|
-
* and push it to the uniform. Returns true while still mid-fade.
|
|
612
|
-
*/
|
|
613
|
-
stepDeclutterFade(deltaMs: number): boolean {
|
|
614
|
-
const target = this._declutterTarget;
|
|
615
|
-
let value = this._declutterHide;
|
|
616
|
-
if (value === target) return false;
|
|
617
|
-
|
|
618
|
-
const step = deltaMs / DECLUTTER_FADE_MS;
|
|
619
|
-
value =
|
|
620
|
-
value < target
|
|
621
|
-
? Math.min(value + step, target)
|
|
622
|
-
: Math.max(value - step, target);
|
|
623
|
-
this._declutterHide = value;
|
|
624
|
-
this._enhancer.mutates().setDeclutterHide(value);
|
|
625
|
-
return value !== target;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
_setFeatureWidth(_width: number): void {
|
|
629
|
-
// Width is not applicable to text meshes.
|
|
630
|
-
// This method is intentionally a no-op to satisfy the FeatureMesh guard.
|
|
631
|
-
}
|
|
632
|
-
_setFeatureOpacity(opacity: number): void {
|
|
633
|
-
const clampedOpacity = Number.isFinite(opacity)
|
|
634
|
-
? Math.max(0, Math.min(1, opacity))
|
|
635
|
-
: 1.0;
|
|
636
|
-
this._enhancer.update({ base: { opacity: clampedOpacity } });
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
setPosition(
|
|
640
|
-
position: Float32Array | { high: Float32Array; low: Float32Array },
|
|
641
|
-
RTE: boolean,
|
|
642
|
-
transform: Transform,
|
|
643
|
-
): void {
|
|
644
|
-
this._enhancer
|
|
645
|
-
.mutates()
|
|
646
|
-
.setPosition(position, RTE, [transform.tx, transform.ty, transform.tz]);
|
|
647
|
-
this._cacheWorldAnchor(position, RTE, [
|
|
648
|
-
transform.tx,
|
|
649
|
-
transform.ty,
|
|
650
|
-
transform.tz,
|
|
651
|
-
]);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/** Reconstruct the absolute ECEF anchor from the same inputs the position
|
|
655
|
-
* uniforms receive (RTE high+low split, or RTC-relative + center). */
|
|
656
|
-
private _cacheWorldAnchor(
|
|
657
|
-
position: Float32Array | { high: Float32Array; low: Float32Array },
|
|
658
|
-
RTE: boolean,
|
|
659
|
-
rtcCenter: [number, number, number],
|
|
660
|
-
): void {
|
|
661
|
-
const anchor = this._worldAnchor;
|
|
662
|
-
if (RTE) {
|
|
663
|
-
const p = position as { high: Float32Array; low: Float32Array };
|
|
664
|
-
anchor[0] = p.high[0] + p.low[0];
|
|
665
|
-
anchor[1] = p.high[1] + p.low[1];
|
|
666
|
-
anchor[2] = (p.high[2] ?? 0.0) + (p.low[2] ?? 0.0);
|
|
667
|
-
} else {
|
|
668
|
-
const p = position as Float32Array;
|
|
669
|
-
anchor[0] = p[0] + rtcCenter[0];
|
|
670
|
-
anchor[1] = p[1] + rtcCenter[1];
|
|
671
|
-
anchor[2] = (p[2] ?? 0.0) + rtcCenter[2];
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* Build this label's collision candidate for the shared declutter pass, or
|
|
677
|
-
* null when it doesn't participate (declutter disabled, hidden, or empty).
|
|
678
|
-
* The local box mirrors the vertex shader's layout: glyphs span
|
|
679
|
-
* [0, textWidth] × [bgMinY, bgMaxY] in em units, shifted by the `center`
|
|
680
|
-
* anchor and scaled by the font size (sdfText.vert.glsl:111-115).
|
|
681
|
-
*/
|
|
682
|
-
getDeclutterCandidate(
|
|
683
|
-
owner: DeclutterParticipant,
|
|
684
|
-
handle: number,
|
|
685
|
-
): DeclutterCandidate | null {
|
|
686
|
-
if (!this._declutter || !this.visible || this._labelWidthEm <= 0) {
|
|
687
|
-
return null;
|
|
688
|
-
}
|
|
689
|
-
const state = this._enhancer.states();
|
|
690
|
-
const size = state.fontSize;
|
|
691
|
-
if (size <= 0) return null;
|
|
692
|
-
|
|
693
|
-
const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
|
|
694
|
-
const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
|
|
695
|
-
const w = this._labelWidthEm;
|
|
696
|
-
const h = this._labelHeightEm;
|
|
697
|
-
return {
|
|
698
|
-
anchorX: this._worldAnchor[0],
|
|
699
|
-
anchorY: this._worldAnchor[1],
|
|
700
|
-
anchorZ: this._worldAnchor[2],
|
|
701
|
-
addHeight: state.addHeight,
|
|
702
|
-
minX: (0 - cx * w) * size,
|
|
703
|
-
maxX: (w - cx * w) * size,
|
|
704
|
-
minY: (this._labelMinYEm - cy * h) * size,
|
|
705
|
-
maxY: (this._labelMaxYEm - cy * h) * size,
|
|
706
|
-
sizeInMeters: state.sizeInMeters,
|
|
707
|
-
priority: this._declutterPriorityOverride ?? this._declutterPriority,
|
|
708
|
-
isShown: this._declutterTarget === 0,
|
|
709
|
-
owner,
|
|
710
|
-
handle,
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Set a per-feature placement priority (higher wins), overriding the
|
|
716
|
-
* layer-level `declutterPriority` for this label. Pass `undefined` to fall
|
|
717
|
-
* back to the layer value. Driven by the feature evaluator.
|
|
718
|
-
*/
|
|
719
|
-
setDeclutterPriority(priority: number | undefined): void {
|
|
720
|
-
this._declutterPriorityOverride = priority;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Apply material properties from WASM TextMaterial.
|
|
725
|
-
* Maps relevant properties to enhancer updates, with change tracking.
|
|
726
|
-
*/
|
|
727
|
-
update(material: NavaraTextMaterial, forceUpdate = false): void {
|
|
728
|
-
const fontUrl = material.font ?? this._fontUrl;
|
|
729
|
-
this.setFont(fontUrl);
|
|
730
|
-
|
|
731
|
-
// Layout properties are baked into the glyph instance geometry, so a
|
|
732
|
-
// change forces a rebuild even when the text itself is unchanged.
|
|
733
|
-
const nextMaxWidth = material.maxWidth ?? 0;
|
|
734
|
-
const nextLineHeight = material.lineHeight ?? 1.0;
|
|
735
|
-
const nextTextAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
|
|
736
|
-
const layoutChanged =
|
|
737
|
-
nextMaxWidth !== this._maxWidth ||
|
|
738
|
-
nextLineHeight !== this._lineHeight ||
|
|
739
|
-
nextTextAlign !== this._textAlign;
|
|
740
|
-
this._maxWidth = nextMaxWidth;
|
|
741
|
-
this._lineHeight = nextLineHeight;
|
|
742
|
-
this._textAlign = nextTextAlign;
|
|
743
|
-
|
|
744
|
-
const nextText = material.text;
|
|
745
|
-
if (nextText !== undefined && nextText !== "") {
|
|
746
|
-
this.setText(nextText, forceUpdate || layoutChanged);
|
|
747
|
-
} else if (forceUpdate || layoutChanged) {
|
|
748
|
-
// Font or layout changed — re-render existing text with the new settings
|
|
749
|
-
this.setText(this._text, true);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Read before the visibility early-return: the declutter pass consults
|
|
753
|
-
// these even while other style state is skipped for hidden labels.
|
|
754
|
-
const nextDeclutter = material.declutter ?? true;
|
|
755
|
-
if (this._declutter && !nextDeclutter) {
|
|
756
|
-
// Leaving declutter mode: clear any hide the pass applied — the mesh
|
|
757
|
-
// stops producing candidates, so nothing else would ever re-show it.
|
|
758
|
-
this.setDeclutterHidden(false);
|
|
759
|
-
}
|
|
760
|
-
this._declutter = nextDeclutter;
|
|
761
|
-
this._declutterPriority = material.declutterPriority ?? 0;
|
|
762
|
-
|
|
763
|
-
this.visible = (material.show ?? true) && !!this._text;
|
|
764
|
-
this._syncGlyphRefs();
|
|
765
|
-
if (!this.visible) return;
|
|
766
|
-
|
|
767
|
-
const state = this._enhancer.states();
|
|
768
|
-
|
|
769
|
-
// Build props for enhancer update
|
|
770
|
-
const baseProps: SdfTextBaseProps = {};
|
|
771
|
-
let hasUpdate = false;
|
|
772
|
-
|
|
773
|
-
const nextColor = material.color ?? 0xffffff;
|
|
774
|
-
if (nextColor !== state.color.getHex()) {
|
|
775
|
-
baseProps.color = nextColor;
|
|
776
|
-
hasUpdate = true;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
const nextOpacityRaw = material.opacity ?? 1.0;
|
|
780
|
-
const nextOpacity = Number.isFinite(nextOpacityRaw)
|
|
781
|
-
? Math.max(0.0, Math.min(1.0, nextOpacityRaw))
|
|
782
|
-
: 1.0;
|
|
783
|
-
if (nextOpacity !== state.opacity) {
|
|
784
|
-
baseProps.opacity = nextOpacity;
|
|
785
|
-
hasUpdate = true;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
const nextFontSize = material.size ?? 16.0;
|
|
789
|
-
if (nextFontSize !== state.fontSize) {
|
|
790
|
-
baseProps.fontSize = nextFontSize;
|
|
791
|
-
hasUpdate = true;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const nextCenterX = material.center?.x ?? 0.5;
|
|
795
|
-
const nextCenterY = material.center?.y ?? 0.0;
|
|
796
|
-
if (nextCenterX !== state.center[0] || nextCenterY !== state.center[1]) {
|
|
797
|
-
baseProps.center = [nextCenterX, nextCenterY];
|
|
798
|
-
hasUpdate = true;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const nextSizeInMeters = material.sizeInMeters ?? true;
|
|
802
|
-
if (nextSizeInMeters !== state.sizeInMeters) {
|
|
803
|
-
baseProps.sizeInMeters = nextSizeInMeters;
|
|
804
|
-
hasUpdate = true;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
const nextDepthTest = material.depthTest ?? true;
|
|
808
|
-
if (nextDepthTest !== state.depthTest) {
|
|
809
|
-
baseProps.depthTest = nextDepthTest;
|
|
810
|
-
hasUpdate = true;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const nextOffsetDepth = material.offsetDepth ?? true;
|
|
814
|
-
if (nextOffsetDepth !== state.offsetDepth) {
|
|
815
|
-
baseProps.offsetDepth = nextOffsetDepth;
|
|
816
|
-
hasUpdate = true;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
const nextHeight = material.height ?? 0;
|
|
820
|
-
if (nextHeight !== state.addHeight) {
|
|
821
|
-
baseProps.addHeight = nextHeight;
|
|
822
|
-
hasUpdate = true;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const nextOutlineWidth = material.outlineWidth ?? 0;
|
|
826
|
-
if (nextOutlineWidth / sdfRadiusFor(state.useMsdf) !== state.outlineWidth) {
|
|
827
|
-
baseProps.outlineWidth = nextOutlineWidth;
|
|
828
|
-
hasUpdate = true;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
const nextOutlineColor = material.outlineColor ?? 0x000000;
|
|
832
|
-
if (nextOutlineColor !== state.outlineColor.getHex()) {
|
|
833
|
-
baseProps.outlineColor = nextOutlineColor;
|
|
834
|
-
hasUpdate = true;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const nextOutlineOpacityRaw = material.outlineOpacity ?? 1.0;
|
|
838
|
-
const nextOutlineOpacity = Number.isFinite(nextOutlineOpacityRaw)
|
|
839
|
-
? Math.max(0.0, Math.min(1.0, nextOutlineOpacityRaw))
|
|
840
|
-
: 1.0;
|
|
841
|
-
if (nextOutlineOpacity !== state.outlineOpacity) {
|
|
842
|
-
baseProps.outlineOpacity = nextOutlineOpacity;
|
|
843
|
-
hasUpdate = true;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
const nextBGColor = material.backgroundColor;
|
|
847
|
-
if (nextBGColor !== undefined) {
|
|
848
|
-
if (!state.showBackground) {
|
|
849
|
-
baseProps.showBackground = true;
|
|
850
|
-
hasUpdate = true;
|
|
851
|
-
}
|
|
852
|
-
if (nextBGColor !== state.backgroundColor.getHex()) {
|
|
853
|
-
baseProps.backgroundColor = nextBGColor;
|
|
854
|
-
hasUpdate = true;
|
|
855
|
-
}
|
|
856
|
-
} else if (state.showBackground) {
|
|
857
|
-
baseProps.showBackground = false;
|
|
858
|
-
hasUpdate = true;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const nextBGOutlineColor = material.borderColor ?? 0x000000;
|
|
862
|
-
if (nextBGOutlineColor !== state.backgroundOutlineColor.getHex()) {
|
|
863
|
-
baseProps.backgroundOutlineColor = nextBGOutlineColor;
|
|
864
|
-
hasUpdate = true;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
const nextBGOutlineWidth = material.borderWidth ?? 0;
|
|
868
|
-
if (nextBGOutlineWidth !== state.backgroundOutlineWidth) {
|
|
869
|
-
baseProps.backgroundOutlineWidth = nextBGOutlineWidth;
|
|
870
|
-
hasUpdate = true;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
if (material.transparent !== undefined) {
|
|
874
|
-
const nextTransparent = material.transparent;
|
|
875
|
-
if (nextTransparent !== state.transparent) {
|
|
876
|
-
baseProps.transparent = nextTransparent;
|
|
877
|
-
hasUpdate = true;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
if (hasUpdate) {
|
|
882
|
-
this._enhancer.update({ base: baseProps });
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// --- FeatureMesh interface ---
|
|
887
|
-
|
|
888
|
-
_setFeatureColor(color: Color): void {
|
|
889
|
-
this.setColor(color);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
_getFeatureColor(): Color {
|
|
893
|
-
const state = this._enhancer.states();
|
|
894
|
-
return state.color;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
_setFeatureShow(visible: boolean): void {
|
|
898
|
-
this.visible = visible;
|
|
899
|
-
this._syncGlyphRefs();
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
_setFeatureExtrudedHeight(_height: number): void {
|
|
903
|
-
throw new Unimplemented();
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
_setFeatureHeight(height: number): void {
|
|
907
|
-
this.setHeight(height);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
_setFrustumCulled(culled: boolean): void {
|
|
911
|
-
this.frustumCulled = culled;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// --- PickableMesh interface ---
|
|
915
|
-
|
|
916
|
-
onBeforePicking(): void {
|
|
917
|
-
this._enhancer.update({ base: { pickable: true } });
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
onAfterPicking(): void {
|
|
921
|
-
this._enhancer.update({ base: { pickable: false } });
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
getRenderable(): Object3D {
|
|
925
|
-
return this;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// --- Cleanup ---
|
|
929
|
-
|
|
930
|
-
dispose(): void {
|
|
931
|
-
// Release atlas glyph references so they can be evicted once unreferenced.
|
|
932
|
-
if (this._retainedKeys) {
|
|
933
|
-
this._fontManager.releaseGlyphs(
|
|
934
|
-
this._fontUrl,
|
|
935
|
-
this._highQuality,
|
|
936
|
-
this._retainedKeys,
|
|
937
|
-
);
|
|
938
|
-
this._retainedKeys = null;
|
|
939
|
-
}
|
|
940
|
-
this.geometry?.dispose();
|
|
941
|
-
if (!this._sharedAtlas) {
|
|
942
|
-
this._atlasTexture?.dispose();
|
|
943
|
-
}
|
|
944
|
-
// Color atlas is always FontManager-owned; never disposed here.
|
|
945
|
-
this.material?.dispose();
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// --- Private ---
|
|
949
|
-
|
|
950
|
-
private _createBaseGeometry(): InstancedBufferGeometry {
|
|
951
|
-
const geo = new InstancedBufferGeometry();
|
|
952
|
-
|
|
953
|
-
// Unit quad: 2 triangles, 6 vertices
|
|
954
|
-
// prettier-ignore
|
|
955
|
-
const positions = new Float32Array([
|
|
956
|
-
-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0,
|
|
957
|
-
-0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
|
|
958
|
-
]);
|
|
959
|
-
// prettier-ignore
|
|
960
|
-
const uvs = new Float32Array([
|
|
961
|
-
0, 0, 1, 0, 1, 1,
|
|
962
|
-
0, 0, 1, 1, 0, 1,
|
|
963
|
-
]);
|
|
964
|
-
|
|
965
|
-
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
|
966
|
-
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
|
967
|
-
|
|
968
|
-
geo.setAttribute(
|
|
969
|
-
"glyphOffset",
|
|
970
|
-
new InstancedBufferAttribute(new Float32Array(), 2),
|
|
971
|
-
);
|
|
972
|
-
geo.setAttribute(
|
|
973
|
-
"glyphSize",
|
|
974
|
-
new InstancedBufferAttribute(new Float32Array(), 2),
|
|
975
|
-
);
|
|
976
|
-
geo.setAttribute(
|
|
977
|
-
"glyphUvRect",
|
|
978
|
-
new InstancedBufferAttribute(new Float32Array(), 4),
|
|
979
|
-
);
|
|
980
|
-
/**
|
|
981
|
-
* Per-instance flag: 1.0 → sample the COLRv1 color atlas, 0.0 → sample SDF.
|
|
982
|
-
* Lets one batched mesh mix text and emoji from the same font family.
|
|
983
|
-
*/
|
|
984
|
-
geo.setAttribute(
|
|
985
|
-
"glyphIsColor",
|
|
986
|
-
new InstancedBufferAttribute(new Float32Array(), 1),
|
|
987
|
-
);
|
|
988
|
-
|
|
989
|
-
geo.instanceCount = 0;
|
|
990
|
-
|
|
991
|
-
return geo;
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
private _buildGlyphInstances(shapeResult: ShapeTextResult): void {
|
|
995
|
-
const { glyphs, metrics, unitsPerEm, ascender, descender, lineGap } =
|
|
996
|
-
shapeResult;
|
|
997
|
-
|
|
998
|
-
// Build composite key -> metrics lookup.
|
|
999
|
-
// Keys are pre-computed by the WASM font worker (composite_key in Rust)
|
|
1000
|
-
// to ensure the key layout is always in sync between Rust and TypeScript.
|
|
1001
|
-
const metricsMap = new Map<bigint, GlyphMetrics>();
|
|
1002
|
-
for (const m of metrics) {
|
|
1003
|
-
metricsMap.set(m.compositeKey, m);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
const fontUnitToSdfPx = SDF_PX_SIZE / unitsPerEm;
|
|
1007
|
-
const fontUnitToColorPx = COLOR_GLYPH_PX_SIZE / unitsPerEm;
|
|
1008
|
-
|
|
1009
|
-
// Baseline-to-baseline distance in font units. Older cached results may
|
|
1010
|
-
// predate line metrics; fall back to one em.
|
|
1011
|
-
const naturalLineHeight = ascender - descender + lineGap;
|
|
1012
|
-
const lineHeightFu =
|
|
1013
|
-
(naturalLineHeight > 0 ? naturalLineHeight : unitsPerEm) *
|
|
1014
|
-
this._lineHeight;
|
|
1015
|
-
|
|
1016
|
-
// `_maxWidth` is in ems so the wrap width tracks the font size in both
|
|
1017
|
-
// sizeInMeters modes; font units are ems × unitsPerEm.
|
|
1018
|
-
const lines = breakLines(
|
|
1019
|
-
glyphs,
|
|
1020
|
-
this._maxWidth * unitsPerEm,
|
|
1021
|
-
isRtlText(this._text),
|
|
1022
|
-
);
|
|
1023
|
-
const widths = lines.map(lineWidthFu);
|
|
1024
|
-
const blockWidthFu = Math.max(...widths);
|
|
1025
|
-
|
|
1026
|
-
const renderable: {
|
|
1027
|
-
offsetEmX: number;
|
|
1028
|
-
offsetEmY: number;
|
|
1029
|
-
sizeEmX: number;
|
|
1030
|
-
sizeEmY: number;
|
|
1031
|
-
uvL: number;
|
|
1032
|
-
uvT: number;
|
|
1033
|
-
uvR: number;
|
|
1034
|
-
uvB: number;
|
|
1035
|
-
isColor: boolean;
|
|
1036
|
-
}[] = [];
|
|
1037
|
-
|
|
1038
|
-
// Unique atlas glyphs this mesh references — retained while it is visible.
|
|
1039
|
-
const glyphKeys = new Set<bigint>();
|
|
1040
|
-
|
|
1041
|
-
// Lay lines out top-down: line 0 keeps its baseline at y=0 (identical to
|
|
1042
|
-
// the old single-line behavior), later baselines step down by the line
|
|
1043
|
-
// height. Alignment shifts each line within the widest line's width.
|
|
1044
|
-
// Each glyph carries its own normalization scale so SDF and color glyphs
|
|
1045
|
-
// share one em-space coordinate system downstream — the two paths may use
|
|
1046
|
-
// different raster sizes (SDF_PX_SIZE vs COLOR_GLYPH_PX_SIZE), but both
|
|
1047
|
-
// end up in [em]-units after dividing by their respective px.
|
|
1048
|
-
for (let li = 0; li < lines.length; li++) {
|
|
1049
|
-
let cursorX = (blockWidthFu - widths[li]) * this._textAlign;
|
|
1050
|
-
let cursorY = -li * lineHeightFu;
|
|
1051
|
-
|
|
1052
|
-
for (const glyph of lines[li]) {
|
|
1053
|
-
const m = metricsMap.get(glyph.compositeKey);
|
|
1054
|
-
if (m && m.atlasW > 0 && m.atlasH > 0) {
|
|
1055
|
-
glyphKeys.add(glyph.compositeKey);
|
|
1056
|
-
const px = m.isColor ? COLOR_GLYPH_PX_SIZE : SDF_PX_SIZE;
|
|
1057
|
-
const fuToPx = m.isColor ? fontUnitToColorPx : fontUnitToSdfPx;
|
|
1058
|
-
const offsetPxX = (cursorX + glyph.xOffset) * fuToPx + m.bearingX;
|
|
1059
|
-
const offsetPxY = (cursorY + glyph.yOffset) * fuToPx + m.bearingY;
|
|
1060
|
-
|
|
1061
|
-
// Atlas rects are stored in PIXEL space; the shader divides by the
|
|
1062
|
-
// current uSdfAtlasSize / uColorAtlasSize uniform to derive UVs, so
|
|
1063
|
-
// these instance attrs survive an atlas resize without rebuilding.
|
|
1064
|
-
renderable.push({
|
|
1065
|
-
offsetEmX: offsetPxX / px,
|
|
1066
|
-
offsetEmY: offsetPxY / px,
|
|
1067
|
-
sizeEmX: m.atlasW / px,
|
|
1068
|
-
sizeEmY: m.atlasH / px,
|
|
1069
|
-
uvL: m.atlasX,
|
|
1070
|
-
uvT: m.atlasY,
|
|
1071
|
-
uvR: m.atlasX + m.atlasW,
|
|
1072
|
-
uvB: m.atlasY + m.atlasH,
|
|
1073
|
-
isColor: m.isColor,
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
cursorX += glyph.xAdvance;
|
|
1077
|
-
cursorY += glyph.yAdvance;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// Update atlas references to this mesh's new glyph set (retains/releases
|
|
1082
|
-
// as needed based on visibility).
|
|
1083
|
-
this._setGlyphKeys([...glyphKeys]);
|
|
1084
|
-
|
|
1085
|
-
const count = renderable.length;
|
|
1086
|
-
if (count === 0) {
|
|
1087
|
-
this.geometry.instanceCount = 0;
|
|
1088
|
-
this._labelWidthEm = 0;
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Text-width metric uses SDF scale; same em-space as the SDF path used to.
|
|
1093
|
-
// The block is as wide as its widest line and grows downward by one line
|
|
1094
|
-
// height per extra line (a single line stays exactly one em tall).
|
|
1095
|
-
const textWidth = blockWidthFu * fontUnitToSdfPx;
|
|
1096
|
-
const textHeight =
|
|
1097
|
-
SDF_PX_SIZE + (lines.length - 1) * lineHeightFu * fontUnitToSdfPx;
|
|
1098
|
-
|
|
1099
|
-
const glyphOffsetData = new Float32Array((count + 1) * 2);
|
|
1100
|
-
const glyphSizeData = new Float32Array((count + 1) * 2);
|
|
1101
|
-
const glyphUvRectData = new Float32Array((count + 1) * 4);
|
|
1102
|
-
const glyphIsColorData = new Float32Array(count + 1);
|
|
1103
|
-
|
|
1104
|
-
// Compute actual Y bounding box of all rendered glyphs
|
|
1105
|
-
let bgMinY = Infinity;
|
|
1106
|
-
let bgMaxY = -Infinity;
|
|
1107
|
-
|
|
1108
|
-
// Index 0 is reserved for the background instance (drawn first).
|
|
1109
|
-
// Glyph data starts at index 1.
|
|
1110
|
-
for (let i = 0; i < count; i++) {
|
|
1111
|
-
const g = renderable[i];
|
|
1112
|
-
const j = i + 1; // offset by 1 to leave index 0 for background
|
|
1113
|
-
|
|
1114
|
-
glyphOffsetData[j * 2] = g.offsetEmX;
|
|
1115
|
-
glyphOffsetData[j * 2 + 1] = g.offsetEmY;
|
|
1116
|
-
|
|
1117
|
-
glyphSizeData[j * 2] = g.sizeEmX;
|
|
1118
|
-
glyphSizeData[j * 2 + 1] = g.sizeEmY;
|
|
1119
|
-
|
|
1120
|
-
bgMinY = Math.min(bgMinY, g.offsetEmY);
|
|
1121
|
-
bgMaxY = Math.max(bgMaxY, g.offsetEmY + g.sizeEmY);
|
|
1122
|
-
|
|
1123
|
-
glyphUvRectData[j * 4] = g.uvL;
|
|
1124
|
-
glyphUvRectData[j * 4 + 1] = g.uvT;
|
|
1125
|
-
glyphUvRectData[j * 4 + 2] = g.uvR;
|
|
1126
|
-
glyphUvRectData[j * 4 + 3] = g.uvB;
|
|
1127
|
-
|
|
1128
|
-
glyphIsColorData[j] = g.isColor ? 1.0 : 0.0;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// Recreate geometry if instance count grew beyond the current capacity.
|
|
1132
|
-
if (this.geometry.instanceCount < count + 1) {
|
|
1133
|
-
this.geometry.dispose();
|
|
1134
|
-
this.geometry = this._createBaseGeometry();
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
this._setInstanceAttribute("glyphOffset", glyphOffsetData, 2);
|
|
1138
|
-
this._setInstanceAttribute("glyphSize", glyphSizeData, 2);
|
|
1139
|
-
this._setInstanceAttribute("glyphUvRect", glyphUvRectData, 4);
|
|
1140
|
-
this._setInstanceAttribute("glyphIsColor", glyphIsColorData, 1);
|
|
1141
|
-
|
|
1142
|
-
this.geometry.instanceCount = count + 1;
|
|
1143
|
-
|
|
1144
|
-
// Update text dimension uniforms via mutates, mirroring them CPU-side for
|
|
1145
|
-
// the declutter pass's collision box.
|
|
1146
|
-
this._labelWidthEm = textWidth / SDF_PX_SIZE;
|
|
1147
|
-
this._labelHeightEm = textHeight / SDF_PX_SIZE;
|
|
1148
|
-
this._labelMinYEm = bgMinY === Infinity ? 0.0 : bgMinY;
|
|
1149
|
-
this._labelMaxYEm = bgMaxY === -Infinity ? 1.0 : bgMaxY;
|
|
1150
|
-
this._enhancer
|
|
1151
|
-
.mutates()
|
|
1152
|
-
.updateTextDimensions(
|
|
1153
|
-
this._labelWidthEm,
|
|
1154
|
-
this._labelHeightEm,
|
|
1155
|
-
this._labelMinYEm,
|
|
1156
|
-
this._labelMaxYEm,
|
|
1157
|
-
);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
private _setInstanceAttribute(
|
|
1161
|
-
name: string,
|
|
1162
|
-
data: Float32Array,
|
|
1163
|
-
itemSize: number,
|
|
1164
|
-
): void {
|
|
1165
|
-
if (this.geometry.hasAttribute(name)) this.geometry.deleteAttribute(name);
|
|
1166
|
-
this.geometry.setAttribute(
|
|
1167
|
-
name,
|
|
1168
|
-
new InstancedBufferAttribute(data, itemSize),
|
|
1169
|
-
);
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
private _updateAtlasTexture(
|
|
1173
|
-
data: Uint8Array,
|
|
1174
|
-
width: number,
|
|
1175
|
-
height: number,
|
|
1176
|
-
channels: number,
|
|
1177
|
-
): void {
|
|
1178
|
-
if (this._atlasTexture) {
|
|
1179
|
-
// Update existing texture in-place (atlas dimensions are constant)
|
|
1180
|
-
this._atlasTexture.image = { data, width, height };
|
|
1181
|
-
this._atlasTexture.needsUpdate = true;
|
|
1182
|
-
return;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const tex = createSdfAtlasTexture(data, width, height, channels);
|
|
1186
|
-
this._atlasTexture = tex;
|
|
1187
|
-
this._enhancer.mutates().setAtlasTexture({ value: tex });
|
|
1188
|
-
}
|
|
1189
|
-
}
|