@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@navaramap/three",
3
3
  "type": "module",
4
- "version": "0.0.7",
4
+ "version": "0.1.0",
5
5
  "main": "dist/index.umd.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -30,16 +30,16 @@
30
30
  "astronomy-engine": "2.1.19",
31
31
  "stats.js": "0.17.0",
32
32
  "three-stdlib": "2.36.1",
33
- "@navaramap/core": "0.0.7",
34
- "@navaramap/engine": "0.0.7",
35
- "@navaramap/engine-api": "0.0.7",
36
- "@navaramap/three-api": "0.0.7",
37
- "@navaramap/font": "0.0.7",
38
- "@navaramap/three-csm": "0.0.7",
39
- "@navaramap/worker": "0.0.7"
33
+ "@navaramap/engine": "0.1.0",
34
+ "@navaramap/engine-api": "0.1.0",
35
+ "@navaramap/core": "0.1.0",
36
+ "@navaramap/three-csm": "0.1.0",
37
+ "@navaramap/font": "0.1.0",
38
+ "@navaramap/worker": "0.1.0",
39
+ "@navaramap/three-api": "0.1.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@playwright/test": "1.61.1",
42
+ "@playwright/test": "1.62.1",
43
43
  "@radix-ui/react-collapsible": "1.1.17",
44
44
  "@radix-ui/react-label": "2.1.12",
45
45
  "@radix-ui/react-select": "2.3.4",
@@ -50,19 +50,19 @@
50
50
  "@tailwindcss/postcss": "4.3.3",
51
51
  "@tweakpane/core": "2.0.5",
52
52
  "@types/geojson": "7946.0.16",
53
- "@types/node": "26.1.1",
53
+ "@types/node": "26.2.0",
54
54
  "@types/stats.js": "0.17.4",
55
55
  "@types/three": "0.185.1",
56
56
  "ajv": "8.20.0",
57
57
  "class-variance-authority": "0.7.1",
58
58
  "clsx": "2.1.1",
59
59
  "dayjs": "1.11.21",
60
- "lucide-react": "1.25.0",
61
- "playwright": "1.61.1",
60
+ "lucide-react": "1.31.0",
61
+ "playwright": "1.62.1",
62
62
  "postcss": "8.5.20",
63
63
  "postprocessing": "6.39.3",
64
64
  "sharp": "0.35.3",
65
- "shiki": "4.3.1",
65
+ "shiki": "4.4.3",
66
66
  "tailwind-merge": "3.6.0",
67
67
  "tailwindcss": "4.3.3",
68
68
  "tailwindcss-animate": "1.0.7",
@@ -71,10 +71,10 @@
71
71
  "tweakpane": "4.0.5",
72
72
  "vite-plugin-glsl": "1.6.0",
73
73
  "vite-plugin-static-copy": "4.1.1",
74
- "@navaramap/three-default-descs": "0.0.7",
75
- "@navaramap/three-plugins": "0.0.7",
76
- "@navaramap/three-default-plugin": "0.0.7",
77
- "@navaramap/three-react": "0.0.7"
74
+ "@navaramap/three-default-plugin": "0.1.0",
75
+ "@navaramap/three-plugins": "0.1.0",
76
+ "@navaramap/three-default-descs": "0.1.0",
77
+ "@navaramap/three-react": "0.1.0"
78
78
  },
79
79
  "scripts": {
80
80
  "start": "vite",
package/src/camera.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  CameraStatusType,
12
12
  CameraControlUpdateEvent,
13
13
  } from "@navaramap/engine";
14
+ import { radianToDegree } from "@navaramap/three-api";
14
15
  import { PerspectiveCamera } from "three";
15
16
  import invariant from "tiny-invariant";
16
17
 
@@ -97,10 +98,6 @@ export class ThreeViewCamera extends EventHandler<CameraEvent> {
97
98
  };
98
99
  }
99
100
 
