@navaramap/three 0.0.1 → 0.0.3
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/README.md +70 -0
- package/dist/assets/fontWorker-wz61edjR.js +2 -0
- package/dist/assets/fontWorker-wz61edjR.js.map +1 -0
- package/dist/assets/index-ywHSGImj.js +25 -0
- package/dist/assets/index-ywHSGImj.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-Bnck8OGf.wasm → navara_wasm_font_worker_bg-Du9nb2Pn.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-DG9ORAmj.wasm → navara_wasm_worker_bg-Xadu67fw.wasm} +0 -0
- package/dist/draco_decoder.wasm +0 -0
- package/dist/draco_decoder2.wasm +0 -0
- package/dist/index.d.ts +578 -18
- package/dist/index.js +5298 -4843
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +266 -234
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +34 -33
- package/src/atmosphere.ts +43 -1
- package/src/core/EffectDesc.ts +2 -4
- package/src/core/MeshDesc.ts +1 -2
- package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
- package/src/declutter/DeclutterManager.test.ts +235 -0
- package/src/declutter/DeclutterManager.ts +256 -0
- package/src/declutter/index.ts +7 -0
- package/src/declutter/kernel.ts +46 -0
- package/src/declutter/types.ts +61 -0
- package/src/declutter/wasmKernel.ts +13 -0
- package/src/device.ts +18 -4
- package/src/evaluations/FeatureEvaluator.ts +21 -0
- package/src/event/context.ts +12 -0
- package/src/event/feature.ts +43 -13
- package/src/event/features/modelGpuBytes.ts +1 -3
- package/src/event/features/polygon.ts +2 -1
- package/src/event/hillshade.test.ts +7 -13
- package/src/event/index.ts +2 -3
- package/src/event/tileHandler.ts +54 -0
- package/src/index.ts +137 -35
- package/src/input.ts +48 -15
- package/src/material/enhancer/ShaderReplacer.ts +1 -2
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +1 -0
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +2 -0
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/types.ts +2 -0
- package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +2 -0
- package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +5 -1
- package/src/material/enhancer/polygon/polygonBaseEnhancer/types.ts +2 -0
- package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +2 -0
- package/src/material/enhancer/polyline/polylineBaseEnhancer/types.ts +2 -0
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +8 -0
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +13 -0
- package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
- package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +13 -3
- package/src/material/overrideMaterialsForMRT.ts +7 -2
- package/src/mesh/batchTexture.ts +2 -4
- package/src/mesh/batchedSdfText.ts +294 -70
- package/src/mesh/instanced.ts +1 -1
- package/src/mesh/polygon.ts +11 -4
- package/src/mesh/polyline.ts +10 -2
- package/src/mesh/sdfText.ts +176 -6
- package/src/mesh/sprite/instancedSprite.ts +245 -3
- package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
- package/src/passes/CustomRenderPass.ts +21 -5
- package/src/passes/RenderTargetCopyPass.ts +24 -2
- package/src/solar.test.ts +33 -0
- package/src/solar.ts +31 -0
- package/src/test-utils/engine.ts +53 -0
- package/src/tileTexture/TileTextureCompositor.test.ts +2 -0
- package/src/tileTexture/TileTextureCompositor.ts +7 -1
- package/src/utils/fixedGpuFootprint.test.ts +110 -0
- package/src/utils/fixedGpuFootprint.ts +162 -0
- package/src/worker/tasks/toCreasedNormals.ts +2 -7
- package/dist/assets/fontWorker-CCWL5pbx.js +0 -2
- package/dist/assets/fontWorker-CCWL5pbx.js.map +0 -1
- package/dist/assets/index-BdtHOOT9.js +0 -12
- package/dist/assets/index-BdtHOOT9.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.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"main": "dist/index.umd.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -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.
|
|
34
|
-
"@navaramap/engine
|
|
35
|
-
"@navaramap/
|
|
36
|
-
"@navaramap/three_api": "0.0.
|
|
37
|
-
"@navaramap/
|
|
38
|
-
"@navaramap/
|
|
39
|
-
"@navaramap/
|
|
33
|
+
"@navaramap/core": "0.0.3",
|
|
34
|
+
"@navaramap/engine": "0.0.3",
|
|
35
|
+
"@navaramap/font": "0.0.3",
|
|
36
|
+
"@navaramap/three_api": "0.0.3",
|
|
37
|
+
"@navaramap/engine-api": "0.0.3",
|
|
38
|
+
"@navaramap/worker": "0.0.3",
|
|
39
|
+
"@navaramap/three_csm": "0.0.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@playwright/test": "1.
|
|
43
|
-
"@radix-ui/react-collapsible": "1.1.
|
|
44
|
-
"@radix-ui/react-label": "2.1.
|
|
45
|
-
"@radix-ui/react-select": "2.
|
|
46
|
-
"@radix-ui/react-separator": "1.1.
|
|
47
|
-
"@radix-ui/react-slider": "1.
|
|
48
|
-
"@radix-ui/react-slot": "1.
|
|
49
|
-
"@radix-ui/react-switch": "1.
|
|
50
|
-
"@tailwindcss/postcss": "4.
|
|
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.
|
|
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.
|
|
59
|
-
"lucide-react": "1.
|
|
60
|
-
"playwright": "1.
|
|
61
|
-
"postcss": "8.5.
|
|
62
|
-
"postprocessing": "6.
|
|
63
|
-
"sharp": "0.
|
|
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.
|
|
66
|
-
"tailwindcss": "4.
|
|
66
|
+
"tailwind-merge": "3.6.0",
|
|
67
|
+
"tailwindcss": "4.3.3",
|
|
67
68
|
"tailwindcss-animate": "1.0.7",
|
|
68
|
-
"three": "0.
|
|
69
|
-
"tsx": "4.
|
|
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.
|
|
73
|
-
"@navaramap/three_default_descs": "0.0.
|
|
74
|
-
"@navaramap/three_default_plugin": "0.0.
|
|
75
|
-
"@navaramap/three_plugins": "0.0.
|
|
76
|
-
"@navaramap/three_react": "0.0.
|
|
73
|
+
"vite-plugin-static-copy": "4.1.1",
|
|
74
|
+
"@navaramap/three_default_descs": "0.0.3",
|
|
75
|
+
"@navaramap/three_default_plugin": "0.0.3",
|
|
76
|
+
"@navaramap/three_plugins": "0.0.3",
|
|
77
|
+
"@navaramap/three_react": "0.0.3"
|
|
77
78
|
},
|
|
78
79
|
"scripts": {
|
|
79
80
|
"start": "vite",
|
package/src/atmosphere.ts
CHANGED
|
@@ -14,7 +14,13 @@ import invariant from "tiny-invariant";
|
|
|
14
14
|
|
|
15
15
|
import { type ThreeViewCamera } from "./camera";
|
|
16
16
|
import { ATMOSPHERE_ASSETS_URL, STBN_URL } from "./constants";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
dateForSolarTime,
|
|
19
|
+
getSolarTime as solarTimeAt,
|
|
20
|
+
getSunElevation as sunElevationAt,
|
|
21
|
+
shiftDateToElevation,
|
|
22
|
+
shiftDateToHourAngle,
|
|
23
|
+
} from "./solar";
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* Events emitted by the {@link Atmosphere} class.
|
|
@@ -258,6 +264,42 @@ export class Atmosphere extends EventHandler<AtmosphereEvents> {
|
|
|
258
264
|
return dotProduct < 0;
|
|
259
265
|
}
|
|
260
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Returns the sun's elevation angle in degrees above the local horizon at
|
|
269
|
+
* `location` for the current {@link date}. Positive is above the horizon,
|
|
270
|
+
* negative below (night); includes atmospheric refraction.
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* const elevation = view.atmosphere.getSunElevation(view.camera.positionGeographic);
|
|
274
|
+
* if (elevation < 0) console.log("The sun has set.");
|
|
275
|
+
*/
|
|
276
|
+
getSunElevation(location: { lat: number; lng: number }): number {
|
|
277
|
+
return sunElevationAt(this.date, location.lat, location.lng);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Returns the local apparent solar time at `lng` for the current
|
|
282
|
+
* {@link date}, in hours in the range [0, 24) where 12 is solar noon.
|
|
283
|
+
* Accounts for the equation of time.
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* const hours = view.atmosphere.getSolarTime({ lng: 139.69 }); // e.g. 6.3 = 06:18
|
|
287
|
+
*/
|
|
288
|
+
getSolarTime(location: { lng: number }): number {
|
|
289
|
+
return solarTimeAt(this.date, location.lng);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Sets {@link date} so the local apparent solar time at `lng` equals `hours`
|
|
294
|
+
* (0–24), keeping the same solar day. Inverse of {@link getSolarTime}.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* view.atmosphere.setSolarTime({ lng: 139.69 }, 6.3); // sunrise over Tokyo
|
|
298
|
+
*/
|
|
299
|
+
setSolarTime(location: { lng: number }, hours: number): void {
|
|
300
|
+
this.date = dateForSolarTime(this.date, location.lng, hours);
|
|
301
|
+
}
|
|
302
|
+
|
|
261
303
|
/**
|
|
262
304
|
* Gets the current date used for calculating sun/moon positions.
|
|
263
305
|
*/
|
package/src/core/EffectDesc.ts
CHANGED
|
@@ -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> {
|
package/src/core/MeshDesc.ts
CHANGED
|
@@ -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
|
+
}
|