mapshaper 0.7.33 → 0.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: force the low-resolution concave bridge at EVERY deep
31963
+ // concave bend, bypassing the default's exposure gate and the loop
31964
+ // remover's clip budget. The guarded form is the default (2026-07-02);
31965
+ // this unguarded form is faster still but NOT area-safe: an exposed
31966
+ // bridge can dent the buffer or create spurious holes (see the caution
31967
+ // at makeCoarseConcaveJoin and "coarse-bridge" in buffer-line-notes.md).
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
  })
@@ -41198,6 +41316,24 @@ ${svg}
41198
41316
  return dataset;
41199
41317
  }
41200
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
+
41201
41337
  function dissolveBufferDataset2(dataset, optsArg) {
41202
41338
  var opts = optsArg || {};
41203
41339
  var lyr = dataset.layers.filter(function(l) { return l.geometry_type == 'polygon'; })[0] ||
@@ -41232,7 +41368,7 @@ ${svg}
41232
41368
  var pathfind = getRingIntersector(mosaicIndex.nodes);
41233
41369
  var shapes2 = lyr.shapes.map(function(shp, shapeId) {
41234
41370
  var tiles = opts.winding_fill ?
41235
- mosaicIndex.getWindingTilesByShapeId(shapeId) :
41371
+ mosaicIndex.getWindingTilesByShapeId(shapeId, false) :
41236
41372
  mosaicIndex.getTilesByShapeIds([shapeId]);
41237
41373
  var rings = [];
41238
41374
  var holes = [];
@@ -41321,12 +41457,10 @@ ${svg}
41321
41457
  // pre-simplification is disabled. Enabled by default with a tolerance of
41322
41458
  // 1% of the buffer radius; pass an explicit tolerance to change the error
41323
41459
  // budget, or tolerance=0 to disable pre-simplification.
41324
- // For a two-sided buffer (the set of points within the buffer distance of
41325
- // the path), the error is bounded by the path's positional deviation. Cap
41326
- // geometry and one-sided buffers also depend on segment bearings; the
41327
- // simplification stage preserves them by pinning the paths' end segments
41328
- // and capping the turning concentrated by removed sub-paths (see
41329
- // 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.
41330
41464
  function getBufferSimplifyFunction(dataset, opts) {
41331
41465
  if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') return null;
41332
41466
  var tolFn = getBufferToleranceFunction(dataset, opts);
@@ -41396,7 +41530,7 @@ ${svg}
41396
41530
  // that a collapsible overshoot pocket must never span.
41397
41531
  function BufferBuilder() {
41398
41532
  var self = {};
41399
- var buffer, path, bufferPos, pathPos;
41533
+ var buffer, path, bufferPos, pathPos, bufferTags, pathTags;
41400
41534
 
41401
41535
  init();
41402
41536
 
@@ -41405,6 +41539,12 @@ ${svg}
41405
41539
  path = [];
41406
41540
  bufferPos = [];
41407
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 = [];
41408
41548
  }
41409
41549
 
41410
41550
  self.size = function() {
@@ -41414,31 +41554,35 @@ ${svg}
41414
41554
  self.addPathVertex = function(p) {
41415
41555
  path.push(p);
41416
41556
  pathPos.push(NaN);
41557
+ pathTags.push(0);
41417
41558
  };
41418
41559
 
41419
- self.addBufferVertices = function(arr, pos) {
41560
+ self.addBufferVertices = function(arr, pos, tag) {
41420
41561
  for (var i=0; i<arr.length; i++) {
41421
- self.addBufferVertex(arr[i], pos);
41562
+ self.addBufferVertex(arr[i], pos, tag);
41422
41563
  }
41423
41564
  };
41424
41565
 
41425
- self.addBufferVertex = function(p, pos) {
41566
+ self.addBufferVertex = function(p, pos, tag) {
41426
41567
  var prevP = buffer[buffer.length - 1];
41427
41568
  if (prevP && pointsAreSame(prevP, p)) {
41428
41569
  return;
41429
41570
  }
41430
41571
  buffer.push(p);
41431
41572
  bufferPos.push(pos === undefined ? NaN : pos);
41573
+ bufferTags.push(tag ? 1 : 0);
41432
41574
  };
41433
41575
 
41434
- // Returns {ring, srcPos}: ring is the closed coordinate ring (first point
41435
- // 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).
41436
41579
  // @allowDegenerate: return null instead of erroring when the ring collapsed
41437
41580
  // to fewer than 3 points (an offset loop whose source ring shrank away, e.g.
41438
41581
  // a hole smaller than the buffer radius) -- a normal outcome, not a bug.
41439
41582
  self.done = function(allowDegenerate) {
41440
41583
  var ring = path.slice().reverse().concat(buffer);
41441
41584
  var srcPos = pathPos.slice().reverse().concat(bufferPos);
41585
+ var dipTags = pathTags.slice().reverse().concat(bufferTags);
41442
41586
  if (ring.length < 3) {
41443
41587
  if (allowDegenerate) {
41444
41588
  init();
@@ -41448,8 +41592,9 @@ ${svg}
41448
41592
  }
41449
41593
  ring.push(ring[0].concat());
41450
41594
  srcPos.push(srcPos[0]);
41595
+ dipTags.push(dipTags[0]);
41451
41596
  init();
41452
- return {ring: ring, srcPos: srcPos};
41597
+ return {ring: ring, srcPos: srcPos, dipTags: dipTags};
41453
41598
  };
41454
41599
 
41455
41600
  return self;
@@ -41459,6 +41604,298 @@ ${svg}
41459
41604
  return a[0] === b[0] && a[1] === b[1];
41460
41605
  }
41461
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
+
41462
41899
  // Removes small self-overlap "fold-back" loops from a constructed two-sided
41463
41900
  // buffer outline ring before it is handed to the dissolve.
41464
41901
  //
@@ -41487,9 +41924,8 @@ ${svg}
41487
41924
  // of pocket orientation; otherwise it is left for the dissolve.
41488
41925
 
41489
41926
  // Look-ahead window: how many ring segments ahead to test for a crossing. The
41490
- // turn gate (not the window) is the safety criterion, so this only bounds cost
41491
- // (O(n * window)); it must still be wide enough to reach the far side of an
41492
- // 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)).
41493
41929
  var BUFFER_LOOP_WINDOW = 30;
41494
41930
 
41495
41931
  // Max source-path turn (degrees) a collapsible loop may span. Below this the
@@ -41498,6 +41934,27 @@ ${svg}
41498
41934
  // for the dissolve.
41499
41935
  var BUFFER_LOOP_MAX_TURN = 150;
41500
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
+
41501
41958
  // ring: closed ring (first point repeated as last) of [x, y] points.
41502
41959
  // srcPos (optional): source-vertex position parallel to ring; NaN for points
41503
41960
  // (caps) with no single source segment.
@@ -41586,98 +42043,189 @@ ${svg}
41586
42043
  return s;
41587
42044
  }
41588
42045
 
41589
- // Collapse self-overlap loops using the crossing-direction signal instead of the
41590
- // source-turn gate (removeBufferRingLoops). Where a constructed offset ring has a
41591
- // consistent +/-1 base winding -- the two-sided outline of an OPEN path -- the
41592
- // winding sense of each minimal self-crossing loop classifies it exactly: a
41593
- // sub-loop wound the SAME way as its parent ring is a covered fold-back overlap
41594
- // (collapse it; the dissolve would only fill it), while a sub-loop wound the
41595
- // OPPOSITE way bounds a winding-0 pocket the dissolve must keep as a hole.
41596
- //
41597
- // This is more precise than the turn-gate's source-turn heuristic and needs no
41598
- // source-path provenance (srcPos/turnPrefix), only the ring geometry. It does
41599
- // NOT apply to the winding-fill construction used for closed rings / polygons,
41600
- // where the base winding is not a constant +/-1, so a local loop's winding sense
41601
- // does not determine the absolute (hole vs covered) winding of its interior.
41602
- //
41603
- // Pass 1 marks every vertex inside an opposite-wound (hole) loop so the collapse
41604
- // pass never eats a hole, including an overlap loop that happens to wrap one.
41605
- 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. Iterating lets the collapse of tight inner overshoot
42063
+ // loops shorten the spans of the loops that wrap them, so a wrapper whose span
42064
+ // was too long (or too-large-turn, on the gated paths) on one pass can become
42065
+ // collapsible on the next -- the multi-pass "peel simple interior loops first"
42066
+ // idea. With dipTags supplied (clean-outline rings; the tags act as a flag,
42067
+ // see below) each candidate is decided by the exact coverage check plus the
42068
+ // opposite-wound hole guard; otherwise the source-turn or geometric turn gate
42069
+ // applies (maxTurn; caps' 180-degree arcs exceed it and are never collapsed).
42070
+ // @fillFloor (coverage path): max winding-0 area a collapse may swallow; a
42071
+ // collapse filling a larger hole/notch is refused (see BUFFER_LOOP_FILL_AREA_FRAC).
42072
+ // Callers pass dist^2 * BUFFER_LOOP_FILL_AREA_FRAC; defaults to the uncovered
42073
+ // floor when omitted (unit tests without a buffer distance).
42074
+ function removeBufferRingLoopsIterative(ring, maxGap, srcPos, turnPrefix, maxTurn, dipTags, maxPasses, fillFloor) {
41606
42075
  if (!ring || ring.length < 6) return ring;
42076
+ if (maxTurn === undefined) maxTurn = BUFFER_LOOP_MAX_TURN;
42077
+ if (!(maxPasses >= 1)) maxPasses = 12;
42078
+ if (!(fillFloor >= 0)) fillFloor = BUFFER_LOOP_CHECK_MIN_AREA;
42079
+ // dipTags acts purely as a flag here: a ring built by the clean-outline
42080
+ // construction opts into the coverage-checked path. The per-vertex tag
42081
+ // values are not consulted (the tag-excluded turn gate was removed in favor
42082
+ // of the exact coverage check), so the tags are not threaded through passes.
42083
+ var coverage = !!dipTags;
42084
+ // Neighborhood clip budget, shared by all passes over this ring: each
42085
+ // accepted collapse's uncovered (clipped) area accumulates in a coarse
42086
+ // grid, and an accept that would push any touched cell past the per-collapse
42087
+ // floor is refused instead. Individually sub-floor clips are the design's
42088
+ // tolerance, but several of them can land in the SAME neighborhood (dense
42089
+ // fold clusters -- routine with coarse bridge geometry, theoretically
42090
+ // possible with overlapping same-pass collapses) and compound into a
42091
+ // floor-scale dent; the budget bounds the damage per neighborhood to the
42092
+ // same floor that bounds it per collapse.
42093
+ // Cell size scales with the buffer radius (recovered from the disk-relative
42094
+ // fillFloor) so a neighborhood is radius-scale at any latitude/units; clip
42095
+ // attribution is O(1) per accept (center cell), so a dent straddling a cell
42096
+ // edge is bounded by 2x the floor rather than 1x.
42097
+ var budgetCell = Math.max(1000, Math.sqrt(fillFloor / BUFFER_LOOP_FILL_AREA_FRAC));
42098
+ var clipBudget = coverage ? {map: new Map(), cell: budgetCell} : null;
42099
+ var work = ring, workPos = null;
42100
+ for (var pass = 0; pass < maxPasses; pass++) {
42101
+ var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn,
42102
+ coverage, fillFloor, clipBudget);
42103
+ if (res.ring.length === work.length) return res.ring; // stable
42104
+ work = res.ring; workPos = res.srcPos;
42105
+ }
42106
+ return work;
42107
+ }
42108
+
42109
+ // One greedy forward-collapse pass (mirrors removeBufferRingLoops' compaction).
42110
+ // The span gate is, in order of preference:
42111
+ // - the reliable source-path turn (getSpanTurn) when srcPos+turnPrefix given;
42112
+ // - else the exact coverage check when `coverage` is set (clean-outline rings);
42113
+ // - else the ring's cumulative turn with a geometric cusp threshold.
42114
+ // Returns {ring, srcPos} so the caller can iterate.
42115
+ function collapseRingLoopsPass(ring, maxGap, srcPos, turnPrefix, maxTurn, coverage, fillFloor, clipBudget) {
42116
+ var gated = !!(srcPos && turnPrefix);
41607
42117
  var n = ring.length - 1;
41608
- var parentCCW = (ringSignedArea(ring) >= 0);
41609
- // Pass 1: a sub-loop wound OPPOSITE to its parent ring encloses a winding-0
41610
- // hole the dissolve must keep; mark its vertices so the collapse pass never
41611
- // eats it (covers an overlap loop that happens to wrap a hole).
41612
- var holeVertex = new Uint8Array(n);
41613
- for (var i = 0; i < n - 1; i++) {
41614
- var a = ring[i], b = ring[i + 1];
41615
- var jMax = Math.min(i + maxGap, n - 1);
41616
- for (var j = i + 2; j <= jMax; j++) {
41617
- if (i === 0 && j === n - 1) continue;
41618
- var c = ring[j], d = ring[j + 1];
41619
- var hit = segHit(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1]);
41620
- if (!hit) continue;
41621
- var loopCCW = loopAreaSign(hit[0], hit[1], b[0], b[1], ring, i + 2, j) >= 0;
41622
- if (loopCCW !== parentCCW) {
41623
- for (var k = i + 1; k <= j; k++) holeVertex[k] = 1;
41624
- }
41625
- }
41626
- }
41627
- // Pass 2: collapse sub-loops wound the SAME way as the parent (covered
41628
- // fold-back overlaps) unless they would eat a hole vertex.
42118
+ // The coverage path defers entirely to the exact coverage check, so it needs
42119
+ // neither a turn gate nor a cumulative-turn prefix; the other two paths
42120
+ // (source-turn, geometric) have no coverage check and keep the turn gate.
42121
+ var ringTurn = (!gated && !coverage) ? ringAbsTurnPrefix(ring) : null;
42122
+ // The y-band edge index and the parent orientation are built lazily, on the
42123
+ // first candidate that survives to the coverage check: most rings (and most
42124
+ // passes) never get that far, and both are O(ring length) to compute.
42125
+ var covIndex = null;
42126
+ var parentCCW = false, parentKnown = false;
41629
42127
  var out = [ring[0]];
42128
+ var outPos = gated ? [srcPos[0]] : null;
41630
42129
  var segmentEnd = ring[1];
42130
+ var segmentEndPos = gated ? srcPos[1] : 0;
41631
42131
  var nextRingIndex = 2;
41632
42132
  while (true) {
41633
42133
  var anchor = out[out.length - 1];
42134
+ var anchorPos = gated ? outPos[outPos.length - 1] : 0;
41634
42135
  var ax = anchor[0], ay = anchor[1], bx = segmentEnd[0], by = segmentEnd[1];
41635
42136
  var lastScanIndex = Math.min(nextRingIndex + maxGap - 2, n - 2);
41636
42137
  var crossing = null, crossingEndIndex = 0;
41637
42138
  for (var s = nextRingIndex; s <= lastScanIndex; s++) {
41638
- var cc = ring[s], dd = ring[s + 1];
41639
- var hit2 = segHit(ax, ay, bx, by, cc[0], cc[1], dd[0], dd[1]);
41640
- if (!hit2) continue;
41641
- var ovCCW = loopAreaSign(hit2[0], hit2[1], bx, by, ring, nextRingIndex, s) >= 0;
41642
- if (ovCCW !== parentCCW) continue; // opposite-wound loop is a hole: keep
41643
- var wrapsHole = false;
41644
- for (var k2 = nextRingIndex; k2 <= s; k2++) {
41645
- if (holeVertex[k2]) { wrapsHole = true; break; }
42139
+ var c = ring[s], d = ring[s + 1];
42140
+ var hit = segHit(ax, ay, bx, by, c[0], c[1], d[0], d[1]);
42141
+ if (!hit) continue;
42142
+ if (coverage) {
42143
+ // Opposite-wound hole protection. The coverage check only guards against
42144
+ // UNCOVERING area, so it cannot catch a collapse that FILLS a real
42145
+ // winding-0 hole (filling adds coverage), and its area pre-filter treats
42146
+ // any sub-floor loop as safe to drop. A dropped sub-loop wound OPPOSITE
42147
+ // to the parent ring bounds such a hole, so refuse the collapse outright
42148
+ // -- this keeps real holes (annulus interiors, self-crossing-line
42149
+ // pockets) the coverage check alone misses, at the cost of only an
42150
+ // O(span) signed-area test on the crossings the collapse actually
42151
+ // evaluates. A same-wound overshoot fold that happens to WRAP a hole is
42152
+ // caught instead by the fill guard in the coverage check.
42153
+ if (!parentKnown) {
42154
+ parentCCW = ringSignedArea(ring) >= 0;
42155
+ parentKnown = true;
42156
+ }
42157
+ if ((loopAreaSign(hit[0], hit[1], bx, by, ring, nextRingIndex, s) >= 0) !== parentCCW) {
42158
+ continue;
42159
+ }
42160
+ // No turn gate on the coverage path: the exact coverage check is the
42161
+ // arbiter. It measures how much of the dropped region would become
42162
+ // uncovered and refuses collapses that would clip a significant lobe
42163
+ // (see docs/development/buffer-line-notes.md), catching real lobes and
42164
+ // end caps that a cheap turn/area/winding signal cannot separate from
42165
+ // safe folds. The turn gate was both leaving valid interior loops
42166
+ // uncollapsed (tight hairpins whose source turn exceeds the cap) and
42167
+ // slowing the pipeline by deferring their removal to the dissolve.
42168
+ if (!covIndex) covIndex = buildEdgeYIndex(ring, n);
42169
+ if (!collapseKeepsAreaCovered(ring, n, hit, segmentEnd, nextRingIndex, s, covIndex, fillFloor, clipBudget)) {
42170
+ continue; // dropping this loop would uncover or fill real area -- leave it
42171
+ }
42172
+ } else {
42173
+ // Source-turn / geometric paths: no coverage check, so gate on cumulative
42174
+ // turn (caps and large real bends exceed maxTurn and are never collapsed).
42175
+ var spanTurn = gated ?
42176
+ getSpanTurn(anchorPos, segmentEndPos, srcPos, nextRingIndex, s + 1, turnPrefix) :
42177
+ (ringTurn[s] - ringTurn[nextRingIndex - 1]);
42178
+ if (spanTurn >= maxTurn) continue;
41646
42179
  }
41647
- if (wrapsHole) continue;
41648
- crossing = hit2;
42180
+ crossing = hit;
41649
42181
  crossingEndIndex = s + 1;
41650
42182
  break;
41651
42183
  }
41652
42184
  if (crossing) {
41653
42185
  segmentEnd = crossing;
42186
+ if (gated) segmentEndPos = anchorPos;
41654
42187
  nextRingIndex = crossingEndIndex;
41655
42188
  continue;
41656
42189
  }
41657
42190
  out.push(segmentEnd);
42191
+ if (gated) outPos.push(segmentEndPos);
41658
42192
  if (nextRingIndex > n - 1) break;
41659
42193
  segmentEnd = ring[nextRingIndex];
42194
+ if (gated) segmentEndPos = srcPos[nextRingIndex];
41660
42195
  nextRingIndex++;
41661
42196
  }
41662
- if (out.length < 4) return ring;
42197
+ if (out.length < 4) return {ring: ring, srcPos: srcPos}; // collapsed away; keep original
41663
42198
  out.push(out[0].concat());
41664
- return out;
41665
- }
41666
-
41667
- // Absolute source turn spanned by a crossing candidate. The span covers the
41668
- // anchor position `apos`, its successor `bpos`, and ring positions srcPos[lo..hi].
41669
- // A pocket touching a cap (NaN position) is never treated as a covered overshoot.
41670
- function getSpanTurn(apos, bpos, srcPos, lo, hi, turnPrefix) {
41671
- if (apos !== apos || bpos !== bpos) return Infinity; // NaN cap
41672
- var posLo = apos < bpos ? apos : bpos;
41673
- var posHi = apos > bpos ? apos : bpos;
41674
- for (var k = lo; k <= hi; k++) {
41675
- var p = srcPos[k];
41676
- if (p !== p) return Infinity; // NaN
41677
- if (p < posLo) posLo = p;
41678
- if (p > posHi) posHi = p;
42199
+ if (gated) outPos.push(outPos[0]);
42200
+ return {ring: out, srcPos: gated ? outPos : null};
42201
+ }
42202
+
42203
+ // Cumulative absolute turn (degrees) indexed by ring vertex, EXCLUDING fold
42204
+ // cusps. prefix[k] = sum of |turn| at ring vertices 1..k-1, skipping the
42205
+ // near-180-degree cusps where the offset doubles back on itself (an artifact of
42206
+ // the self-crossing offset, with no corresponding source bend). A normal offset
42207
+ // join's turn equals its source bend angle, so the remaining sum reconstructs
42208
+ // the source stretch's cumulative turn -- the loop-removal signal -- without
42209
+ // source provenance. Only the provenance-free geometric path uses this (the
42210
+ // clean-outline rings use the exact coverage check instead); the purely
42211
+ // geometric cusp threshold can over-collapse real sharp bends (e.g. a fjord
42212
+ // mouth), which is one reason the coverage check replaced it as the default.
42213
+ var CUSP_TURN = 135;
42214
+ function ringAbsTurnPrefix(ring) {
42215
+ var n = ring.length - 1;
42216
+ var prefix = new Float64Array(n + 1);
42217
+ var RAD = 180 / Math.PI;
42218
+ for (var k = 1; k < n; k++) {
42219
+ var a = ring[k - 1], b = ring[k], c = ring[k + 1];
42220
+ var e1x = b[0] - a[0], e1y = b[1] - a[1];
42221
+ var e2x = c[0] - b[0], e2y = c[1] - b[1];
42222
+ var cross = e1x * e2y - e1y * e2x;
42223
+ var dot = e1x * e2x + e1y * e2y;
42224
+ var ang = Math.abs(Math.atan2(cross, dot)) * RAD;
42225
+ prefix[k] = prefix[k - 1] + (ang > CUSP_TURN ? 0 : ang);
41679
42226
  }
41680
- return turnPrefix[posHi] - turnPrefix[posLo];
42227
+ prefix[n] = prefix[n - 1];
42228
+ return prefix;
41681
42229
  }
41682
42230
 
41683
42231
  // Fast strict-interior segment crossing; returns [x, y] or null. Buffer join
@@ -41699,6 +42247,271 @@ ${svg}
41699
42247
  return [ax + t * abx, ay + t * aby];
41700
42248
  }
41701
42249
 
42250
+ // Exact per-collapse coverage test. A collapse drops the loop
42251
+ // L = [X, b, ring[nextRingIndex..s], X] and replaces anchor->b..c->d with
42252
+ // anchor->X->d; it is area-neutral iff the dropped region stays covered by the
42253
+ // rest of the outline. The dropped span is mostly reversed-arc "dip" folds
42254
+ // (double-covered self-overlap, safe to drop), but it may also swallow a genuine
42255
+ // offset lobe (single-covered real boundary). We measure exactly how much of the
42256
+ // dropped region would become UNCOVERED and refuse the collapse when that exceeds
42257
+ // a "big clip" threshold; small residual clips are left for the dissolve.
42258
+ //
42259
+ // Coverage is decided by winding against the stable pass-input ring (not the
42260
+ // mid-pass output), so it is independent of collapse order: a point in the loop
42261
+ // is still covered afterward iff windingFullRing(point) - windingLoop(point) != 0.
42262
+ // For a two-sided line outline the main body always covers the buffer interior,
42263
+ // so a fold has |winding| >= 2 (body + fold) and survives losing one layer, while
42264
+ // real boundary has |winding| == 1 and drops to 0. The uncovered area is
42265
+ // integrated with a horizontal scanline (below) rather than point-sampled, so an
42266
+ // interior uncovered pocket cannot be missed.
42267
+ //
42268
+ // An area pre-filter keeps this off the hot path: a loop whose own area is below
42269
+ // the threshold cannot produce a clip above it, so only large loops are swept.
42270
+ // The threshold is in ring units; under web Mercator the scale factor is >= 1
42271
+ // everywhere, so it is an upper bound on the real m^2 area and no genuinely large
42272
+ // clip is skipped regardless of latitude.
42273
+ function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor, clipBudget) {
42274
+ // Area pre-filter keeps the scanline off the hot path: a loop can neither clip
42275
+ // nor fill more than its own (absolute winding) area, so it is safe to skip
42276
+ // whenever the loop is below BOTH thresholds; collapseUncoveredArea guards the
42277
+ // self-crossing case where the net shoelace under-reports that area. (Filling
42278
+ // a winding-0 region never reaches the uncovered floor, so the fill floor must
42279
+ // join the skip test -- otherwise small folds that could fill a small hole
42280
+ // would be skipped, or every collapse would be scanned.)
42281
+ var floor = fillFloor < BUFFER_LOOP_CHECK_MIN_AREA ? fillFloor : BUFFER_LOOP_CHECK_MIN_AREA;
42282
+ var u = collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor);
42283
+ if (u < 0) return true; // loop below both floors -- too small to clip or fill
42284
+ // Reject if the collapse would clip a real lobe (uncovered) OR swallow a real
42285
+ // hole/notch (filled). The uncovered floor is an absolute "big clip" bound; the
42286
+ // fill floor scales with the buffer disk (dist^2) because a real hole's area is
42287
+ // a fixed fraction of the disk while a fold sliver is orders of magnitude
42288
+ // smaller relative to the radius (see BUFFER_LOOP_FILL_AREA_FRAC).
42289
+ if (!(u < BUFFER_LOOP_CHECK_MIN_AREA && _lastFillArea < fillFloor)) return false;
42290
+ // Neighborhood budget (see removeBufferRingLoopsIterative): a sub-floor clip
42291
+ // is only accepted while its neighborhood's accumulated clips stay under the
42292
+ // floor. Zero-clip collapses (the common case) skip this entirely.
42293
+ if (u > 0 && clipBudget) {
42294
+ // Key on the clipped region's own centroid (accumulated by the sweep):
42295
+ // overlapping clips from different collapses then share a cell even when
42296
+ // their loops' bboxes differ by kilometers. Two dents straddling a cell
42297
+ // edge can still each spend a budget, so the worst-case neighborhood dent
42298
+ // is 2x the per-collapse floor.
42299
+ var cell = clipBudget.cell;
42300
+ var key = Math.floor(_lastClipX / cell) + ':' + Math.floor(_lastClipY / cell);
42301
+ var spent = clipBudget.map.get(key) || 0;
42302
+ if (spent + u >= BUFFER_LOOP_CHECK_MIN_AREA) return false;
42303
+ clipBudget.map.set(key, spent + u);
42304
+ }
42305
+ return true;
42306
+ }
42307
+
42308
+ // Returns the area of the dropped region a collapse would leave uncovered, or -1
42309
+ // when the loop is provably too small to matter (scanline skipped). See
42310
+ // collapseKeepsAreaCovered for the winding rationale.
42311
+ //
42312
+ // The skip must not trust the net shoelace area alone: a SELF-CROSSING span
42313
+ // (figure-eight) has lobes of opposite winding whose signed areas cancel, so a
42314
+ // near-zero net can hide winding regions far above the floor. |net| bounds the
42315
+ // regions only for a simple loop; the bbox bounds them always. So the scanline
42316
+ // is skipped when the bbox is under the floor, or when the net is under the
42317
+ // floor AND the loop has no self-crossing (O(span^2) pairwise test, span <=
42318
+ // maxGap+2 edges -- run only in the suspicious small-net/large-bbox band).
42319
+ function collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor) {
42320
+ var i, loopLen = s - nextRingIndex + 3; // X, b, ring[next..s]
42321
+ // Loop area (shoelace over X, b, ring[next..s]) and bounding box.
42322
+ var area2 = 0, px = X[0], py = X[1];
42323
+ var minx = X[0], maxx = X[0], miny = X[1], maxy = X[1];
42324
+ var qx = b[0], qy = b[1];
42325
+ if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42326
+ if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42327
+ area2 += px * qy - qx * py; px = qx; py = qy;
42328
+ for (i = nextRingIndex; i <= s; i++) {
42329
+ qx = ring[i][0]; qy = ring[i][1];
42330
+ area2 += px * qy - qx * py; px = qx; py = qy;
42331
+ if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42332
+ if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42333
+ }
42334
+ area2 += px * X[1] - X[0] * py;
42335
+ var smallNet = Math.abs(area2) / 2 < floor;
42336
+ if (smallNet && (maxx - minx) * (maxy - miny) < floor) return -1;
42337
+ var scr = coverageScratch(n + loopLen);
42338
+ var lx0 = scr.lx0, ly0 = scr.ly0, lx1 = scr.lx1, ly1 = scr.ly1;
42339
+ // Loop edges (X->b, b->ring[next..s], ring[s]->X), built before the band
42340
+ // collection so the self-cross test can run first.
42341
+ var lc = 0;
42342
+ lx0[lc] = X[0]; ly0[lc] = X[1]; lx1[lc] = b[0]; ly1[lc] = b[1]; lc++;
42343
+ lx0[lc] = b[0]; ly0[lc] = b[1]; lx1[lc] = ring[nextRingIndex][0]; ly1[lc] = ring[nextRingIndex][1]; lc++;
42344
+ for (i = nextRingIndex; i < s; i++) {
42345
+ lx0[lc] = ring[i][0]; ly0[lc] = ring[i][1]; lx1[lc] = ring[i + 1][0]; ly1[lc] = ring[i + 1][1]; lc++;
42346
+ }
42347
+ lx0[lc] = ring[s][0]; ly0[lc] = ring[s][1]; lx1[lc] = X[0]; ly1[lc] = X[1]; lc++;
42348
+ if (smallNet && !loopEdgesCross(lx0, ly0, lx1, ly1, lc)) return -1; // simple: |net| == area
42349
+ // Collect the ring edges whose y-range meets the loop's band (the only ones
42350
+ // that can cross any scanline); reuse module scratch to avoid per-call garbage.
42351
+ var band = scr.band, le = 0;
42352
+ var gen = ++index.gen, stamp = index.stamp, start = index.start, edges = index.edges;
42353
+ var kb, klo = index.binOf(miny), khi = index.binOf(maxy);
42354
+ for (kb = klo; kb <= khi; kb++) {
42355
+ for (var p = start[kb]; p < start[kb + 1]; p++) {
42356
+ var ei = edges[p];
42357
+ if (stamp[ei] === gen) continue;
42358
+ stamp[ei] = gen;
42359
+ var ay = ring[ei][1], by = ring[ei + 1][1];
42360
+ if (ay < miny && by < miny || ay > maxy && by > maxy) continue;
42361
+ if (ring[ei][0] > maxx && ring[ei + 1][0] > maxx) continue; // entirely right of loop
42362
+ band[le++] = ei;
42363
+ }
42364
+ }
42365
+ return loopUncoveredArea(ring, band, le, lx0, ly0, lx1, ly1, lc, minx, maxx, miny, maxy, scr);
42366
+ }
42367
+
42368
+ // True if any two non-adjacent loop edges cross (strict interior). Adjacent
42369
+ // edges share an endpoint and cannot strictly cross, so they are skipped.
42370
+ function loopEdgesCross(lx0, ly0, lx1, ly1, lc) {
42371
+ for (var i = 0; i < lc - 1; i++) {
42372
+ for (var j = i + 2; j < lc; j++) {
42373
+ if (i === 0 && j === lc - 1) continue; // adjacent via closure at X
42374
+ if (segHit(lx0[i], ly0[i], lx1[i], ly1[i], lx0[j], ly0[j], lx1[j], ly1[j])) {
42375
+ return true;
42376
+ }
42377
+ }
42378
+ }
42379
+ return false;
42380
+ }
42381
+
42382
+ // y-band index of ring edges: bins the ring's y-range so a scanline query at
42383
+ // [miny, maxy] returns only edges reaching that band instead of scanning all n.
42384
+ // start[k]..start[k+1] are indices into `edges` for bin k; `stamp`/`gen` dedup an
42385
+ // edge that spans several bins during a single query.
42386
+ function buildEdgeYIndex(ring, n) {
42387
+ var yMin = Infinity, yMax = -Infinity, i;
42388
+ for (i = 0; i < n; i++) { var y = ring[i][1]; if (y < yMin) yMin = y; if (y > yMax) yMax = y; }
42389
+ var B = n < 32 ? 1 : Math.min(4096, Math.max(16, n >> 2));
42390
+ var binH = (yMax - yMin) / B || 1;
42391
+ var binOf = function (y) { var k = ((y - yMin) / binH) | 0; return k < 0 ? 0 : (k >= B ? B - 1 : k); };
42392
+ var start = new Int32Array(B + 1);
42393
+ for (i = 0; i < n; i++) {
42394
+ var ya = ring[i][1], yb = ring[i + 1][1];
42395
+ var lo = binOf(ya < yb ? ya : yb), hi = binOf(ya < yb ? yb : ya);
42396
+ for (var k = lo; k <= hi; k++) start[k + 1]++;
42397
+ }
42398
+ for (i = 0; i < B; i++) start[i + 1] += start[i];
42399
+ var edges = new Int32Array(start[B]);
42400
+ var cur = start.slice(0, B);
42401
+ for (i = 0; i < n; i++) {
42402
+ var a2 = ring[i][1], b2 = ring[i + 1][1];
42403
+ var lo2 = binOf(a2 < b2 ? a2 : b2), hi2 = binOf(a2 < b2 ? b2 : a2);
42404
+ for (var k2 = lo2; k2 <= hi2; k2++) edges[cur[k2]++] = i;
42405
+ }
42406
+ return { binOf: binOf, start: start, edges: edges, stamp: new Int32Array(n), gen: 0 };
42407
+ }
42408
+
42409
+ var _covScratch = null;
42410
+ function coverageScratch(cap) {
42411
+ if (!_covScratch || _covScratch.cap < cap) {
42412
+ _covScratch = {
42413
+ cap: cap,
42414
+ lx0: new Float64Array(cap), ly0: new Float64Array(cap),
42415
+ lx1: new Float64Array(cap), ly1: new Float64Array(cap),
42416
+ band: new Int32Array(cap),
42417
+ xs: new Float64Array(cap), df: new Int8Array(cap),
42418
+ dl: new Int8Array(cap), order: new Int32Array(cap)
42419
+ };
42420
+ }
42421
+ return _covScratch;
42422
+ }
42423
+
42424
+ // Sweep the removed loop L and measure two area quantities the collapse would
42425
+ // change, both integrated with horizontal scanlines across L's bounding box (at
42426
+ // each scanline the winding of the full ring and of the loop are step functions
42427
+ // of x, reconstructed from the sorted edge crossings). For a point inside L
42428
+ // (windingLoop != 0), the winding after the collapse is windingFull - windingLoop:
42429
+ // - UNCOVERED: was covered (windingFull == windingLoop, so it drops to 0). This
42430
+ // is the "big clip" a collapse of a real single-covered lobe would make.
42431
+ // - FILLED: was a winding-0 hole/exterior (windingFull == 0, so it flips to
42432
+ // covered). This is a real buffer hole (or an open outer-wall notch) the
42433
+ // collapse would swallow -- undetectable by the uncovered measure alone,
42434
+ // since filling adds coverage rather than removing it.
42435
+ // Returns the uncovered area and writes the filled area to _lastFillArea.
42436
+ // `band` lists the indices of ring edges that can reach the loop's y-band.
42437
+ var _lastFillArea = 0;
42438
+ var _lastClipX = 0, _lastClipY = 0;
42439
+ function loopUncoveredArea(ring, band, be, lx0, ly0, lx1, ly1, le, minx, maxx, miny, maxy, scr) {
42440
+ var h = maxy - miny;
42441
+ _lastFillArea = 0;
42442
+ if (h <= 0 || maxx <= minx) return 0;
42443
+ // Known limitation: rows are uniform with a hard cap, so dy scales with the
42444
+ // loop's bbox height and a region thinner than dy in y can fall between row
42445
+ // midpoints unmeasured (only reachable via a loop whose bbox is much taller
42446
+ // than the region -- not observed outside synthetic data). A designed fix
42447
+ // (vertex-guided rows) was implemented, measured at 4-8% of buffer build
42448
+ // time, and reverted; see "Scanline row starvation" in
42449
+ // docs/development/buffer-line-notes.md to re-add it if it is ever needed.
42450
+ var target = Math.sqrt(BUFFER_LOOP_CHECK_MIN_AREA) / 4;
42451
+ var rows = Math.round(h / (target > 0 ? target : h));
42452
+ if (rows < 8) rows = 8; else if (rows > 40) rows = 40;
42453
+ var dy = h / rows;
42454
+ var xs = scr.xs, df = scr.df, dl = scr.dl, order = scr.order;
42455
+ var total = 0, fill = 0, momX = 0, momY = 0, r, k, i;
42456
+ for (r = 0; r < rows; r++) {
42457
+ var y = miny + (r + 0.5) * dy;
42458
+ // Winding just left of the loop's x-range (base), plus the crossings that
42459
+ // fall inside [minx, maxx] (only these need sorting). Crossings right of the
42460
+ // loop are irrelevant to intervals within the range and are dropped.
42461
+ var baseWf = 0, baseWl = 0, m = 0;
42462
+ for (k = 0; k < be; k++) {
42463
+ i = band[k];
42464
+ var y1 = ring[i][1], y2 = ring[i + 1][1];
42465
+ if ((y1 <= y) === (y2 <= y)) continue;
42466
+ var x = ring[i][0] + (y - y1) / (y2 - y1) * (ring[i + 1][0] - ring[i][0]);
42467
+ var sgnF = y2 > y1 ? 1 : -1;
42468
+ if (x <= minx) baseWf += sgnF;
42469
+ else if (x < maxx) { xs[m] = x; df[m] = sgnF; dl[m] = 0; order[m] = m; m++; }
42470
+ }
42471
+ for (i = 0; i < le; i++) {
42472
+ var ya = ly0[i], yb = ly1[i];
42473
+ if ((ya <= y) === (yb <= y)) continue;
42474
+ var xl = lx0[i] + (y - ya) / (yb - ya) * (lx1[i] - lx0[i]);
42475
+ var sl = yb > ya ? 1 : -1;
42476
+ if (xl <= minx) { baseWl += sl; }
42477
+ else if (xl < maxx) { xs[m] = xl; df[m] = 0; dl[m] = sl; order[m] = m; m++; }
42478
+ }
42479
+ sortByX(order, xs, m);
42480
+ var wf = baseWf, wl = baseWl, prevx = minx;
42481
+ for (i = 0; i < m; i++) {
42482
+ var o = order[i], xk = xs[o];
42483
+ if (wl !== 0 && xk > prevx) {
42484
+ if (wf - wl === 0) {
42485
+ total += xk - prevx;
42486
+ momX += (xk + prevx) / 2 * (xk - prevx); momY += y * (xk - prevx);
42487
+ }
42488
+ else if (wf === 0) fill += xk - prevx;
42489
+ }
42490
+ wf += df[o]; wl += dl[o]; prevx = xk;
42491
+ }
42492
+ if (wl !== 0 && maxx > prevx) {
42493
+ if (wf - wl === 0) {
42494
+ total += maxx - prevx;
42495
+ momX += (maxx + prevx) / 2 * (maxx - prevx); momY += y * (maxx - prevx);
42496
+ }
42497
+ else if (wf === 0) fill += maxx - prevx;
42498
+ }
42499
+ }
42500
+ _lastFillArea = fill * dy;
42501
+ if (total > 0) { _lastClipX = momX / total; _lastClipY = momY / total; }
42502
+ return total * dy;
42503
+ }
42504
+
42505
+ // Insertion sort of index array `order[0..m)` by xs[order[k]] ascending. m is
42506
+ // small per scanline (edges straddling the row), so this beats a comparator sort.
42507
+ function sortByX(order, xs, m) {
42508
+ for (var i = 1; i < m; i++) {
42509
+ var oi = order[i], xi = xs[oi], j = i - 1;
42510
+ while (j >= 0 && xs[order[j]] > xi) { order[j + 1] = order[j]; j--; }
42511
+ order[j + 1] = oi;
42512
+ }
42513
+ }
42514
+
41702
42515
  var DouglasPeucker = {};
41703
42516
 
41704
42517
  DouglasPeucker.metricSq3D = geom.pointSegDistSq3D;
@@ -41781,7 +42594,7 @@ ${svg}
41781
42594
  var T = 90 - e;
41782
42595
  var L = -180 + e;
41783
42596
  var B$1 = -90 + e;
41784
- var R$2 = 180 - e;
42597
+ var R$1 = 180 - e;
41785
42598
 
41786
42599
  function lastEl(arr) {
41787
42600
  return arr[arr.length - 1];
@@ -41798,7 +42611,7 @@ ${svg}
41798
42611
  // remove likely rounding errors
41799
42612
  function snapToEdge(p) {
41800
42613
  if (p[0] <= L) p[0] = -180;
41801
- if (p[0] >= R$2) p[0] = 180;
42614
+ if (p[0] >= R$1) p[0] = 180;
41802
42615
  if (p[1] <= B$1) p[1] = -90;
41803
42616
  if (p[1] >= T) p[1] = 90;
41804
42617
  }
@@ -41826,11 +42639,11 @@ ${svg}
41826
42639
  // TODO: handle segments between pole and non-edge point
41827
42640
  // (these shoudn't exist in a properly clipped path)
41828
42641
  return (onPole(a) || onPole(b)) ||
41829
- a[0] <= L && b[0] <= L || a[0] >= R$2 && b[0] >= R$2;
42642
+ a[0] <= L && b[0] <= L || a[0] >= R$1 && b[0] >= R$1;
41830
42643
  }
41831
42644
 
41832
42645
  function isEdgePoint(p) {
41833
- return p[1] <= B$1 || p[1] >= T || p[0] <= L || p[0] >= R$2;
42646
+ return p[1] <= B$1 || p[1] >= T || p[0] <= L || p[0] >= R$1;
41834
42647
  }
41835
42648
 
41836
42649
  // Remove segments that belong solely to cut points
@@ -42095,129 +42908,6 @@ ${svg}
42095
42908
  return (dx2 * p1[1] + dx1 * p2[1]) / (dx1 + dx2);
42096
42909
  }
42097
42910
 
42098
- var R$1 = WGS84.SEMIMAJOR_AXIS;
42099
-
42100
- // GeographicLib docs: https://geographiclib.sourceforge.io/html/js/
42101
- // https://geographiclib.sourceforge.io/html/js/module-GeographicLib_Geodesic.Geodesic.html
42102
- // https://geographiclib.sourceforge.io/html/js/tutorial-2-interface.html
42103
- function getGeodesic(P) {
42104
- if (!isLatLngCRS(P)) error('Expected an unprojected CRS');
42105
- var f = P.es / (1 + Math.sqrt(P.one_es));
42106
- // var GeographicLib = require('mproj').internal.GeographicLib;
42107
- var GeographicLib = require$1('geographiclib-geodesic');
42108
- // return new GeographicLib.Geodesic.Geodesic(P.a, 0)
42109
- return new GeographicLib.Geodesic.Geodesic(P.a, f);
42110
- }
42111
-
42112
- function interpolatePoint2D(ax, ay, bx, by, k) {
42113
- var j = 1 - k;
42114
- return [ax * j + bx * k, ay * j + by * k];
42115
- }
42116
-
42117
- function getInterpolationFunction(P) {
42118
- var spherical = P && isLatLngCRS(P);
42119
- if (!spherical) return interpolatePoint2D;
42120
- var geod = getGeodesic(P);
42121
- return function(lng, lat, lng2, lat2, k) {
42122
- var r = geod.Inverse(lat, lng, lat2, lng2);
42123
- var dist = r.s12 * k;
42124
- var r2 = geod.Direct(lat, lng, r.azi1, dist);
42125
- return [r2.lon2, r2.lat2];
42126
- };
42127
- }
42128
-
42129
- function getPlanarSegmentEndpoint(x, y, bearing, meterDist) {
42130
- var rad = bearing / 180 * Math.PI;
42131
- var dx = Math.sin(rad) * meterDist;
42132
- var dy = Math.cos(rad) * meterDist;
42133
- return [x + dx, y + dy];
42134
- }
42135
-
42136
- // source: https://github.com/mapbox/cheap-ruler/blob/master/index.js
42137
- function fastGeodeticSegmentFunction(lng, lat, bearing, meterDist) {
42138
- var D2R = Math.PI / 180;
42139
- var cos = Math.cos(lat * D2R);
42140
- var cos2 = 2 * cos * cos - 1;
42141
- var cos3 = 2 * cos * cos2 - cos;
42142
- var cos4 = 2 * cos * cos3 - cos2;
42143
- var cos5 = 2 * cos * cos4 - cos3;
42144
- var kx = (111.41513 * cos - 0.09455 * cos3 + 0.00012 * cos5) * 1000;
42145
- var ky = (111.13209 - 0.56605 * cos2 + 0.0012 * cos4) * 1000;
42146
- var bearingRad = bearing * D2R;
42147
- var lat2 = lat + Math.cos(bearingRad) * meterDist / ky;
42148
- var lng2 = lng + Math.sin(bearingRad) * meterDist / kx;
42149
- return [lng2, lat2];
42150
- }
42151
-
42152
-
42153
- function wrap(deg) {
42154
- while (deg < -180) deg += 360;
42155
- while (deg > 180) deg -= 360;
42156
- return deg;
42157
- }
42158
-
42159
- function fastGeodeticBearingFunction(lng1, lat1, lng2, lat2) {
42160
- var D2R = Math.PI / 180;
42161
- var f = 1 / 298.257223563;
42162
- var e2 = f * (2 - f);
42163
- var m = R$1 * D2R;
42164
- var coslat = Math.cos(lat1 * D2R);
42165
- var w2 = 1 / (1 - e2 * (1 - coslat * coslat));
42166
- var w = Math.sqrt(w2);
42167
- var kx = m * w * coslat;
42168
- var ky = m * w * w2 * (1 - e2);
42169
- var dx = wrap(lng2 - lng1) * kx;
42170
- var dy = (lat2 - lat1) * ky;
42171
- return Math.atan2(dx, dy) / D2R;
42172
- }
42173
-
42174
- function getGeodeticSegmentFunction(P) {
42175
- if (!isLatLngCRS(P)) {
42176
- return getPlanarSegmentEndpoint;
42177
- }
42178
- var g = getGeodesic(P);
42179
- return function(lng, lat, bearing, meterDist) {
42180
- var o = g.Direct(lat, lng, bearing, meterDist);
42181
- var p = [o.lon2, o.lat2];
42182
- return p;
42183
- };
42184
- }
42185
-
42186
- function getFastGeodeticSegmentFunction(P) {
42187
- // CAREFUL: this function has higher error at very large distances and at the poles
42188
- // also, it wouldn't work for other planets than Earth
42189
- return isLatLngCRS(P) ? fastGeodeticSegmentFunction : getPlanarSegmentEndpoint;
42190
- }
42191
-
42192
- // return function to calculate bearing of a segment in degrees
42193
- function getBearingFunction(dataset) {
42194
- var P = getDatasetCRS(dataset);
42195
- // return isLatLngCRS(P) ? bearingDegrees : bearingDegrees2D;
42196
- return isLatLngCRS(P) ? fastGeodeticBearingFunction : bearingDegrees2D;
42197
- }
42198
-
42199
- // get bearing in degrees from point ab to point cd
42200
- function bearingDegrees(a, b, c, d) {
42201
- return geom.bearing(a, b, c, d) * 180 / Math.PI;
42202
- }
42203
-
42204
- function bearingDegrees2D(a, b, c, d) {
42205
- return geom.bearing2D(a, b, c, d) * 180 / Math.PI;
42206
- }
42207
-
42208
- var Geodesic = /*#__PURE__*/Object.freeze({
42209
- __proto__: null,
42210
- bearingDegrees: bearingDegrees,
42211
- bearingDegrees2D: bearingDegrees2D,
42212
- getBearingFunction: getBearingFunction,
42213
- getFastGeodeticSegmentFunction: getFastGeodeticSegmentFunction,
42214
- getGeodeticSegmentFunction: getGeodeticSegmentFunction,
42215
- getInterpolationFunction: getInterpolationFunction,
42216
- getPlanarSegmentEndpoint: getPlanarSegmentEndpoint,
42217
- interpolatePoint2D: interpolatePoint2D,
42218
- wrap: wrap
42219
- });
42220
-
42221
42911
  var R = WGS84.SEMIMAJOR_AXIS;
42222
42912
  var D2R = Math.PI / 180;
42223
42913
  var R2D = 180 / Math.PI;
@@ -42340,6 +43030,19 @@ ${svg}
42340
43030
 
42341
43031
  function getOffsetFunction(crs, opts) {
42342
43032
  if (!isLatLngCRS(crs)) {
43033
+ if (opts && opts.geodesic2) {
43034
+ // Experimental geodesic buffering for projected data that stays entirely
43035
+ // in the source projected plane (no web-Mercator round-trip), so it has no
43036
+ // pole singularity or antimeridian wrap. Each offset is taken in the
43037
+ // ordinary projected (cartesian) direction, but its magnitude is corrected
43038
+ // so the endpoint lands at a true ground distance, measured with a
43039
+ // first-party spherical great-circle formula (the same sphere model the
43040
+ // lat-long buffer already uses). Falls back to planar if the CRS can't be
43041
+ // inverted to measure ground distance.
43042
+ var fn = makeScaleCorrectedOffset(crs);
43043
+ if (fn) return fn;
43044
+ message('[buffer] Ignoring "geodesic2": the dataset CRS is unknown or not invertible; using planar offsets.');
43045
+ }
42343
43046
  return getPlanarSegmentEndpoint;
42344
43047
  }
42345
43048
  // Offset each point by a true geodesic distance, working directly in spherical
@@ -42360,6 +43063,90 @@ ${svg}
42360
43063
  return opts && opts.polar ? clampPolar(offset) : offset;
42361
43064
  }
42362
43065
 
43066
+ // Convergence target for the scale-corrected offset: stop when the great-circle
43067
+ // distance reached is within SCALE_OFFSET_TOL (relative) or SCALE_OFFSET_ABS
43068
+ // (absolute, meters) of the requested ground distance.
43069
+ //
43070
+ // The iteration is a linear fixed point (m *= d/g), so the residual shrinks by a
43071
+ // roughly constant factor per step: a tighter tolerance costs extra tail
43072
+ // iterations no matter how good the warm-start guess is, and below ~1e-10 the
43073
+ // projection round-trip + haversine noise floor makes the test unreachable for
43074
+ // small d (it never converges and burns the iteration cap). 1e-8 relative is
43075
+ // 0.25 mm at a 25 km radius -- far below cartographic relevance -- and a sweep
43076
+ // over real data showed it sits just before the cost curve and the noise floor
43077
+ // bite (1e-7 and 1e-8 cost the same; 1e-9 adds ~5%; 1e-12 roughly doubles the
43078
+ // inverse-projection count and caps out on ~11% of points at 1 km).
43079
+ //
43080
+ // The tolerance does NOT need to guarantee bit-identical results for the same
43081
+ // (x, y, bearing, dist) across call paths: the only place two independent offset
43082
+ // computations must coincide -- a closed ring's seam -- closes by reusing the
43083
+ // first offset vertex's reference exactly (see makeFinalJoin in
43084
+ // mapshaper-path-buffer-v4), not by recomputing and comparing. Within a single
43085
+ // run the call order is fixed, so the warm-started result is deterministic.
43086
+ //
43087
+ // The absolute floor keeps tiny-distance helper calls (inset/probe offsets at
43088
+ // dist*1e-4 etc.) from chasing a sub-noise relative target; it is well below the
43089
+ // relative target for any d above ~100 m, so it never loosens normal offsets.
43090
+ var SCALE_OFFSET_TOL = 1e-8;
43091
+ var SCALE_OFFSET_ABS = 1e-6;
43092
+ var SCALE_OFFSET_MAX_ITER = 16;
43093
+
43094
+ // Build a deterministic offset function for projected data that corrects the
43095
+ // offset magnitude so the endpoint sits at a true ground distance. The cartesian
43096
+ // offset direction is kept (so all the builder's planar constructions still hold)
43097
+ // and only the distance is solved: starting from the cartesian magnitude, measure
43098
+ // the great-circle distance actually reached, scale by the error ratio, and
43099
+ // iterate to convergence. The converged scale (projected units per ground meter)
43100
+ // is remembered to warm-start the next point -- adjacent points share nearly the
43101
+ // same projection scale, so the seeded guess is close and the loop converges in
43102
+ // ~2-3 iterations. The warm-start only seeds the initial guess and the loop is
43103
+ // driven to a tolerance (see SCALE_OFFSET_TOL), so within a run the result is
43104
+ // deterministic.
43105
+ // Returns null if the CRS cannot be inverted (no way to measure ground distance).
43106
+ function makeScaleCorrectedOffset(crs) {
43107
+ if (!isInvertibleCRS(crs)) return null;
43108
+ var toLngLat = getProjTransform2(crs, parseCrsString$1('wgs84')); // projected -> lng,lat
43109
+ var warmScale = 1;
43110
+ // Small LRU of inverted source coordinates. Every path vertex is unprojected at
43111
+ // least twice (it is the shared endpoint of two consecutive segment offsets) and
43112
+ // again for each round-join/cap arc point centered on it; a single slot is
43113
+ // clobbered by the join construction between segments. A few slots capture that
43114
+ // reuse and are dropped as construction moves along the path. Returns the exact
43115
+ // toLngLat value (possibly null, out of domain), so caching changes nothing but speed.
43116
+ var SCALE_LL_CACHE = 8;
43117
+ var ckx = [], cky = [], cll = [];
43118
+ function invert(x, y) {
43119
+ for (var j = cll.length - 1; j >= 0; j--) {
43120
+ if (ckx[j] === x && cky[j] === y) return cll[j];
43121
+ }
43122
+ var ll = toLngLat(x, y);
43123
+ ckx.push(x); cky.push(y); cll.push(ll);
43124
+ if (cll.length > SCALE_LL_CACHE) { ckx.shift(); cky.shift(); cll.shift(); }
43125
+ return ll;
43126
+ }
43127
+ return function(x, y, bearing, meterDist) {
43128
+ if (!meterDist) return [x, y];
43129
+ var rad = bearing * D2R;
43130
+ var sign = meterDist < 0 ? -1 : 1;
43131
+ var d = meterDist * sign; // positive ground distance to reach
43132
+ var ux = Math.sin(rad) * sign, uy = Math.cos(rad) * sign;
43133
+ var ll0 = invert(x, y);
43134
+ if (!ll0) return getPlanarSegmentEndpoint(x, y, bearing, meterDist);
43135
+ var m = d * warmScale, qx = x, qy = y;
43136
+ for (var i = 0; i < SCALE_OFFSET_MAX_ITER; i++) {
43137
+ qx = x + ux * m; qy = y + uy * m;
43138
+ var ll1 = toLngLat(qx, qy);
43139
+ if (!ll1) break; // offset left the projection's domain; keep current estimate
43140
+ var g = greatCircleDistance(ll0[0], ll0[1], ll1[0], ll1[1]);
43141
+ if (!(g > 0)) { m *= 2; continue; }
43142
+ if (Math.abs(g - d) <= SCALE_OFFSET_TOL * d + SCALE_OFFSET_ABS) break;
43143
+ m *= d / g;
43144
+ }
43145
+ warmScale = m / d;
43146
+ return [qx, qy];
43147
+ };
43148
+ }
43149
+
42363
43150
  // Great-circle (spherical geodesic) offset, computed directly in Mercator coords.
42364
43151
  // bearing: compass degrees; meterDist: meters; x, y and the result: Mercator.
42365
43152
  function greatCircleOffset(x, y, bearing, meterDist) {
@@ -42429,6 +43216,12 @@ ${svg}
42429
43216
  var getOffsetPoint = getOffsetFunction(crs, opts);
42430
43217
  var roundJoinSegsPerQuadrant = opts.quad_segs >= 2 ? opts.quad_segs : 8;
42431
43218
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
43219
+ // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
43220
+ // the optional low-resolution alternative to makeConcaveJoin in
43221
+ // traceCleanOffsetSide. Larger = fewer points = faster dissolve. NOTE: a
43222
+ // surviving (uncollapsed) bridge CAN become output boundary, where this step
43223
+ // sets the dent depth -- see the caution at makeCoarseConcaveJoin.
43224
+ var CLEAN_OUTLINE_BRIDGE_STEP = 90;
42432
43225
  var capStyle = opts.cap_style || 'round'; // expect 'round' or 'flat'
42433
43226
  var pathIter = useMercator ?
42434
43227
  getProjectingPathIterator(dataset.arcs, opts) : new ShapeIter(dataset.arcs);
@@ -42454,8 +43247,7 @@ ${svg}
42454
43247
  properties: null,
42455
43248
  geometry: {
42456
43249
  type: 'MultiPolygon',
42457
- // convert rings to MultiPolygon format
42458
- coordinates: rings.map(ring => [ring])
43250
+ coordinates: rings.map(function(ring) { return [ring]; })
42459
43251
  }
42460
43252
  }];
