@navaramap/three 0.0.7 → 0.1.0

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 (46) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +20 -8
  3. package/dist/assets/fontWorker-C5cHhkWQ.js +2 -0
  4. package/dist/assets/fontWorker-C5cHhkWQ.js.map +1 -0
  5. package/dist/assets/index-VfUncKuS.js +25 -0
  6. package/dist/assets/index-VfUncKuS.js.map +1 -0
  7. package/dist/assets/{navara_wasm_font_worker_bg-XvE_oYPr.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
  8. package/dist/assets/{navara_wasm_worker_bg-Dg9fnGLP.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
  9. package/dist/index.d.ts +460 -43
  10. package/dist/index.js +3732 -3584
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +230 -209
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +17 -17
  15. package/src/camera.ts +11 -4
  16. package/src/core/InstancedMeshDesc.ts +1 -1
  17. package/src/core/MeshDesc.test.ts +396 -0
  18. package/src/core/MeshDesc.ts +219 -8
  19. package/src/core/errors.ts +14 -0
  20. package/src/core/index.ts +2 -0
  21. package/src/evaluations/FeatureEvaluator.ts +17 -10
  22. package/src/event/context.ts +6 -0
  23. package/src/event/feature.ts +15 -2
  24. package/src/event/features/billboard.test.ts +62 -0
  25. package/src/event/features/billboard.ts +4 -2
  26. package/src/event/index.ts +7 -0
  27. package/src/index.ts +174 -40
  28. package/src/material/macro/tileShader.ts +12 -2
  29. package/src/mesh/polyline.ts +32 -4
  30. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  31. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  32. package/src/mesh/sprite/billboardAtlas.ts +8 -1
  33. package/src/mesh/sprite/instancedSprite.ts +173 -65
  34. package/src/pick/pickHelper.test.ts +302 -0
  35. package/src/pick/pickHelper.ts +119 -20
  36. package/src/plugins/AttributionPlugin/index.ts +1 -1
  37. package/src/renderOrder.ts +10 -0
  38. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  39. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  40. package/src/terrain/terrainHeightEvent.test.ts +32 -0
  41. package/src/terrain/terrainHeightEvent.ts +22 -0
  42. package/src/type/index.ts +22 -1
  43. package/dist/assets/fontWorker-DaqfXmZf.js +0 -2
  44. package/dist/assets/fontWorker-DaqfXmZf.js.map +0 -1
  45. package/dist/assets/index-Dydat1h1.js +0 -25
  46. package/dist/assets/index-Dydat1h1.js.map +0 -1
@@ -81,7 +81,10 @@ type PositionsInfo = PositionsInfoBase &
81
81
  type LabelRecord = {
82
82
  /** Row block in the label data texture, and this label's declutter handle. */
83
83
  slot: number;
84
- batchIndex: number;
84
+ /** Anchor (position) slot this label sits on, NOT the feature index: a
85
+ * feature spans several anchors for MultiPoint geometry and for labels
86
+ * derived from line/polygon vertices via `geometryTypes`. */
87
+ instanceIndex: number;
85
88
  batchId: number;
86
89
  /** The text currently laid out into the glyph run. */
87
90
  text: string;
@@ -179,8 +182,15 @@ export class BatchedSdfTextMesh
179
182
 
180
183
  /** Labels in slot order — `_labels[slot]` is also the declutter handle map. */
181
184
  private _labels: LabelRecord[] = [];
182
- /** Sparse batchIndex → label; labels are created on first per-feature touch. */
183
- private _labelByIndex: (LabelRecord | undefined)[] = [];
185
+ /** Sparse anchor slot → label; labels are created on first per-feature touch. */
186
+ private _labelByInstance: (LabelRecord | undefined)[] = [];
187
+ /**
188
+ * Feature (batch) index → this feature's anchor slots. A feature owns
189
+ * multiple anchors for MultiPoint geometry and for labels derived from
190
+ * line/polygon vertices via `geometryTypes`, so per-feature styling must fan
191
+ * out to all of them. `null` means anchors and features are 1:1.
192
+ */
193
+ private _batchIndexToInstances: Map<number, number[]> | null = null;
184
194
 
185
195
  /** In-flight per-feature text preparations; see {@link whenLabelsSettled}. */
186
196
  private _pendingTextPrepares = new PendingSettlement();
@@ -240,6 +250,7 @@ export class BatchedSdfTextMesh
240
250
  this._declutterPriority = material.declutterPriority ?? 0;
241
251
 
242
252
  this._positions = this.extractPositions(m);
253
+ this._rebuildBatchIndexMap(m);
243
254
  this._glyphs = new GlyphBuffers();
244
255
  this._labelData = new LabelDataTexture();
245
256
 
@@ -359,12 +370,12 @@ export class BatchedSdfTextMesh
359
370
  for (let i = 0; i < info.nPositions; i++) this._ensureLabel(i);
360
371
  }
361
372
 
362
- private _ensureLabel(batchIndex: number): LabelRecord | undefined {
363
- const existing = this._labelByIndex[batchIndex];
373
+ private _ensureLabel(instanceIndex: number): LabelRecord | undefined {
374
+ const existing = this._labelByInstance[instanceIndex];
364
375
  if (existing) return existing;
365
376
 
366
377
  const info = this._positions;
367
- if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
378
+ if (!info || instanceIndex < 0 || instanceIndex >= info.nPositions) {
368
379
  return undefined;
369
380
  }
370
381
 
@@ -376,8 +387,10 @@ export class BatchedSdfTextMesh
376
387
 
377
388
  const record: LabelRecord = {
378
389
  slot,
379
- batchIndex,
380
- batchId: info.batchIDs ? info.batchIDs[batchIndex * info.batchIDSize] : 0,
390
+ instanceIndex,
391
+ batchId: info.batchIDs
392
+ ? info.batchIDs[instanceIndex * info.batchIDSize]
393
+ : 0,
381
394
  text: "",
382
395
  requestedText: "",
383
396
  prepareDeferred: false,
@@ -405,7 +418,7 @@ export class BatchedSdfTextMesh
405
418
  };
406
419
 
407
420
  this._labels.push(record);
408
- this._labelByIndex[batchIndex] = record;
421
+ this._labelByInstance[instanceIndex] = record;
409
422
 
410
423
  this._writeAnchor(record);
411
424
  this._writeStyle(record);
@@ -419,7 +432,7 @@ export class BatchedSdfTextMesh
419
432
  private _writeAnchor(record: LabelRecord): void {
420
433
  const info = this._positions;
421
434
  if (!info) return;
422
- const idx = record.batchIndex * info.positionSize;
435
+ const idx = record.instanceIndex * info.positionSize;
423
436
  const anchor = record.anchor;
424
437
 
425
438
  if (info.RTE) {
@@ -971,6 +984,51 @@ export class BatchedSdfTextMesh
971
984
  return null;
972
985
  }
973
986
 
987
+ /**
988
+ * Group anchor slots by their feature's batch index from the geometry's
989
+ * per-anchor `batch_index` buffer. The u32 view is consumed synchronously —
990
+ * other wasm calls may detach views, so it must not be stored. An identity
991
+ * mapping (every feature owns exactly one anchor, the common MVT case) skips
992
+ * the map entirely.
993
+ */
994
+ private _rebuildBatchIndexMap(m: NavaraTextMesh): void {
995
+ const batchIndexData = m.geometry.batch_index?.data;
996
+ const batchIndices =
997
+ batchIndexData !== undefined ? this.ctx.buf.u32(batchIndexData) : null;
998
+ this._batchIndexToInstances = null;
999
+ if (!batchIndices) return;
1000
+
1001
+ let identity = true;
1002
+ for (let i = 0; i < batchIndices.length; i++) {
1003
+ if (batchIndices[i] !== i) {
1004
+ identity = false;
1005
+ break;
1006
+ }
1007
+ }
1008
+ if (identity) return;
1009
+
1010
+ const map = new Map<number, number[]>();
1011
+ for (let i = 0; i < batchIndices.length; i++) {
1012
+ const batchIndex = batchIndices[i];
1013
+ let instances = map.get(batchIndex);
1014
+ if (!instances) {
1015
+ instances = [];
1016
+ map.set(batchIndex, instances);
1017
+ }
1018
+ instances.push(i);
1019
+ }
1020
+ this._batchIndexToInstances = map;
1021
+ }
1022
+
1023
+ /** All anchor slots owned by the feature at `batchIndex`. */
1024
+ private _instancesOfBatchIndex(batchIndex: number): number[] {
1025
+ if (this._batchIndexToInstances) {
1026
+ return this._batchIndexToInstances.get(batchIndex) ?? [];
1027
+ }
1028
+ const nPositions = this._positions?.nPositions ?? 0;
1029
+ return batchIndex >= 0 && batchIndex < nPositions ? [batchIndex] : [];
1030
+ }
1031
+
974
1032
  async _update(m: NavaraTextMesh, needRender?: () => void) {
975
1033
  if (needRender) this._needRender = needRender;
976
1034
 
@@ -992,6 +1050,7 @@ export class BatchedSdfTextMesh
992
1050
  "Number of positions in the updated geometry must match the initial geometry",
993
1051
  );
994
1052
  this._positions = positionInfo;
1053
+ this._rebuildBatchIndexMap(m);
995
1054
  this._enhancer
996
1055
  .mutates()
997
1056
  .setRtcCenter([m.transform.tx, m.transform.ty, m.transform.tz]);
@@ -1162,12 +1221,18 @@ export class BatchedSdfTextMesh
1162
1221
  // --- Per-feature API (the FeatureEvaluator contract) ---
1163
1222
 
1164
1223
  setTextByBatchIndex(batchIndex: number, text: string) {
1224
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1225
+ this._setTextByInstance(instanceIndex, text);
1226
+ }
1227
+ }
1228
+
1229
+ private _setTextByInstance(instanceIndex: number, text: string) {
1165
1230
  // An empty text on a label that doesn't exist yet changes nothing (a
1166
1231
  // lazily-skipped label is exactly an invisible empty-text label); only a
1167
1232
  // non-empty text forces one into existence.
1168
1233
  const record = text
1169
- ? this._ensureLabel(batchIndex)
1170
- : this._labelByIndex[batchIndex];
1234
+ ? this._ensureLabel(instanceIndex)
1235
+ : this._labelByInstance[instanceIndex];
1171
1236
  if (!record) return;
1172
1237
 
1173
1238
  // Record the intent up front so a slower prepare for an older text can
@@ -1284,57 +1349,69 @@ export class BatchedSdfTextMesh
1284
1349
  }
1285
1350
 
1286
1351
  override setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
1287
- const record = this._ensureLabel(batchIndex);
1288
- if (!record) return;
1289
- record.colorHex = color.getHex();
1290
- this._writeStyle(record);
1352
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1353
+ const record = this._ensureLabel(instanceIndex);
1354
+ if (!record) continue;
1355
+ record.colorHex = color.getHex();
1356
+ this._writeStyle(record);
1357
+ }
1291
1358
  }
1292
1359
 
1293
1360
  override setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
1294
- // A label that doesn't exist yet is already effectively `show:false`, so
1295
- // only `show:true` needs to force one into existence — the common MVT case
1296
- // is thousands of features that stay hidden and never get a label.
1297
- const record = rawVisible
1298
- ? this._ensureLabel(batchIndex)
1299
- : this._labelByIndex[batchIndex];
1300
- if (!record) return;
1301
- record.requestedShow = rawVisible;
1302
- this._recomputeShow(record);
1361
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1362
+ // A label that doesn't exist yet is already effectively `show:false`, so
1363
+ // only `show:true` needs to force one into existence — the common MVT
1364
+ // case is thousands of features that stay hidden and never get a label.
1365
+ const record = rawVisible
1366
+ ? this._ensureLabel(instanceIndex)
1367
+ : this._labelByInstance[instanceIndex];
1368
+ if (!record) continue;
1369
+ record.requestedShow = rawVisible;
1370
+ this._recomputeShow(record);
1371
+ }
1303
1372
  this._markDeclutterDirty();
1304
1373
  }