100
- get fovy(): number | undefined {
101
- return this._core?.getCameraFOVY();
102
- }
103
-
104
101
  /**
105
102
  * Effective Web Mercator zoom level the camera is viewing the surface at
106
103
  * (fractional). Computed engine-side from the camera's ellipsoid height (not
@@ -110,6 +107,16 @@ export class ThreeViewCamera extends EventHandler<CameraEvent> {
110
107
  return this._core?.getZoomLevel();
111
108
  }
112
109
 
110
+ /**
111
+ * The vertical field of view in degrees; the horizontal extent follows
112
+ * from the viewport aspect ratio.
113
+ * Reading returns `undefined` until the engine camera exists.
114
+ */
115
+ get fov(): number | undefined {
116
+ const fov = this._core?.getCameraFOV();
117
+ return fov === undefined ? undefined : radianToDegree(fov);
118
+ }
119
+
113
120
  set fov(val: number) {
114
121
  if (val < 1 || val > 180) {
115
122
  return;
@@ -58,7 +58,7 @@ const _swapColor = new ThreeColor();
58
58
  * Per-instance variation is achieved through `instanceMatrix` (position, rotation, scale)
59
59
  * and `instanceColor` (per-instance color).
60
60
  *
61
- * The parent's transform fields (position, scale, rotation, matrix, matrixWorld)
61
+ * The parent's transform fields (position, scale, rotation, matrix, matrixWorld, geodetic)
62
62
  * define the parent coordinate space. Each instance's transform is local to the parent.
63
63
  *
64
64
  * Subclasses that maintain external per-instance state (e.g. picking batchIds)
@@ -0,0 +1,396 @@
1
+ import { headingPitchRollToFixedFrame } from "@navaramap/three-api";
2
+ import {
3
+ BoxGeometry,
4
+ MeshBasicMaterial,
5
+ Matrix4,
6
+ Mesh,
7
+ Scene,
8
+ Vector3,
9
+ } from "three";
10
+ import invariant from "tiny-invariant";
11
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import type ThreeView from "../index";
14
+ import { initTestEngine } from "../test-utils/engine";
15
+
16
+ import { ConflictingTransformError } from "./errors";
17
+ import { MeshDesc, type MeshConfig, type MeshUpdate } from "./MeshDesc";
18
+ import type { ViewContext } from "./ViewContext";
19
+
20
+ // vitest never initialises the WASM module, so the real
21
+ // `headingPitchRollToFixedFrame` throws
22
+ // "Cannot read properties of undefined (reading 'lle_new')". Stand in a
23
+ // transparent model that encodes its inputs into the translation column, so
24
+ // every assertion below can name exactly which placement was lowered.
25
+ //
26
+ // These tests are about LOWERING — does `geodetic` reach `matrixWorld`, does a
27
+ // partial update merge, does terrain height get added, do conflicts throw.
28
+ // The geodesy itself is covered without any mocks by `frames.test.ts` and
29
+ // `index.test.ts` in @navaramap/three-api, so re-deriving it here would only
30
+ // duplicate the production formula and prove nothing.
31
+ vi.mock("@navaramap/three-api", async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import("@navaramap/three-api")>();
33
+ const { Matrix4: M } = await import("three");
34
+ return {
35
+ ...actual,
36
+ headingPitchRollToFixedFrame: vi.fn(
37
+ (p: { lng: number; lat: number; height?: number }) =>
38
+ new M().makeTranslation(p.lng, p.lat, p.height ?? 0),
39
+ ),
40
+ };
41
+ });
42
+
43
+ class TestMeshDesc extends MeshDesc<MeshConfig, MeshUpdate, Mesh> {
44
+ createMesh() {
45
+ return new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial());
46
+ }
47
+ }
48
+
49
+ const makeCtx = () =>
50
+ ({
51
+ scenes: {
52
+ opaque: new Scene(),
53
+ transparent: new Scene(),
54
+ mrt: new Scene(),
55
+ skyEnvMap: new Scene(),
56
+ draped: new Scene(),
57
+ },
58
+ emit: vi.fn(),
59
+ applyShadowMaterial: vi.fn(),
60
+ registerPickableMesh: vi.fn(),
61
+ }) as unknown as ViewContext;
62
+
63
+ const makeView = () =>
64
+ ({
65
+ sampleTerrainHeight: vi.fn(() => undefined),
66
+ observeTerrainHeightAt: vi.fn(() => () => {}),
67
+ }) as unknown as ThreeView;
68
+
69
+ const create = (config: MeshConfig) => {
70
+ const desc = new TestMeshDesc(makeView(), makeCtx(), config);
71
+ desc.onCreate();
72
+ return desc;
73
+ };
74
+
75
+ /** The placement object most recently handed to the frame builder. */
76
+ const lastPlacement = () => {
77
+ const calls = vi.mocked(headingPitchRollToFixedFrame).mock.calls;
78
+ return calls[calls.length - 1]?.[0];
79
+ };
80
+
81
+ /**
82
+ * The `Object3D` a Descriptor created, with `undefined` narrowed away.
83
+ * A helper rather than `desc.raw!` — the repo bans non-null assertions
84
+ * (`@typescript-eslint/no-non-null-assertion`), and this yields a named
85
+ * failure instead of a `TypeError` if `onCreate` ever stops setting it.
86
+ */
87
+ const rawOf = (desc: TestMeshDesc) => {
88
+ const { raw } = desc;
89
+ invariant(raw, "descriptor has no Three.js instance");
90
+ return raw;
91
+ };
92
+
93
+ const translationOf = (m: Matrix4) =>
94
+ new Vector3().setFromMatrixPosition(m).toArray();
95
+
96
+ const TOKYO = { lng: 139.767125, lat: 35.681236 };
97
+
98
+ // Two DIFFERENT WASM modules are in play, and they need opposite treatment.
99
+ // `@navaramap/engine-api` (the geodetic math) stays mocked, above.
100
+ // `@navaramap/engine` is a separate module backing `generateId()`, which
101
+ // `BaseDesc`'s constructor calls for any Descriptor created without an
102
+ // explicit `id` — so it must be really initialised. `initTestEngine` is the
103
+ // project's helper for exactly this and is idempotent per vitest worker.
104
+ // Do not replace this by passing an `id` to every config: that hides the
105
+ // dependency, and the next Rust-backed call added to BaseDesc/MeshDesc would
106
+ // break this suite for a reason nobody would connect back to the workaround.
107
+ beforeAll(initTestEngine);
108
+
109
+ beforeEach(() => {
110
+ vi.mocked(headingPitchRollToFixedFrame).mockClear();
111
+ });
112
+
113
+ describe("MeshDesc geodetic placement", () => {
114
+ it("places the mesh via matrixWorld and disables auto updates", () => {
115
+ const desc = create({ geodetic: { ...TOKYO, height: 100 } });
116
+ const raw = rawOf(desc);
117
+
118
+ expect(raw.matrixAutoUpdate).toBe(false);
119
+ expect(raw.matrixWorldAutoUpdate).toBe(false);
120
+ expect(translationOf(raw.matrixWorld)).toEqual([TOKYO.lng, TOKYO.lat, 100]);
121
+ });
122
+
123
+ it("forwards the whole placement to the frame builder, without heightReference", () => {
124
+ create({
125
+ geodetic: {
126
+ ...TOKYO,
127
+ height: 5,
128
+ heading: 321,
129
+ pitch: 4,
130
+ roll: -2,
131
+ scale: 3,
132
+ heightReference: "ellipsoid",
133
+ },
134
+ });
135
+
136
+ expect(lastPlacement()).toEqual({
137
+ lng: TOKYO.lng,
138
+ lat: TOKYO.lat,
139
+ height: 5,
140
+ heading: 321,
141
+ pitch: 4,
142
+ roll: -2,
143
+ scale: 3,
144
+ });
145
+ // `headingPitchRollToFixedFrame` takes Omit<…, "heightReference">;
146
+ // leaking it would mean silently ignoring a terrain request.
147
+ expect(lastPlacement()).not.toHaveProperty("heightReference");
148
+ });
149
+
150
+ it("composes position and scale as offsets inside the frame", () => {
151
+ const desc = create({
152
+ geodetic: TOKYO,
153
+ position: { x: 0, y: 500, z: 0 },
154
+ scale: { x: 2, y: 2, z: 2 },
155
+ });
156
+
157
+ // Pins the documented order `frame · T · R · S`, scale innermost.
158
+ const expected = new Matrix4()
159
+ .makeTranslation(TOKYO.lng, TOKYO.lat, 0)
160
+ .multiply(new Matrix4().makeTranslation(0, 500, 0))
161
+ .multiply(new Matrix4().makeScale(2, 2, 2));
162
+
163
+ rawOf(desc).matrixWorld.elements.forEach((value, i) => {
164
+ expect(value).toBeCloseTo(expected.elements[i], 9);
165
+ });
166
+ });
167
+
168
+ it("throws when geodetic is combined with matrixWorld or matrix", () => {
169
+ expect(() =>
170
+ create({ geodetic: TOKYO, matrixWorld: new Matrix4() }),
171
+ ).toThrow(ConflictingTransformError);
172
+ expect(() => create({ geodetic: TOKYO, matrix: new Matrix4() })).toThrow(
173
+ ConflictingTransformError,
174
+ );
175
+ });
176
+
177
+ // `matrix` was previously accepted alongside `matrixWorld` and then dropped
178
+ // by `applyTransform`'s priority chain — and it cannot be honoured there at
179
+ // all, since the `matrixWorld` branch turns `matrixWorldAutoUpdate` off, so
180
+ // Three.js never derives `matrixWorld` from `matrix` again.
181
+ it("throws when matrix is combined with matrixWorld", () => {
182
+ expect(() =>
183
+ create({ matrix: new Matrix4(), matrixWorld: new Matrix4() }),
184
+ ).toThrow(ConflictingTransformError);
185
+ });
186
+
187
+ it("throws when an update introduces matrix onto a matrixWorld mesh", () => {
188
+ const frame = new Matrix4().makeTranslation(1, 2, 3);
189
+ const desc = create({ matrixWorld: frame });
190
+
191
+ expect(() => desc.onUpdateConfig({ matrix: new Matrix4() })).toThrow(
192
+ ConflictingTransformError,
193
+ );
194
+
195
+ // The rejected field must not have landed, and the surviving placement
196
+ // must still be the one that was configured.
197
+ expect(desc.matrix).toBeUndefined();
198
+ expect(translationOf(rawOf(desc).matrixWorld)).toEqual([1, 2, 3]);
199
+ expect(() => desc.onUpdateConfig({ visible: false })).not.toThrow();
200
+ });
201
+
202
+ it("throws when an update introduces matrixWorld onto a matrix mesh", () => {
203
+ const desc = create({ matrix: new Matrix4().makeTranslation(4, 5, 6) });
204
+
205
+ expect(() => desc.onUpdateConfig({ matrixWorld: new Matrix4() })).toThrow(
206
+ ConflictingTransformError,
207
+ );
208
+
209
+ expect(desc.matrixWorld).toBeUndefined();
210
+ expect(translationOf(rawOf(desc).matrix)).toEqual([4, 5, 6]);
211
+ });
212
+
213
+ it("merges a partial geodetic update instead of replacing it", () => {
214
+ const desc = create({ geodetic: { ...TOKYO, height: 100, heading: 10 } });
215
+
216
+ desc.onUpdateConfig({ geodetic: { heading: 200 } });
217
+
218
+ // lng/lat/height survived a heading-only update.
219
+ expect(lastPlacement()).toEqual({
220
+ lng: TOKYO.lng,
221
+ lat: TOKYO.lat,
222
+ height: 100,
223
+ heading: 200,
224
+ });
225
+ expect(desc.geodetic).toEqual({ ...TOKYO, height: 100, heading: 200 });
226
+ });
227
+
228
+ it("rejects a partial geodetic update on a mesh with no placement", () => {
229
+ const desc = create({ position: { x: 1, y: 2, z: 3 } });
230
+ expect(() => desc.onUpdateConfig({ geodetic: { heading: 90 } })).toThrow(
231
+ /requires `lng` and `lat`/,
232
+ );
233
+ });
234
+
235
+ it("throws when an update introduces a conflicting matrixWorld", () => {
236
+ const desc = create({ geodetic: TOKYO });
237
+ expect(() => desc.onUpdateConfig({ matrixWorld: new Matrix4() })).toThrow(
238
+ ConflictingTransformError,
239
+ );
240
+ });
241
+
242
+ it("throws when an update introduces a conflicting matrix", () => {
243
+ const desc = create({ geodetic: TOKYO });
244
+ expect(() => desc.onUpdateConfig({ matrix: new Matrix4() })).toThrow(
245
+ ConflictingTransformError,
246
+ );
247
+ });
248
+
249
+ it("leaves the mesh usable after a rejected update", () => {
250
+ const desc = create({ geodetic: { ...TOKYO, heading: 10 } });
251
+
252
+ expect(() => desc.onUpdateConfig({ matrixWorld: new Matrix4() })).toThrow(
253
+ ConflictingTransformError,
254
+ );
255
+
256
+ // The rejected field must not have landed, or the guard would fire on
257
+ // every later call and no update could ever clear it again.
258
+ expect(desc.matrixWorld).toBeUndefined();
259
+ expect(() => desc.onUpdateConfig({ visible: false })).not.toThrow();
260
+ expect(() =>
261
+ desc.onUpdateConfig({ geodetic: { heading: 20 } }),
262
+ ).not.toThrow();
263
+ expect(desc.geodetic?.heading).toBe(20);
264
+ });
265
+
266
+ it("leaves the existing matrixWorld path untouched", () => {
267
+ const desc = create({
268
+ matrixWorld: new Matrix4().makeTranslation(1, 2, 3),
269
+ });
270
+
271
+ expect(translationOf(rawOf(desc).matrixWorld)).toEqual([1, 2, 3]);
272
+ expect(headingPitchRollToFixedFrame).not.toHaveBeenCalled();
273
+ });
274
+ });
275
+
276
+ describe("MeshDesc terrain-referenced placement", () => {
277
+ type LatLngDeg = { lat: number; lng: number };
278
+
279
+ const terrainView = (height: number | undefined) => {
280
+ const listeners: ((h: number) => void)[] = [];
281
+ const unsubscribe = vi.fn();
282
+ const spy = {
283
+ sampleTerrainHeight: vi.fn((_pos: LatLngDeg) => height),
284
+ observeTerrainHeightAt: vi.fn(
285
+ (_pos: LatLngDeg, cb: (h: number) => void) => {
286
+ listeners.push(cb);
287
+ return unsubscribe;
288
+ },
289
+ ),
290
+ };
291
+ return { view: spy as unknown as ThreeView, listeners, unsubscribe, spy };
292
+ };
293
+
294
+ const createWith = (view: ThreeView, config: MeshConfig) => {
295
+ const desc = new TestMeshDesc(view, makeCtx(), config);
296
+ desc.onCreate();
297
+ return desc;
298
+ };
299
+
300
+ it("seeds from the resident-tile sample and subscribes", () => {
301
+ const { view, spy } = terrainView(300);
302
+ createWith(view, {
303
+ geodetic: { ...TOKYO, height: 10, heightReference: "terrain" },
304
+ });
305
+
306
+ expect(spy.sampleTerrainHeight).toHaveBeenCalled();
307
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalled();
308
+
309
+ // Both terrain methods take the same degrees `geodetic` carries.
310
+ const pos = spy.sampleTerrainHeight.mock.calls[0][0];
311
+ expect(pos.lat).toBeCloseTo(TOKYO.lat, 9);
312
+ expect(pos.lng).toBeCloseTo(TOKYO.lng, 9);
313
+
314
+ // terrain 300 + requested 10
315
+ expect(lastPlacement()?.height).toBeCloseTo(310, 9);
316
+ expect(lastPlacement()).not.toHaveProperty("heightReference");
317
+ });
318
+
319
+ it("re-places the mesh when terrain refines", () => {
320
+ const { view, listeners } = terrainView(0);
321
+ createWith(view, {
322
+ geodetic: { ...TOKYO, height: 10, heightReference: "terrain" },
323
+ });
324
+ expect(lastPlacement()?.height).toBeCloseTo(10, 9);
325
+
326
+ listeners[0](450);
327
+
328
+ expect(lastPlacement()?.height).toBeCloseTo(460, 9);
329
+ });
330
+
331
+ it("falls back to ellipsoid height when no terrain is loaded", () => {
332
+ const { view, spy } = terrainView(undefined);
333
+ createWith(view, {
334
+ geodetic: { ...TOKYO, height: 10, heightReference: "terrain" },
335
+ });
336
+
337
+ // Still subscribes — a terrain layer may be added after the mesh.
338
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalled();
339
+ expect(lastPlacement()?.height).toBeCloseTo(10, 9);
340
+ });
341
+
342
+ it("does not subscribe for ellipsoid-referenced placements", () => {
343
+ const { view, spy } = terrainView(300);
344
+ createWith(view, { geodetic: { ...TOKYO, heightReference: "ellipsoid" } });
345
+ expect(spy.observeTerrainHeightAt).not.toHaveBeenCalled();
346
+ expect(spy.sampleTerrainHeight).not.toHaveBeenCalled();
347
+
348
+ const { view: v2, spy: s2 } = terrainView(300);
349
+ createWith(v2, { geodetic: TOKYO });
350
+ expect(s2.observeTerrainHeightAt).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it("unsubscribes on destroy", () => {
354
+ const { view, unsubscribe } = terrainView(300);
355
+ const desc = createWith(view, {
356
+ geodetic: { ...TOKYO, heightReference: "terrain" },
357
+ });
358
+
359
+ desc.onDestroy();
360
+
361
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
362
+ });
363
+
364
+ it("re-subscribes when the position or height reference changes", () => {
365
+ const { view, unsubscribe, spy } = terrainView(300);
366
+ const desc = createWith(view, {
367
+ geodetic: { ...TOKYO, heightReference: "terrain" },
368
+ });
369
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalledTimes(1);
370
+
371
+ desc.onUpdateConfig({ geodetic: { lng: 140 } });
372
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
373
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalledTimes(2);
374
+
375
+ desc.onUpdateConfig({ geodetic: { heightReference: "ellipsoid" } });
376
+ expect(unsubscribe).toHaveBeenCalledTimes(2);
377
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalledTimes(2);
378
+ // Dropping the terrain reference drops the terrain contribution too.
379
+ expect(lastPlacement()?.height).toBeCloseTo(0, 9);
380
+ });
381
+
382
+ it("does not re-subscribe for a heading-only update", () => {
383
+ const { view, unsubscribe, spy } = terrainView(300);
384
+ const desc = createWith(view, {
385
+ geodetic: { ...TOKYO, heightReference: "terrain" },
386
+ });
387
+
388
+ desc.onUpdateConfig({ geodetic: { heading: 90 } });
389
+
390
+ expect(unsubscribe).not.toHaveBeenCalled();
391
+ expect(spy.observeTerrainHeightAt).toHaveBeenCalledTimes(1);
392
+ // The re-composed placement still carries the seeded terrain height.
393
+ expect(lastPlacement()?.height).toBeCloseTo(300, 9);
394
+ expect(lastPlacement()?.heading).toBe(90);
395
+ });
396
+ });