geolibre-wasm 0.7.0 → 0.7.2

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/geolibre-cli.wasm CHANGED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "geolibre-wasm",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.7.2",
5
5
  "description": "Pure-Rust geospatial toolkit (whitebox_next_gen) compiled to WebAssembly (WASI) for GeoLibre's in-browser tool execution",
6
6
  "license": "MIT",
7
7
  "repository": {
package/tools.d.ts CHANGED
@@ -117,7 +117,17 @@ export function listTools(): Promise<string[]>;
117
117
  /** Fetch every tool manifest (parameter schemas), for building UIs offline. */
118
118
  export function listManifests(): Promise<ToolManifest[]>;
119
119
 
120
- /** Run one tool over an in-memory filesystem. */
120
+ /**
121
+ * Run one tool over an in-memory filesystem.
122
+ *
123
+ * `extract_cog_subset`, `extract_wms_subset`, `extract_xyz_tile_subset`, and
124
+ * `pmtiles_extract` are intercepted and run as JS-orchestrated byte-range
125
+ * extractions instead of whole-file WASI reads: pass `--url=<http source>` to
126
+ * subset a remote archive without downloading it whole (the host must allow
127
+ * cross-origin Range reads). `pmtiles_extract` also accepts `--bbox_crs` (the
128
+ * bbox is reprojected to WGS84, which PMTiles address) and still supports a
129
+ * local `--input=/work/local.pmtiles`.
130
+ */
121
131
  export function runTool(tool: string, opts?: RunToolOptions): Promise<ToolResult>;
122
132
 
123
133
  /** Extract a bbox subset from a local COG or HTTP COG. HTTP sources use byte-range requests. */
package/tools.mjs CHANGED
@@ -11,13 +11,23 @@
11
11
  // });
12
12
  // const slopeCog = files["slope.tif"]; // Uint8Array
13
13
  import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
14
- import initGeoLibre, { CogBuilder, CogStream, GeoTiffReader, transform_bbox_epsg, transform_points_epsg } from "./geolibre_wasm.js";
14
+ import initGeoLibre, { CogBuilder, CogStream, GeoTiffReader, PmtilesExtractor, transform_bbox_epsg, transform_points_epsg } from "./geolibre_wasm.js";
15
15
 
16
16
  let _module = null;
17
17
  let _libraryReady = null;
18
18
  const COG_SUBSET_TOOL_ID = "extract_cog_subset";
19
19
  const WMS_SUBSET_TOOL_ID = "extract_wms_subset";
20
20
  const XYZ_SUBSET_TOOL_ID = "extract_xyz_tile_subset";
21
+ const PMTILES_EXTRACT_TOOL_ID = "pmtiles_extract";
22
+ // The `url` + `bbox_crs` params the JS interception adds to the WASI
23
+ // `pmtiles_extract` tool's own manifest, so a host can extract from a remote
24
+ // archive by byte-range (no whole-file download) and offer the same
25
+ // "Use map extent" / "Draw on map" bbox affordance the COG/WMS/XYZ extractors
26
+ // get. The native `geolibre` CLI keeps its local file→file behaviour.
27
+ const PMTILES_EXTRACT_EXTRA_PARAMS = [
28
+ { name: "url", description: "HTTP(S) PMTiles URL. Provide either url or input; HTTP sources use byte-range requests (the host must allow cross-origin Range reads).", required: false, schema: { type: "string" } },
29
+ { name: "bbox_crs", description: "EPSG code of the bbox coordinates. Defaults to 4326 (WGS84 lon/lat); other CRSs are reprojected to 4326.", required: false, schema: { kind: "scalar", scalar: "integer" } },
30
+ ];
21
31
  const COG_SUBSET_MANIFEST = {
22
32
  id: COG_SUBSET_TOOL_ID,
23
33
  display_name: "Extract COG Subset",
@@ -189,6 +199,25 @@ export async function listManifests() {
189
199
  if (!manifests.some((m) => m.id === XYZ_SUBSET_TOOL_ID)) {
190
200
  manifests.push(XYZ_SUBSET_MANIFEST);
191
201
  }
202
+ // pmtiles_extract is a real WASI tool, so its manifest already lists
203
+ // input/output/bbox/min_zoom/max_zoom/max_tiles. Augment it with the `url`
204
+ // and `bbox_crs` params the JS interception handles, so hosts can extract
205
+ // from a remote archive and reproject the bbox.
206
+ const pmtiles = manifests.find((m) => m.id === PMTILES_EXTRACT_TOOL_ID);
207
+ if (pmtiles && Array.isArray(pmtiles.params)) {
208
+ pmtiles.source = "geolibre";
209
+ for (const extra of PMTILES_EXTRACT_EXTRA_PARAMS) {
210
+ if (!pmtiles.params.some((p) => p.name === extra.name)) {
211
+ pmtiles.params.push({ ...extra });
212
+ }
213
+ }
214
+ // The native tool requires a local `input`, but through this JS path a
215
+ // remote `url` is an equal alternative, so `input` is optional here (the
216
+ // handler enforces exactly-one-source). Matches extract_cog_subset's
217
+ // optional `input`; without this a host UI blocks a url-only run.
218
+ const input = pmtiles.params.find((p) => p.name === "input");
219
+ if (input) input.required = false;
220
+ }
192
221
  return manifests;
193
222
  }
194
223
 
@@ -206,6 +235,7 @@ export async function runTool(tool, opts = {}) {
206
235
  if (tool === COG_SUBSET_TOOL_ID) return runCogSubsetTool(args, input);
207
236
  if (tool === WMS_SUBSET_TOOL_ID) return runWmsSubsetTool(args);
208
237
  if (tool === XYZ_SUBSET_TOOL_ID) return runXyzTileSubsetTool(args);
238
+ if (tool === PMTILES_EXTRACT_TOOL_ID) return runPmtilesExtractTool(args, input);
209
239
  return exec([tool, ...args], input);
210
240
  }
211
241
 
@@ -343,6 +373,117 @@ async function runXyzTileSubsetTool(args) {
343
373
  }
344
374
  }
345
375
 
376
+ /**
377
+ * Extract a bbox/zoom subset of a PMTiles archive (local or remote) into a new
378
+ * self-contained archive, driving the wasm `PmtilesExtractor` with byte-range
379
+ * reads. Mirrors the COG/WMS/XYZ interception so a remote planet build is
380
+ * subset by range instead of downloaded whole. The bbox is reprojected from
381
+ * `bbox_crs` to WGS84 (what PMTiles address); `bbox_crs` defaults to 4326.
382
+ */
383
+ async function runPmtilesExtractTool(args, inputFiles) {
384
+ const flags = parseFlagArgs(args);
385
+ const url = flags.url;
386
+ const inputPath = flags.input;
387
+ const rawBbox = parseBbox(flags.bbox);
388
+ const bboxCrs = parseOptionalEpsg(flags.bbox_crs ?? flags.bboxCrs ?? flags.crs, "bbox_crs");
389
+ const minZoom = clampZoom(flags.min_zoom ?? flags.minZoom, 0);
390
+ const maxZoom = clampZoom(flags.max_zoom ?? flags.maxZoom, 30);
391
+ const maxTiles = parseOptionalInteger(flags.max_tiles ?? flags.maxTiles, "max_tiles");
392
+ const key = pmtilesOutputKey(flags.output);
393
+ const stdout = [];
394
+
395
+ try {
396
+ await initLibrary();
397
+ // PMTiles are addressed in WGS84 lon/lat; reproject if the caller gave a
398
+ // bbox in another CRS (the map extent affordance always sends 4326).
399
+ const bbox =
400
+ bboxCrs && bboxCrs !== 4326
401
+ ? Array.from(transform_bbox_epsg(bboxCrs, 4326, Float64Array.from(rawBbox)))
402
+ : rawBbox;
403
+
404
+ const reader = makeSourceReader(
405
+ await resolvePmtilesSource({ url, inputPath, inputFiles }),
406
+ );
407
+ const archive = await drivePmtilesExtractor(reader, {
408
+ bbox,
409
+ minZoom,
410
+ maxZoom,
411
+ maxTiles,
412
+ });
413
+ stdout.push(
414
+ JSON.stringify({ output: `/work/${key}`, bytes: archive.byteLength }),
415
+ );
416
+ return { exitCode: 0, stdout, files: { [key]: archive } };
417
+ } catch (error) {
418
+ stdout.push(String(error?.message || error));
419
+ return { exitCode: 1, stdout, files: {} };
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Drive a `PmtilesExtractor` to completion over a range reader, fetching each
425
+ * round of wanted ranges concurrently.
426
+ * @param {{range: (offset:number, length:number) => Promise<Uint8Array>}} reader
427
+ * @param {{bbox:number[], minZoom:number, maxZoom:number, maxTiles?:number}} opts
428
+ * @returns {Promise<Uint8Array>}
429
+ */
430
+ async function drivePmtilesExtractor(reader, { bbox, minZoom, maxZoom, maxTiles }) {
431
+ const extractor = new PmtilesExtractor(
432
+ bbox[0], bbox[1], bbox[2], bbox[3], minZoom, maxZoom,
433
+ );
434
+ try {
435
+ if (maxTiles != null) extractor.set_max_tiles(maxTiles);
436
+ while (!extractor.done) {
437
+ const wanted = JSON.parse(extractor.wanted_json());
438
+ if (wanted.length === 0) {
439
+ throw new Error("PMTiles extractor stalled: not done but nothing wanted");
440
+ }
441
+ const chunks = await Promise.all(
442
+ wanted.map(({ offset, length }) => reader.range(offset, length)),
443
+ );
444
+ wanted.forEach(({ offset }, i) => extractor.feed(offset, chunks[i]));
445
+ }
446
+ return extractor.finish();
447
+ } finally {
448
+ extractor.free();
449
+ }
450
+ }
451
+
452
+ /** Resolve a PMTiles source to a URL string or local bytes for makeSourceReader. */
453
+ async function resolvePmtilesSource({ url, inputPath, inputFiles }) {
454
+ const hasUrl = url && url !== true && String(url).trim() !== "";
455
+ if (!hasUrl && !inputPath) {
456
+ throw new Error("provide either --url=<http pmtiles> or --input=/work/local.pmtiles");
457
+ }
458
+ if (hasUrl && inputPath) {
459
+ throw new Error("provide only one source: --url or --input");
460
+ }
461
+ if (hasUrl) return String(url).trim();
462
+
463
+ const key = String(inputPath).replace(/^\/work\/?/, "");
464
+ if (!inputFiles || !(key in inputFiles)) {
465
+ throw new Error(`input file not found in /work: ${inputPath}`);
466
+ }
467
+ return materializeInput(inputFiles[key]);
468
+ }
469
+
470
+ /** Clamp/parse a zoom flag to an integer in [0, 30], falling back to a default. */
471
+ function clampZoom(raw, fallback) {
472
+ if (raw == null || raw === true || String(raw).trim() === "") return fallback;
473
+ const value = Number(raw);
474
+ if (!Number.isInteger(value) || value < 0 || value > 30) {
475
+ throw new Error("zoom levels must be whole numbers in 0..=30");
476
+ }
477
+ return value;
478
+ }
479
+
480
+ /** Default a PMTiles output path to extract.pmtiles, stripping the /work prefix. */
481
+ function pmtilesOutputKey(path) {
482
+ if (!path || path === true) return "extract.pmtiles";
483
+ const normalized = String(path).replace(/^\/work\/?/, "");
484
+ return normalized || "extract.pmtiles";
485
+ }
486
+
346
487
  async function resolveCogSubsetSource({ url, inputPath, inputFiles }) {
347
488
  if ((url == null || url === true || String(url).trim() === "") && !inputPath) {
348
489
  throw new Error("provide either --url=<http COG> or --input=/work/local.tif");