@navaramap/three 0.0.8 → 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 (35) 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-DaejBdUz.js → index-VfUncKuS.js} +2 -2
  6. package/dist/assets/index-VfUncKuS.js.map +1 -0
  7. package/dist/assets/{navara_wasm_font_worker_bg-CSmoDs_Q.wasm → navara_wasm_font_worker_bg-CKqHBoCR.wasm} +0 -0
  8. package/dist/assets/{navara_wasm_worker_bg-C1dUd_Nk.wasm → navara_wasm_worker_bg-CT26EH41.wasm} +0 -0
  9. package/dist/index.d.ts +213 -34
  10. package/dist/index.js +1476 -1351
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.umd.cjs +79 -58
  13. package/dist/index.umd.cjs.map +1 -1
  14. package/package.json +12 -12
  15. package/src/core/MeshDesc.test.ts +6 -8
  16. package/src/core/MeshDesc.ts +2 -5
  17. package/src/evaluations/FeatureEvaluator.ts +17 -10
  18. package/src/event/feature.ts +15 -2
  19. package/src/event/features/billboard.test.ts +62 -0
  20. package/src/event/features/billboard.ts +4 -2
  21. package/src/index.ts +98 -27
  22. package/src/material/macro/tileShader.ts +12 -2
  23. package/src/mesh/polyline.ts +32 -4
  24. package/src/mesh/sdfText/batchedSdfText.test.ts +65 -0
  25. package/src/mesh/sdfText/batchedSdfText.ts +121 -43
  26. package/src/mesh/sprite/instancedSprite.ts +173 -65
  27. package/src/pick/pickHelper.test.ts +261 -3
  28. package/src/pick/pickHelper.ts +94 -7
  29. package/src/renderOrder.ts +10 -0
  30. package/src/terrain/sampleTerrainMostDetailed.test.ts +6 -6
  31. package/src/terrain/sampleTerrainMostDetailed.ts +7 -5
  32. package/src/type/index.ts +22 -1
  33. package/dist/assets/fontWorker-CkLGtrXf.js +0 -2
  34. package/dist/assets/fontWorker-CkLGtrXf.js.map +0 -1
  35. package/dist/assets/index-DaejBdUz.js.map +0 -1
@@ -1,7 +1,9 @@
1
- import { Vector2 } from "three";
2
- import { describe, expect, it } from "vitest";
1
+ import { PerspectiveCamera, Vector2, type WebGLRenderer } from "three";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
- import { isClickGesture } from "./pickHelper";
4
+ import type { MeshCache } from "../type";
5
+
6
+ import { isClickGesture, PickHelper } from "./pickHelper";
5
7
 
