@navaramap/three 0.0.4 → 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.
Files changed (51) hide show
  1. package/dist/assets/fontWorker-OwvKiQ8o.js +2 -0
  2. package/dist/assets/fontWorker-OwvKiQ8o.js.map +1 -0
  3. package/dist/assets/{index-ywHSGImj.js → index-TKhRxurv.js} +2 -2
  4. package/dist/assets/index-TKhRxurv.js.map +1 -0
  5. package/dist/assets/{navara_wasm_font_worker_bg-Du9nb2Pn.wasm → navara_wasm_font_worker_bg-Kk06ArRB.wasm} +0 -0
  6. package/dist/assets/{navara_wasm_worker_bg-Xadu67fw.wasm → navara_wasm_worker_bg-BZjLKf4i.wasm} +0 -0
  7. package/dist/index.d.ts +349 -282
  8. package/dist/index.js +5394 -5470
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.umd.cjs +287 -235
  11. package/dist/index.umd.cjs.map +1 -1
  12. package/package.json +12 -12
  13. package/src/atmosphere.test.ts +227 -28
  14. package/src/atmosphere.ts +219 -55
  15. package/src/constants/assets.ts +63 -13
  16. package/src/declutter/types.ts +1 -1
  17. package/src/evaluations/FeatureEvaluator.test.ts +152 -0
  18. package/src/evaluations/FeatureEvaluator.ts +73 -2
  19. package/src/event/features/billboard.ts +1 -0
  20. package/src/event/features/point.ts +1 -0
  21. package/src/index.ts +7 -9
  22. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/index.ts +10 -7
  23. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.test.ts +74 -81
  24. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +18 -85
  25. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +77 -0
  26. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +9 -1
  27. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.test.ts +15 -19
  28. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.ts +0 -13
  29. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +46 -48
  30. package/src/mesh/constants.ts +22 -0
  31. package/src/mesh/index.ts +1 -1
  32. package/src/mesh/model.ts +6 -0
  33. package/src/mesh/polygon.ts +6 -0
  34. package/src/mesh/polyline.ts +6 -0
  35. package/src/mesh/sdfText/batchedSdfText.ts +1223 -0
  36. package/src/mesh/sdfText/glyphBuffers.ts +222 -0
  37. package/src/mesh/sdfText/glyphSlots.test.ts +151 -0
  38. package/src/mesh/sdfText/glyphSlots.ts +98 -0
  39. package/src/mesh/sdfText/index.ts +10 -0
  40. package/src/mesh/sdfText/labelData.test.ts +178 -0
  41. package/src/mesh/sdfText/labelData.ts +187 -0
  42. package/src/mesh/{sdfText.test.ts → sdfText/layout.test.ts} +1 -1
  43. package/src/mesh/sdfText/layout.ts +306 -0
  44. package/src/mesh/sprite/instancedSprite.ts +32 -0
  45. package/dist/assets/fontWorker-wz61edjR.js +0 -2
  46. package/dist/assets/fontWorker-wz61edjR.js.map +0 -1
  47. package/dist/assets/index-ywHSGImj.js.map +0 -1
  48. package/src/mesh/batchedSdfText.ts +0 -708
  49. package/src/mesh/sdfText.ts +0 -1189
  50. /package/dist/{draco_decoder.wasm → assets/draco_decoder-C32yEggz.wasm} +0 -0
  51. /package/dist/{draco_decoder2.wasm → assets/draco_decoder-Z1_iN-Ht.wasm} +0 -0
