mapshaper 0.7.34 → 0.7.36

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', {
@@ -32519,6 +32519,14 @@ ${svg}
32519
32519
  type: 'number',
32520
32520
  describe: 'Visvalingam angle-weight coefficient (default 0.7); higher removes spiky detail more eagerly'
32521
32521
  })
32522
+ .option('roundness', {
32523
+ type: 'number',
32524
+ describe: 'protect rounded loops: min enclosed-area / loop-perimeter as a fraction of the distance (default 0.2); higher removes more, 0 disables'
32525
+ })
32526
+ .option('min-area', {
32527
+ type: 'number',
32528
+ describe: 'drop a closed ring (island/hole) when the filter leaves less than this fraction of its original area (default 0.6); 0 disables'
32529
+ })
32522
32530
  .option('planar', {
32523
32531
  describe: 'treat decimal degree coords as planar x,y (default is spherical)',
32524
32532
  type: 'flag'
@@ -33052,6 +33060,13 @@ ${svg}
33052
33060
  describe: 'shrinkage-correction (default 1; 0 = none, >1 exaggerates bends)',
33053
33061
  type: 'number'
33054
33062
  })
33063
+ .option('strength', {
33064
+ // undocumented: multiplier on the smoothing kernel scale relative to the
33065
+ // distance (default 1; >1 smooths more strongly with larger divergence from
33066
+ // the original, <1 more gently). Scales only the low-pass kernel -- corner
33067
+ // detection, prefiltering and island dropping stay keyed to the raw distance.
33068
+ type: 'number'
33069
+ })
33055
33070
  .option('max-bend-angle', {
33056
33071
  describe: 'max bend between output segments in degrees (default is 8)',
33057
33072
  type: 'number'
@@ -33061,10 +33076,15 @@ ${svg}
33061
33076
  type: 'flag'
33062
33077
  })
33063
33078
  .option('corner-bias', {
33064
- // undocumented: corner-preservation sensitivity (default 1). Its inverse
33065
- // scales the min structural-run length, so corner-bias=0.5 doubles it (fewer
33066
- // corners kept) and corner-bias=2 halves it (more corners kept).
33067
- // corner-bias=0 turns corner preservation off entirely.
33079
+ // Sensitivity of corner detection (default 0 = neutral). Positive keeps
33080
+ // more corners, negative fewer; +/- values of equal size are inverses.
33081
+ // Under the hood it scales only the distance-proportional detection
33082
+ // parameters (not angles), detecting corners as if the distance were
33083
+ // divided by k, where k = bias+1 for bias >= 0 and 1/(1-bias) for bias < 0;
33084
+ // the smoothing kernel keeps using the real distance. So corner-bias=-1
33085
+ // finds the corners a 2x distance would, corner-bias=1 those of a 0.5x
33086
+ // distance. Use no-corners to turn corner preservation off entirely.
33087
+ describe: 'corner-detection sensitivity (default 0; + is more sensitive, - is less)',
33068
33088
  type: 'number'
33069
33089
  })
33070
33090
  .option('prefilter-gate', {
@@ -33072,6 +33092,19 @@ ${svg}
33072
33092
  // intricate detail (default 4; higher removes less)
33073
33093
  type: 'number'
33074
33094
  })
33095
+ .option('prefilter-roundness', {
33096
+ // undocumented: prefilter roundness protection -- min enclosed-area /
33097
+ // loop-perimeter (as a fraction of the smoothing distance) for a loop
33098
+ // (e.g. an island) to be protected from the prefilter (default 0.2;
33099
+ // higher removes more, 0 disables and lets rounded islands collapse)
33100
+ type: 'number'
33101
+ })
33102
+ .option('prefilter-min-area', {
33103
+ // undocumented: drop a closed ring (island/hole) when the prefilter would
33104
+ // leave less than this fraction of its original area (default 0.6), rather
33105
+ // than smoothing a distorted remnant; 0 disables the drop
33106
+ type: 'number'
33107
+ })
33075
33108
  .option('planar', {
33076
33109
  // describe: 'smooth decimal degree coords in 2D space (default is spherical)',
33077
33110
  type: 'flag'
@@ -42059,17 +42092,15 @@ ${svg}
42059
42092
  return turnPrefix[posHi] - turnPrefix[posLo];
42060
42093
  }
42061
42094
 
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
42095
+ // Multi-pass collapse. Iterating lets the collapse of tight inner overshoot
42096
+ // loops shorten the spans of the loops that wrap them, so a wrapper whose span
42097
+ // was too long (or too-large-turn, on the gated paths) on one pass can become
42098
+ // collapsible on the next -- the multi-pass "peel simple interior loops first"
42099
+ // idea. With dipTags supplied (clean-outline rings; the tags act as a flag,
42100
+ // see below) each candidate is decided by the exact coverage check plus the
42101
+ // opposite-wound hole guard; otherwise the source-turn or geometric turn gate
42102
+ // applies (maxTurn; caps' 180-degree arcs exceed it and are never collapsed).
42103
+ // @fillFloor (coverage path): max winding-0 area a collapse may swallow; a
42073
42104
  // collapse filling a larger hole/notch is refused (see BUFFER_LOOP_FILL_AREA_FRAC).
42074
42105
  // Callers pass dist^2 * BUFFER_LOOP_FILL_AREA_FRAC; defaults to the uncovered
42075
42106
  // floor when omitted (unit tests without a buffer distance).
@@ -42078,11 +42109,32 @@ ${svg}
42078
42109
  if (maxTurn === undefined) maxTurn = BUFFER_LOOP_MAX_TURN;
42079
42110
  if (!(maxPasses >= 1)) maxPasses = 12;
42080
42111
  if (!(fillFloor >= 0)) fillFloor = BUFFER_LOOP_CHECK_MIN_AREA;
42081
- var work = ring, workPos = null, workTags = dipTags || null;
42112
+ // dipTags acts purely as a flag here: a ring built by the clean-outline
42113
+ // construction opts into the coverage-checked path. The per-vertex tag
42114
+ // values are not consulted (the tag-excluded turn gate was removed in favor
42115
+ // of the exact coverage check), so the tags are not threaded through passes.
42116
+ var coverage = !!dipTags;
42117
+ // Neighborhood clip budget, shared by all passes over this ring: each
42118
+ // accepted collapse's uncovered (clipped) area accumulates in a coarse
42119
+ // grid, and an accept that would push any touched cell past the per-collapse
42120
+ // floor is refused instead. Individually sub-floor clips are the design's
42121
+ // tolerance, but several of them can land in the SAME neighborhood (dense
42122
+ // fold clusters -- routine with coarse bridge geometry, theoretically
42123
+ // possible with overlapping same-pass collapses) and compound into a
42124
+ // floor-scale dent; the budget bounds the damage per neighborhood to the
42125
+ // same floor that bounds it per collapse.
42126
+ // Cell size scales with the buffer radius (recovered from the disk-relative
42127
+ // fillFloor) so a neighborhood is radius-scale at any latitude/units; clip
42128
+ // attribution is O(1) per accept (center cell), so a dent straddling a cell
42129
+ // edge is bounded by 2x the floor rather than 1x.
42130
+ var budgetCell = Math.max(1000, Math.sqrt(fillFloor / BUFFER_LOOP_FILL_AREA_FRAC));
42131
+ var clipBudget = coverage ? {map: new Map(), cell: budgetCell} : null;
42132
+ var work = ring, workPos = null;
42082
42133
  for (var pass = 0; pass < maxPasses; pass++) {
42083
- var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn, workTags, fillFloor);
42134
+ var res = collapseRingLoopsPass(work, maxGap, workPos, turnPrefix, maxTurn,
42135
+ coverage, fillFloor, clipBudget);
42084
42136
  if (res.ring.length === work.length) return res.ring; // stable
42085
- work = res.ring; workPos = res.srcPos; workTags = res.dipTags;
42137
+ work = res.ring; workPos = res.srcPos;
42086
42138
  }
42087
42139
  return work;
42088
42140
  }
@@ -42090,38 +42142,25 @@ ${svg}
42090
42142
  // One greedy forward-collapse pass (mirrors removeBufferRingLoops' compaction).
42091
42143
  // The span gate is, in order of preference:
42092
42144
  // - 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) {
42145
+ // - else the exact coverage check when `coverage` is set (clean-outline rings);
42146
+ // - else the ring's cumulative turn with a geometric cusp threshold.
42147
+ // Returns {ring, srcPos} so the caller can iterate.
42148
+ function collapseRingLoopsPass(ring, maxGap, srcPos, turnPrefix, maxTurn, coverage, fillFloor, clipBudget) {
42098
42149
  var gated = !!(srcPos && turnPrefix);
42099
42150
  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
42151
+ // The coverage path defers entirely to the exact coverage check, so it needs
42152
+ // neither a turn gate nor a cumulative-turn prefix; the other two paths
42102
42153
  // (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;
42154
+ var ringTurn = (!gated && !coverage) ? ringAbsTurnPrefix(ring) : null;
42155
+ // The y-band edge index and the parent orientation are built lazily, on the
42156
+ // first candidate that survives to the coverage check: most rings (and most
42157
+ // passes) never get that far, and both are O(ring length) to compute.
42158
+ var covIndex = null;
42159
+ var parentCCW = false, parentKnown = false;
42119
42160
  var out = [ring[0]];
42120
42161
  var outPos = gated ? [srcPos[0]] : null;
42121
- var outTags = dipTags ? [dipTags[0]] : null;
42122
42162
  var segmentEnd = ring[1];
42123
42163
  var segmentEndPos = gated ? srcPos[1] : 0;
42124
- var segmentEndTag = dipTags ? dipTags[1] : 0;
42125
42164
  var nextRingIndex = 2;
42126
42165
  while (true) {
42127
42166
  var anchor = out[out.length - 1];
@@ -42133,22 +42172,34 @@ ${svg}
42133
42172
  var c = ring[s], d = ring[s + 1];
42134
42173
  var hit = segHit(ax, ay, bx, by, c[0], c[1], d[0], d[1]);
42135
42174
  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).
42175
+ if (coverage) {
42176
+ // Opposite-wound hole protection. The coverage check only guards against
42177
+ // UNCOVERING area, so it cannot catch a collapse that FILLS a real
42178
+ // winding-0 hole (filling adds coverage), and its area pre-filter treats
42179
+ // any sub-floor loop as safe to drop. A dropped sub-loop wound OPPOSITE
42180
+ // to the parent ring bounds such a hole, so refuse the collapse outright
42181
+ // -- this keeps real holes (annulus interiors, self-crossing-line
42182
+ // pockets) the coverage check alone misses, at the cost of only an
42183
+ // O(span) signed-area test on the crossings the collapse actually
42184
+ // evaluates. A same-wound overshoot fold that happens to WRAP a hole is
42185
+ // caught instead by the fill guard in the coverage check.
42186
+ if (!parentKnown) {
42187
+ parentCCW = ringSignedArea(ring) >= 0;
42188
+ parentKnown = true;
42189
+ }
42140
42190
  if ((loopAreaSign(hit[0], hit[1], bx, by, ring, nextRingIndex, s) >= 0) !== parentCCW) {
42141
42191
  continue;
42142
42192
  }
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)) {
42193
+ // No turn gate on the coverage path: the exact coverage check is the
42194
+ // arbiter. It measures how much of the dropped region would become
42195
+ // uncovered and refuses collapses that would clip a significant lobe
42196
+ // (see docs/development/buffer-line-notes.md), catching real lobes and
42197
+ // end caps that a cheap turn/area/winding signal cannot separate from
42198
+ // safe folds. The turn gate was both leaving valid interior loops
42199
+ // uncollapsed (tight hairpins whose source turn exceeds the cap) and
42200
+ // slowing the pipeline by deferring their removal to the dissolve.
42201
+ if (!covIndex) covIndex = buildEdgeYIndex(ring, n);
42202
+ if (!collapseKeepsAreaCovered(ring, n, hit, segmentEnd, nextRingIndex, s, covIndex, fillFloor, clipBudget)) {
42152
42203
  continue; // dropping this loop would uncover or fill real area -- leave it
42153
42204
  }
42154
42205
  } else {
@@ -42166,24 +42217,20 @@ ${svg}
42166
42217
  if (crossing) {
42167
42218
  segmentEnd = crossing;
42168
42219
  if (gated) segmentEndPos = anchorPos;
42169
- segmentEndTag = 0; // an offset crossing is not a dip vertex
42170
42220
  nextRingIndex = crossingEndIndex;
42171
42221
  continue;
42172
42222
  }
42173
42223
  out.push(segmentEnd);
42174
42224
  if (gated) outPos.push(segmentEndPos);
42175
- if (dipTags) outTags.push(segmentEndTag);
42176
42225
  if (nextRingIndex > n - 1) break;
42177
42226
  segmentEnd = ring[nextRingIndex];
42178
42227
  if (gated) segmentEndPos = srcPos[nextRingIndex];
42179
- if (dipTags) segmentEndTag = dipTags[nextRingIndex];
42180
42228
  nextRingIndex++;
42181
42229
  }
