@navaramap/three-plugins 0.0.4 → 0.0.6

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.
@@ -3,13 +3,17 @@ import { describe, it, expect, vi, afterEach } from "vitest";
3
3
  import { TileJsonPlugin, type TileJson } from "./TileJsonPlugin";
4
4
 
5
5
  // Importing the real @navaramap/three touches WASM/os at module load, which fails
6
- // in the test environment. TileJsonPlugin only needs `Plugin` and `EventHandler`
7
- // as runtime values (everything else it imports from there is type-only), so
8
- // stub the module. The EventHandler stub mirrors the real on/once/off/emit/clear.
6
+ // in the test environment. TileJsonPlugin only needs `Plugin`, `EventHandler`,
7
+ // and the elevation decoder factories as runtime values (everything else it
8
+ // imports from there is type-only), so stub the module. The decoder factories
9
+ // return sentinels the tests can assert on; the EventHandler stub mirrors the
10
+ // real on/once/off/emit/clear.
9
11
  /* eslint-disable @typescript-eslint/no-extraneous-class */
10
12
  vi.mock("@navaramap/three", () => ({
11
13
  default: class ThreeView {},
12
14
  Plugin: class Plugin {},
15
+ MAPBOX_ELEVATION_DECODER: () => ({ decoder: "mapbox" }),
16
+ TERRARIUM_ELEVATION_DECODER: () => ({ decoder: "terrarium" }),
13
17
  EventHandler: class EventHandler {
14
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
19
  private listeners = new Map<string, Set<(...a: any[]) => void>>();
@@ -192,6 +196,118 @@ describe("TileJsonPlugin.addSource", () => {
192
196
  expect(desc).not.toHaveProperty("tms");
193
197
  });
194
198
 
199
+ it("creates a raster-dem source with MapLibre defaults (tileSize 512, mapbox encoding)", async () => {
200
+ const { view } = makeFakeView();
201
+ const plugin = initPlugin(view);
202
+ stubTileJson({
203
+ tilejson: "3.0.0",
204
+ tiles: ["https://a/{z}/{x}/{y}.png"],
205
+ minzoom: 2,
206
+ maxzoom: 12,
207
+ } satisfies TileJson);
208
+
209
+ await plugin.addSource({ type: "raster-dem", id: "dem", url: DOC_URL });
210
+
211
+ expect(view.addSource).toHaveBeenCalledWith({
212
+ type: "raster-dem",
213
+ url: "https://a/{z}/{x}/{y}.png",
214
+ id: "dem",
215
+ minZoom: 2,
216
+ maxZoom: 12,
217
+ tileSize: 512,
218
+ elevationDecoder: { decoder: "mapbox" },
219
+ });
220
+ });
221
+
222
+ it("honors the document's MapLibre-compatible tileSize/encoding for raster-dem", async () => {
223
+ const { view } = makeFakeView();
224
+ const plugin = initPlugin(view);
225
+ stubTileJson({
226
+ tilejson: "3.0.0",
227
+ tiles: ["https://a/{z}/{x}/{y}.png"],
228
+ tileSize: 256,
229
+ encoding: "terrarium",
230
+ } satisfies TileJson);
231
+
232
+ await plugin.addSource({ type: "raster-dem", url: DOC_URL });
233
+
234
+ expect(view.addSource).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ tileSize: 256,
237
+ elevationDecoder: { decoder: "terrarium" },
238
+ }),
239
+ );
240
+ });
241
+
242
+ it("rejects an unsupported raster-dem encoding", async () => {
243
+ const { view } = makeFakeView();
244
+ const plugin = initPlugin(view);
245
+ stubTileJson({
246
+ tilejson: "3.0.0",
247
+ tiles: ["https://a/{z}/{x}/{y}.png"],
248
+ encoding: "custom" as never,
249
+ } satisfies TileJson);
250
+
251
+ await expect(
252
+ plugin.addSource({ type: "raster-dem", url: DOC_URL }),
253
+ ).rejects.toThrow(/unsupported raster-dem encoding/);
254
+ expect(view.addSource).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it("prefers description-level fields over the fetched document", async () => {
258
+ const { view } = makeFakeView();
259
+ const plugin = initPlugin(view);
260
+ stubTileJson({
261
+ tilejson: "3.0.0",
262
+ tiles: ["https://a/{z}/{x}/{y}.png"],
263
+ minzoom: 0,
264
+ maxzoom: 10,
265
+ scheme: "xyz",
266
+ tileSize: 512,
267
+ encoding: "mapbox",
268
+ } satisfies TileJson);
269
+
270
+ await plugin.addSource({
271
+ type: "raster-dem",
272
+ url: DOC_URL,
273
+ minzoom: 3,
274
+ maxzoom: 15,
275
+ scheme: "tms",
276
+ tileSize: 256,
277
+ encoding: "terrarium",
278
+ });
279
+
280
+ expect(view.addSource).toHaveBeenCalledWith({
281
+ type: "raster-dem",
282
+ url: "https://a/{z}/{x}/{y}.png",
283
+ minZoom: 3,
284
+ maxZoom: 15,
285
+ tms: true,
286
+ tileSize: 256,
287
+ elevationDecoder: { decoder: "terrarium" },
288
+ });
289
+ });
290
+
291
+ it("applies description-level fields even when the document omits them", async () => {
292
+ const { view } = makeFakeView();
293
+ const plugin = initPlugin(view);
294
+ stubTileJson({
295
+ tilejson: "3.0.0",
296
+ tiles: ["https://a/{z}/{x}/{y}.png"],
297
+ } satisfies TileJson);
298
+
299
+ await plugin.addSource({
300
+ type: "raster-tile",
301
+ url: DOC_URL,
302
+ minzoom: 4,
303
+ maxzoom: 8,
304
+ });
305
+
306
+ expect(view.addSource).toHaveBeenCalledWith(
307
+ expect.objectContaining({ minZoom: 4, maxZoom: 8 }),
308
+ );
309
+ });
310
+
195
311
  it("throws when the TileJSON fetch fails", async () => {
196
312
  const { view } = makeFakeView();
197
313
  const plugin = initPlugin(view);
@@ -6,7 +6,10 @@
6
6
  * document and registers it as a single Navara source. {@link TileJsonPlugin.addSource}
7
7
  * mirrors {@link ThreeView.addSource}: a discriminated `type` plus an optional
8
8
  * `id`, with `url` pointing at the TileJSON document. The plugin derives the tile
9
- * URL, `minzoom`/`maxzoom`, and `scheme` from the document.
9
+ * URL, `minzoom`/`maxzoom`, and `scheme` from the document, plus — for
10
+ * `raster-dem` sources — the MapLibre-compatible `tileSize` and `encoding`
11
+ * fields. Each derived field can also be set on the description itself, which
12
+ * takes precedence over the document.
10
13
  *
11
14
  * Each document's `attribution` is surfaced automatically through the view's
12
15
  * built-in attribution UI (`view.attribution`). Callers who want a custom credit
@@ -36,6 +39,16 @@
36
39
  * view.addLayer({ type: "raster", source });
37
40
  * // ...or directly by the id passed above.
38
41
  * view.addLayer({ type: "raster", source: "basemap" });
42
+ *
43
+ * // Raster-DEM terrain. The MapLibre-compatible `tileSize`/`encoding` fields
44
+ * // are honored from the document (or the description below), defaulting to
45
+ * // MapLibre's 512 / "mapbox".
46
+ * const dem = await tilejson.addSource({
47
+ * type: "raster-dem",
48
+ * url: "https://example.com/terrain.json",
49
+ * encoding: "terrarium", // takes precedence over the document
50
+ * });
51
+ * view.addLayer({ type: "terrain", source: dem, terrain: {} });
39
52
  * ```
40
53
  *
41
54
  * ### Custom attribution UI
@@ -55,6 +68,8 @@
55
68
  import ThreeView, {
56
69
  Plugin,
57
70
  EventHandler,
71
+ MAPBOX_ELEVATION_DECODER,
72
+ TERRARIUM_ELEVATION_DECODER,
58
73
  type Source,
59
74
  type ViewContext,
60
75
  } from "@navaramap/three";
@@ -65,6 +80,17 @@ type View = ThreeView<DefaultDescriptions>;
65
80
  /** Matches a TileJSON version such as "3.0.0" (major.minor.patch). */
66
81
  const TILEJSON_VERSION = /^3\.\d+\.\d+$/;
67
82
 
83
+ /** MapLibre's raster-dem defaults, applied when neither the description nor the document sets them. */
84
+ const DEFAULT_DEM_TILE_SIZE = 512;
85
+ const DEFAULT_DEM_ENCODING: TileJsonDemEncoding = "mapbox";
86
+
87
+ /**
88
+ * How a `raster-dem` document's RGB tiles encode elevation, matching MapLibre's
89
+ * `encoding` values. MapLibre's `"custom"` (free-form decode factors) is not
90
+ * supported.
91
+ */
92
+ export type TileJsonDemEncoding = "mapbox" | "terrarium";
93
+
68
94
  /**
69
95
  * The subset of a TileJSON 3.0.0 document this plugin consumes. Other spec
70
96
  * fields (`bounds`, `center`, `grids`, ...) are ignored.
@@ -82,24 +108,34 @@ export type TileJson = {
82
108
  maxzoom?: number;
83
109
  /** Tiling scheme. `"tms"` flips the Y axis (raster sources only). @defaultValue "xyz" */
84
110
  scheme?: "xyz" | "tms";
111
+ /**
112
+ * Tile size in pixels. Not part of the TileJSON spec, but emitted by
113
+ * MapLibre-oriented tile servers; applied to `raster-dem` sources only.
114
+ * @defaultValue 512
115
+ */
116
+ tileSize?: number;
117
+ /**
118
+ * Elevation encoding of DEM tiles. Not part of the TileJSON spec, but
119
+ * emitted by MapLibre-oriented tile servers; applied to `raster-dem` sources
120
+ * only.
121
+ * @defaultValue "mapbox"
122
+ */
123
+ encoding?: TileJsonDemEncoding;
85
124
  };
86
125
 
87
126
  /**
88
127
  * Which Navara source type a TileJSON document is materialized into. Declared by
89
128
  * the caller because TileJSON has no field that reliably distinguishes raster
90
- * imagery from vector tiles.
129
+ * imagery from vector or elevation tiles.
91
130
  */
92
- export type TileJsonSourceType = "raster-tile" | "vector-tile";
131
+ export type TileJsonSourceType = "raster-tile" | "vector-tile" | "raster-dem";
93
132
 
94
133
  /**
95
- * Describes a TileJSON tile source to register via {@link TileJsonPlugin.addSource}.
96
- * Mirrors the shape of {@link ThreeView.addSource}, except `url` is the TileJSON
97
- * document URL (not a tile template) — the plugin fetches it and derives the tile
98
- * URL, zoom range, and attribution from the document.
134
+ * Fields shared by every {@link TileJsonSourceDescription} variant. Alongside
135
+ * `url` and `id`, it accepts the same optional fields the TileJSON document
136
+ * carries; a field set here takes precedence over the fetched document's value.
99
137
  */
100
- export type TileJsonSourceDescription = {
101
- /** Navara source type to create, as in `addSource`. */
102
- type: TileJsonSourceType;
138
+ type TileJsonSourceDescriptionBase = {
103
139
  /** URL of the TileJSON 3.0.0 document to fetch and expand. */
104
140
  url: string;
105
141
  /**
@@ -108,8 +144,49 @@ export type TileJsonSourceDescription = {
108
144
  * handle. When omitted, the engine generates one.
109
145
  */
110
146
  id?: string;
147
+ /** Overrides the document's `minzoom` when set. */
148
+ minzoom?: number;
149
+ /** Overrides the document's `maxzoom` when set. */
150
+ maxzoom?: number;
151
+ /** Overrides the document's `scheme` when set. */
152
+ scheme?: "xyz" | "tms";
111
153
  };
112
154
 
155
+ /** Registers a TileJSON document as a `raster-tile` (imagery) source. */
156
+ export type TileJsonRasterTileSourceDescription =
157
+ TileJsonSourceDescriptionBase & {
158
+ type: "raster-tile";
159
+ };
160
+
161
+ /** Registers a TileJSON document as a `vector-tile` (MVT) source. */
162
+ export type TileJsonVectorTileSourceDescription =
163
+ TileJsonSourceDescriptionBase & {
164
+ type: "vector-tile";
165
+ };
166
+
167
+ /** Registers a TileJSON document as a `raster-dem` (elevation) source. */
168
+ export type TileJsonRasterDemSourceDescription =
169
+ TileJsonSourceDescriptionBase & {
170
+ type: "raster-dem";
171
+ /** Overrides the document's `tileSize` when set. @defaultValue 512 */
172
+ tileSize?: number;
173
+ /** Overrides the document's `encoding` when set. @defaultValue "mapbox" */
174
+ encoding?: TileJsonDemEncoding;
175
+ };
176
+
177
+ /**
178
+ * Describes a TileJSON tile source to register via {@link TileJsonPlugin.addSource}.
179
+ * Mirrors the shape of {@link ThreeView.addSource}, except `url` is the TileJSON
180
+ * document URL (not a tile template) — the plugin fetches it and derives the tile
181
+ * URL, zoom range, and attribution from the document. Document-derived fields
182
+ * (`minzoom`, `maxzoom`, `scheme` — plus `tileSize` and `encoding` for
183
+ * `raster-dem`) can also be set here to take precedence over the document.
184
+ */
185
+ export type TileJsonSourceDescription =
186
+ | TileJsonRasterTileSourceDescription
187
+ | TileJsonVectorTileSourceDescription
188
+ | TileJsonRasterDemSourceDescription;
189
+
113
190
  /** Detail passed to the {@link TileJsonPlugin} `loaded` event. */
114
191
  export type TileJsonLoadedEvent = {
115
192
  /** The Navara source created for the document. */
@@ -153,6 +230,26 @@ function validateTileJson(doc: TileJson): void {
153
230
  }
154
231
  }
155
232
 
233
+ /**
234
+ * Map a MapLibre `encoding` value to the engine's elevation decoder. Throws for
235
+ * unknown encodings (including MapLibre's `"custom"`, whose free-form decode
236
+ * factors have no TileJSON representation) so a raster-dem source is never
237
+ * silently created with a wrong decoder.
238
+ */
239
+ function resolveElevationDecoder(encoding: TileJsonDemEncoding) {
240
+ switch (encoding) {
241
+ case "mapbox":
242
+ return MAPBOX_ELEVATION_DECODER();
243
+ case "terrarium":
244
+ return TERRARIUM_ELEVATION_DECODER();
245
+ default:
246
+ throw new Error(
247
+ `TileJsonPlugin: unsupported raster-dem encoding ${JSON.stringify(encoding)}. ` +
248
+ 'Supported encodings: "mapbox", "terrarium".',
249
+ );
250
+ }
251
+ }
252
+
156
253
  export class TileJsonPlugin extends Plugin<View, ViewContext> {
157
254
  private view?: View;
158
255
  private readonly events = new EventHandler<TileJsonPluginEventMap>();
@@ -195,9 +292,13 @@ export class TileJsonPlugin extends Plugin<View, ViewContext> {
195
292
  *
196
293
  * `desc.url` is the TileJSON document URL; the plugin fetches it, then creates
197
294
  * one source of the requested `desc.type` using the document's first tile
198
- * endpoint, `minzoom`/`maxzoom`, and `scheme`. The document's `attribution` is
199
- * surfaced through the view's built-in attribution UI (`view.attribution`), and
200
- * a `loaded` event is emitted carrying the source, document, and attribution.
295
+ * endpoint, `minzoom`/`maxzoom`, and `scheme` — plus, for `raster-dem`, the
296
+ * MapLibre-compatible `tileSize` and `encoding` fields (defaulting to
297
+ * MapLibre's 512 and `"mapbox"`). Each of those fields can also be set on
298
+ * `desc` itself, taking precedence over the document. The document's
299
+ * `attribution` is surfaced through the view's built-in attribution UI
300
+ * (`view.attribution`), and a `loaded` event is emitted carrying the source,
301
+ * document, and attribution.
201
302
  *
202
303
  * A TileJSON `tiles` array lists mirror endpoints for the same tileset (as in
203
304
  * MapLibre, which shards requests across them). Navara sources take a single
@@ -228,26 +329,38 @@ export class TileJsonPlugin extends Plugin<View, ViewContext> {
228
329
  );
229
330
  }
230
331
 
231
- const { minzoom, maxzoom, scheme } = doc;
332
+ // Description-level fields take precedence over the fetched document.
333
+ const minzoom = desc.minzoom ?? doc.minzoom;
334
+ const maxzoom = desc.maxzoom ?? doc.maxzoom;
335
+ const scheme = desc.scheme ?? doc.scheme;
232
336
  const id = desc.id;
233
- const source =
234
- desc.type === "raster-tile"
235
- ? view.addSource({
236
- type: "raster-tile",
237
- url,
238
- ...(id !== undefined ? { id } : {}),
239
- ...(minzoom !== undefined ? { minZoom: minzoom } : {}),
240
- ...(maxzoom !== undefined ? { maxZoom: maxzoom } : {}),
241
- ...(scheme === "tms" ? { tms: true } : {}),
242
- })
243
- : // vector-tile sources have no minZoom / tms fields in the engine, so
244
- // only maxzoom carries over.
245
- view.addSource({
246
- type: "vector-tile",
247
- url,
248
- ...(id !== undefined ? { id } : {}),
249
- ...(maxzoom !== undefined ? { maxZoom: maxzoom } : {}),
250
- });
337
+ const shared = {
338
+ url,
339
+ ...(id !== undefined ? { id } : {}),
340
+ ...(maxzoom !== undefined ? { maxZoom: maxzoom } : {}),
341
+ };
342
+ const rasterShared = {
343
+ ...shared,
344
+ ...(minzoom !== undefined ? { minZoom: minzoom } : {}),
345
+ ...(scheme === "tms" ? { tms: true } : {}),
346
+ };
347
+ let source: Source;
348
+ if (desc.type === "raster-tile") {
349
+ source = view.addSource({ type: "raster-tile", ...rasterShared });
350
+ } else if (desc.type === "raster-dem") {
351
+ source = view.addSource({
352
+ type: "raster-dem",
353
+ ...rasterShared,
354
+ tileSize: desc.tileSize ?? doc.tileSize ?? DEFAULT_DEM_TILE_SIZE,
355
+ elevationDecoder: resolveElevationDecoder(
356
+ desc.encoding ?? doc.encoding ?? DEFAULT_DEM_ENCODING,
357
+ ),
358
+ });
359
+ } else {
360
+ // vector-tile sources have no minZoom / tms fields in the engine, so
361
+ // only maxzoom carries over.
362
+ source = view.addSource({ type: "vector-tile", ...shared });
363
+ }
251
364
 
252
365
  if (doc.attribution) {
253
366
  this.addCredit(doc.attribution);
package/src/index.ts CHANGED
@@ -4,10 +4,13 @@ export {
4
4
  type PersonViewState,
5
5
  type CharacterConfig,
6
6
  type AnimationConfig,
7
+ type CollisionConfig,
8
+ type CollisionMode,
7
9
  type ModelRotationOffset,
8
10
  type KeyBindings,
9
11
  type ViewMode,
10
12
  type TeleportOptions,
13
+ type PersonViewAction,
11
14
  } from "./PersonViewPlugin";
12
15
 
13
16
  export {
@@ -28,8 +31,12 @@ export {
28
31
  export {
29
32
  TileJsonPlugin,
30
33
  type TileJson,
34
+ type TileJsonDemEncoding,
31
35
  type TileJsonSourceType,
32
36
  type TileJsonSourceDescription,
37
+ type TileJsonRasterTileSourceDescription,
38
+ type TileJsonVectorTileSourceDescription,
39
+ type TileJsonRasterDemSourceDescription,
33
40
  type TileJsonLoadedEvent,
34
41
  type TileJsonPluginEventMap,
35
42
  } from "./TileJsonPlugin";