@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.
Files changed (64) hide show
  1. package/README.md +7 -7
  2. package/dist/assets/fontWorker-OwvKiQ8o.js +2 -0
  3. package/dist/assets/fontWorker-OwvKiQ8o.js.map +1 -0
  4. package/dist/assets/{index-ywHSGImj.js → index-TKhRxurv.js} +2 -2
  5. package/dist/assets/index-TKhRxurv.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Du9nb2Pn.wasm → navara_wasm_font_worker_bg-Kk06ArRB.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-Xadu67fw.wasm → navara_wasm_worker_bg-BZjLKf4i.wasm} +0 -0
  8. package/dist/index.d.ts +351 -284
  9. package/dist/index.js +5394 -5470
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.umd.cjs +287 -235
  12. package/dist/index.umd.cjs.map +1 -1
  13. package/package.json +12 -12
  14. package/src/ShadowMapViewers.ts +1 -1
  15. package/src/atmosphere.test.ts +227 -28
  16. package/src/atmosphere.ts +219 -55
  17. package/src/constants/assets.ts +63 -13
  18. package/src/declutter/DeclutterManager.ts +1 -1
  19. package/src/declutter/types.ts +1 -1
  20. package/src/evaluations/FeatureEvaluator.test.ts +152 -0
  21. package/src/evaluations/FeatureEvaluator.ts +73 -2
  22. package/src/event/features/billboard.ts +1 -0
  23. package/src/event/features/model.ts +1 -1
  24. package/src/event/features/point.ts +1 -0
  25. package/src/event/index.ts +1 -1
  26. package/src/index.iife.ts +3 -3
  27. package/src/index.ts +10 -12
  28. package/src/layers/effect/README.md +1 -1
  29. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +2 -2
  30. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +1 -1
  31. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +1 -1
  32. package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +1 -1
  33. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/index.ts +10 -7
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.test.ts +74 -81
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +19 -86
  36. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +77 -0
  37. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +9 -1
  38. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.test.ts +15 -19
  39. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.ts +0 -13
  40. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +46 -48
  41. package/src/mesh/constants.ts +22 -0
  42. package/src/mesh/index.ts +1 -1
  43. package/src/mesh/model.ts +6 -0
  44. package/src/mesh/polygon.ts +6 -0
  45. package/src/mesh/polyline.ts +6 -0
  46. package/src/mesh/rtcRteHelper.ts +1 -1
  47. package/src/mesh/sdfText/batchedSdfText.ts +1223 -0
  48. package/src/mesh/sdfText/glyphBuffers.ts +222 -0
  49. package/src/mesh/sdfText/glyphSlots.test.ts +151 -0
  50. package/src/mesh/sdfText/glyphSlots.ts +98 -0
  51. package/src/mesh/sdfText/index.ts +10 -0
  52. package/src/mesh/sdfText/labelData.test.ts +178 -0
  53. package/src/mesh/sdfText/labelData.ts +187 -0
  54. package/src/mesh/{sdfText.test.ts → sdfText/layout.test.ts} +1 -1
  55. package/src/mesh/sdfText/layout.ts +306 -0
  56. package/src/mesh/sprite/instancedSprite.ts +33 -1
  57. package/src/utils/screen.ts +1 -1
  58. package/dist/assets/fontWorker-wz61edjR.js +0 -2
  59. package/dist/assets/fontWorker-wz61edjR.js.map +0 -1
  60. package/dist/assets/index-ywHSGImj.js.map +0 -1
  61. package/src/mesh/batchedSdfText.ts +0 -708
  62. package/src/mesh/sdfText.ts +0 -1189
  63. /package/dist/{draco_decoder.wasm → assets/draco_decoder-C32yEggz.wasm} +0 -0
  64. /package/dist/{draco_decoder2.wasm → assets/draco_decoder-Z1_iN-Ht.wasm} +0 -0
package/src/atmosphere.ts CHANGED
@@ -3,17 +3,38 @@ import {
3
3
  getECIToECEFRotationMatrix,
4
4
  getMoonDirectionECEF,
5
5
  getSunDirectionECEF,
6
- PrecomputedTexturesLoader,
6
+ IRRADIANCE_TEXTURE_HEIGHT,
7
+ IRRADIANCE_TEXTURE_WIDTH,
8
+ SCATTERING_TEXTURE_DEPTH,
9
+ SCATTERING_TEXTURE_HEIGHT,
10
+ SCATTERING_TEXTURE_WIDTH,
11
+ TRANSMITTANCE_TEXTURE_HEIGHT,
12
+ TRANSMITTANCE_TEXTURE_WIDTH,
7
13
  type AtmosphereOverlay,
8
14
  type AtmosphereShadow,
9
15
  type AtmosphereShadowLength,
10
16
  type PrecomputedTextures,
11
17
  } from "@takram/three-atmosphere";