42182
- if (out.length < 4) return {ring: ring, srcPos: srcPos, dipTags: dipTags};
42230
+ if (out.length < 4) return {ring: ring, srcPos: srcPos}; // collapsed away; keep original
42183
42231
  out.push(out[0].concat());
42184
42232
  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};
42233
+ return {ring: out, srcPos: gated ? outPos : null};
42187
42234
  }
42188
42235
 
42189
42236
  // Cumulative absolute turn (degrees) indexed by ring vertex, EXCLUDING fold
@@ -42191,17 +42238,13 @@ ${svg}
42191
42238
  // near-180-degree cusps where the offset doubles back on itself (an artifact of
42192
42239
  // the self-crossing offset, with no corresponding source bend). A normal offset
42193
42240
  // 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.
42241
+ // the source stretch's cumulative turn -- the loop-removal signal -- without
42242
+ // source provenance. Only the provenance-free geometric path uses this (the
42243
+ // clean-outline rings use the exact coverage check instead); the purely
42244
+ // geometric cusp threshold can over-collapse real sharp bends (e.g. a fjord
42245
+ // mouth), which is one reason the coverage check replaced it as the default.
42203
42246
  var CUSP_TURN = 135;
42204
- function ringAbsTurnPrefix(ring, dipTags) {
42247
+ function ringAbsTurnPrefix(ring) {
42205
42248
  var n = ring.length - 1;
42206
42249
  var prefix = new Float64Array(n + 1);
42207
42250
  var RAD = 180 / Math.PI;
@@ -42212,8 +42255,7 @@ ${svg}
42212
42255
  var cross = e1x * e2y - e1y * e2x;
42213
42256
  var dot = e1x * e2x + e1y * e2y;
42214
42257
  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);
42258
+ prefix[k] = prefix[k - 1] + (ang > CUSP_TURN ? 0 : ang);
42217
42259
  }
42218
42260
  prefix[n] = prefix[n - 1];
42219
42261
  return prefix;
@@ -42261,12 +42303,14 @@ ${svg}
42261
42303
  // The threshold is in ring units; under web Mercator the scale factor is >= 1
42262
42304
  // everywhere, so it is an upper bound on the real m^2 area and no genuinely large
42263
42305
  // clip is skipped regardless of latitude.
42264
- function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor) {
42306
+ function collapseKeepsAreaCovered(ring, n, X, b, nextRingIndex, s, index, fillFloor, clipBudget) {
42265
42307
  // 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.)
42308
+ // nor fill more than its own (absolute winding) area, so it is safe to skip
42309
+ // whenever the loop is below BOTH thresholds; collapseUncoveredArea guards the
42310
+ // self-crossing case where the net shoelace under-reports that area. (Filling
42311
+ // a winding-0 region never reaches the uncovered floor, so the fill floor must
42312
+ // join the skip test -- otherwise small folds that could fill a small hole
42313
+ // would be skipped, or every collapse would be scanned.)
42270
42314
  var floor = fillFloor < BUFFER_LOOP_CHECK_MIN_AREA ? fillFloor : BUFFER_LOOP_CHECK_MIN_AREA;
42271
42315
  var u = collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor);
42272
42316
  if (u < 0) return true; // loop below both floors -- too small to clip or fill
@@ -42275,33 +42319,68 @@ ${svg}
42275
42319
  // fill floor scales with the buffer disk (dist^2) because a real hole's area is
42276
42320
  // a fixed fraction of the disk while a fold sliver is orders of magnitude
42277
42321
  // smaller relative to the radius (see BUFFER_LOOP_FILL_AREA_FRAC).
42278
- return u < BUFFER_LOOP_CHECK_MIN_AREA && _lastFillArea < fillFloor;
42322
+ if (!(u < BUFFER_LOOP_CHECK_MIN_AREA && _lastFillArea < fillFloor)) return false;
42323
+ // Neighborhood budget (see removeBufferRingLoopsIterative): a sub-floor clip
42324
+ // is only accepted while its neighborhood's accumulated clips stay under the
42325
+ // floor. Zero-clip collapses (the common case) skip this entirely.
42326
+ if (u > 0 && clipBudget) {
42327
+ // Key on the clipped region's own centroid (accumulated by the sweep):
42328
+ // overlapping clips from different collapses then share a cell even when
42329
+ // their loops' bboxes differ by kilometers. Two dents straddling a cell
42330
+ // edge can still each spend a budget, so the worst-case neighborhood dent
42331
+ // is 2x the per-collapse floor.
42332
+ var cell = clipBudget.cell;
42333
+ var key = Math.floor(_lastClipX / cell) + ':' + Math.floor(_lastClipY / cell);
42334
+ var spent = clipBudget.map.get(key) || 0;
42335
+ if (spent + u >= BUFFER_LOOP_CHECK_MIN_AREA) return false;
42336
+ clipBudget.map.set(key, spent + u);
42337
+ }
42338
+ return true;
42279
42339
  }
42280
42340
 
42281
42341
  // 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.
42342
+ // when the loop is provably too small to matter (scanline skipped). See
42343
+ // collapseKeepsAreaCovered for the winding rationale.
42344
+ //
42345
+ // The skip must not trust the net shoelace area alone: a SELF-CROSSING span
42346
+ // (figure-eight) has lobes of opposite winding whose signed areas cancel, so a
42347
+ // near-zero net can hide winding regions far above the floor. |net| bounds the
42348
+ // regions only for a simple loop; the bbox bounds them always. So the scanline
42349
+ // is skipped when the bbox is under the floor, or when the net is under the
42350
+ // floor AND the loop has no self-crossing (O(span^2) pairwise test, span <=
42351
+ // maxGap+2 edges -- run only in the suspicious small-net/large-bbox band).
42284
42352
  function collapseUncoveredArea(ring, n, X, b, nextRingIndex, s, index, floor) {
42285
42353
  var i, loopLen = s - nextRingIndex + 3; // X, b, ring[next..s]
42286
42354
  // Loop area (shoelace over X, b, ring[next..s]) and bounding box.
42287
42355
  var area2 = 0, px = X[0], py = X[1];
42288
42356
  var minx = X[0], maxx = X[0], miny = X[1], maxy = X[1];
42289
- function bbox(qx, qy) {
42357
+ var qx = b[0], qy = b[1];
42358
+ if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42359
+ if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42360
+ area2 += px * qy - qx * py; px = qx; py = qy;
42361
+ for (i = nextRingIndex; i <= s; i++) {
42362
+ qx = ring[i][0]; qy = ring[i][1];
42363
+ area2 += px * qy - qx * py; px = qx; py = qy;
42290
42364
  if (qx < minx) minx = qx; else if (qx > maxx) maxx = qx;
42291
42365
  if (qy < miny) miny = qy; else if (qy > maxy) maxy = qy;
42292
42366
  }
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
42367
  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.
42368
+ var smallNet = Math.abs(area2) / 2 < floor;
42369
+ if (smallNet && (maxx - minx) * (maxy - miny) < floor) return -1;
42303
42370
  var scr = coverageScratch(n + loopLen);
42304
42371
  var lx0 = scr.lx0, ly0 = scr.ly0, lx1 = scr.lx1, ly1 = scr.ly1;
42372
+ // Loop edges (X->b, b->ring[next..s], ring[s]->X), built before the band
42373
+ // collection so the self-cross test can run first.
42374
+ var lc = 0;
42375
+ lx0[lc] = X[0]; ly0[lc] = X[1]; lx1[lc] = b[0]; ly1[lc] = b[1]; lc++;
42376
+ lx0[lc] = b[0]; ly0[lc] = b[1]; lx1[lc] = ring[nextRingIndex][0]; ly1[lc] = ring[nextRingIndex][1]; lc++;
42377
+ for (i = nextRingIndex; i < s; i++) {
42378
+ lx0[lc] = ring[i][0]; ly0[lc] = ring[i][1]; lx1[lc] = ring[i + 1][0]; ly1[lc] = ring[i + 1][1]; lc++;
42379
+ }
42380
+ lx0[lc] = ring[s][0]; ly0[lc] = ring[s][1]; lx1[lc] = X[0]; ly1[lc] = X[1]; lc++;
42381
+ if (smallNet && !loopEdgesCross(lx0, ly0, lx1, ly1, lc)) return -1; // simple: |net| == area
42382
+ // Collect the ring edges whose y-range meets the loop's band (the only ones
42383
+ // that can cross any scanline); reuse module scratch to avoid per-call garbage.
42305
42384
  var band = scr.band, le = 0;
42306
42385
  var gen = ++index.gen, stamp = index.stamp, start = index.start, edges = index.edges;
42307
42386
  var kb, klo = index.binOf(miny), khi = index.binOf(maxy);
@@ -42316,17 +42395,23 @@ ${svg}
42316
42395
  band[le++] = ei;
42317
42396
  }
42318
42397
  }
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
42398
  return loopUncoveredArea(ring, band, le, lx0, ly0, lx1, ly1, lc, minx, maxx, miny, maxy, scr);
42328
42399
  }
42329
42400
 
42401
+ // True if any two non-adjacent loop edges cross (strict interior). Adjacent
42402
+ // edges share an endpoint and cannot strictly cross, so they are skipped.
42403
+ function loopEdgesCross(lx0, ly0, lx1, ly1, lc) {
42404
+ for (var i = 0; i < lc - 1; i++) {
42405
+ for (var j = i + 2; j < lc; j++) {
42406
+ if (i === 0 && j === lc - 1) continue; // adjacent via closure at X
42407
+ if (segHit(lx0[i], ly0[i], lx1[i], ly1[i], lx0[j], ly0[j], lx1[j], ly1[j])) {
42408
+ return true;
42409
+ }
42410
+ }
42411
+ }
42412
+ return false;
42413
+ }
42414
+
42330
42415
  // y-band index of ring edges: bins the ring's y-range so a scanline query at
42331
42416
  // [miny, maxy] returns only edges reaching that band instead of scanning all n.
42332
42417
  // start[k]..start[k+1] are indices into `edges` for bin k; `stamp`/`gen` dedup an
@@ -42383,16 +42468,24 @@ ${svg}
42383
42468
  // Returns the uncovered area and writes the filled area to _lastFillArea.
42384
42469
  // `band` lists the indices of ring edges that can reach the loop's y-band.
42385
42470
  var _lastFillArea = 0;