6
8
  describe("isClickGesture", () => {
7
9
  it("accepts a mouseup at the exact mousedown position", () => {
@@ -42,3 +44,259 @@ describe("isClickGesture", () => {
42
44
  expect(isClickGesture(new Vector2(0, 0), new Vector2(4, 4))).toBe(false);
43
45
  });
44
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
+ });
@@ -47,6 +47,12 @@ export function isClickGesture(
47
47
  * Re-parenting is safe because pass scenes all have identity world
48
48
  * transforms, so world matrices of the re-parented objects are
49
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).
50
56
  */
51
57
  export class PickHelper {
52
58
  private element: HTMLElement;
@@ -64,9 +70,20 @@ export class PickHelper {
64
70
  /** Full-size render target used for click picking with scissor restriction. */
65
71
  private pickRenderTarget: WebGLRenderTarget;
66
72
 
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
+
67
79
  private mouseDownPosition?: Vector2;
68
80
  private mouseDownHandler: (event: MouseEvent) => void;
69
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;
70
87
 
71
88
  constructor(
72
89
  element: HTMLElement,
@@ -74,6 +91,9 @@ export class PickHelper {
74
91
  camera: PerspectiveCamera,
75
92
  meshes: MeshCache,
76
93
  onPickCallback: (pickArr: number[]) => void,
94
+ onHoverCallback: (pickArr: number[]) => void,
95
+ shouldClickPick: () => boolean,
96
+ shouldHoverPick: () => boolean,
77
97
  options?: PickHelperOptions,
78
98
  ) {
79
99
  this.element = element;
@@ -82,9 +102,14 @@ export class PickHelper {
82
102
  this._camera = camera;
83
103
  this._meshes = meshes;
84
104
  this.onPickCallback = onPickCallback;
105
+ this.onHoverCallback = onHoverCallback;
106
+ this.shouldClickPick = shouldClickPick;
107
+ this.shouldHoverPick = shouldHoverPick;
85
108
 
86
109
  this.mouseDownHandler = (event: MouseEvent) => this.onMouseDown(event);
87
110
  this.mouseUpHandler = (event: MouseEvent) => this.onMouseUp(event);
111
+ this.hoverMoveHandler = (event: MouseEvent) => this.onHoverMouseMove(event);
112
+ this.hoverLeaveHandler = () => this.onHoverMouseLeave();
88
113
 
89
114
  const width = this._renderer.getContext().drawingBufferWidth;
90
115
  const height = this._renderer.getContext().drawingBufferHeight;
@@ -114,6 +139,9 @@ export class PickHelper {
114
139
  this.mouseDownPosition = undefined;
115
140
  if (!downPosition) return;
116
141
 
142
+ // Skip the GPU pick entirely when nobody is listening for the result.
143
+ if (!this.shouldClickPick()) return;
144
+
117
145
  if (
118
146
  isClickGesture(downPosition, new Vector2(event.clientX, event.clientY))
119
147
  ) {
@@ -125,10 +153,61 @@ export class PickHelper {
125
153
  if (bPick) {
126
154
  this.element.addEventListener("mousedown", this.mouseDownHandler);
127
155
  this.element.addEventListener("mouseup", this.mouseUpHandler);
156
+ this.element.addEventListener("mousemove", this.hoverMoveHandler);
157
+ this.element.addEventListener("mouseleave", this.hoverLeaveHandler);
128
158
  } else {
129
159
  this.element.removeEventListener("mousedown", this.mouseDownHandler);
130
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([]);
202
+ }
203
+ }
204
+
205
+ private cancelPendingHoverPick() {
206
+ if (this.hoverRafId !== undefined) {
207
+ cancelAnimationFrame(this.hoverRafId);
208
+ this.hoverRafId = undefined;
131
209
  }
210
+ this.hoverPosition = undefined;
132
211
  }
133
212
 
134
213
  /**
@@ -225,9 +304,19 @@ export class PickHelper {
225
304
  }
226
305
 
227
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 {
228
317
  const rect = this.element.getBoundingClientRect();
229
- const x = event.clientX - rect.left;
230
- const y = event.clientY - rect.top;
318
+ const x = clientX - rect.left;
319
+ const y = clientY - rect.top;
231
320
 
232
321
  const pixelRatio = this._renderer.getPixelRatio();
233
322
  const fullWidth = this._renderer.getContext().drawingBufferWidth;
@@ -267,13 +356,11 @@ export class PickHelper {
267
356
 
268
357
  this._renderer.setScissorTest(false);
269
358
 
270
- const batchId =
359
+ return (
271
360
  (this.pixelBuffer[0] << 16) +
272
361
  (this.pixelBuffer[1] << 8) +
273
- this.pixelBuffer[2];
274
-
275
- const pickArr = batchId > 0 ? [batchId] : [];
276
- this.onPickCallback(pickArr);
362
+ this.pixelBuffer[2]
363
+ );
277
364
  }
278
365
 
279
366
  public dispose() {
@@ -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
package/src/type/index.ts CHANGED
@@ -75,6 +75,27 @@ type ConvertColorFields<T> = {
75
75
  */
76
76
  type WithColorSupport<T> = ConvertColorFields<T>;
77
77
 
78
+ /**
79
+ * Source geometry categories a vector appearance can consume via its
80
+ * `geometryTypes` option. Each appearance defaults to its native geometry only
81
+ * (`"point"` for point/billboard/text, `"line"` for polyline); adding entries
82
+ * derives extra representations, e.g. `polyline: { geometryTypes: ["line",
83
+ * "polygon"] }` also renders polygon boundary rings as polylines.
84
+ */
85
+ export type SourceGeometryType = "point" | "line" | "polygon";
86
+
87
+ /**
88
+ * Helper type to narrow the generated `geometryTypes?: string[]` material
89
+ * fields to the {@link SourceGeometryType} literal union.
90
+ */
91
+ type ConvertGeometryTypeFields<T> = {
92
+ [K in keyof T]: K extends "geometryTypes"
93
+ ? SourceGeometryType[] | Extract<T[K], undefined>
94
+ : T[K] extends object | undefined
95
+ ? ConvertGeometryTypeFields<T[K]> | Extract<T[K], undefined>
96
+ : T[K];
97
+ };
98
+
78
99
  /**
79
100
  * A reference to a {@link Source}: either the source handle returned by
80
101
  * `addSource`, or its `id` string.
@@ -87,7 +108,7 @@ export type SourceLayerBase<Layer extends { source?: string | undefined }> =
87
108
  };
88
109
 
89
110
  type VectorLayerBase = WithColorSupport<
90
- Layer<VectorLayerDescription & { type: "vector" }>
111
+ ConvertGeometryTypeFields<Layer<VectorLayerDescription & { type: "vector" }>>
91
112
  >;
92
113
 
93
114
  /**