12
- import { Vector3, Matrix4, type WebGLRenderer } from "three";
13
- import invariant from "tiny-invariant";
18
+ import {
19
+ EXR3DTextureLoader,
20
+ EXRTextureLoader,
21
+ Float16Array,
22
+ isFloatLinearSupported,
23
+ reinterpretType,
24
+ } from "@takram/three-geospatial";
25
+ import {
26
+ FloatType,
27
+ HalfFloatType,
28
+ LinearFilter,
29
+ Matrix4,
30
+ Vector3,
31
+ type DataTextureImageData,
32
+ type Texture,
33
+ type WebGLRenderer,
34
+ } from "three";
14
35
 
15
36
  import { type ThreeViewCamera } from "./camera";
16
- import { ATMOSPHERE_ASSETS_URL, STBN_URL } from "./constants";
37
+ import { ATMOSPHERE_TEXTURE_URLS, STBN_URL } from "./constants";
17
38
  import {
18
39
  dateForSolarTime,
19
40
  getSolarTime as solarTimeAt,
@@ -28,7 +49,7 @@ import {
28
49
  export type AtmosphereEvents = {
29
50
  /** Emitted when the atmosphere needs to trigger a re-render. */
30
51
  needsUpdate: () => void;
31
- /** Emitted when precomputed atmosphere textures have been loaded. */
52
+ /** Emitted each time a precomputed atmosphere texture finishes loading. */
32
53
  textureLoaded: () => void;
33
54
  /** Emitted when the atmosphere is disposed. */
34
55
  disposed: () => void;
@@ -36,12 +57,62 @@ export type AtmosphereEvents = {
36
57
  sunChanged: (sunDirection: Vector3) => void;
37
58
  };
38
59
 
60
+ /**
61
+ * Names of the precomputed atmosphere textures that can be loaded
62
+ * individually.
63
+ */
64
+ export type AtmosphereTextureName =
65
+ "transmittance" | "scattering" | "irradiance" | "higherOrderScattering";
66
+
67
+ /**
68
+ * Which precomputed textures a consumer needs, e.g.
69
+ * `{ transmittance: true }`. Only `true` is allowed as a value so that every
70
+ * listed texture is guaranteed to be loaded.
71
+ */
72
+ export type AtmosphereTextureNeeds = Partial<
73
+ Record<AtmosphereTextureName, true>
74
+ >;
75
+
76
+ const ATMOSPHERE_TEXTURE_KEYS = {
77
+ transmittance: "transmittanceTexture",
78
+ scattering: "scatteringTexture",
79
+ irradiance: "irradianceTexture",
80
+ higherOrderScattering: "higherOrderScatteringTexture",
81
+ } as const satisfies Record<AtmosphereTextureName, keyof PrecomputedTextures>;
82
+
83
+ const ATMOSPHERE_TEXTURE_FILES: Record<AtmosphereTextureName, string> = {
84
+ transmittance: "transmittance.exr",
85
+ scattering: "scattering.exr",
86
+ irradiance: "irradiance.exr",
87
+ higherOrderScattering: "higher_order_scattering.exr",
88
+ };
89
+
90
+ const ALL_ATMOSPHERE_TEXTURE_NAMES = Object.keys(
91
+ ATMOSPHERE_TEXTURE_KEYS,
92
+ ) as AtmosphereTextureName[];
93
+
94
+ /**
95
+ * The textures guaranteed to be present in an {@link Atmosphere.onTexturesReady}
96
+ * callback for a given needs declaration.
97
+ */
98
+ export type LoadedAtmosphereTextures<N extends AtmosphereTextureNeeds> =
99
+ Required<
100
+ Pick<
101
+ PrecomputedTextures,
102
+ `${Extract<keyof N, AtmosphereTextureName>}Texture`
103
+ >
104
+ >;
105
+
39
106
  /**
40
107
  * Configuration options for the {@link Atmosphere} class.
41
108
  */
42
109
  export type AtmosphereOptions = {
43
- /** URL to load precomputed atmosphere textures from. */
44
- atmosphereAssetsUrl?: string;
110
+ /**
111
+ * URL of a directory to load precomputed atmosphere textures from. The
112
+ * directory must keep the original filenames (`transmittance.exr` etc.).
113
+ * When omitted, the assets bundled with the package are used.
114
+ */
115
+ atmosphereAssetsUrl?: string | undefined;
45
116
  /** URL to load STBN (Spatio-Temporal Blue Noise) textures from. */
46
117
  stbnUrl?: string;
47
118
  /** Date used for calculating sun/moon positions. Defaults to current date. */
@@ -51,8 +122,11 @@ export type AtmosphereOptions = {
51
122
  /**
52
123
  * Default values for {@link AtmosphereOptions}.
53
124
  */
54
- export const DEFAULT_ATMOSPHERE_OPTIONS: Required<AtmosphereOptions> = {
55
- atmosphereAssetsUrl: ATMOSPHERE_ASSETS_URL,
125
+ export const DEFAULT_ATMOSPHERE_OPTIONS: Required<
126
+ Omit<AtmosphereOptions, "atmosphereAssetsUrl">
127
+ > &
128
+ Pick<AtmosphereOptions, "atmosphereAssetsUrl"> = {
129
+ atmosphereAssetsUrl: undefined,
56
130
  stbnUrl: STBN_URL,
57
131
  date: new Date(),
58
132
  };
@@ -85,10 +159,11 @@ export class Atmosphere extends EventHandler<AtmosphereEvents> {
85
159
  private rotationMatrix = new Matrix4();
86
160
 
87
161
  /**
88
- * Precomputed atmosphere textures used for rendering.
89
- * Loaded asynchronously via {@link initTextures}.
162
+ * Precomputed atmosphere textures used for rendering. Each texture is
163
+ * loaded lazily when a consumer declares a need for it via
164
+ * {@link onTexturesReady}, so the object may hold any subset at a time.
90
165
  */
91
- textures?: PrecomputedTextures;
166
+ textures: Partial<PrecomputedTextures> = {};
92
167
 
93
168
  // Variables that come from Clouds.
94
169
  overlay = new Observed<AtmosphereOverlay | null>(null);
@@ -112,11 +187,11 @@ export class Atmosphere extends EventHandler<AtmosphereEvents> {
112
187
  private _camera: ThreeViewCamera | undefined;
113
188
 
114
189
  /**
115
- * In-flight texture loading promise, shared across concurrent
116
- * {@link initTextures} calls so the assets are fetched only once.
190
+ * In-flight per-texture loading promises, shared across consumers so each
191
+ * asset is fetched only once.
117
192
  * @private
118
193
  */
119
- private texturesPromise: Promise<void> | undefined;
194
+ private texturePromises = new Map<AtmosphereTextureName, Promise<void>>();
120
195
 
121
196
  /**
122
197
  * Creates a new Atmosphere instance.
@@ -148,67 +223,156 @@ export class Atmosphere extends EventHandler<AtmosphereEvents> {
148
223
  };
149
224
 
150
225
  /**
151
- * Loads precomputed atmosphere textures asynchronously.
152
- * If textures are already loaded, this method returns immediately.
153
- * @returns A promise that resolves when textures are loaded.
226
+ * Loads all precomputed atmosphere textures asynchronously.
227
+ *
228
+ * Each texture loads automatically once a consumer declares a need for it
229
+ * via {@link onTexturesReady}; call this manually only to prefetch every
230
+ * texture ahead of time.
231
+ *
232
+ * @returns A promise that resolves when all textures are loaded.
154
233
  */
155
- // TODO: Add an option to disable loading textures.
156
234
  async initTextures() {
157
- if (this.textures) return;
158
-
159
- this.texturesPromise ??= new PrecomputedTexturesLoader()
160
- .setType(this.renderer)
161
- .loadAsync(
162
- this.options.atmosphereAssetsUrl ??
163
- DEFAULT_ATMOSPHERE_OPTIONS.atmosphereAssetsUrl,
164
- )
165
- .then((textures) => {
166
- this.textures = textures;
167
- this.emit("textureLoaded");
168
- })
169
- .finally(() => {
170
- // Allow retrying after a failed load instead of caching the rejection.
171
- this.texturesPromise = undefined;
172
- });
173
-
174
- await this.texturesPromise;
235
+ await Promise.all(
236
+ ALL_ATMOSPHERE_TEXTURE_NAMES.map((name) => this.loadTexture(name)),
237
+ );
175
238
  }
176
239
 
177
240
  /**
241
+ * Loads a single precomputed texture, sharing in-flight loads and caching
242
+ * the result. A failed load is forgotten so the next request retries it.
178
243
  * @private
179
244
  */
180
- async _init() {
181
- await this.initTextures();
245
+ private loadTexture(name: AtmosphereTextureName): Promise<void> {
246
+ let promise = this.texturePromises.get(name);
247
+ if (promise == null) {
248
+ const assetsUrl = this.options.atmosphereAssetsUrl;
249
+ const file = ATMOSPHERE_TEXTURE_FILES[name];
250
+ const url =
251
+ assetsUrl == null
252
+ ? ATMOSPHERE_TEXTURE_URLS[file]
253
+ : `${assetsUrl.replace(/\/+$/, "")}/${file}`;
254
+ promise = this.fetchTexture(name, url)
255
+ .then((texture) => {
256
+ const type = isFloatLinearSupported(this.renderer)
257
+ ? FloatType
258
+ : HalfFloatType;
259
+ texture.type = type;
260
+ // EXR data is parsed to Uint16Array, which must be converted to
261
+ // Float32Array when FloatType is used.
262
+ if (type === FloatType) {
263
+ reinterpretType<DataTextureImageData>(texture.image);
264
+ if (texture.image.data != null) {
265
+ texture.image.data = new Float32Array(
266
+ new Float16Array(texture.image.data.buffer),
267
+ );
268
+ }
269
+ }
270
+ texture.minFilter = LinearFilter;
271
+ texture.magFilter = LinearFilter;
272
+ Object.assign(this.textures, {
273
+ [ATMOSPHERE_TEXTURE_KEYS[name]]: texture,
274
+ });
275
+ this.emit("textureLoaded");
276
+ })
277
+ .catch((e: unknown) => {
278
+ // Allow retrying after a failed load instead of caching the
279
+ // rejection.
280
+ this.texturePromises.delete(name);
281
+ throw e;
282
+ });
283
+ this.texturePromises.set(name, promise);
284
+ }
285
+ return promise;
286
+ }
287
+
288
+ private fetchTexture(
289
+ name: AtmosphereTextureName,
290
+ url: string,
291
+ ): Promise<Texture> {
292
+ switch (name) {
293
+ case "transmittance":
294
+ return new EXRTextureLoader({
295
+ width: TRANSMITTANCE_TEXTURE_WIDTH,
296
+ height: TRANSMITTANCE_TEXTURE_HEIGHT,
297
+ }).loadAsync(url);
298
+ case "irradiance":
299
+ return new EXRTextureLoader({
300
+ width: IRRADIANCE_TEXTURE_WIDTH,
301
+ height: IRRADIANCE_TEXTURE_HEIGHT,
302
+ }).loadAsync(url);
303
+ case "scattering":
304
+ case "higherOrderScattering":
305
+ return new EXR3DTextureLoader({
306
+ width: SCATTERING_TEXTURE_WIDTH,
307
+ height: SCATTERING_TEXTURE_HEIGHT,
308
+ depth: SCATTERING_TEXTURE_DEPTH,
309
+ }).loadAsync(url);
310
+ }
182
311
  }
183
312
 
184
313
  /**
185
314
  * @private
186
315
  */
187
316
  _dispose() {
188
- if (this.textures) {
189
- this.textures.irradianceTexture.dispose();
190
- this.textures.scatteringTexture.dispose();
191
- this.textures.transmittanceTexture.dispose();
192
- this.textures.singleMieScatteringTexture?.dispose();
193
- this.textures.higherOrderScatteringTexture?.dispose();
194
- this.textures = undefined;
317
+ for (const texture of Object.values(this.textures)) {
318
+ texture?.dispose();
195
319
  }
320
+ this.textures = {};
321
+ this.texturePromises.clear();
196
322
  this.emit("disposed");
197
323
  }
198
324
 
199
325
  /**
200
- * Invokes the callback with precomputed textures immediately if already loaded,
201
- * or registers a one-time listener to invoke it once textures are ready.
326
+ * Invokes the callback once every needed precomputed texture is loaded —
327
+ * immediately if they already are.
328
+ *
329
+ * Registering also starts loading the needed textures if they aren't loaded
330
+ * yet, so a texture is only fetched once something that needs it exists.
331
+ *
332
+ * @param callback - Receives the textures; the ones listed in `needs` are
333
+ * guaranteed to be present.
334
+ * @param needs - The textures the caller needs, e.g.
335
+ * `{ transmittance: true }`. Omit to load all of them.
336
+ *
337
+ * @example
338
+ * atmosphere.onTexturesReady(
339
+ * (t) => light.setTransmittanceTexture(t.transmittanceTexture),
340
+ * { transmittance: true },
341
+ * );
202
342
  */
203
- onTexturesReady(callback: (textures: PrecomputedTextures) => void): void {
204
- if (this.textures) {
205
- callback(this.textures);
206
- } else {
207
- this.once("textureLoaded", () => {
208
- invariant(this.textures);
209
- callback(this.textures);
343
+ onTexturesReady<
344
+ N extends AtmosphereTextureNeeds = Record<AtmosphereTextureName, true>,
345
+ >(
346
+ callback: (textures: LoadedAtmosphereTextures<N>) => void,
347
+ needs?: N,
348
+ ): void {
349
+ const names =
350
+ needs == null
351
+ ? ALL_ATMOSPHERE_TEXTURE_NAMES
352
+ : ALL_ATMOSPHERE_TEXTURE_NAMES.filter((name) => needs[name]);
353
+ for (const name of names) {
354
+ this.loadTexture(name).catch((e: unknown) => {
355
+ console.error("Failed to load atmosphere textures:", e);
210
356
  });
211
357
  }
358
+
359
+ const isReady = () =>
360
+ names.every(
361
+ (name) => this.textures[ATMOSPHERE_TEXTURE_KEYS[name]] != null,
362
+ );
363
+ const invoke = () => {
364
+ callback(this.textures as LoadedAtmosphereTextures<N>);
365
+ };
366
+ if (isReady()) {
367
+ invoke();
368
+ } else {
369
+ const listener = () => {
370
+ if (!isReady()) return;
371
+ this.off("textureLoaded", listener);
372
+ invoke();
373
+ };
374
+ this.on("textureLoaded", listener);
375
+ }
212
376
  }
213
377
 
214
378
  /**
@@ -1,30 +1,80 @@
1
- export const ATMOSPHERE_ASSETS_URL = new URL(
2
- `${import.meta.env.BASE_URL}assets/atmosphere`,
1
+ // Every bundled asset must be referenced through its own static
2
+ // `new URL("<literal>", import.meta.url)` call so that consumers' bundlers
3
+ // (Vite/Rollup/webpack) can detect the file and emit it into their build
4
+ // output. Runtime-concatenated paths are invisible to bundlers and 404 in
5
+ // production builds (#746). The `?no-inline` query opts each file out of
6
+ // Vite's lib-mode base64 inlining so our own dist emits it as a fetchable
7
+ // asset instead.
8
+
9
+ export const ATMOSPHERE_TRANSMITTANCE_URL = new URL(
10
+ "../../assets/atmosphere/transmittance.exr?no-inline",
11
+ import.meta.url,
12
+ ).toString();
13
+
14
+ export const ATMOSPHERE_SCATTERING_URL = new URL(
15
+ "../../assets/atmosphere/scattering.exr?no-inline",
16
+ import.meta.url,
17
+ ).toString();
18
+
19
+ export const ATMOSPHERE_IRRADIANCE_URL = new URL(
20
+ "../../assets/atmosphere/irradiance.exr?no-inline",
21
+ import.meta.url,
22
+ ).toString();
23
+
24
+ export const ATMOSPHERE_HIGHER_ORDER_SCATTERING_URL = new URL(
25
+ "../../assets/atmosphere/higher_order_scattering.exr?no-inline",
3
26
  import.meta.url,
4
27
  ).toString();
5
28
 
6
- export const CLOUD_ASSETS_URL = new URL(
7
- `${import.meta.env.BASE_URL}assets/cloud`,
29
+ export const ATMOSPHERE_SINGLE_MIE_SCATTERING_URL = new URL(
30
+ "../../assets/atmosphere/single_mie_scattering.exr?no-inline",
8
31
  import.meta.url,
9
32
  ).toString();
10
33
 
11
- export const NOISE_ASSETS_URL = new URL(
12
- `${import.meta.env.BASE_URL}assets/noise`,
34
+ export const STARS_ASSETS_URL = new URL(
35
+ "../../assets/atmosphere/stars.bin?no-inline",
13
36
  import.meta.url,
14
37
  ).toString();
15
38
 
16
- export const RAIN_ASSETS_URL = new URL(
17
- `${import.meta.env.BASE_URL}assets/rain`,
39
+ export const CLOUD_LOCAL_WEATHER_URL = new URL(
40
+ "../../assets/cloud/local_weather.png?no-inline",
18
41
  import.meta.url,
19
42
  ).toString();
20
43
 
21
- export const WATER_ASSETS_URL = new URL(
22
- `${import.meta.env.BASE_URL}assets/water`,
44
+ export const CLOUD_SHAPE_URL = new URL(
45
+ "../../assets/cloud/shape.bin?no-inline",
23
46
  import.meta.url,
24
47
  ).toString();
25
48
 
26
- export const STARS_ASSETS_URL = `${ATMOSPHERE_ASSETS_URL}/stars.bin`;
49
+ export const CLOUD_SHAPE_DETAIL_URL = new URL(
50
+ "../../assets/cloud/shape_detail.bin?no-inline",
51
+ import.meta.url,
52
+ ).toString();
53
+
54
+ export const CLOUD_TURBULENCE_URL = new URL(
55
+ "../../assets/cloud/turbulence.png?no-inline",
56
+ import.meta.url,
57
+ ).toString();
27
58
 
28
- export const STBN_URL = `${NOISE_ASSETS_URL}/stbn.bin`;
59
+ export const STBN_URL = new URL(
60
+ "../../assets/noise/stbn.bin?no-inline",
61
+ import.meta.url,
62
+ ).toString();
63
+
64
+ export const WATER_NORMAL_URL = new URL(
65
+ "../../assets/water/waternormals.jpg?no-inline",
66
+ import.meta.url,
67
+ ).toString();
29
68
 
30
- export const WATER_NORMAL_URL = `${WATER_ASSETS_URL}/waternormals.jpg`;
69
+ /**
70
+ * Filenames requested by `PrecomputedTexturesLoader` mapped to their
71
+ * statically-resolved URLs. Used to remap directory-relative requests when
72
+ * loading the bundled default atmosphere assets.
73
+ */
74
+ export const ATMOSPHERE_TEXTURE_URLS: Record<string, string> = {
75
+ "transmittance.exr": ATMOSPHERE_TRANSMITTANCE_URL,
76
+ "scattering.exr": ATMOSPHERE_SCATTERING_URL,
77
+ "irradiance.exr": ATMOSPHERE_IRRADIANCE_URL,
78
+ "higher_order_scattering.exr": ATMOSPHERE_HIGHER_ORDER_SCATTERING_URL,
79
+ "single_mie_scattering.exr": ATMOSPHERE_SINGLE_MIE_SCATTERING_URL,
80
+ };
@@ -203,7 +203,7 @@ export class DeclutterManager {
203
203
  camera.near,
204
204
  widthPx,
205
205
  heightPx,
206
- // Plain math instead of @navara/three_api's degreeToRadian — that one
206
+ // Plain math instead of @navaramap/three-api's degreeToRadian — that one
207
207
  // is a WASM call whose result we'd only marshal straight back in.
208
208
  (camera.fov * Math.PI) / 180.0,
209
209
  DeclutterManager.PADDING_PX,
@@ -31,7 +31,7 @@ export type DeclutterCandidate = {
31
31
  isShown: boolean;
32
32
  /** Participant that owns this candidate; receives the placement result. */
33
33
  owner: DeclutterParticipant;
34
- /** Participant-defined id (mesh index for text batches, instance index for
34
+ /** Participant-defined id (label slot for text batches, instance index for
35
35
  * sprites) passed back to `applyDeclutter`. */
36
36
  handle: number;
37
37
  };
@@ -0,0 +1,152 @@
1
+ import { Object3D } from "three";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ import type { FeatureHandler } from "../event/context";
5
+
6
+ import { FeatureEvaluator, type GeometryType } from "./FeatureEvaluator";
7
+
8
+ // Mock worker module to avoid os.cpus() error in test environment
9
+ vi.mock("@navaramap/worker", () => ({}));
10
+
11
+ describe("FeatureEvaluator", () => {
12
+ let mockHandler: FeatureHandler;
13
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
14
+
15
+ beforeEach(() => {
16
+ // Create mock FeatureHandler with minimal implementation
17
+ mockHandler = {
18
+ readAllBatchedProperties: vi.fn((_, callback) => {
19
+ // Simulate a single batch with empty properties object
20
+ callback(0, 0, {});
21
+ }),
22
+ readFilteredBatchedProperties: vi.fn((_, __, callback) => {
23
+ // Simulate a single batch with empty properties object (not array)
24
+ // Production code expects Record<string, unknown>, not an array
25
+ callback(0, 0, {});
26
+ }),
27
+ } as unknown as FeatureHandler;
28
+
29
+ // Spy on console.warn to check for warnings
30
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
31
+ });
32
+
33
+ afterEach(() => {
34
+ consoleWarnSpy.mockRestore();
35
+ });
36
+
37
+ describe("determineGeometryType", () => {
38
+ it("should pass through valid geometry type literals", () => {
39
+ // Test representative types: billboard and text (critical for symbol layers)
40
+ const mockMesh = new Object3D();
41
+ Object.defineProperty(mockMesh, "geometryType", {
42
+ get: () => "billboard",
43
+ configurable: true,
44
+ });
45
+
46
+ const evaluator = new FeatureEvaluator(
47
+ mockHandler,
48
+ "test-layer",
49
+ 0n,
50
+ mockMesh,
51
+ );
52
+
53
+ let capturedGeometryType: GeometryType | undefined;
54
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
55
+ capturedGeometryType = gt;
56
+ });
57
+
58
+ expect(capturedGeometryType).toBe("billboard");
59
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("should return undefined and warn once for invalid values", () => {
63
+ const mockMesh = new Object3D();
64
+ Object.defineProperty(mockMesh, "geometryType", {
65
+ get: () => "invalid-type",
66
+ configurable: true,
67
+ });
68
+
69
+ const evaluator = new FeatureEvaluator(
70
+ mockHandler,
71
+ "test-layer",
72
+ 0n,
73
+ mockMesh,
74
+ );
75
+
76
+ // First evaluation - should warn
77
+ let capturedGeometryType: GeometryType | undefined;
78
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
79
+ capturedGeometryType = gt;
80
+ });
81
+
82
+ expect(capturedGeometryType).toBeUndefined();
83
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
84
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
85
+ expect.stringContaining(
86
+ 'Invalid geometry type returned from geometryType getter: "invalid-type"',
87
+ ),
88
+ );
89
+
90
+ // Second evaluation - should NOT warn again (same object)
91
+ consoleWarnSpy.mockClear();
92
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
93
+ capturedGeometryType = gt;
94
+ });
95
+
96
+ expect(capturedGeometryType).toBeUndefined();
97
+ expect(consoleWarnSpy).not.toHaveBeenCalled(); // No additional warnings
98
+ });
99
+
100
+ it("should return undefined without warning when geometryType is not present", () => {
101
+ const mockMesh = new Object3D();
102
+ // No geometryType getter
103
+
104
+ const evaluator = new FeatureEvaluator(
105
+ mockHandler,
106
+ "test-layer",
107
+ 0n,
108
+ mockMesh,
109
+ );
110
+
111
+ let capturedGeometryType: GeometryType | undefined;
112
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
113
+ capturedGeometryType = gt;
114
+ });
115
+
116
+ expect(capturedGeometryType).toBeUndefined();
117
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("should read geometryType dynamically on each evaluation (critical for symbol layers)", () => {
121
+ const mockMesh = new Object3D();
122
+ let currentType: GeometryType = "billboard";
123
+ Object.defineProperty(mockMesh, "geometryType", {
124
+ get: () => currentType,
125
+ configurable: true,
126
+ });
127
+
128
+ const evaluator = new FeatureEvaluator(
129
+ mockHandler,
130
+ "test-layer",
131
+ 0n,
132
+ mockMesh,
133
+ );
134
+
135
+ // First read - should be "billboard"
136
+ let capturedGeometryType: GeometryType | undefined;
137
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
138
+ capturedGeometryType = gt;
139
+ });
140
+ expect(capturedGeometryType).toBe("billboard");
141
+
142
+ // Change the geometry type (simulates symbol layer switching from billboard to text)
143
+ currentType = "text";
144
+
145
+ // Second read - should be "text" (not cached)
146
+ evaluator.readFeatureProperties(({ meshGeomType: gt }) => {
147
+ capturedGeometryType = gt;
148
+ });
149
+ expect(capturedGeometryType).toBe("text");
150
+ });
151
+ });
152
+ });