42471
+ var _lastClipX = 0, _lastClipY = 0;
42386
42472
  function loopUncoveredArea(ring, band, be, lx0, ly0, lx1, ly1, le, minx, maxx, miny, maxy, scr) {
42387
42473
  var h = maxy - miny;
42388
42474
  _lastFillArea = 0;
42389
42475
  if (h <= 0 || maxx <= minx) return 0;
42476
+ // Known limitation: rows are uniform with a hard cap, so dy scales with the
42477
+ // loop's bbox height and a region thinner than dy in y can fall between row
42478
+ // midpoints unmeasured (only reachable via a loop whose bbox is much taller
42479
+ // than the region -- not observed outside synthetic data). A designed fix
42480
+ // (vertex-guided rows) was implemented, measured at 4-8% of buffer build
42481
+ // time, and reverted; see "Scanline row starvation" in
42482
+ // docs/development/buffer-line-notes.md to re-add it if it is ever needed.
42390
42483
  var target = Math.sqrt(BUFFER_LOOP_CHECK_MIN_AREA) / 4;
42391
42484
  var rows = Math.round(h / (target > 0 ? target : h));
42392
42485
  if (rows < 8) rows = 8; else if (rows > 40) rows = 40;
42393
42486
  var dy = h / rows;
42394
42487
  var xs = scr.xs, df = scr.df, dl = scr.dl, order = scr.order;
42395
- var total = 0, fill = 0, r, k, i;
42488
+ var total = 0, fill = 0, momX = 0, momY = 0, r, k, i;
42396
42489
  for (r = 0; r < rows; r++) {
42397
42490
  var y = miny + (r + 0.5) * dy;
42398
42491
  // Winding just left of the loop's x-range (base), plus the crossings that
@@ -42421,17 +42514,24 @@ ${svg}
42421
42514
  for (i = 0; i < m; i++) {
42422
42515
  var o = order[i], xk = xs[o];
42423
42516
  if (wl !== 0 && xk > prevx) {
42424
- if (wf - wl === 0) total += xk - prevx;
42517
+ if (wf - wl === 0) {
42518
+ total += xk - prevx;
42519
+ momX += (xk + prevx) / 2 * (xk - prevx); momY += y * (xk - prevx);
42520
+ }
42425
42521
  else if (wf === 0) fill += xk - prevx;
42426
42522
  }
42427
42523
  wf += df[o]; wl += dl[o]; prevx = xk;
42428
42524
  }
42429
42525
  if (wl !== 0 && maxx > prevx) {
42430
- if (wf - wl === 0) total += maxx - prevx;
42526
+ if (wf - wl === 0) {
42527
+ total += maxx - prevx;
42528
+ momX += (maxx + prevx) / 2 * (maxx - prevx); momY += y * (maxx - prevx);
42529
+ }
42431
42530
  else if (wf === 0) fill += maxx - prevx;
42432
42531
  }
42433
42532
  }
42434
42533
  _lastFillArea = fill * dy;
42534
+ if (total > 0) { _lastClipX = momX / total; _lastClipY = momY / total; }
42435
42535
  return total * dy;
42436
42536
  }
42437
42537
 
@@ -43151,9 +43251,9 @@ ${svg}
43151
43251
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
43152
43252
  // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
43153
43253
  // 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.
43254
+ // traceCleanOffsetSide. Larger = fewer points = faster dissolve. NOTE: a
43255
+ // surviving (uncollapsed) bridge CAN become output boundary, where this step
43256
+ // sets the dent depth -- see the caution at makeCoarseConcaveJoin.
43157
43257
  var CLEAN_OUTLINE_BRIDGE_STEP = 90;
43158
43258
  var capStyle = opts.cap_style || 'round'; // expect 'round' or 'flat'
43159
43259
  var pathIter = useMercator ?
@@ -44159,11 +44259,38 @@ ${svg}
44159
44259
  } else {
44160
44260
  add(p2Prev, segId, 1);
44161
44261
  joinPoints = concaveJoin(x1, y1, bearing - 90, -joinAngle, dist);
44262
+ var exposedWedge = false;
44263
+ if (!opts.coarse_bridge) {
44264
+ // Exposure-gated coarse bridge (default): a dip may be emitted
44265
+ // coarsely only when it cannot affect the output.
44266
+ // 1. Exposure gate: probe the full-resolution arc + tips against
44267
+ // the source path (wedgeIsExposed; each probe uses its own
44268
+ // radius with a 0.98 margin, erring toward "exposed" = the
44269
+ // full arc). An uncovered arc point is potential true output
44270
+ // boundary -- an exposed dip that the loop remover refuses to
44271
+ // collapse IS the boundary there, so it must stay at full
44272
+ // resolution (see the caution at makeCoarseConcaveJoin).
44273
+ // Collapsed coarse dips legally clip up to the remover's
44274
+ // per-collapse floor (the chord-to-arc lens is single-covered
44275
+ // where only the fold's own winding spans it); the remover's
44276
+ // neighborhood clip budget keeps clustered dips from
44277
+ // compounding several such clips into one visible dent (a
44278
+ // 102k m^2 dent on the innerlines 2km Sabine River fold
44279
+ // cluster before the budget existed).
44280
+ if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44281
+ exposedWedge = wedgeIsExposed(vertsSegIndex, segId - 1, segId,
44282
+ x1, y1, joinPoints, p2Prev, p1);
44283
+ if (!exposedWedge) {
44284
+ joinPoints = makeCoarseConcaveJoin(x1, y1, bearing - 90, -joinAngle, dist);
44285
+ }
44286
+ }
44162
44287
  if (useGapPatch(opts, useMercator) &&
44163
44288
  offsetEdgesFanApart(p1Prev, p2Prev, p1, p2)) {
44164
44289
  if (!vertsSegIndex) vertsSegIndex = buildVertsSegmentIndex(verts);
44165
- if (wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44166
- joinPoints, p2Prev, p1)) {
44290
+ if (opts.coarse_bridge ?
44291
+ wedgeIsExposed(vertsSegIndex, segId - 1, segId, x1, y1,
44292
+ joinPoints, p2Prev, p1) :
44293
+ exposedWedge) {
44167
44294
  fanApartBends.push(segId);
44168
44295
  }
44169
44296
  }
@@ -44367,13 +44494,21 @@ ${svg}
44367
44494
  return makeRoundJoin(cx, cy, startBearing, arcAngle, dist).reverse();
44368
44495
  }
44369
44496
 
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.
44497
+ // Coarse alternative to makeConcaveJoin: bridges a concave bend with as few
44498
+ // as one reversed arc vertex (CLEAN_OUTLINE_BRIDGE_STEP), producing a
44499
+ // smaller ring for the winding dissolve to chew through. Used by default
44500
+ // only behind the exposure gate above (plus the loop remover's neighborhood
44501
+ // clip budget); opts.coarse_bridge forces it everywhere, unguarded.
44502
+ // CAUTION -- unsound WITHOUT those guards (2026-07-02 eval, see
44503
+ // "coarse-bridge" in docs/development/buffer-line-notes.md): a dip that the
44504
+ // loop remover refuses to collapse can be real output boundary (near-U-turn
44505
+ // bends whose wedge nothing else covers; single-sided polygon grows; hole
44506
+ // boundaries), and there the coarse chords replace the true arc. Chord
44507
+ // vertices stay on the radius-dist circle, so coarse geometry never falls
44508
+ // OUTSIDE the true buffer -- the failures are inward dents (sagitta up to
44509
+ // (1-cos45)*dist ~ 0.29*dist at the 90-degree step) and spurious holes
44510
+ // (chord-triangle slivers whose at-radius vertices defeat the artifact-hole
44511
+ // filter's distance classification).
44377
44512
  function makeCoarseConcaveJoin(cx, cy, startBearing, arcAngle, dist) {
44378
44513
  var segs = Math.min(roundJoinSegsPerQuadrant,
44379
44514
  Math.max(1, Math.ceil(arcAngle / CLEAN_OUTLINE_BRIDGE_STEP)));
@@ -44385,6 +44520,7 @@ ${svg}
44385
44520
  return points.reverse();
44386
44521
  }
44387
44522
 
44523
+
44388
44524
  // get interior vertices of an interpolated CW arc
