mapshaper 0.7.34 → 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
@@ -31959,12 +31959,12 @@ ${svg}
31959
31959
  type: 'flag'
31960
31960
  })
31961
31961
  .option('coarse-bridge', {
31962
- // Undocumented: in the clean-outline-winding construction, bridge concave
31963
- // bends with the low-resolution makeCoarseConcaveJoin (as few as one
31964
- // reversed arc vertex) instead of the full-resolution makeConcaveJoin.
31965
- // The reversed bridge only bounds a self-overlap loop the direction remover
31966
- // collapses, so this leaves the final boundary unchanged while producing a
31967
- // smaller ring for the winding dissolve -- a construction-speed tradeoff.
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
31968
  type: 'flag'
31969
31969
  })
31970
31970
  .option('no-gap-patch', {
@@ -42059,17 +42059,15 @@ ${svg}
42059
42059
  return turnPrefix[posHi] - turnPrefix[posLo];
42060
42060
  }
42061
42061
 
42062
- // Multi-pass collapse gated by the offset ring's own cumulative absolute turn over the collapsed span, a
42063
- // provenance-free proxy for the source-path turn gate (each offset join's turn
42064
- // angle equals the source bend angle it derives from, and straight offset
42065
- // segments contribute zero turn, so summing |turn| over a ring span with no cap
42066
- // reconstructs the source stretch's cumulative turn). Iterating lets the
42067
- // collapse of tight inner overshoot loops shorten the spans of the loops that
42068
- // wrap them, so a wrapper that was too-large-turn on one pass can become a
42069
- // single-bend covered overshoot on the next -- the multi-pass "peel simple
42070
- // interior loops first" idea. maxTurn is the same gate constant as the source
42071
- // turn gate; caps (180-degree arcs) exceed it and so are never collapsed.
42072
- // @fillFloor (dip+coverage path): max winding-0 area a collapse may swallow; a
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
42073
42071
  // collapse filling a larger hole/notch is refused (see BUFFER_LOOP_FILL_AREA_FRAC).
42074
42072
  // Callers pass dist^2 * BUFFER_LOOP_FILL_AREA_FRAC; defaults to the uncovered
42075
42073
  // floor when omitted (unit tests without a buffer distance).
@@ -42078,11 +42076,32 @@ ${svg}
42078
42076
  if (maxTurn === undefined) maxTurn = BUFFER_LOOP_MAX_TURN;
42079
42077
  if (!(maxPasses >= 1)) maxPasses = 12;
42080
42078
  if (!(fillFloor >= 0)) fillFloor = BUFFER_LOOP_CHECK_MIN_AREA;
42081
- var work = ring, workPos = null, workTags = dipTags || null;
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;
42082
42100
  for (var pass = 0; pass < maxPasses; pass++) {
42083
- var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn, workTags, fillFloor);
42101
+ var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn,
42102
+ coverage, fillFloor, clipBudget);
42084
42103
  if (res.ring.length === work.length) return res.ring; // stable
42085
- work = res.ring; workPos = res.srcPos; workTags = res.dipTags;
42104
+ work = res.ring; workPos = res.srcPos;
42086
42105
  }
42087
42106
  return work;
42088
42107
  }
@@ -42090,38 +42109,25 @@ ${svg}
42090
42109
  // One greedy forward-collapse pass (mirrors removeBufferRingLoops' compaction).
42091
42110
  // The span gate is, in order of preference:
42092
42111
  // - the reliable source-path turn (getSpanTurn) when srcPos+turnPrefix given;
