@navaramap/three 0.0.0 → 0.0.2

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 (66) hide show
  1. package/README.md +70 -0
  2. package/dist/assets/fontWorker-8oWvyvwL.js +2 -0
  3. package/dist/assets/fontWorker-8oWvyvwL.js.map +1 -0
  4. package/dist/assets/index-D9G7kIEj.js +25 -0
  5. package/dist/assets/index-D9G7kIEj.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-665OLxjy.wasm → navara_wasm_font_worker_bg-CkZP9l2i.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-BXhKC5bE.wasm → navara_wasm_worker_bg-Q8zrkLCR.wasm} +0 -0
  8. package/dist/draco_decoder.wasm +0 -0
  9. package/dist/draco_decoder2.wasm +0 -0
  10. package/dist/index.d.ts +587 -67
  11. package/dist/index.js +5640 -4944
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +238 -211
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +35 -34
  16. package/src/core/EffectDesc.ts +2 -4
  17. package/src/core/MeshDesc.ts +1 -2
  18. package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
  19. package/src/declutter/DeclutterManager.test.ts +235 -0
  20. package/src/declutter/DeclutterManager.ts +256 -0
  21. package/src/declutter/index.ts +7 -0
  22. package/src/declutter/kernel.ts +46 -0
  23. package/src/declutter/types.ts +61 -0
  24. package/src/declutter/wasmKernel.ts +13 -0
  25. package/src/device.ts +18 -4
  26. package/src/evaluations/FeatureEvaluator.ts +21 -0
  27. package/src/event/context.ts +17 -0
  28. package/src/event/features/modelGpuBytes.ts +1 -3
  29. package/src/event/hillshade.test.ts +7 -13
  30. package/src/event/index.ts +2 -3
  31. package/src/event/tileHandler.ts +54 -0
  32. package/src/index.ts +143 -33
  33. package/src/material/enhancer/ShaderReplacer.ts +1 -2
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +6 -0
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +11 -0
  36. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
  37. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +19 -4
  38. package/src/material/enhancer/tileComposite/tileElevationHeatmapEnhancer/shader.ts +9 -2
  39. package/src/mesh/batchTexture.ts +2 -4
  40. package/src/mesh/batchedSdfText.ts +61 -1
  41. package/src/mesh/sdfText.ts +170 -5
  42. package/src/mesh/sprite/instancedSprite.ts +245 -3
  43. package/src/mesh/tile/drapeResolver.ts +76 -0
  44. package/src/mesh/{tile.test.ts → tile/index.test.ts} +16 -6
  45. package/src/mesh/{tile.ts → tile/index.ts} +124 -392
  46. package/src/mesh/tile/rasterDrapeResolver.ts +423 -0
  47. package/src/mesh/tile/vectorDrapeResolver.ts +404 -0
  48. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  49. package/src/passes/CustomRenderPass.ts +21 -5
  50. package/src/passes/RenderTargetCopyPass.ts +24 -2
  51. package/src/test-utils/engine.ts +53 -0
  52. package/src/tileTexture/TileTextureCache.test.ts +2 -0
  53. package/src/tileTexture/TileTextureCache.ts +1 -0
  54. package/src/tileTexture/TileTextureCompositor.test.ts +107 -0
  55. package/src/tileTexture/TileTextureCompositor.ts +248 -32
  56. package/src/tileTexture/__snapshots__/CompositeShader.snapshot.test.ts.snap +36 -16
  57. package/src/tileTexture/types.ts +7 -0
  58. package/src/utils/demNoDataColor.test.ts +47 -0
  59. package/src/utils/demNoDataColor.ts +48 -0
  60. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  61. package/src/utils/fixedGpuFootprint.ts +162 -0
  62. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  63. package/dist/assets/fontWorker-CEJEfRpb.js +0 -2
  64. package/dist/assets/fontWorker-CEJEfRpb.js.map +0 -1
  65. package/dist/assets/index-BUB-ijMO.js +0 -12
  66. package/dist/assets/index-BUB-ijMO.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.0",
4
+ "version": "0.0.2",
5
5
  "main": "dist/index.umd.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "license": "MIT OR Apache-2.0",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://github.com/eukarya-inc/navara.git",