44389
44525
  function makeRoundJoin(cx, cy, startBearing, arcAngle, dist) {
44390
44526
  var points = [];
@@ -58231,18 +58367,55 @@ ${svg}
58231
58367
  // threshold) -- a jetty, fjord or crinkle. Otherwise restore the run's
58232
58368
  // original vertices, so gentle stretches keep full detail for -smooth.
58233
58369
  //
58370
+ // A ROUNDNESS gate protects substantial, rounded loops from both the merge pass
58371
+ // and the commit. Tortuosity (path length / chord) alone cannot tell a thin
58372
+ // needle from a round bulge -- both can be highly tortuous, and a closing chord
58373
+ // of length ~0 makes tortuosity infinite for either. Worst case: a closed ring
58374
+ // stores its start and end vertex at the same coordinate, so the seam chord
58375
+ // (vertex 0 -> vertex n-1) has length 0 and infinite tortuosity, which used to
58376
+ // make the merge pass splice out the entire ring -- destroying every island
58377
+ // whose perimeter fit inside the merge window, regardless of size or roundness.
58378
+ // The gate distinguishes them by the enclosed area: for a candidate section
58379
+ // closed by its chord, protect it when area / loop-perimeter >= roundness * D
58380
+ // (the isoperimetric area-to-perimeter ratio, biased by the detail distance). A
58381
+ // thin needle encloses ~0 area (never protected -> still cut); a round island
58382
+ // encloses a large area (protected). Because area/perimeter equals radius/2 for
58383
+ // a circle, the default gate protects circular features at or above the detail
58384
+ // resolution and preferentially removes smaller or less-round ones.
58385
+ //
58234
58386
  // @xx, @yy coordinate arrays for one arc (may be typed-array subarrays).
58235
- // @opts: {distance, tortuosity, spherical, weighting}
58387
+ // @opts: {distance, tortuosity, spherical, weighting, roundness}
58236
58388
  // distance detail size threshold in ground units (meters when spherical): the
58237
58389
  // longest chord the filter is allowed to create.
58238
58390
  // tortuosity min original-length / chord ratio for a run to be cut (default 2).
58239
58391
  // spherical measure area/length on the sphere (lng/lat -> geocentric x,y,z).
58240
58392
  // weighting Visvalingam angle-weight coefficient (default 0.7), matching
58241
58393
  // -simplify's weighted_visvalingam.
58394
+ // roundness min enclosed-area / loop-perimeter (as a fraction of the detail
58395
+ // distance) for a loop to be protected from removal (default 0.2);
58396
+ // higher removes more, 0 disables the protection.
58242
58397
  // Returns {xx: [], yy: []}. Arc endpoints are always preserved, so shared
58243
58398
  // topology nodes stay put and the operation is topology-safe like -simplify.
58244
58399
  var DEFAULT_WEIGHTING = 0.7;
58245
58400
  var DEFAULT_TORTUOSITY = 4;
58401
+ // Protect a candidate loop from collapse when its enclosed-area / loop-perimeter
58402
+ // exceeds this fraction of the detail distance D. For a circle area/perimeter =
58403
+ // radius/2, so 0.2 protects circular features of diameter >= ~D and drops finer
58404
+ // or less-round detail; a thin needle (area ~ 0) is never protected.
58405
+ var DEFAULT_ROUNDNESS = 0.2;
58406
+ // A closed ring (island, lake or hole) is dropped entirely when the filter would
58407
+ // leave less than this fraction of its original enclosed area. The roundness gate
58408
+ // stops a substantial ring from being merged away wholesale, but it is evaluated
58409
+ // per candidate span, so cutRun can still slice off convoluted sub-spans of the
58410
+ // perimeter and shrink a near-scale island to a small, distorted remnant. Once
58411
+ // most of the area is gone the remnant no longer faithfully represents the island
58412
+ // and a clean drop is better than a mangled sliver -- so if the survivors enclose
58413
+ // less than this share of the original area, collapse the ring to its degenerate
58414
+ // seam (like a fully sub-scale ring) and let the pipeline discard it. Only closed
58415
+ // rings are affected; open arcs and shared boundaries are never dropped, so the
58416
+ // operation stays topology-safe. Set to 0 to disable. The default 0.6 keeps a
58417
+ // ring only while the filter removes at most ~40% of its area.
58418
+ var DEFAULT_MIN_RING_AREA = 0.6;
58246
58419
  // How far (in detail-distances of arc length) the survivor-merge pass looks ahead
58247
58420
  // for a chord that closes a convoluted excursion. Bounds the pass to O(n) and
58248
58421
  // caps how long a thin spike it can slice in one merge.
@@ -58256,7 +58429,7 @@ ${svg}
58256
58429
 
58257
58430
  function collapseArcDetail(xx, yy, opts) {
58258
58431
  var n = xx.length;
58259
- var outX = [], outY = [];
58432
+ var outX = [], outY = [], outIdx = [];
58260
58433
  var D = opts.distance;
58261
58434
  if (n < 3 || !(D > 0)) {
58262
58435
  for (var p = 0; p < n; p++) { outX.push(xx[p]); outY.push(yy[p]); }
@@ -58265,6 +58438,12 @@ ${svg}
58265
58438
  var spherical = !!opts.spherical;
58266
58439
  var k = opts.weighting >= 0 ? opts.weighting : DEFAULT_WEIGHTING;
58267
58440
  var T = opts.tortuosity > 0 ? opts.tortuosity : DEFAULT_TORTUOSITY;
58441
+ // roundness >= 0; 0 disables the roundness protection (legacy behavior).
58442
+ var R = opts.roundness >= 0 ? opts.roundness : DEFAULT_ROUNDNESS;
58443
+ // minRingArea >= 0; 0 disables the drop-shredded-ring gate.
58444
+ var minRingArea = opts.minRingArea >= 0 ? opts.minRingArea : DEFAULT_MIN_RING_AREA;
58445
+ // A closed ring stores its start and end vertex at the same coordinate.
58446
+ var isRing = n >= 4 && xx[0] === xx[n - 1] && yy[0] === yy[n - 1];
58268
58447
  var Dsq = D * D;
58269
58448
 
58270
58449
  // Metric coordinates: geocentric x,y,z on a sphere for lng/lat input, plain
@@ -58342,6 +58521,52 @@ ${svg}
58342
58521
  var cumLen = new Float64Array(n);
58343
58522
  for (var q = 1; q < n; q++) cumLen[q] = cumLen[q - 1] + dist(q - 1, q);
58344
58523
 
58524
+ // Cumulative cross products of successive position vectors (in a frame
58525
+ // translated to vertex 0, to keep magnitudes small and the subtraction
58526
+ // precise) give the enclosed area of any section i..j closed by its chord in
58527
+ // O(1). The vector-area form (0.5 * |sum of edge cross products + closing
58528
+ // term|) is origin-independent for a closed loop, so it works unchanged for
58529
+ // the geocentric 3D coords used in spherical mode and the plain 2D coords
58530
+ // otherwise. Only used by the roundness gate below.
58531
+ var ox = mx[0], oy = my[0], oz = spherical ? mz[0] : 0;
58532
+ var cumCz = new Float64Array(n), cumCx, cumCy;
58533
+ if (spherical) { cumCx = new Float64Array(n); cumCy = new Float64Array(n); }
58534
+ for (var t = 1; t < n; t++) {
58535
+ var ax = mx[t - 1] - ox, ay = my[t - 1] - oy, bx = mx[t] - ox, by = my[t] - oy;
58536
+ if (spherical) {
58537
+ var az = mz[t - 1] - oz, bz = mz[t] - oz;
58538
+ cumCx[t] = cumCx[t - 1] + (ay * bz - az * by);
58539
+ cumCy[t] = cumCy[t - 1] + (az * bx - ax * bz);
58540
+ cumCz[t] = cumCz[t - 1] + (ax * by - ay * bx);
58541
+ } else {
58542
+ cumCz[t] = cumCz[t - 1] + (ax * by - ay * bx);
58543
+ }
58544
+ }
58545
+
58546
+ // Area enclosed by the section vertices i..j closed by the chord j->i.
58547
+ function sectionArea(i, j) {
58548
+ var aix = mx[i] - ox, aiy = my[i] - oy, ajx = mx[j] - ox, ajy = my[j] - oy;
58549
+ if (spherical) {
58550
+ var aiz = mz[i] - oz, ajz = mz[j] - oz;
58551
+ var vx = (cumCx[j] - cumCx[i]) + (ajy * aiz - ajz * aiy);
58552
+ var vy = (cumCy[j] - cumCy[i]) + (ajz * aix - ajx * aiz);
58553
+ var vz = (cumCz[j] - cumCz[i]) + (ajx * aiy - ajy * aix);
58554
+ return 0.5 * Math.sqrt(vx * vx + vy * vy + vz * vz);
58555
+ }
58556
+ return 0.5 * Math.abs((cumCz[j] - cumCz[i]) + (ajx * aiy - ajy * aix));
58557
+ }
58558
+
58559
+ // A candidate section (i..j) is protected from collapse when the loop it forms
58560
+ // with its closing chord is a substantial, rounded feature: enclosed area per
58561
+ // unit loop-perimeter reaches the roundness fraction of the detail distance.
58562
+ // A thin needle encloses ~0 area (area/perimeter ~ 0) and is never protected;
58563
+ // a round bulge or island encloses enough area to clear the gate.
58564
+ function isProtected(i, j) {
58565
+ if (!(R > 0)) return false;
58566
+ var perim = (cumLen[j] - cumLen[i]) + dist(i, j);
58567
+ return perim > 0 && sectionArea(i, j) / perim >= R * D;
58568
+ }
58569
+
58345
58570
  function cutRun(a, e) {
58346
58571
  // Emit the kept vertices in (a, e]; a has already been emitted. Walk from a
58347
58572
  // and, at each step, find the span [i, j] (chord <= D) with the highest
@@ -58358,7 +58583,8 @@ ${svg}
58358
58583
  var c = dist(i, j);
58359
58584
  if (c > D) continue; // never create a chord longer than the detail scale
58360
58585
  var tort = c > 0 ? (cumLen[j] - cumLen[i]) / c : Infinity;
58361
- if (tort > bestTort) {
58586
+ // roundness gate: keep substantial, rounded bulges at full detail
58587
+ if (tort > bestTort && !isProtected(i, j)) {
58362
58588
  bestTort = tort;
58363
58589
  bestJ = j;
58364
58590
  }
@@ -58366,10 +58592,12 @@ ${svg}
58366
58592
  if (bestJ >= 0) {
58367
58593
  outX.push(xx[bestJ]);
58368
58594
  outY.push(yy[bestJ]);
58595
+ outIdx.push(bestJ);
58369
58596
  i = bestJ;
58370
58597
  } else {
58371
58598
  outX.push(xx[i + 1]);
58372
58599
  outY.push(yy[i + 1]);
58600
+ outIdx.push(i + 1);
58373
58601
  i++;
58374
58602
  }
58375
58603
  }
@@ -58399,7 +58627,9 @@ ${svg}
58399
58627
  if (chordSq(s, u) <= mergeChordSq) {
58400
58628
  var ud = dist(s, u);
58401
58629
  var utort = ud > 0 ? (cumLen[u] - cumLen[s]) / ud : Infinity;
58402
- if (utort > mergeTort) {
58630
+ // roundness gate: a rounded loop (e.g. a closed ring closing on its own
58631
+ // zero-length seam) is a real feature, not a needle -- never merge it.
58632
+ if (utort > mergeTort && !isProtected(s, u)) {
58403
58633
  mergeTort = utort;
58404
58634
  mergeJ = u;
58405
58635
  }
@@ -58418,13 +58648,49 @@ ${svg}
58418
58648
 
58419
58649
  outX.push(xx[0]);
58420
58650
  outY.push(yy[0]);
58651
+ outIdx.push(0);
58421
58652
  var a = 0;
58422
58653
  while (a !== n - 1) {
58423
58654
  var e = next[a];
58424
58655
  cutRun(a, e);
58425
58656
  a = e;
58426
58657
  }
58658
+
58659
+ // Drop a closed ring that the filter has shredded to a small remnant: if the
58660
+ // survivors enclose less than minRingArea of the original ring's area, collapse
58661
+ // it to its degenerate seam so the pipeline discards it (a clean drop rather
58662
+ // than a distorted sliver). Uses the survivors' metric coordinates for the
58663
+ // filtered area and the whole-ring section area for the original.
58664
+ if (isRing && minRingArea > 0) {
58665
+ var origArea = sectionArea(0, n - 1);
58666
+ if (origArea > 0 && ringAreaByIdx(outIdx) < minRingArea * origArea) {
58667
+ return {xx: [xx[0], xx[n - 1]], yy: [yy[0], yy[n - 1]]};
58668
+ }
58669
+ }
58427
58670
  return {xx: outX, yy: outY};
58671
+
58672
+ // Enclosed area of the closed ring formed by the survivor vertices (given as
58673
+ // indices into the original arc), measured in the same metric space as
58674
+ // sectionArea via the origin-independent vector-area form.
58675
+ function ringAreaByIdx(idx) {
58676
+ var m = idx.length;
58677
+ if (m < 4) return 0;
58678
+ var o0x = mx[idx[0]], o0y = my[idx[0]], o0z = spherical ? mz[idx[0]] : 0;
58679
+ var vx = 0, vy = 0, vz = 0;
58680
+ for (var t = 1; t < m; t++) {
58681
+ var p = idx[t - 1], q = idx[t];
58682
+ var ax = mx[p] - o0x, ay = my[p] - o0y, bx = mx[q] - o0x, by = my[q] - o0y;
58683
+ if (spherical) {
58684
+ var az = mz[p] - o0z, bz = mz[q] - o0z;
58685
+ vx += ay * bz - az * by;
58686
+ vy += az * bx - ax * bz;
58687
+ vz += ax * by - ay * bx;
58688
+ } else {
58689
+ vz += ax * by - ay * bx;
58690
+ }
58691
+ }
58692
+ return spherical ? 0.5 * Math.sqrt(vx * vx + vy * vy + vz * vz) : 0.5 * Math.abs(vz);
58693
+ }
58428
58694
  }
58429
58695
 
58430
58696
  // Optional preprocessing step before -smooth: collapse intricate sub-scale
@@ -58455,6 +58721,8 @@ ${svg}
58455
58721
  distance: distance,
58456
58722
  tortuosity: opts.tortuosity,
58457
58723
  weighting: opts.weighting,
58724
+ roundness: opts.roundness,
58725
+ minRingArea: opts.min_area,
58458
58726
  spherical: spherical
58459
58727
  });
58460
58728
  var removed = before - arcs.getPointCount();
@@ -58478,6 +58746,8 @@ ${svg}
58478
58746
  distance: opts.distance,
58479
58747
  tortuosity: opts.tortuosity,
58480
58748
  weighting: opts.weighting,
58749
+ roundness: opts.roundness,
58750
+ minRingArea: opts.minRingArea,
58481
58751
  spherical: opts.spherical
58482
58752
  });
58483
58753
  nn.push(res.xx.length);
@@ -63320,7 +63590,7 @@ ${svg}
63320
63590
  });
63321
63591
 
63322
63592
  // Structural-corner detection for -smooth's corner preservation (on by default;
63323
- // disabled with no-corners or corner-bias=0).
63593
+ // disabled with no-corners).
63324
63594
  //
63325
63595
  // Many boundaries alternate between natural, freely-curving stretches (coast,
63326
63596
  // river centerline) and artificial straight-line segments (state/county