42093
- // - else the ring's cumulative turn with tagged fold cusps excluded, when
42094
- // dipTags (construction-tagged reversed concave-join arcs) are supplied;
42095
- // - else the same but with a geometric cusp threshold (no provenance).
42096
- // Returns {ring, srcPos, dipTags} so the caller can iterate.
42097
- function collapseRingLoopsPass(ring, maxGap, srcPos, turnPrefix, maxTurn, dipTags, fillFloor) {
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) {
42098
42116
  var gated = !!(srcPos && turnPrefix);
42099
42117
  var n = ring.length - 1;
42100
- // The dip path defers entirely to the exact coverage check, so it needs neither
42101
- // the tag-excluded turn gate nor its cumulative-turn prefix; the other two paths
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
42102
42120
  // (source-turn, geometric) have no coverage check and keep the turn gate.
42103
- var coveragePath = !gated && !!dipTags;
42104
- var ringTurn = (!gated && !dipTags) ? ringAbsTurnPrefix(ring, dipTags) : null;
42105
- // Build the ring's y-band edge index once per pass so the coverage check's
42106
- // per-loop edge lookup is local rather than O(ring length).
42107
- var covIndex = coveragePath ? buildEdgeYIndex(ring, n) : null;
42108
- // Opposite-wound hole protection. The coverage check only guards against
42109
- // UNCOVERING area, so it cannot catch a collapse that FILLS a real winding-0
42110
- // hole (filling adds coverage), and its area pre-filter treats any sub-floor
42111
- // loop as safe to drop. A dropped sub-loop wound OPPOSITE to the parent ring
42112
- // bounds such a hole, so refuse the collapse outright -- this keeps real holes
42113
- // (annulus interiors, self-crossing-line pockets) the coverage check alone
42114
- // misses, at the cost of only an O(span) signed-area test on the crossings the
42115
- // collapse actually evaluates (far cheaper than the old O(n*maxGap) per-pass
42116
- // pre-scan). A same-wound overshoot fold that happens to WRAP a hole is caught
42117
- // instead by the fill guard in the coverage check (it measures filled area).
42118
- var parentCCW = coveragePath ? (ringSignedArea(ring) >= 0) : false;
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;
42119
42127
  var out = [ring[0]];
42120
42128
  var outPos = gated ? [srcPos[0]] : null;
42121
- var outTags = dipTags ? [dipTags[0]] : null;
42122
42129
  var segmentEnd = ring[1];
42123
42130
  var segmentEndPos = gated ? srcPos[1] : 0;
42124
- var segmentEndTag = dipTags ? dipTags[1] : 0;
42125
42131
  var nextRingIndex = 2;
42126
42132
  while (true) {
42127
42133
  var anchor = out[out.length - 1];
@@ -42133,22 +42139,34 @@ ${svg}
42133
42139
  var c = ring[s], d = ring[s + 1];
42134
42140
  var hit = segHit(ax, ay, bx, by, c[0], c[1], d[0], d[1]);
42135
42141
  if (!hit) continue;
42136
- if (coveragePath) {
42137
- // Never collapse a sub-loop wound opposite to the parent: it bounds a
42138
- // real winding-0 hole the coverage check cannot see being filled (see the
42139
- // hole-protection note above).
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
+ }
42140
42157
  if ((loopAreaSign(hit[0], hit[1], bx, by, ring, nextRingIndex, s) >= 0) !== parentCCW) {
42141
42158
  continue;
42142
42159
  }
42143
- // No turn gate on the dip path: the exact coverage check is the arbiter.
42144
- // It measures how much of the dropped region would become uncovered and
42145
- // refuses collapses that would clip a significant lobe (see
42146
- // docs/development/buffer-line-notes.md), catching real lobes and end caps
42147
- // that a cheap turn/area/winding signal cannot separate from safe folds.
42148
- // The turn gate was both leaving valid interior loops uncollapsed (tight
42149
- // hairpins whose source turn exceeds the cap) and slowing the pipeline by
42150
- // deferring their removal to the dissolve.
42151
- if (!collapseKeepsAreaCovered(ring, n, hit, segmentEnd, nextRingIndex, s, covIndex, fillFloor)) {
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)) {
42152
42170
  continue; // dropping this loop would uncover or fill real area -- leave it
42153
42171
  }
42154
42172
  } else {
@@ -42166,24 +42184,20 @@ ${svg}
42166
42184
  if (crossing) {
42167
42185
  segmentEnd = crossing;
42168
42186
  if (gated) segmentEndPos = anchorPos;
42169
- segmentEndTag = 0; // an offset crossing is not a dip vertex
42170
42187
  nextRingIndex = crossingEndIndex;
42171
42188
  continue;
42172
42189
  }
42173
42190
  out.push(segmentEnd);
42174
42191
  if (gated) outPos.push(segmentEndPos);
42175
- if (dipTags) outTags.push(segmentEndTag);
42176
42192
  if (nextRingIndex > n - 1) break;
42177
42193
  segmentEnd = ring[nextRingIndex];
42178
42194
  if (gated) segmentEndPos = srcPos[nextRingIndex];
42179
- if (dipTags) segmentEndTag = dipTags[nextRingIndex];
42180
42195
  nextRingIndex++;
42181
42196
  }
