@navaramap/three 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +20 -8
  3. package/dist/assets/fontWorker-C5cHhkWQ.js +2 -0
  4. package/dist/assets/fontWorker-C5cHhkWQ.js.map +1 -0
  5. package/dist/assets/index-VfUncKuS.js +25 -0
  6. package/dist/assets/index-VfUncKuS.js.map +1 -0
  7. package/dist/assets/{navara_wasm_font_worker_bg-XvE_oYPr.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
  8. package/dist/assets/{navara_wasm_worker_bg-Dg9fnGLP.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
  9. package/dist/index.d.ts +460 -43
  10. package/dist/index.js +3732 -3584
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +230 -209
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +17 -17
  15. package/src/camera.ts +11 -4
  16. package/src/core/InstancedMeshDesc.ts +1 -1
  17. package/src/core/MeshDesc.test.ts +396 -0
  18. package/src/core/MeshDesc.ts +219 -8
  19. package/src/core/errors.ts +14 -0
  20. package/src/core/index.ts +2 -0
  21. package/src/evaluations/FeatureEvaluator.ts +17 -10
  22. package/src/event/context.ts +6 -0
  23. package/src/event/feature.ts +15 -2
  24. package/src/event/features/billboard.test.ts +62 -0
  25. package/src/event/features/billboard.ts +4 -2
  26. package/src/event/index.ts +7 -0
  27. package/src/index.ts +174 -40
  28. package/src/material/macro/tileShader.ts +12 -2
  29. package/src/mesh/polyline.ts +32 -4
  30. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  31. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  32. package/src/mesh/sprite/billboardAtlas.ts +8 -1
  33. package/src/mesh/sprite/instancedSprite.ts +173 -65
  34. package/src/pick/pickHelper.test.ts +302 -0
  35. package/src/pick/pickHelper.ts +119 -20
  36. package/src/plugins/AttributionPlugin/index.ts +1 -1
  37. package/src/renderOrder.ts +10 -0
  38. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  39. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  40. package/src/terrain/terrainHeightEvent.test.ts +32 -0
  41. package/src/terrain/terrainHeightEvent.ts +22 -0
  42. package/src/type/index.ts +22 -1
  43. package/dist/assets/fontWorker-DaqfXmZf.js +0 -2
  44. package/dist/assets/fontWorker-DaqfXmZf.js.map +0 -1
  45. package/dist/assets/index-Dydat1h1.js +0 -25
  46. package/dist/assets/index-Dydat1h1.js.map +0 -1
@@ -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
+ });
@@ -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 mouseMoved: boolean;
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(_event: MouseEvent) {
97
- this.mouseMoved = false;
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
- if (!this.mouseMoved) {
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 = event.clientX - rect.left;
218
- const y = event.clientY - rect.top;
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
- const batchId =
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. `fovy` is undefined until the first
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.
@@ -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
- const KARIYA = { lat: (35.0 * Math.PI) / 180, lng: (137.0 * Math.PI) / 180 };
91
- const NEARBY = {
92
- lat: (35.001 * Math.PI) / 180,
93
- lng: (137.001 * Math.PI) / 180,
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 radians, echoed from the input. */
33
+ /** Latitude in degrees, echoed from the input. */
33
34
  lat: number;
34
- /** Longitude in radians, echoed from the input. */
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 radians)
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
+ }