@@ -0,0 +1,187 @@
1
+ import {
2
+ ClampToEdgeWrapping,
3
+ DataTexture,
4
+ FloatType,
5
+ NearestFilter,
6
+ RGBAFormat,
7
+ Vector2,
8
+ } from "three";
9
+
10
+ import {
11
+ LABEL_ROWS,
12
+ LabelRow,
13
+ } from "../../material/enhancer/sdfText/sdfTextBaseEnhancer/types";
14
+
15
+ export { LABEL_ROWS, LabelRow };
16
+
17
+ /**
18
+ * Texels per texture row. Fixed so a capacity grow never changes an existing
19
+ * label's address — only the height grows, and previously written data stays
20
+ * valid after the copy.
21
+ */
22
+ const TEXTURE_WIDTH = 64;
23
+
24
+ /** Labels a freshly-created store is sized for, before row padding. */
25
+ const INITIAL_CAPACITY = 16;
26
+
27
+ /** Floats needed to hold `capacity` labels, padded out to whole texture rows. */
28
+ function floatsFor(capacity: number): number {
29
+ const rows = Math.ceil((capacity * LABEL_ROWS) / TEXTURE_WIDTH);
30
+ return rows * TEXTURE_WIDTH * 4;
31
+ }
32
+
33
+ /**
34
+ * Labels an allocation can actually address — the row padding {@link floatsFor}
35
+ * adds is usable space, not slack. Deriving capacity from the buffer instead of
36
+ * from the requested count is what stops `ensureCapacity` from growing while
37
+ * there are still free slots inside the current allocation.
38
+ */
39
+ function labelsIn(floats: number): number {
40
+ return Math.max(1, Math.floor(floats / 4 / LABEL_ROWS));
41
+ }
42
+
43
+ /**
44
+ * Per-label state for a batched text mesh, stored in a float texture that the
45
+ * vertex shader reads with `texelFetch`.
46
+ *
47
+ * A batched mesh's instances are *glyphs*, not labels, so anything that varies
48
+ * per label can't be an instance attribute without replicating it onto every
49
+ * glyph. Indirecting through this texture (via the `labelIndex` attribute)
50
+ * keeps a per-label change — colour, size, the declutter fade running every
51
+ * frame — at a handful of float writes regardless of how many glyphs the label
52
+ * has, and leaves label state untouched when a text change relocates the
53
+ * label's glyph run.
54
+ *
55
+ * Addressing mirrors `fogLight.frag.glsl`: a linear texel index split over a
56
+ * fixed-width texture, sampled unfiltered. The row layout itself
57
+ * ({@link LabelRow} / {@link LABEL_ROWS}) is the shader contract and lives with
58
+ * the enhancer that owns the shader; `LABEL_ROWS` is injected there as a GLSL
59
+ * define so the two addressing schemes can't drift.
60
+ */
61
+ export class LabelDataTexture {
62
+ private _capacity = 0;
63
+ private _data: Float32Array;
64
+ private _texture: DataTexture;
65
+ private readonly _size = new Vector2();
66
+
67
+ constructor(initialCapacity = INITIAL_CAPACITY) {
68
+ this._data = new Float32Array(floatsFor(Math.max(1, initialCapacity)));
69
+ this._capacity = labelsIn(this._data.length);
70
+ this._texture = this._createTexture();
71
+ }
72
+
73
+ /** The texture to bind to `uLabelData`. Replaced by {@link ensureCapacity}. */
74
+ get texture(): DataTexture {
75
+ return this._texture;
76
+ }
77
+
78
+ /** Dimensions in texels, for the `uLabelTexSize` uniform. */
79
+ get size(): Vector2 {
80
+ return this._size;
81
+ }
82
+
83
+ /**
84
+ * Labels addressable without a grow. Derived from the allocation, so it
85
+ * includes the row padding and is generally larger than the count the store
86
+ * was constructed or grown for.
87
+ */
88
+ get capacity(): number {
89
+ return this._capacity;
90
+ }
91
+
92
+ /**
93
+ * Make room for `slotCount` labels.
94
+ *
95
+ * Returns `true` when the backing `DataTexture` was replaced, in which case
96
+ * the caller must re-point the `uLabelData` uniform and refresh
97
+ * `uLabelTexSize`. Existing label data survives unchanged.
98
+ */
99
+ ensureCapacity(slotCount: number): boolean {
100
+ if (slotCount <= this._capacity) return false;
101
+
102
+ let target = this._capacity;
103
+ while (target < slotCount) target *= 2;
104
+
105
+ const data = new Float32Array(floatsFor(target));
106
+ data.set(this._data);
107
+
108
+ this._data = data;
109
+ this._capacity = labelsIn(data.length);
110
+ this._texture.dispose();
111
+ this._texture = this._createTexture();
112
+ return true;
113
+ }
114
+
115
+ /** Write a whole texel. */
116
+ setRow(
117
+ slot: number,
118
+ row: number,
119
+ x: number,
120
+ y: number,
121
+ z: number,
122
+ w: number,
123
+ ): void {
124
+ const i = (slot * LABEL_ROWS + row) * 4;
125
+ const data = this._data;
126
+ data[i] = x;
127
+ data[i + 1] = y;
128
+ data[i + 2] = z;
129
+ data[i + 3] = w;
130
+ this._texture.needsUpdate = true;
131
+ }
132
+
133
+ /** Write one channel of a texel, leaving the other three alone. */
134
+ setComponent(
135
+ slot: number,
136
+ row: number,
137
+ component: number,
138
+ value: number,
139
+ ): void {
140
+ this._data[(slot * LABEL_ROWS + row) * 4 + component] = value;
141
+ this._texture.needsUpdate = true;
142
+ }
143
+
144
+ /** Read one channel back — used by the declutter fade's read/modify/write. */
145
+ getComponent(slot: number, row: number, component: number): number {
146
+ return this._data[(slot * LABEL_ROWS + row) * 4 + component];
147
+ }
148
+
149
+ /** Zero a label's rows so a recycled slot can't inherit stale state. */
150
+ clearSlot(slot: number): void {
151
+ const start = slot * LABEL_ROWS * 4;
152
+ this._data.fill(0, start, start + LABEL_ROWS * 4);
153
+ this._texture.needsUpdate = true;
154
+ }
155
+
156
+ /** Mark the texture for re-upload without writing anything. */
157
+ markDirty(): void {
158
+ this._texture.needsUpdate = true;
159
+ }
160
+
161
+ dispose(): void {
162
+ this._texture.dispose();
163
+ }
164
+
165
+ private _createTexture(): DataTexture {
166
+ // Derived from the buffer rather than recomputed from `_capacity`: the
167
+ // texture must describe exactly the memory it is backed by.
168
+ const height = this._data.length / (TEXTURE_WIDTH * 4);
169
+ const tex = new DataTexture(
170
+ this._data,
171
+ TEXTURE_WIDTH,
172
+ height,
173
+ RGBAFormat,
174
+ FloatType,
175
+ );
176
+ // texelFetch is unfiltered by definition, but the sampler state still has
177
+ // to be complete for the texture to be bindable.
178
+ tex.magFilter = NearestFilter;
179
+ tex.minFilter = NearestFilter;
180
+ tex.wrapS = ClampToEdgeWrapping;
181
+ tex.wrapT = ClampToEdgeWrapping;
182
+ tex.generateMipmaps = false;
183
+ tex.needsUpdate = true;
184
+ this._size.set(TEXTURE_WIDTH, height);
185
+ return tex;
186
+ }
187
+ }
@@ -1,7 +1,7 @@
1
1
  import { GlyphCharClass, type ShapedGlyph } from "@navaramap/font";
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
- import { breakLines, isRtlText, lineWidthFu } from "./sdfText";
4
+ import { breakLines, isRtlText, lineWidthFu } from "./layout";
5
5
 