42182
- if (out.length < 4) return {ring: ring, srcPos: srcPos, dipTags: dipTags};
42197
+ if (out.length < 4) return {ring: ring, srcPos: srcPos}; // collapsed away; keep original
42183
42198
  out.push(out[0].concat());
42184
42199
  if (gated) outPos.push(outPos[0]);
42185
- if (dipTags) outTags.push(outTags[0]);
42186
- return {ring: out, srcPos: gated ? outPos : null, dipTags: dipTags ? outTags : null};
42200
+ return {ring: out, srcPos: gated ? outPos : null};
42187
42201
  }
42188
42202
 
42189
42203
  // Cumulative absolute turn (degrees) indexed by ring vertex, EXCLUDING fold
@@ -42191,17 +42205,13 @@ ${svg}
42191
42205
  // near-180-degree cusps where the offset doubles back on itself (an artifact of
42192
42206
  // the self-crossing offset, with no corresponding source bend). A normal offset
42193
42207
  // join's turn equals its source bend angle, so the remaining sum reconstructs
42194
- // the source stretch's cumulative turn -- the reliable loop-removal signal --
42195
- // without source provenance.
42196
- //
42197
- // A cusp is identified by construction tag when dipTags are supplied (a vertex
42198
- // on a reversed concave-join arc AND turning sharply); otherwise by a purely
42199
- // geometric turn threshold. Tag-gating is the point: a real sharp bend (e.g. a
42200
- // fjord mouth, emitted as a miter, never tagged) keeps its turn and so is never
42201
- // mistaken for a fold, which is what makes the geometric-only threshold
42202
- // over-collapse sharp coastlines.
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.
42203
42213
  var CUSP_TURN = 135;
42204
- function ringAbsTurnPrefix(ring, dipTags) {
42214
+ function ringAbsTurnPrefix(ring) {
42205
42215
  var n = ring.length - 1;
42206
42216
  var prefix = new Float64Array(n + 1);
42207
42217
  var RAD = 180 / Math.PI;
@@ -42212,8 +42222,7 @@ ${svg}
42212
42222
  var cross = e1x * e2y - e1y * e2x;
42213
42223
  var dot = e1x * e2x + e1y * e2y;
42214
42224
  var ang = Math.abs(Math.atan2(cross, dot)) * RAD;
42215
- var isCusp = ang > CUSP_TURN && (dipTags ? dipTags[k] : true);
42216
- prefix[k] = prefix[k - 1] + (isCusp ? 0 : ang);
42225
+ prefix[k] = prefix[k - 1] + (ang > CUSP_TURN ? 0 : ang);
42217
42226
  }
42218
42227
  prefix[n] = prefix[n - 1];
42219
42228
  return prefix;
@@ -42261,12 +42270,14 @@ ${svg}
42261
42270
  // The threshold is in ring units; under web Mercator the scale factor is >= 1
42262
42271
  // everywhere, so it is an upper bound on the real m^2 area and no genuinely large
42263
42272
  // clip is skipped regardless of latitude.
42264
- function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor) {
42273
+ function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor, clipBudget) {
42265
42274
  // Area pre-filter keeps the scanline off the hot path: a loop can neither clip
42266
- // nor fill more than its own area, so it is safe to skip whenever the loop is
42267
- // below BOTH thresholds. (Filling a winding-0 region never reaches the uncovered
42268
- // floor, so the fill floor must join the skip test -- otherwise small folds that
42269
- // could fill a small hole would be skipped, or every collapse would be scanned.)
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.)
42270
42281
  var floor = fillFloor < BUFFER_LOOP_CHECK_MIN_AREA ? fillFloor : BUFFER_LOOP_CHECK_MIN_AREA;
