@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.
- package/LICENSE +1 -1
- package/README.md +20 -8
- package/dist/assets/fontWorker-C5cHhkWQ.js +2 -0
- package/dist/assets/fontWorker-C5cHhkWQ.js.map +1 -0
- package/dist/assets/index-VfUncKuS.js +25 -0
- package/dist/assets/index-VfUncKuS.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-XvE_oYPr.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Dg9fnGLP.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
- package/dist/index.d.ts +460 -43
- package/dist/index.js +3732 -3584
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +230 -209
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +17 -17
- package/src/camera.ts +11 -4
- package/src/core/InstancedMeshDesc.ts +1 -1
- package/src/core/MeshDesc.test.ts +396 -0
- package/src/core/MeshDesc.ts +219 -8
- package/src/core/errors.ts +14 -0
- package/src/core/index.ts +2 -0
- package/src/evaluations/FeatureEvaluator.ts +17 -10
- package/src/event/context.ts +6 -0
- package/src/event/feature.ts +15 -2
- package/src/event/features/billboard.test.ts +62 -0
- package/src/event/features/billboard.ts +4 -2
- package/src/event/index.ts +7 -0
- package/src/index.ts +174 -40
- package/src/material/macro/tileShader.ts +12 -2
- package/src/mesh/polyline.ts +32 -4
- package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
- package/src/mesh/sdfText/batchedSdfText.ts +121 -43
- package/src/mesh/sprite/billboardAtlas.ts +8 -1
- package/src/mesh/sprite/instancedSprite.ts +173 -65
- package/src/pick/pickHelper.test.ts +302 -0
- package/src/pick/pickHelper.ts +119 -20
- package/src/plugins/AttributionPlugin/index.ts +1 -1
- package/src/renderOrder.ts +10 -0
- package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
- package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
- package/src/terrain/terrainHeightEvent.test.ts +32 -0
- package/src/terrain/terrainHeightEvent.ts +22 -0
- package/src/type/index.ts +22 -1
- package/dist/assets/fontWorker-DaqfXmZf.js +0 -2
- package/dist/assets/fontWorker-DaqfXmZf.js.map +0 -1
- package/dist/assets/index-Dydat1h1.js +0 -25
- package/dist/assets/index-Dydat1h1.js.map +0 -1
package/src/core/MeshDesc.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { BaseEventMap, XYZ } from "@navaramap/core";
|
|
2
|
+
import {
|
|
3
|
+
headingPitchRollToFixedFrame,
|
|
4
|
+
type GeodeticPlacement,
|
|
5
|
+
} from "@navaramap/three-api";
|
|
2
6
|
import { Euler, Matrix4, Object3D, Vector3, type Mesh } from "three";
|
|
3
7
|
import invariant from "tiny-invariant";
|
|
4
8
|
|
|
@@ -12,6 +16,7 @@ import {
|
|
|
12
16
|
type BaseDescConfig,
|
|
13
17
|
type BaseDescConfigUpdate,
|
|
14
18
|
} from "./BaseDesc";
|
|
19
|
+
import { ConflictingTransformError } from "./errors";
|
|
15
20
|
import type { ViewContext } from "./ViewContext";
|
|
16
21
|
|
|
17
22
|
export type MeshConfig = {
|
|
@@ -35,6 +40,9 @@ export type MeshConfig = {
|
|
|
35
40
|
* Local frame. When combined with `position` / `rotation` / `scale`, the
|
|
36
41
|
* effective local matrix is `matrix · T(position) · R(rotation) · S(scale)`.
|
|
37
42
|
* Disables Three.js's auto matrix update.
|
|
43
|
+
*
|
|
44
|
+
* Mutually exclusive with `matrixWorld` and `geodetic`: all three define the
|
|
45
|
+
* object's placement.
|
|
38
46
|
*/
|
|
39
47
|
matrix?: Matrix4;
|
|
40
48
|
/**
|
|
@@ -43,8 +51,25 @@ export type MeshConfig = {
|
|
|
43
51
|
* matrix is `matrixWorld · T(position) · R(rotation) · S(scale)`, so the
|
|
44
52
|
* offset fields are interpreted in the frame's local coordinates.
|
|
45
53
|
* Disables Three.js's auto matrix-world update.
|
|
54
|
+
*
|
|
55
|
+
* Mutually exclusive with `matrix` and `geodetic`: all three define the
|
|
56
|
+
* object's placement.
|
|
46
57
|
*/
|
|
47
58
|
matrixWorld?: Matrix4;
|
|
59
|
+
/**
|
|
60
|
+
* High-level geographic placement in degrees and metres. Builds a
|
|
61
|
+
* West-Up-North tangent frame at `lng`/`lat`/`height` and applies
|
|
62
|
+
* `heading`/`pitch`/`roll`/`scale`, then occupies the same slot as
|
|
63
|
+
* `matrixWorld` — so `position` / `rotation` / `scale` remain offsets
|
|
64
|
+
* *inside* the resulting frame.
|
|
65
|
+
*
|
|
66
|
+
* `heading` is the compass bearing the asset's front (glTF `+Z`) faces, the
|
|
67
|
+
* same convention as `setCamera`. Because the frame is Y-up with `+Z` north,
|
|
68
|
+
* an unmodified glTF asset needs no up-axis correction.
|
|
69
|
+
*
|
|
70
|
+
* Mutually exclusive with `matrix` and `matrixWorld`.
|
|
71
|
+
*/
|
|
72
|
+
geodetic?: GeodeticPlacement;
|
|
48
73
|
/**
|
|
49
74
|
* Lighting override for every material of the mesh. `false` outputs albedo
|
|
50
75
|
* only, while normals and the shadow G-buffer keep being written.
|
|
@@ -57,8 +82,14 @@ export type MeshConfig = {
|
|
|
57
82
|
export type MeshUpdate = Pick<
|
|
58
83
|
MeshConfig,
|
|
59
84
|
"position" | "scale" | "rotation" | "matrix" | "matrixWorld" | "lit"
|
|
60
|
-
> &
|
|
61
|
-
|
|
85
|
+
> & {
|
|
86
|
+
/**
|
|
87
|
+
* Partial geographic placement, merged into the stored placement — so
|
|
88
|
+
* `update({ geodetic: { heading } })` keeps the existing lng/lat/height.
|
|
89
|
+
* `lng` and `lat` are required only when the object has no placement yet.
|
|
90
|
+
*/
|
|
91
|
+
geodetic?: Partial<GeodeticPlacement>;
|
|
92
|
+
} & BaseDescConfigUpdate;
|
|
62
93
|
|
|
63
94
|
export type PassKey = keyof Pick<
|
|
64
95
|
Scenes,
|
|
@@ -74,6 +105,55 @@ export type MeshBaseInstance<Instance extends object = object> =
|
|
|
74
105
|
? Instance & { raw: Raw } & BaseInstance
|
|
75
106
|
: Instance & BaseInstance;
|
|
76
107
|
|
|
108
|
+
/**
|
|
109
|
+
* `geodetic`, `matrix` and `matrixWorld` all claim the object's placement, so
|
|
110
|
+
* they share a single slot and at most one may be set. Every consumer resolves
|
|
111
|
+
* that slot with a priority chain (`applyTransform`, `applyRTETransform`), so
|
|
112
|
+
* allowing two would silently drop the lower-priority one — notably `matrix`
|
|
113
|
+
* under `matrixWorld`, which cannot be honoured at all once
|
|
114
|
+
* `matrixWorldAutoUpdate` is off. Takes the values rather than reading `this`,
|
|
115
|
+
* so a caller can check a prospective state before committing it. Exported so
|
|
116
|
+
* subclasses that override `onUpdateConfig` (e.g. RTE Descriptors like
|
|
117
|
+
* `GLTFModelDesc`) reuse this single check instead of hand-rolling their own.
|
|
118
|
+
*/
|
|
119
|
+
export function assertNoTransformConflict(
|
|
120
|
+
geodetic: GeodeticPlacement | undefined,
|
|
121
|
+
matrix: Matrix4 | undefined,
|
|
122
|
+
matrixWorld: Matrix4 | undefined,
|
|
123
|
+
): void {
|
|
124
|
+
if (geodetic) {
|
|
125
|
+
if (matrixWorld) {
|
|
126
|
+
throw new ConflictingTransformError("geodetic", "matrixWorld");
|
|
127
|
+
}
|
|
128
|
+
if (matrix) {
|
|
129
|
+
throw new ConflictingTransformError("geodetic", "matrix");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (matrix && matrixWorld) {
|
|
133
|
+
throw new ConflictingTransformError("matrixWorld", "matrix");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merges a partial placement onto an existing one and checks it is complete.
|
|
139
|
+
* Pure — returns the merged value instead of assigning it, so the caller can
|
|
140
|
+
* validate before committing. Exported so subclasses that manage their own
|
|
141
|
+
* `onUpdateConfig` (e.g. `GLTFModelDesc`'s RTE-split transform) can merge a
|
|
142
|
+
* `geodetic` update with the same semantics instead of re-deriving them.
|
|
143
|
+
*/
|
|
144
|
+
export function mergeGeodetic(
|
|
145
|
+
current: GeodeticPlacement | undefined,
|
|
146
|
+
update: Partial<GeodeticPlacement>,
|
|
147
|
+
): GeodeticPlacement {
|
|
148
|
+
const merged = { ...current, ...update };
|
|
149
|
+
if (merged.lng === undefined || merged.lat === undefined) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"`geodetic` update requires `lng` and `lat` when the object has no geodetic placement yet.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return merged as GeodeticPlacement;
|
|
155
|
+
}
|
|
156
|
+
|
|
77
157
|
/**
|
|
78
158
|
* Abstract base class for creating custom mesh descriptors.
|
|
79
159
|
*
|
|
@@ -192,7 +272,7 @@ export type MeshBaseInstance<Instance extends object = object> =
|
|
|
192
272
|
* The base class applies position/scale/rotation and adds it to the scene
|
|
193
273
|
* determined by {@link getPassKey}.
|
|
194
274
|
* 3. **{@link onUpdateConfig}** - Called when `handle.update()` is invoked. The base class
|
|
195
|
-
* handles `visible`, `matrix`, `matrixWorld`, `position`, `scale`, and `
|
|
275
|
+
* handles `visible`, `matrix`, `matrixWorld`, `position`, `scale`, `rotation`, and `geodetic`; override to handle your
|
|
196
276
|
* custom properties. Always call `super.onUpdateConfig(updates)`.
|
|
197
277
|
* 4. **{@link update}** - Optional per-frame callback for animation.
|
|
198
278
|
* 5. **{@link onResize}** - Optional callback when the viewport is resized.
|
|
@@ -222,6 +302,10 @@ export abstract class MeshDesc<
|
|
|
222
302
|
public rotation?: XYZ;
|
|
223
303
|
public matrix?: Matrix4;
|
|
224
304
|
public matrixWorld?: Matrix4;
|
|
305
|
+
public geodetic?: GeodeticPlacement;
|
|
306
|
+
/** Terrain height under `geodetic`, when `heightReference` is `"terrain"`. */
|
|
307
|
+
protected terrainHeight?: number;
|
|
308
|
+
private terrainUnsubscribe?: () => void;
|
|
225
309
|
/** Three-state `lit` override (see {@link MeshConfig.lit}). */
|
|
226
310
|
public lit?: boolean;
|
|
227
311
|
private prevPassKey?: PassKey;
|
|
@@ -234,9 +318,88 @@ export abstract class MeshDesc<
|
|
|
234
318
|
this.rotation = resolvedConfig.rotation;
|
|
235
319
|
this.matrix = resolvedConfig.matrix;
|
|
236
320
|
this.matrixWorld = resolvedConfig.matrixWorld;
|
|
321
|
+
this.geodetic = resolvedConfig.geodetic;
|
|
322
|
+
assertNoTransformConflict(this.geodetic, this.matrix, this.matrixWorld);
|
|
237
323
|
this.lit = resolvedConfig.lit;
|
|
238
324
|
}
|
|
239
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Builds the world frame for a {@link GeodeticPlacement}, adding
|
|
328
|
+
* {@link terrainHeight} when one has been observed. This method does not
|
|
329
|
+
* gate on `heightReference` itself — `terrainHeight` is only ever populated
|
|
330
|
+
* for a terrain-referenced placement, and is cleared otherwise.
|
|
331
|
+
*
|
|
332
|
+
* Fields are passed explicitly rather than spread: `headingPitchRollToFixedFrame`
|
|
333
|
+
* rejects `heightReference`, which this class resolves itself.
|
|
334
|
+
*/
|
|
335
|
+
protected resolveGeodeticFrame(g: GeodeticPlacement): Matrix4 {
|
|
336
|
+
return headingPitchRollToFixedFrame({
|
|
337
|
+
lng: g.lng,
|
|
338
|
+
lat: g.lat,
|
|
339
|
+
height: (g.height ?? 0) + (this.terrainHeight ?? 0),
|
|
340
|
+
heading: g.heading,
|
|
341
|
+
pitch: g.pitch,
|
|
342
|
+
roll: g.roll,
|
|
343
|
+
scale: g.scale,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Subscribes to terrain height under a terrain-referenced `geodetic`
|
|
349
|
+
* placement.
|
|
350
|
+
*
|
|
351
|
+
* `sampleTerrainHeight` seeds synchronously from tiles already resident, so
|
|
352
|
+
* the object does not sit at the wrong altitude while tiles stream in; the
|
|
353
|
+
* observer then re-places it on every terrain update. With no terrain layer
|
|
354
|
+
* added, the sample is `undefined` and the observer does not fire until a
|
|
355
|
+
* terrain layer is added, so the placement degrades to ellipsoid-relative
|
|
356
|
+
* rather than failing — terrain layers may be added after the object.
|
|
357
|
+
*
|
|
358
|
+
* Protected (not private) so a subclass that manages its own world-matrix
|
|
359
|
+
* representation for `geodetic` (e.g. `GLTFModelDesc`'s RTE position split)
|
|
360
|
+
* can call it directly from its own `onCreate`/`onUpdateConfig` instead of
|
|
361
|
+
* routing through this class's `onCreate`.
|
|
362
|
+
*/
|
|
363
|
+
protected subscribeTerrainHeight(): void {
|
|
364
|
+
this.unsubscribeTerrainHeight();
|
|
365
|
+
|
|
366
|
+
const g = this.geodetic;
|
|
367
|
+
if (!g || g.heightReference !== "terrain") {
|
|
368
|
+
this.terrainHeight = undefined;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const pos = { lat: g.lat, lng: g.lng };
|
|
373
|
+
this.terrainHeight = this.view.sampleTerrainHeight(pos) ?? 0;
|
|
374
|
+
this.terrainUnsubscribe = this.view.observeTerrainHeightAt(
|
|
375
|
+
pos,
|
|
376
|
+
(height) => {
|
|
377
|
+
if (this.terrainHeight === height) return;
|
|
378
|
+
this.terrainHeight = height;
|
|
379
|
+
if (!this.raw) return;
|
|
380
|
+
this.reapplyGeodeticFrame();
|
|
381
|
+
this.requestUpdate();
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Re-applies the effective transform after `geodetic`'s resolved frame
|
|
388
|
+
* changes outside of `onUpdateConfig` — currently only a terrain-height
|
|
389
|
+
* observation firing asynchronously. Subclasses that override
|
|
390
|
+
* `applyTransform`'s effect with their own representation (e.g.
|
|
391
|
+
* `GLTFModelDesc`'s RTE position/rotation split, which needs the *encoded*
|
|
392
|
+
* position refreshed too) override this instead of relying on the default.
|
|
393
|
+
*/
|
|
394
|
+
protected reapplyGeodeticFrame(): void {
|
|
395
|
+
this.applyTransform();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private unsubscribeTerrainHeight(): void {
|
|
399
|
+
this.terrainUnsubscribe?.();
|
|
400
|
+
this.terrainUnsubscribe = undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
240
403
|
/**
|
|
241
404
|
* Applies {@link lit} to every material under the mesh. Subclasses that
|
|
242
405
|
* attach materials asynchronously (glTF) call it again once they exist.
|
|
@@ -300,6 +463,7 @@ export abstract class MeshDesc<
|
|
|
300
463
|
this._instance = this.createMesh();
|
|
301
464
|
invariant(this.raw);
|
|
302
465
|
|
|
466
|
+
this.subscribeTerrainHeight();
|
|
303
467
|
this.applyTransform();
|
|
304
468
|
this.applyLit();
|
|
305
469
|
|
|
@@ -339,28 +503,39 @@ export abstract class MeshDesc<
|
|
|
339
503
|
/**
|
|
340
504
|
* Applies the configured transform to the underlying `Object3D`.
|
|
341
505
|
*
|
|
342
|
-
* When `matrixWorld` (or `matrix`) is set together with any of
|
|
506
|
+
* When `matrixWorld` / `geodetic` (or `matrix`) is set together with any of
|
|
343
507
|
* `position` / `rotation` / `scale`, the base/frame is left-multiplied
|
|
344
508
|
* by the local `T · R · S`: e.g. `effective = matrixWorld · T · R · S`.
|
|
345
509
|
* This lets callers pass a frame (such as an NUE-to-ECEF matrix) as
|
|
346
510
|
* `matrixWorld` and express offsets inside that frame via
|
|
347
511
|
* `position` / `rotation` / `scale`.
|
|
512
|
+
*
|
|
513
|
+
* The branches below are a priority chain, which is only safe because
|
|
514
|
+
* `assertNoTransformConflict` has already rejected any state with more than
|
|
515
|
+
* one of `geodetic` / `matrixWorld` / `matrix` set — so an earlier branch
|
|
516
|
+
* can never shadow a configured field.
|
|
348
517
|
*/
|
|
349
518
|
private applyTransform(): void {
|
|
350
519
|
invariant(this.raw);
|
|
351
520
|
const hasLocal =
|
|
352
521
|
this.position != null || this.rotation != null || this.scale != null;
|
|
353
522
|
|
|
354
|
-
|
|
523
|
+
// `geodetic` takes the `matrixWorld` slot; the composition rule
|
|
524
|
+
// `world · T · R · S` is identical either way.
|
|
525
|
+
const worldMatrix = this.geodetic
|
|
526
|
+
? this.resolveGeodeticFrame(this.geodetic)
|
|
527
|
+
: this.matrixWorld;
|
|
528
|
+
|
|
529
|
+
if (worldMatrix) {
|
|
355
530
|
this.raw.matrixAutoUpdate = false;
|
|
356
531
|
this.raw.matrixWorldAutoUpdate = false;
|
|
357
532
|
if (hasLocal) {
|
|
358
533
|
this.raw.matrixWorld.multiplyMatrices(
|
|
359
|
-
|
|
534
|
+
worldMatrix,
|
|
360
535
|
this.composeLocalTransform(),
|
|
361
536
|
);
|
|
362
537
|
} else {
|
|
363
|
-
this.raw.matrixWorld.copy(
|
|
538
|
+
this.raw.matrixWorld.copy(worldMatrix);
|
|
364
539
|
}
|
|
365
540
|
this.raw.updateMatrixWorld();
|
|
366
541
|
return;
|
|
@@ -404,16 +579,50 @@ export abstract class MeshDesc<
|
|
|
404
579
|
}
|
|
405
580
|
|
|
406
581
|
onUpdateConfig(updates: UpdateConfig): void {
|
|
582
|
+
// Validate the PROSPECTIVE transform state before mutating anything: a
|
|
583
|
+
// throw has to leave the Descriptor exactly as it was. Assigning first
|
|
584
|
+
// and checking after would brick the instance — the guard would then fire
|
|
585
|
+
// on every later call, including unrelated ones like `{ visible }`, and
|
|
586
|
+
// the `!== undefined` update convention can never reset a field back to
|
|
587
|
+
// `undefined`, so there would be no way to recover.
|
|
588
|
+
const nextGeodetic =
|
|
589
|
+
updates.geodetic !== undefined
|
|
590
|
+
? mergeGeodetic(this.geodetic, updates.geodetic)
|
|
591
|
+
: this.geodetic;
|
|
592
|
+
const nextMatrix =
|
|
593
|
+
updates.matrix !== undefined ? updates.matrix : this.matrix;
|
|
594
|
+
const nextMatrixWorld =
|
|
595
|
+
updates.matrixWorld !== undefined
|
|
596
|
+
? updates.matrixWorld
|
|
597
|
+
: this.matrixWorld;
|
|
598
|
+
assertNoTransformConflict(nextGeodetic, nextMatrix, nextMatrixWorld);
|
|
599
|
+
|
|
407
600
|
super.onUpdateConfig(updates);
|
|
408
601
|
invariant(this.raw);
|
|
409
602
|
|
|
410
603
|
const spatialChanged =
|
|
411
604
|
updates.matrix !== undefined ||
|
|
412
605
|
updates.matrixWorld !== undefined ||
|
|
606
|
+
updates.geodetic !== undefined ||
|
|
413
607
|
updates.position !== undefined ||
|
|
414
608
|
updates.scale !== undefined ||
|
|
415
609
|
updates.rotation !== undefined;
|
|
416
610
|
|
|
611
|
+
if (updates.geodetic !== undefined) {
|
|
612
|
+
const prevGeodetic = this.geodetic;
|
|
613
|
+
this.geodetic = nextGeodetic;
|
|
614
|
+
|
|
615
|
+
// Only these three change what we sample; heading/pitch/roll/scale
|
|
616
|
+
// leave the sampled position and the reference untouched.
|
|
617
|
+
const resubscribe =
|
|
618
|
+
nextGeodetic?.lng !== prevGeodetic?.lng ||
|
|
619
|
+
nextGeodetic?.lat !== prevGeodetic?.lat ||
|
|
620
|
+
nextGeodetic?.heightReference !== prevGeodetic?.heightReference;
|
|
621
|
+
if (resubscribe) {
|
|
622
|
+
this.subscribeTerrainHeight();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
417
626
|
if (updates.matrix !== undefined) this.matrix = updates.matrix;
|
|
418
627
|
if (updates.matrixWorld !== undefined)
|
|
419
628
|
this.matrixWorld = updates.matrixWorld;
|
|
@@ -434,7 +643,7 @@ export abstract class MeshDesc<
|
|
|
434
643
|
// so subclasses that strip a field from `updates` (e.g. GLTFModelLayer
|
|
435
644
|
// keeping `raw.position` at 0 for its RTE shader) can still opt out
|
|
436
645
|
// of having the base class copy it onto `raw`.
|
|
437
|
-
if (this.matrixWorld || this.matrix) {
|
|
646
|
+
if (this.geodetic || this.matrixWorld || this.matrix) {
|
|
438
647
|
this.applyTransform();
|
|
439
648
|
} else {
|
|
440
649
|
if (updates.position !== undefined) {
|
|
@@ -468,6 +677,8 @@ export abstract class MeshDesc<
|
|
|
468
677
|
}
|
|
469
678
|
|
|
470
679
|
onDestroy(): void {
|
|
680
|
+
this.unsubscribeTerrainHeight();
|
|
681
|
+
|
|
471
682
|
if (this.raw && this.raw.parent) {
|
|
472
683
|
this.raw.parent.remove(this.raw);
|
|
473
684
|
}
|
package/src/core/errors.ts
CHANGED
|
@@ -14,3 +14,17 @@ export class UnknownTypeError extends Error {
|
|
|
14
14
|
);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Raised when a config or update names more than one of `geodetic` / `matrix` /
|
|
20
|
+
* `matrixWorld`. They share a single placement slot, so the message is
|
|
21
|
+
* deliberately symmetric — neither argument order implies a winner.
|
|
22
|
+
*/
|
|
23
|
+
export class ConflictingTransformError extends Error {
|
|
24
|
+
constructor(field: string, conflictsWith: string) {
|
|
25
|
+
super(
|
|
26
|
+
`\`${field}\` cannot be combined with \`${conflictsWith}\`: both define the object's placement. ` +
|
|
27
|
+
`Set only one of them — use \`geodetic\` for geographic placement, or \`matrix\` / \`matrixWorld\` to supply a frame yourself.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -99,6 +99,11 @@ export type { GeometryType };
|
|
|
99
99
|
* Information about a feature batch, passed to evaluation callbacks.
|
|
100
100
|
*/
|
|
101
101
|
export type FeatureInfo = {
|
|
102
|
+
/**
|
|
103
|
+
* The feature's canonical global batch id (its first instance's id when a
|
|
104
|
+
* feature spans several instances, e.g. MultiPoint). Pick events report the
|
|
105
|
+
* same canonical id, so picks and evaluations correlate through this field.
|
|
106
|
+
*/
|
|
102
107
|
batchId: number;
|
|
103
108
|
properties: Record<string, unknown> | undefined;
|
|
104
109
|
layerId: LayerId | undefined;
|
|
@@ -431,7 +436,7 @@ export class FeatureEvaluator {
|
|
|
431
436
|
) {
|
|
432
437
|
const evaluate = (info: FeatureInfo, batchIndex: number) => {
|
|
433
438
|
const evaluated = f(info);
|
|
434
|
-
this.applyEvaluatedValues(batchIndex,
|
|
439
|
+
this.applyEvaluatedValues(batchIndex, evaluated);
|
|
435
440
|
};
|
|
436
441
|
|
|
437
442
|
if (options?.filters) {
|
|
@@ -443,30 +448,32 @@ export class FeatureEvaluator {
|
|
|
443
448
|
|
|
444
449
|
private applyEvaluatedValues(
|
|
445
450
|
batchIndex: number,
|
|
446
|
-
batchId: number,
|
|
447
451
|
evaluated: Partial<EvaluatedValue>,
|
|
448
452
|
) {
|
|
449
453
|
const obj = this.obj;
|
|
450
454
|
|
|
455
|
+
// Sprites apply by batch index: a feature can own several instances
|
|
456
|
+
// (MultiPoint, points derived from line/polygon vertices), so a
|
|
457
|
+
// batch-id keyed lookup would style only one of them.
|
|
451
458
|
if (obj instanceof InstancedSpriteMesh) {
|
|
452
459
|
if (evaluated.color != null) {
|
|
453
|
-
obj.
|
|
460
|
+
obj.setFeatureColorByBatchIndex(batchIndex, evaluated.color.raw);
|
|
454
461
|
}
|
|
455
462
|
if (evaluated.show != null) {
|
|
456
|
-
obj.
|
|
463
|
+
obj.setFeatureShowByBatchIndex(batchIndex, evaluated.show);
|
|
457
464
|
}
|
|
458
465
|
if (evaluated.height != null) {
|
|
459
|
-
obj.
|
|
466
|
+
obj.setFeatureHeightByBatchIndex(batchIndex, evaluated.height);
|
|
460
467
|
}
|
|
461
468
|
if (evaluated.size != null) {
|
|
462
|
-
obj.
|
|
469
|
+
obj.setFeatureSizeByBatchIndex(batchIndex, evaluated.size);
|
|
463
470
|
}
|
|
464
471
|
if (evaluated.opacity != null) {
|
|
465
|
-
obj.
|
|
472
|
+
obj.setFeatureOpacityByBatchIndex(batchIndex, evaluated.opacity);
|
|
466
473
|
}
|
|
467
474
|
if (evaluated.declutterPriority != null) {
|
|
468
|
-
obj.
|
|
469
|
-
|
|
475
|
+
obj.setFeatureDeclutterPriorityByBatchIndex(
|
|
476
|
+
batchIndex,
|
|
470
477
|
evaluated.declutterPriority,
|
|
471
478
|
);
|
|
472
479
|
}
|
|
@@ -474,7 +481,7 @@ export class FeatureEvaluator {
|
|
|
474
481
|
// Async by nature (the image may need fetching); the atlas dedupes
|
|
475
482
|
// loads by URL so evaluating many features costs one fetch per image.
|
|
476
483
|
// A nullish url clears the override back to the material default.
|
|
477
|
-
void obj.
|
|
484
|
+
void obj.setFeatureImageByBatchIndex(batchIndex, evaluated.image);
|
|
478
485
|
}
|
|
479
486
|
return;
|
|
480
487
|
}
|
package/src/event/context.ts
CHANGED
|
@@ -189,6 +189,9 @@ export type MeshHandler = {
|
|
|
189
189
|
setTileMeshPrepared: (handle: bigint) => void;
|
|
190
190
|
};
|
|
191
191
|
|
|
192
|
+
/** Settles the pending `flyTo` promise registered under `id`. */
|
|
193
|
+
export type FlightHandler = (id: number, completed: boolean) => void;
|
|
194
|
+
|
|
192
195
|
export type LayerHandler = {
|
|
193
196
|
getLayerIndex: (layerId: string) => number | undefined;
|
|
194
197
|
};
|
|
@@ -216,6 +219,7 @@ type EventContextArgs = {
|
|
|
216
219
|
viewEvents: EventHandler<ViewEvents>;
|
|
217
220
|
layersManager: LayersManager;
|
|
218
221
|
viewContext: ViewContext;
|
|
222
|
+
flightHandler: FlightHandler;
|
|
219
223
|
layerHandler?: LayerHandler;
|
|
220
224
|
fontManager?: FontManager;
|
|
221
225
|
textureFragmentIndex?: Map<string, Set<TextureSlot>>;
|
|
@@ -251,6 +255,7 @@ export class EventContext {
|
|
|
251
255
|
readonly viewEvents: EventHandler<ViewEvents>;
|
|
252
256
|
readonly layersManager: LayersManager;
|
|
253
257
|
readonly viewContext: ViewContext;
|
|
258
|
+
readonly flightHandler: FlightHandler;
|
|
254
259
|
readonly layerHandler?: LayerHandler;
|
|
255
260
|
readonly fontManager?: FontManager;
|
|
256
261
|
readonly textureFragmentIndex?: Map<string, Set<TextureSlot>>;
|
|
@@ -286,6 +291,7 @@ export class EventContext {
|
|
|
286
291
|
this.viewEvents = args.viewEvents;
|
|
287
292
|
this.layersManager = args.layersManager;
|
|
288
293
|
this.viewContext = args.viewContext;
|
|
294
|
+
this.flightHandler = args.flightHandler;
|
|
289
295
|
this.layerHandler = args.layerHandler;
|
|
290
296
|
this.fontManager = args.fontManager;
|
|
291
297
|
this.textureFragmentIndex = args.textureFragmentIndex;
|
package/src/event/feature.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
PolygonMesh,
|
|
15
15
|
PolylineMesh,
|
|
16
16
|
} from "../mesh";
|
|
17
|
-
import { FEATURE_RENDER_ORDER } from "../renderOrder";
|
|
17
|
+
import { FEATURE_RENDER_ORDER, STROKE_RENDER_ORDER } from "../renderOrder";
|
|
18
18
|
import type { Scenes } from "../scene";
|
|
19
19
|
|
|
20
20
|
import type { EventContext } from "./context";
|
|
@@ -190,7 +190,20 @@ export async function processRenderableFeatureAdded(
|
|
|
190
190
|
setTransform(obj, transform);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
// Strokes draw after fills: sibling meshes are created asynchronously in
|
|
194
|
+
// arbitrary order, and a fill drawn after a coplanar stroke either paints
|
|
195
|
+
// over it (bake scene) or z-fights against its depth (MRT scene), so the
|
|
196
|
+
// stacking must come from renderOrder, not insertion order. Only polylines
|
|
197
|
+
// stroking polygon rings sit on a sibling fill; standalone line strokes
|
|
198
|
+
// keep the base order — elevated, they would deterministically composite
|
|
199
|
+
// over every marker and outline they merely cross (PolylineMesh skips the
|
|
200
|
+
// depth test in the MRT scene).
|
|
201
|
+
const strokesSiblingFills =
|
|
202
|
+
polyline?.material.geometryTypes?.includes("polygon") ?? false;
|
|
203
|
+
obj.renderOrder =
|
|
204
|
+
obj instanceof PolylineMesh && strokesSiblingFills
|
|
205
|
+
? STROKE_RENDER_ORDER
|
|
206
|
+
: FEATURE_RENDER_ORDER;
|
|
194
207
|
|
|
195
208
|
// Add to MRT scene if not draped (draped features render to texturized scene)
|
|
196
209
|
const isDraped =
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { BillboardMesh as NavaraBillboardMesh } from "@navaramap/engine";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { EventContext } from "../context";
|
|
5
|
+
|
|
6
|
+
import { renderBillboard } from "./billboard";
|
|
7
|
+
|
|
8
|
+
const init = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
const setActive = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock("../../mesh", () => ({
|
|
12
|
+
InstancedSpriteMesh: class {
|
|
13
|
+
_init = init;
|
|
14
|
+
setActive = setActive;
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("renderBillboard", () => {
|
|
19
|
+
const ctx = {} as EventContext;
|
|
20
|
+
|
|
21
|
+
// `url` is omitted from the material entirely when not passed, mirroring the
|
|
22
|
+
// engine's `string | undefined` accessor. The engine itself currently always
|
|
23
|
+
// sends a string (`BillboardMaterial::url` is a `String` defaulting to `""`,
|
|
24
|
+
// converted as `Some(url)`), so `""` is the shape a real url-less layer
|
|
25
|
+
// produces — both are covered below.
|
|
26
|
+
const createMesh = (url?: string) =>
|
|
27
|
+
({
|
|
28
|
+
material: url === undefined ? {} : { url },
|
|
29
|
+
active: true,
|
|
30
|
+
}) as unknown as NavaraBillboardMesh;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
init.mockClear();
|
|
34
|
+
setActive.mockClear();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("creates a mesh when the material carries a default image", async () => {
|
|
38
|
+
const obj = await renderBillboard(ctx, createMesh("marker.png"));
|
|
39
|
+
|
|
40
|
+
expect(obj).toBeDefined();
|
|
41
|
+
expect(init).toHaveBeenCalledOnce();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Regression: the mesh used to be skipped when the material had no `url`,
|
|
45
|
+
// so a layer that styles every feature through a FeatureEvaluator
|
|
46
|
+
// (`{ image }`) never got a mesh — and never a `featureCreated` event to
|
|
47
|
+
// attach the evaluator to. Empty atlas rects keep such instances invisible
|
|
48
|
+
// until an image is packed, so the mesh is safe to create eagerly.
|
|
49
|
+
it("creates a mesh when the material's default image is empty", async () => {
|
|
50
|
+
const obj = await renderBillboard(ctx, createMesh(""));
|
|
51
|
+
|
|
52
|
+
expect(obj).toBeDefined();
|
|
53
|
+
expect(init).toHaveBeenCalledOnce();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("creates a mesh when the material has no `url` at all", async () => {
|
|
57
|
+
const obj = await renderBillboard(ctx, createMesh());
|
|
58
|
+
|
|
59
|
+
expect(obj).toBeDefined();
|
|
60
|
+
expect(init).toHaveBeenCalledOnce();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -8,8 +8,10 @@ export async function renderBillboard(
|
|
|
8
8
|
ctx: EventContext,
|
|
9
9
|
m: NavaraBillboardMesh,
|
|
10
10
|
) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// No `url` check: a billboard layer may carry no material-level image and
|
|
12
|
+
// get every image per-feature from a FeatureEvaluator (`{ image }`). The
|
|
13
|
+
// mesh starts with empty atlas rects, which the shader culls, so instances
|
|
14
|
+
// stay invisible until an image is packed for them.
|
|
13
15
|
const mesh = new InstancedSpriteMesh({
|
|
14
16
|
renderOrder: FEATURE_RENDER_ORDER,
|
|
15
17
|
ctx,
|
package/src/event/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ export type {
|
|
|
49
49
|
FeatureHandler,
|
|
50
50
|
MeshHandler,
|
|
51
51
|
LayerHandler,
|
|
52
|
+
FlightHandler,
|
|
52
53
|
} from "./context";
|
|
53
54
|
export { EventContext } from "./context";
|
|
54
55
|
export { HillshadeContext } from "./HillshadeContext";
|
|
@@ -73,6 +74,10 @@ export function processEvent(ctx: EventContext, event: Events | undefined) {
|
|
|
73
74
|
processCameraFrustumUpdated(ctx, ev),
|
|
74
75
|
);
|
|
75
76
|
|
|
77
|
+
eventManager.forEachStack("camera_flight_ended", (ev) =>
|
|
78
|
+
ctx.flightHandler(ev.id, ev.completed),
|
|
79
|
+
);
|
|
80
|
+
|
|
76
81
|
eventManager.forEachStack("object_transform_updated", (ev) =>
|
|
77
82
|
processObjectTransformUpdated(ctx, ev),
|
|
78
83
|
);
|
|
@@ -318,6 +323,8 @@ function processCameraFrustumUpdated(
|
|
|
318
323
|
|
|
319
324
|
ctx.camera.raw.near = frustum.near;
|
|
320
325
|
ctx.camera.raw.far = frustum.far;
|
|
326
|
+
// The engine's `fov` is the vertical fov, matching what
|
|
327
|
+
// `PerspectiveCamera.fov` expects.
|
|
321
328
|
ctx.camera.raw.fov = radianToDegree(frustum.fov);
|
|
322
329
|
ctx.camera.raw.updateProjectionMatrix();
|
|
323
330
|
ctx.camera.emit("frustumChanged");
|