@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,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 "./
|
|
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
|
+
}
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
PointMesh as NavaraPointMesh,
|
|
3
3
|
BillboardMesh as NavaraBillboardMesh,
|
|
4
4
|
} from "@navaramap/engine";
|
|
5
|
-
import { degreeToRadian } from "@navaramap/
|
|
5
|
+
import { degreeToRadian } from "@navaramap/three-api";
|
|
6
6
|
import {
|
|
7
7
|
InstancedBufferAttribute,
|
|
8
8
|
InstancedBufferGeometry,
|
|
@@ -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 {
|
package/src/utils/screen.ts
CHANGED