Skip to content

Commit 8fbcb62

Browse files
committed
v0.7.29
1 parent 9e4ad9d commit 8fbcb62

5 files changed

Lines changed: 107 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
v0.7.29
2+
* Fixed a `-buffer topological`/`fill-gaps` bug.
3+
14
v0.7.28
25
* The `-buffer topological` option now partitions overlapping buffer space by proximity to the source polygons (the nearest polygon wins), instead of assigning each overlap to the largest-area polygon.
36
* The new `-buffer fill-gaps` mode fills gaps that are narrower than the given distance parameter, including both interior holes and gaps along the outside of a polygon mosaic (such as coastal inlets and rivers).

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mapshaper",
3-
"version": "0.7.28",
3+
"version": "0.7.29",
44
"description": "A tool for editing geospatial data for mapping and GIS.",
55
"keywords": [
66
"shapefile",

src/buffer/mapshaper-buffer-voronoi.mjs

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function buildInterFeatureMedialLines(shapes, coordDistances, arcs, opts)
5454
message('[medial] sample sites: ' + sites.coords.length);
5555
}
5656
profileStart('medial:computeSegments');
57-
var medial = computeMedialSegments(sites, coordDistances);
57+
var medial = computeMedialSegments(sites, coordDistances, sites.grid);
5858
profileEnd('medial:computeSegments');
5959
if (medial.segments.length === 0) return null;
6060
// Stitch the individual Voronoi edges (2-point segments that meet at shared
@@ -319,7 +319,11 @@ function collectSites(shapes, coordDistances, arcs) {
319319
// extrapolated as an outward ray). Dropping the touching interior borders and
320320
// the no-feature coastline shrinks the one remaining Delaunay and avoids
321321
// building a redundant medial where the source boundary already partitions.
322-
return keptSites(sites, grid, coordDistances);
322+
var kept = keptSites(sites, grid, coordDistances);
323+
// Keep the segment grid with the sites so computeMedialSegments can re-measure
324+
// the true source gap when the sample-pair proximity test is too coarse.
325+
kept.grid = grid;
326+
return kept;
323327
}
324328

325329
// Bucket every boundary segment into a uniform grid so the nearest cross-feature
@@ -434,6 +438,50 @@ function nearestCrossFeatureSegmentDist(vx, vy, feat, reachF, seg, grid, cellKey
434438
return best;
435439
}
436440

441+
// True when medial vertex c lies in the buffer overlap of features fp and fq:
442+
// within fp's radius of an fp-owned source segment AND within fq's radius of an
443+
// fq-owned source segment. Measured against the actual source segments via the
444+
// grid, so it is correct regardless of how coarsely the banks were sampled --
445+
// unlike the sample-pair distance, which overestimates the gap when the nearest
446+
// samples on opposite banks are staggered or far from the true closest approach.
447+
// Slack on each reach when rescuing a cross-feature edge whose sample endpoints
448+
// fell outside the cheap proximity test. It absorbs the discretization of the
449+
// medial graph near a pinch point: the connecting Voronoi edge is bounded by the
450+
// site spacing (capped at the buffer distance), so a genuinely contested edge can
451+
// run up to ~1.5x reach and its medial vertices can land a similar fraction
452+
// outside the overlap. 1.3 covers the worst real case observed (~1.18) with
453+
// headroom, while spurious edges between sites contested with *other* features
454+
// miss by far more (>=1.5 or have no nearby source segment) and stay pruned.
455+
var MEDIAL_OVERLAP_SLACK = 1.3;
456+
457+
function medialVertexInOverlap(ctx, c, fp, fq, rp, rq) {
458+
var sp = rp * MEDIAL_OVERLAP_SLACK, sq = rq * MEDIAL_OVERLAP_SLACK;
459+
return pointFeatureDistSq(ctx, c[0], c[1], fp) <= sp * sp &&
460+
pointFeatureDistSq(ctx, c[0], c[1], fq) <= sq * sq;
461+
}
462+
463+
// Squared distance from (x, y) to the nearest segment owned by feature @feat,
464+
// probing the 3x3 grid-cell neighborhood (cell == max reach, so any segment
465+
// within a single feature's radius is in the window). Infinity if none.
466+
function pointFeatureDistSq(ctx, x, y, feat) {
467+
var seg = ctx.seg, grid = ctx.grid;
468+
var cx = ctx.colOf(x), cy = ctx.rowOf(y);
469+
var best = Infinity;
470+
for (var gx = cx - 1; gx <= cx + 1; gx++) {
471+
for (var gy = cy - 1; gy <= cy + 1; gy++) {
472+
var bucket = grid.get(ctx.cellKey(gx, gy));
473+
if (!bucket) continue;
474+
for (var b = 0; b < bucket.length; b++) {
475+
var s = bucket[b];
476+
if (seg.feat[s] !== feat) continue;
477+
var d2 = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
478+
if (d2 < best) best = d2;
479+
}
480+
}
481+
}
482+
return best;
483+
}
484+
437485
// Keep only the sites that border a real gap: a different feature within reach
438486
// (finite gap) but farther than the touching threshold. These are the only sites
439487
// that can shape the medial axis. Touching/coincident interior borders (gap ~ 0,
@@ -614,7 +662,7 @@ function triangleOfEdge(e) {
614662
return Math.floor(e / 3);
615663
}
616664