6
6
  /** Build a glyph run from a compact spec: one entry per glyph. */
7
7
  function glyphs(
@@ -0,0 +1,306 @@
1
+ import {
2
+ COLOR_GLYPH_PX_SIZE,
3
+ GlyphCharClass,
4
+ type GlyphMetrics,
5
+ type ShapedGlyph,
6
+ type ShapeTextResult,
7
+ } from "@navaramap/font";
8
+
9
+ /**
10
+ * Pure text-layout helpers: line breaking, paragraph direction, and turning a
11
+ * shaping result into positioned glyph quads in em space.
12
+ *
13
+ * Nothing here touches three.js or WASM, which is what makes the layout rules
14
+ * — the part with all the edge cases — unit-testable without a GL context.
15
+ */
16
+
17
+ /** Must match Rust `SDF_PX_SIZE` in `crates/navara_wasm_font_worker/src/atlas.rs`. */
18
+ export const SDF_PX_SIZE = 64.0;
19
+
20
+ /** Horizontal alignment of lines within a multi-line block, as the fraction
21
+ * of leftover width placed before each line. */
22
+ export const ALIGN_FACTORS: Record<string, number> = {
23
+ left: 0,
24
+ center: 0.5,
25
+ right: 1,
26
+ };
27
+
28
+ /** Line width in font units: advances summed, trailing whitespace ignored so
29
+ * it never affects alignment or the block width. */
30
+ export function lineWidthFu(line: ShapedGlyph[]): number {
31
+ let end = line.length;
32
+ while (end > 0 && line[end - 1].charClass === GlyphCharClass.Whitespace) {
33
+ end--;
34
+ }
35
+ let width = 0;
36
+ for (let i = 0; i < end; i++) width += line[i].xAdvance;
37
+ return width;
38
+ }
39
+
40
+ /** Strong RTL code points (Hebrew through Arabic Extended, presentation
41
+ * forms, and the supplementary-plane RTL blocks). Used for first-strong
42
+ * paragraph direction detection, mirroring the shaper's own direction guess.
43
+ */
44
+ const STRONG_RTL_RE =
45
+ /[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
46
+
47
+ /** Paragraph direction from the first strong directional character (UAX #9
48
+ * P2/P3 approximation): the first letter decides, RTL ranges win over other
49
+ * letters. */
50
+ export function isRtlText(text: string): boolean {
51
+ for (const ch of text) {
52
+ if (STRONG_RTL_RE.test(ch)) return true;
53
+ if (/\p{L}/u.test(ch)) return false;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Split a shaped glyph run into lines: hard breaks at newline markers, greedy
60
+ * soft breaks at the last whitespace/ideographic glyph when a line would
61
+ * exceed `maxWidthFu` (font units; 0 disables wrapping). A word longer than
62
+ * the wrap width overflows rather than breaking mid-word.
63
+ *
64
+ * When `rtl` is set, glyphs are assumed to arrive in visual order (leftmost
65
+ * first — how the shaper emits RTL runs), i.e. reversed logical order. Each
66
+ * hard-break segment is wrapped in logical order so the greedy fill starts at
67
+ * the sentence start and lines stack top-to-bottom in reading order, then
68
+ * every line is flipped back to visual order for rendering. Mixed-direction
69
+ * lines may still break sub-optimally — same trade other map renderers make.
70
+ */
71
+ export function breakLines(
72
+ glyphs: ShapedGlyph[],
73
+ maxWidthFu: number,
74
+ rtl = false,
75
+ ): ShapedGlyph[][] {
76
+ const lines: ShapedGlyph[][] = [];
77
+
78
+ const pushSegment = (segment: ShapedGlyph[]) => {
79
+ if (!rtl) {
80
+ lines.push(...wrapSegment(segment, maxWidthFu));
81
+ return;
82
+ }
83
+ segment.reverse();
84
+ for (const line of wrapSegment(segment, maxWidthFu)) {
85
+ lines.push(line.reverse());
86
+ }
87
+ };
88
+
89
+ let segment: ShapedGlyph[] = [];
90
+ for (const g of glyphs) {
91
+ if (g.charClass === GlyphCharClass.Newline) {
92
+ pushSegment(segment);
93
+ segment = [];
94
+ } else {
95
+ segment.push(g);
96
+ }
97
+ }
98
+ pushSegment(segment);
99
+ return lines;
100
+ }
101
+
102
+ /** Greedy soft-wrap of a single hard-break-free segment in logical order. */
103
+ function wrapSegment(
104
+ glyphs: ShapedGlyph[],
105
+ maxWidthFu: number,
106
+ ): ShapedGlyph[][] {
107
+ const lines: ShapedGlyph[][] = [];
108
+ let line: ShapedGlyph[] = [];
109
+ let width = 0;
110
+ let breakIdx = -1; // index in `line` of the last break opportunity
111
+
112
+ for (const g of glyphs) {
113
+ // Trailing whitespace is invisible at a line end, so it never triggers a
114
+ // wrap itself — it just gets trimmed if a later glyph wraps.
115
+ if (
116
+ maxWidthFu > 0 &&
117
+ g.charClass !== GlyphCharClass.Whitespace &&
118
+ line.length > 0 &&
119
+ breakIdx >= 0 &&
120
+ width + g.xAdvance > maxWidthFu
121
+ ) {
122
+ const head = line.slice(0, breakIdx + 1);
123
+ while (
124
+ head.length > 0 &&
125
+ head[head.length - 1].charClass === GlyphCharClass.Whitespace
126
+ ) {
127
+ head.pop();
128
+ }
129
+ lines.push(head);
130
+
131
+ const tail = line.slice(breakIdx + 1);
132
+ const start = tail.findIndex(
133
+ (gg) => gg.charClass !== GlyphCharClass.Whitespace,
134
+ );
135
+ line = start === -1 ? [] : tail.slice(start);
136
+
137
+ width = 0;
138
+ for (const rest of line) width += rest.xAdvance;
139
+ breakIdx = -1;
140
+ }
141
+
142
+ line.push(g);
143
+ width += g.xAdvance;
144
+ if (
145
+ g.charClass === GlyphCharClass.Whitespace ||
146
+ g.charClass === GlyphCharClass.Ideographic
147
+ ) {
148
+ breakIdx = line.length - 1;
149
+ }
150
+ }
151
+
152
+ lines.push(line);
153
+ return lines;
154
+ }
155
+
156
+ /** One positioned glyph quad in em space, ready to become an instance. */
157
+ export type GlyphQuad = {
158
+ offsetEmX: number;
159
+ offsetEmY: number;
160
+ sizeEmX: number;
161
+ sizeEmY: number;
162
+ /** Atlas sub-rect in PIXEL space; the shader divides by the live atlas size
163
+ * uniform so an atlas resize doesn't invalidate the instance data. */
164
+ uvL: number;
165
+ uvT: number;
166
+ uvR: number;
167
+ uvB: number;
168
+ /** Sample the COLRv1 colour atlas rather than the SDF atlas. */
169
+ isColor: boolean;
170
+ };
171
+
172
+ /** A laid-out label: its glyph quads plus the block metrics the shader and the
173
+ * declutter pass need. All measurements are in ems. */
174
+ export type LabelLayout = {
175
+ quads: GlyphQuad[];
176
+ /** Unique atlas glyphs referenced, for retain/release against the atlas. */
177
+ glyphKeys: bigint[];
178
+ widthEm: number;
179
+ heightEm: number;
180
+ /** Y bounds of the actual rendered glyph bboxes, for the background quad. */
181
+ minYEm: number;
182
+ maxYEm: number;
183
+ };
184
+
185
+ export type LayoutOptions = {
186
+ /** The source string — only used to detect paragraph direction. */
187
+ text: string;
188
+ /** Wrap width in ems (multiples of font size); 0 disables soft wrapping. */
189
+ maxWidth: number;
190
+ /** Multiplier on the font's natural line height. */
191
+ lineHeight: number;
192
+ /** 0 left, 0.5 center, 1 right. */
193
+ textAlign: number;
194
+ };
195
+
196
+ const EMPTY_LAYOUT: LabelLayout = {
197
+ quads: [],
198
+ glyphKeys: [],
199
+ widthEm: 0,
200
+ heightEm: 0,
201
+ minYEm: 0,
202
+ maxYEm: 1,
203
+ };
204
+
205
+ /**
206
+ * Turn a shaping result into positioned glyph quads.
207
+ *
208
+ * Lines lay out top-down: line 0 keeps its baseline at y=0, later baselines
209
+ * step down by the line height, and alignment shifts each line within the
210
+ * widest line's width. Each glyph carries its own normalization scale so SDF
211
+ * and colour glyphs share one em-space coordinate system downstream — the two
212
+ * paths rasterize at different sizes (`SDF_PX_SIZE` vs `COLOR_GLYPH_PX_SIZE`)
213
+ * but both end up in ems after dividing by their respective pixel size.
214
+ */
215
+ export function buildLabelLayout(
216
+ shapeResult: ShapeTextResult,
217
+ options: LayoutOptions,
218
+ ): LabelLayout {
219
+ const { glyphs, metrics, unitsPerEm, ascender, descender, lineGap } =
220
+ shapeResult;
221
+
222
+ // Keys are pre-computed by the WASM font worker (composite_key in Rust) so
223
+ // the key layout stays in sync between Rust and TypeScript.
224
+ const metricsMap = new Map<bigint, GlyphMetrics>();
225
+ for (const m of metrics) metricsMap.set(m.compositeKey, m);
226
+
227
+ const fontUnitToSdfPx = SDF_PX_SIZE / unitsPerEm;
228
+ const fontUnitToColorPx = COLOR_GLYPH_PX_SIZE / unitsPerEm;
229
+
230
+ // Baseline-to-baseline distance in font units. Older cached results may
231
+ // predate line metrics; fall back to one em.
232
+ const naturalLineHeight = ascender - descender + lineGap;
233
+ const lineHeightFu =
234
+ (naturalLineHeight > 0 ? naturalLineHeight : unitsPerEm) *
235
+ options.lineHeight;
236
+
237
+ // `maxWidth` is in ems so the wrap width tracks the font size in both
238
+ // sizeInMeters modes; font units are ems × unitsPerEm.
239
+ const lines = breakLines(
240
+ glyphs,
241
+ options.maxWidth * unitsPerEm,
242
+ isRtlText(options.text),
243
+ );
244
+ const widths = lines.map(lineWidthFu);
245
+ const blockWidthFu = Math.max(...widths);
246
+
247
+ const quads: GlyphQuad[] = [];
248
+ const glyphKeys = new Set<bigint>();
249
+ let minYEm = Infinity;
250
+ let maxYEm = -Infinity;
251
+
252
+ for (let li = 0; li < lines.length; li++) {
253
+ let cursorX = (blockWidthFu - widths[li]) * options.textAlign;
254
+ let cursorY = -li * lineHeightFu;
255
+
256
+ for (const glyph of lines[li]) {
257
+ const m = metricsMap.get(glyph.compositeKey);
258
+ if (m && m.atlasW > 0 && m.atlasH > 0) {
259
+ glyphKeys.add(glyph.compositeKey);
260
+ const px = m.isColor ? COLOR_GLYPH_PX_SIZE : SDF_PX_SIZE;
261
+ const fuToPx = m.isColor ? fontUnitToColorPx : fontUnitToSdfPx;
262
+ const offsetEmX =
263
+ ((cursorX + glyph.xOffset) * fuToPx + m.bearingX) / px;
264
+ const offsetEmY =
265
+ ((cursorY + glyph.yOffset) * fuToPx + m.bearingY) / px;
266
+ const sizeEmY = m.atlasH / px;
267
+
268
+ minYEm = Math.min(minYEm, offsetEmY);
269
+ maxYEm = Math.max(maxYEm, offsetEmY + sizeEmY);
270
+
271
+ quads.push({
272
+ offsetEmX,
273
+ offsetEmY,
274
+ sizeEmX: m.atlasW / px,
275
+ sizeEmY,
276
+ uvL: m.atlasX,
277
+ uvT: m.atlasY,
278
+ uvR: m.atlasX + m.atlasW,
279
+ uvB: m.atlasY + m.atlasH,
280
+ isColor: m.isColor,
281
+ });
282
+ }
283
+ cursorX += glyph.xAdvance;
284
+ cursorY += glyph.yAdvance;
285
+ }
286
+ }
287
+
288
+ if (quads.length === 0) {
289
+ // Glyphs may have shaped but all been blank (whitespace only) — the label
290
+ // still has no collision box and nothing to draw.
291
+ return { ...EMPTY_LAYOUT, glyphKeys: [...glyphKeys] };
292
+ }
293
+
294
+ // The block is as wide as its widest line and grows downward by one line
295
+ // height per extra line (a single line stays exactly one em tall).
296
+ return {
297
+ quads,
298
+ glyphKeys: [...glyphKeys],
299
+ widthEm: (blockWidthFu * fontUnitToSdfPx) / SDF_PX_SIZE,
300
+ heightEm:
301
+ (SDF_PX_SIZE + (lines.length - 1) * lineHeightFu * fontUnitToSdfPx) /
302
+ SDF_PX_SIZE,
303
+ minYEm,
304
+ maxYEm,
305
+ };
306
+ }
@@ -26,14 +26,21 @@ import {
26
26
  import type { EventContext } from "../../event/context";
27
27
  import { createInstancedSpriteMaterialEnhancer } from "../../material/enhancer";
28
28
  import type { CustomObject3DEventMap } from "../../object3DEvent";
29
+ import { GEOMETRY_TYPES, type GeometryType } from "../constants";
29
30
  import { PickableMesh } from "../pickableMesh";
30
31
 
31
32
  import { BillboardAtlas, type AtlasRect } from "./billboardAtlas";
32
33
  import { loadAtlasImageFromUrl } from "./billboardAtlasImageLoader";
33
34
 
35
+ type SpriteGeometryType = Extract<
36
+ GeometryType,
37
+ typeof GEOMETRY_TYPES.Point | typeof GEOMETRY_TYPES.Billboard
38
+ >;
39
+
34
40
  export type InstancedSpriteOptions = {
35
41
  renderOrder?: number;
36
42
  ctx: EventContext;
43
+ geometryType: SpriteGeometryType;
37
44
  };
38
45
 
39
46
  type PositionsInfo = {
@@ -58,6 +65,7 @@ export class InstancedSpriteMesh
58
65
  extends Mesh<BufferGeometry, Material | Material[], CustomObject3DEventMap>
59
66
  implements PickableMesh, DeclutterParticipant
60
67
  {
68
+ private _geometryType: SpriteGeometryType = GEOMETRY_TYPES.Point;
61
69
  private _batchIdToInstance = new Map<number, number>();
62
70
  private _initialColor: Color = new Color(0xffffff);
63
71
  private _initialHeight = 0.0;
@@ -103,6 +111,7 @@ export class InstancedSpriteMesh
103
111
  super();
104
112
  this.renderOrder = options.renderOrder ?? this.renderOrder;
105
113
  this.ctx = options.ctx;
114
+ this._geometryType = options.geometryType;
106
115
  this.ctx.declutter?.register(this);
107
116
  // `processObjectRemoved` dispatches this for every removed mesh; it is the
108
117
  // reliable teardown signal (this class's dispose() is not called there).
@@ -117,6 +126,29 @@ export class InstancedSpriteMesh
117
126
  this.ctx.declutter?.markDirty();
118
127
  }
119
128
 
129
+ /**
130
+ * Set the geometry type for this mesh.
131
+ *
132
+ * This affects how FeatureEvaluator provides meshGeomType to evaluators,
133
+ * which is used by MapLibreStylePlugin to determine which properties to apply
134
+ * (e.g., billboard vs text rendering for symbol layers).
135
+ *
136
+ * Changing this after initialization is supported and will affect subsequent
137
+ * feature evaluations, but does NOT automatically trigger re-evaluation.
138
+ * Call layer.forceUpdate() if you need to re-evaluate existing features.
139
+ */
140
+ setGeometryType(type: SpriteGeometryType): void {
141
+ this._geometryType = type;
142
+ }
143
+
144
+ /**
145
+ * Get the geometry type of this mesh.
146
+ * Defaults to GEOMETRY_TYPES.Point if not explicitly set.
147
+ */
148
+ get geometryType(): SpriteGeometryType {
149
+ return this._geometryType;
150
+ }
151
+
120
152
  // --- DeclutterParticipant ---
121
153
 
122
154
  collectDeclutterCandidates(out: DeclutterCandidate[]): void {