Skip to content

Commit f983f08

Browse files
authored
Fix oval Whitebox vector buffers (#1801)
* fix(processing): keep Whitebox buffers round * Address CodeRabbit review feedback - Recursively project nested geometry collections before assigning the projected CRS. - Prepare buffer distance overrides before serializing manifest parameters.
1 parent d9899a9 commit f983f08

3 files changed

Lines changed: 165 additions & 2 deletions

File tree

packages/processing/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export {
264264
outputBaseName,
265265
fileOutputTargetExtension,
266266
outputTextFormatHint,
267+
prepareGeographicBufferInput,
267268
isTiff,
268269
} from "./wasm-client";
269270
export {

packages/processing/src/wasm-client.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,80 @@ function isFeatureCollection(value: unknown): value is FeatureCollection {
449449
);
450450
}
451451

452+
const WEB_MERCATOR_RADIUS = 6378137;
453+
const MAX_WEB_MERCATOR_LATITUDE = 85.0511287798066;
454+
455+
/**
456+
* Prepare a WGS84 map layer for Whitebox's planar buffer operation.
457+
*
458+
* `buffer_vector` treats both axes as Cartesian map units. Buffering RFC 7946
459+
* longitude/latitude directly therefore creates a circle in degrees which is
460+
* stretched into an oval when MapLibre displays it in Web Mercator. Projecting
461+
* the input to EPSG:3857 makes the tool operate in the same conformal plane as
462+
* the map. The GeoJSON writer sees the attached CRS and reprojects the result
463+
* back to WGS84 before GeoLibre imports it.
464+
*
465+
* The dialog stores the buffer distance in degrees, including values converted
466+
* from metres by its explicitly approximate geographic-distance control. Using
467+
* the equatorial metres-per-degree scale preserves the old buffer's horizontal
468+
* radius while making its vertical radius match.
469+
*/
470+
export function prepareGeographicBufferInput(
471+
geojson: FeatureCollection,
472+
distance: unknown,
473+
): { geojson: FeatureCollection; distance: number } | null {
474+
const degrees = typeof distance === "number" ? distance : Number(distance);
475+
if (!Number.isFinite(degrees) || degrees <= 0) return null;
476+
477+
const projectPosition = (position: number[]): number[] => {
478+
const longitude = position[0];
479+
const latitude = Math.max(
480+
-MAX_WEB_MERCATOR_LATITUDE,
481+
Math.min(MAX_WEB_MERCATOR_LATITUDE, position[1]),
482+
);
483+
const x = WEB_MERCATOR_RADIUS * ((longitude * Math.PI) / 180);
484+
const y = WEB_MERCATOR_RADIUS * Math.log(Math.tan(Math.PI / 4 + (latitude * Math.PI) / 360));
485+
return [x, y, ...position.slice(2)];
486+
};
487+
488+
const projectCoordinates = (coordinates: unknown): unknown => {
489+
if (!Array.isArray(coordinates)) return coordinates;
490+
if (
491+
coordinates.length >= 2 &&
492+
typeof coordinates[0] === "number" &&
493+
typeof coordinates[1] === "number"
494+
) {
495+
return projectPosition(coordinates as number[]);
496+
}
497+
return coordinates.map(projectCoordinates);
498+
};
499+
500+
const projected = structuredClone(geojson) as FeatureCollection & {
501+
crs?: { type: "name"; properties: { name: string } };
502+
};
503+
const projectGeometry = (geometry: (typeof projected.features)[number]["geometry"]): void => {
504+
if (!geometry) return;
505+
if (geometry.type === "GeometryCollection") {
506+
for (const member of geometry.geometries) {
507+
projectGeometry(member);
508+
}
509+
return;
510+
}
511+
if ("coordinates" in geometry) {
512+
geometry.coordinates = projectCoordinates(geometry.coordinates) as never;
513+
}
514+
};
515+
for (const feature of projected.features) {
516+
projectGeometry(feature.geometry);
517+
}
518+
projected.crs = { type: "name", properties: { name: "EPSG:3857" } };
519+
520+
return {
521+
geojson: projected,
522+
distance: WEB_MERCATOR_RADIUS * ((degrees * Math.PI) / 180),
523+
};
524+
}
525+
452526
/**
453527
* Whether bytes start with the TIFF signature: "II" (little-endian) or "MM"
454528
* (big-endian) followed by the version number in the byte order's own
@@ -567,6 +641,18 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
567641
const encoder = new TextEncoder();
568642
const input: Record<string, Uint8Array> = {};
569643
const args: string[] = [];
644+
const parameterOverrides: Record<string, unknown> = {};
645+
let geographicBufferInput: FeatureCollection | null = null;
646+
if (request.tool_id === "buffer_vector") {
647+
const geojson = request.layer_inputs?.input?.geojson;
648+
const prepared = geojson
649+
? prepareGeographicBufferInput(geojson, request.parameters.distance)
650+
: null;
651+
if (prepared) {
652+
geographicBufferInput = prepared.geojson;
653+
parameterOverrides.distance = prepared.distance;
654+
}
655+
}
570656
// How each output file is turned into a job output: "geojson" is parsed into a
571657
// FeatureCollection (a map layer); "raster" is normalized to a COG before it
572658
// reaches the map; "bytes" is returned raw (a file_out blob or a
@@ -591,8 +677,12 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
591677
const name = param.name;
592678

593679
if (kind === "vector_in") {
594-
const geojson = request.layer_inputs?.[name]?.geojson;
680+
let geojson = request.layer_inputs?.[name]?.geojson;
595681
if (!geojson) throw new Error(`Missing vector input for "${name}"`);
682+
// A map layer is RFC 7946 WGS84, while Whitebox's buffer is Cartesian.
683+
// Run this one operation in Web Mercator so its round buffers stay round
684+
// on the map; the EPSG tag makes the tool's GeoJSON writer return WGS84.
685+
if (name === "input" && geographicBufferInput) geojson = geographicBufferInput;
596686
const file = `${name}.geojson`;
597687
input[file] = encoder.encode(JSON.stringify(geojson));
598688
args.push(`--${name}=/work/${file}`);
@@ -659,7 +749,7 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
659749
outputs.push({ name, file, kind: kind === "raster_out" ? "raster" : "bytes" });
660750
args.push(`--${name}=/work/${file}`);
661751
} else {
662-
const value = request.parameters[name];
752+
const value = parameterOverrides[name] ?? request.parameters[name];
663753
if (value !== undefined && value !== null && value !== "") {
664754
args.push(`--${name}=${value}`);
665755
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import type { FeatureCollection, GeometryCollection, Point } from "geojson";
4+
import { prepareGeographicBufferInput } from "@geolibre/processing";
5+
6+
const warsaw: FeatureCollection<Point> = {
7+
type: "FeatureCollection",
8+
features: [
9+
{
10+
type: "Feature",
11+
properties: { name: "Warsaw" },
12+
geometry: { type: "Point", coordinates: [21.0122, 52.2297] },
13+
},
14+
],
15+
};
16+
17+
describe("prepareGeographicBufferInput", () => {
18+
it("projects WGS84 coordinates and degree distances into Web Mercator", () => {
19+
const prepared = prepareGeographicBufferInput(warsaw, "0.1");
20+
assert.ok(prepared);
21+
assert.deepEqual((prepared.geojson as FeatureCollection & { crs?: unknown }).crs, {
22+
type: "name",
23+
properties: { name: "EPSG:3857" },
24+
});
25+
26+
const point = prepared.geojson.features[0].geometry as Point;
27+
assert.ok(Math.abs(point.coordinates[0] - 2_339_067.4) < 1);
28+
assert.ok(Math.abs(point.coordinates[1] - 6_841_765.2) < 1);
29+
assert.ok(Math.abs(prepared.distance - 11_131.949) < 0.01);
30+
});
31+
32+
it("does not mutate the map layer supplied by the caller", () => {
33+
const before = structuredClone(warsaw);
34+
prepareGeographicBufferInput(warsaw, 0.1);
35+
assert.deepEqual(warsaw, before);
36+
});
37+
38+
it("projects coordinate geometries nested inside geometry collections", () => {
39+
const nested: FeatureCollection<GeometryCollection> = {
40+
type: "FeatureCollection",
41+
features: [
42+
{
43+
type: "Feature",
44+
properties: {},
45+
geometry: {
46+
type: "GeometryCollection",
47+
geometries: [
48+
{
49+
type: "GeometryCollection",
50+
geometries: [{ type: "Point", coordinates: [21.0122, 52.2297] }],
51+
},
52+
],
53+
},
54+
},
55+
],
56+
};
57+
58+
const prepared = prepareGeographicBufferInput(nested, 0.1);
59+
assert.ok(prepared);
60+
const outer = prepared.geojson.features[0].geometry as GeometryCollection;
61+
const inner = outer.geometries[0] as GeometryCollection;
62+
const point = inner.geometries[0] as Point;
63+
assert.ok(Math.abs(point.coordinates[0] - 2_339_067.4) < 1);
64+
assert.ok(Math.abs(point.coordinates[1] - 6_841_765.2) < 1);
65+
});
66+
67+
it("rejects missing, non-numeric, and non-positive distances", () => {
68+
for (const distance of [undefined, "", "not a number", 0, -1]) {
69+
assert.equal(prepareGeographicBufferInput(warsaw, distance), null);
70+
}
71+
});
72+
});

0 commit comments

Comments
 (0)