42271
42282
  var u = collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor);
42272
42283
  if (u < 0) return true; // loop below both floors -- too small to clip or fill
@@ -42275,33 +42286,68 @@ ${svg}
42275
42286
  // fill floor scales with the buffer disk (dist^2) because a real hole's area is
42276
42287
  // a fixed fraction of the disk while a fold sliver is orders of magnitude
42277
42288
  // smaller relative to the radius (see BUFFER_LOOP_FILL_AREA_FRAC).
42278
- return u < BUFFER_LOOP_CHECK_MIN_AREA && _lastFillArea < fillFloor;
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;
42279
42306
  }
42280
42307
 
42281
42308
  // Returns the area of the dropped region a collapse would leave uncovered, or -1
42282
- // when the loop's own area is below `floor` (too small to matter -- scanline
42283
- // skipped). See collapseKeepsAreaCovered for the winding rationale.
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).
42284
42319
  function collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor) {
42285
42320
  var i, loopLen = s - nextRingIndex + 3; // X, b, ring[next..s]
42286
42321
  // Loop area (shoelace over X, b, ring[next..s]) and bounding box.
42287
42322
  var area2 = 0, px = X[0], py = X[1];
42288
42323
  var minx = X[0], maxx = X[0], miny = X[1], maxy = X[1];
42289
- function bbox(qx, qy) {
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;
42290
42331
  if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42291
42332
  if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42292
42333
  }
42293
- bbox(b[0], b[1]);
42294
- area2 += px * b[1] - b[0] * py; px = b[0]; py = b[1];
42295
- for (i = nextRingIndex; i <= s; i++) {
42296
- area2 += px * ring[i][1] - ring[i][0] * py; px = ring[i][0]; py = ring[i][1];
42297
- bbox(ring[i][0], ring[i][1]);
42298
- }
42299
42334
  area2 += px * X[1] - X[0] * py;
42300
- if (Math.abs(area2) / 2 < floor) return -1; // too small to matter
42301
- // Collect the ring edges whose y-range meets the loop's band (the only ones
42302
- // that can cross any scanline); reuse module scratch to avoid per-call garbage.
42335
+ var smallNet = Math.abs(area2) / 2 < floor;
42336
+ if (smallNet && (maxx - minx) * (maxy - miny) < floor) return -1;
42303
42337
  var scr = coverageScratch(n + loopLen);
42304
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.
42305
42351
  var band = scr.band, le = 0;
42306
42352
  var gen = ++index.gen, stamp = index.stamp, start = index.start, edges = index.edges;
42307
42353
  var kb, klo = index.binOf(miny), khi = index.binOf(maxy);
@@ -42316,17 +42362,23 @@ ${svg}
42316
42362
  band[le++] = ei;
42317
42363
  }
42318
42364
  }
42319
- // Loop edges (X->b, b->ring[next..s], ring[s]->X).
42320
- var lc = 0;
42321
- lx0[lc] = X[0]; ly0[lc] = X[1]; lx1[lc] = b[0]; ly1[lc] = b[1]; lc++;
42322
- lx0[lc] = b[0]; ly0[lc] = b[1]; lx1[lc] = ring[nextRingIndex][0]; ly1[lc] = ring[nextRingIndex][1]; lc++;
42323
- for (i = nextRingIndex; i < s; i++) {
42324
- lx0[lc] = ring[i][0]; ly0[lc] = ring[i][1]; lx1[lc] = ring[i + 1][0]; ly1[lc] = ring[i + 1][1]; lc++;
42325
- }
42326
- lx0[lc] = ring[s][0]; ly0[lc] = ring[s][1]; lx1[lc] = X[0]; ly1[lc] = X[1]; lc++;
42327
42365
  return loopUncoveredArea(ring, band, le, lx0, ly0, lx1, ly1, lc, minx, maxx, miny, maxy, scr);