@@ -63354,21 +63624,112 @@ ${svg}
63354
63624
  var INNER_WINDOW_FACTOR = 0.4; // concentration probe window = tangentWindow * this
63355
63625
  var CORNER_CONCENTRATION = 0.6; // min ratio of inner-window turn to full-window turn
63356
63626
  var MIN_RUN_LEN_FACTOR = 1.0; // a structural run must be at least tol * this long
63357
- var MIN_RUN_RADIUS_FACTOR = 1.0; // and bend no tighter than radius tol * this
63358
-
63359
- // @cornerBias (optional, default 1) divides the min structural-run length, so a
63360
- // value < 1 lengthens the run a corner must border to be preserved (fewer, only
63361
- // well-supported corners), and a value > 1 shortens it (more corners kept).
63627
+ // ...and bend no tighter than radius tol * this. This is the curvature gate for
63628
+ // "structural" (straight or slowly-curving, e.g. a surveyed border or graticule
63629
+ // arc). It must be well above 1: at factor 1 a minimal run may turn a full
63630
+ // radian (~57 deg) over its own length, so ordinary coastal arcs qualify and
63631
+ // their end bends get pinned as spurious corners (radius ~1-1.6*tol). At 3 a
63632
+ // minimal run turns <= ~19 deg, excluding natural coastal curvature while still
63633
+ // admitting genuinely straight borders (radius ~infinite) and graticule arcs
63634
+ // (radius >> tol).
63635
+ var MIN_RUN_RADIUS_FACTOR = 3.0;
63636
+
63637
+ // Straightness gate used to decide corner *retention* (whether a detected corner
63638
+ // borders a run worth pinning), as distinct from isStructuralRun's per-vertex
63639
+ // gate that decides whether a span is copied verbatim. A run is "straight at the
63640
+ // smoothing scale" if every vertex stays within a thin corridor around the
63641
+ // straight chord joining the run's endpoints: max perpendicular deviation <=
63642
+ // STRAIGHT_DEV_FACTOR * chord length. Because this measures deviation from the
63643
+ // chord rather than summing raw per-segment turning, it is robust to
63644
+ // sub-tolerance digitizing wiggle: a finely ragged but geometrically straight
63645
+ // border (huge total per-vertex turning, tiny deviation) qualifies, so its
63646
+ // bounding corners are kept -- while isStructuralRun would (correctly, for its
63647
+ // own purpose) reject it as too wiggly to copy verbatim. The ratio behaves like
63648
+ // a minimum-radius-over-length gate: a run bending with radius R over length L
63649
+ // deviates from its chord by ~L/(8R), so the threshold corresponds to
63650
+ // R >~ L/(8*STRAIGHT_DEV_FACTOR) -- longer runs must be proportionally straighter
63651
+ // to count, which matches intuition (a 28 km stretch bending at radius 4 km is
63652
+ // obviously not straight). Genuinely curving coastline bows far from its chord
63653
+ // and is still rejected, so spurious corners inside wiggly stretches keep getting
63654
+ // culled.
63655
+ var STRAIGHT_DEV_FACTOR = 0.03;
63656
+
63657
+ // Angle coupling for corner retention: how much sharper the corner must turn than
63658
+ // the run it borders already curves. A run that passes the chord test may still
63659
+ // bend gently within the STRAIGHT_DEV_FACTOR corridor -- for a circular arc the
63660
+ // chord-deviation ratio is ~ (the run's total turn)/8, so the base 0.03 admits a
63661
+ // run that curves ~14 deg over its length. Pinning a *gentle* bend at the end of
63662
+ // such a run is unsafe: the "corner" is barely sharper than the run's own
63663
+ // curving, so it is really a point on a smooth bend, not a junction. (This is the
63664
+ // failure mode on coarsely sampled / already-simplified coastlines, where a
63665
+ // gently curving stretch is sampled as a few long segments that read as a
63666
+ // borderline-straight run with soft bends at each end.) So the straightness limit
63667
+ // for retention is tightened for gentle corners: a corner is pinnable only if its
63668
+ // turn is at least PIN_TURN_RATIO times the run's own bend, i.e.
63669
+ // turn >= PIN_TURN_RATIO * (8 * dev) <=> dev <= turn / (8 * PIN_TURN_RATIO).
63670
+ // retentionDevLimit() returns the smaller of STRAIGHT_DEV_FACTOR and
63671
+ // turn/(8*PIN_TURN_RATIO), so the coupling only bites for gentle corners (below
63672
+ // ~2*STRAIGHT_DEV_FACTOR*PIN_TURN_RATIO ~ 69 deg); sharp corners (surveyed-border
63673
+ // right angles, spits, hairpins) keep the full base tolerance, unchanged.
63674
+ var PIN_TURN_RATIO = 5;
63675
+
63676
+ // Minimum length (in tol units) a straight run must have to justify *pinning* a
63677
+ // bordering corner. This is deliberately larger than MIN_RUN_LEN_FACTOR (the
63678
+ // floor for calling a span "structural" at all): pinning a corner is a stronger
63679
+ // commitment than copying a clean span, so it demands stronger evidence that the
63680
+ // run is a deliberate straight feature rather than incidental collinearity.
63681
+ //
63682
+ // The failure mode this guards against appears on sparse / already-simplified
63683
+ // data, where a gently curving coastline is sampled as a few long segments. A
63684
+ // short near-collinear stretch only ~1*tol long (often just 1-2 segments) then
63685
+ // passes the chord-deviation test -- with so few interior points there is almost
63686
+ // nothing to deviate -- and gets pinned, kinking an otherwise smooth curve. At
63687
+ // the smoothing scale such a stretch is indistinguishable from a coarsely
63688
+ // sampled bend, so it should not anchor a corner. Requiring the run to be
63689
+ // clearly longer than the smoothing distance (factor 2) drops these stubs while
63690
+ // keeping genuine straight borders (which run many times the distance) and even
63691
+ // coarsely sampled but truly long straight segments (e.g. a 2-3*tol contour
63692
+ // edge). Scales with corner-bias (via ctol), so a positive bias restores the
63693
+ // old 1*tol behaviour for users who want shorter runs pinned.
63694
+ var MIN_PIN_RUN_LEN_FACTOR = 2.0;
63695
+
63696
+ // Convert the user-facing corner-bias (0 = neutral) into the positive multiplier
63697
+ // k applied to corner-detection resolution (ctol = tol / k). The mapping is
63698
+ // symmetric about zero -- k(+b) * k(-b) = 1 -- and smooth there (both branches
63699
+ // have slope 1 at b = 0), so opposite biases of equal magnitude are exact
63700
+ // inverses. A positive bias makes detection finer (k > 1, ctol < tol: more, more
63701
+ // finely supported corners); a negative bias makes it coarser (k < 1, ctol > tol:
63702
+ // fewer corners), each as if the smoothing distance were tol/k. Examples: +1
63703
+ // doubles the resolution (k=2, "as if distance were halved"), -1 halves it
63704
+ // (k=1/2, "as if doubled"); +2 -> k=3, -2 -> k=1/3.
63705
+ function cornerBiasScale(cornerBias) {
63706
+ var b = cornerBias || 0;
63707
+ return b >= 0 ? b + 1 : 1 / (1 - b);
63708
+ }
63709
+
63710
+ // @cornerBias (optional, default 0 = neutral) scales only the distance-
63711
+ // proportional corner parameters, by dividing the tolerance they key off
63712
+ // (ctol = tol / k, k = cornerBiasScale(bias)). The dimensionless thresholds are
63713
+ // left untouched: the corner angle, the concentration ratio, and -- downstream,
63714
+ // inside isStraightRun / retentionDevLimit -- STRAIGHT_DEV_FACTOR and
63715
+ // PIN_TURN_RATIO. So corner-bias detects (and retains) corners exactly as if the
63716
+ // smoothing distance were tol/k, while the smoothing kernel keeps using the real
63717
+ // distance. In particular `-smooth corner-bias=-1 1km` gives the same corner
63718
+ // results as `-smooth 2km` (a negative bias finds fewer, only well-supported
63719
+ // corners; a positive bias finds more), but smooths at 1km. All lengths below are
63720
+ // derived from ctol; only cornerAngle and concentration (both dimensionless) stay
63721
+ // fixed.
63362
63722
  function getCornerParams(tol, cornerBias) {
63363
- var bias = cornerBias > 0 ? cornerBias : 1;
63723
+ var ctol = tol / cornerBiasScale(cornerBias);
63364
63724
  return {
63365
63725
  tol: tol,
63366
63726
  cornerAngle: CORNER_ANGLE,
63367
- tangentWindow: TANGENT_WINDOW_FACTOR * tol,
63368
- innerWindow: INNER_WINDOW_FACTOR * TANGENT_WINDOW_FACTOR * tol,
63727
+ tangentWindow: TANGENT_WINDOW_FACTOR * ctol,
63728
+ innerWindow: INNER_WINDOW_FACTOR * TANGENT_WINDOW_FACTOR * ctol,
63369
63729
  concentration: CORNER_CONCENTRATION,
63370
- minRunLen: MIN_RUN_LEN_FACTOR * tol / bias,
63371
- maxTurnRate: 1 / (MIN_RUN_RADIUS_FACTOR * tol) // radians of turning per ground unit
63730
+ minRunLen: MIN_RUN_LEN_FACTOR * ctol,
63731
+ minPinRunLen: MIN_PIN_RUN_LEN_FACTOR * ctol,
63732
+ maxTurnRate: 1 / (MIN_RUN_RADIUS_FACTOR * ctol) // radians of turning per ground unit
63372
63733
  };
63373
63734
  }
63374
63735
 
@@ -63420,6 +63781,122 @@ ${svg}
63420
63781
  return totalTurn / len <= params.maxTurnRate;
63421
63782
  }
63422
63783
 