1305
1374
 
1306
1375
  override setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
1307
- const record = this._ensureLabel(batchIndex);
1308
- if (!record) return;
1309
- record.addHeight = height;
1310
- this._writeAddHeight(record);
1376
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1377
+ const record = this._ensureLabel(instanceIndex);
1378
+ if (!record) continue;
1379
+ record.addHeight = height;
1380
+ this._writeAddHeight(record);
1381
+ }
1311
1382
  this._markDeclutterDirty();
1312
1383
  }
1313
1384
 
1314
1385
  setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
1315
- const record = this._ensureLabel(batchIndex);
1316
- if (!record) return;
1317
- record.fontSize = Number.isFinite(size)
1318
- ? Math.max(0.0, size)
1319
- : record.fontSize;
1320
- this._writeFontSize(record);
1386
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1387
+ const record = this._ensureLabel(instanceIndex);
1388
+ if (!record) continue;
1389
+ record.fontSize = Number.isFinite(size)
1390
+ ? Math.max(0.0, size)
1391
+ : record.fontSize;
1392
+ this._writeFontSize(record);
1393
+ }
1321
1394
  this._markDeclutterDirty();
1322
1395
  }
1323
1396
 
1324
1397
  setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
1325
- const record = this._ensureLabel(batchIndex);
1326
- if (!record) return;
1327
- record.opacity = clamp01(opacity);
1328
- this._writeStyle(record);
1398
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1399
+ const record = this._ensureLabel(instanceIndex);
1400
+ if (!record) continue;
1401
+ record.opacity = clamp01(opacity);
1402
+ this._writeStyle(record);
1403
+ }
1329
1404
  }