42328
42366
  }
42329
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
+
42330
42382
  // y-band index of ring edges: bins the ring's y-range so a scanline query at
42331
42383
  // [miny, maxy] returns only edges reaching that band instead of scanning all n.
42332
42384
  // start[k]..start[k+1] are indices into `edges` for bin k; `stamp`/`gen` dedup an
@@ -42383,16 +42435,24 @@ ${svg}
42383
42435
  // Returns the uncovered area and writes the filled area to _lastFillArea.
42384
42436
  // `band` lists the indices of ring edges that can reach the loop's y-band.
42385
42437
  var _lastFillArea = 0;
42438
+ var _lastClipX = 0, _lastClipY = 0;
42386
42439
  function loopUncoveredArea(ring, band, be, lx0, ly0, lx1, ly1, le, minx, maxx, miny, maxy, scr) {
42387
42440
  var h = maxy - miny;
42388
42441
  _lastFillArea = 0;
42389
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.
42390
42450
  var target = Math.sqrt(BUFFER_LOOP_CHECK_MIN_AREA) / 4;
42391
42451
  var rows = Math.round(h / (target > 0 ? target : h));
42392
42452
  if (rows < 8) rows = 8; else if (rows > 40) rows = 40;
42393
42453
  var dy = h / rows;
42394
42454
  var xs = scr.xs, df = scr.df, dl = scr.dl, order = scr.order;
42395
- var total = 0, fill = 0, r, k, i;
42455
+ var total = 0, fill = 0, momX = 0, momY = 0, r, k, i;
42396
42456
  for (r = 0; r < rows; r++) {
42397
42457
  var y = miny + (r + 0.5) * dy;
42398
42458
  // Winding just left of the loop's x-range (base), plus the crossings that
@@ -42421,17 +42481,24 @@ ${svg}
42421
42481
  for (i = 0; i < m; i++) {
42422
42482
  var o = order[i], xk = xs[o];
42423
42483
  if (wl !== 0 && xk > prevx) {
42424
- if (wf - wl === 0) total += xk - prevx;
42484
+ if (wf - wl === 0) {
42485
+ total += xk - prevx;
42486
+ momX += (xk + prevx) / 2 * (xk - prevx); momY += y * (xk - prevx);
42487
+ }
42425
42488
  else if (wf === 0) fill += xk - prevx;
42426
42489
  }
42427
42490
  wf += df[o]; wl += dl[o]; prevx = xk;
42428
42491
  }
42429
42492
  if (wl !== 0 && maxx > prevx) {
42430
- if (wf - wl === 0) total += maxx - prevx;
42493
+ if (wf - wl === 0) {
42494
+ total += maxx - prevx;
42495
+ momX += (maxx + prevx) / 2 * (maxx - prevx); momY += y * (maxx - prevx);
42496
+ }
42431
42497
  else if (wf === 0) fill += maxx - prevx;
42432
42498
  }
42433
42499
  }
42434
42500
  _lastFillArea = fill * dy;
42501
+ if (total > 0) { _lastClipX = momX / total; _lastClipY = momY / total; }
42435
42502
  return total * dy;
42436
42503
  }
42437
42504
 
@@ -43151,9 +43218,9 @@ ${svg}
43151
43218
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
43152
43219
  // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
43153
43220
  // the optional low-resolution alternative to makeConcaveJoin in
43154
- // traceCleanOffsetSide. Larger = fewer points = faster dissolve. The reversed
43155
- // bridge only bounds a self-overlap loop that the direction remover collapses,
43156
- // so its resolution does not affect the final boundary.
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.
43157
43224
  var CLEAN_OUTLINE_BRIDGE_STEP = 90;