15
+ "url": "git+https://github.com/reearth/navara.git",
16
16
  "directory": "web/navara_three"
17
17
  },
18
18
  "publishConfig": {
@@ -30,50 +30,51 @@
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.0",
34
- "@navaramap/worker": "0.0.0",
35
- "@navaramap/engine": "0.0.0",
36
- "@navaramap/font": "0.0.0",
37
- "@navaramap/engine-api": "0.0.0",
38
- "@navaramap/three_csm": "0.0.0",
39
- "@navaramap/three_api": "0.0.0"
33
+ "@navaramap/font": "0.0.2",
34
+ "@navaramap/core": "0.0.2",
35
+ "@navaramap/engine-api": "0.0.2",
36
+ "@navaramap/engine": "0.0.2",
37
+ "@navaramap/three_api": "0.0.2",
38
+ "@navaramap/three_csm": "0.0.2",
39
+ "@navaramap/worker": "0.0.2"
40
40
  },
41
41
  "devDependencies": {
42
- "@playwright/test": "1.59.1",
43
- "@radix-ui/react-collapsible": "1.1.12",
44
- "@radix-ui/react-label": "2.1.8",
45
- "@radix-ui/react-select": "2.2.6",
46
- "@radix-ui/react-separator": "1.1.8",
47
- "@radix-ui/react-slider": "1.3.6",
48
- "@radix-ui/react-slot": "1.2.4",
49
- "@radix-ui/react-switch": "1.2.6",
50
- "@tailwindcss/postcss": "4.2.2",
42
+ "@playwright/test": "1.61.1",
43
+ "@radix-ui/react-collapsible": "1.1.17",
44
+ "@radix-ui/react-label": "2.1.12",
45
+ "@radix-ui/react-select": "2.3.4",
46
+ "@radix-ui/react-separator": "1.1.12",
47
+ "@radix-ui/react-slider": "1.4.4",
48
+ "@radix-ui/react-slot": "1.3.0",
49
+ "@radix-ui/react-switch": "1.3.4",
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
54
  "@types/stats.js": "0.17.4",
54
- "@types/three": "0.184.0",
55
+ "@types/three": "0.185.1",
55
56
  "ajv": "8.20.0",
56
57
  "class-variance-authority": "0.7.1",
57
58
  "clsx": "2.1.1",
58
- "dayjs": "1.11.20",
59
- "lucide-react": "1.14.0",
60
- "playwright": "1.59.1",
61
- "postcss": "8.5.9",
62
- "postprocessing": "6.38.3",
63
- "sharp": "0.34.5",
59
+ "dayjs": "1.11.21",
60
+ "lucide-react": "1.25.0",
61
+ "playwright": "1.61.1",
62
+ "postcss": "8.5.20",
63
+ "postprocessing": "6.39.3",
64
+ "sharp": "0.35.3",
64
65
  "shiki": "4.3.1",
65
- "tailwind-merge": "3.5.0",
66
- "tailwindcss": "4.2.2",
66
+ "tailwind-merge": "3.6.0",
67
+ "tailwindcss": "4.3.3",
67
68
  "tailwindcss-animate": "1.0.7",
68
- "three": "0.184.0",
69
- "tsx": "4.21.0",
69
+ "three": "0.185.1",
70
+ "tsx": "4.23.1",
70
71
  "tweakpane": "4.0.5",
71
72
  "vite-plugin-glsl": "1.6.0",
72
- "vite-plugin-static-copy": "4.1.0",
73
- "@navaramap/three_default_descs": "0.0.0",
74
- "@navaramap/three_default_plugin": "0.0.0",
75
- "@navaramap/three_plugins": "0.0.0",
76
- "@navaramap/three_react": "0.0.0"
73
+ "vite-plugin-static-copy": "4.1.1",
74
+ "@navaramap/three_default_descs": "0.0.2",
75
+ "@navaramap/three_default_plugin": "0.0.2",
76
+ "@navaramap/three_plugins": "0.0.2",
77
+ "@navaramap/three_react": "0.0.2"
77
78
  },