42461
43253
  if (useMercator) {
@@ -42523,14 +43315,26 @@ ${svg}
42523
43315
 
42524
43316
  // each path may be converted into multiple buffer rings, which later
42525
43317
  // need to be dissolved
42526
- // Re-anchor a closed ring [v0, v1, ..., v0] to the midpoint of its first edge:
42527
- // returns [m, v1, ..., v0, m] where m = midpoint(v0, v1). The offset then
42528
- // starts/ends mid-edge (a collinear seam) and v0 becomes an interior join.
43318
+ // Re-anchor a closed ring [v0, v1, ..., v0] so its offset seam falls at the
43319
+ // midpoint of a chosen edge (vk -> vk+1): returns [m, vk+1, ..., vk, m] where
43320
+ // m = midpoint(vk, vk+1). The offset then starts/ends mid-edge (a collinear
43321
+ // seam) and every original vertex becomes an interior join.
43322
+ //
43323
+ // The seam edge is chosen by chooseSeamEdge() to sit away from concave (reflex)
43324
+ // corners. In winding-fill mode a concave corner dips the offset back to its
43325
+ // source vertex (resolved later by the dissolve); a seam landing next to such a
43326
+ // dip leaves the pre-dissolve overshoot-loop remover (removeBufferRingLoops*)
43327
+ // starting from an anchor buried inside that tangle, where it can collapse the
43328
+ // true outer boundary instead of the self-overlap (an inward notch). Putting the
43329
+ // seam in a clean convex stretch keeps the remover's first-vertex anchor on the
43330
+ // real boundary.
42529
43331
  function startRingAtEdgeMidpoint(verts) {
42530
- var a = verts[0], b = verts[1];
43332
+ var k = chooseSeamEdge(verts);
43333
+ var n = verts.length - 1; // distinct vertices (verts[0] == verts[n])
43334
+ var a = verts[k], b = verts[(k + 1) % n];
42531
43335
  var m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
42532
43336
  var out = [m];
42533
- for (var i = 1; i < verts.length; i++) out.push(verts[i]);
43337
+ for (var t = 1; t <= n; t++) out.push(verts[(k + t) % n]);
42534
43338
  out.push(m.concat());
42535
43339
  return out;
42536
43340
  }
@@ -42540,25 +43344,49 @@ ${svg}
42540
43344
  var pathSideVerts = collectPathVertices(pathArcs);
42541
43345
  var verts = pathSideVerts;
42542
43346
  if (simplifyIntervalFn) {
42543
- verts = presimplifyPathVerts(verts, simplifyIntervalFn(dist), dist);
43347
+ verts = presimplifyPathVerts(verts, simplifyIntervalFn(dist));
42544
43348
  }
42545
- if (!oneSidedBuffer && !opts.band_method && pathIsOpen(verts)) {
43349
+ // Closed two-sided line rings: open with a sub-tolerance gap at the first
43350
+ // vertex so the open-path outline builder applies (round caps close the
43351
+ // seam). Gap size is in the same coordinate units as verts.
43352
+ if (!oneSidedBuffer && !opts.band_method && !pathIsOpen(verts)) {
43353
+ verts = openClosedRingWithMicroGap(verts);
43354
+ }
43355
+ if (!opts.band_method && pathIsOpen(verts) && (!oneSidedBuffer || opts.outline)) {
42546
43356
  // Fast path for ordinary two-sided line buffers: emit one closed
42547
43357
  // outline instead of many per-segment bands that must be dissolved.
42548
43358
  // The band-method escape hatch skips it to fall through to the
42549
43359
  // per-segment band construction (makeLeftBufferRings, no winding fill).
43360
+ //
43361
+ // Also used for the topological polygon grow's OPEN boundary chains
43362
+ // (outline mode, one-sided 'left'): an open unshared-boundary chain must
43363
+ // be capped on BOTH ends. The one-sided outline (buildCleanOutlineRings)
43364
+ // offsets a single side and self-closes with a straight chord between the
43365
+ // chain's endpoints -- harmless when they nearly coincide, but for a chain
43366
+ // spanning a whole border (e.g. a state's Canada boundary) that chord is a
43367
+ // multi-degree spike. The two-sided stadium caps both ends instead; the
43368
+ // mosaic union with the source polygon absorbs the inner (interior) half.
42550
43369
  var built = makeTwoSidedOutlineRing(verts, dist);
42551
- if (opts.no_loop_removal) return [built.ring];
42552
- // Strip self-overlap loops (the dissolve would fill them anyway) so it has
42553
- // fewer segments and self-intersections to resolve, while keeping real
42554
- // holes. The two-sided outline of an open path has a consistent +/-1 base
42555
- // winding, so the crossing-direction method classifies loops exactly from
42556
- // the ring geometry alone; the source-turn gate is kept as an alternative.
42557
- if (opts.loop_removal_turn_gate) {
42558
- return [removeBufferRingLoops(built.ring, BUFFER_LOOP_WINDOW,
42559
- built.srcPos, getSourceTurnPrefix(verts))];
43370
+ var out;
43371
+ if (opts.no_loop_removal) {
43372
+ out = [built.ring];
43373
+ } else {
43374
+ // Multi-pass dip+coverage remover: construction-tagged reversed concave-join
43375
+ // ("dip") cusps mark self-overlap folds, and an exact scanline coverage
43376
+ // check refuses any collapse that would uncover a real boundary lobe OR
43377
+ // swallow a real hole/notch.
43378
+ out = [removeBufferRingLoopsIterative(built.ring, BUFFER_LOOP_WINDOW,
43379
+ null, null, undefined, built.dipTags, undefined,
43380
+ dist * dist * BUFFER_LOOP_FILL_AREA_FRAC)];
43381
+ }
43382
+ // Geodesic fan-apart gap patches (same mechanism as the polygon outline):
43383
+ // union a single-segment round-cap stadium for the source segments at each
43384
+ // exposed fan-apart bend so the winding dissolve fills the sliver gap.
43385
+ if (built.fanApartBends.length) {
43386
+ addFanApartGapPatches(out, verts, built.fanApartBends, dist,
43387
+ ringSignedArea(out[0]) >= 0);
42560
43388
  }
42561
- return [removeBufferRingLoopsByDirection(built.ring, BUFFER_LOOP_WINDOW)];
43389
+ return out;
42562
43390
  }
42563
43391
  if (!opts.right || opts.left) {
42564
43392
  rings = rings.concat(buildOneSidedRings(verts));
@@ -42597,22 +43425,17 @@ ${svg}
42597
43425
  if (opts.outline && sideVerts.length > 2 && !pathIsOpen(sideVerts)) {
42598
43426
  sideVerts = startRingAtEdgeMidpoint(sideVerts);
42599
43427
  }
43428
+ // Polygon-grow outline: the shared constant-radius construction used by
43429
+ // two-sided line outlines (traceCleanOffsetSide + dip+coverage loop
43430
+ // removal inside buildCleanOutlineRings).
43431
+ if (opts.outline) {
43432
+ return buildCleanOutlineRings(sideVerts, dist);
43433
+ }
42600
43434
  var built = makeLeftBufferRings(sideVerts, dist,
42601
43435
  oneSidedBuffer ? pathSideVerts : null);
42602
43436
  if (opts.no_loop_removal || !opts.winding_fill) {
42603
43437
  return built.rings;
42604
43438
  }
42605
- if (opts.outline) {
42606
- // Outline mode rings are clean offset-only loops (no source-path edge),
42607
- // so each has a consistent +/-1 base winding and the crossing-direction
42608
- // remover classifies overshoot loops exactly from the ring geometry --
42609
- // the same condition that makes it safe for the open-path two-sided
42610
- // outline. (The band-ribbon rings of the default construction do not,
42611
- // which is why they use the source-turn gate below.)
42612
- return built.rings.map(function(ring) {
42613
- return removeBufferRingLoopsByDirection(ring, BUFFER_LOOP_WINDOW);
42614
- });
42615
- }
42616
43439
  var turnPrefix = getSourceTurnPrefix(sideVerts);
42617
43440
  return built.rings.map(function(ring, i) {
42618
43441
  var srcPos = built.srcPositions[i];
@@ -42622,6 +43445,86 @@ ${svg}
42622
43445
  }
42623
43446
  }
42624
43447
 
43448
+ // Build the clean-outline-winding ring for one offset side from the shared
43449
+ // traceCleanOffsetSide construction (the same construction the open two-sided
43450
+ // line outline uses), then strip self-overlap loops before the dissolve.
43451
+ //
43452
+ // A closed source ring closes on its first offset vertex: the midpoint seam is
43453
+ // collinear (see startRingAtEdgeMidpoint), so the recomputed final offset point
43454
+ // is a sub-ULP duplicate of the first and is dropped (done() repeats the first
43455
+ // vertex exactly). An open arc appends the final offset endpoint and an end cap.
43456
+ //
43457
+ // Loop removal: multi-pass dip+coverage (removeBufferRingLoopsIterative with
43458
+ // construction dip tags), the same method used for two-sided line outlines.
43459
+ function removePolygonOutlineLoops(ring, dipTags, dist) {
43460
+ return removeBufferRingLoopsIterative(ring, BUFFER_LOOP_WINDOW,
43461
+ null, null, undefined, dipTags, undefined,
43462
+ dist * dist * BUFFER_LOOP_FILL_AREA_FRAC);
43463
+ }
43464
+
43465
+ function buildCleanOutlineRings(sideVerts, dist) {
43466
+ if (sideVerts.length < 2) return [];
43467
+ var closed = !pathIsOpen(sideVerts);
43468
+ var info = traceCleanOffsetSide(sideVerts, dist);
43469
+ var pts = info.points, segs = info.segs, tags = info.dipTags;
43470
+ for (var i = 0; i < pts.length; i++) builder.addBufferVertex(pts[i], segs[i], tags[i]);
43471
+ if (!closed) {
43472
+ if (info.lastPoint) builder.addBufferVertex(info.lastPoint, info.lastSeg);
43473
+ if (capStyle == 'round') {
43474
+ var end = sideVerts[sideVerts.length - 1];
43475
+ builder.addBufferVertices(
43476
+ makeRoundCap(end[0], end[1], info.lastBearing - 90, dist), NaN);
43477
+ }
43478
+ }
43479
+ var d = builder.done(true);
43480
+ if (!d) return [];
43481
+ var mainRing;
43482
+ if (opts.no_loop_removal) {
43483
+ mainRing = d.ring;
43484
+ } else {
43485
+ mainRing = removePolygonOutlineLoops(d.ring, d.dipTags, dist);
43486
+ }
43487
+ var out = [mainRing];
43488
+ // Union in a single-segment round-cap patch for every fan-apart concave bend
43489
+ // (offsetEdgesFanApart). Each patch is the exact buffer of one source
43490
+ // segment, hence a subset of the true buffer, so it can only fill the
43491
+ // winding-0 sliver the pinched bridge leaves -- it can never push geometry
43492
+ // through the outer wall. Oriented to match the main ring so the winding fill
43493
+ // adds (not subtracts) its area.
43494
+ if (info.fanApartBends.length) {
43495
+ addFanApartGapPatches(out, sideVerts, info.fanApartBends, dist,
43496
+ ringSignedArea(mainRing) >= 0);
43497
+ }
43498
+ return out;
43499
+ }
43500
+
43501
+ // Append a round-cap stadium patch for the two source segments meeting at each
43502
+ // recorded fan-apart bend vertex. A single segment has no bend (so its buffer
43503
+ // is the exact, gap-free stadium); the two patches' caps at the shared vertex
43504
+ // cover the sliver wedge the pinched bridge failed to fill.
43505
+ function addFanApartGapPatches(out, sideVerts, bends, dist, parentCCW) {
43506
+ for (var b = 0; b < bends.length; b++) {
43507
+ var k = bends[b];
43508
+ if (k - 1 >= 0) pushPatch(out, [sideVerts[k - 1], sideVerts[k]], dist, parentCCW);
43509
+ if (k + 1 < sideVerts.length) pushPatch(out, [sideVerts[k], sideVerts[k + 1]], dist, parentCCW);
43510
+ }
43511
+ }
43512
+
43513
+ function pushPatch(out, seg, dist, parentCCW) {
43514
+ if (seg[0][0] === seg[1][0] && seg[0][1] === seg[1][1]) return;
43515
+ var ring = makeTwoSidedOutlineRing(seg, dist).ring;
43516
+ if ((ringSignedArea(ring) >= 0) !== parentCCW) ring.reverse();
43517
+ out.push(ring);
43518
+ }
43519
+
43520
+ function ringSignedArea(ring) {
43521
+ var s = 0;
43522
+ for (var i = 0; i < ring.length - 1; i++) {
43523
+ s += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
43524
+ }
43525
+ return s;
43526
+ }
43527
+
42625
43528
  function makeLeftBufferRings(verts, dist, pathSideVerts) {
42626
43529
  var rings = [];
42627
43530
  // Parallel to rings[]: each entry is the source-position array for the
@@ -42689,21 +43592,18 @@ ${svg}
42689
43592
  // original path vertex, then walk out the full outgoing offset (p1).
42690
43593
  // The self-overlap is resolved by the winding-number union, so no
42691
43594
  // section splits, join-sector rings, or band-coverage audit are needed.
43595
+ // (The clean-outline-winding grow does NOT reach here -- it is routed to
43596
+ // buildCleanOutlineRings, which bridges concave corners with
43597
+ // makeConcaveJoin to keep a constant +/-1 winding.)
42692
43598
  builder.addBufferVertex(p2Prev, segId);
42693
43599
  builder.addBufferVertex([x1, y1], segId);
42694
43600
  builder.addBufferVertex(p1, segId);
42695
43601
  } else if (joinAngle > roundJoinSegAngle * 1.5) {
42696
- // large rightwards bend in path requiring a round join with at least
42697
- // one interpolated segment
42698
- // don't add endpoint of last offset segment to the buffer - we start
42699
- // by extending the last segment to make an outside join
42700
- // builder.addBufferVertex(p2Prev, false)
42701
- joinPoints = makeOutsideRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
43602
+ // Large convex bend: arc vertices on the offset circle replace the
43603
+ // previous segment end (p2Prev) and current segment start (p1).
43604
+ joinPoints = makeInscribedRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
42702
43605
  builder.addBufferVertices(joinPoints, segId);
42703
- // don't add first endpoint of new offset segment to the buffer - we just
42704
- // added an extended vertex to replace it.
42705
- // builder.addBufferVertex(p1, false)
42706
- p1 = joinPoints.pop();
43606
+ p1 = joinPoints.pop(); // track outgoing segment start (already added)
42707
43607
  } else if (joinAngle > -1e-10 && joinAngle < 1e-10) {
42708
43608
  // nearly collinear segments - add one point to the buffer
42709
43609
  // TODO: confirm that p1 and p2Prev are always very close
@@ -43160,6 +44060,31 @@ ${svg}
43160
44060
  return verts[0][0] !== verts[n-1][0] || verts[0][1] !== verts[n-1][1];
43161
44061
  }
43162
44062
 
44063
+ // Open a closed ring at its first vertex: nudge the corner into two points
44064
+ // straddling it along the incoming and outgoing edges (sub-tolerance gap).
44065
+ function openClosedRingWithMicroGap(verts) {
44066
+ var gap = 1e-6;
44067
+ var n = verts.length;
44068
+ if (n < 4 || pathIsOpen(verts)) return verts;
44069
+ var ring = [], i;
44070
+ for (i = 0; i < n; i++) ring.push(verts[i].concat());
44071
+ if (ring[0][0] === ring[n - 1][0] && ring[0][1] === ring[n - 1][1]) ring.pop();
44072
+ if (ring.length < 3) return verts;
44073
+ var p0 = ring[0], p1 = ring[1], pPrev = ring[ring.length - 1];
44074
+ ring[0] = nudgeVertexFrom(p0, p1, gap);
44075
+ ring.push(nudgeVertexFrom(p0, pPrev, gap));
44076
+ return ring;
44077
+ }
44078
+
44079
+ function nudgeVertexFrom(origin, toward, dist) {
44080
+ var dx = toward[0] - origin[0], dy = toward[1] - origin[1];
44081
+ var len = Math.hypot(dx, dy);
44082
+ if (len > 0) {
44083
+ return [origin[0] + dx / len * dist, origin[1] + dy / len * dist];
44084
+ }
44085
+ return [origin[0] + dist, origin[1]];
44086
+ }
44087
+
43163
44088
  // get angle between two extruded segments in degrees
43164
44089
  // positive angle means join is convex; negative angle means join is concave
43165
44090
  function getJoinAngle(direction1, direction2) {
@@ -43193,10 +44118,22 @@ ${svg}
43193
44118
  // reverse, so its positions map back to source order; cap points get NaN so
43194
44119
  // a pocket spanning a cap is never treated as a single-bend overshoot.
43195
44120
  var nan = function(arr) { return arr.map(function() { return NaN; }); };
44121
+ var zeros = function(arr) { return arr.map(function() { return 0; }); };
43196
44122
  var rightPos = right.srcPos.map(function(p) { return (n - 1) - p; });
43197
44123
  var srcPos = left.srcPos.concat(nan(endCap), rightPos, nan(startCap));
43198
44124
  srcPos.push(srcPos[0]);
43199
- return {ring: ring, srcPos: srcPos};
44125
+ // Parallel reversed-arc ("dip") tags: caps are never dips.
44126
+ var dipTags = left.dipTags.concat(zeros(endCap), right.dipTags, zeros(startCap));
44127
+ dipTags.push(dipTags[0]);
44128
+ // Fan-apart gap-patch bends from both offset sides, as source-vertex indices.
44129
+ // A bend is concave from only one side, so the two sides flag disjoint
44130
+ // vertices; the right side was traced in reverse, so map its indices back to
44131
+ // source order ((n-1) - r).
44132
+ var bendSet = {};
44133
+ (left.fanApartBends || []).forEach(function(j) { bendSet[j] = 1; });
44134
+ (right.fanApartBends || []).forEach(function(j) { bendSet[(n - 1) - j] = 1; });
44135
+ var bends = Object.keys(bendSet).map(Number);
44136
+ return {ring: ring, srcPos: srcPos, dipTags: dipTags, fanApartBends: bends};
43200
44137
  }
43201
44138
 
43202
44139
  // Cumulative absolute turn of the source path, indexed by vertex. The turn
@@ -43218,26 +44155,45 @@ ${svg}
43218
44155
  return prefix;
43219
44156
  }
43220
44157
 
43221
- function makeOffsetSide(verts, dist) {
43222
- // Trace a single left-offset side of the source path, including joins
43223
- // between adjacent offset segments but not endpoint caps. srcPos[] records,
43224
- // per emitted point, the source-path vertex it derives from, so loop
43225
- // removal can judge a self-crossing by the turn of the originating path
43226
- // span rather than by the (unreliable) geometry of the offset itself.
43227
- var points = [];
43228
- var srcPos = [];
44158
+ // Shared per-segment offset construction for the constant-radius "clean"
44159
+ // buffer outline. Walks the left offset of `verts`, joining adjacent offset
44160
+ // segments with inscribed round joins (convex bends), elbow/shallow joins, and
44161
+ // reversed makeConcaveJoin arcs (concave bends) -- every emitted vertex stays
44162
+ // at distance `dist`, so the traced side keeps a true +/-1 winding. This is
44163
+ // the single construction used by BOTH the open two-sided line outline
44164
+ // (makeOffsetSide) and the clean-outline-winding polygon grow
44165
+ // (buildCleanOutlineRings), so a construction fix lands in one place for both.
44166
+ //
44167
+ // Returns parallel arrays { points, segs }: each offset vertex (consecutive
44168
+ // duplicates dropped) and the source segment it derives from, in trace order.
44169
+ // Returning the finished arrays rather than taking a per-vertex `emit` callback
44170
+ // keeps the tracer a pure (verts, dist) -> data function (easy to unit-test in
44171
+ // isolation) and puts the consecutive-duplicate dedup in one place, so the line
44172
+ // and polygon callers can't drift apart on it. (Both styles measure the same;
44173
+ // the line caller reuses `points`/`segs` in place, so construction is still a
44174
+ // single pass.) The final segment endpoint is NOT pushed; it is returned as
44175
+ // `lastPoint` so the caller can either append it (open side) or close the ring
44176
+ // on its first
44177
+ // vertex (closed outline -- its collinear midpoint seam makes the recomputed
44178
+ // final point a sub-ULP duplicate of the first offset vertex, which must be
44179
+ // dropped rather than emitted, see makeLeftBufferRings closure notes).
44180
+ function traceCleanOffsetSide(verts, dist) {
43229
44181
  var x1, y1, x2, y2, bearing, bearingPrev, joinAngle, hit;
43230
- var p1, p2, p1Prev, p2Prev;
43231
- var firstBearing, lastBearing, joinPoints;
43232
- var pos = 0;
43233
- function pushPt(p) {
43234
- var before = points.length;
43235
- addPoint(points, p);
43236
- if (points.length > before) srcPos.push(pos);
43237
- }
43238
- function pushPts(pts) {
43239
- for (var i = 0; i < pts.length; i++) pushPt(pts[i]);
43240
- }
44182
+ var p1, p2, p1Prev, p2Prev, firstBearing, lastBearing, joinPoints, i;
44183
+ var points = [], segs = [], dipTags = [], fanApartBends = [];
44184
+ var vertsSegIndex = null;
44185
+ // tag: 1 marks a vertex emitted as part of a reversed concave-join arc
44186
+ // (the "dip" the construction inserts when adjacent offset segments do not
44187
+ // meet locally). These runs are pure self-overlap artifacts, so loop
44188
+ // removal can key on them directly instead of guessing from ring geometry.
44189
+ function add(p, segId, tag) {
44190
+ var prev = points[points.length - 1];
44191
+ if (prev && prev[0] === p[0] && prev[1] === p[1]) return;
44192
+ points.push(p);
44193
+ segs.push(segId);
44194
+ dipTags.push(tag ? 1 : 0);
44195
+ }
44196
+ var concaveJoin = opts.coarse_bridge ? makeCoarseConcaveJoin : makeConcaveJoin;
43241
44197
  for (var segId = 0; segId < verts.length - 1; segId++) {
43242
44198
  x1 = verts[segId][0];
43243
44199
  y1 = verts[segId][1];
@@ -43246,42 +44202,67 @@ ${svg}
43246
44202
  bearing = bearingDegrees2D(x1, y1, x2, y2);
43247
44203
  p1 = getOffsetPoint(x1, y1, bearing - 90, dist);
43248
44204
  p2 = getOffsetPoint(x2, y2, bearing - 90, dist);
43249
- pos = segId;
43250
44205
  if (segId === 0) {
43251
- pushPt(p1);
44206
+ add(p1, segId);
43252
44207
  firstBearing = bearing;
43253
44208
  } else {
43254
44209
  joinAngle = getJoinAngle(bearingPrev, bearing);
43255
- if (opts.winding_fill && joinAngle < 0) {
43256
- // Clipper2-style concave join: never cut the band. Walk the full
43257
- // incoming offset (p2Prev), dip back to the original vertex, then
43258
- // walk out the full outgoing offset (p1). The self-overlapping
43259
- // pocket this creates is resolved by the winding-number union
43260
- // (it cancels where another band covers it, survives where the
43261
- // concavity is real), so no band-coverage audit is needed.
43262
- pushPt(p2Prev);
43263
- pushPt([x1, y1]);
43264
- pushPt(p1);
43265
- } else if (joinAngle > roundJoinSegAngle * 1.5) {
43266
- joinPoints = makeOutsideRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
43267
- pushPts(joinPoints);
44210
+ if (joinAngle > roundJoinSegAngle * 1.5) {
44211
+ joinPoints = makeInscribedRoundJoin(x1, y1, bearingPrev - 90, joinAngle, dist);
44212
+ for (i = 0; i < joinPoints.length; i++) add(joinPoints[i], segId);
43268
44213
  p1 = joinPoints[joinPoints.length - 1];
43269
44214
  } else if (joinAngle > -1e-10 && joinAngle < 1e-10) {
43270
- pushPt(p1);
44215
+ add(p1, segId);
43271
44216
  } else if (joinAngle > 0 && (hit = elbowJoin(p1Prev, p2Prev, p1, p2,
43272
44217
  bearingPrev, bearing, x1, y1, dist)) ||
43273
44218
  joinAngle < 0 && (hit = bufferSegmentIntersection(p1Prev, p2Prev, p1, p2))) {
43274
- pushPt(hit);
44219
+ add(hit, segId);
43275
44220
  p1 = hit;
43276
44221
  } else if (joinAngle > 0) {
43277
- pushPt(p1);
44222
+ add(p1, segId);
43278
44223
  } else if (joinAngle < 0 && (hit = shallowAngleJoin(p2Prev, p1, x1, y1, dist))) {
43279
- pushPt(hit);
44224
+ add(hit, segId);
43280
44225
  p1 = hit;
43281
44226
  } else {
43282
- pushPt(p2Prev);
43283
- pushPts(makeConcaveJoin(x1, y1, bearing - 90, -joinAngle, dist));
43284
- pushPt(p1);
44227
+ add(p2Prev, segId, 1);
44228
+ joinPoints = concaveJoin(x1, y1, bearing - 90, -joinAngle, dist);
44229
+ var exposedWedge = false;
44230
+ if (!opts.coarse_bridge) {
44231
+ // Exposure-gated coarse bridge (default): a dip may be emitted
44232
+ // coarsely only when it cannot affect the output.
44233
+ // 1. Exposure gate: probe the full-resolution arc + tips against
44234
+ // the source path (wedgeIsExposed; each probe uses its own
44235
+ // radius with a 0.98 margin, erring toward "exposed" = the
44236
+ // full arc). An uncovered arc point is potential true output
44237
+ // boundary -- an exposed dip that the loop remover refuses to
44238
+ // collapse IS the boundary there, so it must stay at full
44239
+ // resolution (see the caution at makeCoarseConcaveJoin).
44240
+ // Collapsed coarse dips legally clip up to the remover's
44241
+ // per-collapse floor (the chord-to-arc lens is single-covered
44242
+ // where only the fold's own winding spans it); the remover's
44243
+ // neighborhood clip budget keeps clustered dips from
44244
+ // compounding several such clips into one visible dent (a
44245
+ // 102k m^2 dent on the innerlines 2km Sabine River fold
44246
+ // cluster before the budget existed).
44247
+ if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44248
+ exposedWedge = wedgeIsExposed(vertsSegIndex, segId - 1, segId,
44249
+ x1, y1, joinPoints, p2Prev, p1);
44250
+ if (!exposedWedge) {
44251
+ joinPoints = makeCoarseConcaveJoin(x1, y1, bearing - 90, -joinAngle, dist);
44252
+ }
44253
+ }
44254
+ if (useGapPatch(opts, useMercator) &&
44255
+ offsetEdgesFanApart(p1Prev, p2Prev, p1, p2)) {
44256
+ if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44257
+ if (opts.coarse_bridge ?
44258
+ wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44259
+ joinPoints, p2Prev, p1) :
44260
+ exposedWedge) {
44261
+ fanApartBends.push(segId);
44262
+ }
44263
+ }
44264
+ for (i = 0; i < joinPoints.length; i++) add(joinPoints[i], segId, 1);
44265
+ add(p1, segId, 1);
43285
44266
  }
43286
44267
  }
43287
44268
  bearingPrev = bearing;
@@ -43289,29 +44270,57 @@ ${svg}
43289
44270
  p2Prev = p2;
43290
44271
  lastBearing = bearing;
43291
44272
  }
43292
- pos = verts.length - 1;
43293
- pushPt(p2Prev);
43294
44273
  return {
43295
44274
  points: points,
43296
- srcPos: srcPos,
44275
+ segs: segs,
44276
+ dipTags: dipTags,
44277
+ fanApartBends: fanApartBends,
43297
44278
  firstBearing: firstBearing,
43298
- lastBearing: lastBearing
44279
+ lastBearing: lastBearing,
44280
+ lastPoint: p2Prev,
44281
+ lastSeg: verts.length - 1
43299
44282
  };
43300
44283
  }
43301
44284
 
43302
- function addPoint(arr, p) {
43303
- var prev = arr[arr.length - 1];
43304
- if (!prev || prev[0] !== p[0] || prev[1] !== p[1]) {
43305
- arr.push(p);
44285
+ function makeOffsetSide(verts, dist) {
44286
+ // Thin wrapper over the shared traceCleanOffsetSide construction. The tracer
44287
+ // already returns the deduped offset polyline (points[]) and a parallel
44288
+ // srcPos[] of each point's source segment (so loop removal can judge a
44289
+ // self-crossing by the turn of the originating path span rather than by the
44290
+ // unreliable geometry of the offset itself), so we reuse those arrays in
44291
+ // place and only append the final segment endpoint here (with the same
44292
+ // consecutive-duplicate guard); endpoint caps are added by the caller.
44293
+ var info = traceCleanOffsetSide(verts, dist);
44294
+ var points = info.points;
44295
+ var srcPos = info.segs;
44296
+ var dipTags = info.dipTags;
44297
+ var last = info.lastPoint;
44298
+ if (last) {
44299
+ var prev = points[points.length - 1];
44300
+ if (!prev || prev[0] !== last[0] || prev[1] !== last[1]) {
44301
+ points.push(last);
44302
+ srcPos.push(info.lastSeg);
44303
+ dipTags.push(0);
44304
+ }
43306
44305
  }
44306
+ return {
44307
+ points: points,
44308
+ srcPos: srcPos,
44309
+ dipTags: dipTags,
44310
+ fanApartBends: info.fanApartBends,
44311
+ firstBearing: info.firstBearing,
44312
+ lastBearing: info.lastBearing
44313
+ };
43307
44314
  }
43308
44315
 
43309
44316
  // Reduce a path's vertex count with Douglas-Peucker simplification
43310
44317
  // before buffering. Removed vertices lie within the interval of the
43311
44318
  // simplified path, so the buffer outline deviates from the unsimplified
43312
- // buffer by at most the interval; D.P. retains locally extreme vertices
43313
- // (the ones that determine the outline), so the typical deviation is
43314
- // much smaller. Collapsed paths fall back to their original vertices: a
44319
+ // buffer by at most the interval (see getBufferSimplifyFunction for the
44320
+ // empirical calibration). D.P. retains locally extreme vertices (the ones
44321
+ // that determine the outline), so the typical deviation is much smaller.
44322
+ // End-segment bearings are pinned (kk[1] and kk[n-2]) so cap geometry
44323
+ // stays exact. Collapsed paths fall back to their original vertices: a
43315
44324
  // small ring is below the error budget, but its buffer is a whole disk.
43316
44325
  function presimplifyPathVerts(verts, interval, dist) {
43317
44326
  var n = verts.length;
@@ -43340,14 +44349,6 @@ ${svg}
43340
44349
  // segments are preserved exactly: cap geometry at path endpoints
43341
44350
  // (flat caps especially) depends on them.
43342
44351
  kk[1] = kk[n-2] = Infinity;
43343
- if (oneSidedBuffer) {
43344
- // One-sided coverage is directional, so the turning that
43345
- // simplification concentrates into single bends must stay below the
43346
- // angle whose corner cut at the buffer radius would exceed the
43347
- // simplification interval (see limitGapTurning).
43348
- limitGapTurning(verts, kk, interval,
43349
- 2 * Math.acos(1 / (1 + interval / (dist * mercScale))));
43350
- }
43351
44352
  var verts2 = [];
43352
44353
  for (i = 0; i < n; i++) {
43353
44354
  if (kk[i] >= interval) verts2.push(verts[i]);
@@ -43356,74 +44357,6 @@ ${svg}
43356
44357
  return verts2.length >= (closed ? 4 : 2) ? verts2 : verts;
43357
44358
  }
43358
44359
 
43359
- // One-sided buffer coverage is directional: each segment's band lies on
43360
- // one side of its own bearing, so the angular structure of the path
43361
- // matters with weight proportional to the buffer radius, not just its
43362
- // positional structure. Replacing a sub-path with a chord concentrates
43363
- // the sub-path's turning into the chord's two end joints: convex
43364
- // turning is harmless (round joins reproduce the full fan of coverage
43365
- // around a retained vertex), but concentrated concave turning B deepens
43366
- // the corner cut at the joint from gradual elbow cuts to a single cut
43367
- // ~ r * (1/cos(B/2) - 1) deeper, and a removed self-loop's 360 degrees
43368
- // of turning would cost a whole swept disk. Capping each gap's total
43369
- // absolute turning (interior bends plus the bearing mismatch between
43370
- // the chord and the gap's end segments) at the angle whose corner cut
43371
- // equals the simplification interval keeps the one-sided buffer's error
43372
- // within the interval; where a gap exceeds the cap, re-retain its most
43373
- // prominent vertex (the D.P. split point) and re-check the halves. A
43374
- // self-loop subdivides into a coarse polygon with the same total
43375
- // turning, which sweeps the same disk.
43376
- function limitGapTurning(verts, kk, interval, maxTurn) {
43377
- var stack = [];
43378
- var prev = 0;
43379
- var i, a, b, gap;
43380
- for (i = 1; i < verts.length; i++) {
43381
- if (kk[i] >= interval) {
43382
- if (i - prev > 1) stack.push([prev, i]);
43383
- prev = i;
43384
- }
43385
- }
43386
- while (stack.length > 0) {
43387
- gap = stack.pop();
43388
- a = gap[0];
43389
- b = gap[1];
43390
- if (b - a < 2 || gapTurning(verts, a, b) <= maxTurn) continue;
43391
- var maxI = a + 1;
43392
- for (i = a + 2; i < b; i++) {
43393
- if (kk[i] > kk[maxI]) maxI = i;
43394
- }
43395
- kk[maxI] = Infinity;
43396
- stack.push([a, maxI], [maxI, b]);
43397
- }
43398
- }
43399
-
43400
- // Total absolute turning (in radians) concentrated by replacing the
43401
- // sub-path verts[a..b] with a single chord: bends interior to the gap,
43402
- // plus the mismatch between the chord bearing and the bearings of the
43403
- // gap's first and last segments.
43404
- function gapTurning(verts, a, b) {
43405
- var chord = Math.atan2(verts[b][0] - verts[a][0], verts[b][1] - verts[a][1]);
43406
- if (verts[b][0] === verts[a][0] && verts[b][1] === verts[a][1]) {
43407
- return Infinity; // gap closes on itself (a loop)
43408
- }
43409
- var total = 0;
43410
- var prev = chord;
43411
- for (var i = a; i < b; i++) {
43412
- var bearing = Math.atan2(verts[i+1][0] - verts[i][0], verts[i+1][1] - verts[i][1]);
43413
- total += Math.abs(angleDelta(prev, bearing));
43414
- prev = bearing;
43415
- }
43416
- total += Math.abs(angleDelta(prev, chord));
43417
- return total;
43418
- }
43419
-
43420
- function angleDelta(a, b) {
43421
- var d = b - a;
43422
- if (d > Math.PI) d -= 2 * Math.PI;
43423
- if (d < -Math.PI) d += 2 * Math.PI;
43424
- return d;
43425
- }
43426
-
43427
44360
  // Traverse a path with the (possibly projecting) path iterator,
43428
44361
  // collecting its vertices into an array; skips duplicate points.
43429
44362
  function collectPathVertices(path) {
@@ -43456,46 +44389,37 @@ ${svg}
43456
44389
  [getOffsetPoint(x, y, startDir + 180, dist)];
43457
44390
  }
43458
44391
 
43459
- // The vertices of this join are outside of the arc that it approximates and
43460
- // the segments of the join touch the arc at their midpoints.
43461
- // The first and last of the returned vertices extend the segments on either
43462
- // side of the join.
43463
- function makeOutsideRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43464
- // point count of 1 would be an elbow joint
43465
- // (elbow joins should be created elsewhere)
44392
+ // Inscribed round join: vertices on the offset arc at equal angle steps,
44393
+ // each at the true offset distance (geodesic or planar).
44394
+ function makeInscribedRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43466
44395
  var pointCount = Math.max(1, Math.round(arcAngle / roundJoinSegAngle));
43467
44396
  var stepAngle = arcAngle / pointCount;
43468
44397
  var points = [];
43469
- var i = 0;
43470
- var a, b, c, d, joinP, tanP, bearing;
43471
- while (i <= pointCount) {
43472
- bearing = startBearing + stepAngle * i;
43473
- tanP = getOffsetPoint(cx, cy, bearing, dist);
43474
- c = getOffsetPoint(tanP[0], tanP[1], bearing - 90, dist * 2);
43475
- d = getOffsetPoint(tanP[0], tanP[1], bearing + 90, dist * 2);
43476
- if (i > 0) {
43477
- joinP = bufferSegmentIntersection(a, b, c, d);
43478
- if (!joinP) {
43479
- if (opts.polar) {
43480
- // Near a clamped pole/antimeridian corner the swept offset tangents
43481
- // collapse onto the boundary and stop intersecting; hug the clamped
43482
- // tangent point so the round join degenerates to the pinned corner
43483
- // instead of throwing.
43484
- points.push(tanP);
43485
- } else {
43486
- throw Error(`no intersection on ${i} of ${pointCount}`);
43487
- }
43488
- } else {
43489
- points.push(joinP);
43490
- }
43491
- }
43492
- a = c;
43493
- b = d;
43494
- i++;
44398
+ for (var i = 1; i <= pointCount; i++) {
44399
+ points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i, dist));
43495
44400
  }
43496
44401
  return points;
43497
44402
  }
43498
44403
 
44404
+ // True when the two consecutive offset edges of a negative-angle (concave)
44405
+ // bend do not overlap but instead diverge: the infinite-line intersection of
44406
+ // the incoming edge (a->b) and outgoing edge (c->d) lies behind the incoming
44407
+ // edge (t < 0) and beyond the outgoing edge (u > 1). A planar concave bend
44408
+ // always overlaps (the crossing is in front: t > 1, u < 0); this fan-apart
44409
+ // configuration only arises when a variable geodesic offset distance stretches
44410
+ // the two edges past each other, leaving the outer-edge gap we want to bridge
44411
+ // with a forward round join instead of a reversed (doubling-back) arc.
44412
+ function offsetEdgesFanApart(a, b, c, d) {
44413
+ var rx = b[0] - a[0], ry = b[1] - a[1];
44414
+ var sx = d[0] - c[0], sy = d[1] - c[1];
44415
+ var den = rx * sy - ry * sx;
44416
+ if (den === 0) return false; // parallel: keep the reversed bridge
44417
+ var wx = c[0] - a[0], wy = c[1] - a[1];
44418
+ var t = (wx * sy - wy * sx) / den;
44419
+ var u = (wx * ry - wy * rx) / den;
44420
+ return t < 0 && u > 1;
44421
+ }
44422
+
43499
44423
  function shallowAngleJoin(a, b, cx, cy, dist) {
43500
44424
  var gap = distance2D(a[0], a[1], b[0], b[1]);
43501
44425
  var radius = getJoinExtensionDistance(cx, cy, a, b, dist);
@@ -43537,6 +44461,33 @@ ${svg}
43537
44461
  return makeRoundJoin(cx, cy, startBearing, arcAngle, dist).reverse();
43538
44462
  }
43539
44463
 
44464
+ // Coarse alternative to makeConcaveJoin: bridges a concave bend with as few
44465
+ // as one reversed arc vertex (CLEAN_OUTLINE_BRIDGE_STEP), producing a
44466
+ // smaller ring for the winding dissolve to chew through. Used by default
44467
+ // only behind the exposure gate above (plus the loop remover's neighborhood
44468
+ // clip budget); opts.coarse_bridge forces it everywhere, unguarded.
44469
+ // CAUTION -- unsound WITHOUT those guards (2026-07-02 eval, see
44470
+ // "coarse-bridge" in docs/development/buffer-line-notes.md): a dip that the
44471
+ // loop remover refuses to collapse can be real output boundary (near-U-turn
44472
+ // bends whose wedge nothing else covers; single-sided polygon grows; hole
44473
+ // boundaries), and there the coarse chords replace the true arc. Chord
44474
+ // vertices stay on the radius-dist circle, so coarse geometry never falls
44475
+ // OUTSIDE the true buffer -- the failures are inward dents (sagitta up to
44476
+ // (1-cos45)*dist ~ 0.29*dist at the 90-degree step) and spurious holes
44477
+ // (chord-triangle slivers whose at-radius vertices defeat the artifact-hole
44478
+ // filter's distance classification).
44479
+ function makeCoarseConcaveJoin(cx, cy, startBearing, arcAngle, dist) {
44480
+ var segs = Math.min(roundJoinSegsPerQuadrant,
44481
+ Math.max(1, Math.ceil(arcAngle / CLEAN_OUTLINE_BRIDGE_STEP)));
44482
+ var points = [];
44483
+ var increment = arcAngle / (segs + 1);
44484
+ for (var i = 1; i <= segs; i++) {
44485
+ points.push(getOffsetPoint(cx, cy, startBearing + increment * i, dist));
44486
+ }
44487
+ return points.reverse();
44488
+ }
44489
+
44490
+
43540
44491
  // get interior vertices of an interpolated CW arc
43541
44492
  function makeRoundJoin(cx, cy, startBearing, arcAngle, dist) {
43542
44493
  var points = [];
@@ -43666,18 +44617,9 @@ ${svg}
43666
44617
  if (debug) {
43667
44618
  // Debug visualizations (raw offset rings, mosaic) want the whole layer's
43668
44619
  // geometry/topology in one dataset; keep the original global dissolve for
43669
- // them (no artifact-hole filter runs in debug mode anyway). Mirror the real
43670
- // pipeline's construction so the debug view reflects what the buffer
43671
- // actually builds: an all-closed-ring two-sided layer uses the winding-fill
43672
- // + loop-removal construction (and a winding-number dissolve), so
43673
- // debug-offset shows the loop-removed offset rings and the no-loop-removal
43674
- // flag has a visible effect. (See makePolylineBufferTwoSidedPerFeature.)
43675
- var useWindingConstruction = !oneSided && layerIsAllClosed(lyr, dataset.arcs);
43676
- var debugMakerOpts = useWindingConstruction ?
43677
- Object.assign({}, opts, {winding_fill: true}) : opts;
43678
- var debugDissolveOpts = Object.assign({}, opts, {per_part_holes: true},
43679
- useWindingConstruction ? {winding_fill: true} : null);
43680
- dataset2 = importGeoJSON(makeShapeBufferGeoJSON(lyr, dataset, debugMakerOpts), {});
44620
+ // them (no artifact-hole filter runs in debug mode anyway).
44621
+ var debugDissolveOpts = getOutlineBufferDissolveOpts(opts);
44622
+ dataset2 = importGeoJSON(makeShapeBufferGeoJSON(lyr, dataset, opts), {});
43681
44623
  dissolveBufferDataset2(dataset2, debugDissolveOpts);
43682
44624
  } else {
43683
44625
  dataset2 = makePolylineBufferTwoSidedPerFeature(lyr, dataset, opts, spherical);
@@ -43692,48 +44634,13 @@ ${svg}
43692
44634
  // Two-sided buffer pipeline, run per source feature. Each feature's outline
43693
44635
  // rings are dissolved (and artifact-hole filtered) in isolation, then the
43694
44636
  // per-feature results are merged into one polygon layer (one shape per buffered
43695
- // source feature, in input order). Buffering does not union across features --
43696
- // 560 input features yield 560 output shapes -- so a single global dissolve of
43697
- // every feature's rings pays to intersect overlapping buffers of distinct
43698
- // features whose tiles are then selected per shape anyway. On a world-scale
43699
- // line layer that global planar arrangement exploded the arc count ~20x and ran
43700
- // the process out of memory; per-feature isolation bounds peak memory to the
43701
- // most complex single feature. The dissolve collapses any internal cuts, so
43702
- // each feature's output boundary is identical to the global pipeline's.
43703
- // True if every part of a polyline shape is a closed ring (first vertex ==
43704
- // last vertex), so its two-sided buffer is an annulus per ring.
43705
- function shapeIsAllClosed(shape, arcs) {
43706
- return !!shape && shape.length > 0 && shape.every(function(part) {
43707
- return pathIsClosed(part, arcs);
43708
- });
43709
- }
43710
-
43711
- // True if every shape in the layer is made entirely of closed rings.
43712
- function layerIsAllClosed(lyr, arcs) {
43713
- var shapes = lyr.shapes || [];
43714
- return shapes.length > 0 && shapes.every(function(shape) {
43715
- return shapeIsAllClosed(shape, arcs);
43716
- });
43717
- }
43718
-
44637
+ // source feature, in input order).
43719
44638
  function makePolylineBufferTwoSidedPerFeature(lyr, dataset, opts, spherical) {
43720
44639
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
43721
- var simplifyFn = getBufferSimplifyFunction(dataset, opts); // null if tolerance=0
43722
44640
  var makerOpts = Object.assign({geometry_type: lyr.geometry_type}, opts);
43723
44641
  var makeShapeBuffer = getPolylineBufferMaker(dataset, makerOpts);
43724
- // A shape whose parts are all closed rings buffers to an annulus per ring. The
43725
- // default (open-path) construction builds each side as many split sections plus
43726
- // join-sector rings (no loop removal), which floods the dissolve with raw rings
43727
- // (~8x more on a dense coastline). Instead route these shapes through the same
43728
- // winding-fill + loop-removal construction the polygon-ring buffer uses: one
43729
- // continuous offset ring per side, with self-overlap loops stripped, resolved
43730
- // by a winding-number dissolve. Open or mixed shapes keep the tuned outline +
43731
- // boundary-flood path unchanged.
43732
- var closedMaker = getPolylineBufferMaker(dataset,
43733
- Object.assign({}, makerOpts, {winding_fill: true}));
43734
44642
  var useFilter = useArtifactHoleFilter(opts);
43735
- var quadSegs = opts.quad_segs >= 2 ? opts.quad_segs : 8;
43736
- var sagPct = 1 - Math.cos(Math.PI / 4 / quadSegs);
44643
+ var outlineDissolveOpts = getOutlineBufferDissolveOpts(opts);
43737
44644
  // The two-sided pipeline is also reached by a one-sided buffer when the
43738
44645
  // winding fill is explicitly disabled (winding_fill: false); the hole filter
43739
44646
  // must then use its one-sided coverage test (see filterOutlineArtifactHolesFromShape).
@@ -43742,28 +44649,18 @@ ${svg}
43742
44649
  side: opts.right ? -1 : 1,
43743
44650
  roundCaps: (opts.cap_style || 'round') == 'round'
43744
44651
  } : null;
43745
- var dissolveOpts = Object.assign({}, opts, {per_part_holes: true});
43746
- var closedDissolveOpts = Object.assign({}, dissolveOpts, {winding_fill: true});
43747
44652
  var datasets = [];
43748
44653
  lyr.shapes.forEach(function(shape, i) {
43749
44654
  var distance = distanceFn(i);
43750
44655
  if (!distance || !shape) return;
43751
- // Closed-ring fast path only for ordinary two-sided buffers (the one-sided
43752
- // construction reaches this function with winding_fill:false and needs its
43753
- // own coverage handling); mixed open/closed shapes fall back too.
43754
- var allClosed = !oneSided && shapeIsAllClosed(shape, dataset.arcs);
43755
- var retn = (allClosed ? closedMaker : makeShapeBuffer)(shape, distance);
44656
+ var retn = makeShapeBuffer(shape, distance);
43756
44657
  var feats = (Array.isArray(retn) ? retn : [retn]).filter(Boolean);
43757
44658
  if (!feats.length) return;
43758
44659
  var ds = importGeoJSON(getBufferGeoJSON(feats), {});
43759
- dissolveBufferDataset2(ds, allClosed ? closedDissolveOpts : dissolveOpts);
43760
- if (useFilter && !allClosed) {
43761
- var bufLyr = ds.layers[0];
43762
- var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
43763
- bufLyr.shapes = bufLyr.shapes.map(function(bufShape) {
43764
- return filterOutlineArtifactHolesFromShape(bufShape, ds.arcs, shape,
43765
- dataset.arcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
43766
- });
44660
+ dissolveBufferDataset2(ds, outlineDissolveOpts);
44661
+ if (useFilter) {
44662
+ applyOutlineArtifactHoleFilter(ds.layers[0], ds.arcs, lyr, dataset, opts,
44663
+ spherical, {oneSided: oneSided, sideOpts: sideOpts, srcIndex: i});
43767
44664
  }
43768
44665
  datasets.push(ds);
43769
44666
  });
@@ -44086,6 +44983,32 @@ ${svg}
44086
44983
  return !opts.debug_offset && !opts.debug_mosaic;
44087
44984
  }
44088
44985
 
44986
+ // Apply the outline artifact-hole filter to every shape in a buffer layer,
44987
+ // using the parallel source layer for distance-to-path tests. Shared by the
44988
+ // two-sided line buffer (per feature) and the clean-outline polygon grow.
44989
+ function applyOutlineArtifactHoleFilter(bufLyr, bufArcs, srcLyr, srcDataset,
44990
+ opts, spherical, filterOpts) {
44991
+ filterOpts = filterOpts || {};
44992
+ if (!useArtifactHoleFilter(opts)) return;
44993
+ var distanceFn = getBufferDistanceFunction(srcLyr, srcDataset, opts);
44994
+ var simplifyFn = getBufferSimplifyFunction(srcDataset, opts);
44995
+ var quadSegs = opts.quad_segs >= 2 ? opts.quad_segs : 8;
44996
+ var sagPct = 1 - Math.cos(Math.PI / 4 / quadSegs);
44997
+ var oneSided = !!filterOpts.oneSided;
44998
+ var sideOpts = filterOpts.sideOpts || null;
44999
+ var srcArcs = srcDataset.arcs;
45000
+ bufLyr.shapes = bufLyr.shapes.map(function(bufShape, i) {
45001
+ var srcIdx = filterOpts.srcIndex != null ? filterOpts.srcIndex : i;
45002
+ var srcShape = srcLyr.shapes[srcIdx];
45003
+ var distance = distanceFn(srcIdx);
45004
+ if (!bufShape || !srcShape || !(distance > 0)) return bufShape;
45005
+ if (filterOpts.skipShape && filterOpts.skipShape(srcShape, srcArcs)) return bufShape;
45006
+ var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
45007
+ return filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
45008
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
45009
+ });
45010
+ }
45011
+
44089
45012
  // Remove artifact rings left by dissolving the self-intersecting outline rings
44090
45013
  // made by the two-sided buffer fast path. The outline's concave-join loops can
44091
45014
  // survive the dissolve as spurious holes (and, degenerately, as zero-area rings
@@ -44999,12 +45922,57 @@ ${svg}
44999
45922
  });
45000
45923
  profileEnd('medial:simplify');
45001
45924
  }
45925
+ // Extend each chain's endpoints outward along their terminal tangent. A medial
45926
+ // chain is a cut-line: it only subdivides a contested buffer tile if it spans
45927
+ // from boundary to boundary, so the mosaic builder keeps it (an end that
45928
+ // terminates in a tile's interior is acyclic and detachAcyclicArcs prunes the
45929
+ // whole path). The sampled-site Voronoi stops a fraction of the site spacing
45930
+ // short of where two source rings meet (the gap pinches shut), leaving that end
45931
+ // dangling INSIDE the buffer. Extending past the source boundary lets the cut
45932
+ // node against it; the overshoot lands outside the contested region and is
45933
+ // self-pruned. Without this, a whole river-gap tile is left uncut and assigned
45934
+ // wholesale to one feature (e.g. the Columbia between Oregon and Washington).
45935
+ var extendDist = 0;
45936
+ for (var di = 0; di < coordDistances.length; di++) {
45937
+ if (coordDistances[di] > extendDist) extendDist = coordDistances[di];
45938
+ }
45939
+ if (extendDist > 0) {
45940
+ chains = chains.map(function(chain) {
45941
+ return extendChainEndpoints(chain, extendDist);
45942
+ });
45943
+ }
45002
45944
  return {
45003
45945
  type: 'MultiLineString',
45004
45946
  coordinates: chains
45005
45947
  };
45006
45948
  }
45007
45949
 
45950
+ // Extend an open chain past both endpoints by @len along the direction of the
45951
+ // terminal segment (so the cut-line pokes out of the contested tile at each end
45952
+ // and nodes against the enclosing boundary). Zero-length terminal segments and
45953
+ // chains shorter than 2 points are left unchanged.
45954
+ function extendChainEndpoints(chain, len) {
45955
+ if (!chain || chain.length < 2) return chain;
45956
+ var out = chain.concat();
45957
+ var head = projectPast(out[0], out[1], len);
45958
+ if (head) out.unshift(head);
45959
+ var n = out.length;
45960
+ var tail = projectPast(out[n - 1], out[n - 2], len);
45961
+ if (tail) out.push(tail);
45962
+ return out;
45963
+ }
45964
+
45965
+ // Point at distance @len beyond @from, going away from @toward (i.e. continuing
45966
+ // the from->beyond ray that the toward->from segment defines). Returns null for a
45967
+ // degenerate (coincident) segment.
45968
+ function projectPast(from, toward, len) {
45969
+ var dx = from[0] - toward[0];
45970
+ var dy = from[1] - toward[1];
45971
+ var d = Math.sqrt(dx * dx + dy * dy);
45972
+ if (d === 0) return null;
45973
+ return [from[0] + dx / d * len, from[1] + dy / d * len];
45974
+ }
45975
+
45008
45976
  // Build the medial-construction triangles for the -buffer debug-delaunay option
45009
45977
  // as a GeometryCollection of triangle polygons. collectSites returns only the
45010
45978
  // contested sites, so the Delaunay is already the per-region mesh from which the
@@ -46698,6 +47666,14 @@ ${svg}
46698
47666
  warn('debug-mosaic is not implemented for polygon buffers; ignoring');
46699
47667
  opts = Object.assign({}, opts, {debug_mosaic: false});
46700
47668
  }
47669
+ // The clean-outline-winding construction is the default polygon-grow outline
47670
+ // and the topological per-feature offset (band-method restores the older band
47671
+ // ribbon). Spurious dissolve holes on the default grow are removed by the
47672
+ // shared outline artifact-hole filter; gap-patch handles geodesic fan-apart
47673
+ // outer-wall gaps at construction time.
47674
+ if (!opts.band_method) {
47675
+ opts = Object.assign({}, opts, {clean_outline_winding: true});
47676
+ }
46701
47677
  if (opts.fill_gaps) {
46702
47678
  // Fill enclosed holes and narrow-mouthed inlets without growing the outer
46703
47679
  // boundary -- a topology-aware morphological closing (see
@@ -46756,10 +47732,23 @@ ${svg}
46756
47732
  dissolveBufferDataset2(dataset2, opts);
46757
47733
  }
46758
47734
  }
47735
+ if (useOutlineGrowArtifactFilter(opts)) {
47736
+ applyOutlineArtifactHoleFilter(dataset2.layers[0], dataset2.arcs,
47737
+ lyr, dataset, opts, spherical, {
47738
+ skipShape: function(shape, arcs) {
47739
+ return shapeHasFillInsideHole(shape, arcs);
47740
+ }
47741
+ });
47742
+ }
46759
47743
  cullSubTolerancePolygonArtifacts(dataset2, lyr, dataset, opts);
46760
47744
  return dataset2;
46761
47745
  }
46762
47746
 
47747
+ // True for clean-outline polygon grow and topological (not band-method or debug).
47748
+ function useOutlineGrowArtifactFilter(opts) {
47749
+ return !opts.band_method && !bufferOutputIsDebug(opts);
47750
+ }
47751
+
46763
47752
  // True when the buffer is producing a debug view (raw offsets or medial
46764
47753
  // construction) rather than a real buffer; those must skip the artifact cull.
46765
47754
  function bufferOutputIsDebug(opts) {
@@ -46775,10 +47764,9 @@ ${svg}
46775
47764
  // is dropped. The smallest legitimate buffer part (the grow of a point-like
46776
47765
  // feature, ~pi*d^2) is ~4 orders of magnitude larger, so the threshold never
46777
47766
  // touches real geometry, and a genuinely thin-but-long fill (area =
46778
- // tol*length >> tol^2) is kept. Holes are intentionally left alone: a small hole
46779
- // can be a neighbor's preserved territory (see removePositiveBufferArtifactHoles,
46780
- // which is territory-aware) and must not be filled here. Disabled when tolerance
46781
- // is turned off (tolerance=0), matching the medial-simplify contract.
47767
+ // tol*length >> tol^2) is kept. Holes are intentionally left alone; the outline
47768
+ // artifact-hole filter runs after dissolve and keeps legitimate carved holes.
47769
+ // Disabled when tolerance is turned off (tolerance=0), matching the medial-simplify contract.
46782
47770
  function cullSubTolerancePolygonArtifacts(outDataset, srcLyr, srcDataset, opts) {
46783
47771
  if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') return;
46784
47772
  if (!outDataset || !outDataset.arcs) return;
@@ -46810,8 +47798,9 @@ ${svg}
46810
47798
  // - The clean-outline construction is the default for ordinary polygon grow
46811
47799
  // (outer rings offset to a single self-contained loop; far fewer rings and
46812
47800
  // self-intersections into the winding dissolve than the band ribbon).
46813
- // - The topological pipeline (which pre-dissolves per feature into a shared
46814
- // mosaic) and the band-method escape hatch keep the band-ribbon construction.
47801
+ // - The band-method escape hatch keeps the band-ribbon construction; the
47802
+ // topological pipeline uses the same clean-outline grow (with shared-arc path
47803
+ // splitting) unless band-method is set.
46815
47804
  // - Negative buffers and hole shrink fall back to the band erode inside the
46816
47805
  // outline path itself.
46817
47806
  // Shared by makePolygonBuffer and makePolarPolygonBuffer so the polar option is
@@ -47006,13 +47995,13 @@ ${svg}
47006
47995
  // Drives the same per-ring makers the real construction uses, so the view shows
47007
47996
  // exactly what is built and 'no-loop-removal' has a visible effect (loop removal
47008
47997
  // runs inside the maker -- see buildOneSidedRings). Positive grow uses the
47009
- // clean-outline maker by default (the band maker under band-method/topological);
47010
- // holes are eroded with the band maker reversed to outer orientation (matching
47998
+ // clean-outline maker by default (the band maker under band-method); holes are
47999
+ // eroded with the band maker reversed to outer orientation (matching
47011
48000
  // makeOutlineBufferGeometry); negative (erode) buffers offset every ring inward
47012
48001
  // with the band maker.
47013
48002
  function makePolygonDebugOffsetGeoJSON(lyr, dataset, opts) {
47014
48003
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
47015
- var useOutline = !opts.band_method && !opts.topological;
48004
+ var useOutline = !opts.band_method;
47016
48005
  var leftOpts = useOutline ? Object.assign({}, opts, {outline: true}) : opts;
47017
48006
  var leftMaker = getPolygonRingBufferMaker(dataset, leftOpts, 'left');
47018
48007
  var rightMaker = getPolygonRingBufferMaker(dataset,
@@ -47158,17 +48147,7 @@ ${svg}
47158
48147
  getBufferMultiPolygonCoords(rings.outer, distance, outerMaker) : [];
47159
48148
  if (outerLoops.length === 0) return null;
47160
48149
  // Resolve the outer offset loops' self-overlaps into clean grown polygons.
47161
- var coords = dissolveOffsetRingsToCoords(outerLoops, opts);
47162
- if (coords.length === 0) return null;
47163
- // Strip artifact holes left by the outer grow (the offset loops dissolve into
47164
- // a clean fill, so any interior ring is a self-overlap artifact) BEFORE
47165
- // carving the real holes. The artifact filter's heuristic can otherwise
47166
- // delete a legitimately-shrunk hole whose eroded boundary happens to run near
47167
- // the source outline; the carved holes below are explicit and known-good, so
47168
- // they must not pass through it.
47169
- var grown = removePositiveBufferArtifactHoles(
47170
- {type: 'MultiPolygon', coordinates: coords}, shape, arcs, distance);
47171
- coords = grown ? grown.coordinates : [];
48150
+ var coords = dissolveOffsetRingsToCoords(outerLoops, opts, true);
47172
48151
  if (coords.length === 0) return null;
47173
48152
  if (rings.holes.length > 0) {
47174
48153
  // Shrink the holes (an inward offset) with the band erode: treat each hole
@@ -47184,6 +48163,42 @@ ${svg}
47184
48163
  return {type: 'MultiPolygon', coordinates: coords};
47185
48164
  }
47186
48165
 
48166
+ // Clean-outline grow for one topological feature: offset each shared-arc path
48167
+ // chain (see getPolygonBufferPathData), grow outers with the outline maker,
48168
+ // shrink holes with the band erode, then union -- same hole semantics as
48169
+ // makeOutlineBufferGeometry but with inter-feature path splitting preserved.
48170
+ //
48171
+ // A chain is classified as outer (grow) vs hole (shrink) by the signed area of
48172
+ // the CLOSED SOURCE RING it was split from, not by the chain's own area: an
48173
+ // unshared-boundary chain is an OPEN fragment (e.g. a state's international or
48174
+ // coastline segment between two shared interior borders), whose planar signed
48175
+ // area has an arbitrary sign and would misclassify a real outer boundary as a
48176
+ // hole -- eroding it inward instead of growing it (Ohio's Lake Erie coast, New
48177
+ // Mexico's Mexico border).
48178
+ function makeTopologicalOutlineBufferCoords(shape, arcs, distance, uniqueArcTest,
48179
+ opts, outerMaker, holeEroder) {
48180
+ var outer = [];
48181
+ var holes = [];
48182
+ (shape || []).forEach(function(ring) {
48183
+ var target = getPlanarPathArea(ring, arcs) < 0 ? holes : outer;
48184
+ var chains = uniqueArcTest ? splitPathAtSharedArcs(ring, uniqueArcTest) :
48185
+ [ring.concat()];
48186
+ chains.forEach(function(chain) { target.push(chain); });
48187
+ });
48188
+ var outerLoops = outer.length > 0 ?
48189
+ getBufferMultiPolygonCoords(outer, distance, outerMaker) : [];
48190
+ if (outerLoops.length === 0) return [];
48191
+ var coords = dissolveOffsetRingsToCoords(outerLoops, opts, true);
48192
+ if (coords.length === 0) return [];
48193
+ if (holes.length > 0) {
48194
+ var holeGeom = holeEroder(holes.map(reversePath), distance);
48195
+ if (holeGeom && holeGeom.coordinates.length > 0) {
48196
+ coords = subtractHolesFromOuter(coords, holeGeom.coordinates);
48197
+ }
48198
+ }
48199
+ return coords;
48200
+ }
48201
+
47187
48202
  // Carve clean shrunk-hole regions out of clean grown-outer polygons. Both arrive
47188
48203
  // as positive (CCW) rings. The winding union can't subtract one nested loop from
47189
48204
  // another -- GeoJSON import rewinds every outer ring to CCW, so two separately
@@ -47260,13 +48275,23 @@ ${svg}
47260
48275
  var hasNegativeDistance = false;
47261
48276
  if (useTopologicalMode) {
47262
48277
  // The topological pipeline selects mosaic tiles by source membership
47263
- // (boundary flood), which cannot resolve the self-overlapping winding-fill
47264
- // ring. So each feature's winding-fill offset rings are pre-dissolved into a
47265
- // clean (non-self-overlapping) polygon before they enter the shared mosaic;
47266
- // the construction speedup (loop removal cutting per-feature self-
47267
- // intersections) is preserved, and the mosaic is unchanged.
48278
+ // (boundary flood), which cannot resolve self-overlapping offset rings.
48279
+ // Each feature's offset is pre-dissolved into a clean polygon before it
48280
+ // enters the shared mosaic. By default this uses the same clean-outline grow
48281
+ // as ordinary polygon buffers (gap-patch, loop removal); band-method keeps
48282
+ // the older band ribbon.
48283
+ var topoLeftOpts = opts.band_method ? opts :
48284
+ Object.assign({}, opts, {outline: true});
48285
+ var outerMaker = getPolygonRingBufferMaker(dataset, topoLeftOpts, 'left');
48286
+ var bandOpts = Object.assign({}, opts, {outline: false});
48287
+ var bandLeftMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'left');
48288
+ var bandRightMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'right');
48289
+ var holeEroder = function(holeShape, dist) {
48290
+ return makeNegativePolygonBufferGeometry(holeShape, dist, dataset, bandOpts,
48291
+ bandRightMaker);
48292
+ };
47268
48293
  return makeTopologicalPolygonBufferGeoJSON(lyr, dataset, opts, distanceFn,
47269
- uniqueArcTest, getPolygonRingBufferMaker(dataset, opts, 'left'));
48294
+ uniqueArcTest, outerMaker, holeEroder, bandLeftMaker);
47270
48295
  }
47271
48296
  // Closed source rings are offset with the winding-fill construction: one
47272
48297
  // self-overlapping ring per source ring (its overshoot loops resolved by the
@@ -47332,6 +48357,7 @@ ${svg}
47332
48357
  // for callers that request winding-fill (see the 'band-method' option).
47333
48358
  var useWinding = !opts.band_method;
47334
48359
  var makerOpts = Object.assign({}, opts, {
48360
+ geometry_type: 'polygon',
47335
48361
  left: side == 'left',
47336
48362
  right: side == 'right',
47337
48363
  // Winding-fill construction also enables overshoot-loop removal on the single
@@ -47352,7 +48378,7 @@ ${svg}
47352
48378
  }
47353
48379
 
47354
48380
  function makeTopologicalPolygonBufferGeoJSON(lyr, dataset, opts, distanceFn,
47355
- uniqueArcTest, bufferMaker) {
48381
+ uniqueArcTest, outerMaker, holeEroder, bandFallbackMaker) {
47356
48382
  var shapes = lyr.shapes || [];
47357
48383
  var distances = [];
47358
48384
  var sourceIds = [];
@@ -47376,18 +48402,22 @@ ${svg}
47376
48402
  profileStart('topo:offsets');
47377
48403
  shapes.forEach(function(shape, i) {
47378
48404
  var distance = distances[i];
47379
- var pathData, bufferCoords;
48405
+ var bufferCoords;
47380
48406
  if (!distance || !shape) return;
47381
48407
  hasPositiveDistance = true;
47382
- pathData = getPolygonBufferPathData(shape, uniqueArcTest);
47383
- bufferCoords = getBufferMultiPolygonCoords(pathData.paths, distance, bufferMaker);
47384
- // Resolve the winding-fill rings' self-overlaps into a clean polygon (the
47385
- // mosaic's boundary-flood membership cannot), so this feature enters the
47386
- // shared mosaic as an ordinary polygon. The band-method fallback emits
47387
- // boundary-flood-resolvable bands, so it feeds the mosaic directly (as the
47388
- // topological pipeline did before the winding-fill construction).
47389
- if (!opts.band_method) {
47390
- bufferCoords = dissolveOffsetRingsToCoords(bufferCoords, opts);
48408
+ if (!opts.band_method && opts.clean_outline_winding &&
48409
+ !shapeHasFillInsideHole(shape, dataset.arcs)) {
48410
+ bufferCoords = makeTopologicalOutlineBufferCoords(shape, dataset.arcs,
48411
+ distance, uniqueArcTest, opts, outerMaker, holeEroder);
48412
+ } else {
48413
+ var pathData = getPolygonBufferPathData(shape, uniqueArcTest);
48414
+ var maker = bandFallbackMaker || outerMaker;
48415
+ bufferCoords = getBufferMultiPolygonCoords(pathData.paths, distance, maker);
48416
+ // Resolve winding-fill band rings' self-overlaps (the mosaic's boundary-
48417
+ // flood membership cannot). band-method feeds bands directly.
48418
+ if (!opts.band_method) {
48419
+ bufferCoords = dissolveOffsetRingsToCoords(bufferCoords, opts);
48420
+ }
47391
48421
  }
47392
48422
  if (bufferCoords.length > 0) {
47393
48423
  bufferIds[i] = tmpGeometries.length;
@@ -48062,13 +49092,36 @@ ${svg}
48062
49092
  function ringEnclosesOtherTerritory(ring, ctx) {
48063
49093
  var points = ctx.territoryPoints;
48064
49094
  if (!points) return false;
49095
+ var bounds = getGeoJSONRingBounds(ring);
48065
49096
  for (var i = 0; i < points.length; i++) {
48066
49097
  if (points[i].featureId === ctx.featureId) continue;
49098
+ if (!pointInGeoJSONRingBounds(points[i].x, points[i].y, bounds)) continue;
48067
49099
  if (pointInGeoJSONRing(points[i].x, points[i].y, ring)) return true;
48068
49100
  }
48069
49101
  return false;
48070
49102
  }
48071
49103
 
49104
+ function getGeoJSONRingBounds(ring) {
49105
+ var n = ring.length - 1; // skip duplicate closing vertex
49106
+ if (n <= 0) n = ring.length;
49107
+ var xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity;
49108
+ var i, x, y;
49109
+ for (i = 0; i < n; i++) {
49110
+ x = ring[i][0];
49111
+ y = ring[i][1];
49112
+ if (x < xmin) xmin = x;
49113
+ if (x > xmax) xmax = x;
49114
+ if (y < ymin) ymin = y;
49115
+ if (y > ymax) ymax = y;
49116
+ }
49117
+ return {xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax};
49118
+ }
49119
+
49120
+ function pointInGeoJSONRingBounds(x, y, bounds) {
49121
+ return x >= bounds.xmin && x <= bounds.xmax &&
49122
+ y >= bounds.ymin && y <= bounds.ymax;
49123
+ }
49124
+
48072
49125
  // Ray-casting point-in-ring test for a closed GeoJSON ring (array of [x, y],
48073
49126
  // first == last). Boundary cases are irrelevant here: territory probe points are
48074
49127
  // well inside their source part, far from any hole-ring edge.
@@ -48113,9 +49166,18 @@ ${svg}
48113
49166
  return getCoordinateDistance(distance, arcs) * 0.5;
48114
49167
  }
48115
49168
 
49169
+ // Minimum area for a grow-generated interior ring to be treated as a real hole
49170
+ // rather than numerical noise. A positive buffer can legitimately enclose a hole
49171
+ // far smaller than the buffer disk (a pocket between source arms whose mouth just
49172
+ // closed leaves an arbitrarily small gap), so this is only a degenerate-sliver
49173
+ // floor -- a tiny fraction of the buffer-disk area -- NOT a "holes smaller than
49174
+ // the radius are artifacts" rule. (It used to be the full disk area d*d, which
49175
+ // silently deleted real holes whose area was less than the radius squared; the
49176
+ // near-source boundary classifier in positiveBufferHoleIsArtifact is what
49177
+ // actually distinguishes self-overlap artifacts from real holes.)
48116
49178
  function getPositiveHoleArtifactAreaThreshold(distance, arcs) {
48117
49179
  var d = getCoordinateDistance(distance, arcs);
48118
- return d * d;
49180
+ return d * d * 0.01;
48119
49181
  }
48120
49182
 
48121
49183
  function getGeoJSONRingArea(ring) {
@@ -48189,11 +49251,14 @@ ${svg}
48189
49251
  // dissolve. Used by the topological pipeline to feed an ordinary polygon into
48190
49252
  // the shared mosaic (whose boundary-flood membership cannot resolve the
48191
49253
  // self-overlapping construction ring directly).
48192
- function dissolveOffsetRingsToCoords(coords, opts) {
49254
+ function dissolveOffsetRingsToCoords(coords, opts, outlineDissolve) {
48193
49255
  if (!coords || coords.length === 0) return [];
48194
49256
  var dataset = getBufferDataset(coords);
48195
49257
  if (!dataset.arcs) return [];
48196
- dissolveBufferDataset2(dataset, Object.assign({}, opts, {winding_fill: true}));
49258
+ var dissolveOpts = outlineDissolve ?
49259
+ getOutlineBufferDissolveOpts(opts) :
49260
+ Object.assign({}, opts, {winding_fill: true});
49261
+ dissolveBufferDataset2(dataset, dissolveOpts);
48197
49262
  var lyr = dataset.layers[0];
48198
49263
  var shape = lyr.shapes && lyr.shapes[0];
48199
49264
  return shape ? getPolygonMultiPolygonCoords(shape, dataset.arcs) : [];
@@ -65123,7 +66188,7 @@ ${svg}
65123
66188
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
65124
66189
  }
65125
66190
 
65126
- var version = "0.7.33";
66191
+ var version = "0.7.35";
65127
66192
 
65128
66193
  // Parse command line args into commands and run them
65129
66194
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.