1330
1405
 
1331
1406
  setFeatureDeclutterPriorityByBatchIndex(
1332
1407
  batchIndex: number,
1333
1408
  priority: number,
1334
1409
  ) {
1335
- const record = this._ensureLabel(batchIndex);
1336
- if (!record) return;
1337
- record.priorityOverride = priority;
1410
+ for (const instanceIndex of this._instancesOfBatchIndex(batchIndex)) {
1411
+ const record = this._ensureLabel(instanceIndex);
1412
+ if (!record) continue;
1413
+ record.priorityOverride = priority;
1414
+ }
1338
1415
  this._markDeclutterDirty();
1339
1416
  }
1340
1417
 
@@ -1368,7 +1445,8 @@ export class BatchedSdfTextMesh
1368
1445
  record.retainedKeys = null;
1369
1446
  }
1370
1447
  this._labels.length = 0;
1371
- this._labelByIndex.length = 0;
1448
+ this._labelByInstance.length = 0;
1449
+ this._batchIndexToInstances = null;
1372
1450
 
1373
1451
  this._labelData.dispose();
1374
1452
  this._glyphs.dispose();
@@ -1,4 +1,10 @@
1
- import { DataTexture, LinearFilter, RGBAFormat, UnsignedByteType } from "three";
1
+ import {
2
+ DataTexture,
3
+ LinearFilter,
4
+ RGBAFormat,
5
+ SRGBColorSpace,
6
+ UnsignedByteType,
7
+ } from "three";
2
8
 
