mapshaper 0.7.32 → 0.7.34

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 (3) hide show
  1. package/mapshaper.js +1540 -539
  2. package/package.json +1 -1
  3. package/www/mapshaper.js +1540 -539
package/www/mapshaper.js CHANGED
@@ -17694,7 +17694,7 @@
17694
17694
  // construction and are left in place -- removing them at the tile level
17695
17695
  // (topological flood or per-tile geometric vote) was measured to add more
17696
17696
  // artifacts (coverage dents) than it removed.
17697
- this.getWindingTilesByShapeId = function(shapeId) {
17697
+ this.getWindingTilesByShapeId = function(shapeId, fillGaps) {
17698
17698
  var shp = shapes[shapeId];
17699
17699
  if (!shp || !shp.length) return [];
17700
17700
  var i, r, ring, d, fwd;
@@ -17720,7 +17720,7 @@
17720
17720
  if (!shp[r].length) continue;
17721
17721
  var seedTile = arcTileIndex.getShapeIdByArcId(shp[r][0]);
17722
17722
  if (seedTile < 0 || windStamp[seedTile] === stamp) continue;
17723
- floodComponent(seedTile, flux, sb, stamp, ids);
17723
+ floodComponent(seedTile, flux, sb, stamp, ids, fillGaps);
17724
17724
  }
17725
17725
  return ids.map(tileIdToTile);
17726
17726
  };
@@ -17933,12 +17933,16 @@
17933
17933
  // any neighbor known to be outside the buffer (the true exterior, or a tile
17934
17934
  // entirely outside the shape bbox) at winding 0. Nonzero tiles are pushed to
17935
17935
  // @ids. The flood is bounded to the shape bbox for speed.