63784
+ // Cyclic form of isStructuralRun for a closed ring: the span runs forward from
63785
+ // ring vertex @a to ring vertex @b over the m = n-1 unique vertices, wrapping
63786
+ // when b <= a (a == b means the whole ring). Length and turning are measured
63787
+ // cyclically. Used to decide whether a detected ring corner borders a genuine
63788
+ // straight/low-curvature run before it is pinned (see smoothArcCoords).
63789
+ function isStructuralRingSpan(t, channels, n, a, b, params) {
63790
+ var m = n - 1;
63791
+ if (m < 2) return false;
63792
+ var L = t[n - 1];
63793
+ var len = b > a ? t[b] - t[a] : (L - t[a]) + t[b];
63794
+ if (!(len >= params.minRunLen)) return false;
63795
+ var K = channels.length;
63796
+ var totalTurn = 0;
63797
+ var i = a;
63798
+ while (true) {
63799
+ i = (i + 1) % m;
63800
+ if (i === b) break;
63801
+ totalTurn += ringVertexTurn(channels, K, m, i);
63802
+ if (totalTurn / len > params.maxTurnRate) return false;
63803
+ }
63804
+ return totalTurn / len <= params.maxTurnRate;
63805
+ }
63806
+
63807
+ // Is span [a, b] (inclusive vertex indices, a < b, open frame) "straight at the
63808
+ // smoothing scale": clearly longer than the smoothing distance (>= minPinRunLen,
63809
+ // see MIN_PIN_RUN_LEN_FACTOR) AND confined to a thin corridor around its endpoint
63810
+ // chord (see STRAIGHT_DEV_FACTOR)? Used to decide whether a detected corner
63811
+ // borders a straight run worth pinning. Unlike isStructuralRun -- which sums raw
63812
+ // per-segment turning and is therefore defeated by sub-tolerance digitizing
63813
+ // noise -- this measures perpendicular deviation from the chord, so a finely
63814
+ // ragged but geometrically straight border still qualifies. The length floor is
63815
+ // the pinning-specific minPinRunLen (not minRunLen): a run only ~1*tol long has
63816
+ // too few interior points for the chord test to distinguish a true straight
63817
+ // border from a coarsely sampled bend, so it must not anchor a corner. @devLimit
63818
+ // overrides the corridor half-width (default STRAIGHT_DEV_FACTOR); retention
63819
+ // passes a per-corner value tightened for gentle bends (see retentionDevLimit).
63820
+ function isStraightRun(t, channels, a, b, params, devLimit) {
63821
+ var lim = devLimit === undefined ? STRAIGHT_DEV_FACTOR : devLimit;
63822
+ var len = t[b] - t[a];
63823
+ if (!(len >= params.minPinRunLen)) return false;
63824
+ var K = channels.length;
63825
+ var A = getPt(channels, K, a);
63826
+ var AB = subv(getPt(channels, K, b), A, K);
63827
+ var abDot = dot(AB, AB, K);
63828
+ if (!(abDot > 0)) return false;
63829
+ var limit2 = lim * lim * abDot;
63830
+ for (var i = a + 1; i < b; i++) {
63831
+ if (perpDistSq(channels, K, i, A, AB, abDot) > limit2) return false;
63832
+ }
63833
+ return true;
63834
+ }
63835
+
63836
+ // Cyclic form of isStraightRun for a closed ring: the span runs forward from ring
63837
+ // vertex @a to ring vertex @b over the m = n-1 unique vertices, wrapping when
63838
+ // b <= a. A whole-ring span (a == b, the single-corner case) has no meaningful
63839
+ // chord, so it falls back to the turning-rate test (a large low-curvature ring
63840
+ // keeps its one corner). Used by the closed-ring corner cull (see
63841
+ // filterRingCornersByStructure in mapshaper-smooth-algos).
63842
+ function isStraightRingSpan(t, channels, n, a, b, params, devLimit) {
63843
+ var lim = devLimit === undefined ? STRAIGHT_DEV_FACTOR : devLimit;
63844
+ var m = n - 1;
63845
+ if (m < 2) return false;
63846
+ if (a === b) return isStructuralRingSpan(t, channels, n, a, b, params);
63847
+ var L = t[n - 1];
63848
+ var len = b > a ? t[b] - t[a] : (L - t[a]) + t[b];
63849
+ if (!(len >= params.minPinRunLen)) return false;
63850
+ var K = channels.length;
63851
+ var A = getPt(channels, K, a);
63852
+ var AB = subv(getPt(channels, K, b), A, K);
63853
+ var abDot = dot(AB, AB, K);
63854
+ if (!(abDot > 0)) return false;
63855
+ var limit2 = lim * lim * abDot;
63856
+ var i = a;
63857
+ while (true) {
63858
+ i = (i + 1) % m;
63859
+ if (i === b) break;
63860
+ if (perpDistSq(channels, K, i, A, AB, abDot) > limit2) return false;
63861
+ }
63862
+ return true;
63863
+ }
63864
+
63865
+ // Straightness limit for pinning a corner whose windowed turn is @turnRad (see
63866
+ // PIN_TURN_RATIO): min(STRAIGHT_DEV_FACTOR, turnRad / (8 * PIN_TURN_RATIO)).
63867
+ function retentionDevLimit(turnRad) {
63868
+ var lim = turnRad / (8 * PIN_TURN_RATIO);
63869
+ return lim < STRAIGHT_DEV_FACTOR ? lim : STRAIGHT_DEV_FACTOR;
63870
+ }
63871
+
63872
+ // Windowed turn (radians) at vertex @i, over params.tangentWindow each side --
63873
+ // the same measure findInteriorCorners uses to flag the corner. @cyclic selects
63874
+ // the open or ring frame.
63875
+ function cornerTurn(t, channels, n, i, cyclic, params) {
63876
+ var K = channels.length;
63877
+ var L = t[n - 1];
63878
+ var m = cyclic ? n - 1 : n;
63879
+ var segLen = cyclic ? ringSegLengths(t, m) : null;
63880
+ return windowedTurn(t, channels, K, n, L, m, segLen, i, params.tangentWindow, cyclic);
63881
+ }
63882
+
63883
+ // Does the open span [a, b] justify pinning the corner at vertex @corner: is it a
63884
+ // straight run (isStraightRun) whose straightness is enough for the corner's turn
63885
+ // angle (retentionDevLimit)? A gentle bend needs a straighter run than a sharp
63886
+ // one. Used by refineBounds.
63887
+ function bordersStraightRun(t, channels, n, corner, a, b, params) {
63888
+ var lim = retentionDevLimit(cornerTurn(t, channels, n, corner, false, params));
63889
+ return isStraightRun(t, channels, a, b, params, lim);
63890
+ }
63891
+
63892
+ // Ring analogue of bordersStraightRun, for the closed-ring corner cull
63893
+ // (filterRingCornersByStructure). @corner is a ring vertex; the span runs from
63894
+ // ring vertex @a to @b (cyclic when b <= a).
63895
+ function bordersStraightRingSpan(t, channels, n, corner, a, b, params) {
63896
+ var lim = retentionDevLimit(cornerTurn(t, channels, n, corner, true, params));
63897
+ return isStraightRingSpan(t, channels, n, a, b, params, lim);
63898
+ }
63899
+
63423
63900
  // --- internals ---
63424
63901
 
63425
63902
  function ringSegLengths(t, m) {
@@ -63448,6 +63925,14 @@ ${svg}
63448
63925
  return angleBetween(subv(pi, pp, K), subv(pn, pi, K), K);
63449
63926
  }
63450
63927
 
63928
+ // Local turn at ring vertex i using cyclic neighbours over m unique vertices.
63929
+ function ringVertexTurn(channels, K, m, i) {
63930
+ var pi = getPt(channels, K, i);
63931
+ var pp = getPt(channels, K, (i - 1 + m) % m);
63932
+ var pn = getPt(channels, K, (i + 1) % m);
63933
+ return angleBetween(subv(pi, pp, K), subv(pn, pi, K), K);
63934
+ }
63935
+
63451
63936
  // Walk from vertex i in direction dir (+1/-1) until accumulated arc length
63452
63937
  // reaches W (or a boundary, for open arcs), returning the reached vertex index.
63453
63938
  function reach(t, segLen, n, m, L, i, dir, W, cyclic) {
@@ -63492,6 +63977,25 @@ ${svg}
63492
63977
  return o;
63493
63978
  }
63494
63979
 