78
79
  "scripts": {
79
80
  "start": "vite",
@@ -16,8 +16,7 @@ import {
16
16
  import type { ViewContext } from "./ViewContext";
17
17
 
18
18
  type EffectInstance =
19
- | PostProcessingPass
20
- | Pass<PostProcessingPass, PostProcessingEffect>;
19
+ PostProcessingPass | Pass<PostProcessingPass, PostProcessingEffect>;
21
20
 
22
21
  export type EffectConfig = {
23
22
  type?: "effect";
@@ -183,8 +182,7 @@ export abstract class EffectDesc<
183
182
  Config extends EffectConfig = EffectConfig,
184
183
  UpdateConfig extends EffectUpdate = EffectUpdate,
185
184
  InstanceObj extends EffectInstance | { raw: EffectInstance } =
186
- | EffectInstance
187
- | { raw: EffectInstance },
185
+ EffectInstance | { raw: EffectInstance },
188
186
  Instance extends EffectBaseInstance<InstanceObj> =
189
187
  EffectBaseInstance<InstanceObj>,
190
188
  > extends BaseDesc<Config, UpdateConfig, Instance> {
@@ -204,8 +204,7 @@ export abstract class MeshDesc<
204
204
  Config extends MeshConfig = MeshConfig,
205
205
  UpdateConfig extends MeshUpdate = MeshUpdate,
206
206
  InstanceObj extends Object3D | { raw: Object3D } =
207
- | Object3D
208
- | { raw: Object3D },
207
+ Object3D | { raw: Object3D },
209
208
  CustomEvent extends BaseEventMap = BaseEventMap,
210
209
  Instance extends MeshBaseInstance<InstanceObj> =
211
210
  MeshBaseInstance<InstanceObj>,
@@ -26,8 +26,7 @@ export abstract class MeshDescWithSelectiveEffect<
26
26
  UpdateConfig extends MeshUpdateWithSelectiveEffect =
27
27
  MeshUpdateWithSelectiveEffect,
28
28
  InstanceObj extends Object3D | { raw: Object3D } =
29
- | Object3D
30
- | { raw: Object3D },
29
+ Object3D | { raw: Object3D },
31
30
  CustomEvent extends BaseEventMap = BaseEventMap,
32
31
  Instance extends MeshBaseInstance<InstanceObj> =
33
32
  MeshBaseInstance<InstanceObj>,
@@ -0,0 +1,235 @@
1
+ import { PerspectiveCamera } from "three";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { DeclutterManager } from "./DeclutterManager";
5
+ import { CANDIDATE_STRIDE, type DeclutterKernel } from "./kernel";
6
+ import type { DeclutterCandidate, DeclutterParticipant } from "./types";
7
+
8
+ const R = 6378137.0;
9
+
10
+ /** Camera one earth-radius above (R, 0, 0), looking at the surface. */
11
+ function makeCamera(): PerspectiveCamera {
12
+ const camera = new PerspectiveCamera(60, 800 / 600, 1, 1e9);
13
+ camera.position.set(2 * R, 0, 0);
14
+ camera.up.set(0, 0, 1);
15
+ camera.lookAt(R, 0, 0);
16
+ camera.updateMatrixWorld(true);
17
+ return camera;
18
+ }
19
+
20
+ /**
21
+ * Placement kernel stub. The real projection/grid/placement is Rust and is
22
+ * covered by the crate's tests; here we only need to drive the manager's
23
+ * orchestration, so the stub records what it was handed and returns a
24
+ * caller-controlled hidden pattern.
25
+ */
26
+ class StubKernel implements DeclutterKernel {
27
+ calls: { candidates: Float64Array; n: number }[] = [];
28
+ /** Override to control which candidates come back hidden. Default: all shown. */
29
+ hiddenFor: (n: number) => Uint8Array = (n) => new Uint8Array(n);
30
+
31
+ place(candidates: Float64Array): Uint8Array {
32
+ const n = candidates.length / CANDIDATE_STRIDE;
33
+ this.calls.push({ candidates: candidates.slice(), n });
34
+ return this.hiddenFor(n);
35
+ }
36
+ }
37
+
38
+ class FakeParticipant implements DeclutterParticipant {
39
+ hidden = new Map<number, boolean>();
40
+ /** How many more stepDeclutterFade calls report an active fade. */
41
+ fadeStepsLeft = 0;
42
+ fadeDeltas: number[] = [];
43
+
44
+ constructor(readonly candidates: Omit<DeclutterCandidate, "owner">[]) {}
45
+
46
+ collectDeclutterCandidates(out: DeclutterCandidate[]): void {
47
+ for (const c of this.candidates) out.push({ ...c, owner: this });
48
+ }
49
+
50
+ applyDeclutter(handle: number, hidden: boolean): void {
51
+ this.hidden.set(handle, hidden);
52
+ }
53
+
54
+ stepDeclutterFade(deltaMs: number): boolean {
55
+ this.fadeDeltas.push(deltaMs);
56
+ if (this.fadeStepsLeft <= 0) return false;
57
+ this.fadeStepsLeft--;
58
+ return true;
59
+ }
60
+ }
61
+
62
+ function label(
63
+ partial: Partial<Omit<DeclutterCandidate, "owner">>,
64
+ ): Omit<DeclutterCandidate, "owner"> {
65
+ return {
66
+ anchorX: R,
67
+ anchorY: 0,
68
+ anchorZ: 0,
69
+ addHeight: 0,
70
+ minX: -10,
71
+ maxX: 10,
72
+ minY: -10,
73
+ maxY: 10,
74
+ sizeInMeters: false,
75
+ priority: 0,
76
+ isShown: false,
77
+ handle: 0,
78
+ ...partial,
79
+ };
80
+ }
81
+
82
+ describe("DeclutterManager", () => {
83
+ it("packs candidates for the kernel and applies the returned hidden flags", () => {
84
+ const kernel = new StubKernel();
85
+ // Return the second candidate hidden.
86
+ kernel.hiddenFor = (n) => {
87
+ const flags = new Uint8Array(n);
88
+ flags[1] = 1;
89
+ return flags;
90
+ };
91
+ const manager = new DeclutterManager(kernel);
92
+ const p = new FakeParticipant([
93
+ label({
94
+ handle: 0,
95
+ priority: 3,
96
+ isShown: true,
97
+ minX: -4,
98
+ sizeInMeters: true,
99
+ }),
100
+ label({ handle: 1, priority: 1 }),
101
+ ]);
102
+ manager.register(p);
103
+
104
+ expect(manager.update(makeCamera(), 800, 600, 0)).toBe("ran");
105
+
106
+ // Results applied by handle, in candidate order.
107
+ expect(p.hidden.get(0)).toBe(false);
108
+ expect(p.hidden.get(1)).toBe(true);
109
+
110
+ // Candidate 0 was packed into the stride layout the Rust kernel expects.
111
+ const { candidates } = kernel.calls[0];
112
+ expect(candidates.length).toBe(2 * CANDIDATE_STRIDE);
113
+ expect(candidates[0]).toBe(R); // anchorX
114
+ expect(candidates[4]).toBe(-4); // minX
115
+ expect(candidates[8]).toBe(1); // sizeInMeters -> 1
116
+ expect(candidates[9]).toBe(3); // priority
117
+ expect(candidates[10]).toBe(1); // isShown -> 1
118
+ });
119
+
120
+ it("throttles re-runs and reports them so the caller can schedule a frame", () => {
121
+ const manager = new DeclutterManager(new StubKernel());
122
+ const p = new FakeParticipant([label({ handle: 0 })]);
123
+ manager.register(p);
124
+ const camera = makeCamera();
125
+
126
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
127
+ expect(manager.update(camera, 800, 600, 50)).toBe("idle");
128
+
129
+ manager.markDirty();
130
+ expect(manager.update(camera, 800, 600, 50)).toBe("throttled");
131
+ expect(
132
+ manager.update(camera, 800, 600, DeclutterManager.MIN_INTERVAL_MS),
133
+ ).toBe("ran");
134
+ });
135
+
136
+ it("re-runs when the camera moves and stays idle when nothing changed", () => {
137
+ const manager = new DeclutterManager(new StubKernel());
138
+ const p = new FakeParticipant([label({ handle: 0 })]);
139
+ manager.register(p);
140
+ const camera = makeCamera();
141
+
142
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
143
+ expect(manager.update(camera, 800, 600, 1000)).toBe("idle");
144
+
145
+ camera.position.z += 1000;
146
+ camera.updateMatrixWorld(true);
147
+ expect(manager.update(camera, 800, 600, 2000)).toBe("ran");
148
+
149
+ // A viewport resize also invalidates placement.
150
+ expect(manager.update(camera, 1024, 768, 3000)).toBe("ran");
151
+ });
152
+
153
+ it("reports 'animating' while fades are active, then settles", () => {
154
+ const manager = new DeclutterManager(new StubKernel());
155
+ const p = new FakeParticipant([label({ handle: 0 })]);
156
+ manager.register(p);
157
+ const camera = makeCamera();
158
+
159
+ // The pass that (re)targets labels also starts their fade.
160
+ p.fadeStepsLeft = 2;
161
+ expect(manager.update(camera, 800, 600, 0)).toBe("animating");
162
+ expect(manager.update(camera, 800, 600, 16)).toBe("animating");
163
+ expect(manager.update(camera, 800, 600, 32)).toBe("idle");
164
+ });
165
+
166
+ it("clamps fade steps after an idle gap", () => {
167
+ const manager = new DeclutterManager(new StubKernel());
168
+ const p = new FakeParticipant([label({ handle: 0 })]);
169
+ manager.register(p);
170
+ const camera = makeCamera();
171
+
172
+ manager.update(camera, 800, 600, 0);
173
+ // 10s of engine idle must not advance a new fade by 10s worth.
174
+ manager.update(camera, 800, 600, 10_000);
175
+ for (const delta of p.fadeDeltas) {
176
+ expect(delta).toBeLessThanOrEqual(DeclutterManager.MAX_FADE_STEP_MS);
177
+ }
178
+ });
179
+
180
+ it("prefers 'animating' over 'throttled' so pending passes still settle", () => {
181
+ const manager = new DeclutterManager(new StubKernel());
182
+ const p = new FakeParticipant([label({ handle: 0 })]);
183
+ manager.register(p);
184
+ const camera = makeCamera();
185
+
186
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
187
+ manager.markDirty();
188
+ p.fadeStepsLeft = 1;
189
+ // Dirty + inside the throttle window + fading: animating wins because its
190
+ // prompt follow-ups re-enter update, which runs the pass once due.
191
+ expect(manager.update(camera, 800, 600, 50)).toBe("animating");
192
+ expect(
193
+ manager.update(camera, 800, 600, DeclutterManager.MIN_INTERVAL_MS),
194
+ ).toBe("ran");
195
+ });
196
+
197
+ it("goes idle when the last participant unregisters", () => {
198
+ const manager = new DeclutterManager(new StubKernel());
199
+ const p = new FakeParticipant([label({ handle: 0 })]);
200
+ manager.register(p);
201
+ const camera = makeCamera();
202
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
203
+
204
+ manager.unregister(p);
205
+ expect(manager.update(camera, 800, 600, 1000)).toBe("idle");
206
+ });
207
+
208
+ it("reports the remaining throttle window since the last pass, not the full constant", () => {
209
+ const manager = new DeclutterManager(new StubKernel());
210
+ const p = new FakeParticipant([label({ handle: 0 })]);
211
+ manager.register(p);
212
+ const camera = makeCamera();
213
+
214
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
215
+ manager.markDirty();
216
+ expect(manager.update(camera, 800, 600, 100)).toBe("throttled");
217
+ // 50ms of the 150ms window has already elapsed since the last pass.
218
+ expect(manager.remainingThrottleMs(100)).toBe(
219
+ DeclutterManager.MIN_INTERVAL_MS - 100,
220
+ );
221
+ // Never negative once the window has fully elapsed.
222
+ expect(manager.remainingThrottleMs(10_000)).toBe(0);
223
+ });
224
+
225
+ it("dispose() drops participants so a later update() is idle", () => {
226
+ const manager = new DeclutterManager(new StubKernel());
227
+ const p = new FakeParticipant([label({ handle: 0 })]);
228
+ manager.register(p);
229
+ const camera = makeCamera();
230
+ expect(manager.update(camera, 800, 600, 0)).toBe("ran");
231
+
232
+ manager.dispose();
233
+ expect(manager.update(camera, 800, 600, 1000)).toBe("idle");
234
+ });
235
+ });
@@ -0,0 +1,256 @@
1
+ import { Matrix4, type PerspectiveCamera } from "three";
2
+
3
+ import { CANDIDATE_STRIDE, type DeclutterKernel } from "./kernel";
4
+ import type { DeclutterCandidate, DeclutterParticipant } from "./types";
5
+
6
+ export type DeclutterUpdateResult =
7
+ /** Nothing to do: no participants, or camera and data unchanged. */
8
+ | "idle"
9
+ /** A placement pass ran; results are applied to the participants. */
10
+ | "ran"
11
+ /** Labels are mid-fade — the caller should schedule a prompt follow-up
12
+ * frame so the animation keeps stepping. */
13
+ | "animating"
14
+ /** A pass is due but the throttle window hasn't elapsed — the caller should
15
+ * schedule another frame so placement settles after the camera stops. */
16
+ | "throttled";
17
+
18
+ /**
19
+ * Shared screen-space label decluttering.
20
+ *
21
+ * Text batches and sprite meshes register as participants; each placement
22
+ * pass packs every candidate label into a flat buffer and hands it to the
23
+ * Rust {@link DeclutterKernel}, which projects the anchors, sorts by priority,
24
+ * and greedily claims screen space — losers are hidden through the meshes'
25
+ * declutter channels (`uDeclutterHide` uniform / `instanceDeclutterHide`
26
+ * attribute), which are separate from user-driven `show` state and free to
27
+ * toggle every pass.
28
+ *
29
+ * Placement is hysteretic: currently-shown labels win equal-priority ties
30
+ * and tolerate {@link DeclutterManager.HYSTERESIS_PX} of marginal overlap
31
+ * before eviction, so near-ties don't flicker while the camera drifts.
32
+ *
33
+ * The pass is view-dependent, so it runs from the render loop — but throttled:
34
+ * placement only re-runs when the camera or the label set actually changed,
35
+ * and at most once per {@link DeclutterManager.MIN_INTERVAL_MS}. Callers run
36
+ * it *before* rendering so results land in the same frame.
37
+ */
38
+ export class DeclutterManager {
39
+ /** Minimum time between placement passes (ms). */
40
+ static readonly MIN_INTERVAL_MS = 150;
41
+ /** Padding added around every collision box (px); two labels never sit
42
+ * closer than twice this. */
43
+ static readonly PADDING_PX = 2;
44
+ /** Largest single fade step (ms), so a frame after an idle gap doesn't
45
+ * swallow most of a fade in one jump. */
46
+ static readonly MAX_FADE_STEP_MS = 50;
47
+ /** Placement hysteresis (px): a currently-shown label's collision test box
48
+ * shrinks by this per side, so it survives marginal overlaps that camera
49
+ * drift toggles frame to frame instead of flickering. Its full box is
50
+ * still claimed against competitors. */
51
+ static readonly HYSTERESIS_PX = 6;
52
+
53
+ private _participants = new Set<DeclutterParticipant>();
54
+ /** Set when the label set changed (register/update/text change) so the next
55
+ * update re-places even with a static camera. */
56
+ private _dirty = true;
57
+
58
+ // Snapshot of the camera/viewport the last pass ran with.
59
+ private _prevCamera = new Float64Array(34).fill(Number.NaN);
60
+ private _lastRunAt = Number.NEGATIVE_INFINITY;
61
+ private _lastStepAt = Number.NaN;
62
+
63
+ // Reused per-pass scratch to keep steady-state allocations low.
64
+ private _candidates: DeclutterCandidate[] = [];
65
+ /** Packed candidate input for the kernel; grows as the label count does. */
66
+ private _input = new Float64Array(0);
67
+ private _viewMatrix = new Matrix4();
68
+ private _view16 = new Float64Array(16);
69
+ private _proj16 = new Float64Array(16);
70
+
71
+ /** @param _kernel the placement kernel — the Rust-backed
72
+ * `wasmDeclutterKernel` in production, a stub in unit tests. */
73
+ constructor(private readonly _kernel: DeclutterKernel) {}
74
+
75
+ /** Milliseconds until the throttle window since the last placement pass
76
+ * elapses, clamped to `[0, MIN_INTERVAL_MS]`. Callers use this to schedule
77
+ * a follow-up frame after a `"throttled"` result without overshooting the
78
+ * window by waiting out the full constant from now. */
79
+ remainingThrottleMs(nowMs: number): number {
80
+ return Math.max(
81
+ 0,
82
+ DeclutterManager.MIN_INTERVAL_MS - (nowMs - this._lastRunAt),
83
+ );
84
+ }
85
+
86
+ register(participant: DeclutterParticipant): void {
87
+ this._participants.add(participant);
88
+ this._dirty = true;
89
+ }
90
+
91
+ unregister(participant: DeclutterParticipant): void {
92
+ if (this._participants.delete(participant)) {
93
+ this._dirty = true;
94
+ }
95
+ }
96
+
97
+ /** Signal that candidates changed (text, positions, visibility, style) so
98
+ * the next update re-runs placement even if the camera is still. */
99
+ markDirty(): void {
100
+ this._dirty = true;
101
+ }
102
+
103
+ /**
104
+ * Run a placement pass if one is due, then advance any active show/hide
105
+ * fades. `widthPx`/`heightPx` are the viewport in CSS pixels (matching the
106
+ * `uScreenHeightPx` uniform); `nowMs` is the frame timestamp used for
107
+ * throttling and fade stepping.
108
+ */
109
+ update(
110
+ camera: PerspectiveCamera,
111
+ widthPx: number,
112
+ heightPx: number,
113
+ nowMs: number,
114
+ ): DeclutterUpdateResult {
115
+ if (this._participants.size === 0) return "idle";
116
+
117
+ let ran = false;
118
+ let throttled = false;
119
+ if (this._dirty || this._snapshotChanged(camera, widthPx, heightPx)) {
120
+ if (nowMs - this._lastRunAt >= DeclutterManager.MIN_INTERVAL_MS) {
121
+ this._run(camera, widthPx, heightPx);
122
+ this._takeSnapshot(camera, widthPx, heightPx);
123
+ this._dirty = false;
124
+ this._lastRunAt = nowMs;
125
+ ran = true;
126
+ } else {
127
+ throttled = true;
128
+ }
129
+ }
130
+
131
+ // Step fades even when no pass ran — placement only sets targets; the
132
+ // animation itself advances here, frame by frame. Clamp the step so the
133
+ // first frame after an idle gap doesn't jump most of the fade at once.
134
+ const deltaMs = Number.isFinite(this._lastStepAt)
135
+ ? Math.min(nowMs - this._lastStepAt, DeclutterManager.MAX_FADE_STEP_MS)
136
+ : 0;
137
+ this._lastStepAt = nowMs;
138
+ let animating = false;
139
+ for (const p of this._participants) {
140
+ animating = p.stepDeclutterFade(deltaMs) || animating;
141
+ }
142
+
143
+ // "animating" wins over "throttled": its prompt follow-up frames re-enter
144
+ // this method, so a pending throttled pass still runs once the window
145
+ // elapses.
146
+ if (animating) return "animating";
147
+ if (throttled) return "throttled";
148
+ return ran ? "ran" : "idle";
149
+ }
150
+
151
+ private _run(
152
+ camera: PerspectiveCamera,
153
+ widthPx: number,
154
+ heightPx: number,
155
+ ): void {
156
+ const candidates = this._candidates;
157
+ candidates.length = 0;
158
+ for (const p of this._participants) {
159
+ p.collectDeclutterCandidates(candidates);
160
+ }
161
+ const n = candidates.length;
162
+ if (n === 0) return;
163
+
164
+ // Pack candidates into the flat kernel input. The layout must match
165
+ // CANDIDATE_STRIDE / the field order the Rust kernel reads.
166
+ const stride = CANDIDATE_STRIDE;
167
+ if (this._input.length < n * stride) {
168
+ this._input = new Float64Array(n * stride);
169
+ }
170
+ const input = this._input;
171
+ for (let i = 0; i < n; i++) {
172
+ const c = candidates[i];
173
+ const o = i * stride;
174
+ input[o] = c.anchorX;
175
+ input[o + 1] = c.anchorY;
176
+ input[o + 2] = c.anchorZ;
177
+ input[o + 3] = c.addHeight;
178
+ input[o + 4] = c.minX;
179
+ input[o + 5] = c.maxX;
180
+ input[o + 6] = c.minY;
181
+ input[o + 7] = c.maxY;
182
+ input[o + 8] = c.sizeInMeters ? 1 : 0;
183
+ input[o + 9] = c.priority;
184
+ input[o + 10] = c.isShown ? 1 : 0;
185
+ }
186
+
187
+ // The pass may run before the renderer refreshed matrixWorldInverse, so
188
+ // derive the view matrix from matrixWorld instead of trusting it.
189
+ const view = this._viewMatrix.copy(camera.matrixWorld).invert();
190
+ const cam = camera.matrixWorld.elements;
191
+ // Three.js Matrix4.elements is a plain number[]; copy into f64 scratch so
192
+ // the typed-array boundary to WASM stays a single cheap copy per matrix.
193
+ this._view16.set(view.elements);
194
+ this._proj16.set(camera.projectionMatrix.elements);
195
+
196
+ const hidden = this._kernel.place(
197
+ input.subarray(0, n * stride),
198
+ this._view16,
199
+ this._proj16,
200
+ cam[12],
201
+ cam[13],
202
+ cam[14],
203
+ camera.near,
204
+ widthPx,
205
+ heightPx,
206
+ // Plain math instead of @navara/three_api's degreeToRadian — that one
207
+ // is a WASM call whose result we'd only marshal straight back in.
208
+ (camera.fov * Math.PI) / 180.0,
209
+ DeclutterManager.PADDING_PX,
210
+ DeclutterManager.HYSTERESIS_PX,
211
+ );
212
+
213
+ // Apply results as fade targets. Order is irrelevant — applyDeclutter is
214
+ // idempotent per handle — so iterate in candidate order, not sorted order.
215
+ for (let i = 0; i < n; i++) {
216
+ const c = candidates[i];
217
+ c.owner.applyDeclutter(c.handle, hidden[i] !== 0);
218
+ }
219
+
220
+ candidates.length = 0;
221
+ }
222
+
223
+ /** Drop all registered participants. Call when the owning view is disposed
224
+ * so this manager doesn't keep strong references to removed meshes (and
225
+ * their GPU/font resources) past the view's lifetime. */
226
+ dispose(): void {
227
+ this._participants.clear();
228
+ this._candidates.length = 0;
229
+ }
230
+
231
+ private _snapshotChanged(
232
+ camera: PerspectiveCamera,
233
+ widthPx: number,
234
+ heightPx: number,
235
+ ): boolean {
236
+ const s = this._prevCamera;
237
+ const world = camera.matrixWorld.elements;
238
+ const proj = camera.projectionMatrix.elements;
239
+ for (let i = 0; i < 16; i++) {
240
+ if (s[i] !== world[i] || s[16 + i] !== proj[i]) return true;
241
+ }
242
+ return s[32] !== widthPx || s[33] !== heightPx;
243
+ }
244
+
245
+ private _takeSnapshot(
246
+ camera: PerspectiveCamera,
247
+ widthPx: number,
248
+ heightPx: number,
249
+ ): void {
250
+ const s = this._prevCamera;
251
+ s.set(camera.matrixWorld.elements, 0);
252
+ s.set(camera.projectionMatrix.elements, 16);
253
+ s[32] = widthPx;
254
+ s[33] = heightPx;
255
+ }
256
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ DeclutterManager,
3
+ type DeclutterUpdateResult,
4
+ } from "./DeclutterManager";
5
+ export { CANDIDATE_STRIDE, type DeclutterKernel } from "./kernel";
6
+ export { wasmDeclutterKernel } from "./wasmKernel";
7
+ export type { DeclutterCandidate, DeclutterParticipant } from "./types";