@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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { PerspectiveCamera, Vector2, type WebGLRenderer } from "three";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { MeshCache } from "../type";
|
|
5
|
+
|
|
6
|
+
import { isClickGesture, PickHelper } from "./pickHelper";
|
|
7
|
+
|
|
8
|
+
describe("isClickGesture", () => {
|
|
9
|
+
it("accepts a mouseup at the exact mousedown position", () => {
|
|
10
|
+
expect(isClickGesture(new Vector2(100, 100), new Vector2(100, 100))).toBe(
|
|
11
|
+
true,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accepts small jitter during a click (a few pixels)", () => {
|
|
16
|
+
expect(isClickGesture(new Vector2(100, 100), new Vector2(102, 101))).toBe(
|
|
17
|
+
true,
|
|
18
|
+
);
|
|
19
|
+
expect(isClickGesture(new Vector2(100, 100), new Vector2(97, 103))).toBe(
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("accepts travel just under the 5px tolerance", () => {
|
|
25
|
+
// 3-4-5 triangle scaled slightly down: distance ~4.99
|
|
26
|
+
expect(isClickGesture(new Vector2(0, 0), new Vector2(2.99, 3.99))).toBe(
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects travel at or beyond the 5px tolerance", () => {
|
|
32
|
+
expect(isClickGesture(new Vector2(0, 0), new Vector2(3, 4))).toBe(false);
|
|
33
|
+
expect(isClickGesture(new Vector2(0, 0), new Vector2(5, 0))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects a clear drag", () => {
|
|
37
|
+
expect(isClickGesture(new Vector2(100, 100), new Vector2(180, 40))).toBe(
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses euclidean distance, not per-axis deltas", () => {
|
|
43
|
+
// Each axis moved less than 5px but the combined travel exceeds it.
|
|
44
|
+
expect(isClickGesture(new Vector2(0, 0), new Vector2(4, 4))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Renderer stub: just enough of the WebGLRenderer surface for PickHelper.
|
|
50
|
+
* `pixel` is what the next readback returns; `scissors`/`renderCount`
|
|
51
|
+
* record the GPU work a pick performed.
|
|
52
|
+
*/
|
|
53
|
+
const createRendererStub = () => {
|
|
54
|
+
const state = {
|
|
55
|
+
pixel: [0, 0, 0] as [number, number, number],
|
|
56
|
+
renderCount: 0,
|
|
57
|
+
scissors: [] as { x: number; y: number; w: number; h: number }[],
|
|
58
|
+
};
|
|
59
|
+
const renderer = {
|
|
60
|
+
getContext: () => ({ drawingBufferWidth: 800, drawingBufferHeight: 600 }),
|
|
61
|
+
getPixelRatio: () => 1,
|
|
62
|
+
getClearColor: () => undefined,
|
|
63
|
+
getClearAlpha: () => 1,
|
|
64
|
+
getRenderTarget: () => null,
|
|
65
|
+
setClearColor: () => undefined,
|
|
66
|
+
setRenderTarget: () => undefined,
|
|
67
|
+
clear: () => undefined,
|
|
68
|
+
autoClear: true,
|
|
69
|
+
render: () => {
|
|
70
|
+
state.renderCount++;
|
|
71
|
+
},
|
|
72
|
+
setScissor: (x: number, y: number, w: number, h: number) => {
|
|
73
|
+
state.scissors.push({ x, y, w, h });
|
|
74
|
+
},
|
|
75
|
+
setScissorTest: () => undefined,
|
|
76
|
+
readRenderTargetPixels: (
|
|
77
|
+
_target: unknown,
|
|
78
|
+
_x: number,
|
|
79
|
+
_y: number,
|
|
80
|
+
_w: number,
|
|
81
|
+
_h: number,
|
|
82
|
+
buffer: Uint8Array,
|
|
83
|
+
) => {
|
|
84
|
+
[buffer[0], buffer[1], buffer[2]] = state.pixel;
|
|
85
|
+
buffer[3] = 255;
|
|
86
|
+
},
|
|
87
|
+
} as unknown as WebGLRenderer;
|
|
88
|
+
return { renderer, state };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe("PickHelper", () => {
|
|
92
|
+
let rafCallbacks: Map<number, FrameRequestCallback>;
|
|
93
|
+
let nextRafId: number;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
rafCallbacks = new Map();
|
|
97
|
+
nextRafId = 0;
|
|
98
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
99
|
+
rafCallbacks.set(++nextRafId, cb);
|
|
100
|
+
return nextRafId;
|
|
101
|
+
});
|
|
102
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
|
103
|
+
rafCallbacks.delete(id);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
vi.unstubAllGlobals();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Runs every rAF callback queued so far (one simulated frame). */
|
|
112
|
+
const runFrame = () => {
|
|
113
|
+
const callbacks = [...rafCallbacks.values()];
|
|
114
|
+
rafCallbacks.clear();
|
|
115
|
+
for (const cb of callbacks) cb(0);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const setup = (opts?: { clickable?: boolean; hoverable?: boolean }) => {
|
|
119
|
+
const element = document.createElement("div");
|
|
120
|
+
const { renderer, state } = createRendererStub();
|
|
121
|
+
const picks: number[][] = [];
|
|
122
|
+
const hovers: number[][] = [];
|
|
123
|
+
// Mutable so tests can flip a gate mid-flight (listener removal).
|
|
124
|
+
const gates = {
|
|
125
|
+
clickable: opts?.clickable ?? true,
|
|
126
|
+
hoverable: opts?.hoverable ?? true,
|
|
127
|
+
};
|
|
128
|
+
const helper = new PickHelper(
|
|
129
|
+
element,
|
|
130
|
+
renderer,
|
|
131
|
+
new PerspectiveCamera(),
|
|
132
|
+
new Map() as MeshCache,
|
|
133
|
+
(arr) => picks.push(arr),
|
|
134
|
+
(arr) => hovers.push(arr),
|
|
135
|
+
() => gates.clickable,
|
|
136
|
+
() => gates.hoverable,
|
|
137
|
+
);
|
|
138
|
+
helper.enablePick(true);
|
|
139
|
+
|
|
140
|
+
const mouse = (type: string, x: number, y: number, buttons = 0) =>
|
|
141
|
+
element.dispatchEvent(
|
|
142
|
+
new MouseEvent(type, { clientX: x, clientY: y, buttons }),
|
|
143
|
+
);
|
|
144
|
+
return { helper, element, state, picks, hovers, gates, mouse };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
describe("click picking", () => {
|
|
148
|
+
it("decodes the batch id from the pick render", () => {
|
|
149
|
+
const { state, picks, mouse } = setup();
|
|
150
|
+
state.pixel = [0, 1, 2]; // (0 << 16) + (1 << 8) + 2
|
|
151
|
+
|
|
152
|
+
mouse("mousedown", 100, 100);
|
|
153
|
+
mouse("mouseup", 100, 100);
|
|
154
|
+
|
|
155
|
+
expect(picks).toEqual([[258]]);
|
|
156
|
+
expect(state.renderCount).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("reports an empty pick for a miss (batch id 0)", () => {
|
|
160
|
+
const { picks, mouse } = setup();
|
|
161
|
+
|
|
162
|
+
mouse("mousedown", 100, 100);
|
|
163
|
+
mouse("mouseup", 100, 100);
|
|
164
|
+
|
|
165
|
+
expect(picks).toEqual([[]]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("limits GPU work to the clicked pixel via scissor", () => {
|
|
169
|
+
const { state, mouse } = setup();
|
|
170
|
+
|
|
171
|
+
mouse("mousedown", 30, 40);
|
|
172
|
+
mouse("mouseup", 30, 40);
|
|
173
|
+
|
|
174
|
+
// y is flipped into WebGL space: 600 - 1 - 40.
|
|
175
|
+
expect(state.scissors).toEqual([{ x: 30, y: 559, w: 1, h: 1 }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not pick when the gesture is a drag", () => {
|
|
179
|
+
const { state, picks, mouse } = setup();
|
|
180
|
+
|
|
181
|
+
mouse("mousedown", 100, 100);
|
|
182
|
+
mouse("mouseup", 150, 150);
|
|
183
|
+
|
|
184
|
+
expect(picks).toEqual([]);
|
|
185
|
+
expect(state.renderCount).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("skips the pick render entirely when nobody listens", () => {
|
|
189
|
+
const { state, picks, mouse } = setup({ clickable: false });
|
|
190
|
+
|
|
191
|
+
mouse("mousedown", 100, 100);
|
|
192
|
+
mouse("mouseup", 100, 100);
|
|
193
|
+
|
|
194
|
+
expect(picks).toEqual([]);
|
|
195
|
+
expect(state.renderCount).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("hover picking", () => {
|
|
200
|
+
it("picks at most once per animation frame, at the latest position", () => {
|
|
201
|
+
const { state, hovers, mouse } = setup();
|
|
202
|
+
state.pixel = [0, 0, 7];
|
|
203
|
+
|
|
204
|
+
mouse("mousemove", 10, 10);
|
|
205
|
+
mouse("mousemove", 20, 20);
|
|
206
|
+
mouse("mousemove", 30, 30);
|
|
207
|
+
expect(hovers).toEqual([]); // nothing until the frame fires
|
|
208
|
+
|
|
209
|
+
runFrame();
|
|
210
|
+
|
|
211
|
+
expect(hovers).toEqual([[7]]);
|
|
212
|
+
expect(state.renderCount).toBe(1);
|
|
213
|
+
expect(state.scissors).toEqual([{ x: 30, y: 569, w: 1, h: 1 }]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("reports an empty hover for a miss", () => {
|
|
217
|
+
const { hovers, mouse } = setup();
|
|
218
|
+
|
|
219
|
+
mouse("mousemove", 10, 10);
|
|
220
|
+
runFrame();
|
|
221
|
+
|
|
222
|
+
expect(hovers).toEqual([[]]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("is suppressed while a mouse button is pressed", () => {
|
|
226
|
+
const { state, hovers, mouse } = setup();
|
|
227
|
+
|
|
228
|
+
mouse("mousemove", 10, 10, 1);
|
|
229
|
+
runFrame();
|
|
230
|
+
|
|
231
|
+
expect(hovers).toEqual([]);
|
|
232
|
+
expect(state.renderCount).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("re-checks the listener gate when the frame fires", () => {
|
|
236
|
+
const { state, hovers, gates, mouse } = setup();
|
|
237
|
+
state.pixel = [0, 0, 7];
|
|
238
|
+
|
|
239
|
+
mouse("mousemove", 10, 10);
|
|
240
|
+
// Listeners are removed between the mousemove and the frame.
|
|
241
|
+
gates.hoverable = false;
|
|
242
|
+
runFrame();
|
|
243
|
+
|
|
244
|
+
expect(hovers).toEqual([]);
|
|
245
|
+
expect(state.renderCount).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("schedules nothing when no hover listener is registered", () => {
|
|
249
|
+
const { state, hovers, mouse } = setup({ hoverable: false });
|
|
250
|
+
|
|
251
|
+
mouse("mousemove", 10, 10);
|
|
252
|
+
|
|
253
|
+
expect(rafCallbacks.size).toBe(0);
|
|
254
|
+
runFrame();
|
|
255
|
+
expect(hovers).toEqual([]);
|
|
256
|
+
expect(state.renderCount).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("mouseleave reports nothing hovered and cancels the pending pick", () => {
|
|
260
|
+
const { state, hovers, element, mouse } = setup();
|
|
261
|
+
state.pixel = [0, 0, 7];
|
|
262
|
+
|
|
263
|
+
mouse("mousemove", 10, 10);
|
|
264
|
+
element.dispatchEvent(new MouseEvent("mouseleave"));
|
|
265
|
+
|
|
266
|
+
expect(hovers).toEqual([[]]);
|
|
267
|
+
|
|
268
|
+
runFrame();
|
|
269
|
+
// The queued pick was cancelled: no render, no second callback.
|
|
270
|
+
expect(hovers).toEqual([[]]);
|
|
271
|
+
expect(state.renderCount).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("mouseleave stays silent without hover listeners", () => {
|
|
275
|
+
const { hovers, element } = setup({ hoverable: false });
|
|
276
|
+
|
|
277
|
+
element.dispatchEvent(new MouseEvent("mouseleave"));
|
|
278
|
+
|
|
279
|
+
expect(hovers).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("enablePick(false)", () => {
|
|
284
|
+
it("detaches every handler and cancels pending hover picks", () => {
|
|
285
|
+
const { helper, state, picks, hovers, mouse } = setup();
|
|
286
|
+
state.pixel = [0, 0, 7];
|
|
287
|
+
mouse("mousemove", 10, 10);
|
|
288
|
+
|
|
289
|
+
helper.enablePick(false);
|
|
290
|
+
|
|
291
|
+
expect(rafCallbacks.size).toBe(0);
|
|
292
|
+
mouse("mousedown", 100, 100);
|
|
293
|
+
mouse("mouseup", 100, 100);
|
|
294
|
+
mouse("mousemove", 20, 20);
|
|
295
|
+
runFrame();
|
|
296
|
+
|
|
297
|
+
expect(picks).toEqual([]);
|
|
298
|
+
expect(hovers).toEqual([]);
|
|
299
|
+
expect(state.renderCount).toBe(0);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
package/src/pick/pickHelper.ts
CHANGED
|
@@ -18,6 +18,21 @@ export type PickHelperOptions = {
|
|
|
18
18
|
debug: boolean;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Travel tolerance in CSS pixels between mousedown and mouseup to count as a click.
|
|
23
|
+
* Follows Cesium's `_clickPixelTolerance` (MapLibre uses 3px).
|
|
24
|
+
* ref: https://github.com/CesiumGS/cesium/blob/9fda7ab97a762e40c74b1d0e1814c98a2de43337/packages/engine/Source/Core/ScreenSpaceEventHandler.js#L1012
|
|
25
|
+
* ref: https://github.com/maplibre/maplibre-gl-js/blob/3a0a4f795fef5b2a29034d71833475589c344eaf/src/ui/map.ts#L542
|
|
26
|
+
*/
|
|
27
|
+
const CLICK_PIXEL_TOLERANCE = 5;
|
|
28
|
+
|
|
29
|
+
export function isClickGesture(
|
|
30
|
+
downPosition: Vector2,
|
|
31
|
+
upPosition: Vector2,
|
|
32
|
+
): boolean {
|
|
33
|
+
return downPosition.distanceTo(upPosition) < CLICK_PIXEL_TOLERANCE;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* GPU picking using a dedicated single-pixel render pass.
|
|
23
38
|
*
|
|
@@ -32,6 +47,12 @@ export type PickHelperOptions = {
|
|
|
32
47
|
* Re-parenting is safe because pass scenes all have identity world
|
|
33
48
|
* transforms, so world matrices of the re-parented objects are
|
|
34
49
|
* unaffected.
|
|
50
|
+
*
|
|
51
|
+
* Two gestures trigger a pick: a click (mousedown/mouseup within
|
|
52
|
+
* {@link CLICK_PIXEL_TOLERANCE}) and hovering, throttled to at most one
|
|
53
|
+
* pick per animation frame. Both are lazy: a pick runs only while the
|
|
54
|
+
* matching `shouldClickPick` / `shouldHoverPick` gate returns true
|
|
55
|
+
* (i.e. someone is listening for the result).
|
|
35
56
|
*/
|
|
36
57
|
export class PickHelper {
|
|
37
58
|
private element: HTMLElement;
|
|
@@ -49,10 +70,20 @@ export class PickHelper {
|
|
|
49
70
|
/** Full-size render target used for click picking with scissor restriction. */
|
|
50
71
|
private pickRenderTarget: WebGLRenderTarget;
|
|
51
72
|
|
|
52
|
-
private
|
|
73
|
+
private onHoverCallback: (pickArr: number[]) => void;
|
|
74
|
+
/** Click picks run only while this returns true (e.g. featureClick listeners exist). */
|
|
75
|
+
private shouldClickPick: () => boolean;
|
|
76
|
+
/** Hover picks run only while this returns true (e.g. hover listeners exist). */
|
|
77
|
+
private shouldHoverPick: () => boolean;
|
|
78
|
+
|
|
79
|
+
private mouseDownPosition?: Vector2;
|
|
53
80
|
private mouseDownHandler: (event: MouseEvent) => void;
|
|
54
|
-
private mouseMoveHandler: (event: MouseEvent) => void;
|
|
55
81
|
private mouseUpHandler: (event: MouseEvent) => void;
|
|
82
|
+
private hoverMoveHandler: (event: MouseEvent) => void;
|
|
83
|
+
private hoverLeaveHandler: () => void;
|
|
84
|
+
/** Latest mousemove position awaiting a hover pick, in client coords. */
|
|
85
|
+
private hoverPosition?: Vector2;
|
|
86
|
+
private hoverRafId?: number;
|
|
56
87
|
|
|
57
88
|
constructor(
|
|
58
89
|
element: HTMLElement,
|
|
@@ -60,6 +91,9 @@ export class PickHelper {
|
|
|
60
91
|
camera: PerspectiveCamera,
|
|
61
92
|
meshes: MeshCache,
|
|
62
93
|
onPickCallback: (pickArr: number[]) => void,
|
|
94
|
+
onHoverCallback: (pickArr: number[]) => void,
|
|
95
|
+
shouldClickPick: () => boolean,
|
|
96
|
+
shouldHoverPick: () => boolean,
|
|
63
97
|
options?: PickHelperOptions,
|
|
64
98
|
) {
|
|
65
99
|
this.element = element;
|
|
@@ -68,11 +102,14 @@ export class PickHelper {
|
|
|
68
102
|
this._camera = camera;
|
|
69
103
|
this._meshes = meshes;
|
|
70
104
|
this.onPickCallback = onPickCallback;
|
|
105
|
+
this.onHoverCallback = onHoverCallback;
|
|
106
|
+
this.shouldClickPick = shouldClickPick;
|
|
107
|
+
this.shouldHoverPick = shouldHoverPick;
|
|
71
108
|
|
|
72
|
-
this.mouseMoved = false;
|
|
73
109
|
this.mouseDownHandler = (event: MouseEvent) => this.onMouseDown(event);
|
|
74
|
-
this.mouseMoveHandler = (event: MouseEvent) => this.onMouseMove(event);
|
|
75
110
|
this.mouseUpHandler = (event: MouseEvent) => this.onMouseUp(event);
|
|
111
|
+
this.hoverMoveHandler = (event: MouseEvent) => this.onHoverMouseMove(event);
|
|
112
|
+
this.hoverLeaveHandler = () => this.onHoverMouseLeave();
|
|
76
113
|
|
|
77
114
|
const width = this._renderer.getContext().drawingBufferWidth;
|
|
78
115
|
const height = this._renderer.getContext().drawingBufferHeight;
|
|
@@ -93,16 +130,21 @@ export class PickHelper {
|
|
|
93
130
|
}
|
|
94
131
|
}
|
|
95
132
|
|
|
96
|
-
private onMouseDown(
|
|
97
|
-
this.
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private onMouseMove(_event: MouseEvent) {
|
|
101
|
-
this.mouseMoved = true;
|
|
133
|
+
private onMouseDown(event: MouseEvent) {
|
|
134
|
+
this.mouseDownPosition = new Vector2(event.clientX, event.clientY);
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
private onMouseUp(event: MouseEvent) {
|
|
105
|
-
|
|
138
|
+
const downPosition = this.mouseDownPosition;
|
|
139
|
+
this.mouseDownPosition = undefined;
|
|
140
|
+
if (!downPosition) return;
|
|
141
|
+
|
|
142
|
+
// Skip the GPU pick entirely when nobody is listening for the result.
|
|
143
|
+
if (!this.shouldClickPick()) return;
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
isClickGesture(downPosition, new Vector2(event.clientX, event.clientY))
|
|
147
|
+
) {
|
|
106
148
|
this.onMouseClick(event);
|
|
107
149
|
}
|
|
108
150
|
}
|
|
@@ -110,15 +152,64 @@ export class PickHelper {
|
|
|
110
152
|
enablePick(bPick: boolean) {
|
|
111
153
|
if (bPick) {
|
|
112
154
|
this.element.addEventListener("mousedown", this.mouseDownHandler);
|
|
113
|
-
this.element.addEventListener("mousemove", this.mouseMoveHandler);
|
|
114
155
|
this.element.addEventListener("mouseup", this.mouseUpHandler);
|
|
156
|
+
this.element.addEventListener("mousemove", this.hoverMoveHandler);
|
|
157
|
+
this.element.addEventListener("mouseleave", this.hoverLeaveHandler);
|
|
115
158
|
} else {
|
|
116
159
|
this.element.removeEventListener("mousedown", this.mouseDownHandler);
|
|
117
|
-
this.element.removeEventListener("mousemove", this.mouseMoveHandler);
|
|
118
160
|
this.element.removeEventListener("mouseup", this.mouseUpHandler);
|
|
161
|
+
this.element.removeEventListener("mousemove", this.hoverMoveHandler);
|
|
162
|
+
this.element.removeEventListener("mouseleave", this.hoverLeaveHandler);
|
|
163
|
+
this.cancelPendingHoverPick();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Hover picking: schedules at most one GPU pick per animation frame from
|
|
169
|
+
* the latest mousemove position. Suppressed while any mouse button is
|
|
170
|
+
* pressed (camera drags would otherwise trigger a pick per frame) and
|
|
171
|
+
* while `shouldHoverPick` returns false, so pages without hover
|
|
172
|
+
* listeners pay nothing.
|
|
173
|
+
*/
|
|
174
|
+
private onHoverMouseMove(event: MouseEvent) {
|
|
175
|
+
if (event.buttons !== 0 || !this.shouldHoverPick()) return;
|
|
176
|
+
|
|
177
|
+
if (!this.hoverPosition) this.hoverPosition = new Vector2();
|
|
178
|
+
this.hoverPosition.set(event.clientX, event.clientY);
|
|
179
|
+
|
|
180
|
+
if (this.hoverRafId !== undefined) return;
|
|
181
|
+
this.hoverRafId = requestAnimationFrame(() => {
|
|
182
|
+
this.hoverRafId = undefined;
|
|
183
|
+
const position = this.hoverPosition;
|
|
184
|
+
if (!position) return;
|
|
185
|
+
this.hoverPosition = undefined;
|
|
186
|
+
|
|
187
|
+
// Listeners may have been removed between the mousemove that
|
|
188
|
+
// scheduled this frame and now. Re-check so no orphan pick runs.
|
|
189
|
+
if (!this.shouldHoverPick()) return;
|
|
190
|
+
|
|
191
|
+
const batchId = this.pickBatchIdAt(position.x, position.y);
|
|
192
|
+
this.onHoverCallback(batchId > 0 ? [batchId] : []);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private onHoverMouseLeave() {
|
|
197
|
+
this.cancelPendingHoverPick();
|
|
198
|
+
// Report "nothing hovered" so diff-based enter/leave synthesis can
|
|
199
|
+
// close out the current hover when the cursor exits the canvas.
|
|
200
|
+
if (this.shouldHoverPick()) {
|
|
201
|
+
this.onHoverCallback([]);
|
|
119
202
|
}
|
|
120
203
|
}
|
|
121
204
|
|
|
205
|
+
private cancelPendingHoverPick() {
|
|
206
|
+
if (this.hoverRafId !== undefined) {
|
|
207
|
+
cancelAnimationFrame(this.hoverRafId);
|
|
208
|
+
this.hoverRafId = undefined;
|
|
209
|
+
}
|
|
210
|
+
this.hoverPosition = undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
122
213
|
/**
|
|
123
214
|
* Moves every pickable raw (visible, currently parented) into the
|
|
124
215
|
* dedicated `pickScene` and activates the picking uniforms on its
|
|
@@ -213,9 +304,19 @@ export class PickHelper {
|
|
|
213
304
|
}
|
|
214
305
|
|
|
215
306
|
private onMouseClick(event: MouseEvent) {
|
|
307
|
+
const batchId = this.pickBatchIdAt(event.clientX, event.clientY);
|
|
308
|
+
const pickArr = batchId > 0 ? [batchId] : [];
|
|
309
|
+
this.onPickCallback(pickArr);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Runs the single-pixel pick render at the given client coordinates and
|
|
314
|
+
* returns the decoded batch ID (0 when nothing pickable is hit).
|
|
315
|
+
*/
|
|
316
|
+
private pickBatchIdAt(clientX: number, clientY: number): number {
|
|
216
317
|
const rect = this.element.getBoundingClientRect();
|
|
217
|
-
const x =
|
|
218
|
-
const y =
|
|
318
|
+
const x = clientX - rect.left;
|
|
319
|
+
const y = clientY - rect.top;
|
|
219
320
|
|
|
220
321
|
const pixelRatio = this._renderer.getPixelRatio();
|
|
221
322
|
const fullWidth = this._renderer.getContext().drawingBufferWidth;
|
|
@@ -255,13 +356,11 @@ export class PickHelper {
|
|
|
255
356
|
|
|
256
357
|
this._renderer.setScissorTest(false);
|
|
257
358
|
|
|
258
|
-
|
|
359
|
+
return (
|
|
259
360
|
(this.pixelBuffer[0] << 16) +
|
|
260
361
|
(this.pixelBuffer[1] << 8) +
|
|
261
|
-
this.pixelBuffer[2]
|
|
262
|
-
|
|
263
|
-
const pickArr = batchId > 0 ? [batchId] : [];
|
|
264
|
-
this.onPickCallback(pickArr);
|
|
362
|
+
this.pixelBuffer[2]
|
|
363
|
+
);
|
|
265
364
|
}
|
|
266
365
|
|
|
267
366
|
public dispose() {
|
|
@@ -189,7 +189,7 @@ export class AttributionPlugin extends Plugin<View, ViewContext> {
|
|
|
189
189
|
|
|
190
190
|
async init(view: View, _ctx: ViewContext): Promise<void> {
|
|
191
191
|
this.view = view;
|
|
192
|
-
// Recompute the zoom level on render. `
|
|
192
|
+
// Recompute the zoom level on render. `fov` is undefined until the first
|
|
193
193
|
// frame, so a camera `moveend` alone would miss the initial value on a
|
|
194
194
|
// static map; `preRender` reliably fires while the scene renders. The
|
|
195
195
|
// level-change gate in `handlePreRender` keeps this from churning the DOM.
|
package/src/renderOrder.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
export const FEATURE_RENDER_ORDER = 1;
|
|
2
|
+
/**
|
|
3
|
+
* Polylines (strokes) must draw after polygon fills. In the per-tile bake
|
|
4
|
+
* scene, sibling fill/stroke meshes are created asynchronously and land in
|
|
5
|
+
* the scene in arbitrary order, so a fill baked later paints over the stroke.
|
|
6
|
+
* In the MRT scene, polyline materials skip the depth test but still write
|
|
7
|
+
* depth, so a coplanar fill drawn after a stroke z-fights against the
|
|
8
|
+
* stroke's depth per fragment (mottled boundaries). Encoding the stacking in
|
|
9
|
+
* renderOrder makes both cases deterministic.
|
|
10
|
+
*/
|
|
11
|
+
export const STROKE_RENDER_ORDER = FEATURE_RENDER_ORDER + 1;
|
|
2
12
|
export const SKY_RENDER_ORDER = 100;
|
|
3
13
|
export const STARS_RENDER_ORDER = SKY_RENDER_ORDER + 1;
|
|
@@ -37,6 +37,7 @@ vi.mock("@navaramap/engine-api", () => {
|
|
|
37
37
|
};
|
|
38
38
|
return {
|
|
39
39
|
default: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
angleToRadian: (deg: number) => (deg * Math.PI) / 180,
|
|
40
41
|
terrainPositionsToTiles: vi.fn(
|
|
41
42
|
(geographic: boolean, level: number, positions: Float64Array) => {
|
|
42
43
|
const result = new Uint32Array(positions.length);
|
|
@@ -87,12 +88,11 @@ vi.mock("@navaramap/engine-api", () => {
|
|
|
87
88
|
};
|
|
88
89
|
});
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
};
|
|
95
|
-
const TOKYO = { lat: (35.68 * Math.PI) / 180, lng: (139.76 * Math.PI) / 180 };
|
|
91
|
+
// Public inputs are degrees; the mocked engine functions still receive the
|
|
92
|
+
// radians the implementation converts to.
|
|
93
|
+
const KARIYA = { lat: 35.0, lng: 137.0 };
|
|
94
|
+
const NEARBY = { lat: 35.001, lng: 137.001 };
|
|
95
|
+
const TOKYO = { lat: 35.68, lng: 139.76 };
|
|
96
96
|
|
|
97
97
|
const SOURCE: QuantizedMeshSource = {
|
|
98
98
|
type: "quantized-mesh",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LatLng } from "@navaramap/core";
|
|
2
2
|
import {
|
|
3
|
+
angleToRadian,
|
|
3
4
|
sampleQuantizedMeshHeights,
|
|
4
5
|
sampleRasterDemHeights,
|
|
5
6
|
terrainPositionsToTiles,
|
|
@@ -29,9 +30,9 @@ export type SampleTerrainOptions = {
|
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export type SampledTerrainPosition = {
|
|
32
|
-
/** Latitude in
|
|
33
|
+
/** Latitude in degrees, echoed from the input. */
|
|
33
34
|
lat: number;
|
|
34
|
-
/** Longitude in
|
|
35
|
+
/** Longitude in degrees, echoed from the input. */
|
|
35
36
|
lng: number;
|
|
36
37
|
/**
|
|
37
38
|
* Terrain height in meters, or `undefined` when no tile covering the
|
|
@@ -104,7 +105,7 @@ type SourceConfig = {
|
|
|
104
105
|
*
|
|
105
106
|
* @param source - A `quantized-mesh` or `raster-dem` source description (the
|
|
106
107
|
* same shape `addSource` accepts)
|
|
107
|
-
* @param positions - Geodetic positions (lat/lng in
|
|
108
|
+
* @param positions - Geodetic positions (lat/lng in degrees)
|
|
108
109
|
* @param options - Optional abort signal and fixed-level sampling
|
|
109
110
|
* @returns One result per input position, in the same order (the input
|
|
110
111
|
* objects are not mutated)
|
|
@@ -123,10 +124,11 @@ export async function sampleTerrainMostDetailed(
|
|
|
123
124
|
// Fixed-level sampling never falls back to parents.
|
|
124
125
|
const floorLevel = options?.level != null ? startLevel : config.minZoom;
|
|
125
126
|
|
|
127
|
+
// The engine samplers work in radians; inputs and echoed outputs are degrees.
|
|
126
128
|
const flat = new Float64Array(positions.length * 2);
|
|
127
129
|
positions.forEach((p, i) => {
|
|
128
|
-
flat[i * 2] = p.lng;
|
|
129
|
-
flat[i * 2 + 1] = p.lat;
|
|
130
|
+
flat[i * 2] = angleToRadian(p.lng);
|
|
131
|
+
flat[i * 2 + 1] = angleToRadian(p.lat);
|
|
130
132
|
});
|
|
131
133
|
|
|
132
134
|
// Group positions by their containing tile at the starting level; each
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TerrainHeightUpdatedEvent } from "@navaramap/engine";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { hasObservedTerrainHeight } from "./terrainHeightEvent";
|
|
5
|
+
|
|
6
|
+
const BITS = 42n;
|
|
7
|
+
|
|
8
|
+
/** Only `bits` and `height` are read; the rest of the event is irrelevant here. */
|
|
9
|
+
const event = (height: number | undefined, bits = BITS) =>
|
|
10
|
+
({ bits, height }) as TerrainHeightUpdatedEvent;
|
|
11
|
+
|
|
12
|
+
describe("hasObservedTerrainHeight", () => {
|
|
13
|
+
it("accepts a sea-level height of 0", () => {
|
|
14
|
+
// Regression: a truthiness guard dropped 0, so a terrain refinement that
|
|
15
|
+
// resolved to sea level never re-placed a `heightReference: "terrain"` mesh.
|
|
16
|
+
expect(hasObservedTerrainHeight(event(0), BITS)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts ordinary and negative heights", () => {
|
|
20
|
+
expect(hasObservedTerrainHeight(event(910.5), BITS)).toBe(true);
|
|
21
|
+
expect(hasObservedTerrainHeight(event(-12), BITS)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects an absent height", () => {
|
|
25
|
+
expect(hasObservedTerrainHeight(event(undefined), BITS)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects an event for a different observer entity", () => {
|
|
29
|
+
expect(hasObservedTerrainHeight(event(0, 7n), BITS)).toBe(false);
|
|
30
|
+
expect(hasObservedTerrainHeight(event(910.5, 7n), BITS)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TerrainHeightUpdatedEvent } from "@navaramap/engine";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a terrain-height event carries a usable height for the observer
|
|
5
|
+
* registered as `entityBits`.
|
|
6
|
+
*
|
|
7
|
+
* `height` is the engine's `Option<f64>` (`TerrainHeightObserver.height` in
|
|
8
|
+
* `navara_tile_component`): `undefined` means no tile covering the position has
|
|
9
|
+
* resolved a height yet, which is the case this filters out.
|
|
10
|
+
*
|
|
11
|
+
* **Deliberately `!= null` and not a truthiness check.** `0` is sea level — a
|
|
12
|
+
* perfectly valid computed height, and the engine only emits an event when the
|
|
13
|
+
* height actually changed. Dropping it would leave a mesh placed with
|
|
14
|
+
* `geodetic.heightReference: "terrain"` stranded at its previous altitude
|
|
15
|
+
* whenever a refinement resolved to the sea.
|
|
16
|
+
*/
|
|
17
|
+
export function hasObservedTerrainHeight(
|
|
18
|
+
ev: TerrainHeightUpdatedEvent,
|
|
19
|
+
entityBits: bigint,
|
|
20
|
+
): ev is TerrainHeightUpdatedEvent & { height: number } {
|
|
21
|
+
return ev.bits === entityBits && ev.height != null;
|
|
22
|
+
}
|