@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,222 @@
1
+ import {
2
+ BufferAttribute,
3
+ InstancedBufferAttribute,
4
+ InstancedBufferGeometry,
5
+ } from "three";
6
+
7
+ import type { GlyphQuad } from "./layout";
8
+
9
+ /**
10
+ * The glyph-instance geometry of a batched text mesh: a shared unit quad plus
11
+ * one instance per glyph, drawn in a single call for every label in the batch.
12
+ *
13
+ * Instances are grouped into contiguous per-label runs handed out by
14
+ * `GlyphSlotAllocator`, so writing a label pushes only that run to the GPU via
15
+ * `addUpdateRange`. Growth doubles the buffers, which is the only path that
16
+ * re-uploads everything.
17
+ */
18
+
19
+ /** Per-instance `glyphKind` values. Must match the `GLYPH_KIND_*` defines in
20
+ * `shaders/glsl/sdfText.vert.glsl`. */
21
+ export const GlyphKind = {
22
+ SDF: 0,
23
+ COLOR: 1,
24
+ BACKGROUND: 2,
25
+ /** A slot inside a label's run that its text doesn't use, or a hole left by
26
+ * a freed run. Culled in the vertex shader. */
27
+ EMPTY: 3,
28
+ } as const;
29
+
30
+ /** Instance slots a freshly-created batch reserves. */
31
+ const INITIAL_CAPACITY = 64;
32
+
33
+ /** Attribute name and component count, in the order they are (re)created. */
34
+ const INSTANCE_ATTRIBUTES = [
35
+ ["glyphOffset", 2],
36
+ ["glyphSize", 2],
37
+ ["glyphUvRect", 4],
38
+ ["glyphKind", 1],
39
+ ["labelIndex", 1],
40
+ ] as const;
41
+
42
+ /** The backing arrays, one per instance attribute. */
43
+ type InstanceArrays = {
44
+ glyphOffset: Float32Array;
45
+ glyphSize: Float32Array;
46
+ glyphUvRect: Float32Array;
47
+ glyphKind: Float32Array;
48
+ labelIndex: Float32Array;
49
+ };
50
+
51
+ export class GlyphBuffers {
52
+ readonly geometry: InstancedBufferGeometry;
53
+ private _capacity: number;
54
+ private _arrays: InstanceArrays;
55
+ /** The live instance attributes, paired with their component count.
56
+ * Held directly so partial uploads don't go through `getAttribute`, whose
57
+ * union return type doesn't expose `addUpdateRange`. */
58
+ private _attributes: [InstancedBufferAttribute, number][] = [];
59
+
60
+ constructor(initialCapacity = INITIAL_CAPACITY) {
61
+ this._capacity = Math.max(1, initialCapacity);
62
+ this.geometry = new InstancedBufferGeometry();
63
+
64
+ // Unit quad: 2 triangles, 6 vertices.
65
+ // prettier-ignore
66
+ const positions = new Float32Array([
67
+ -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0,
68
+ -0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
69
+ ]);
70
+ // prettier-ignore
71
+ const uvs = new Float32Array([
72
+ 0, 0, 1, 0, 1, 1,
73
+ 0, 0, 1, 1, 0, 1,
74
+ ]);
75
+ this.geometry.setAttribute("position", new BufferAttribute(positions, 3));
76
+ this.geometry.setAttribute("uv", new BufferAttribute(uvs, 2));
77
+
78
+ this._arrays = this._allocate(this._capacity);
79
+ this.geometry.instanceCount = 0;
80
+ }
81
+
82
+ get capacity(): number {
83
+ return this._capacity;
84
+ }
85
+
86
+ /** Instances the draw call covers — the allocator's high-water mark. */
87
+ setInstanceCount(count: number): void {
88
+ this.geometry.instanceCount = count;
89
+ }
90
+
91
+ /**
92
+ * Grow to hold at least `slotCount` instances, preserving existing data.
93
+ * No-op when it already fits.
94
+ */
95
+ ensureCapacity(slotCount: number): void {
96
+ if (slotCount <= this._capacity) return;
97
+ let capacity = this._capacity;
98
+ while (capacity < slotCount) capacity *= 2;
99
+
100
+ const previousCapacity = this._capacity;
101
+ this._capacity = capacity;
102
+ this._arrays = this._allocate(capacity, this._arrays, previousCapacity);
103
+
104
+ // three caches the instance ceiling on the first VAO bind and only clears
105
+ // it when the geometry is disposed, then clamps every draw with
106
+ // `min(geometry.instanceCount, geometry._maxInstanceCount)`. Replacing the
107
+ // instance attributes with larger ones does NOT refresh it, so without this
108
+ // the batch stays pinned to whatever capacity it had when it was first
109
+ // rendered — labels added later silently never draw. Dropping the cached
110
+ // value makes three recompute it from the new attributes on the next bind.
111
+ delete (
112
+ this.geometry as InstancedBufferGeometry &
113
+ Partial<Record<"_maxInstanceCount", number>>
114
+ )._maxInstanceCount;
115
+ }
116
+
117
+ /**
118
+ * Write a label's glyph quads into its run and blank the rest of it, then
119
+ * queue a partial upload of exactly that range.
120
+ *
121
+ * `runCapacity` is the slots the label owns; anything past the quads becomes
122
+ * {@link GlyphKind.EMPTY}, so an over-allocated run — or a label whose text
123
+ * just got shorter — draws nothing in the leftover slots.
124
+ */
125
+ writeRun(
126
+ start: number,
127
+ runCapacity: number,
128
+ labelIndex: number,
129
+ quads: GlyphQuad[],
130
+ withBackground: boolean,
131
+ ): void {
132
+ const {
133
+ glyphOffset,
134
+ glyphSize,
135
+ glyphUvRect,
136
+ glyphKind,
137
+ labelIndex: label,
138
+ } = this._arrays;
139
+
140
+ // The background occupies the run's first slot so it is drawn before the
141
+ // label's glyphs — the fragment shader's outline-seam fix depends on that
142
+ // ordering (see sdfText.frag.glsl).
143
+ let slot = start;
144
+ if (withBackground) {
145
+ glyphKind[slot] = GlyphKind.BACKGROUND;
146
+ label[slot] = labelIndex;
147
+ // The background derives its quad from the label's box, not from these.
148
+ glyphOffset[slot * 2] = 0;
149
+ glyphOffset[slot * 2 + 1] = 0;
150
+ glyphSize[slot * 2] = 0;
151
+ glyphSize[slot * 2 + 1] = 0;
152
+ slot++;
153
+ }
154
+
155
+ for (const q of quads) {
156
+ glyphKind[slot] = q.isColor ? GlyphKind.COLOR : GlyphKind.SDF;
157
+ label[slot] = labelIndex;
158
+ glyphOffset[slot * 2] = q.offsetEmX;
159
+ glyphOffset[slot * 2 + 1] = q.offsetEmY;
160
+ glyphSize[slot * 2] = q.sizeEmX;
161
+ glyphSize[slot * 2 + 1] = q.sizeEmY;
162
+ glyphUvRect[slot * 4] = q.uvL;
163
+ glyphUvRect[slot * 4 + 1] = q.uvT;
164
+ glyphUvRect[slot * 4 + 2] = q.uvR;
165
+ glyphUvRect[slot * 4 + 3] = q.uvB;
166
+ slot++;
167
+ }
168
+
169
+ for (; slot < start + runCapacity; slot++) {
170
+ glyphKind[slot] = GlyphKind.EMPTY;
171
+ label[slot] = labelIndex;
172
+ }
173
+
174
+ this._queueUpload(start, runCapacity);
175
+ }
176
+
177
+ /** Blank a run so its slots draw nothing until reallocated. */
178
+ clearRun(start: number, runCapacity: number): void {
179
+ this._arrays.glyphKind.fill(GlyphKind.EMPTY, start, start + runCapacity);
180
+ this._queueUpload(start, runCapacity);
181
+ }
182
+
183
+ dispose(): void {
184
+ this.geometry.dispose();
185
+ }
186
+
187
+ /** Queue a `bufferSubData` covering `[start, start + count)` instances on
188
+ * every instance attribute. */
189
+ private _queueUpload(start: number, count: number): void {
190
+ for (const [attr, itemSize] of this._attributes) {
191
+ // addUpdateRange takes array-element units, not instance units.
192
+ attr.addUpdateRange(start * itemSize, count * itemSize);
193
+ attr.needsUpdate = true;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * (Re)create the instance attributes at `capacity`, copying `previous` in.
199
+ * Slots past the copied region start as EMPTY — a zero-filled array would
200
+ * otherwise read as `GlyphKind.SDF` and draw garbage quads.
201
+ */
202
+ private _allocate(
203
+ capacity: number,
204
+ previous?: InstanceArrays,
205
+ previousCapacity = 0,
206
+ ): InstanceArrays {
207
+ const arrays = {} as InstanceArrays;
208
+ this._attributes = [];
209
+ for (const [name, itemSize] of INSTANCE_ATTRIBUTES) {
210
+ const array = new Float32Array(capacity * itemSize);
211
+ if (previous) array.set(previous[name]);
212
+ if (name === "glyphKind") {
213
+ array.fill(GlyphKind.EMPTY, previousCapacity, capacity);
214
+ }
215
+ arrays[name] = array;
216
+ const attr = new InstancedBufferAttribute(array, itemSize);
217
+ this.geometry.setAttribute(name, attr);
218
+ this._attributes.push([attr, itemSize]);
219
+ }
220
+ return arrays;
221
+ }
222
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { GlyphSlotAllocator, capacityFor } from "./glyphSlots";
4
+
5
+ describe("capacityFor", () => {
6
+ it("floors at the minimum run size", () => {
7
+ expect(capacityFor(0)).toBe(4);
8
+ expect(capacityFor(1)).toBe(4);
9
+ expect(capacityFor(4)).toBe(4);
10
+ });
11
+
12
+ it("rounds up to the next power of two", () => {
13
+ expect(capacityFor(5)).toBe(8);
14
+ expect(capacityFor(8)).toBe(8);
15
+ expect(capacityFor(9)).toBe(16);
16
+ expect(capacityFor(1000)).toBe(1024);
17
+ expect(capacityFor(1024)).toBe(1024);
18
+ expect(capacityFor(1025)).toBe(2048);
19
+ });
20
+ });
21
+
22
+ describe("GlyphSlotAllocator", () => {
23
+ it("hands out non-overlapping runs and tracks the high-water mark", () => {
24
+ const a = new GlyphSlotAllocator();
25
+ expect(a.highWater).toBe(0);
26
+
27
+ const first = a.alloc(3); // → capacity 4
28
+ const second = a.alloc(10); // → capacity 16
29
+
30
+ expect(first).toEqual({ start: 0, capacity: 4 });
31
+ expect(second).toEqual({ start: 4, capacity: 16 });
32
+ expect(a.highWater).toBe(20);
33
+ });
34
+
35
+ // The core of the design: a label whose text changes length must keep its
36
+ // slots so the update is an in-place write, not a buffer rebuild.
37
+ describe("realloc within a size class", () => {
38
+ it("keeps the same run when the glyph count stays in class", () => {
39
+ const a = new GlyphSlotAllocator();
40
+ const run = a.alloc(5); // capacity 8
41
+ const after = a.realloc(run, 7);
42
+
43
+ expect(after).toBe(run);
44
+ expect(a.highWater).toBe(8);
45
+ });
46
+
47
+ it("keeps the same run when the text gets shorter", () => {
48
+ const a = new GlyphSlotAllocator();
49
+ const run = a.alloc(8); // capacity 8
50
+ const after = a.realloc(run, 6);
51
+
52
+ expect(after).toBe(run);
53
+ expect(a.highWater).toBe(8);
54
+ });
55
+
56
+ it("does not shrink below the minimum class", () => {
57
+ const a = new GlyphSlotAllocator();
58
+ const run = a.alloc(4);
59
+ // "ab" → "a" stays in the floor class, so nothing moves.
60
+ expect(a.realloc(run, 1)).toBe(run);
61
+ expect(a.highWater).toBe(4);
62
+ });
63
+ });
64
+
65
+ describe("realloc across size classes", () => {
66
+ it("moves the run and recycles the old slots", () => {
67
+ const a = new GlyphSlotAllocator();
68
+ const run = a.alloc(4); // capacity 4 at 0
69
+ const grown = a.realloc(run, 9); // needs capacity 16
70
+
71
+ expect(grown.capacity).toBe(16);
72
+ expect(grown.start).not.toBe(run.start);
73
+ expect(a.highWater).toBe(20);
74
+
75
+ // The vacated capacity-4 run is reusable.
76
+ expect(a.alloc(4)).toEqual({ start: 0, capacity: 4 });
77
+ expect(a.highWater).toBe(20);
78
+ });
79
+
80
+ it("reuses a freed run of the matching class before growing", () => {
81
+ const a = new GlyphSlotAllocator();
82
+ const first = a.alloc(8);
83
+ a.alloc(8);
84
+ expect(a.highWater).toBe(16);
85
+
86
+ a.free(first);
87
+ expect(a.alloc(6)).toEqual({ start: first.start, capacity: 8 });
88
+ expect(a.highWater).toBe(16);
89
+ });
90
+
91
+ it("does not serve a request from a different class's free list", () => {
92
+ const a = new GlyphSlotAllocator();
93
+ a.free(a.alloc(4));
94
+ expect(a.highWater).toBe(4);
95
+
96
+ // A capacity-16 request can't use the freed capacity-4 run.
97
+ expect(a.alloc(16)).toEqual({ start: 4, capacity: 16 });
98
+ expect(a.highWater).toBe(20);
99
+ });
100
+ });
101
+
102
+ it("allocates a fresh run when reallocating from null", () => {
103
+ const a = new GlyphSlotAllocator();
104
+ expect(a.realloc(null, 2)).toEqual({ start: 0, capacity: 4 });
105
+ });
106
+
107
+ it("reset clears the free lists and the high-water mark", () => {
108
+ const a = new GlyphSlotAllocator();
109
+ a.free(a.alloc(8));
110
+ a.reset();
111
+
112
+ expect(a.highWater).toBe(0);
113
+ expect(a.alloc(8)).toEqual({ start: 0, capacity: 8 });
114
+ });
115
+
116
+ it("never overlaps runs across a churn of allocs, frees and reallocs", () => {
117
+ const a = new GlyphSlotAllocator();
118
+ const live = new Map<number, { start: number; capacity: number }>();
119
+
120
+ // Deterministic pseudo-random churn — a cheap stand-in for the real
121
+ // workload where labels appear, change text and disappear.
122
+ let seed = 42;
123
+ const next = (n: number) => {
124
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
125
+ return seed % n;
126
+ };
127
+
128
+ for (let step = 0; step < 500; step++) {
129
+ const id = next(40);
130
+ const existing = live.get(id);
131
+ const op = next(3);
132
+
133
+ if (op === 0 && existing) {
134
+ a.free(existing);
135
+ live.delete(id);
136
+ } else {
137
+ live.set(id, a.realloc(existing ?? null, 1 + next(30)));
138
+ }
139
+
140
+ // Every live run must stay inside the high-water mark and disjoint.
141
+ const occupied = new Set<number>();
142
+ for (const run of live.values()) {
143
+ expect(run.start + run.capacity).toBeLessThanOrEqual(a.highWater);
144
+ for (let s = run.start; s < run.start + run.capacity; s++) {
145
+ expect(occupied.has(s)).toBe(false);
146
+ occupied.add(s);
147
+ }
148
+ }
149
+ }
150
+ });
151
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Suballocator for the glyph-instance array of a batched text mesh.
3
+ *
4
+ * Every label owns one contiguous run of instance slots, so a text change
5
+ * rewrites only that run (one `addUpdateRange` on each instance attribute)
6
+ * instead of the whole buffer. Runs are rounded up to power-of-two size
7
+ * classes, which is what makes the common case free: re-shaping a label to a
8
+ * similar length ({@link realloc}) keeps the same `start`, so the caller
9
+ * overwrites in place and marks the leftover tail as empty slots.
10
+ *
11
+ * Freed runs go back to a per-class free list and are handed out again before
12
+ * the high-water mark advances. Worst-case internal fragmentation is 2x, which
13
+ * is the price of never having to relocate a run that merely changed length.
14
+ */
15
+
16
+ /** Smallest run handed out: one background quad plus a few glyphs. Below this
17
+ * the size classes churn without saving meaningful memory. */
18
+ const MIN_RUN_CAPACITY = 4;
19
+
20
+ /** A label's contiguous span of glyph-instance slots. */
21
+ export type GlyphRun = {
22
+ /** First instance index owned by the label. */
23
+ readonly start: number;
24
+ /** Slots reserved — always >= the glyph count the run was sized for. */
25
+ readonly capacity: number;
26
+ };
27
+
28
+ /** Capacity class for `count` slots: the next power of two, floored at
29
+ * {@link MIN_RUN_CAPACITY}. Exported for tests. */
30
+ export function capacityFor(count: number): number {
31
+ if (count <= MIN_RUN_CAPACITY) return MIN_RUN_CAPACITY;
32
+ // 2^ceil(log2(count)), computed without floating-point log to stay exact for
33
+ // the large counts a pathological label could reach.
34
+ let capacity = MIN_RUN_CAPACITY;
35
+ while (capacity < count) capacity *= 2;
36
+ return capacity;
37
+ }
38
+
39
+ export class GlyphSlotAllocator {
40
+ /** Free run starts, keyed by capacity. Each list holds runs of exactly that
41
+ * capacity, so a request is served without searching. */
42
+ private _free = new Map<number, number[]>();
43
+ private _highWater = 0;
44
+
45
+ /**
46
+ * One past the highest slot ever allocated — the value to assign to
47
+ * `geometry.instanceCount`. Slots below it that no label owns are holes and
48
+ * must be filled with empty-kind instances by the caller.
49
+ */
50
+ get highWater(): number {
51
+ return this._highWater;
52
+ }
53
+
54
+ /** Reserve a run large enough for `count` glyph instances. */
55
+ alloc(count: number): GlyphRun {
56
+ const capacity = capacityFor(count);
57
+
58
+ const pool = this._free.get(capacity);
59
+ const reused = pool?.pop();
60
+ if (reused !== undefined) {
61
+ return { start: reused, capacity };
62
+ }
63
+
64
+ const start = this._highWater;
65
+ this._highWater += capacity;
66
+ return { start, capacity };
67
+ }
68
+
69
+ /** Return a run to its size class's free list. */
70
+ free(run: GlyphRun): void {
71
+ let pool = this._free.get(run.capacity);
72
+ if (!pool) {
73
+ pool = [];
74
+ this._free.set(run.capacity, pool);
75
+ }
76
+ pool.push(run.start);
77
+ }
78
+
79
+ /**
80
+ * Resize a label's run to hold `count` glyphs.
81
+ *
82
+ * Returns the **same run** whenever `count` still fits its capacity class —
83
+ * the case that makes in-place text updates cheap. Otherwise the old run is
84
+ * freed and a new one allocated, and the caller must rewrite the run's
85
+ * attribute data at the new `start`.
86
+ */
87
+ realloc(run: GlyphRun | null, count: number): GlyphRun {
88
+ if (run && capacityFor(count) === run.capacity) return run;
89
+ if (run) this.free(run);
90
+ return this.alloc(count);
91
+ }
92
+
93
+ /** Drop all bookkeeping. The caller is responsible for the backing buffers. */
94
+ reset(): void {
95
+ this._free.clear();
96
+ this._highWater = 0;
97
+ }
98
+ }
@@ -0,0 +1,10 @@
1
+ export { BatchedSdfTextMesh } from "./batchedSdfText";
2
+ export { GlyphKind } from "./glyphBuffers";
3
+ export {
4
+ breakLines,
5
+ isRtlText,
6
+ lineWidthFu,
7
+ buildLabelLayout,
8
+ type GlyphQuad,
9
+ type LabelLayout,
10
+ } from "./layout";
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { LABEL_ROWS, LabelDataTexture, LabelRow } from "./labelData";
4
+
5
+ /** The texel index the shader's `readLabel(slot, row)` resolves to. */
6
+ const texelIndex = (slot: number, row: number) => slot * LABEL_ROWS + row;
7
+
8
+ const dataOf = (store: LabelDataTexture) =>
9
+ store.texture.image.data as unknown as Float32Array;
10
+
11
+ describe("LabelDataTexture", () => {
12
+ it("creates a float RGBA texture sized to hold the initial capacity", () => {
13
+ const store = new LabelDataTexture(16);
14
+ const { width, height } = store.texture.image;
15
+
16
+ expect(width * height).toBeGreaterThanOrEqual(16 * LABEL_ROWS);
17
+ expect(store.size.x).toBe(width);
18
+ expect(store.size.y).toBe(height);
19
+ expect(dataOf(store).length).toBe(width * height * 4);
20
+ });
21
+
22
+ it("writes a row at the address the shader reads", () => {
23
+ const store = new LabelDataTexture(8);
24
+ store.setRow(3, LabelRow.COLOR_OPACITY, 0.25, 0.5, 0.75, 0.5);
25
+
26
+ const base = texelIndex(3, LabelRow.COLOR_OPACITY) * 4;
27
+ expect(Array.from(dataOf(store).slice(base, base + 4))).toEqual([
28
+ 0.25, 0.5, 0.75, 0.5,
29
+ ]);
30
+ });
31
+
32
+ it("keeps rows of the same label independent", () => {
33
+ const store = new LabelDataTexture(4);
34
+ store.setRow(2, LabelRow.BOX, 1, 2, 3, 4);
35
+ store.setRow(2, LabelRow.STATE, 5, 6, 7, 8);
36
+
37
+ const box = texelIndex(2, LabelRow.BOX) * 4;
38
+ const state = texelIndex(2, LabelRow.STATE) * 4;
39
+ expect(Array.from(dataOf(store).slice(box, box + 4))).toEqual([1, 2, 3, 4]);
40
+ expect(Array.from(dataOf(store).slice(state, state + 4))).toEqual([
41
+ 5, 6, 7, 8,
42
+ ]);
43
+ });
44
+
45
+ it("setComponent touches only its channel", () => {
46
+ const store = new LabelDataTexture(4);
47
+ store.setRow(1, LabelRow.STATE, 1, 2, 3, 4);
48
+ store.setComponent(1, LabelRow.STATE, 0, 0.5);
49
+
50
+ const base = texelIndex(1, LabelRow.STATE) * 4;
51
+ expect(Array.from(dataOf(store).slice(base, base + 4))).toEqual([
52
+ 0.5, 2, 3, 4,
53
+ ]);
54
+ expect(store.getComponent(1, LabelRow.STATE, 0)).toBe(0.5);
55
+ expect(store.getComponent(1, LabelRow.STATE, 2)).toBe(3);
56
+ });
57
+
58
+ it("clearSlot zeroes exactly one label's rows", () => {
59
+ const store = new LabelDataTexture(4);
60
+ store.setRow(0, LabelRow.BOX, 9, 9, 9, 9);
61
+ store.setRow(1, LabelRow.BOX, 7, 7, 7, 7);
62
+ store.setRow(2, LabelRow.BOX, 5, 5, 5, 5);
63
+
64
+ store.clearSlot(1);
65
+
66
+ expect(store.getComponent(0, LabelRow.BOX, 0)).toBe(9);
67
+ expect(store.getComponent(1, LabelRow.BOX, 0)).toBe(0);
68
+ expect(store.getComponent(2, LabelRow.BOX, 0)).toBe(5);
69
+ });
70
+
71
+ describe("ensureCapacity", () => {
72
+ it("is a no-op while the request fits", () => {
73
+ const store = new LabelDataTexture(16);
74
+ const before = store.texture;
75
+
76
+ expect(store.ensureCapacity(16)).toBe(false);
77
+ expect(store.texture).toBe(before);
78
+ });
79
+
80
+ // The allocation is padded out to whole texture rows, and that padding is
81
+ // usable space — 16 labels round up to 2 rows, which address 25. Growing
82
+ // at 17 would mean an allocate + copy + texture recreate (and a full GPU
83
+ // re-upload) while free slots were still sitting in the buffer.
84
+ it("uses the row padding before growing", () => {
85
+ const store = new LabelDataTexture(16);
86
+ const before = store.texture;
87
+
88
+ expect(store.capacity).toBeGreaterThan(16);
89
+ expect(store.ensureCapacity(17)).toBe(false);
90
+ expect(store.ensureCapacity(store.capacity)).toBe(false);
91
+ expect(store.texture).toBe(before);
92
+
93
+ // ...and the padded slots are genuinely addressable.
94
+ const last = store.capacity - 1;
95
+ store.setRow(last, LabelRow.BOX, 1, 2, 3, 4);
96
+ expect(store.getComponent(last, LabelRow.BOX, 3)).toBe(4);
97
+ expect(texelIndex(last, LABEL_ROWS - 1) * 4 + 3).toBeLessThan(
98
+ dataOf(store).length,
99
+ );
100
+
101
+ // One past the padded capacity is what actually triggers the grow.
102
+ expect(store.ensureCapacity(store.capacity + 1)).toBe(true);
103
+ });
104
+
105
+ it("reports a capacity the texture can really back", () => {
106
+ for (const requested of [1, 2, 16, 32, 40, 64, 128]) {
107
+ const store = new LabelDataTexture(requested);
108
+ const { width, height } = store.texture.image;
109
+ expect(store.capacity).toBeGreaterThanOrEqual(requested);
110
+ // Every row of every label must land inside the texture.
111
+ expect(store.capacity * LABEL_ROWS).toBeLessThanOrEqual(width * height);
112
+ expect(dataOf(store).length).toBe(width * height * 4);
113
+ }
114
+ });
115
+
116
+ it("grows, replaces the texture, and preserves existing data", () => {
117
+ const store = new LabelDataTexture(2);
118
+ store.setRow(0, LabelRow.BOX, 1, 2, 3, 4);
119
+ store.setRow(1, LabelRow.STATE, 5, 6, 7, 8);
120
+ const before = store.texture;
121
+
122
+ expect(store.ensureCapacity(40)).toBe(true);
123
+ expect(store.texture).not.toBe(before);
124
+ expect(store.capacity).toBeGreaterThanOrEqual(40);
125
+
126
+ // Addresses are stable across a grow: the width is fixed, so only the
127
+ // height changes and previously written texels keep their index.
128
+ expect(store.getComponent(0, LabelRow.BOX, 1)).toBe(2);
129
+ expect(store.getComponent(1, LabelRow.STATE, 3)).toBe(8);
130
+
131
+ // ...and the new tail is addressable.
132
+ store.setRow(39, LabelRow.BOX, 11, 12, 13, 14);
133
+ expect(store.getComponent(39, LabelRow.BOX, 0)).toBe(11);
134
+ });
135
+
136
+ it("reports the grown dimensions through size", () => {
137
+ const store = new LabelDataTexture(2);
138
+ const heightBefore = store.size.y;
139
+
140
+ store.ensureCapacity(500);
141
+
142
+ expect(store.size.y).toBeGreaterThan(heightBefore);
143
+ expect(store.size.x).toBe(store.texture.image.width);
144
+ expect(store.size.y).toBe(store.texture.image.height);
145
+ expect(dataOf(store).length).toBe(store.size.x * store.size.y * 4);
146
+ });
147
+
148
+ it("keeps the highest addressable slot inside the buffer", () => {
149
+ const store = new LabelDataTexture(3);
150
+ store.ensureCapacity(100);
151
+
152
+ const last = texelIndex(store.capacity - 1, LABEL_ROWS - 1) * 4 + 3;
153
+ expect(last).toBeLessThan(dataOf(store).length);
154
+ });
155
+ });
156
+
157
+ // `needsUpdate` is a write-only setter that bumps `version`, so the upload
158
+ // request is only observable through the version counter.
159
+ it("flags the texture for upload on every write", () => {
160
+ const store = new LabelDataTexture(4);
161
+
162
+ let version = store.texture.version;
163
+ store.setRow(0, LabelRow.BOX, 1, 1, 1, 1);
164
+ expect(store.texture.version).toBeGreaterThan(version);
165
+
166
+ version = store.texture.version;
167
+ store.setComponent(0, LabelRow.STATE, 0, 1);
168
+ expect(store.texture.version).toBeGreaterThan(version);
169
+
170
+ version = store.texture.version;
171
+ store.clearSlot(0);
172
+ expect(store.texture.version).toBeGreaterThan(version);
173
+
174
+ version = store.texture.version;
175
+ store.markDirty();
176
+ expect(store.texture.version).toBeGreaterThan(version);
177
+ });
178
+ });