17936
- function floodComponent(seedTile, flux, sb, stamp, ids) {
17936
+ function floodComponent(seedTile, flux, sb, stamp, ids, fillGaps) {
17937
17937
  windVal[seedTile] = 0;
17938
17938
  windStamp[seedTile] = stamp;
17939
17939
  var stack = [seedTile];
17940
17940
  var visited = [seedTile];
17941
17941
  var C = null;
17942
+ // Node keys of arcs on the buffer's true outer edge (nb < 0). The exterior
17943
+ // is not a mosaic tile, so a pinched fold-back wedge is detected by sharing
17944
+ // one of these nodes (see fillPinchedGaps).
17945
+ var outerKeys = fillGaps ? {} : null;
17942
17946
  var cur, ctile, cw, pp, ring2, kk, d2, nb, fwd2, nbb, delta, i;
17943
17947
  while (stack.length) {
17944
17948
  cur = stack.pop();
@@ -17956,6 +17960,10 @@
17956
17960
  if (nb < 0 || nbb.xmax < sb.xmin || nbb.xmin > sb.xmax ||
17957
17961
  nbb.ymax < sb.ymin || nbb.ymin > sb.ymax) {
17958
17962
  if (C === null) C = -(cw + delta); // anchor: this neighbor is winding 0
17963
+ if (outerKeys && nb < 0) {
17964
+ outerKeys[arcEndpointKey(d2, 0)] = true;
17965
+ outerKeys[arcEndpointKey(d2, -1)] = true;
17966
+ }
17959
17967
  continue;
17960
17968
  }
17961
17969
  windVal[nb] = cw + delta;
@@ -17969,6 +17977,93 @@
17969
17977
  for (i = 0; i < visited.length; i++) {
17970
17978
  if (windVal[visited[i]] + C !== 0) ids.push(visited[i]);
17971
17979
  }
17980
+ if (fillGaps) {
17981
+ fillPinchedGaps(visited, stamp, C, outerKeys, ids);
17982
+ }
17983
+ }
17984
+
17985
+ // Reclaim winding-zero gaps that the winding rule dropped but that are not
17986
+ // really open. Where variable geodesic offset distance breaks the planar
17987
+ // self-crossing assumption, a fold-back leaves a thin winding-zero wedge that
17988
+ // is pinched to the open exterior at a single point: a shared node (arc
17989
+ // endpoint on the buffer's outer edge), NOT a shared arc. So a wedge is
17990
+ // absorbed when its winding-zero component:
17991
+ // - borders the outer edge nowhere across an arc (it is not the open
17992
+ // exterior or an open concavity, which the winding rule legitimately
17993
+ // leaves out), and
17994
+ // - shares a boundary node with the outer edge (the pinch point).
17995
+ // This is purely topological (the analogue of -clean/removeGaps); unlike an
17996
+ // area threshold it cannot fill a genuine interior hole, whose boundary nodes
17997
+ // are all interior and never coincide with the buffer's outer edge.
17998
+ function fillPinchedGaps(visited, stamp, C, outerKeys, ids) {
17999
+ if (!outerKeys) return;
18000
+ var candidates = [];
18001
+ for (var i = 0; i < visited.length; i++) {
18002
+ if (windVal[visited[i]] + C === 0) candidates.push(visited[i]);
18003
+ }
18004
+ var isGap = function(tileId) {
18005
+ return windStamp[tileId] === stamp && windVal[tileId] + C === 0;
18006
+ };
18007
+ var pinched = collectPinchedGapTiles(candidates, isGap, outerKeys);
18008
+ for (i = 0; i < pinched.length; i++) ids.push(pinched[i]);
18009
+ }
18010
+
18011
+ // Group @candidates (gap-region tile ids) into arc-connected components, then
18012
+ // return the ids of components that are pinched to the buffer's outer edge: a
18013
+ // component that borders the exterior (nb < 0) nowhere across an arc, yet
18014
+ // shares a boundary node with @outerKeys (the outer-edge nodes). @isGap tells
18015
+ // whether a neighbor tile belongs to the gap region (grows the component).
18016
+ // Purely topological, so it never reclaims a genuine interior hole (whose
18017
+ // boundary nodes are all interior).
18018
+ function collectPinchedGapTiles(candidates, isGap, outerKeys) {
18019
+ var comp = {}, out = [];
18020
+ var i, t, tile, p, ring, k, d, nb, tiles, keys, arcOpen, stack;
18021
+ for (i = 0; i < candidates.length; i++) {
18022
+ if (comp[candidates[i]] !== undefined) continue;
18023
+ tiles = [candidates[i]];
18024
+ keys = [];
18025
+ arcOpen = false;
18026
+ comp[candidates[i]] = true;
18027
+ stack = [candidates[i]];
18028
+ while (stack.length) {
18029
+ t = stack.pop();
18030
+ tile = mosaic[t];
18031
+ for (p = 0; p < tile.length; p++) {
18032
+ ring = tile[p];
18033
+ for (k = 0; k < ring.length; k++) {
18034
+ d = ring[k];
18035
+ keys.push(arcEndpointKey(d, 0), arcEndpointKey(d, -1));
18036
+ nb = arcTileIndex.getShapeIdByArcId(~d);
18037
+ if (nb < 0) {
18038
+ arcOpen = true; // borders the outer edge across an arc
18039
+ continue;
18040
+ }
18041
+ if (!isGap(nb)) continue; // selected boundary of the gap
18042
+ if (comp[nb] === undefined) {
18043
+ comp[nb] = true;
18044
+ tiles.push(nb);
18045
+ stack.push(nb);
18046
+ }
18047
+ }
18048
+ }
18049
+ }
18050
+ if (!arcOpen && sharesKey(keys, outerKeys)) {
18051
+ for (k = 0; k < tiles.length; k++) out.push(tiles[k]);
18052
+ }
18053
+ }
18054
+ return out;
18055
+ }
18056
+
18057
+ function arcEndpointKey(arcId, nth) {
18058
+ var v = nodes.arcs.getVertex(arcId, nth);
18059
+ return v.x + ',' + v.y;
18060
+ }
18061
+
18062
+ function sharesKey(keys, set) {
18063
+ for (var i = 0; i < keys.length; i++) {
18064
+ if (set[keys[i]]) return true;
18065
+ }
18066
+ return false;
17972
18067
  }
17973
18068
 
17974
18069
  function getOverlapPriorityFunction(shapes, arcs, rule) {
@@ -31779,6 +31874,13 @@ ${svg}
31779
31874
  describe: '[projected data] buffer using geodesic distances',
31780
31875
  type: 'flag'
31781
31876
  })
31877
+ .option('geodesic2', {
31878
+ // undocumented/experimental: geodesic buffering for projected data done
31879
+ // in-place in the projected plane via per-point scale correction (no
31880
+ // web-Mercator round-trip; avoids pole/antimeridian edge cases). Compare
31881
+ // with the 'geodesic' flag, which reprojects through lng/lat instead.
31882
+ type: 'flag'
31883
+ })
31782
31884
  .option('polar', {
31783
31885
  // describe: 'keep lat-long buffers within the valid extent (+/-180, +/-90); for growing polygons sliced at the antimeridian/poles (erode not yet supported)',
31784
31886
  type: 'flag'
@@ -31837,12 +31939,6 @@ ${svg}
31837
31939
  // before the dissolve) is on by default; this opts out.
31838
31940
  type: 'flag'
31839
31941
  })
31840
- .option('loop-removal-turn-gate', {
31841
- // Undocumented: for two-sided open-path buffers, use the source-turn-gate
31842
- // loop-removal method instead of the default crossing-direction method.
31843
- // Kept as an alternative for A/B comparison and as a conservative fallback.
31844
- type: 'flag'
31845
- })
31846
31942
  .option('band-method', {
31847
31943
  // Undocumented escape hatch: build buffers with the older band (sector-
31848
31944
  // band) construction -- per-segment offset bands + join-sector rings + a
@@ -31855,6 +31951,28 @@ ${svg}
31855
31951
  // debugging aid in case the default construction mishandles some input.
31856
31952
  type: 'flag'
31857
31953
  })
31954
+ .option('clean-outline-winding', {
31955
+ // Undocumented: build the polygon-grow outer ring with the constant-winding
31956
+ // concave-join construction used by the open-path two-sided outline (a
31957
+ // reversed arc at the offset radius). This is now the DEFAULT polygon-grow
31958
+ // construction; the flag is retained as an explicit/no-op selector.
31959
+ type: 'flag'
31960
+ })
31961
+ .option('coarse-bridge', {
31962
+ // Undocumented: in the clean-outline-winding construction, bridge concave
31963
+ // bends with the low-resolution makeCoarseConcaveJoin (as few as one
31964
+ // reversed arc vertex) instead of the full-resolution makeConcaveJoin.
31965
+ // The reversed bridge only bounds a self-overlap loop the direction remover
31966
+ // collapses, so this leaves the final boundary unchanged while producing a
31967
+ // smaller ring for the winding dissolve -- a construction-speed tradeoff.
31968
+ type: 'flag'
31969
+ })
31970
+ .option('no-gap-patch', {
31971
+ // Undocumented: disable the default geodesic gap-patch stadium union
31972
+ // (see useGapPatch in mapshaper-buffer-common.mjs). Useful for comparing
31973
+ // raw dissolve behavior against the patched outline construction.
31974
+ type: 'flag'
31975
+ })
31858
31976
  .option('no-cleanup', {
31859
31977
  type: 'flag'
31860
31978
  })
@@ -32930,10 +33048,6 @@ ${svg}
32930
33048
  .option('method', {
32931
33049
  // hidden option (set by the paek/gaussian flags)
32932
33050
  })
32933
- .option('keep-corners', {
32934
- describe: 'preserve sharp corners where straight-line segments meet',
32935
- type: 'flag'
32936
- })
32937
33051
  .option('gain', {
32938
33052
  describe: 'shrinkage-correction (default 1; 0 = none, >1 exaggerates bends)',
32939
33053
  type: 'number'
@@ -32942,6 +33056,22 @@ ${svg}
32942
33056
  describe: 'max bend between output segments in degrees (default is 8)',
32943
33057
  type: 'number'
32944
33058
  })
33059
+ .option('no-corners', {
33060
+ describe: 'round sharp corners instead of preserving them (on by default)',
33061
+ type: 'flag'
33062
+ })
33063
+ .option('corner-bias', {
33064
+ // undocumented: corner-preservation sensitivity (default 1). Its inverse
33065
+ // scales the min structural-run length, so corner-bias=0.5 doubles it (fewer
33066
+ // corners kept) and corner-bias=2 halves it (more corners kept).
33067
+ // corner-bias=0 turns corner preservation off entirely.
33068
+ type: 'number'
33069
+ })
33070
+ .option('prefilter-gate', {
33071
+ // undocumented: prefilter sinuosity (path/chord) threshold for cutting
33072
+ // intricate detail (default 4; higher removes less)
33073
+ type: 'number'
33074
+ })
32945
33075
  .option('planar', {
32946
33076
  // describe: 'smooth decimal degree coords in 2D space (default is spherical)',
32947
33077
  type: 'flag'
@@ -41186,6 +41316,24 @@ ${svg}
41186
41316
  return dataset;
41187
41317
  }
41188
41318
 
41319
+ // Dissolve options shared by clean-outline polygon grow and two-sided line
41320
+ // outline buffers. per_part_holes treats each input ring as an independent
41321
+ // overlapping union part (not shape-wide hole nesting). Boundary-flood
41322
+ // membership (no winding_fill) matches the line buffer dissolve; spurious
41323
+ // interior rings are removed afterward by applyOutlineArtifactHoleFilter.
41324
+ function getOutlineBufferDissolveOpts(opts) {
41325
+ return Object.assign({}, opts, {per_part_holes: true});
41326
+ }
41327
+
41328
+ // True when geodesic gap-patch stadiums should be unioned into the dissolve.
41329
+ // Disabled in line debug-offset (patch rings broke the m_loops debug view) but
41330
+ // kept on for polygon debug-offset so the extra stadium rings are visible.
41331
+ function useGapPatch(opts, geodesic) {
41332
+ if (!geodesic || opts.no_gap_patch) return false;
41333
+ if (opts.debug_offset) return opts.geometry_type == 'polygon';
41334
+ return true;
41335
+ }
41336
+
41189
41337
  function dissolveBufferDataset2(dataset, optsArg) {
41190
41338
  var opts = optsArg || {};
41191
41339
  var lyr = dataset.layers.filter(function(l) { return l.geometry_type == 'polygon'; })[0] ||
@@ -41220,7 +41368,7 @@ ${svg}
41220
41368
  var pathfind = getRingIntersector(mosaicIndex.nodes);
41221
41369
  var shapes2 = lyr.shapes.map(function(shp, shapeId) {
41222
41370
  var tiles = opts.winding_fill ?
41223
- mosaicIndex.getWindingTilesByShapeId(shapeId) :
41371
+ mosaicIndex.getWindingTilesByShapeId(shapeId, false) :
41224
41372
  mosaicIndex.getTilesByShapeIds([shapeId]);
41225
41373
  var rings = [];
41226
41374
  var holes = [];
@@ -41309,12 +41457,10 @@ ${svg}
41309
41457
  // pre-simplification is disabled. Enabled by default with a tolerance of
41310
41458
  // 1% of the buffer radius; pass an explicit tolerance to change the error
41311
41459
  // budget, or tolerance=0 to disable pre-simplification.
41312
- // For a two-sided buffer (the set of points within the buffer distance of
41313
- // the path), the error is bounded by the path's positional deviation. Cap
41314
- // geometry and one-sided buffers also depend on segment bearings; the
41315
- // simplification stage preserves them by pinning the paths' end segments
41316
- // and capping the turning concentrated by removed sub-paths (see
41317
- // presimplifyPathVerts in mapshaper-path-buffer-v4.mjs).
41460
+ // The error budget is expressed as a positional Douglas-Peucker interval
41461
+ // (see presimplifyPathVerts in mapshaper-path-buffer-v4.mjs). End-segment
41462
+ // bearings are pinned so cap geometry stays exact; pass tolerance=0 to
41463
+ // disable pre-simplification entirely.
41318
41464
  function getBufferSimplifyFunction(dataset, opts) {
41319
41465
  if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') return null;
41320
41466
  var tolFn = getBufferToleranceFunction(dataset, opts);
@@ -41384,7 +41530,7 @@ ${svg}
41384
41530
  // that a collapsible overshoot pocket must never span.
41385
41531
  function BufferBuilder() {
41386
41532
  var self = {};
41387
- var buffer, path, bufferPos, pathPos;
41533
+ var buffer, path, bufferPos, pathPos, bufferTags, pathTags;
41388
41534
 
41389
41535
  init();
41390
41536
 
@@ -41393,6 +41539,12 @@ ${svg}
41393
41539
  path = [];
41394
41540
  bufferPos = [];
41395
41541
  pathPos = [];
41542
+ // Parallel reversed-arc ("dip") tags: 1 marks a vertex emitted as part of a
41543
+ // reversed concave-join arc (a pure self-overlap construction artifact) so
41544
+ // the coverage-based loop remover can key on provenance. Path-side vertices
41545
+ // (the source-path edge) and untagged callers default to 0.
41546
+ bufferTags = [];
41547
+ pathTags = [];
41396
41548
  }
41397
41549
 
41398
41550
  self.size = function() {
@@ -41402,31 +41554,35 @@ ${svg}
41402
41554
  self.addPathVertex = function(p) {
41403
41555
  path.push(p);
41404
41556
  pathPos.push(NaN);
41557
+ pathTags.push(0);
41405
41558
  };
41406
41559
 
41407
- self.addBufferVertices = function(arr, pos) {
41560
+ self.addBufferVertices = function(arr, pos, tag) {
41408
41561
  for (var i=0; i<arr.length; i++) {
41409
- self.addBufferVertex(arr[i], pos);
41562
+ self.addBufferVertex(arr[i], pos, tag);
41410
41563
  }
41411
41564
  };
41412
41565
 
41413
- self.addBufferVertex = function(p, pos) {
41566
+ self.addBufferVertex = function(p, pos, tag) {
41414
41567
  var prevP = buffer[buffer.length - 1];
41415
41568
  if (prevP && pointsAreSame(prevP, p)) {
41416
41569
  return;
41417
41570
  }
41418
41571
  buffer.push(p);
41419
41572
  bufferPos.push(pos === undefined ? NaN : pos);
41573
+ bufferTags.push(tag ? 1 : 0);
41420
41574
  };
41421
41575
 
41422
- // Returns {ring, srcPos}: ring is the closed coordinate ring (first point
41423
- // repeated as last); srcPos is the parallel source-position array.
41576
+ // Returns {ring, srcPos, dipTags}: ring is the closed coordinate ring (first
41577
+ // point repeated as last); srcPos and dipTags are parallel arrays (source
41578
+ // position and reversed-arc tag).
41424
41579
  // @allowDegenerate: return null instead of erroring when the ring collapsed
41425
41580
  // to fewer than 3 points (an offset loop whose source ring shrank away, e.g.
41426
41581
  // a hole smaller than the buffer radius) -- a normal outcome, not a bug.
41427
41582
  self.done = function(allowDegenerate) {
41428
41583
  var ring = path.slice().reverse().concat(buffer);
41429
41584
  var srcPos = pathPos.slice().reverse().concat(bufferPos);
41585
+ var dipTags = pathTags.slice().reverse().concat(bufferTags);
41430
41586
  if (ring.length < 3) {
41431
41587
  if (allowDegenerate) {
41432
41588
  init();
@@ -41436,8 +41592,9 @@ ${svg}
41436
41592
  }
41437
41593
  ring.push(ring[0].concat());
41438
41594
  srcPos.push(srcPos[0]);
41595
+ dipTags.push(dipTags[0]);
41439
41596
  init();
41440
- return {ring: ring, srcPos: srcPos};
41597
+ return {ring: ring, srcPos: srcPos, dipTags: dipTags};
41441
41598
  };
41442
41599
 
41443
41600
  return self;
@@ -41447,6 +41604,298 @@ ${svg}
41447
41604
  return a[0] === b[0] && a[1] === b[1];
41448
41605
  }
41449
41606
 
41607
+ // Chunk size for the per-ring source segment index used by wedgeIsExposed().
41608
+ var WEDGE_SEGMENT_CHUNK_SIZE = 48;
41609
+
41610
+ // Build a flat segment index for a source path vertex list (one ring). Each chunk
41611
+ // stores up to WEDGE_SEGMENT_CHUNK_SIZE consecutive segments with a bounding box
41612
+ // so probeIsExposed() can skip segments far from the probe.
41613
+ function buildVertsSegmentIndex(verts, chunkSize) {
41614
+ chunkSize = chunkSize || WEDGE_SEGMENT_CHUNK_SIZE;
41615
+ var coords = [];
41616
+ var chunks = [];
41617
+ var n = verts.length;
41618
+ if (n < 2) return {coords: coords, chunks: chunks};
41619
+ var inChunk = 0;
41620
+ var chunkSegStart = 0;
41621
+ var start = 0;
41622
+ var xmin = 0, ymin = 0, xmax = 0, ymax = 0;
41623
+ var ax, ay, bx, by, i;
41624
+ for (i = 0; i < n - 1; i++) {
41625
+ ax = verts[i][0];
41626
+ ay = verts[i][1];
41627
+ bx = verts[i + 1][0];
41628
+ by = verts[i + 1][1];
41629
+ if (inChunk === 0) {
41630
+ chunkSegStart = i;
41631
+ start = coords.length / 4;
41632
+ xmin = Math.min(ax, bx);
41633
+ xmax = Math.max(ax, bx);
41634
+ ymin = Math.min(ay, by);
41635
+ ymax = Math.max(ay, by);
41636
+ } else {
41637
+ if (ax < xmin) xmin = ax; else if (ax > xmax) xmax = ax;
41638
+ if (bx < xmin) xmin = bx; else if (bx > xmax) xmax = bx;
41639
+ if (ay < ymin) ymin = ay; else if (ay > ymax) ymax = ay;
41640
+ if (by < ymin) ymin = by; else if (by > ymax) ymax = by;
41641
+ }
41642
+ coords.push(ax, ay, bx, by);
41643
+ inChunk++;
41644
+ if (inChunk === chunkSize) {
41645
+ chunks.push({
41646
+ start: start,
41647
+ end: coords.length / 4,
41648
+ segStart: chunkSegStart,
41649
+ segEnd: i + 1,
41650
+ xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax
41651
+ });
41652
+ inChunk = 0;
41653
+ }
41654
+ }
41655
+ if (inChunk > 0) {
41656
+ chunks.push({
41657
+ start: start,
41658
+ end: coords.length / 4,
41659
+ segStart: chunkSegStart,
41660
+ segEnd: n - 1,
41661
+ xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax
41662
+ });
41663
+ }
41664
+ return {coords: coords, chunks: chunks};
41665
+ }
41666
+
41667
+ // True if any point of the round-join wedge at a fan-apart concave bend is NOT
41668
+ // covered by another source segment. Probes the concave-bridge arc plus the two
41669
+ // offset tips; tips alone miss bends whose tips are covered but whose arc flank
41670
+ // is still exposed (see idaho 150km regression).
41671
+ function wedgeIsExposed(index, skipA, skipB, vx, vy, arc, tipA, tipB) {
41672
+ var i;
41673
+ if (probeIsExposed(index, skipA, skipB, vx, vy, tipA) ||
41674
+ probeIsExposed(index, skipA, skipB, vx, vy, tipB)) {
41675
+ return true;
41676
+ }
41677
+ for (i = 0; i < arc.length; i++) {
41678
+ if (probeIsExposed(index, skipA, skipB, vx, vy, arc[i])) return true;
41679
+ }
41680
+ return false;
41681
+ }
41682
+
41683
+ function probeIsExposed(index, skipA, skipB, vx, vy, p) {
41684
+ var r = distance2D(vx, vy, p[0], p[1]);
41685
+ if (!(r > 0)) return false;
41686
+ var r2 = r * r * 0.98 * 0.98;
41687
+ var px = p[0], py = p[1];
41688
+ var chunks = index.chunks;
41689
+ var coords = index.coords;
41690
+ var c, chunk, s, o, d;
41691
+ for (c = 0; c < chunks.length; c++) {
41692
+ chunk = chunks[c];
41693
+ if (chunkBoxDistSq(px, py, chunk) >= r2) continue;
41694
+ for (s = chunk.segStart; s < chunk.segEnd; s++) {
41695
+ if (s === skipA || s === skipB) continue;
41696
+ o = (chunk.start + (s - chunk.segStart)) * 4;
41697
+ d = pointSegDistSq2(px, py, coords[o], coords[o + 1], coords[o + 2], coords[o + 3]);
41698
+ if (d < r2) return false;
41699
+ }
41700
+ }
41701
+ return true;
41702
+ }
41703
+
41704
+ function chunkBoxDistSq(px, py, chunk) {
41705
+ var dx = px < chunk.xmin ? chunk.xmin - px : (px > chunk.xmax ? px - chunk.xmax : 0);
41706
+ var dy = py < chunk.ymin ? chunk.ymin - py : (py > chunk.ymax ? py - chunk.ymax : 0);
41707
+ return dx * dx + dy * dy;
41708
+ }
41709
+
41710
+ var R$2 = WGS84.SEMIMAJOR_AXIS;
41711
+
41712
+ // GeographicLib docs: https://geographiclib.sourceforge.io/html/js/
41713
+ // https://geographiclib.sourceforge.io/html/js/module-GeographicLib_Geodesic.Geodesic.html
41714
+ // https://geographiclib.sourceforge.io/html/js/tutorial-2-interface.html
41715
+ function getGeodesic(P) {
41716
+ if (!isLatLngCRS(P)) error('Expected an unprojected CRS');
41717
+ var f = P.es / (1 + Math.sqrt(P.one_es));
41718
+ // var GeographicLib = require('mproj').internal.GeographicLib;
41719
+ var GeographicLib = require$1('geographiclib-geodesic');
41720
+ // return new GeographicLib.Geodesic.Geodesic(P.a, 0)
41721
+ return new GeographicLib.Geodesic.Geodesic(P.a, f);
41722
+ }
41723
+
41724
+ function interpolatePoint2D(ax, ay, bx, by, k) {
41725
+ var j = 1 - k;
41726
+ return [ax * j + bx * k, ay * j + by * k];
41727
+ }
41728
+
41729
+ function getInterpolationFunction(P) {
41730
+ var spherical = P && isLatLngCRS(P);
41731
+ if (!spherical) return interpolatePoint2D;
41732
+ var geod = getGeodesic(P);
41733
+ return function(lng, lat, lng2, lat2, k) {
41734
+ var r = geod.Inverse(lat, lng, lat2, lng2);
41735
+ var dist = r.s12 * k;
41736
+ var r2 = geod.Direct(lat, lng, r.azi1, dist);
41737
+ return [r2.lon2, r2.lat2];
41738
+ };
41739
+ }
41740
+
41741
+ function getPlanarSegmentEndpoint(x, y, bearing, meterDist) {
41742
+ var rad = bearing / 180 * Math.PI;
41743
+ var dx = Math.sin(rad) * meterDist;
41744
+ var dy = Math.cos(rad) * meterDist;
41745
+ return [x + dx, y + dy];
41746
+ }
41747
+
41748
+ // source: https://github.com/mapbox/cheap-ruler/blob/master/index.js
41749
+ function fastGeodeticSegmentFunction(lng, lat, bearing, meterDist) {
41750
+ var D2R = Math.PI / 180;
41751
+ var cos = Math.cos(lat * D2R);
41752
+ var cos2 = 2 * cos * cos - 1;
41753
+ var cos3 = 2 * cos * cos2 - cos;
41754
+ var cos4 = 2 * cos * cos3 - cos2;
41755
+ var cos5 = 2 * cos * cos4 - cos3;
41756
+ var kx = (111.41513 * cos - 0.09455 * cos3 + 0.00012 * cos5) * 1000;
41757
+ var ky = (111.13209 - 0.56605 * cos2 + 0.0012 * cos4) * 1000;
41758
+ var bearingRad = bearing * D2R;
41759
+ var lat2 = lat + Math.cos(bearingRad) * meterDist / ky;
41760
+ var lng2 = lng + Math.sin(bearingRad) * meterDist / kx;
41761
+ return [lng2, lat2];
41762
+ }
41763
+
41764
+
41765
+ function wrap(deg) {
41766
+ while (deg < -180) deg += 360;
41767
+ while (deg > 180) deg -= 360;
41768
+ return deg;
41769
+ }
41770
+
41771
+ function fastGeodeticBearingFunction(lng1, lat1, lng2, lat2) {
41772
+ var D2R = Math.PI / 180;
41773
+ var f = 1 / 298.257223563;
41774
+ var e2 = f * (2 - f);
41775
+ var m = R$2 * D2R;
41776
+ var coslat = Math.cos(lat1 * D2R);
41777
+ var w2 = 1 / (1 - e2 * (1 - coslat * coslat));
41778
+ var w = Math.sqrt(w2);
41779
+ var kx = m * w * coslat;
41780
+ var ky = m * w * w2 * (1 - e2);
41781
+ var dx = wrap(lng2 - lng1) * kx;
41782
+ var dy = (lat2 - lat1) * ky;
41783
+ return Math.atan2(dx, dy) / D2R;
41784
+ }
41785
+
41786
+ function getGeodeticSegmentFunction(P) {
41787
+ if (!isLatLngCRS(P)) {
41788
+ return getPlanarSegmentEndpoint;
41789
+ }
41790
+ var g = getGeodesic(P);
41791
+ return function(lng, lat, bearing, meterDist) {
41792
+ var o = g.Direct(lat, lng, bearing, meterDist);
41793
+ var p = [o.lon2, o.lat2];
41794
+ return p;
41795
+ };
41796
+ }
41797
+
41798
+ function getFastGeodeticSegmentFunction(P) {
41799
+ // CAREFUL: this function has higher error at very large distances and at the poles
41800
+ // also, it wouldn't work for other planets than Earth
41801
+ return isLatLngCRS(P) ? fastGeodeticSegmentFunction : getPlanarSegmentEndpoint;
41802
+ }
41803
+
41804
+ // return function to calculate bearing of a segment in degrees
41805
+ function getBearingFunction(dataset) {
41806
+ var P = getDatasetCRS(dataset);
41807
+ // return isLatLngCRS(P) ? bearingDegrees : bearingDegrees2D;
41808
+ return isLatLngCRS(P) ? fastGeodeticBearingFunction : bearingDegrees2D;
41809
+ }
41810
+
41811
+ // get bearing in degrees from point ab to point cd
41812
+ function bearingDegrees(a, b, c, d) {
41813
+ return geom.bearing(a, b, c, d) * 180 / Math.PI;
41814
+ }
41815
+
41816
+ function bearingDegrees2D(a, b, c, d) {
41817
+ return geom.bearing2D(a, b, c, d) * 180 / Math.PI;
41818
+ }
41819
+
41820
+ var Geodesic = /*#__PURE__*/Object.freeze({
41821
+ __proto__: null,
41822
+ bearingDegrees: bearingDegrees,
41823
+ bearingDegrees2D: bearingDegrees2D,
41824
+ getBearingFunction: getBearingFunction,
41825
+ getFastGeodeticSegmentFunction: getFastGeodeticSegmentFunction,
41826
+ getGeodeticSegmentFunction: getGeodeticSegmentFunction,
41827
+ getInterpolationFunction: getInterpolationFunction,
41828
+ getPlanarSegmentEndpoint: getPlanarSegmentEndpoint,
41829
+ interpolatePoint2D: interpolatePoint2D,
41830
+ wrap: wrap
41831
+ });
41832
+
41833
+ function getJoinAngle(direction1, direction2) {
41834
+ var delta = direction2 - direction1;
41835
+ if (delta > 180) delta -= 360;
41836
+ if (delta < -180) delta += 360;
41837
+ return delta;
41838
+ }
41839
+
41840
+ // Ring-step distance from each vertex to the nearest concave vertex, in O(n).
41841
+ function minDistToConcaveOnRing(concave, n) {
41842
+ var dist = new Array(n);
41843
+ var d, i, j;
41844
+ for (j = 0; j < n; j++) dist[j] = n;
41845
+ d = n;
41846
+ for (i = 0; i < 2 * n; i++) {
41847
+ j = i % n;
41848
+ if (concave[j]) d = 0;
41849
+ else d++;
41850
+ if (d < dist[j]) dist[j] = d;
41851
+ }
41852
+ d = n;
41853
+ for (i = 2 * n - 1; i >= 0; i--) {
41854
+ j = i % n;
41855
+ if (concave[j]) d = 0;
41856
+ else d++;
41857
+ if (d < dist[j]) dist[j] = d;
41858
+ }
41859
+ return dist;
41860
+ }
41861
+
41862
+ // Pick the index of the edge (vk -> vk+1) whose midpoint makes the best offset
41863
+ // seam: an edge with two convex endpoints that lies as far as possible (in ring
41864
+ // steps) from any concave corner, breaking ties toward the longest such edge.
41865
+ function chooseSeamEdge(verts) {
41866
+ var n = verts.length - 1; // distinct vertices
41867
+ if (n < 3) return 0;
41868
+ var concave = [];
41869
+ var anyConcave = false;
41870
+ var bPrev = bearingDegrees2D(verts[n - 1][0], verts[n - 1][1], verts[0][0], verts[0][1]);
41871
+ var i, ni, b, ja;
41872
+ for (i = 0; i < n; i++) {
41873
+ ni = (i + 1) % n;
41874
+ b = bearingDegrees2D(verts[i][0], verts[i][1], verts[ni][0], verts[ni][1]);
41875
+ ja = getJoinAngle(bPrev, b);
41876
+ concave[i] = ja < 0;
41877
+ if (concave[i]) anyConcave = true;
41878
+ bPrev = b;
41879
+ }
41880
+ if (!anyConcave) return 0;
41881
+ var minDist = minDistToConcaveOnRing(concave, n);
41882
+ var bestK = -1, bestScore = -1, bestLen = -1;
41883
+ var fallbackK = 0, fallbackLen = -1;
41884
+ var k, len, score, a, c;
41885
+ for (k = 0; k < n; k++) {
41886
+ a = verts[k];
41887
+ c = verts[(k + 1) % n];
41888
+ len = Math.abs(a[0] - c[0]) + Math.abs(a[1] - c[1]);
41889
+ if (len > fallbackLen) { fallbackLen = len; fallbackK = k; }
41890
+ if (concave[k] || concave[(k + 1) % n]) continue;
41891
+ score = Math.min(minDist[k], minDist[(k + 1) % n]);
41892
+ if (score > bestScore || (score === bestScore && len > bestLen)) {
41893
+ bestScore = score; bestLen = len; bestK = k;
41894
+ }
41895
+ }
41896
+ return bestK >= 0 ? bestK : fallbackK;
41897
+ }
41898
+
41450
41899
  // Removes small self-overlap "fold-back" loops from a constructed two-sided
41451
41900
  // buffer outline ring before it is handed to the dissolve.
41452
41901
  //
@@ -41475,9 +41924,8 @@ ${svg}
41475
41924
  // of pocket orientation; otherwise it is left for the dissolve.
41476
41925
 
41477
41926
  // Look-ahead window: how many ring segments ahead to test for a crossing. The
41478
- // turn gate (not the window) is the safety criterion, so this only bounds cost
41479
- // (O(n * window)); it must still be wide enough to reach the far side of an
41480
- // overshoot loop, which can include many round-join vertices at large radii.
41927
+ // turn gate (not the window) is the safety criterion in removeBufferRingLoops;
41928
+ // in the dip+coverage iterative path it only bounds cost (O(n * window)).
41481
41929
  var BUFFER_LOOP_WINDOW = 30;
41482
41930
 
41483
41931
  // Max source-path turn (degrees) a collapsible loop may span. Below this the
@@ -41486,6 +41934,27 @@ ${svg}
41486
41934
  // for the dissolve.
41487
41935
  var BUFFER_LOOP_MAX_TURN = 150;
41488
41936
 
41937
+ // Dip-tag iterative remover: a candidate collapse is allowed only if the region
41938
+ // it drops stays covered by the rest of the outline (collapseKeepsAreaCovered).
41939
+ // That per-collapse winding sweep is O(spanLen * ringLen), so it is skipped when
41940
+ // the dropped loop's own area is below this threshold -- such a loop cannot
41941
+ // create a clip larger than the threshold, so nothing "big" is missed. In ring
41942
+ // units (m^2 for planar; web-Mercator m^2 for lat/lng, whose scale factor >= 1
41943
+ // keeps this an upper bound on the real area, so latitude never hides a big clip).
41944
+ var BUFFER_LOOP_CHECK_MIN_AREA = 3e4;
41945
+
41946
+ // Hole-fill guard (dip+coverage path). A collapse is also refused if it would
41947
+ // swallow a winding-0 region (a real buffer hole or an open outer-wall notch)
41948
+ // larger than this fraction of the buffer disk (pi*dist^2, passed as fillFloor =
41949
+ // dist^2 * this). A genuine hole is a fixed fraction of the disk (the line wound
41950
+ // far enough to leave a region the radius can't reach), while a self-overlap fold
41951
+ // only pinches off a sliver orders of magnitude smaller relative to the radius --
41952
+ // so a disk-relative floor separates them with a wide margin where an absolute
41953
+ // area threshold does not (a 10km fold sliver and a 650m real hole have similar
41954
+ // absolute areas). Leans low (toward preserving holes): a false veto only leaves
41955
+ // a self-overlap for the dissolve, while a false pass deletes a real hole.
41956
+ var BUFFER_LOOP_FILL_AREA_FRAC = 5e-4;
41957
+
41489
41958
  // ring: closed ring (first point repeated as last) of [x, y] points.
41490
41959
  // srcPos (optional): source-vertex position parallel to ring; NaN for points
41491
41960
  // (caps) with no single source segment.
@@ -41574,98 +42043,180 @@ ${svg}
41574
42043
  return s;
41575
42044
  }
41576
42045
 
41577
- // Collapse self-overlap loops using the crossing-direction signal instead of the
41578
- // source-turn gate (removeBufferRingLoops). Where a constructed offset ring has a
41579
- // consistent +/-1 base winding -- the two-sided outline of an OPEN path -- the
41580
- // winding sense of each minimal self-crossing loop classifies it exactly: a
41581
- // sub-loop wound the SAME way as its parent ring is a covered fold-back overlap
41582
- // (collapse it; the dissolve would only fill it), while a sub-loop wound the
41583
- // OPPOSITE way bounds a winding-0 pocket the dissolve must keep as a hole.
41584
- //
41585
- // This is more precise than the turn-gate's source-turn heuristic and needs no
41586
- // source-path provenance (srcPos/turnPrefix), only the ring geometry. It does
41587
- // NOT apply to the winding-fill construction used for closed rings / polygons,
41588
- // where the base winding is not a constant +/-1, so a local loop's winding sense
41589
- // does not determine the absolute (hole vs covered) winding of its interior.
41590
- //
41591
- // Pass 1 marks every vertex inside an opposite-wound (hole) loop so the collapse
41592
- // pass never eats a hole, including an overlap loop that happens to wrap one.
41593
- function removeBufferRingLoopsByDirection(ring, maxGap) {
42046
+ // Absolute source turn spanned by a crossing candidate. The span covers the
42047
+ // anchor position `apos`, its successor `bpos`, and ring positions srcPos[lo..hi].
42048
+ // A pocket touching a cap (NaN position) is never treated as a covered overshoot.
42049
+ function getSpanTurn(apos, bpos, srcPos, lo, hi, turnPrefix) {
42050
+ if (apos !== apos || bpos !== bpos) return Infinity; // NaN cap
42051
+ var posLo = apos < bpos ? apos : bpos;
42052
+ var posHi = apos > bpos ? apos : bpos;
42053
+ for (var k = lo; k <= hi; k++) {
42054
+ var p = srcPos[k];
42055
+ if (p !== p) return Infinity; // NaN
42056
+ if (p < posLo) posLo = p;
42057
+ if (p > posHi) posHi = p;
42058
+ }
42059
+ return turnPrefix[posHi] - turnPrefix[posLo];
42060
+ }
42061
+
42062
+ // Multi-pass collapse gated by the offset ring's own cumulative absolute turn over the collapsed span, a
42063
+ // provenance-free proxy for the source-path turn gate (each offset join's turn
42064
+ // angle equals the source bend angle it derives from, and straight offset
42065
+ // segments contribute zero turn, so summing |turn| over a ring span with no cap
42066
+ // reconstructs the source stretch's cumulative turn). Iterating lets the
42067
+ // collapse of tight inner overshoot loops shorten the spans of the loops that
42068
+ // wrap them, so a wrapper that was too-large-turn on one pass can become a
42069
+ // single-bend covered overshoot on the next -- the multi-pass "peel simple
42070
+ // interior loops first" idea. maxTurn is the same gate constant as the source
42071
+ // turn gate; caps (180-degree arcs) exceed it and so are never collapsed.
42072
+ // @fillFloor (dip+coverage path): max winding-0 area a collapse may swallow; a
42073
+ // collapse filling a larger hole/notch is refused (see BUFFER_LOOP_FILL_AREA_FRAC).
42074
+ // Callers pass dist^2 * BUFFER_LOOP_FILL_AREA_FRAC; defaults to the uncovered
42075
+ // floor when omitted (unit tests without a buffer distance).
42076
+ function removeBufferRingLoopsIterative(ring, maxGap, srcPos, turnPrefix, maxTurn, dipTags, maxPasses, fillFloor) {
41594
42077
  if (!ring || ring.length < 6) return ring;
42078
+ if (maxTurn === undefined) maxTurn = BUFFER_LOOP_MAX_TURN;
42079
+ if (!(maxPasses >= 1)) maxPasses = 12;
42080
+ if (!(fillFloor >= 0)) fillFloor = BUFFER_LOOP_CHECK_MIN_AREA;
42081
+ var work = ring, workPos = null, workTags = dipTags || null;
42082
+ for (var pass = 0; pass < maxPasses; pass++) {
42083
+ var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn, workTags, fillFloor);
42084
+ if (res.ring.length === work.length) return res.ring; // stable
42085
+ work = res.ring; workPos = res.srcPos; workTags = res.dipTags;
42086
+ }
42087
+ return work;
42088
+ }
42089
+
42090
+ // One greedy forward-collapse pass (mirrors removeBufferRingLoops' compaction).
42091
+ // The span gate is, in order of preference:
42092
+ // - the reliable source-path turn (getSpanTurn) when srcPos+turnPrefix given;
42093
+ // - else the ring's cumulative turn with tagged fold cusps excluded, when
42094
+ // dipTags (construction-tagged reversed concave-join arcs) are supplied;
42095
+ // - else the same but with a geometric cusp threshold (no provenance).
42096
+ // Returns {ring, srcPos, dipTags} so the caller can iterate.
42097
+ function collapseRingLoopsPass(ring, maxGap, srcPos, turnPrefix, maxTurn, dipTags, fillFloor) {
42098
+ var gated = !!(srcPos && turnPrefix);
41595
42099
  var n = ring.length - 1;
41596
- var parentCCW = (ringSignedArea(ring) >= 0);
41597
- // Pass 1: a sub-loop wound OPPOSITE to its parent ring encloses a winding-0
41598
- // hole the dissolve must keep; mark its vertices so the collapse pass never
41599
- // eats it (covers an overlap loop that happens to wrap a hole).
41600
- var holeVertex = new Uint8Array(n);
41601
- for (var i = 0; i < n - 1; i++) {
41602
- var a = ring[i], b = ring[i + 1];
41603
- var jMax = Math.min(i + maxGap, n - 1);
41604
- for (var j = i + 2; j <= jMax; j++) {
41605
- if (i === 0 && j === n - 1) continue;
41606
- var c = ring[j], d = ring[j + 1];
41607
- var hit = segHit(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1]);
41608
- if (!hit) continue;
41609
- var loopCCW = loopAreaSign(hit[0], hit[1], b[0], b[1], ring, i + 2, j) >= 0;
41610
- if (loopCCW !== parentCCW) {
41611
- for (var k = i + 1; k <= j; k++) holeVertex[k] = 1;
41612
- }
41613
- }
41614
- }
41615
- // Pass 2: collapse sub-loops wound the SAME way as the parent (covered
41616
- // fold-back overlaps) unless they would eat a hole vertex.
42100
+ // The dip path defers entirely to the exact coverage check, so it needs neither
42101
+ // the tag-excluded turn gate nor its cumulative-turn prefix; the other two paths
42102
+ // (source-turn, geometric) have no coverage check and keep the turn gate.
42103
+ var coveragePath = !gated && !!dipTags;
42104
+ var ringTurn = (!gated && !dipTags) ? ringAbsTurnPrefix(ring, dipTags) : null;
42105
+ // Build the ring's y-band edge index once per pass so the coverage check's
42106
+ // per-loop edge lookup is local rather than O(ring length).
42107
+ var covIndex = coveragePath ? buildEdgeYIndex(ring, n) : null;
42108
+ // Opposite-wound hole protection. The coverage check only guards against
42109
+ // UNCOVERING area, so it cannot catch a collapse that FILLS a real winding-0
42110
+ // hole (filling adds coverage), and its area pre-filter treats any sub-floor
42111
+ // loop as safe to drop. A dropped sub-loop wound OPPOSITE to the parent ring
42112
+ // bounds such a hole, so refuse the collapse outright -- this keeps real holes
42113
+ // (annulus interiors, self-crossing-line pockets) the coverage check alone
42114
+ // misses, at the cost of only an O(span) signed-area test on the crossings the
42115
+ // collapse actually evaluates (far cheaper than the old O(n*maxGap) per-pass
42116
+ // pre-scan). A same-wound overshoot fold that happens to WRAP a hole is caught
42117
+ // instead by the fill guard in the coverage check (it measures filled area).
42118
+ var parentCCW = coveragePath ? (ringSignedArea(ring) >= 0) : false;
41617
42119
  var out = [ring[0]];
42120
+ var outPos = gated ? [srcPos[0]] : null;
42121
+ var outTags = dipTags ? [dipTags[0]] : null;
41618
42122
  var segmentEnd = ring[1];
42123
+ var segmentEndPos = gated ? srcPos[1] : 0;
42124
+ var segmentEndTag = dipTags ? dipTags[1] : 0;
41619
42125
  var nextRingIndex = 2;
41620
42126
  while (true) {
41621
42127
  var anchor = out[out.length - 1];
42128
+ var anchorPos = gated ? outPos[outPos.length - 1] : 0;
41622
42129
  var ax = anchor[0], ay = anchor[1], bx = segmentEnd[0], by = segmentEnd[1];
41623
42130
  var lastScanIndex = Math.min(nextRingIndex + maxGap - 2, n - 2);
41624
42131
  var crossing = null, crossingEndIndex = 0;
41625
42132
  for (var s = nextRingIndex; s <= lastScanIndex; s++) {
41626
- var cc = ring[s], dd = ring[s + 1];
41627
- var hit2 = segHit(ax, ay, bx, by, cc[0], cc[1], dd[0], dd[1]);
41628
- if (!hit2) continue;
41629
- var ovCCW = loopAreaSign(hit2[0], hit2[1], bx, by, ring, nextRingIndex, s) >= 0;
41630
- if (ovCCW !== parentCCW) continue; // opposite-wound loop is a hole: keep
41631
- var wrapsHole = false;
41632
- for (var k2 = nextRingIndex; k2 <= s; k2++) {
41633
- if (holeVertex[k2]) { wrapsHole = true; break; }
42133
+ var c = ring[s], d = ring[s + 1];
42134
+ var hit = segHit(ax, ay, bx, by, c[0], c[1], d[0], d[1]);
42135
+ if (!hit) continue;
42136
+ if (coveragePath) {
42137
+ // Never collapse a sub-loop wound opposite to the parent: it bounds a
42138
+ // real winding-0 hole the coverage check cannot see being filled (see the
42139
+ // hole-protection note above).
42140
+ if ((loopAreaSign(hit[0], hit[1], bx, by, ring, nextRingIndex, s) >= 0) !== parentCCW) {
42141
+ continue;
42142
+ }
42143
+ // No turn gate on the dip path: the exact coverage check is the arbiter.
42144
+ // It measures how much of the dropped region would become uncovered and
42145
+ // refuses collapses that would clip a significant lobe (see
42146
+ // docs/development/buffer-line-notes.md), catching real lobes and end caps
42147
+ // that a cheap turn/area/winding signal cannot separate from safe folds.
42148
+ // The turn gate was both leaving valid interior loops uncollapsed (tight
42149
+ // hairpins whose source turn exceeds the cap) and slowing the pipeline by
42150
+ // deferring their removal to the dissolve.
42151
+ if (!collapseKeepsAreaCovered(ring, n, hit, segmentEnd, nextRingIndex, s, covIndex, fillFloor)) {
42152
+ continue; // dropping this loop would uncover or fill real area -- leave it
42153
+ }
42154
+ } else {
42155
+ // Source-turn / geometric paths: no coverage check, so gate on cumulative
42156
+ // turn (caps and large real bends exceed maxTurn and are never collapsed).
42157
+ var spanTurn = gated ?
42158
+ getSpanTurn(anchorPos, segmentEndPos, srcPos, nextRingIndex, s + 1, turnPrefix) :
42159
+ (ringTurn[s] - ringTurn[nextRingIndex - 1]);
42160
+ if (spanTurn >= maxTurn) continue;
41634
42161
  }
41635
- if (wrapsHole) continue;
41636
- crossing = hit2;
42162
+ crossing = hit;
41637
42163
  crossingEndIndex = s + 1;
41638
42164
  break;
41639
42165
  }
41640
42166
  if (crossing) {
41641
42167
  segmentEnd = crossing;
42168
+ if (gated) segmentEndPos = anchorPos;
42169
+ segmentEndTag = 0; // an offset crossing is not a dip vertex
41642
42170
  nextRingIndex = crossingEndIndex;
41643
42171
  continue;
41644
42172
  }
41645
42173
  out.push(segmentEnd);
42174
+ if (gated) outPos.push(segmentEndPos);
42175
+ if (dipTags) outTags.push(segmentEndTag);
41646
42176
  if (nextRingIndex > n - 1) break;
41647
42177
  segmentEnd = ring[nextRingIndex];
42178
+ if (gated) segmentEndPos = srcPos[nextRingIndex];
42179
+ if (dipTags) segmentEndTag = dipTags[nextRingIndex];
41648
42180
  nextRingIndex++;
41649
42181
  }
41650
- if (out.length < 4) return ring;
42182
+ if (out.length < 4) return {ring: ring, srcPos: srcPos, dipTags: dipTags};
41651
42183
  out.push(out[0].concat());
41652
- return out;
41653
- }
41654
-
41655
- // Absolute source turn spanned by a crossing candidate. The span covers the
41656
- // anchor position `apos`, its successor `bpos`, and ring positions srcPos[lo..hi].
41657
- // A pocket touching a cap (NaN position) is never treated as a covered overshoot.
41658
- function getSpanTurn(apos, bpos, srcPos, lo, hi, turnPrefix) {
41659
- if (apos !== apos || bpos !== bpos) return Infinity; // NaN cap
41660
- var posLo = apos < bpos ? apos : bpos;
41661
- var posHi = apos > bpos ? apos : bpos;
41662
- for (var k = lo; k <= hi; k++) {
41663
- var p = srcPos[k];
41664
- if (p !== p) return Infinity; // NaN
41665
- if (p < posLo) posLo = p;
41666
- if (p > posHi) posHi = p;
41667
- }
41668
- return turnPrefix[posHi] - turnPrefix[posLo];
42184
+ if (gated) outPos.push(outPos[0]);
42185
+ if (dipTags) outTags.push(outTags[0]);
42186
+ return {ring: out, srcPos: gated ? outPos : null, dipTags: dipTags ? outTags : null};
42187
+ }
42188
+
42189
+ // Cumulative absolute turn (degrees) indexed by ring vertex, EXCLUDING fold
42190
+ // cusps. prefix[k] = sum of |turn| at ring vertices 1..k-1, skipping the
42191
+ // near-180-degree cusps where the offset doubles back on itself (an artifact of
42192
+ // the self-crossing offset, with no corresponding source bend). A normal offset
42193
+ // join's turn equals its source bend angle, so the remaining sum reconstructs
42194
+ // the source stretch's cumulative turn -- the reliable loop-removal signal --
42195
+ // without source provenance.
42196
+ //
42197
+ // A cusp is identified by construction tag when dipTags are supplied (a vertex
42198
+ // on a reversed concave-join arc AND turning sharply); otherwise by a purely
42199
+ // geometric turn threshold. Tag-gating is the point: a real sharp bend (e.g. a
42200
+ // fjord mouth, emitted as a miter, never tagged) keeps its turn and so is never
42201
+ // mistaken for a fold, which is what makes the geometric-only threshold
42202
+ // over-collapse sharp coastlines.
42203
+ var CUSP_TURN = 135;
42204
+ function ringAbsTurnPrefix(ring, dipTags) {
42205
+ var n = ring.length - 1;
42206
+ var prefix = new Float64Array(n + 1);
42207
+ var RAD = 180 / Math.PI;
42208
+ for (var k = 1; k < n; k++) {
42209
+ var a = ring[k - 1], b = ring[k], c = ring[k + 1];
42210
+ var e1x = b[0] - a[0], e1y = b[1] - a[1];
42211
+ var e2x = c[0] - b[0], e2y = c[1] - b[1];
42212
+ var cross = e1x * e2y - e1y * e2x;
42213
+ var dot = e1x * e2x + e1y * e2y;
42214
+ var ang = Math.abs(Math.atan2(cross, dot)) * RAD;
42215
+ var isCusp = ang > CUSP_TURN && (dipTags ? dipTags[k] : true);
42216
+ prefix[k] = prefix[k - 1] + (isCusp ? 0 : ang);
42217
+ }
42218
+ prefix[n] = prefix[n - 1];
42219
+ return prefix;
41669
42220
  }
41670
42221
 
41671
42222
  // Fast strict-interior segment crossing; returns [x, y] or null. Buffer join
@@ -41687,6 +42238,213 @@ ${svg}
41687
42238
  return [ax + t * abx, ay + t * aby];
41688
42239
  }
41689
42240
 
42241
+ // Exact per-collapse coverage test. A collapse drops the loop
42242
+ // L = [X, b, ring[nextRingIndex..s], X] and replaces anchor->b..c->d with
42243
+ // anchor->X->d; it is area-neutral iff the dropped region stays covered by the
42244
+ // rest of the outline. The dropped span is mostly reversed-arc "dip" folds
42245
+ // (double-covered self-overlap, safe to drop), but it may also swallow a genuine
42246
+ // offset lobe (single-covered real boundary). We measure exactly how much of the
42247
+ // dropped region would become UNCOVERED and refuse the collapse when that exceeds
42248
+ // a "big clip" threshold; small residual clips are left for the dissolve.
42249
+ //
42250
+ // Coverage is decided by winding against the stable pass-input ring (not the
42251
+ // mid-pass output), so it is independent of collapse order: a point in the loop
42252
+ // is still covered afterward iff windingFullRing(point) - windingLoop(point) != 0.
42253
+ // For a two-sided line outline the main body always covers the buffer interior,
42254
+ // so a fold has |winding| >= 2 (body + fold) and survives losing one layer, while
42255
+ // real boundary has |winding| == 1 and drops to 0. The uncovered area is
42256
+ // integrated with a horizontal scanline (below) rather than point-sampled, so an
42257
+ // interior uncovered pocket cannot be missed.
42258
+ //
42259
+ // An area pre-filter keeps this off the hot path: a loop whose own area is below
42260
+ // the threshold cannot produce a clip above it, so only large loops are swept.
42261
+ // The threshold is in ring units; under web Mercator the scale factor is >= 1
42262
+ // everywhere, so it is an upper bound on the real m^2 area and no genuinely large
42263
+ // clip is skipped regardless of latitude.
42264
+ function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor) {
42265
+ // Area pre-filter keeps the scanline off the hot path: a loop can neither clip
42266
+ // nor fill more than its own area, so it is safe to skip whenever the loop is
42267
+ // below BOTH thresholds. (Filling a winding-0 region never reaches the uncovered
42268
+ // floor, so the fill floor must join the skip test -- otherwise small folds that
42269
+ // could fill a small hole would be skipped, or every collapse would be scanned.)
42270
+ var floor = fillFloor < BUFFER_LOOP_CHECK_MIN_AREA ? fillFloor : BUFFER_LOOP_CHECK_MIN_AREA;
42271
+ var u = collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor);
42272
+ if (u < 0) return true; // loop below both floors -- too small to clip or fill
42273
+ // Reject if the collapse would clip a real lobe (uncovered) OR swallow a real
42274
+ // hole/notch (filled). The uncovered floor is an absolute "big clip" bound; the
42275
+ // fill floor scales with the buffer disk (dist^2) because a real hole's area is
42276
+ // a fixed fraction of the disk while a fold sliver is orders of magnitude
42277
+ // smaller relative to the radius (see BUFFER_LOOP_FILL_AREA_FRAC).
42278
+ return u < BUFFER_LOOP_CHECK_MIN_AREA && _lastFillArea < fillFloor;
42279
+ }
42280
+
42281
+ // Returns the area of the dropped region a collapse would leave uncovered, or -1
42282
+ // when the loop's own area is below `floor` (too small to matter -- scanline
42283
+ // skipped). See collapseKeepsAreaCovered for the winding rationale.
42284
+ function collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor) {
42285
+ var i, loopLen = s - nextRingIndex + 3; // X, b, ring[next..s]
42286
+ // Loop area (shoelace over X, b, ring[next..s]) and bounding box.
42287
+ var area2 = 0, px = X[0], py = X[1];
42288
+ var minx = X[0], maxx = X[0], miny = X[1], maxy = X[1];
42289
+ function bbox(qx, qy) {
42290
+ if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42291
+ if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42292
+ }
42293
+ bbox(b[0], b[1]);
42294
+ area2 += px * b[1] - b[0] * py; px = b[0]; py = b[1];
42295
+ for (i = nextRingIndex; i <= s; i++) {
42296
+ area2 += px * ring[i][1] - ring[i][0] * py; px = ring[i][0]; py = ring[i][1];
42297
+ bbox(ring[i][0], ring[i][1]);
42298
+ }
42299
+ area2 += px * X[1] - X[0] * py;
42300
+ if (Math.abs(area2) / 2 < floor) return -1; // too small to matter
42301
+ // Collect the ring edges whose y-range meets the loop's band (the only ones
42302
+ // that can cross any scanline); reuse module scratch to avoid per-call garbage.
42303
+ var scr = coverageScratch(n + loopLen);
42304
+ var lx0 = scr.lx0, ly0 = scr.ly0, lx1 = scr.lx1, ly1 = scr.ly1;
42305
+ var band = scr.band, le = 0;
42306
+ var gen = ++index.gen, stamp = index.stamp, start = index.start, edges = index.edges;
42307
+ var kb, klo = index.binOf(miny), khi = index.binOf(maxy);
42308
+ for (kb = klo; kb <= khi; kb++) {
42309
+ for (var p = start[kb]; p < start[kb + 1]; p++) {
42310
+ var ei = edges[p];
42311
+ if (stamp[ei] === gen) continue;
42312
+ stamp[ei] = gen;
42313
+ var ay = ring[ei][1], by = ring[ei + 1][1];
42314
+ if (ay < miny && by < miny || ay > maxy && by > maxy) continue;
42315
+ if (ring[ei][0] > maxx && ring[ei + 1][0] > maxx) continue; // entirely right of loop
42316
+ band[le++] = ei;
42317
+ }
42318
+ }
42319
+ // Loop edges (X->b, b->ring[next..s], ring[s]->X).
42320
+ var lc = 0;
42321
+ lx0[lc] = X[0]; ly0[lc] = X[1]; lx1[lc] = b[0]; ly1[lc] = b[1]; lc++;
42322
+ lx0[lc] = b[0]; ly0[lc] = b[1]; lx1[lc] = ring[nextRingIndex][0]; ly1[lc] = ring[nextRingIndex][1]; lc++;
42323
+ for (i = nextRingIndex; i < s; i++) {
42324
+ lx0[lc] = ring[i][0]; ly0[lc] = ring[i][1]; lx1[lc] = ring[i + 1][0]; ly1[lc] = ring[i + 1][1]; lc++;
42325
+ }
42326
+ lx0[lc] = ring[s][0]; ly0[lc] = ring[s][1]; lx1[lc] = X[0]; ly1[lc] = X[1]; lc++;
42327
+ return loopUncoveredArea(ring, band, le, lx0, ly0, lx1, ly1, lc, minx, maxx, miny, maxy, scr);
42328
+ }
42329
+
42330
+ // y-band index of ring edges: bins the ring's y-range so a scanline query at
42331
+ // [miny, maxy] returns only edges reaching that band instead of scanning all n.
42332
+ // start[k]..start[k+1] are indices into `edges` for bin k; `stamp`/`gen` dedup an
42333
+ // edge that spans several bins during a single query.
42334
+ function buildEdgeYIndex(ring, n) {
42335
+ var yMin = Infinity, yMax = -Infinity, i;
42336
+ for (i = 0; i < n; i++) { var y = ring[i][1]; if (y < yMin) yMin = y; if (y > yMax) yMax = y; }
42337
+ var B = n < 32 ? 1 : Math.min(4096, Math.max(16, n >> 2));
42338
+ var binH = (yMax - yMin) / B || 1;
42339
+ var binOf = function (y) { var k = ((y - yMin) / binH) | 0; return k < 0 ? 0 : (k >= B ? B - 1 : k); };
42340
+ var start = new Int32Array(B + 1);
42341
+ for (i = 0; i < n; i++) {
42342
+ var ya = ring[i][1], yb = ring[i + 1][1];
42343
+ var lo = binOf(ya < yb ? ya : yb), hi = binOf(ya < yb ? yb : ya);
42344
+ for (var k = lo; k <= hi; k++) start[k + 1]++;
42345
+ }
42346
+ for (i = 0; i < B; i++) start[i + 1] += start[i];
42347
+ var edges = new Int32Array(start[B]);
42348
+ var cur = start.slice(0, B);
42349
+ for (i = 0; i < n; i++) {
42350
+ var a2 = ring[i][1], b2 = ring[i + 1][1];
42351
+ var lo2 = binOf(a2 < b2 ? a2 : b2), hi2 = binOf(a2 < b2 ? b2 : a2);
42352
+ for (var k2 = lo2; k2 <= hi2; k2++) edges[cur[k2]++] = i;
42353
+ }
42354
+ return { binOf: binOf, start: start, edges: edges, stamp: new Int32Array(n), gen: 0 };
42355
+ }
42356
+
42357
+ var _covScratch = null;
42358
+ function coverageScratch(cap) {
42359
+ if (!_covScratch || _covScratch.cap < cap) {
42360
+ _covScratch = {
42361
+ cap: cap,
42362
+ lx0: new Float64Array(cap), ly0: new Float64Array(cap),
42363
+ lx1: new Float64Array(cap), ly1: new Float64Array(cap),
42364
+ band: new Int32Array(cap),
42365
+ xs: new Float64Array(cap), df: new Int8Array(cap),
42366
+ dl: new Int8Array(cap), order: new Int32Array(cap)
42367
+ };
42368
+ }
42369
+ return _covScratch;
42370
+ }
42371
+
42372
+ // Sweep the removed loop L and measure two area quantities the collapse would
42373
+ // change, both integrated with horizontal scanlines across L's bounding box (at
42374
+ // each scanline the winding of the full ring and of the loop are step functions
42375
+ // of x, reconstructed from the sorted edge crossings). For a point inside L
42376
+ // (windingLoop != 0), the winding after the collapse is windingFull - windingLoop:
42377
+ // - UNCOVERED: was covered (windingFull == windingLoop, so it drops to 0). This
42378
+ // is the "big clip" a collapse of a real single-covered lobe would make.
42379
+ // - FILLED: was a winding-0 hole/exterior (windingFull == 0, so it flips to
42380
+ // covered). This is a real buffer hole (or an open outer-wall notch) the
42381
+ // collapse would swallow -- undetectable by the uncovered measure alone,
42382
+ // since filling adds coverage rather than removing it.
42383
+ // Returns the uncovered area and writes the filled area to _lastFillArea.
42384
+ // `band` lists the indices of ring edges that can reach the loop's y-band.
42385
+ var _lastFillArea = 0;
42386
+ function loopUncoveredArea(ring, band, be, lx0, ly0, lx1, ly1, le, minx, maxx, miny, maxy, scr) {
42387
+ var h = maxy - miny;
42388
+ _lastFillArea = 0;
42389
+ if (h <= 0 || maxx <= minx) return 0;
42390
+ var target = Math.sqrt(BUFFER_LOOP_CHECK_MIN_AREA) / 4;
42391
+ var rows = Math.round(h / (target > 0 ? target : h));
42392
+ if (rows < 8) rows = 8; else if (rows > 40) rows = 40;
42393
+ var dy = h / rows;
42394
+ var xs = scr.xs, df = scr.df, dl = scr.dl, order = scr.order;
42395
+ var total = 0, fill = 0, r, k, i;
42396
+ for (r = 0; r < rows; r++) {
42397
+ var y = miny + (r + 0.5) * dy;
42398
+ // Winding just left of the loop's x-range (base), plus the crossings that
42399
+ // fall inside [minx, maxx] (only these need sorting). Crossings right of the
42400
+ // loop are irrelevant to intervals within the range and are dropped.
42401
+ var baseWf = 0, baseWl = 0, m = 0;
42402
+ for (k = 0; k < be; k++) {
42403
+ i = band[k];
42404
+ var y1 = ring[i][1], y2 = ring[i + 1][1];
42405
+ if ((y1 <= y) === (y2 <= y)) continue;
42406
+ var x = ring[i][0] + (y - y1) / (y2 - y1) * (ring[i + 1][0] - ring[i][0]);
42407
+ var sgnF = y2 > y1 ? 1 : -1;
42408
+ if (x <= minx) baseWf += sgnF;
42409
+ else if (x < maxx) { xs[m] = x; df[m] = sgnF; dl[m] = 0; order[m] = m; m++; }
42410
+ }
42411
+ for (i = 0; i < le; i++) {
42412
+ var ya = ly0[i], yb = ly1[i];
42413
+ if ((ya <= y) === (yb <= y)) continue;
42414
+ var xl = lx0[i] + (y - ya) / (yb - ya) * (lx1[i] - lx0[i]);
42415
+ var sl = yb > ya ? 1 : -1;
42416
+ if (xl <= minx) { baseWl += sl; }
42417
+ else if (xl < maxx) { xs[m] = xl; df[m] = 0; dl[m] = sl; order[m] = m; m++; }
42418
+ }
42419
+ sortByX(order, xs, m);
42420
+ var wf = baseWf, wl = baseWl, prevx = minx;
42421
+ for (i = 0; i < m; i++) {
42422
+ var o = order[i], xk = xs[o];
42423
+ if (wl !== 0 && xk > prevx) {
42424
+ if (wf - wl === 0) total += xk - prevx;
42425
+ else if (wf === 0) fill += xk - prevx;
42426
+ }
42427
+ wf += df[o]; wl += dl[o]; prevx = xk;
42428
+ }
42429
+ if (wl !== 0 && maxx > prevx) {
42430
+ if (wf - wl === 0) total += maxx - prevx;
42431
+ else if (wf === 0) fill += maxx - prevx;
42432
+ }
42433
+ }
42434
+ _lastFillArea = fill * dy;
42435
+ return total * dy;
42436
+ }
42437
+
42438
+ // Insertion sort of index array `order[0..m)` by xs[order[k]] ascending. m is
42439
+ // small per scanline (edges straddling the row), so this beats a comparator sort.
42440
+ function sortByX(order, xs, m) {
42441
+ for (var i = 1; i < m; i++) {
42442
+ var oi = order[i], xi = xs[oi], j = i - 1;
42443
+ while (j >= 0 && xs[order[j]] > xi) { order[j + 1] = order[j]; j--; }
42444
+ order[j + 1] = oi;
42445
+ }
42446
+ }
42447
+
41690
42448
  var DouglasPeucker = {};
41691
42449
 
41692
42450
  DouglasPeucker.metricSq3D = geom.pointSegDistSq3D;
@@ -41769,7 +42527,7 @@ ${svg}
41769
42527
  var T = 90 - e;
41770
42528
  var L = -180 + e;
41771
42529
  var B$1 = -90 + e;
41772
- var R$2 = 180 - e;
42530
+ var R$1 = 180 - e;
41773
42531
 
41774
42532
  function lastEl(arr) {
41775
42533
  return arr[arr.length - 1];
@@ -41786,7 +42544,7 @@ ${svg}
41786
42544
  // remove likely rounding errors
41787
42545
  function snapToEdge(p) {
41788
42546
  if (p[0] <= L) p[0] = -180;
41789
- if (p[0] >= R$2) p[0] = 180;
42547
+ if (p[0] >= R$1) p[0] = 180;
41790
42548
  if (p[1] <= B$1) p[1] = -90;
41791
42549
  if (p[1] >= T) p[1] = 90;
41792
42550
  }
@@ -41814,11 +42572,11 @@ ${svg}
41814
42572
  // TODO: handle segments between pole and non-edge point
41815
42573
  // (these shoudn't exist in a properly clipped path)
41816
42574
  return (onPole(a) || onPole(b)) ||
41817
- a[0] <= L && b[0] <= L || a[0] >= R$2 && b[0] >= R$2;
42575
+ a[0] <= L && b[0] <= L || a[0] >= R$1 && b[0] >= R$1;
41818
42576
  }
41819
42577
 
41820
42578
  function isEdgePoint(p) {
41821
- return p[1] <= B$1 || p[1] >= T || p[0] <= L || p[0] >= R$2;
42579
+ return p[1] <= B$1 || p[1] >= T || p[0] <= L || p[0] >= R$1;
41822
42580
  }
41823
42581
 
41824
42582
  // Remove segments that belong solely to cut points
@@ -42083,129 +42841,6 @@ ${svg}
42083
42841
  return (dx2 * p1[1] + dx1 * p2[1]) / (dx1 + dx2);
42084
42842
  }
42085
42843
 
42086
- var R$1 = WGS84.SEMIMAJOR_AXIS;
42087
-
42088
- // GeographicLib docs: https://geographiclib.sourceforge.io/html/js/
42089
- // https://geographiclib.sourceforge.io/html/js/module-GeographicLib_Geodesic.Geodesic.html
42090
- // https://geographiclib.sourceforge.io/html/js/tutorial-2-interface.html
42091
- function getGeodesic(P) {
42092
- if (!isLatLngCRS(P)) error('Expected an unprojected CRS');
42093
- var f = P.es / (1 + Math.sqrt(P.one_es));
42094
- // var GeographicLib = require('mproj').internal.GeographicLib;
42095
- var GeographicLib = require$1('geographiclib-geodesic');
42096
- // return new GeographicLib.Geodesic.Geodesic(P.a, 0)
42097
- return new GeographicLib.Geodesic.Geodesic(P.a, f);
42098
- }
42099
-
42100
- function interpolatePoint2D(ax, ay, bx, by, k) {
42101
- var j = 1 - k;
42102
- return [ax * j + bx * k, ay * j + by * k];
42103
- }
42104
-
42105
- function getInterpolationFunction(P) {
42106
- var spherical = P && isLatLngCRS(P);
42107
- if (!spherical) return interpolatePoint2D;
42108
- var geod = getGeodesic(P);
42109
- return function(lng, lat, lng2, lat2, k) {
42110
- var r = geod.Inverse(lat, lng, lat2, lng2);
42111
- var dist = r.s12 * k;
42112
- var r2 = geod.Direct(lat, lng, r.azi1, dist);
42113
- return [r2.lon2, r2.lat2];
42114
- };
42115
- }
42116
-
42117
- function getPlanarSegmentEndpoint(x, y, bearing, meterDist) {
42118
- var rad = bearing / 180 * Math.PI;
42119
- var dx = Math.sin(rad) * meterDist;
42120
- var dy = Math.cos(rad) * meterDist;
42121
- return [x + dx, y + dy];
42122
- }
42123
-
42124
- // source: https://github.com/mapbox/cheap-ruler/blob/master/index.js
42125
- function fastGeodeticSegmentFunction(lng, lat, bearing, meterDist) {
42126
- var D2R = Math.PI / 180;
42127
- var cos = Math.cos(lat * D2R);
42128
- var cos2 = 2 * cos * cos - 1;
42129
- var cos3 = 2 * cos * cos2 - cos;
42130
- var cos4 = 2 * cos * cos3 - cos2;
42131
- var cos5 = 2 * cos * cos4 - cos3;
42132
- var kx = (111.41513 * cos - 0.09455 * cos3 + 0.00012 * cos5) * 1000;
42133
- var ky = (111.13209 - 0.56605 * cos2 + 0.0012 * cos4) * 1000;
42134
- var bearingRad = bearing * D2R;
42135
- var lat2 = lat + Math.cos(bearingRad) * meterDist / ky;
42136
- var lng2 = lng + Math.sin(bearingRad) * meterDist / kx;
42137
- return [lng2, lat2];
42138
- }
42139
-
42140
-
42141
- function wrap(deg) {
42142
- while (deg < -180) deg += 360;
42143
- while (deg > 180) deg -= 360;
42144
- return deg;
42145
- }
42146
-
42147
- function fastGeodeticBearingFunction(lng1, lat1, lng2, lat2) {
42148
- var D2R = Math.PI / 180;
42149
- var f = 1 / 298.257223563;
42150
- var e2 = f * (2 - f);
42151
- var m = R$1 * D2R;
42152
- var coslat = Math.cos(lat1 * D2R);
42153
- var w2 = 1 / (1 - e2 * (1 - coslat * coslat));
42154
- var w = Math.sqrt(w2);
42155
- var kx = m * w * coslat;
42156
- var ky = m * w * w2 * (1 - e2);
42157
- var dx = wrap(lng2 - lng1) * kx;
42158
- var dy = (lat2 - lat1) * ky;
42159
- return Math.atan2(dx, dy) / D2R;
42160
- }
42161
-
42162
- function getGeodeticSegmentFunction(P) {
42163
- if (!isLatLngCRS(P)) {
42164
- return getPlanarSegmentEndpoint;
42165
- }
42166
- var g = getGeodesic(P);
42167
- return function(lng, lat, bearing, meterDist) {
42168
- var o = g.Direct(lat, lng, bearing, meterDist);
42169
- var p = [o.lon2, o.lat2];
42170
- return p;
42171
- };
42172
- }
42173
-
42174
- function getFastGeodeticSegmentFunction(P) {
42175
- // CAREFUL: this function has higher error at very large distances and at the poles
42176
- // also, it wouldn't work for other planets than Earth
42177
- return isLatLngCRS(P) ? fastGeodeticSegmentFunction : getPlanarSegmentEndpoint;
42178
- }
42179
-
42180
- // return function to calculate bearing of a segment in degrees
42181
- function getBearingFunction(dataset) {
42182
- var P = getDatasetCRS(dataset);
42183
- // return isLatLngCRS(P) ? bearingDegrees : bearingDegrees2D;
42184
- return isLatLngCRS(P) ? fastGeodeticBearingFunction : bearingDegrees2D;
42185
- }
42186
-
42187
- // get bearing in degrees from point ab to point cd
42188
- function bearingDegrees(a, b, c, d) {
42189
- return geom.bearing(a, b, c, d) * 180 / Math.PI;
42190
- }
42191
-
42192
- function bearingDegrees2D(a, b, c, d) {
42193
- return geom.bearing2D(a, b, c, d) * 180 / Math.PI;
42194
- }
42195
-
42196
- var Geodesic = /*#__PURE__*/Object.freeze({
42197
- __proto__: null,
42198
- bearingDegrees: bearingDegrees,
42199
- bearingDegrees2D: bearingDegrees2D,
42200
- getBearingFunction: getBearingFunction,
42201
- getFastGeodeticSegmentFunction: getFastGeodeticSegmentFunction,
42202
- getGeodeticSegmentFunction: getGeodeticSegmentFunction,
42203
- getInterpolationFunction: getInterpolationFunction,
42204
- getPlanarSegmentEndpoint: getPlanarSegmentEndpoint,
42205
- interpolatePoint2D: interpolatePoint2D,
42206
- wrap: wrap
42207
- });
42208
-
42209
42844
  var R = WGS84.SEMIMAJOR_AXIS;
42210
42845
  var D2R = Math.PI / 180;
42211
42846
  var R2D = 180 / Math.PI;
@@ -42328,6 +42963,19 @@ ${svg}
42328
42963
 
42329
42964
  function getOffsetFunction(crs, opts) {
42330
42965
  if (!isLatLngCRS(crs)) {
42966
+ if (opts && opts.geodesic2) {
42967
+ // Experimental geodesic buffering for projected data that stays entirely
42968
+ // in the source projected plane (no web-Mercator round-trip), so it has no
42969
+ // pole singularity or antimeridian wrap. Each offset is taken in the
42970
+ // ordinary projected (cartesian) direction, but its magnitude is corrected
42971
+ // so the endpoint lands at a true ground distance, measured with a
42972
+ // first-party spherical great-circle formula (the same sphere model the
42973
+ // lat-long buffer already uses). Falls back to planar if the CRS can't be
42974
+ // inverted to measure ground distance.
42975
+ var fn = makeScaleCorrectedOffset(crs);
42976
+ if (fn) return fn;
42977
+ message('[buffer] Ignoring "geodesic2": the dataset CRS is unknown or not invertible; using planar offsets.');
42978
+ }
42331
42979
  return getPlanarSegmentEndpoint;
42332
42980
  }
42333
42981
  // Offset each point by a true geodesic distance, working directly in spherical
@@ -42348,6 +42996,90 @@ ${svg}
42348
42996
  return opts && opts.polar ? clampPolar(offset) : offset;
42349
42997
  }
42350
42998
 
42999
+ // Convergence target for the scale-corrected offset: stop when the great-circle
43000
+ // distance reached is within SCALE_OFFSET_TOL (relative) or SCALE_OFFSET_ABS
43001
+ // (absolute, meters) of the requested ground distance.
43002
+ //
43003
+ // The iteration is a linear fixed point (m *= d/g), so the residual shrinks by a
43004
+ // roughly constant factor per step: a tighter tolerance costs extra tail
43005
+ // iterations no matter how good the warm-start guess is, and below ~1e-10 the
43006
+ // projection round-trip + haversine noise floor makes the test unreachable for
43007
+ // small d (it never converges and burns the iteration cap). 1e-8 relative is
43008
+ // 0.25 mm at a 25 km radius -- far below cartographic relevance -- and a sweep
43009
+ // over real data showed it sits just before the cost curve and the noise floor
43010
+ // bite (1e-7 and 1e-8 cost the same; 1e-9 adds ~5%; 1e-12 roughly doubles the
43011
+ // inverse-projection count and caps out on ~11% of points at 1 km).
43012
+ //
43013
+ // The tolerance does NOT need to guarantee bit-identical results for the same
43014
+ // (x, y, bearing, dist) across call paths: the only place two independent offset
43015
+ // computations must coincide -- a closed ring's seam -- closes by reusing the
43016
+ // first offset vertex's reference exactly (see makeFinalJoin in
43017
+ // mapshaper-path-buffer-v4), not by recomputing and comparing. Within a single
43018
+ // run the call order is fixed, so the warm-started result is deterministic.
43019
+ //
43020
+ // The absolute floor keeps tiny-distance helper calls (inset/probe offsets at
43021
+ // dist*1e-4 etc.) from chasing a sub-noise relative target; it is well below the
43022
+ // relative target for any d above ~100 m, so it never loosens normal offsets.
43023
+ var SCALE_OFFSET_TOL = 1e-8;
43024
+ var SCALE_OFFSET_ABS = 1e-6;
43025
+ var SCALE_OFFSET_MAX_ITER = 16;
43026
+
43027
+ // Build a deterministic offset function for projected data that corrects the
43028
+ // offset magnitude so the endpoint sits at a true ground distance. The cartesian
43029
+ // offset direction is kept (so all the builder's planar constructions still hold)
43030
+ // and only the distance is solved: starting from the cartesian magnitude, measure
43031
+ // the great-circle distance actually reached, scale by the error ratio, and
43032
+ // iterate to convergence. The converged scale (projected units per ground meter)
43033
+ // is remembered to warm-start the next point -- adjacent points share nearly the
43034
+ // same projection scale, so the seeded guess is close and the loop converges in
43035
+ // ~2-3 iterations. The warm-start only seeds the initial guess and the loop is
43036
+ // driven to a tolerance (see SCALE_OFFSET_TOL), so within a run the result is
43037
+ // deterministic.
43038
+ // Returns null if the CRS cannot be inverted (no way to measure ground distance).
43039
+ function makeScaleCorrectedOffset(crs) {
43040
+ if (!isInvertibleCRS(crs)) return null;
43041
+ var toLngLat = getProjTransform2(crs, parseCrsString$1('wgs84')); // projected -> lng,lat
43042
+ var warmScale = 1;
43043
+ // Small LRU of inverted source coordinates. Every path vertex is unprojected at
43044
+ // least twice (it is the shared endpoint of two consecutive segment offsets) and
43045
+ // again for each round-join/cap arc point centered on it; a single slot is
43046
+ // clobbered by the join construction between segments. A few slots capture that
43047
+ // reuse and are dropped as construction moves along the path. Returns the exact
43048
+ // toLngLat value (possibly null, out of domain), so caching changes nothing but speed.
43049
+ var SCALE_LL_CACHE = 8;
43050
+ var ckx = [], cky = [], cll = [];
43051
+ function invert(x, y) {
43052
+ for (var j = cll.length - 1; j >= 0; j--) {
43053
+ if (ckx[j] === x && cky[j] === y) return cll[j];
43054
+ }
43055
+ var ll = toLngLat(x, y);
43056
+ ckx.push(x); cky.push(y); cll.push(ll);
43057
+ if (cll.length > SCALE_LL_CACHE) { ckx.shift(); cky.shift(); cll.shift(); }
43058
+ return ll;
43059
+ }
43060
+ return function(x, y, bearing, meterDist) {
43061
+ if (!meterDist) return [x, y];
43062
+ var rad = bearing * D2R;
43063
+ var sign = meterDist < 0 ? -1 : 1;
43064
+ var d = meterDist * sign; // positive ground distance to reach
43065
+ var ux = Math.sin(rad) * sign, uy = Math.cos(rad) * sign;
43066
+ var ll0 = invert(x, y);
43067
+ if (!ll0) return getPlanarSegmentEndpoint(x, y, bearing, meterDist);
43068
+ var m = d * warmScale, qx = x, qy = y;
43069
+ for (var i = 0; i < SCALE_OFFSET_MAX_ITER; i++) {
43070
+ qx = x + ux * m; qy = y + uy * m;
43071
+ var ll1 = toLngLat(qx, qy);
43072
+ if (!ll1) break; // offset left the projection's domain; keep current estimate
43073
+ var g = greatCircleDistance(ll0[0], ll0[1], ll1[0], ll1[1]);
43074
+ if (!(g > 0)) { m *= 2; continue; }
43075
+ if (Math.abs(g - d) <= SCALE_OFFSET_TOL * d + SCALE_OFFSET_ABS) break;
43076
+ m *= d / g;
43077
+ }
43078
+ warmScale = m / d;
43079
+ return [qx, qy];
43080
+ };
43081
+ }
43082
+
42351
43083
  // Great-circle (spherical geodesic) offset, computed directly in Mercator coords.
42352
43084
  // bearing: compass degrees; meterDist: meters; x, y and the result: Mercator.
42353
43085
  function greatCircleOffset(x, y, bearing, meterDist) {
@@ -42417,6 +43149,12 @@ ${svg}
42417
43149
  var getOffsetPoint = getOffsetFunction(crs, opts);
42418
43150
  var roundJoinSegsPerQuadrant = opts.quad_segs >= 2 ? opts.quad_segs : 8;
42419
43151
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
43152
+ // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
43153
+ // the optional low-resolution alternative to makeConcaveJoin in
43154
+ // traceCleanOffsetSide. Larger = fewer points = faster dissolve. The reversed
43155
+ // bridge only bounds a self-overlap loop that the direction remover collapses,
43156
+ // so its resolution does not affect the final boundary.
43157
+ var CLEAN_OUTLINE_BRIDGE_STEP = 90;
42420
43158
  var capStyle = opts.cap_style || 'round'; // expect 'round' or 'flat'
42421
43159
  var pathIter = useMercator ?
42422
43160
  getProjectingPathIterator(dataset.arcs, opts) : new ShapeIter(dataset.arcs);
@@ -42442,8 +43180,7 @@ ${svg}
42442
43180
  properties: null,
42443
43181
  geometry: {
42444
43182
  type: 'MultiPolygon',
42445
- // convert rings to MultiPolygon format
42446
- coordinates: rings.map(ring => [ring])
43183
+ coordinates: rings.map(function(ring) { return [ring]; })
42447
43184
  }
42448
43185
  }];
42449
43186
  if (useMercator) {
@@ -42511,14 +43248,26 @@ ${svg}
42511
43248
 
42512
43249
  // each path may be converted into multiple buffer rings, which later
42513
43250
  // need to be dissolved
42514
- // Re-anchor a closed ring [v0, v1, ..., v0] to the midpoint of its first edge:
42515
- // returns [m, v1, ..., v0, m] where m = midpoint(v0, v1). The offset then
42516
- // starts/ends mid-edge (a collinear seam) and v0 becomes an interior join.
43251
+ // Re-anchor a closed ring [v0, v1, ..., v0] so its offset seam falls at the
43252
+ // midpoint of a chosen edge (vk -> vk+1): returns [m, vk+1, ..., vk, m] where
43253
+ // m = midpoint(vk, vk+1). The offset then starts/ends mid-edge (a collinear
43254
+ // seam) and every original vertex becomes an interior join.
43255
+ //
43256
+ // The seam edge is chosen by chooseSeamEdge() to sit away from concave (reflex)
43257
+ // corners. In winding-fill mode a concave corner dips the offset back to its
43258
+ // source vertex (resolved later by the dissolve); a seam landing next to such a
43259
+ // dip leaves the pre-dissolve overshoot-loop remover (removeBufferRingLoops*)
43260
+ // starting from an anchor buried inside that tangle, where it can collapse the
43261
+ // true outer boundary instead of the self-overlap (an inward notch). Putting the
43262
+ // seam in a clean convex stretch keeps the remover's first-vertex anchor on the
43263
+ // real boundary.
42517
43264
  function startRingAtEdgeMidpoint(verts) {
42518
- var a = verts[0], b = verts[1];
43265
+ var k = chooseSeamEdge(verts);
43266
+ var n = verts.length - 1; // distinct vertices (verts[0] == verts[n])
43267
+ var a = verts[k], b = verts[(k + 1) % n];
42519
43268
  var m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
42520
43269
  var out = [m];
42521
- for (var i = 1; i < verts.length; i++) out.push(verts[i]);
43270
+ for (var t = 1; t <= n; t++) out.push(verts[(k + t) % n]);
42522
43271
  out.push(m.concat());
42523
43272
  return out;
42524
43273
  }
@@ -42528,25 +43277,49 @@ ${svg}
42528
43277
  var pathSideVerts = collectPathVertices(pathArcs);
42529
43278
  var verts = pathSideVerts;
42530
43279
  if (simplifyIntervalFn) {
42531
- verts = presimplifyPathVerts(verts, simplifyIntervalFn(dist), dist);
43280
+ verts = presimplifyPathVerts(verts, simplifyIntervalFn(dist));
43281
+ }
43282
+ // Closed two-sided line rings: open with a sub-tolerance gap at the first
43283
+ // vertex so the open-path outline builder applies (round caps close the
43284
+ // seam). Gap size is in the same coordinate units as verts.
43285
+ if (!oneSidedBuffer && !opts.band_method && !pathIsOpen(verts)) {
43286
+ verts = openClosedRingWithMicroGap(verts);
42532
43287
  }
42533
- if (!oneSidedBuffer && !opts.band_method && pathIsOpen(verts)) {
43288
+ if (!opts.band_method && pathIsOpen(verts) && (!oneSidedBuffer || opts.outline)) {
42534
43289
  // Fast path for ordinary two-sided line buffers: emit one closed
42535
43290
  // outline instead of many per-segment bands that must be dissolved.
42536
43291
  // The band-method escape hatch skips it to fall through to the
42537
43292
  // per-segment band construction (makeLeftBufferRings, no winding fill).
43293
+ //
43294
+ // Also used for the topological polygon grow's OPEN boundary chains
43295
+ // (outline mode, one-sided 'left'): an open unshared-boundary chain must
43296
+ // be capped on BOTH ends. The one-sided outline (buildCleanOutlineRings)
43297
+ // offsets a single side and self-closes with a straight chord between the
43298
+ // chain's endpoints -- harmless when they nearly coincide, but for a chain
43299
+ // spanning a whole border (e.g. a state's Canada boundary) that chord is a
43300
+ // multi-degree spike. The two-sided stadium caps both ends instead; the
43301
+ // mosaic union with the source polygon absorbs the inner (interior) half.
42538
43302
  var built = makeTwoSidedOutlineRing(verts, dist);
42539
- if (opts.no_loop_removal) return [built.ring];
42540
- // Strip self-overlap loops (the dissolve would fill them anyway) so it has
42541
- // fewer segments and self-intersections to resolve, while keeping real
42542
- // holes. The two-sided outline of an open path has a consistent +/-1 base
42543
- // winding, so the crossing-direction method classifies loops exactly from
42544
- // the ring geometry alone; the source-turn gate is kept as an alternative.
42545
- if (opts.loop_removal_turn_gate) {
42546
- return [removeBufferRingLoops(built.ring, BUFFER_LOOP_WINDOW,
42547
- built.srcPos, getSourceTurnPrefix(verts))];
43303
+ var out;
43304
+ if (opts.no_loop_removal) {
43305
+ out = [built.ring];
43306
+ } else {
43307
+ // Multi-pass dip+coverage remover: construction-tagged reversed concave-join
43308
+ // ("dip") cusps mark self-overlap folds, and an exact scanline coverage
43309
+ // check refuses any collapse that would uncover a real boundary lobe OR
43310
+ // swallow a real hole/notch.
43311
+ out = [removeBufferRingLoopsIterative(built.ring, BUFFER_LOOP_WINDOW,
43312
+ null, null, undefined, built.dipTags, undefined,
43313
+ dist * dist * BUFFER_LOOP_FILL_AREA_FRAC)];
42548
43314
  }
42549
- return [removeBufferRingLoopsByDirection(built.ring, BUFFER_LOOP_WINDOW)];
43315
+ // Geodesic fan-apart gap patches (same mechanism as the polygon outline):
43316
+ // union a single-segment round-cap stadium for the source segments at each
43317
+ // exposed fan-apart bend so the winding dissolve fills the sliver gap.
43318
+ if (built.fanApartBends.length) {
43319
+ addFanApartGapPatches(out, verts, built.fanApartBends, dist,
43320
+ ringSignedArea(out[0]) >= 0);
43321
+ }
43322
+ return out;
42550
43323
  }
42551
43324
  if (!opts.right || opts.left) {
42552
43325
  rings = rings.concat(buildOneSidedRings(verts));
@@ -42585,22 +43358,17 @@ ${svg}
42585
43358
  if (opts.outline && sideVerts.length > 2 && !pathIsOpen(sideVerts)) {
42586
43359
  sideVerts = startRingAtEdgeMidpoint(sideVerts);
42587
43360
  }
43361
+ // Polygon-grow outline: the shared constant-radius construction used by
43362
+ // two-sided line outlines (traceCleanOffsetSide + dip+coverage loop
43363
+ // removal inside buildCleanOutlineRings).
43364
+ if (opts.outline) {
43365
+ return buildCleanOutlineRings(sideVerts, dist);
43366
+ }
42588
43367
  var built = makeLeftBufferRings(sideVerts, dist,
42589
43368
  oneSidedBuffer ? pathSideVerts : null);
42590
43369
  if (opts.no_loop_removal || !opts.winding_fill) {
42591
43370
  return built.rings;
42592
43371
  }
42593
- if (opts.outline) {
42594
- // Outline mode rings are clean offset-only loops (no source-path edge),
42595
- // so each has a consistent +/-1 base winding and the crossing-direction
42596
- // remover classifies overshoot loops exactly from the ring geometry --
42597
- // the same condition that makes it safe for the open-path two-sided
42598
- // outline. (The band-ribbon rings of the default construction do not,
42599
- // which is why they use the source-turn gate below.)
42600
- return built.rings.map(function(ring) {
42601
- return removeBufferRingLoopsByDirection(ring, BUFFER_LOOP_WINDOW);
42602
- });
42603
- }
42604
43372
  var turnPrefix = getSourceTurnPrefix(sideVerts);
42605
43373
  return built.rings.map(function(ring, i) {
42606
43374
  var srcPos = built.srcPositions[i];
@@ -42610,6 +43378,86 @@ ${svg}
42610
43378
  }
42611
43379
  }
42612
43380
 
43381
+ // Build the clean-outline-winding ring for one offset side from the shared
43382
+ // traceCleanOffsetSide construction (the same construction the open two-sided
43383
+ // line outline uses), then strip self-overlap loops before the dissolve.
43384
+ //
43385
+ // A closed source ring closes on its first offset vertex: the midpoint seam is
43386
+ // collinear (see startRingAtEdgeMidpoint), so the recomputed final offset point
43387
+ // is a sub-ULP duplicate of the first and is dropped (done() repeats the first
43388
+ // vertex exactly). An open arc appends the final offset endpoint and an end cap.
43389
+ //
43390
+ // Loop removal: multi-pass dip+coverage (removeBufferRingLoopsIterative with
43391
+ // construction dip tags), the same method used for two-sided line outlines.
43392
+ function removePolygonOutlineLoops(ring, dipTags, dist) {
43393
+ return removeBufferRingLoopsIterative(ring, BUFFER_LOOP_WINDOW,
43394
+ null, null, undefined, dipTags, undefined,
43395
+ dist * dist * BUFFER_LOOP_FILL_AREA_FRAC);
43396
+ }
43397
+
43398
+ function buildCleanOutlineRings(sideVerts, dist) {
43399
+ if (sideVerts.length < 2) return [];
43400
+ var closed = !pathIsOpen(sideVerts);
43401
+ var info = traceCleanOffsetSide(sideVerts, dist);
43402
+ var pts = info.points, segs = info.segs, tags = info.dipTags;
43403
+ for (var i = 0; i < pts.length; i++) builder.addBufferVertex(pts[i], segs[i], tags[i]);
43404
+ if (!closed) {
43405
+ if (info.lastPoint) builder.addBufferVertex(info.lastPoint, info.lastSeg);
43406
+ if (capStyle == 'round') {
43407
+ var end = sideVerts[sideVerts.length - 1];
43408
+ builder.addBufferVertices(
43409
+ makeRoundCap(end[0], end[1], info.lastBearing - 90, dist), NaN);
43410
+ }
43411
+ }
43412
+ var d = builder.done(true);
43413
+ if (!d) return [];
43414
+ var mainRing;
43415
+ if (opts.no_loop_removal) {
43416
+ mainRing = d.ring;
43417
+ } else {
43418
+ mainRing = removePolygonOutlineLoops(d.ring, d.dipTags, dist);
43419
+ }
43420
+ var out = [mainRing];
43421
+ // Union in a single-segment round-cap patch for every fan-apart concave bend
43422
+ // (offsetEdgesFanApart). Each patch is the exact buffer of one source
43423
+ // segment, hence a subset of the true buffer, so it can only fill the
43424
+ // winding-0 sliver the pinched bridge leaves -- it can never push geometry
43425
+ // through the outer wall. Oriented to match the main ring so the winding fill
43426
+ // adds (not subtracts) its area.
43427
+ if (info.fanApartBends.length) {
43428
+ addFanApartGapPatches(out, sideVerts, info.fanApartBends, dist,
43429
+ ringSignedArea(mainRing) >= 0);
43430
+ }
43431
+ return out;
43432
+ }
43433
+
43434
+ // Append a round-cap stadium patch for the two source segments meeting at each
43435
+ // recorded fan-apart bend vertex. A single segment has no bend (so its buffer
43436
+ // is the exact, gap-free stadium); the two patches' caps at the shared vertex
43437
+ // cover the sliver wedge the pinched bridge failed to fill.
43438
+ function addFanApartGapPatches(out, sideVerts, bends, dist, parentCCW) {
43439
+ for (var b = 0; b < bends.length; b++) {
43440
+ var k = bends[b];
43441
+ if (k - 1 >= 0) pushPatch(out, [sideVerts[k - 1], sideVerts[k]], dist, parentCCW);
43442
+ if (k + 1 < sideVerts.length) pushPatch(out, [sideVerts[k], sideVerts[k + 1]], dist, parentCCW);
43443
+ }
43444
+ }
43445
+
43446
+ function pushPatch(out, seg, dist, parentCCW) {
43447
+ if (seg[0][0] === seg[1][0] && seg[0][1] === seg[1][1]) return;
43448
+ var ring = makeTwoSidedOutlineRing(seg, dist).ring;
43449
+ if ((ringSignedArea(ring) >= 0) !== parentCCW) ring.reverse();
43450
+ out.push(ring);
43451
+ }
43452
+
43453
+ function ringSignedArea(ring) {
43454
+ var s = 0;
43455
+ for (var i = 0; i < ring.length - 1; i++) {
43456
+ s += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
43457
+ }
43458
+ return s;
43459
+ }
43460
+
42613
43461
  function makeLeftBufferRings(verts, dist, pathSideVerts) {
42614
43462
  var rings = [];
42615
43463
  // Parallel to rings[]: each entry is the source-position array for the
@@ -42677,21 +43525,18 @@ ${svg}
42677
43525
  // original path vertex, then walk out the full outgoing offset (p1).
42678
43526
  // The self-overlap is resolved by the winding-number union, so no
42679
43527
  // section splits, join-sector rings, or band-coverage audit are needed.
43528
+ // (The clean-outline-winding grow does NOT reach here -- it is routed to
43529
+ // buildCleanOutlineRings, which bridges concave corners with
43530
+ // makeConcaveJoin to keep a constant +/-1 winding.)
42680
43531
  builder.addBufferVertex(p2Prev, segId);
42681
43532
  builder.addBufferVertex([x1, y1], segId);
42682
43533
  builder.addBufferVertex(p1, segId);
42683
43534
  } else if (joinAngle > roundJoinSegAngle * 1.5) {
42684
- // large rightwards bend in path requiring a round join with at least
42685
- // one interpolated segment
42686
- // don't add endpoint of last offset segment to the buffer - we start
42687
- // by extending the last segment to make an outside join
42688
- // builder.addBufferVertex(p2Prev, false)
42689
- joinPoints = makeOutsideRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
43535
+ // Large convex bend: arc vertices on the offset circle replace the
43536
+ // previous segment end (p2Prev) and current segment start (p1).
43537
+ joinPoints = makeInscribedRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
42690
43538
  builder.addBufferVertices(joinPoints, segId);
42691
- // don't add first endpoint of new offset segment to the buffer - we just
42692
- // added an extended vertex to replace it.
42693
- // builder.addBufferVertex(p1, false)
42694
- p1 = joinPoints.pop();
43539
+ p1 = joinPoints.pop(); // track outgoing segment start (already added)
42695
43540
  } else if (joinAngle > -1e-10 && joinAngle < 1e-10) {
42696
43541
  // nearly collinear segments - add one point to the buffer
42697
43542
  // TODO: confirm that p1 and p2Prev are always very close
@@ -43148,6 +43993,31 @@ ${svg}
43148
43993
  return verts[0][0] !== verts[n-1][0] || verts[0][1] !== verts[n-1][1];
43149
43994
  }
43150
43995
 
43996
+ // Open a closed ring at its first vertex: nudge the corner into two points
43997
+ // straddling it along the incoming and outgoing edges (sub-tolerance gap).
43998
+ function openClosedRingWithMicroGap(verts) {
43999
+ var gap = 1e-6;
44000
+ var n = verts.length;
44001
+ if (n < 4 || pathIsOpen(verts)) return verts;
44002
+ var ring = [], i;
44003
+ for (i = 0; i < n; i++) ring.push(verts[i].concat());
44004
+ if (ring[0][0] === ring[n - 1][0] && ring[0][1] === ring[n - 1][1]) ring.pop();
44005
+ if (ring.length < 3) return verts;
44006
+ var p0 = ring[0], p1 = ring[1], pPrev = ring[ring.length - 1];
44007
+ ring[0] = nudgeVertexFrom(p0, p1, gap);
44008
+ ring.push(nudgeVertexFrom(p0, pPrev, gap));
44009
+ return ring;
44010
+ }
44011
+
44012
+ function nudgeVertexFrom(origin, toward, dist) {
44013
+ var dx = toward[0] - origin[0], dy = toward[1] - origin[1];
44014
+ var len = Math.hypot(dx, dy);
44015
+ if (len > 0) {
44016
+ return [origin[0] + dx / len * dist, origin[1] + dy / len * dist];
44017
+ }
44018
+ return [origin[0] + dist, origin[1]];
44019
+ }
44020
+
43151
44021
  // get angle between two extruded segments in degrees
43152
44022
  // positive angle means join is convex; negative angle means join is concave
43153
44023
  function getJoinAngle(direction1, direction2) {
@@ -43181,10 +44051,22 @@ ${svg}
43181
44051
  // reverse, so its positions map back to source order; cap points get NaN so
43182
44052
  // a pocket spanning a cap is never treated as a single-bend overshoot.
43183
44053
  var nan = function(arr) { return arr.map(function() { return NaN; }); };
44054
+ var zeros = function(arr) { return arr.map(function() { return 0; }); };
43184
44055
  var rightPos = right.srcPos.map(function(p) { return (n - 1) - p; });
43185
44056
  var srcPos = left.srcPos.concat(nan(endCap), rightPos, nan(startCap));
43186
44057
  srcPos.push(srcPos[0]);
43187
- return {ring: ring, srcPos: srcPos};
44058
+ // Parallel reversed-arc ("dip") tags: caps are never dips.
44059
+ var dipTags = left.dipTags.concat(zeros(endCap), right.dipTags, zeros(startCap));
44060
+ dipTags.push(dipTags[0]);
44061
+ // Fan-apart gap-patch bends from both offset sides, as source-vertex indices.
44062
+ // A bend is concave from only one side, so the two sides flag disjoint
44063
+ // vertices; the right side was traced in reverse, so map its indices back to
44064
+ // source order ((n-1) - r).
44065
+ var bendSet = {};
44066
+ (left.fanApartBends || []).forEach(function(j) { bendSet[j] = 1; });
44067
+ (right.fanApartBends || []).forEach(function(j) { bendSet[(n - 1) - j] = 1; });
44068
+ var bends = Object.keys(bendSet).map(Number);
44069
+ return {ring: ring, srcPos: srcPos, dipTags: dipTags, fanApartBends: bends};
43188
44070
  }
43189
44071
 
43190
44072
  // Cumulative absolute turn of the source path, indexed by vertex. The turn
@@ -43206,26 +44088,45 @@ ${svg}
43206
44088
  return prefix;
43207
44089
  }
43208
44090
 
43209
- function makeOffsetSide(verts, dist) {
43210
- // Trace a single left-offset side of the source path, including joins
43211
- // between adjacent offset segments but not endpoint caps. srcPos[] records,
43212
- // per emitted point, the source-path vertex it derives from, so loop
43213
- // removal can judge a self-crossing by the turn of the originating path
43214
- // span rather than by the (unreliable) geometry of the offset itself.
43215
- var points = [];
43216
- var srcPos = [];
44091
+ // Shared per-segment offset construction for the constant-radius "clean"
44092
+ // buffer outline. Walks the left offset of `verts`, joining adjacent offset
44093
+ // segments with inscribed round joins (convex bends), elbow/shallow joins, and
44094
+ // reversed makeConcaveJoin arcs (concave bends) -- every emitted vertex stays
44095
+ // at distance `dist`, so the traced side keeps a true +/-1 winding. This is
44096
+ // the single construction used by BOTH the open two-sided line outline
44097
+ // (makeOffsetSide) and the clean-outline-winding polygon grow
44098
+ // (buildCleanOutlineRings), so a construction fix lands in one place for both.
44099
+ //
44100
+ // Returns parallel arrays { points, segs }: each offset vertex (consecutive
44101
+ // duplicates dropped) and the source segment it derives from, in trace order.
44102
+ // Returning the finished arrays rather than taking a per-vertex `emit` callback
44103
+ // keeps the tracer a pure (verts, dist) -> data function (easy to unit-test in
44104
+ // isolation) and puts the consecutive-duplicate dedup in one place, so the line
44105
+ // and polygon callers can't drift apart on it. (Both styles measure the same;
44106
+ // the line caller reuses `points`/`segs` in place, so construction is still a
44107
+ // single pass.) The final segment endpoint is NOT pushed; it is returned as
44108
+ // `lastPoint` so the caller can either append it (open side) or close the ring
44109
+ // on its first
44110
+ // vertex (closed outline -- its collinear midpoint seam makes the recomputed
44111
+ // final point a sub-ULP duplicate of the first offset vertex, which must be
44112
+ // dropped rather than emitted, see makeLeftBufferRings closure notes).
44113
+ function traceCleanOffsetSide(verts, dist) {
43217
44114
  var x1, y1, x2, y2, bearing, bearingPrev, joinAngle, hit;
43218
- var p1, p2, p1Prev, p2Prev;
43219
- var firstBearing, lastBearing, joinPoints;
43220
- var pos = 0;
43221
- function pushPt(p) {
43222
- var before = points.length;
43223
- addPoint(points, p);
43224
- if (points.length > before) srcPos.push(pos);
43225
- }
43226
- function pushPts(pts) {
43227
- for (var i = 0; i < pts.length; i++) pushPt(pts[i]);
43228
- }
44115
+ var p1, p2, p1Prev, p2Prev, firstBearing, lastBearing, joinPoints, i;
44116
+ var points = [], segs = [], dipTags = [], fanApartBends = [];
44117
+ var vertsSegIndex = null;
44118
+ // tag: 1 marks a vertex emitted as part of a reversed concave-join arc
44119
+ // (the "dip" the construction inserts when adjacent offset segments do not
44120
+ // meet locally). These runs are pure self-overlap artifacts, so loop
44121
+ // removal can key on them directly instead of guessing from ring geometry.
44122
+ function add(p, segId, tag) {
44123
+ var prev = points[points.length - 1];
44124
+ if (prev && prev[0] === p[0] && prev[1] === p[1]) return;
44125
+ points.push(p);
44126
+ segs.push(segId);
44127
+ dipTags.push(tag ? 1 : 0);
44128
+ }
44129
+ var concaveJoin = opts.coarse_bridge ? makeCoarseConcaveJoin : makeConcaveJoin;
43229
44130
  for (var segId = 0; segId < verts.length - 1; segId++) {
43230
44131
  x1 = verts[segId][0];
43231
44132
  y1 = verts[segId][1];
@@ -43234,42 +44135,40 @@ ${svg}
43234
44135
  bearing = bearingDegrees2D(x1, y1, x2, y2);
43235
44136
  p1 = getOffsetPoint(x1, y1, bearing - 90, dist);
43236
44137
  p2 = getOffsetPoint(x2, y2, bearing - 90, dist);
43237
- pos = segId;
43238
44138
  if (segId === 0) {
43239
- pushPt(p1);
44139
+ add(p1, segId);
43240
44140
  firstBearing = bearing;
43241
44141
  } else {
43242
44142
  joinAngle = getJoinAngle(bearingPrev, bearing);
43243
- if (opts.winding_fill && joinAngle < 0) {
43244
- // Clipper2-style concave join: never cut the band. Walk the full
43245
- // incoming offset (p2Prev), dip back to the original vertex, then
43246
- // walk out the full outgoing offset (p1). The self-overlapping
43247
- // pocket this creates is resolved by the winding-number union
43248
- // (it cancels where another band covers it, survives where the
43249
- // concavity is real), so no band-coverage audit is needed.
43250
- pushPt(p2Prev);
43251
- pushPt([x1, y1]);
43252
- pushPt(p1);
43253
- } else if (joinAngle > roundJoinSegAngle * 1.5) {
43254
- joinPoints = makeOutsideRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
43255
- pushPts(joinPoints);
44143
+ if (joinAngle > roundJoinSegAngle * 1.5) {
44144
+ joinPoints = makeInscribedRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
44145
+ for (i = 0; i < joinPoints.length; i++) add(joinPoints[i], segId);
43256
44146
  p1 = joinPoints[joinPoints.length - 1];
43257
44147
  } else if (joinAngle > -1e-10 && joinAngle < 1e-10) {
43258
- pushPt(p1);
44148
+ add(p1, segId);
43259
44149
  } else if (joinAngle > 0 && (hit = elbowJoin(p1Prev, p2Prev, p1, p2,
43260
44150
  bearingPrev, bearing, x1, y1, dist)) ||
43261
44151
  joinAngle < 0 && (hit = bufferSegmentIntersection(p1Prev, p2Prev, p1, p2))) {
43262
- pushPt(hit);
44152
+ add(hit, segId);
43263
44153
  p1 = hit;
43264
44154
  } else if (joinAngle > 0) {
43265
- pushPt(p1);
44155
+ add(p1, segId);
43266
44156
  } else if (joinAngle < 0 && (hit = shallowAngleJoin(p2Prev, p1, x1, y1, dist))) {
43267
- pushPt(hit);
44157
+ add(hit, segId);
43268
44158
  p1 = hit;
43269
44159
  } else {
43270
- pushPt(p2Prev);
43271
- pushPts(makeConcaveJoin(x1, y1, bearing - 90, -joinAngle, dist));
43272
- pushPt(p1);
44160
+ add(p2Prev, segId, 1);
44161
+ joinPoints = concaveJoin(x1, y1, bearing - 90, -joinAngle, dist);
44162
+ if (useGapPatch(opts, useMercator) &&
44163
+ offsetEdgesFanApart(p1Prev, p2Prev, p1, p2)) {
44164
+ if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44165
+ if (wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44166
+ joinPoints, p2Prev, p1)) {
44167
+ fanApartBends.push(segId);
44168
+ }
44169
+ }
44170
+ for (i = 0; i < joinPoints.length; i++) add(joinPoints[i], segId, 1);
44171
+ add(p1, segId, 1);
43273
44172
  }
43274
44173
  }
43275
44174
  bearingPrev = bearing;
@@ -43277,29 +44176,57 @@ ${svg}
43277
44176
  p2Prev = p2;
43278
44177
  lastBearing = bearing;
43279
44178
  }
43280
- pos = verts.length - 1;
43281
- pushPt(p2Prev);
43282
44179
  return {
43283
44180
  points: points,
43284
- srcPos: srcPos,
44181
+ segs: segs,
44182
+ dipTags: dipTags,
44183
+ fanApartBends: fanApartBends,
43285
44184
  firstBearing: firstBearing,
43286
- lastBearing: lastBearing
44185
+ lastBearing: lastBearing,
44186
+ lastPoint: p2Prev,
44187
+ lastSeg: verts.length - 1
43287
44188
  };
43288
44189
  }
43289
44190
 
43290
- function addPoint(arr, p) {
43291
- var prev = arr[arr.length - 1];
43292
- if (!prev || prev[0] !== p[0] || prev[1] !== p[1]) {
43293
- arr.push(p);
44191
+ function makeOffsetSide(verts, dist) {
44192
+ // Thin wrapper over the shared traceCleanOffsetSide construction. The tracer
44193
+ // already returns the deduped offset polyline (points[]) and a parallel
44194
+ // srcPos[] of each point's source segment (so loop removal can judge a
44195
+ // self-crossing by the turn of the originating path span rather than by the
44196
+ // unreliable geometry of the offset itself), so we reuse those arrays in
44197
+ // place and only append the final segment endpoint here (with the same
44198
+ // consecutive-duplicate guard); endpoint caps are added by the caller.
44199
+ var info = traceCleanOffsetSide(verts, dist);
44200
+ var points = info.points;
44201
+ var srcPos = info.segs;
44202
+ var dipTags = info.dipTags;
44203
+ var last = info.lastPoint;
44204
+ if (last) {
44205
+ var prev = points[points.length - 1];
44206
+ if (!prev || prev[0] !== last[0] || prev[1] !== last[1]) {
44207
+ points.push(last);
44208
+ srcPos.push(info.lastSeg);
44209
+ dipTags.push(0);
44210
+ }
43294
44211
  }
44212
+ return {
44213
+ points: points,
44214
+ srcPos: srcPos,
44215
+ dipTags: dipTags,
44216
+ fanApartBends: info.fanApartBends,
44217
+ firstBearing: info.firstBearing,
44218
+ lastBearing: info.lastBearing
44219
+ };
43295
44220
  }
43296
44221
 
43297
44222
  // Reduce a path's vertex count with Douglas-Peucker simplification
43298
44223
  // before buffering. Removed vertices lie within the interval of the
43299
44224
  // simplified path, so the buffer outline deviates from the unsimplified
43300
- // buffer by at most the interval; D.P. retains locally extreme vertices
43301
- // (the ones that determine the outline), so the typical deviation is
43302
- // much smaller. Collapsed paths fall back to their original vertices: a
44225
+ // buffer by at most the interval (see getBufferSimplifyFunction for the
44226
+ // empirical calibration). D.P. retains locally extreme vertices (the ones
44227
+ // that determine the outline), so the typical deviation is much smaller.
44228
+ // End-segment bearings are pinned (kk[1] and kk[n-2]) so cap geometry
44229
+ // stays exact. Collapsed paths fall back to their original vertices: a
43303
44230
  // small ring is below the error budget, but its buffer is a whole disk.
43304
44231
  function presimplifyPathVerts(verts, interval, dist) {
43305
44232
  var n = verts.length;
@@ -43328,14 +44255,6 @@ ${svg}
43328
44255
  // segments are preserved exactly: cap geometry at path endpoints
43329
44256
  // (flat caps especially) depends on them.
43330
44257
  kk[1] = kk[n-2] = Infinity;
43331
- if (oneSidedBuffer) {
43332
- // One-sided coverage is directional, so the turning that
43333
- // simplification concentrates into single bends must stay below the
43334
- // angle whose corner cut at the buffer radius would exceed the
43335
- // simplification interval (see limitGapTurning).
43336
- limitGapTurning(verts, kk, interval,
43337
- 2 * Math.acos(1 / (1 + interval / (dist * mercScale))));
43338
- }
43339
44258
  var verts2 = [];
43340
44259
  for (i = 0; i < n; i++) {
43341
44260
  if (kk[i] >= interval) verts2.push(verts[i]);
@@ -43344,74 +44263,6 @@ ${svg}
43344
44263
  return verts2.length >= (closed ? 4 : 2) ? verts2 : verts;
43345
44264
  }
43346
44265
 
43347
- // One-sided buffer coverage is directional: each segment's band lies on
43348
- // one side of its own bearing, so the angular structure of the path
43349
- // matters with weight proportional to the buffer radius, not just its
43350
- // positional structure. Replacing a sub-path with a chord concentrates
43351
- // the sub-path's turning into the chord's two end joints: convex
43352
- // turning is harmless (round joins reproduce the full fan of coverage
43353
- // around a retained vertex), but concentrated concave turning B deepens
43354
- // the corner cut at the joint from gradual elbow cuts to a single cut
43355
- // ~ r * (1/cos(B/2) - 1) deeper, and a removed self-loop's 360 degrees
43356
- // of turning would cost a whole swept disk. Capping each gap's total
43357
- // absolute turning (interior bends plus the bearing mismatch between
43358
- // the chord and the gap's end segments) at the angle whose corner cut
43359
- // equals the simplification interval keeps the one-sided buffer's error
43360
- // within the interval; where a gap exceeds the cap, re-retain its most
43361
- // prominent vertex (the D.P. split point) and re-check the halves. A
43362
- // self-loop subdivides into a coarse polygon with the same total
43363
- // turning, which sweeps the same disk.
43364
- function limitGapTurning(verts, kk, interval, maxTurn) {
43365
- var stack = [];
43366
- var prev = 0;
43367
- var i, a, b, gap;
43368
- for (i = 1; i < verts.length; i++) {
43369
- if (kk[i] >= interval) {
43370
- if (i - prev > 1) stack.push([prev, i]);
43371
- prev = i;
43372
- }
43373
- }
43374
- while (stack.length > 0) {
43375
- gap = stack.pop();
43376
- a = gap[0];
43377
- b = gap[1];
43378
- if (b - a < 2 || gapTurning(verts, a, b) <= maxTurn) continue;
43379
- var maxI = a + 1;
43380
- for (i = a + 2; i < b; i++) {
43381
- if (kk[i] > kk[maxI]) maxI = i;
43382
- }
43383
- kk[maxI] = Infinity;
43384
- stack.push([a, maxI], [maxI, b]);
43385
- }
43386
- }
43387
-
43388
- // Total absolute turning (in radians) concentrated by replacing the
43389
- // sub-path verts[a..b] with a single chord: bends interior to the gap,
43390
- // plus the mismatch between the chord bearing and the bearings of the
43391
- // gap's first and last segments.
43392
- function gapTurning(verts, a, b) {
43393
- var chord = Math.atan2(verts[b][0] - verts[a][0], verts[b][1] - verts[a][1]);
43394
- if (verts[b][0] === verts[a][0] && verts[b][1] === verts[a][1]) {
43395
- return Infinity; // gap closes on itself (a loop)
43396
- }
43397
- var total = 0;
43398
- var prev = chord;
43399
- for (var i = a; i < b; i++) {
43400
- var bearing = Math.atan2(verts[i+1][0] - verts[i][0], verts[i+1][1] - verts[i][1]);
43401
- total += Math.abs(angleDelta(prev, bearing));
43402
- prev = bearing;
43403
- }
43404
- total += Math.abs(angleDelta(prev, chord));
43405
- return total;
43406
- }
43407
-
43408
- function angleDelta(a, b) {
43409
- var d = b - a;
43410
- if (d > Math.PI) d -= 2 * Math.PI;
43411
- if (d < -Math.PI) d += 2 * Math.PI;
43412
- return d;
43413
- }
43414
-
43415
44266
  // Traverse a path with the (possibly projecting) path iterator,
43416
44267
  // collecting its vertices into an array; skips duplicate points.
43417
44268
  function collectPathVertices(path) {
@@ -43444,46 +44295,37 @@ ${svg}
43444
44295
  [getOffsetPoint(x, y, startDir + 180, dist)];
43445
44296
  }
43446
44297
 
43447
- // The vertices of this join are outside of the arc that it approximates and
43448
- // the segments of the join touch the arc at their midpoints.
43449
- // The first and last of the returned vertices extend the segments on either
43450
- // side of the join.
43451
- function makeOutsideRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43452
- // point count of 1 would be an elbow joint
43453
- // (elbow joins should be created elsewhere)
44298
+ // Inscribed round join: vertices on the offset arc at equal angle steps,
44299
+ // each at the true offset distance (geodesic or planar).
44300
+ function makeInscribedRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43454
44301
  var pointCount = Math.max(1, Math.round(arcAngle / roundJoinSegAngle));
43455
44302
  var stepAngle = arcAngle / pointCount;
43456
44303
  var points = [];
43457
- var i = 0;
43458
- var a, b, c, d, joinP, tanP, bearing;
43459
- while (i <= pointCount) {
43460
- bearing = startBearing + stepAngle * i;
43461
- tanP = getOffsetPoint(cx, cy, bearing, dist);
43462
- c = getOffsetPoint(tanP[0], tanP[1], bearing - 90, dist * 2);
43463
- d = getOffsetPoint(tanP[0], tanP[1], bearing + 90, dist * 2);
43464
- if (i > 0) {
43465
- joinP = bufferSegmentIntersection(a, b, c, d);
43466
- if (!joinP) {
43467
- if (opts.polar) {
43468
- // Near a clamped pole/antimeridian corner the swept offset tangents
43469
- // collapse onto the boundary and stop intersecting; hug the clamped
43470
- // tangent point so the round join degenerates to the pinned corner
43471
- // instead of throwing.
43472
- points.push(tanP);
43473
- } else {
43474
- throw Error(`no intersection on ${i} of ${pointCount}`);
43475
- }
43476
- } else {
43477
- points.push(joinP);
43478
- }
43479
- }
43480
- a = c;
43481
- b = d;
43482
- i++;
44304
+ for (var i = 1; i <= pointCount; i++) {
44305
+ points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i, dist));
43483
44306
  }
43484
44307
  return points;
43485
44308
  }
43486
44309
 
44310
+ // True when the two consecutive offset edges of a negative-angle (concave)
44311
+ // bend do not overlap but instead diverge: the infinite-line intersection of
44312
+ // the incoming edge (a->b) and outgoing edge (c->d) lies behind the incoming
44313
+ // edge (t < 0) and beyond the outgoing edge (u > 1). A planar concave bend
44314
+ // always overlaps (the crossing is in front: t > 1, u < 0); this fan-apart
44315
+ // configuration only arises when a variable geodesic offset distance stretches
44316
+ // the two edges past each other, leaving the outer-edge gap we want to bridge
44317
+ // with a forward round join instead of a reversed (doubling-back) arc.
44318
+ function offsetEdgesFanApart(a, b, c, d) {
44319
+ var rx = b[0] - a[0], ry = b[1] - a[1];
44320
+ var sx = d[0] - c[0], sy = d[1] - c[1];
44321
+ var den = rx * sy - ry * sx;
44322
+ if (den === 0) return false; // parallel: keep the reversed bridge
44323
+ var wx = c[0] - a[0], wy = c[1] - a[1];
44324
+ var t = (wx * sy - wy * sx) / den;
44325
+ var u = (wx * ry - wy * rx) / den;
44326
+ return t < 0 && u > 1;
44327
+ }
44328
+
43487
44329
  function shallowAngleJoin(a, b, cx, cy, dist) {
43488
44330
  var gap = distance2D(a[0], a[1], b[0], b[1]);
43489
44331
  var radius = getJoinExtensionDistance(cx, cy, a, b, dist);
@@ -43525,6 +44367,24 @@ ${svg}
43525
44367
  return makeRoundJoin(cx, cy, startBearing, arcAngle, dist).reverse();
43526
44368
  }
43527
44369
 
44370
+ // Coarse alternative to makeConcaveJoin (selected by opts.coarse_bridge in
44371
+ // traceCleanOffsetSide): bridges a concave bend with as few as one reversed
44372
+ // arc vertex (CLEAN_OUTLINE_BRIDGE_STEP), producing a smaller ring for the
44373
+ // winding dissolve to chew through. The reversed bridge only bounds a self-
44374
+ // overlap loop the direction remover collapses, so the coarser resolution
44375
+ // does not change the final boundary -- it just trades a little construction
44376
+ // fidelity for dissolve speed.
44377
+ function makeCoarseConcaveJoin(cx, cy, startBearing, arcAngle, dist) {
44378
+ var segs = Math.min(roundJoinSegsPerQuadrant,
44379
+ Math.max(1, Math.ceil(arcAngle / CLEAN_OUTLINE_BRIDGE_STEP)));
44380
+ var points = [];
44381
+ var increment = arcAngle / (segs + 1);
44382
+ for (var i = 1; i <= segs; i++) {
44383
+ points.push(getOffsetPoint(cx, cy, startBearing + increment * i, dist));
44384
+ }
44385
+ return points.reverse();
44386
+ }
44387
+
43528
44388
  // get interior vertices of an interpolated CW arc
43529
44389
  function makeRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43530
44390
  var points = [];
@@ -43654,18 +44514,9 @@ ${svg}
43654
44514
  if (debug) {
43655
44515
  // Debug visualizations (raw offset rings, mosaic) want the whole layer's
43656
44516
  // geometry/topology in one dataset; keep the original global dissolve for
43657
- // them (no artifact-hole filter runs in debug mode anyway). Mirror the real
43658
- // pipeline's construction so the debug view reflects what the buffer
43659
- // actually builds: an all-closed-ring two-sided layer uses the winding-fill
43660
- // + loop-removal construction (and a winding-number dissolve), so
43661
- // debug-offset shows the loop-removed offset rings and the no-loop-removal
43662
- // flag has a visible effect. (See makePolylineBufferTwoSidedPerFeature.)
43663
- var useWindingConstruction = !oneSided && layerIsAllClosed(lyr, dataset.arcs);
43664
- var debugMakerOpts = useWindingConstruction ?
43665
- Object.assign({}, opts, {winding_fill: true}) : opts;
43666
- var debugDissolveOpts = Object.assign({}, opts, {per_part_holes: true},
43667
- useWindingConstruction ? {winding_fill: true} : null);
43668
- dataset2 = importGeoJSON(makeShapeBufferGeoJSON(lyr, dataset, debugMakerOpts), {});
44517
+ // them (no artifact-hole filter runs in debug mode anyway).
44518
+ var debugDissolveOpts = getOutlineBufferDissolveOpts(opts);
44519
+ dataset2 = importGeoJSON(makeShapeBufferGeoJSON(lyr, dataset, opts), {});
43669
44520
  dissolveBufferDataset2(dataset2, debugDissolveOpts);
43670
44521
  } else {
43671
44522
  dataset2 = makePolylineBufferTwoSidedPerFeature(lyr, dataset, opts, spherical);
@@ -43680,48 +44531,13 @@ ${svg}
43680
44531
  // Two-sided buffer pipeline, run per source feature. Each feature's outline
43681
44532
  // rings are dissolved (and artifact-hole filtered) in isolation, then the
43682
44533
  // per-feature results are merged into one polygon layer (one shape per buffered
43683
- // source feature, in input order). Buffering does not union across features --
43684
- // 560 input features yield 560 output shapes -- so a single global dissolve of
43685
- // every feature's rings pays to intersect overlapping buffers of distinct
43686
- // features whose tiles are then selected per shape anyway. On a world-scale
43687
- // line layer that global planar arrangement exploded the arc count ~20x and ran
43688
- // the process out of memory; per-feature isolation bounds peak memory to the
43689
- // most complex single feature. The dissolve collapses any internal cuts, so
43690
- // each feature's output boundary is identical to the global pipeline's.
43691
- // True if every part of a polyline shape is a closed ring (first vertex ==
43692
- // last vertex), so its two-sided buffer is an annulus per ring.
43693
- function shapeIsAllClosed(shape, arcs) {
43694
- return !!shape && shape.length > 0 && shape.every(function(part) {
43695
- return pathIsClosed(part, arcs);
43696
- });
43697
- }
43698
-
43699
- // True if every shape in the layer is made entirely of closed rings.
43700
- function layerIsAllClosed(lyr, arcs) {
43701
- var shapes = lyr.shapes || [];
43702
- return shapes.length > 0 && shapes.every(function(shape) {
43703
- return shapeIsAllClosed(shape, arcs);
43704
- });
43705
- }
43706
-
44534
+ // source feature, in input order).
43707
44535
  function makePolylineBufferTwoSidedPerFeature(lyr, dataset, opts, spherical) {
43708
44536
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
43709
- var simplifyFn = getBufferSimplifyFunction(dataset, opts); // null if tolerance=0
43710
44537
  var makerOpts = Object.assign({geometry_type: lyr.geometry_type}, opts);
43711
44538
  var makeShapeBuffer = getPolylineBufferMaker(dataset, makerOpts);
43712
- // A shape whose parts are all closed rings buffers to an annulus per ring. The
43713
- // default (open-path) construction builds each side as many split sections plus
43714
- // join-sector rings (no loop removal), which floods the dissolve with raw rings
43715
- // (~8x more on a dense coastline). Instead route these shapes through the same
43716
- // winding-fill + loop-removal construction the polygon-ring buffer uses: one
43717
- // continuous offset ring per side, with self-overlap loops stripped, resolved
43718
- // by a winding-number dissolve. Open or mixed shapes keep the tuned outline +
43719
- // boundary-flood path unchanged.
43720
- var closedMaker = getPolylineBufferMaker(dataset,
43721
- Object.assign({}, makerOpts, {winding_fill: true}));
43722
44539
  var useFilter = useArtifactHoleFilter(opts);
43723
- var quadSegs = opts.quad_segs >= 2 ? opts.quad_segs : 8;
43724
- var sagPct = 1 - Math.cos(Math.PI / 4 / quadSegs);
44540
+ var outlineDissolveOpts = getOutlineBufferDissolveOpts(opts);
43725
44541
  // The two-sided pipeline is also reached by a one-sided buffer when the
43726
44542
  // winding fill is explicitly disabled (winding_fill: false); the hole filter
43727
44543
  // must then use its one-sided coverage test (see filterOutlineArtifactHolesFromShape).
@@ -43730,28 +44546,18 @@ ${svg}
43730
44546
  side: opts.right ? -1 : 1,
43731
44547
  roundCaps: (opts.cap_style || 'round') == 'round'
43732
44548
  } : null;
43733
- var dissolveOpts = Object.assign({}, opts, {per_part_holes: true});
43734
- var closedDissolveOpts = Object.assign({}, dissolveOpts, {winding_fill: true});
43735
44549
  var datasets = [];
43736
44550
  lyr.shapes.forEach(function(shape, i) {
43737
44551
  var distance = distanceFn(i);
43738
44552
  if (!distance || !shape) return;
43739
- // Closed-ring fast path only for ordinary two-sided buffers (the one-sided
43740
- // construction reaches this function with winding_fill:false and needs its
43741
- // own coverage handling); mixed open/closed shapes fall back too.
43742
- var allClosed = !oneSided && shapeIsAllClosed(shape, dataset.arcs);
43743
- var retn = (allClosed ? closedMaker : makeShapeBuffer)(shape, distance);
44553
+ var retn = makeShapeBuffer(shape, distance);
43744
44554
  var feats = (Array.isArray(retn) ? retn : [retn]).filter(Boolean);
43745
44555
  if (!feats.length) return;
43746
44556
  var ds = importGeoJSON(getBufferGeoJSON(feats), {});
43747
- dissolveBufferDataset2(ds, allClosed ? closedDissolveOpts : dissolveOpts);
43748
- if (useFilter && !allClosed) {
43749
- var bufLyr = ds.layers[0];
43750
- var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
43751
- bufLyr.shapes = bufLyr.shapes.map(function(bufShape) {
43752
- return filterOutlineArtifactHolesFromShape(bufShape, ds.arcs, shape,
43753
- dataset.arcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
43754
- });
44557
+ dissolveBufferDataset2(ds, outlineDissolveOpts);
44558
+ if (useFilter) {
44559
+ applyOutlineArtifactHoleFilter(ds.layers[0], ds.arcs, lyr, dataset, opts,
44560
+ spherical, {oneSided: oneSided, sideOpts: sideOpts, srcIndex: i});
43755
44561
  }
43756
44562
  datasets.push(ds);
43757
44563
  });
@@ -44074,6 +44880,32 @@ ${svg}
44074
44880
  return !opts.debug_offset && !opts.debug_mosaic;
44075
44881
  }
44076
44882
 
44883
+ // Apply the outline artifact-hole filter to every shape in a buffer layer,
44884
+ // using the parallel source layer for distance-to-path tests. Shared by the
44885
+ // two-sided line buffer (per feature) and the clean-outline polygon grow.
44886
+ function applyOutlineArtifactHoleFilter(bufLyr, bufArcs, srcLyr, srcDataset,
44887
+ opts, spherical, filterOpts) {
44888
+ filterOpts = filterOpts || {};
44889
+ if (!useArtifactHoleFilter(opts)) return;
44890
+ var distanceFn = getBufferDistanceFunction(srcLyr, srcDataset, opts);
44891
+ var simplifyFn = getBufferSimplifyFunction(srcDataset, opts);
44892
+ var quadSegs = opts.quad_segs >= 2 ? opts.quad_segs : 8;
44893
+ var sagPct = 1 - Math.cos(Math.PI / 4 / quadSegs);
44894
+ var oneSided = !!filterOpts.oneSided;
44895
+ var sideOpts = filterOpts.sideOpts || null;
44896
+ var srcArcs = srcDataset.arcs;
44897
+ bufLyr.shapes = bufLyr.shapes.map(function(bufShape, i) {
44898
+ var srcIdx = filterOpts.srcIndex != null ? filterOpts.srcIndex : i;
44899
+ var srcShape = srcLyr.shapes[srcIdx];
44900
+ var distance = distanceFn(srcIdx);
44901
+ if (!bufShape || !srcShape || !(distance > 0)) return bufShape;
44902
+ if (filterOpts.skipShape && filterOpts.skipShape(srcShape, srcArcs)) return bufShape;
44903
+ var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
44904
+ return filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
44905
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
44906
+ });
44907
+ }
44908
+
44077
44909
  // Remove artifact rings left by dissolving the self-intersecting outline rings
44078
44910
  // made by the two-sided buffer fast path. The outline's concave-join loops can
44079
44911
  // survive the dissolve as spurious holes (and, degenerately, as zero-area rings
@@ -44987,12 +45819,57 @@ ${svg}
44987
45819
  });
44988
45820
  profileEnd('medial:simplify');
44989
45821
  }
45822
+ // Extend each chain's endpoints outward along their terminal tangent. A medial
45823
+ // chain is a cut-line: it only subdivides a contested buffer tile if it spans
45824
+ // from boundary to boundary, so the mosaic builder keeps it (an end that
45825
+ // terminates in a tile's interior is acyclic and detachAcyclicArcs prunes the
45826
+ // whole path). The sampled-site Voronoi stops a fraction of the site spacing
45827
+ // short of where two source rings meet (the gap pinches shut), leaving that end
45828
+ // dangling INSIDE the buffer. Extending past the source boundary lets the cut
45829
+ // node against it; the overshoot lands outside the contested region and is
45830
+ // self-pruned. Without this, a whole river-gap tile is left uncut and assigned
45831
+ // wholesale to one feature (e.g. the Columbia between Oregon and Washington).
45832
+ var extendDist = 0;
45833
+ for (var di = 0; di < coordDistances.length; di++) {
45834
+ if (coordDistances[di] > extendDist) extendDist = coordDistances[di];
45835
+ }
45836
+ if (extendDist > 0) {
45837
+ chains = chains.map(function(chain) {
45838
+ return extendChainEndpoints(chain, extendDist);
45839
+ });
45840
+ }
44990
45841
  return {
44991
45842
  type: 'MultiLineString',
44992
45843
  coordinates: chains
44993
45844
  };
44994
45845
  }
44995
45846
 
45847
+ // Extend an open chain past both endpoints by @len along the direction of the
45848
+ // terminal segment (so the cut-line pokes out of the contested tile at each end
45849
+ // and nodes against the enclosing boundary). Zero-length terminal segments and
45850
+ // chains shorter than 2 points are left unchanged.
45851
+ function extendChainEndpoints(chain, len) {
45852
+ if (!chain || chain.length < 2) return chain;
45853
+ var out = chain.concat();
45854
+ var head = projectPast(out[0], out[1], len);
45855
+ if (head) out.unshift(head);
45856
+ var n = out.length;
45857
+ var tail = projectPast(out[n - 1], out[n - 2], len);
45858
+ if (tail) out.push(tail);
45859
+ return out;
45860
+ }
45861
+
45862
+ // Point at distance @len beyond @from, going away from @toward (i.e. continuing
45863
+ // the from->beyond ray that the toward->from segment defines). Returns null for a
45864
+ // degenerate (coincident) segment.
45865
+ function projectPast(from, toward, len) {
45866
+ var dx = from[0] - toward[0];
45867
+ var dy = from[1] - toward[1];
45868
+ var d = Math.sqrt(dx * dx + dy * dy);
45869
+ if (d === 0) return null;
45870
+ return [from[0] + dx / d * len, from[1] + dy / d * len];
45871
+ }
45872
+
44996
45873
  // Build the medial-construction triangles for the -buffer debug-delaunay option
44997
45874
  // as a GeometryCollection of triangle polygons. collectSites returns only the
44998
45875
  // contested sites, so the Delaunay is already the per-region mesh from which the
@@ -46686,6 +47563,14 @@ ${svg}
46686
47563
  warn('debug-mosaic is not implemented for polygon buffers; ignoring');
46687
47564
  opts = Object.assign({}, opts, {debug_mosaic: false});
46688
47565
  }
47566
+ // The clean-outline-winding construction is the default polygon-grow outline
47567
+ // and the topological per-feature offset (band-method restores the older band
47568
+ // ribbon). Spurious dissolve holes on the default grow are removed by the
47569
+ // shared outline artifact-hole filter; gap-patch handles geodesic fan-apart
47570
+ // outer-wall gaps at construction time.
47571
+ if (!opts.band_method) {
47572
+ opts = Object.assign({}, opts, {clean_outline_winding: true});
47573
+ }
46689
47574
  if (opts.fill_gaps) {
46690
47575
  // Fill enclosed holes and narrow-mouthed inlets without growing the outer
46691
47576
  // boundary -- a topology-aware morphological closing (see
@@ -46744,10 +47629,23 @@ ${svg}
46744
47629
  dissolveBufferDataset2(dataset2, opts);
46745
47630
  }
46746
47631
  }
47632
+ if (useOutlineGrowArtifactFilter(opts)) {
47633
+ applyOutlineArtifactHoleFilter(dataset2.layers[0], dataset2.arcs,
47634
+ lyr, dataset, opts, spherical, {
47635
+ skipShape: function(shape, arcs) {
47636
+ return shapeHasFillInsideHole(shape, arcs);
47637
+ }
47638
+ });
47639
+ }
46747
47640
  cullSubTolerancePolygonArtifacts(dataset2, lyr, dataset, opts);
46748
47641
  return dataset2;
46749
47642
  }
46750
47643
 
47644
+ // True for clean-outline polygon grow and topological (not band-method or debug).
47645
+ function useOutlineGrowArtifactFilter(opts) {
47646
+ return !opts.band_method && !bufferOutputIsDebug(opts);
47647
+ }
47648
+
46751
47649
  // True when the buffer is producing a debug view (raw offsets or medial
46752
47650
  // construction) rather than a real buffer; those must skip the artifact cull.
46753
47651
  function bufferOutputIsDebug(opts) {
@@ -46763,10 +47661,9 @@ ${svg}
46763
47661
  // is dropped. The smallest legitimate buffer part (the grow of a point-like
46764
47662
  // feature, ~pi*d^2) is ~4 orders of magnitude larger, so the threshold never
46765
47663
  // touches real geometry, and a genuinely thin-but-long fill (area =
46766
- // tol*length >> tol^2) is kept. Holes are intentionally left alone: a small hole
46767
- // can be a neighbor's preserved territory (see removePositiveBufferArtifactHoles,
46768
- // which is territory-aware) and must not be filled here. Disabled when tolerance
46769
- // is turned off (tolerance=0), matching the medial-simplify contract.
47664
+ // tol*length >> tol^2) is kept. Holes are intentionally left alone; the outline
47665
+ // artifact-hole filter runs after dissolve and keeps legitimate carved holes.
47666
+ // Disabled when tolerance is turned off (tolerance=0), matching the medial-simplify contract.
46770
47667
  function cullSubTolerancePolygonArtifacts(outDataset, srcLyr, srcDataset, opts) {
46771
47668
  if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') return;
46772
47669
  if (!outDataset || !outDataset.arcs) return;
@@ -46798,8 +47695,9 @@ ${svg}
46798
47695
  // - The clean-outline construction is the default for ordinary polygon grow
46799
47696
  // (outer rings offset to a single self-contained loop; far fewer rings and
46800
47697
  // self-intersections into the winding dissolve than the band ribbon).
46801
- // - The topological pipeline (which pre-dissolves per feature into a shared
46802
- // mosaic) and the band-method escape hatch keep the band-ribbon construction.
47698
+ // - The band-method escape hatch keeps the band-ribbon construction; the
47699
+ // topological pipeline uses the same clean-outline grow (with shared-arc path
47700
+ // splitting) unless band-method is set.
46803
47701
  // - Negative buffers and hole shrink fall back to the band erode inside the
46804
47702
  // outline path itself.
46805
47703
  // Shared by makePolygonBuffer and makePolarPolygonBuffer so the polar option is
@@ -46994,13 +47892,13 @@ ${svg}
46994
47892
  // Drives the same per-ring makers the real construction uses, so the view shows
46995
47893
  // exactly what is built and 'no-loop-removal' has a visible effect (loop removal
46996
47894
  // runs inside the maker -- see buildOneSidedRings). Positive grow uses the
46997
- // clean-outline maker by default (the band maker under band-method/topological);
46998
- // holes are eroded with the band maker reversed to outer orientation (matching
47895
+ // clean-outline maker by default (the band maker under band-method); holes are
47896
+ // eroded with the band maker reversed to outer orientation (matching
46999
47897
  // makeOutlineBufferGeometry); negative (erode) buffers offset every ring inward
47000
47898
  // with the band maker.
47001
47899
  function makePolygonDebugOffsetGeoJSON(lyr, dataset, opts) {
47002
47900
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
47003
- var useOutline = !opts.band_method && !opts.topological;
47901
+ var useOutline = !opts.band_method;
47004
47902
  var leftOpts = useOutline ? Object.assign({}, opts, {outline: true}) : opts;
47005
47903
  var leftMaker = getPolygonRingBufferMaker(dataset, leftOpts, 'left');
47006
47904
  var rightMaker = getPolygonRingBufferMaker(dataset,
@@ -47146,17 +48044,7 @@ ${svg}
47146
48044
  getBufferMultiPolygonCoords(rings.outer, distance, outerMaker) : [];
47147
48045
  if (outerLoops.length === 0) return null;
47148
48046
  // Resolve the outer offset loops' self-overlaps into clean grown polygons.
47149
- var coords = dissolveOffsetRingsToCoords(outerLoops, opts);
47150
- if (coords.length === 0) return null;
47151
- // Strip artifact holes left by the outer grow (the offset loops dissolve into
47152
- // a clean fill, so any interior ring is a self-overlap artifact) BEFORE
47153
- // carving the real holes. The artifact filter's heuristic can otherwise
47154
- // delete a legitimately-shrunk hole whose eroded boundary happens to run near
47155
- // the source outline; the carved holes below are explicit and known-good, so
47156
- // they must not pass through it.
47157
- var grown = removePositiveBufferArtifactHoles(
47158
- {type: 'MultiPolygon', coordinates: coords}, shape, arcs, distance);
47159
- coords = grown ? grown.coordinates : [];
48047
+ var coords = dissolveOffsetRingsToCoords(outerLoops, opts, true);
47160
48048
  if (coords.length === 0) return null;
47161
48049
  if (rings.holes.length > 0) {
47162
48050
  // Shrink the holes (an inward offset) with the band erode: treat each hole
@@ -47172,6 +48060,42 @@ ${svg}
47172
48060
  return {type: 'MultiPolygon', coordinates: coords};
47173
48061
  }
47174
48062
 
48063
+ // Clean-outline grow for one topological feature: offset each shared-arc path
48064
+ // chain (see getPolygonBufferPathData), grow outers with the outline maker,
48065
+ // shrink holes with the band erode, then union -- same hole semantics as
48066
+ // makeOutlineBufferGeometry but with inter-feature path splitting preserved.
48067
+ //
48068
+ // A chain is classified as outer (grow) vs hole (shrink) by the signed area of
48069
+ // the CLOSED SOURCE RING it was split from, not by the chain's own area: an
48070
+ // unshared-boundary chain is an OPEN fragment (e.g. a state's international or
48071
+ // coastline segment between two shared interior borders), whose planar signed
48072
+ // area has an arbitrary sign and would misclassify a real outer boundary as a
48073
+ // hole -- eroding it inward instead of growing it (Ohio's Lake Erie coast, New
48074
+ // Mexico's Mexico border).
48075
+ function makeTopologicalOutlineBufferCoords(shape, arcs, distance, uniqueArcTest,
48076
+ opts, outerMaker, holeEroder) {
48077
+ var outer = [];
48078
+ var holes = [];
48079
+ (shape || []).forEach(function(ring) {
48080
+ var target = getPlanarPathArea(ring, arcs) < 0 ? holes : outer;
48081
+ var chains = uniqueArcTest ? splitPathAtSharedArcs(ring, uniqueArcTest) :
48082
+ [ring.concat()];
48083
+ chains.forEach(function(chain) { target.push(chain); });
48084
+ });
48085
+ var outerLoops = outer.length > 0 ?
48086
+ getBufferMultiPolygonCoords(outer, distance, outerMaker) : [];
48087
+ if (outerLoops.length === 0) return [];
48088
+ var coords = dissolveOffsetRingsToCoords(outerLoops, opts, true);
48089
+ if (coords.length === 0) return [];
48090
+ if (holes.length > 0) {
48091
+ var holeGeom = holeEroder(holes.map(reversePath), distance);
48092
+ if (holeGeom && holeGeom.coordinates.length > 0) {
48093
+ coords = subtractHolesFromOuter(coords, holeGeom.coordinates);
48094
+ }
48095
+ }
48096
+ return coords;
48097
+ }
48098
+
47175
48099
  // Carve clean shrunk-hole regions out of clean grown-outer polygons. Both arrive
47176
48100
  // as positive (CCW) rings. The winding union can't subtract one nested loop from
47177
48101
  // another -- GeoJSON import rewinds every outer ring to CCW, so two separately
@@ -47248,13 +48172,23 @@ ${svg}
47248
48172
  var hasNegativeDistance = false;
47249
48173
  if (useTopologicalMode) {
47250
48174
  // The topological pipeline selects mosaic tiles by source membership
47251
- // (boundary flood), which cannot resolve the self-overlapping winding-fill
47252
- // ring. So each feature's winding-fill offset rings are pre-dissolved into a
47253
- // clean (non-self-overlapping) polygon before they enter the shared mosaic;
47254
- // the construction speedup (loop removal cutting per-feature self-
47255
- // intersections) is preserved, and the mosaic is unchanged.
48175
+ // (boundary flood), which cannot resolve self-overlapping offset rings.
48176
+ // Each feature's offset is pre-dissolved into a clean polygon before it
48177
+ // enters the shared mosaic. By default this uses the same clean-outline grow
48178
+ // as ordinary polygon buffers (gap-patch, loop removal); band-method keeps
48179
+ // the older band ribbon.
48180
+ var topoLeftOpts = opts.band_method ? opts :
48181
+ Object.assign({}, opts, {outline: true});
48182
+ var outerMaker = getPolygonRingBufferMaker(dataset, topoLeftOpts, 'left');
48183
+ var bandOpts = Object.assign({}, opts, {outline: false});
48184
+ var bandLeftMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'left');
48185
+ var bandRightMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'right');
48186
+ var holeEroder = function(holeShape, dist) {
48187
+ return makeNegativePolygonBufferGeometry(holeShape, dist, dataset, bandOpts,
48188
+ bandRightMaker);
48189
+ };
47256
48190
  return makeTopologicalPolygonBufferGeoJSON(lyr, dataset, opts, distanceFn,
47257
- uniqueArcTest, getPolygonRingBufferMaker(dataset, opts, 'left'));
48191
+ uniqueArcTest, outerMaker, holeEroder, bandLeftMaker);
47258
48192
  }
47259
48193
  // Closed source rings are offset with the winding-fill construction: one
47260
48194
  // self-overlapping ring per source ring (its overshoot loops resolved by the
@@ -47320,6 +48254,7 @@ ${svg}
47320
48254
  // for callers that request winding-fill (see the 'band-method' option).
47321
48255
  var useWinding = !opts.band_method;
47322
48256
  var makerOpts = Object.assign({}, opts, {
48257
+ geometry_type: 'polygon',
47323
48258
  left: side == 'left',
47324
48259
  right: side == 'right',
47325
48260
  // Winding-fill construction also enables overshoot-loop removal on the single
@@ -47340,7 +48275,7 @@ ${svg}
47340
48275
  }
47341
48276
 
47342
48277
  function makeTopologicalPolygonBufferGeoJSON(lyr, dataset, opts, distanceFn,
47343
- uniqueArcTest, bufferMaker) {
48278
+ uniqueArcTest, outerMaker, holeEroder, bandFallbackMaker) {
47344
48279
  var shapes = lyr.shapes || [];
47345
48280
  var distances = [];
47346
48281
  var sourceIds = [];
@@ -47364,18 +48299,22 @@ ${svg}
47364
48299
  profileStart('topo:offsets');
47365
48300
  shapes.forEach(function(shape, i) {
47366
48301
  var distance = distances[i];
47367
- var pathData, bufferCoords;
48302
+ var bufferCoords;
47368
48303
  if (!distance || !shape) return;
47369
48304
  hasPositiveDistance = true;
47370
- pathData = getPolygonBufferPathData(shape, uniqueArcTest);
47371
- bufferCoords = getBufferMultiPolygonCoords(pathData.paths, distance, bufferMaker);
47372
- // Resolve the winding-fill rings' self-overlaps into a clean polygon (the
47373
- // mosaic's boundary-flood membership cannot), so this feature enters the
47374
- // shared mosaic as an ordinary polygon. The band-method fallback emits
47375
- // boundary-flood-resolvable bands, so it feeds the mosaic directly (as the
47376
- // topological pipeline did before the winding-fill construction).
47377
- if (!opts.band_method) {
47378
- bufferCoords = dissolveOffsetRingsToCoords(bufferCoords, opts);
48305
+ if (!opts.band_method && opts.clean_outline_winding &&
48306
+ !shapeHasFillInsideHole(shape, dataset.arcs)) {
48307
+ bufferCoords = makeTopologicalOutlineBufferCoords(shape, dataset.arcs,
48308
+ distance, uniqueArcTest, opts, outerMaker, holeEroder);
48309
+ } else {
48310
+ var pathData = getPolygonBufferPathData(shape, uniqueArcTest);
48311
+ var maker = bandFallbackMaker || outerMaker;
48312
+ bufferCoords = getBufferMultiPolygonCoords(pathData.paths, distance, maker);
48313
+ // Resolve winding-fill band rings' self-overlaps (the mosaic's boundary-
48314
+ // flood membership cannot). band-method feeds bands directly.
48315
+ if (!opts.band_method) {
48316
+ bufferCoords = dissolveOffsetRingsToCoords(bufferCoords, opts);
48317
+ }
47379
48318
  }
47380
48319
  if (bufferCoords.length > 0) {
47381
48320
  bufferIds[i] = tmpGeometries.length;
@@ -48050,13 +48989,36 @@ ${svg}
48050
48989
  function ringEnclosesOtherTerritory(ring, ctx) {
48051
48990
  var points = ctx.territoryPoints;
48052
48991
  if (!points) return false;
48992
+ var bounds = getGeoJSONRingBounds(ring);
48053
48993
  for (var i = 0; i < points.length; i++) {
48054
48994
  if (points[i].featureId === ctx.featureId) continue;
48995
+ if (!pointInGeoJSONRingBounds(points[i].x, points[i].y, bounds)) continue;
48055
48996
  if (pointInGeoJSONRing(points[i].x, points[i].y, ring)) return true;
48056
48997
  }
48057
48998
  return false;
48058
48999
  }
48059
49000
 
49001
+ function getGeoJSONRingBounds(ring) {
49002
+ var n = ring.length - 1; // skip duplicate closing vertex
49003
+ if (n <= 0) n = ring.length;
49004
+ var xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity;
49005
+ var i, x, y;
49006
+ for (i = 0; i < n; i++) {
49007
+ x = ring[i][0];
49008
+ y = ring[i][1];
49009
+ if (x < xmin) xmin = x;
49010
+ if (x > xmax) xmax = x;
49011
+ if (y < ymin) ymin = y;
49012
+ if (y > ymax) ymax = y;
49013
+ }
49014
+ return {xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax};
49015
+ }
49016
+
49017
+ function pointInGeoJSONRingBounds(x, y, bounds) {
49018
+ return x >= bounds.xmin && x <= bounds.xmax &&
49019
+ y >= bounds.ymin && y <= bounds.ymax;
49020
+ }
49021
+
48060
49022
  // Ray-casting point-in-ring test for a closed GeoJSON ring (array of [x, y],
48061
49023
  // first == last). Boundary cases are irrelevant here: territory probe points are
48062
49024
  // well inside their source part, far from any hole-ring edge.
@@ -48101,9 +49063,18 @@ ${svg}
48101
49063
  return getCoordinateDistance(distance, arcs) * 0.5;
48102
49064
  }
48103
49065
 
49066
+ // Minimum area for a grow-generated interior ring to be treated as a real hole
49067
+ // rather than numerical noise. A positive buffer can legitimately enclose a hole
49068
+ // far smaller than the buffer disk (a pocket between source arms whose mouth just
49069
+ // closed leaves an arbitrarily small gap), so this is only a degenerate-sliver
49070
+ // floor -- a tiny fraction of the buffer-disk area -- NOT a "holes smaller than
49071
+ // the radius are artifacts" rule. (It used to be the full disk area d*d, which
49072
+ // silently deleted real holes whose area was less than the radius squared; the
49073
+ // near-source boundary classifier in positiveBufferHoleIsArtifact is what
49074
+ // actually distinguishes self-overlap artifacts from real holes.)
48104
49075
  function getPositiveHoleArtifactAreaThreshold(distance, arcs) {
48105
49076
  var d = getCoordinateDistance(distance, arcs);
48106
- return d * d;
49077
+ return d * d * 0.01;
48107
49078
  }
48108
49079
 
48109
49080
  function getGeoJSONRingArea(ring) {
@@ -48177,11 +49148,14 @@ ${svg}
48177
49148
  // dissolve. Used by the topological pipeline to feed an ordinary polygon into
48178
49149
  // the shared mosaic (whose boundary-flood membership cannot resolve the
48179
49150
  // self-overlapping construction ring directly).
48180
- function dissolveOffsetRingsToCoords(coords, opts) {
49151
+ function dissolveOffsetRingsToCoords(coords, opts, outlineDissolve) {
48181
49152
  if (!coords || coords.length === 0) return [];
48182
49153
  var dataset = getBufferDataset(coords);
48183
49154
  if (!dataset.arcs) return [];
48184
- dissolveBufferDataset2(dataset, Object.assign({}, opts, {winding_fill: true}));
49155
+ var dissolveOpts = outlineDissolve ?
49156
+ getOutlineBufferDissolveOpts(opts) :
49157
+ Object.assign({}, opts, {winding_fill: true});
49158
+ dissolveBufferDataset2(dataset, dissolveOpts);
48185
49159
  var lyr = dataset.layers[0];
48186
49160
  var shape = lyr.shapes && lyr.shapes[0];
48187
49161
  return shape ? getPolygonMultiPolygonCoords(shape, dataset.arcs) : [];
@@ -57268,7 +58242,7 @@ ${svg}
57268
58242
  // Returns {xx: [], yy: []}. Arc endpoints are always preserved, so shared
57269
58243
  // topology nodes stay put and the operation is topology-safe like -simplify.
57270
58244
  var DEFAULT_WEIGHTING = 0.7;
57271
- var DEFAULT_TORTUOSITY = 2;
58245
+ var DEFAULT_TORTUOSITY = 4;
57272
58246
  // How far (in detail-distances of arc length) the survivor-merge pass looks ahead
57273
58247
  // for a chord that closes a convoluted excursion. Bounds the pass to O(n) and
57274
58248
  // caps how long a thin spike it can slice in one merge.
@@ -62345,7 +63319,8 @@ ${svg}
62345
63319
  useSphericalSimplify: useSphericalSimplify
62346
63320
  });
62347
63321
 
62348
- // Structural-corner detection for -smooth's "keep-corners" option.
63322
+ // Structural-corner detection for -smooth's corner preservation (on by default;
63323
+ // disabled with no-corners or corner-bias=0).
62349
63324
  //
62350
63325
  // Many boundaries alternate between natural, freely-curving stretches (coast,
62351
63326
  // river centerline) and artificial straight-line segments (state/county
@@ -62381,14 +63356,18 @@ ${svg}
62381
63356
  var MIN_RUN_LEN_FACTOR = 1.0; // a structural run must be at least tol * this long
62382
63357
  var MIN_RUN_RADIUS_FACTOR = 1.0; // and bend no tighter than radius tol * this
62383
63358
 
62384
- function getCornerParams(tol) {
63359
+ // @cornerBias (optional, default 1) divides the min structural-run length, so a
63360
+ // value < 1 lengthens the run a corner must border to be preserved (fewer, only
63361
+ // well-supported corners), and a value > 1 shortens it (more corners kept).
63362
+ function getCornerParams(tol, cornerBias) {
63363
+ var bias = cornerBias > 0 ? cornerBias : 1;
62385
63364
  return {
62386
63365
  tol: tol,
62387
63366
  cornerAngle: CORNER_ANGLE,
62388
63367
  tangentWindow: TANGENT_WINDOW_FACTOR * tol,
62389
63368
  innerWindow: INNER_WINDOW_FACTOR * TANGENT_WINDOW_FACTOR * tol,
62390
63369
  concentration: CORNER_CONCENTRATION,
62391
- minRunLen: MIN_RUN_LEN_FACTOR * tol,
63370
+ minRunLen: MIN_RUN_LEN_FACTOR * tol / bias,
62392
63371
  maxTurnRate: 1 / (MIN_RUN_RADIUS_FACTOR * tol) // radians of turning per ground unit
62393
63372
  };
62394
63373
  }
@@ -62634,6 +63613,14 @@ ${svg}
62634
63613
  return deg * Math.PI / 180;
62635
63614
  }
62636
63615
 
63616
+ // Resolve the keep-corners run-length bias (default 1). Its inverse scales the
63617
+ // min structural-run length, so a value < 1 protects only longer straight runs
63618
+ // (fewer corners kept) and a value > 1 protects shorter runs (more corners kept).
63619
+ function resolveCornerBias(opts) {
63620
+ var b = opts.cornerBias;
63621
+ return b > 0 ? b : 1;
63622
+ }
63623
+
62637
63624
  function smoothArcCoords(xx, yy, opts) {
62638
63625
  var n = xx.length;
62639
63626
  var origX = toArray(xx);
@@ -62652,6 +63639,7 @@ ${svg}
62652
63639
  method: method,
62653
63640
  spherical: spherical,
62654
63641
  keepCorners: keepCorners,
63642
+ cornerBias: resolveCornerBias(opts),
62655
63643
  gain: resolveGain(opts),
62656
63644
  bendAngle: bendAngle,
62657
63645
  // Refine the dense step for a smaller-than-default bend angle so one dense
@@ -62672,7 +63660,7 @@ ${svg}
62672
63660
 
62673
63661
  if (closed) {
62674
63662
  var corners = keepCorners ?
62675
- findInteriorCorners(t, channels, n, true, getCornerParams(tol)) : [];
63663
+ findInteriorCorners(t, channels, n, true, getCornerParams(tol, ctx.cornerBias)) : [];
62676
63664
  if (corners.length === 0) {
62677
63665
  return smoothClosedCyclic(t, channels, n, ctx);
62678
63666
  }
@@ -62689,7 +63677,7 @@ ${svg}
62689
63677
  }
62690
63678
 
62691
63679
  var openBreaks = keepCorners ?
62692
- findInteriorCorners(t, channels, n, false, getCornerParams(tol)) : [];
63680
+ findInteriorCorners(t, channels, n, false, getCornerParams(tol, ctx.cornerBias)) : [];
62693
63681
  return smoothOpenSpans(origX, origY, t, channels, n, openBreaks, ctx);
62694
63682
  }
62695
63683
 
@@ -62701,9 +63689,9 @@ ${svg}
62701
63689
  var bounds = [0].concat(interiorBreaks);
62702
63690
  bounds.push(n - 1);
62703
63691
  if (ctx.keepCorners && bounds.length > 2) {
62704
- bounds = refineBounds(t, channels, bounds, getCornerParams(ctx.tol));
63692
+ bounds = refineBounds(t, channels, bounds, getCornerParams(ctx.tol, ctx.cornerBias));
62705
63693
  }
62706
- var params = ctx.keepCorners ? getCornerParams(ctx.tol) : null;
63694
+ var params = ctx.keepCorners ? getCornerParams(ctx.tol, ctx.cornerBias) : null;
62707
63695
  var xx = [], yy = [];
62708
63696
  for (var s = 0; s < bounds.length - 1; s++) {
62709
63697
  var lo = bounds[s], hi = bounds[s + 1];
@@ -63209,6 +64197,16 @@ ${svg}
63209
64197
  !(opts.max_bend_angle > 0)) {
63210
64198
  stop$1('Expected max-bend-angle to be a number > 0 (degrees)');
63211
64199
  }
64200
+ if (opts.prefilter_gate !== undefined && opts.prefilter_gate !== null &&
64201
+ !(opts.prefilter_gate > 0)) {
64202
+ stop$1('Expected prefilter-gate to be a number > 0');
64203
+ }
64204
+ if (opts.corner_bias !== undefined && opts.corner_bias !== null &&
64205
+ !(opts.corner_bias >= 0)) {
64206
+ stop$1('Expected corner-bias to be a number >= 0');
64207
+ }
64208
+ // Corner preservation is on by default; no-corners or corner-bias=0 turns it off.
64209
+ var keepCorners = !opts.no_corners && opts.corner_bias !== 0;
63212
64210
  var implicitlySmoothedNames = getImplicitlyTargetedLayerNames(dataset, targetLayers, layerHasPaths);
63213
64211
 
63214
64212
  // Smoothing rewrites coordinates, so lock in any pending (non-destructive)
@@ -63226,6 +64224,7 @@ ${svg}
63226
64224
  var before = arcs.getPointCount();
63227
64225
  filterDetailPaths(arcs, {
63228
64226
  distance: tolerance,
64227
+ tortuosity: opts.prefilter_gate,
63229
64228
  spherical: spherical
63230
64229
  });
63231
64230
  var removed = before - arcs.getPointCount();
@@ -63238,7 +64237,8 @@ ${svg}
63238
64237
  tolerance: tolerance,
63239
64238
  method: method,
63240
64239
  spherical: spherical,
63241
- keepCorners: !!opts.keep_corners,
64240
+ keepCorners: keepCorners,
64241
+ cornerBias: opts.corner_bias,
63242
64242
  gain: opts.gain,
63243
64243
  maxBendAngle: opts.max_bend_angle
63244
64244
  });
@@ -63266,6 +64266,7 @@ ${svg}
63266
64266
  method: opts.method,
63267
64267
  spherical: opts.spherical,
63268
64268
  keepCorners: opts.keepCorners,
64269
+ cornerBias: opts.cornerBias,
63269
64270
  gain: opts.gain,
63270
64271
  maxBendAngle: opts.maxBendAngle,
63271
64272
  closed: arcs.arcIsClosed(arcId)
@@ -65084,7 +66085,7 @@ ${svg}
65084
66085
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
65085
66086
  }
65086
66087
 
65087
- var version = "0.7.32";
66088
+ var version = "0.7.34";
65088
66089
 
65089
66090
  // Parse command line args into commands and run them
65090
66091
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.