3
9
  /**
4
10
  * Sub-rectangle of the atlas in pixels. Row 0 is the bottom of the texture
@@ -201,6 +207,7 @@ export class BillboardAtlas {
201
207
  const texture = new DataTexture(this._data, this._size, this._size);
202
208
  texture.format = RGBAFormat;
203
209
  texture.type = UnsignedByteType;
210
+ texture.colorSpace = SRGBColorSpace;
204
211
  texture.minFilter = LinearFilter;
205
212
  texture.magFilter = LinearFilter;
206
213
  texture.generateMipmaps = false;
@@ -66,7 +66,15 @@ export class InstancedSpriteMesh
66
66
  implements PickableMesh, DeclutterParticipant
67
67
  {
68
68
  private _geometryType: SpriteGeometryType = GEOMETRY_TYPES.Point;
69
- private _batchIdToInstance = new Map<number, number>();
69
+ /**
70
+ * Feature (batch) index → this feature's instance ids. A feature owns
71
+ * multiple instances for MultiPoint geometry and for points derived from
72
+ * line/polygon vertices via `geometryTypes`, so per-feature styling must
73
+ * fan out to all of them. `null` means instances and features are 1:1.
74
+ */
75
+ private _batchIndexToInstances: Map<number, number[]> | null = null;
76
+ /** Instance count of the current geometry; bounds the identity fallback. */
77
+ private _instanceCount = 0;
70
78
  private _initialColor: Color = new Color(0xffffff);