63980
+ function dot(a, b, K) {
63981
+ var d = 0;
63982
+ for (var c = 0; c < K; c++) d += a[c] * b[c];
63983
+ return d;
63984
+ }
63985
+
63986
+ // Squared perpendicular distance of vertex @i from the line through point @A
63987
+ // with direction @AB (abDot = AB.AB). = |AP|^2 - (AP.AB)^2 / |AB|^2.
63988
+ function perpDistSq(channels, K, i, A, AB, abDot) {
63989
+ var apAp = 0, apAb = 0, d;
63990
+ for (var c = 0; c < K; c++) {
63991
+ d = channels[c][i] - A[c];
63992
+ apAp += d * d;
63993
+ apAb += d * AB[c];
63994
+ }
63995
+ var perp = apAp - apAb * apAb / abDot;
63996
+ return perp > 0 ? perp : 0;
63997
+ }
63998
+
63495
63999
  function angleBetween(u, v, K) {
63496
64000
  var d = 0, nu = 0, nv = 0;
63497
64001
  for (var c = 0; c < K; c++) {
@@ -63510,13 +64014,15 @@ ${svg}
63510
64014
  // Scale-aware line smoothing primitives, shared by the -smooth command.
63511
64015
  //
63512
64016
  // The smoother treats a path as coordinate signals parameterized by arc length s
63513
- // and applies a length-scaled low-pass filter. The user-facing distance is a
63514
- // resolution: detail with a wavelength around the distance is reduced to roughly
63515
- // half amplitude (the -6 dB cutoff), finer detail is removed, and features much
63516
- // larger than the distance pass through nearly unchanged. The distance is not a
63517
- // deviation bound -- a tall, narrow sub-resolution spike can still be displaced
63518
- // by an amount comparable to its own amplitude (inherent to convolution
63519
- // smoothers).
64017
+ // and applies a length-scaled low-pass filter. The user-facing distance is
64018
+ // calibrated so that it approximates the maximum displacement of the smoothed
64019
+ // line from the original at high-displacement features (e.g. acute bends): finer
64020
+ // detail is removed and larger features pass through progressively less changed.
64021
+ // In frequency terms the kernel's half-amplitude (-6 dB) wavelength sits at
64022
+ // roughly KERNEL_STRENGTH * distance, so detail a few times finer than the
64023
+ // distance is strongly attenuated. The distance is not a strict deviation
64024
+ // bound -- a tall, narrow sub-resolution spike can still be displaced by an
64025
+ // amount comparable to its own amplitude (inherent to convolution smoothers).
63520
64026
  //
63521
64027
  // The filter is a local second-degree polynomial fit whose quadratic term
63522
64028
  // corrects the inward shrinkage that plain weighted averaging causes on curved
@@ -63536,21 +64042,44 @@ ${svg}
63536
64042
  // -simplify treats spherical coordinates. The kernel scale stays in true ground
63537
64043
  // distance because arc length is measured with great-circle distance.
63538
64044
  //
63539
- // The user-facing distance is a *resolution*: detail finer than it is removed, a
63540
- // feature about its size is reduced to roughly half amplitude, and larger
63541
- // features are largely preserved. KERNEL_FROM_DISTANCE maps that resolution onto
63542
- // the internal kernel scale, calibrated empirically so the half-amplitude
63543
- // (-6 dB) wavelength ~= the distance. The remaining calibration constants are
63544
- // expressed relative to that internal scale and map it onto kernel widths and
63545
- // output sampling; they are collected here so the mapping can be retuned in one
63546
- // place. See docs/reference.md.
64045
+ // KERNEL_FROM_DISTANCE maps the user distance onto the internal reference scale
64046
+ // (tol) that keys corner detection, output sampling and densification. The kernel
64047
+ // itself is then widened by KERNEL_STRENGTH (below), so the distance approximates
64048
+ // the maximum displacement at sharp features and the -6 dB wavelength sits near
64049
+ // KERNEL_STRENGTH * distance. The remaining calibration constants are expressed
64050
+ // relative to the internal scale and map it onto kernel widths and output
64051
+ // sampling; they are collected here so the mapping can be retuned in one place.
64052
+ // See docs/reference.md.
63547
64053
  var KERNEL_FROM_DISTANCE = 1.2; // internal kernel scale = distance * this
64054
+ // Base smoothing strength baked into the default: the low-pass kernel scale is
64055
+ // this * tol (before the user's `strength` multiplier and the ring cap). It is
64056
+ // calibrated so the distance parameter approximates the maximum displacement of
64057
+ // the smoothed line from the original at high-displacement features (e.g. acute
64058
+ // bends) -- a markedly stronger, more intuitive effect than the raw -6 dB
64059
+ // mapping (which displaced the line far less than the distance). ONLY the kernel
64060
+ // scale is affected; tol -- and therefore corner detection, output sampling, the
64061
+ // prefilter and island dropping -- stays keyed to the raw distance.
64062
+ var KERNEL_STRENGTH = 5;
63548
64063
  var GAUSSIAN_SIGMA_FACTOR = 0.4; // gaussian sigma = internal scale * this
63549
64064
  var PAEK_SCALE_FACTOR = 0.4; // exponential kernel scale d = internal scale * this
63550
64065
  var WINDOW_RADIUS_FACTOR = 1.2; // window half-length = internal scale * this
63551
64066
  var SOURCE_SPACING_FACTOR = 0.25; // densify source to <= tolerance * this before smoothing
63552
64067
  var MAX_OUTPUT_FACTOR = 8; // cap output (and source) vertices at inputCount * this
63553
64068
  var MIN_CLOSED_SEGMENTS = 16; // floor on segments for closed rings (so they resolve)
64069
+ // A closed ring cannot be smoothed at a resolution coarser than the ring itself:
64070
+ // once the kernel window (radius = internal scale * WINDOW_RADIUS_FACTOR)
64071
+ // reaches half the ring's perimeter, every output point averages over the whole
64072
+ // loop and the ring degenerates toward a point (a circle once re-inflated). So
64073
+ // for a closed ring the internal scale is capped just below that threshold,
64074
+ // which is factor 1/(2*WINDOW_RADIUS_FACTOR) ~ 0.42. Up to the threshold the
64075
+ // ring keeps its shape (elongated stays elongated, and detail is rounded as much
64076
+ // as the ring's own size allows); the enclosed area it loses to curve-shortening
64077
+ // on the way is restored afterward by restoreRingArea() (a similarity rescale
64078
+ // about the centroid), so a small island is rounded at close to the full
64079
+ // requested scale without shrinking. The cap only binds when the requested
64080
+ // distance nears the ring's own size; large rings (perimeter >> distance) smooth
64081
+ // gently, lose negligible area and are effectively unaffected by either step.
64082
+ var MAX_RING_SCALE_FACTOR = 0.42;
63554
64083
 
63555
64084
  // Output resampling. The smoothed curve is a continuous function of arc length;
63556
64085
  // we sample it densely at a uniform step and then thin that dense polyline with a
@@ -63613,12 +64142,29 @@ ${svg}
63613
64142
  return deg * Math.PI / 180;
63614
64143
  }
63615
64144
 
63616
- // Resolve the keep-corners run-length bias (default 1). Its inverse scales the
63617
- // min structural-run length, so a value < 1 protects only longer straight runs
63618
- // (fewer corners kept) and a value > 1 protects shorter runs (more corners kept).
64145
+ // Resolve the corner-detection bias (default 0 = neutral). This is the raw
64146
+ // user-facing value; getCornerParams / cornerBiasScale convert it into the
64147
+ // multiplier on detection resolution. A positive bias keeps more corners, a
64148
+ // negative bias fewer. Missing/null falls back to neutral.
63619
64149
  function resolveCornerBias(opts) {
63620
64150
  var b = opts.cornerBias;
63621
- return b > 0 ? b : 1;
64151
+ return (b === undefined || b === null) ? 0 : b;
64152
+ }
64153
+
64154
+ // Resolve the smoothing-strength multiplier (default 1). It scales only the
64155
+ // low-pass kernel (window radius and sigma) relative to the distance, so a value
64156
+ // > 1 smooths more strongly (wider kernel, larger divergence from the original)
64157
+ // and < 1 more gently. Everything else keyed to the distance -- corner detection,
64158
+ // output sampling, the prefilter and island dropping -- is left unchanged.
64159
+ // Non-positive or missing values fall back to 1.
64160
+ //
64161
+ // By design, curve exaggeration (gain > 1) scales WITH strength: gain multiplies
64162
+ // the quadratic curvature correction (a0 - mean) in smoothPoint, and that term is
64163
+ // measured over the strength-scaled kernel window, so a wider kernel amplifies a
64164
+ // given gain. This coupling is intentional -- do not normalize it out.
64165
+ function resolveStrength(opts) {
64166
+ var s = opts.strength;
64167
+ return s > 0 ? s : 1;
63622
64168
  }
63623
64169
 
63624
64170
  function smoothArcCoords(xx, yy, opts) {
@@ -63634,6 +64180,29 @@ ${svg}
63634
64180
  var spherical = !!opts.spherical;
63635
64181
  var keepCorners = !!opts.keepCorners;
63636
64182
  var bendAngle = resolveBendAngle(opts);
64183
+
64184
+ // Cumulative arc length in ground units (meters for spherical data), so the
64185
+ // kernel scale stays in true distance regardless of coordinate representation.
64186
+ var t = arcLengths(origX, origY, n, spherical);
64187
+ if (!(t[n - 1] > 0)) {
64188
+ return {xx: origX, yy: origY}; // degenerate (coincident points)
64189
+ }
64190
+ // The low-pass kernel scale is the raw distance scale (tol) times the baked-in
64191
+ // KERNEL_STRENGTH calibration and the user's `strength` multiplier (default 1).
64192
+ // Only the kernel (radius, sigma) uses this scale; tol -- which drives corner
64193
+ // detection, output sampling and densification -- stays keyed to the raw
64194
+ // distance, so those effects are unaffected by either strength factor.
64195
+ var kernelScale = tol * KERNEL_STRENGTH * resolveStrength(opts);
64196
+ // A closed ring smaller than the smoothing resolution would collapse toward its
64197
+ // centroid, so cap both scales at a fraction of the ring's perimeter (see
64198
+ // MAX_RING_SCALE_FACTOR). This only binds when the requested distance (or the
64199
+ // boosted kernel) approaches the ring's own size; otherwise it is a no-op. The
64200
+ // cap on kernelScale also stops a large `strength` from collapsing a ring.
64201
+ if (closed) {
64202
+ var ringCap = MAX_RING_SCALE_FACTOR * t[n - 1];
64203
+ tol = Math.min(tol, ringCap);
64204
+ kernelScale = Math.min(kernelScale, ringCap);
64205
+ }
63637
64206
  var ctx = {
63638
64207
  tol: tol,
63639
64208
  method: method,
@@ -63646,21 +64215,25 @@ ${svg}
63646
64215
  // segment still turns well under the threshold; never coarsen it beyond the
63647
64216
  // default (the angle filter alone thins the output for larger angles).
63648
64217
  denseStep: tol * DENSE_STEP_FACTOR * Math.min(1, bendAngle / DEFAULT_BEND_ANGLE),
63649
- radius: tol * WINDOW_RADIUS_FACTOR,
63650
- scale: (method == 'gaussian' ? GAUSSIAN_SIGMA_FACTOR : PAEK_SCALE_FACTOR) * tol
64218
+ radius: kernelScale * WINDOW_RADIUS_FACTOR,
64219
+ scale: (method == 'gaussian' ? GAUSSIAN_SIGMA_FACTOR : PAEK_SCALE_FACTOR) * kernelScale
63651
64220
  };
63652
-
63653
- // Cumulative arc length in ground units (meters for spherical data), so the
63654
- // kernel scale stays in true distance regardless of coordinate representation.
63655
- var t = arcLengths(origX, origY, n, spherical);
63656
- if (!(t[n - 1] > 0)) {
63657
- return {xx: origX, yy: origY}; // degenerate (coincident points)
63658
- }
63659
64221
  var channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
63660
64222
 
63661
64223
  if (closed) {
64224
+ var ringParams = getCornerParams(tol, ctx.cornerBias);
63662
64225
  var corners = keepCorners ?
63663
- findInteriorCorners(t, channels, n, true, getCornerParams(tol, ctx.cornerBias)) : [];
64226
+ findInteriorCorners(t, channels, n, true, ringParams) : [];
64227
+ // findInteriorCorners flags localized bends by angle alone; it does not check
64228
+ // whether a candidate borders a structural (long, low-curvature) run. Keep
64229
+ // only the corners that do -- a natural ring with no straight segments has
64230
+ // none and must smooth cyclically. Otherwise the ring would be rotated to
64231
+ // corners[0] and smoothed as an open path with that vertex pinned as a
64232
+ // spurious cusp (whose location shifts with the tolerance-scaled detection
64233
+ // window), even though refineBounds later drops every interior breakpoint.
64234
+ if (corners.length > 0) {
64235
+ corners = filterRingCornersByStructure(t, channels, n, corners, ringParams);
64236
+ }
63664
64237
  if (corners.length === 0) {
63665
64238
  return smoothClosedCyclic(t, channels, n, ctx);
63666
64239
  }
@@ -63682,9 +64255,18 @@ ${svg}
63682
64255
  }
63683
64256
 
63684
64257
  // Smooth an open path partitioned at @interiorBreaks (sorted interior vertex
63685
- // indices). Structural runs are copied verbatim; other spans are smoothed with
63686
- // their endpoints pinned, so every breakpoint (and the two arc endpoints) keeps
63687
- // its exact original position. Shared breakpoint vertices are emitted once.
64258
+ // indices). Corner retention and verbatim-copy are two separate decisions:
64259
+ // - A breakpoint is kept only if it borders a straight run that is straight
64260
+ // enough for its turn angle (bordersStraightRun -- deviation from the endpoint
64261
+ // chord, robust to sub-tolerance wiggle, and tightened for gentle bends so a
64262
+ // soft bend on a borderline-straight run is not pinned); otherwise
64263
+ // refineBounds drops it.
64264
+ // - A kept span is copied verbatim only if it is clean per-vertex
64265
+ // (isStructuralRun); otherwise it is smoothed with its endpoints pinned. So a
64266
+ // straight-but-noisy border is smoothed into a clean straight line between
64267
+ // its pinned corners, rather than curving into its neighbours.
64268
+ // Every breakpoint (and the two arc endpoints) keeps its exact original position;
64269
+ // shared breakpoint vertices are emitted once.
63688
64270
  function smoothOpenSpans(origX, origY, t, channels, n, interiorBreaks, ctx) {
63689
64271
  var bounds = [0].concat(interiorBreaks);
63690
64272
  bounds.push(n - 1);
@@ -63704,17 +64286,28 @@ ${svg}
63704
64286
  return {xx: xx, yy: yy};
63705
64287
  }
63706
64288
 
63707
- // Drop interior breakpoints that don't border any structural run (e.g. spikes
63708
- // inside a wiggly stretch), merging their spans, until the partition is stable.
63709
- // Merging can turn two short straight pieces back into one structural run, so
63710
- // structurality is re-tested each pass.
64289
+ // Drop interior breakpoints that don't border any pinnable straight run (e.g.
64290
+ // spikes inside a wiggly stretch, or -- crucially on sparse/simplified data --
64291
+ // points sampled along a gentle curve), merging their spans, until the partition
64292
+ // is stable. Merging can turn two short pieces back into one straight run, so the
64293
+ // test is repeated each pass. A breakpoint is kept only if an adjacent span is
64294
+ // straight at the smoothing scale AND straight enough for the breakpoint's own
64295
+ // turn angle (bordersStraightRun): deviation from the endpoint chord, tightened
64296
+ // for gentle corners so a soft bend on a borderline-straight run is not pinned.
64297
+ // The older per-vertex turning gate (isStructuralRun) is deliberately NOT used
64298
+ // for retention -- it admits any run bending no tighter than radius
64299
+ // MIN_RUN_RADIUS_FACTOR*tol, i.e. gentle curves, which on coarsely-sampled data
64300
+ // produces spurious corners along smooth bends. (isStructuralRun still governs
64301
+ // verbatim-copy of a kept span; see smoothOpenSpans.) The corner for both
64302
+ // adjacent spans is the breakpoint itself, so its turn angle gates each side.
63711
64303
  function refineBounds(t, channels, bounds, params) {
64304
+ var n = channels[0].length;
63712
64305
  var changed = true;
63713
64306
  while (changed && bounds.length > 2) {
63714
64307
  changed = false;
63715
64308
  for (var i = 1; i < bounds.length - 1; i++) {
63716
- var leftStruct = isStructuralRun(t, channels, bounds[i - 1], bounds[i], params);
63717
- var rightStruct = isStructuralRun(t, channels, bounds[i], bounds[i + 1], params);
64309
+ var leftStruct = bordersStraightRun(t, channels, n, bounds[i], bounds[i - 1], bounds[i], params);
64310
+ var rightStruct = bordersStraightRun(t, channels, n, bounds[i], bounds[i], bounds[i + 1], params);
63718
64311
  if (!leftStruct && !rightStruct) {
63719
64312
  bounds.splice(i, 1);
63720
64313
  changed = true;
@@ -63725,6 +64318,39 @@ ${svg}
63725
64318
  return bounds;
63726
64319
  }
63727
64320
 
64321
+ // Drop closed-ring corners that don't border a run worth pinning on either side,
64322
+ // merging their (cyclic) spans, until the set is stable -- the cyclic analogue
64323
+ // of refineBounds, applied before the ring is rotated/pinned. A single corner
64324
+ // is tested against the whole-ring span. Uses the same angle-coupled
64325
+ // chord-straightness criterion as refineBounds (see bordersStraightRingSpan).
64326
+ // Returns the surviving corners (a subset of @corners, order preserved); an empty
64327
+ // result means the ring has no qualifying corner and should smooth cyclically.
64328
+ function filterRingCornersByStructure(t, channels, n, corners, params) {
64329
+ var list = corners.slice();
64330
+ var changed = true;
64331
+ while (changed && list.length > 0) {
64332
+ changed = false;
64333
+ for (var i = 0; i < list.length; i++) {
64334
+ var cur = list[i];
64335
+ var leftStruct, rightStruct;
64336
+ if (list.length === 1) {
64337
+ leftStruct = rightStruct = bordersStraightRingSpan(t, channels, n, cur, cur, cur, params);
64338
+ } else {
64339
+ var prev = list[(i - 1 + list.length) % list.length];
64340
+ var next = list[(i + 1) % list.length];
64341
+ leftStruct = bordersStraightRingSpan(t, channels, n, cur, prev, cur, params);
64342
+ rightStruct = bordersStraightRingSpan(t, channels, n, cur, cur, next, params);
64343
+ }
64344
+ if (!leftStruct && !rightStruct) {
64345
+ list.splice(i, 1);
64346
+ changed = true;
64347
+ break;
64348
+ }
64349
+ }
64350
+ }
64351
+ return list;
64352
+ }
64353
+
63728
64354
  // Smooth a single open span [lo, hi] (inclusive) and pin both ends to their
63729
64355
  // original coordinates. Reuses the whole-arc smoothing pipeline on the sub-arc.
63730
64356
  function smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx) {
@@ -63756,6 +64382,10 @@ ${svg}
63756
64382
  var dense = densifyChannels(t, channels, maxSpacing);
63757
64383
  var src = buildSource(dense.t, dense.channels, true, ctx.radius, L);
63758
64384
  var sm = sampleSmoothedCurve(src, 0, L, true, ctx, n);
64385
+ // Smoothing shrinks a closed loop (curve-shortening); restore its original
64386
+ // enclosed area so small rings can be rounded at the full scale without
64387
+ // shrinking. A no-op for large rings (they lose negligible area).
64388
+ restoreRingArea(sm, channels, n, ctx.spherical);
63759
64389
  var out = ctx.spherical ? xyzChannelsToLngLat(sm) : {xx: sm[0], yy: sm[1]};
63760
64390
  // force an exactly closed ring (the periodic endpoints are equal up to fp)
63761
64391
  out.xx[out.xx.length - 1] = out.xx[0];
@@ -63763,6 +64393,98 @@ ${svg}
63763
64393
  return out;
63764
64394
  }
63765
64395
 
64396
+ // Rescale a smoothed closed ring about its centroid so it re-encloses the
64397
+ // original ring's area. Because it is a uniform similarity transform, the
64398
+ // smoothed *shape* is unchanged -- only its size -- so the rounding introduced by
64399
+ // smoothing is preserved while the curve-shortening shrinkage is undone.
64400
+ // @sm are the smoothed smoothing channels (plain arrays; [x,y] planar or unit-
64401
+ // sphere [X,Y,Z] spherical, last point == first). @orig are the original channels
64402
+ // (length @n, closed). Silent no-op if either area is non-positive.
64403
+ function restoreRingArea(sm, orig, n, spherical) {
64404
+ var origArea = ringChannelArea(orig, n, spherical);
64405
+ var m = sm[0].length;
64406
+ var smArea = ringChannelArea(sm, m, spherical);
64407
+ if (!(origArea > 0) || !(smArea > 0)) return;
64408
+ var f = Math.sqrt(origArea / smArea);
64409
+ if (spherical) {
64410
+ scaleRingSpherical(sm, m, f);
64411
+ } else {
64412
+ scaleRingPlanar(sm, m, f);
64413
+ }
64414
+ }
64415
+
64416
+ // Enclosed-area proxy of a closed ring (@count points, last == first). Planar:
64417
+ // the shoelace area on (x,y). Spherical: the shoelace area of the ring projected
64418
+ // into the tangent plane at its centroid direction. Only ratios of two such
64419
+ // areas are used, so the (unit-sphere) scale is irrelevant, and the tangent-plane
64420
+ // error is second order in the ring's size -- negligible for the small rings
64421
+ // where this is needed.
64422
+ function ringChannelArea(ch, count, spherical) {
64423
+ if (!spherical) {
64424
+ var x = ch[0], y = ch[1], a = 0;
64425
+ for (var i = 0; i < count - 1; i++) a += x[i] * y[i + 1] - x[i + 1] * y[i];
64426
+ return Math.abs(a / 2);
64427
+ }
64428
+ var basis = tangentBasis(ringCentroidDir(ch, count - 1));
64429
+ var ex = basis.ex, ey = basis.ey;
64430
+ var X = ch[0], Y = ch[1], Z = ch[2], area = 0, px, py, qx, qy;
64431
+ for (var j = 0; j < count - 1; j++) {
64432
+ px = X[j] * ex[0] + Y[j] * ex[1] + Z[j] * ex[2];
64433
+ py = X[j] * ey[0] + Y[j] * ey[1] + Z[j] * ey[2];
64434
+ qx = X[j + 1] * ex[0] + Y[j + 1] * ex[1] + Z[j + 1] * ex[2];
64435
+ qy = X[j + 1] * ey[0] + Y[j + 1] * ey[1] + Z[j + 1] * ey[2];
64436
+ area += px * qy - qx * py;
64437
+ }
64438
+ return Math.abs(area / 2);
64439
+ }
64440
+
64441
+ function scaleRingPlanar(sm, count, f) {
64442
+ var x = sm[0], y = sm[1], cx = 0, cy = 0, i;
64443
+ for (i = 0; i < count - 1; i++) { cx += x[i]; cy += y[i]; }
64444
+ cx /= (count - 1); cy /= (count - 1);
64445
+ for (i = 0; i < count; i++) {
64446
+ x[i] = cx + (x[i] - cx) * f;
64447
+ y[i] = cy + (y[i] - cy) * f;
64448
+ }
64449
+ }
64450
+
64451
+ // Scale each unit-sphere point's angular offset from the centroid direction by
64452
+ // ~f (keeping the radial component, then renormalizing), which scales the
64453
+ // enclosed area by ~f^2 for the small caps where this runs.
64454
+ function scaleRingSpherical(sm, count, f) {
64455
+ var X = sm[0], Y = sm[1], Z = sm[2];
64456
+ var c = ringCentroidDir(sm, count - 1);
64457
+ for (var i = 0; i < count; i++) {
64458
+ var dot = X[i] * c[0] + Y[i] * c[1] + Z[i] * c[2];
64459
+ var tx = X[i] - dot * c[0], ty = Y[i] - dot * c[1], tz = Z[i] - dot * c[2];
64460
+ var vx = dot * c[0] + f * tx, vy = dot * c[1] + f * ty, vz = dot * c[2] + f * tz;
64461
+ var nrm = Math.sqrt(vx * vx + vy * vy + vz * vz) || 1;
64462
+ X[i] = vx / nrm; Y[i] = vy / nrm; Z[i] = vz / nrm;
64463
+ }
64464
+ }
64465
+
64466
+ function ringCentroidDir(ch, m) {
64467
+ var X = ch[0], Y = ch[1], Z = ch[2], cx = 0, cy = 0, cz = 0;
64468
+ for (var i = 0; i < m; i++) { cx += X[i]; cy += Y[i]; cz += Z[i]; }
64469
+ var nrm = Math.sqrt(cx * cx + cy * cy + cz * cz) || 1;
64470
+ return [cx / nrm, cy / nrm, cz / nrm];
64471
+ }
64472
+
64473
+ // Orthonormal tangent basis (ex, ey) at unit direction c on the sphere.
64474
+ function tangentBasis(c) {
64475
+ // pick the world axis least aligned with c to avoid a degenerate cross product
64476
+ var ax = Math.abs(c[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
64477
+ var ex = cross(c, ax);
64478
+ var en = Math.sqrt(ex[0] * ex[0] + ex[1] * ex[1] + ex[2] * ex[2]) || 1;
64479
+ ex = [ex[0] / en, ex[1] / en, ex[2] / en];
64480
+ var ey = cross(c, ex);
64481
+ return {c: c, ex: ex, ey: ey};
64482
+ }
64483
+
64484
+ function cross(a, b) {
64485
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
64486
+ }
64487
+
63766
64488
  function copySpan(origX, origY, lo, hi) {
63767
64489
  var xx = [], yy = [];
63768
64490
  for (var i = lo; i <= hi; i++) {
@@ -64202,11 +64924,12 @@ ${svg}
64202
64924
  stop$1('Expected prefilter-gate to be a number > 0');
64203
64925
  }
64204
64926
  if (opts.corner_bias !== undefined && opts.corner_bias !== null &&
64205
- !(opts.corner_bias >= 0)) {
64206
- stop$1('Expected corner-bias to be a number >= 0');
64927
+ typeof opts.corner_bias != 'number') {
64928
+ stop$1('Expected corner-bias to be a number');
64207
64929
  }
64208
- // Corner preservation is on by default; no-corners or corner-bias=0 turns it off.
64209
- var keepCorners = !opts.no_corners && opts.corner_bias !== 0;
64930
+ // Corner preservation is on by default; no-corners turns it off. (corner-bias
64931
+ // only tunes sensitivity: 0 is neutral, not off.)
64932
+ var keepCorners = !opts.no_corners;
64210
64933
  var implicitlySmoothedNames = getImplicitlyTargetedLayerNames(dataset, targetLayers, layerHasPaths);
64211
64934
 
64212
64935
  // Smoothing rewrites coordinates, so lock in any pending (non-destructive)
@@ -64225,6 +64948,8 @@ ${svg}
64225
64948
  filterDetailPaths(arcs, {
64226
64949
  distance: tolerance,
64227
64950
  tortuosity: opts.prefilter_gate,
64951
+ roundness: opts.prefilter_roundness,
64952
+ minRingArea: opts.prefilter_min_area,
64228
64953
  spherical: spherical
64229
64954
  });
64230
64955
  var removed = before - arcs.getPointCount();
@@ -64240,6 +64965,7 @@ ${svg}
64240
64965
  keepCorners: keepCorners,
64241
64966
  cornerBias: opts.corner_bias,
64242
64967
  gain: opts.gain,
64968
+ strength: opts.strength,
64243
64969
  maxBendAngle: opts.max_bend_angle
64244
64970
  });
64245
64971
 
@@ -64268,6 +64994,7 @@ ${svg}
64268
64994
  keepCorners: opts.keepCorners,
64269
64995
  cornerBias: opts.cornerBias,
64270
64996
  gain: opts.gain,
64997
+ strength: opts.strength,
64271
64998
  maxBendAngle: opts.maxBendAngle,
64272
64999
  closed: arcs.arcIsClosed(arcId)
64273
65000
  });
@@ -66085,7 +66812,7 @@ ${svg}
66085
66812
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66086
66813
  }
66087
66814
 
66088
- var version = "0.7.34";
66815
+ var version = "0.7.36";
66089
66816
 
66090
66817
  // Parse command line args into commands and run them
66091
66818
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.