43158
43225
  var capStyle = opts.cap_style || 'round'; // expect 'round' or 'flat'
43159
43226
  var pathIter = useMercator ?
@@ -44159,11 +44226,38 @@ ${svg}
44159
44226
  } else {
44160
44227
  add(p2Prev, segId, 1);
44161
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
+ }
44162
44254
  if (useGapPatch(opts, useMercator) &&
44163
44255
  offsetEdgesFanApart(p1Prev, p2Prev, p1, p2)) {
44164
44256
  if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44165
- if (wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44166
- joinPoints, p2Prev, p1)) {
44257
+ if (opts.coarse_bridge ?
44258
+ wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44259
+ joinPoints, p2Prev, p1) :
44260
+ exposedWedge) {
44167
44261
  fanApartBends.push(segId);
44168
44262
  }
44169
44263
  }
@@ -44367,13 +44461,21 @@ ${svg}
44367
44461
  return makeRoundJoin(cx, cy, startBearing, arcAngle, dist).reverse();
44368
44462
  }
44369
44463
 
44370
- // Coarse alternative to makeConcaveJoin (selected by opts.coarse_bridge in
44371
- // traceCleanOffsetSide): bridges a concave bend with as few as one reversed
44372
- // arc vertex (CLEAN_OUTLINE_BRIDGE_STEP), producing a smaller ring for the
44373
- // winding dissolve to chew through. The reversed bridge only bounds a self-
44374
- // overlap loop the direction remover collapses, so the coarser resolution
44375
- // does not change the final boundary -- it just trades a little construction
44376
- // fidelity for dissolve speed.
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).
44377
44479
  function makeCoarseConcaveJoin(cx, cy, startBearing, arcAngle, dist) {
44378
44480
  var segs = Math.min(roundJoinSegsPerQuadrant,
44379
44481
  Math.max(1, Math.ceil(arcAngle / CLEAN_OUTLINE_BRIDGE_STEP)));
@@ -44385,6 +44487,7 @@ ${svg}
44385
44487
  return points.reverse();
44386
44488
  }
44387
44489
 
44490
+
44388
44491
  // get interior vertices of an interpolated CW arc
44389
44492
  function makeRoundJoin(cx, cy, startBearing, arcAngle, dist) {
44390
44493
  var points = [];
@@ -66085,7 +66188,7 @@ ${svg}
66085
66188
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66086
66189
  }
66087
66190
 
66088
- var version = "0.7.34";
66191
+ var version = "0.7.35";
66089
66192
 
66090
66193
  // Parse command line args into commands and run them
66091
66194
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapshaper",
3
- "version": "0.7.34",
3
+ "version": "0.7.35",
4
4
  "description": "A tool for editing geospatial data for mapping and GIS.",
5
5
  "keywords": [
6
6
  "shapefile",
package/www/index.html CHANGED
@@ -222,8 +222,13 @@ precision=0.001. Click to see all options.</div></div></div></a>
222
222
  <div class="close2-btn"></div>
223
223
  <h3>Display options</h3>
224
224
 
225
- <div class=""><label><input type="checkbox" id="intersections-opt" class="checkbox intersections-opt">detect line intersections</label></div>
226
- <div class=""><label><input type="checkbox" id="ghost-opt" class="checkbox ghost-opt">show reference lines</label></div>
225
+ <div class=""><label><input type="checkbox" id="intersections-opt" class="checkbox intersections-opt">detect line intersections</label><div class="tip-button">?<div class="tip-anchor">
226
+ <div class="tip">Mark points where a layer's lines
227
+ cross or overlap.</div></div></div></div>
228
+ <div class=""><label><input type="checkbox" id="compare-opt" class="checkbox compare-opt">compare with original</label><div class="tip-button">?<div class="tip-anchor">
229
+ <div class="tip">Overlay the original shapes to compare
230
+ before and after an edit. Only works with
231
+ the buffer, smooth and simplify commands.</div></div></div></div>
227
232
 
228
233
  <div class="basemap-opts">
229
234
  <h4>Basemaps</h4>