71
79
  private _initialHeight = 0.0;
72
80
  private _initialSize = -1.0; // Negative value indicates "use uScale" in shader
@@ -161,7 +169,7 @@ export class InstancedSpriteMesh
161
169
  const state = enhancer.states();
162
170
  const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
163
171
  const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
164
- // Mirror of instancedSprite.vert.glsl:100-104 — aspect is per-instance
172
+ // Mirror of instancedSprite.vert.glsl:115-118 — aspect is per-instance
165
173
  // (from the atlas rect), not a material-level uniform; there is no
166
174
  // material-wide "aspect" state to read.
167
175
  const uvRect = state.billboard
@@ -181,9 +189,14 @@ export class InstancedSpriteMesh
181
189
 
182
190
  const override = overrides ? overrides[i] : Number.NaN;
183
191
  const rectH = uvRect ? uvRect.getW(i) : 0;
184
- const aspect = uvRect && rectH > 0.0 ? uvRect.getZ(i) / rectH : 1.0;
185
-
186
- // Mirror of instancedSprite.vert.glsl:95-97 — the quad spans
192
+ const rectW = uvRect ? uvRect.getZ(i) : 0;
193
+ // Mirror of the shader's empty-rect cull: an instance with no image
194
+ // packed yet is not drawn, so it must not reserve declutter space and
195
+ // hide the labels around it.
196
+ if (uvRect && (rectW <= 0.0 || rectH <= 0.0)) continue;
197
+ const aspect = uvRect ? rectW / rectH : 1.0;
198
+
199
+ // Mirror of instancedSprite.vert.glsl:122-125 — the quad spans
187
200
  // (position.xy - center) * vec2(aspect, 1) * size around the anchor.