617-
function computeMedialSegments(sites, coordDistances) {
665+
function computeMedialSegments(sites, coordDistances, ctx) {
618666
var coords = sites.coords;
619667
var owner = sites.owner;
620668
profileStart('medial:delaunay');
@@ -639,18 +687,28 @@ function computeMedialSegments(sites, coordDistances) {
639687
var opp = halfedges[e];
640688
var p = triangles[e];
641689
var q = triangles[nextHalfedge(e)];
642-
if (owner[p] === owner[q]) continue;
690+
var fp = owner[p], fq = owner[q];
691+
if (fp === fq) continue;
643692
var dx = coords[p][0] - coords[q][0];
644693
var dy = coords[p][1] - coords[q][1];
645694
var siteDist = Math.sqrt(dx * dx + dy * dy);
646-
var reach = coordDistances[owner[p]] + coordDistances[owner[q]];
647-
// sites whose sources are farther apart than the sum of their radii can
648-
// never have overlapping buffers, so their bisector is not a contested edge
649-
if (siteDist > reach) continue;
695+
var rp = coordDistances[fp], rq = coordDistances[fq];
696+
var reach = rp + rq;
650697
var t1 = triangleOfEdge(e);
651698
var c1 = verts[t1];
652699
if (!c1) continue; // degenerate (near-collinear) triangle
700+
// Sites within the sum of their radii are accepted directly; this is the
701+
// common, cheap case. When they are farther apart, the bisector might still
702+
// be contested -- the nearest sample pair overestimates the true source gap
703+
// where banks are sampled coarsely or staggered. Re-measure the actual gap
704+
// at the medial vertex against the source segments (the grid) and rescue the
705+
// edge if it really lies in the buffer overlap. Without the rescue the medial
706+
// axis fragments at such spots, leaving the equidistant cut wall open so the
707+
// overlap face is never subdivided and a whole contested corridor is assigned
708+
// to one feature (a feature wrapping a neighbor's enclosed island).
709+
var near = siteDist <= reach;
653710
if (opp === -1) {
711+
if (!near && !(ctx && medialVertexInOverlap(ctx, c1, fp, fq, rp, rq))) continue;
654712
// Hull edge: the Voronoi edge here is an unbounded ray (the bisector of
655713
// two sites on the convex hull). Emit it as an outward ray from the
656714
// circumcenter so the medial line reaches and crosses the buffer
@@ -671,6 +729,9 @@ function computeMedialSegments(sites, coordDistances) {
671729
var t2 = triangleOfEdge(opp);
672730
var c2 = verts[t2];
673731
if (!c2) continue;
732+
if (!near && !(ctx &&
733+
(medialVertexInOverlap(ctx, c1, fp, fq, rp, rq) ||
734+
medialVertexInOverlap(ctx, c2, fp, fq, rp, rq)))) continue;
674735
var sx = c1[0] - c2[0], sy = c1[1] - c2[1];
675736
var segLen = Math.sqrt(sx * sx + sy * sy);
676737
// a real medial edge inside the overlap is short (on the order of the site

test/buffer-test.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,38 @@ describe('mapshaper-buffer.js', function () {
10291029
});
10301030
})
10311031

1032+
// Regression: at scattered buffer distances the inter-feature medial axis
1033+
// broke into separate chains where a connecting Voronoi edge was pruned --
1034+
// its sample endpoints sat just past the sum of the two radii, even though
1035+
// the bisector still ran through the buffer overlap (coarse/staggered
1036+
// sampling makes the sample-pair distance overestimate the true gap). The
1037+
// injected cut wall was then open, the buffer-overlap face was never
1038+
// subdivided, and the whole contested corridor was assigned to one feature
1039+
// by a single representative point: Oregon's buffer wrapped a Washington
1040+
// island in the Columbia, gaining an enclosing hole while the island
1041+
// detached as its own Washington part. The fix re-measures the true source
1042+
// gap at such an edge's medial vertices and keeps it when it really lies in
1043+
// the overlap. Sampled across the failing band (was broken at ~2.4, 4.2-4.6
1044+
// and 5.4-6.25 km) to lock in the whole class, not one distance.
1045+
it('keeps an enclosed island attached to its feature across the distance band', async function () {
1046+
var file = 'test/data/features/buffer/v_columbia_river.json';
1047+
var band = ['2.4km', '4.4km', '5.5km', '6km', '6.25km'];
1048+
for (var i = 0; i < band.length; i++) {
1049+
var out = await api.applyCommands(
1050+
'-i ' + file + ' -buffer ' + band[i] + ' topological -o format=geojson buffer.json');
1051+
var fc = JSON.parse(out['buffer.json']);
1052+
fc.features.forEach(function(f) {
1053+
assert(!geometryHasHole(f.geometry),
1054+
band[i] + ': ' + f.properties.NAME + ' should not enclose a neighbor');
1055+
});
1056+
var wa = fc.features.filter(function(f) {
1057+
return f.properties.NAME == 'Washington';
1058+
})[0];
1059+
assert.equal(getPolygonCount(wa.geometry), 1,
1060+
band[i] + ': the Washington island should stay attached to Washington');
1061+
}
1062+
})
1063+
10321064
it('rejects fill-gaps on a non-polygon layer', async function () {
10331065
await assert.rejects(function () {
10341066
return api.applyCommands(

0 commit comments

Comments
 (0)