188
201
  out.push({
189
202
  anchorX: anchors[i * 3],
@@ -481,17 +494,15 @@ export class InstancedSpriteMesh
481
494
  colorBuffer[i * 3 + 0] = this._initialColor.r;
482
495
  colorBuffer[i * 3 + 1] = this._initialColor.g;
483
496
  colorBuffer[i * 3 + 2] = this._initialColor.b;
484
-
485
- // Map batch ID to instance id
486
- // assuming batch IDs are 32bit floats
487
- const batchId = positionsInfo.batchIDs[i];
488
- this._batchIdToInstance.set(batchId, i);
489
497
  }
490
498
 
499
+ this._instanceCount = instanceCount;
500
+ this._rebuildBatchIndexMap(m);
501
+
491
502
  if (m instanceof NavaraBillboardMesh) {
492
503
  // instanceUvRect: vec4(x, y, w, h) — this instance's atlas sub-rect in
493
- // pixels. Zeroed until an image is packed; a zero-height rect samples a
494
- // transparent texel, so instances stay invisible rather than garbled.
504
+ // pixels. Zeroed until an image is packed; the vertex shader culls empty
505
+ // rects, so instances stay invisible rather than garbled.
495
506
  instancedGeometry.setAttribute(
496
507
  "instanceUvRect",
497
508
  new InstancedBufferAttribute(new Float32Array(instanceCount * 4), 4),
@@ -755,6 +766,20 @@ export class InstancedSpriteMesh
755
766
  rectAttr.setXYZW(i, rect.x, rect.y, rect.w, rect.h);
756
767
  }
757
768
  rectAttr.needsUpdate = true;
769
+ this._onAtlasRectsChanged();
770
+ }
771
+
772
+ /**
773
+ * An instance's atlas rect changed. The rect drives the quad's aspect and
774
+ * whether the instance draws at all, so the declutter bounds computed before
775
+ * the image landed are stale — and because packs resolve long after the
776
+ * frame that requested them, the view has usually gone idle by now. Without
777
+ * the render request the page keeps showing the pre-image state until the
778
+ * next camera move.
779
+ */
780
+ private _onAtlasRectsChanged(): void {
781
+ this.ctx.declutter?.markDirty();
782
+ this.ctx.renderFlag.forceUpdate = true;
758
783
  }
759
784
 
760
785
  onBeforePicking(): void {
@@ -781,32 +806,85 @@ export class InstancedSpriteMesh
781
806
  return this._enhancedMaterial;
782
807
  }
783
808
 
784
- setFeatureColorByBatchId(batchId: number, color: Color) {
785
- const instanceId = this._batchIdToInstance.get(batchId);
786
- if (instanceId === undefined) return;
809
+ /**
810
+ * Group instances by their feature's batch index from the geometry's
811
+ * per-instance `batch_index` buffer. The u32 view is consumed synchronously,
812
+ * so other wasm calls may detach views, so it must not be stored. The handle
813
+ * stays owned by the ECS geometry, whose destroy path frees it
814
+ * (`remove_from_buf`). An identity mapping (every feature owns exactly one
815
+ * instance, the common case) skips the map entirely.
816
+ */
817
+ private _rebuildBatchIndexMap(
818
+ m: NavaraPointMesh | NavaraBillboardMesh,
819
+ ): void {
820
+ const batchIndexData = m.geometry.batch_index?.data;
821
+ const batchIndices =
822
+ batchIndexData !== undefined ? this.ctx.buf.u32(batchIndexData) : null;
823
+ this._batchIndexToInstances = null;
824
+ if (!batchIndices) return;
825
+
826
+ let identity = true;
827
+ for (let i = 0; i < batchIndices.length; i++) {
828
+ if (batchIndices[i] !== i) {
829
+ identity = false;
830
+ break;
831
+ }
832
+ }
833
+ if (identity) return;
834
+
835
+ const map = new Map<number, number[]>();
836
+ for (let i = 0; i < batchIndices.length; i++) {
837
+ const batchIndex = batchIndices[i];
838
+ let instances = map.get(batchIndex);
839
+ if (!instances) {
840
+ instances = [];
841
+ map.set(batchIndex, instances);
842
+ }
843
+ instances.push(i);
844
+ }
845
+ this._batchIndexToInstances = map;
846
+ }
847
+
848
+ /** All instances owned by the feature at `batchIndex`; empty when unknown. */
849
+ private instancesOfBatchIndex(batchIndex: number): number[] {
850
+ if (this._batchIndexToInstances) {
851
+ return this._batchIndexToInstances.get(batchIndex) ?? [];
852
+ }
853
+ return batchIndex >= 0 && batchIndex < this._instanceCount
854
+ ? [batchIndex]
855
+ : [];
856
+ }
857
+
858
+ setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
859
+ const instances = this.instancesOfBatchIndex(batchIndex);
860
+ if (instances.length === 0) return;
787
861
 
788
862
  const colorAttr = this.geometry.getAttribute(
789
863
  "instanceColor",
790
864
  ) as InstancedBufferAttribute;
791
- colorAttr.setXYZ(instanceId, color.r, color.g, color.b);
865
+ for (const instanceId of instances) {
866
+ colorAttr.setXYZ(instanceId, color.r, color.g, color.b);
867
+ }
792
868
  colorAttr.needsUpdate = true;
793
869
  }
794
870
 
795
- setFeatureShowByBatchId(batchId: number, rawVisible: boolean) {
796
- const instanceId = this._batchIdToInstance.get(batchId);
797
- if (instanceId === undefined) return;
871
+ setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
872
+ const instances = this.instancesOfBatchIndex(batchIndex);
873
+ if (instances.length === 0) return;
798
874
 
799
875
  const paramsAttr = this.geometry.getAttribute(
800
876
  "instanceParams",
801
877
  ) as InstancedBufferAttribute;
802
- paramsAttr.setZ(instanceId, rawVisible ? 1.0 : 0.0);
878
+ for (const instanceId of instances) {
879
+ paramsAttr.setZ(instanceId, rawVisible ? 1.0 : 0.0);
880
+ }
803
881
  paramsAttr.needsUpdate = true;
804
882
  this.ctx.declutter?.markDirty();
805
883
  }
806
884
 
807
- setFeatureOpacityByBatchId(batchId: number, opacity: number) {
808
- const instanceId = this._batchIdToInstance.get(batchId);
809
- if (instanceId === undefined) return;
885
+ setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
886
+ const instances = this.instancesOfBatchIndex(batchIndex);
887
+ if (instances.length === 0) return;
810
888
 
811
889
  const paramsAttr = this.geometry.getAttribute(
812
890
  "instanceParams",
@@ -814,20 +892,24 @@ export class InstancedSpriteMesh
814
892
  const clampedOpacity = Number.isFinite(opacity)
815
893
  ? Math.max(0.0, Math.min(1.0, opacity))
816
894
  : 1.0;
817
- paramsAttr.setW(instanceId, clampedOpacity);
895
+ for (const instanceId of instances) {
896
+ paramsAttr.setW(instanceId, clampedOpacity);
897
+ }
818
898
  paramsAttr.needsUpdate = true;
819
899
  }
820
900
 
821
- setFeatureHeightByBatchId(batchId: number, height: number) {
822
- const instanceId = this._batchIdToInstance.get(batchId);
823
- if (instanceId === undefined) return;
901
+ setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
902
+ const instances = this.instancesOfBatchIndex(batchIndex);
903
+ if (instances.length === 0) return;
824
904
 
825
905
  const paramsAttr = this.geometry.getAttribute(
826
906
  "instanceParams",
827
907
  ) as InstancedBufferAttribute;
828
908
 
829
909
  const sanitizedHeight = Number.isFinite(height) ? height : 0.0;
830
- paramsAttr.setX(instanceId, sanitizedHeight);
910
+ for (const instanceId of instances) {
911
+ paramsAttr.setX(instanceId, sanitizedHeight);
912
+ }
831
913
  paramsAttr.needsUpdate = true;
832
914
  this.ctx.declutter?.markDirty();
833
915
  }
@@ -854,9 +936,12 @@ export class InstancedSpriteMesh
854
936
  * layer-level `declutterPriority` for this instance. Driven by the feature
855
937
  * evaluator.
856
938
  */
857
- setFeatureDeclutterPriorityByBatchId(batchId: number, priority: number) {
858
- const instanceId = this._batchIdToInstance.get(batchId);
859
- if (instanceId === undefined) return;
939
+ setFeatureDeclutterPriorityByBatchIndex(
940
+ batchIndex: number,
941
+ priority: number,
942
+ ) {
943
+ const instances = this.instancesOfBatchIndex(batchIndex);
944
+ if (instances.length === 0) return;
860
945
 
861
946
  if (!this._declutterPriorityOverrides) {
862
947
  const count = this._anchors ? this._anchors.length / 3 : 0;
@@ -865,15 +950,21 @@ export class InstancedSpriteMesh
865
950
  Number.NaN,
866
951
  );
867
952
  }
868
- if (instanceId >= this._declutterPriorityOverrides.length) return;
869
- if (this._declutterPriorityOverrides[instanceId] === priority) return;
870
- this._declutterPriorityOverrides[instanceId] = priority;
871
- this.ctx.declutter?.markDirty();
953
+ let changed = false;
954
+ for (const instanceId of instances) {
955
+ if (instanceId >= this._declutterPriorityOverrides.length) continue;
956
+ if (this._declutterPriorityOverrides[instanceId] === priority) continue;
957
+ this._declutterPriorityOverrides[instanceId] = priority;
958
+ changed = true;
959
+ }
960
+ if (changed) {
961
+ this.ctx.declutter?.markDirty();
962
+ }
872
963
  }
873
964
 
874
- setFeatureSizeByBatchId(batchId: number, size: number) {
875
- const instanceId = this._batchIdToInstance.get(batchId);
876
- if (instanceId === undefined) return;
965
+ setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
966
+ const instances = this.instancesOfBatchIndex(batchIndex);
967
+ if (instances.length === 0) return;
877
968
 
878
969
  const paramsAttr = this.geometry.getAttribute(
879
970
  "instanceParams",
@@ -884,7 +975,9 @@ export class InstancedSpriteMesh
884
975
  ? -1.0
885
976
  : size
886
977
  : -1.0;
887
- paramsAttr.setY(instanceId, sanitizedSize);
978
+ for (const instanceId of instances) {
979
+ paramsAttr.setY(instanceId, sanitizedSize);
980
+ }
888
981
  paramsAttr.needsUpdate = true;
889
982
  this.ctx.declutter?.markDirty();
890
983
  }
@@ -897,45 +990,60 @@ export class InstancedSpriteMesh
897
990
  * reverts the feature to the material's default image. No-op for
898
991
  * non-billboard (point) meshes.
899
992
  */
900
- async setFeatureImageByBatchId(
901
- batchId: number,
993
+ async setFeatureImageByBatchIndex(
994
+ batchIndex: number,
902
995
  url: string | null | undefined,
903
996
  ): Promise<void> {
904
- const instanceId = this._batchIdToInstance.get(batchId);
905
- if (instanceId === undefined) return;
997
+ const instances = this.instancesOfBatchIndex(batchIndex);
998
+ if (instances.length === 0) return;
906
999
 
907
1000
  const rectAttr = this.geometry.getAttribute("instanceUvRect") as
908
1001
  InstancedBufferAttribute | undefined;
909
1002
  if (!rectAttr) return;
910
1003
 
911
1004
  if (url == null) {
912
- this._requestedImageUrls.delete(instanceId);
913
- if (!this._imageOverrides.delete(instanceId)) return;
914
- // Zero rect (invisible) until the default image finishes loading, same
915
- // as instances that never had an override.
916
- const rect = this._defaultRect;
917
- rectAttr.setXYZW(
918
- instanceId,
919
- rect?.x ?? 0,
920
- rect?.y ?? 0,
921
- rect?.w ?? 0,
922
- rect?.h ?? 0,
923
- );
924
- rectAttr.needsUpdate = true;
1005
+ let cleared = false;
1006
+ for (const instanceId of instances) {
1007
+ this._requestedImageUrls.delete(instanceId);
1008
+ if (!this._imageOverrides.delete(instanceId)) continue;
1009
+ // Zero rect (invisible) until the default image finishes loading,
1010
+ // same as instances that never had an override.
1011
+ const rect = this._defaultRect;
1012
+ rectAttr.setXYZW(
1013
+ instanceId,
1014
+ rect?.x ?? 0,
1015
+ rect?.y ?? 0,
1016
+ rect?.w ?? 0,
1017
+ rect?.h ?? 0,
1018
+ );
1019
+ cleared = true;
1020
+ }
1021
+ if (cleared) {
1022
+ rectAttr.needsUpdate = true;
1023
+ this._onAtlasRectsChanged();
1024
+ }
925
1025
  return;
926
1026
  }
927
1027
 
928
- this._requestedImageUrls.set(instanceId, url);
1028
+ for (const instanceId of instances) {
1029
+ this._requestedImageUrls.set(instanceId, url);
1030
+ }
929
1031
  const rect = await this._ensureAtlas().pack(url);
930
1032
  this._reportAtlasBytes();
931
1033
  if (!rect) return;
932
- // A newer override or a clear superseded this load while it was in flight.
933
- if (this._requestedImageUrls.get(instanceId) !== url) return;
934
-
935
- this._imageOverrides.add(instanceId);
936
- this._syncAtlasUniforms();
937
- rectAttr.setXYZW(instanceId, rect.x, rect.y, rect.w, rect.h);
938
- rectAttr.needsUpdate = true;
1034
+ let applied = false;
1035
+ for (const instanceId of instances) {
1036
+ // A newer override or a clear superseded this load while in flight.
1037
+ if (this._requestedImageUrls.get(instanceId) !== url) continue;
1038
+ this._imageOverrides.add(instanceId);
1039
+ rectAttr.setXYZW(instanceId, rect.x, rect.y, rect.w, rect.h);
1040
+ applied = true;
1041
+ }
1042
+ if (applied) {
1043
+ this._syncAtlasUniforms();
1044
+ rectAttr.needsUpdate = true;
1045
+ this._onAtlasRectsChanged();
1046
+ }
939
1047
  }
940
1048
 
941
1049
  dispose(): void {
@@ -958,7 +1066,7 @@ export class InstancedSpriteMesh
958
1066
  (this.material as ShaderMaterial).dispose();
959
1067
 
960
1068
  // Clear internal collections to release references
961
- this._batchIdToInstance.clear();
1069
+ this._batchIndexToInstances = null;
962
1070
  this._anchors = null;
963
1071
  this._declutterTargets = null;
964
1072
  this._declutterPriorityOverrides = null;