mapshaper 0.7.35 → 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.
Files changed (3) hide show
  1. package/mapshaper.js +686 -62
  2. package/package.json +1 -1
  3. package/www/mapshaper.js +686 -62
package/www/mapshaper.js CHANGED
@@ -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'
@@ -58334,18 +58367,55 @@ ${svg}
58334
58367
  // threshold) -- a jetty, fjord or crinkle. Otherwise restore the run's
58335
58368
  // original vertices, so gentle stretches keep full detail for -smooth.
58336
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
+ //
58337
58386
  // @xx, @yy coordinate arrays for one arc (may be typed-array subarrays).
58338
- // @opts: {distance, tortuosity, spherical, weighting}
58387
+ // @opts: {distance, tortuosity, spherical, weighting, roundness}
58339
58388
  // distance detail size threshold in ground units (meters when spherical): the
58340
58389
  // longest chord the filter is allowed to create.
58341
58390
  // tortuosity min original-length / chord ratio for a run to be cut (default 2).
58342
58391
  // spherical measure area/length on the sphere (lng/lat -> geocentric x,y,z).
58343
58392
  // weighting Visvalingam angle-weight coefficient (default 0.7), matching
58344
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.
58345
58397
  // Returns {xx: [], yy: []}. Arc endpoints are always preserved, so shared
58346
58398
  // topology nodes stay put and the operation is topology-safe like -simplify.
58347
58399
  var DEFAULT_WEIGHTING = 0.7;
58348
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;
58349
58419
  // How far (in detail-distances of arc length) the survivor-merge pass looks ahead
58350
58420
  // for a chord that closes a convoluted excursion. Bounds the pass to O(n) and
58351
58421
  // caps how long a thin spike it can slice in one merge.
@@ -58359,7 +58429,7 @@ ${svg}
58359
58429
 
58360
58430
  function collapseArcDetail(xx, yy, opts) {
58361
58431
  var n = xx.length;
58362
- var outX = [], outY = [];
58432
+ var outX = [], outY = [], outIdx = [];
58363
58433
  var D = opts.distance;
58364
58434
  if (n < 3 || !(D > 0)) {
58365
58435
  for (var p = 0; p < n; p++) { outX.push(xx[p]); outY.push(yy[p]); }
@@ -58368,6 +58438,12 @@ ${svg}
58368
58438
  var spherical = !!opts.spherical;
58369
58439
  var k = opts.weighting >= 0 ? opts.weighting : DEFAULT_WEIGHTING;
58370
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];
58371
58447
  var Dsq = D * D;
58372
58448
 
58373
58449
  // Metric coordinates: geocentric x,y,z on a sphere for lng/lat input, plain
@@ -58445,6 +58521,52 @@ ${svg}
58445
58521
  var cumLen = new Float64Array(n);
58446
58522
  for (var q = 1; q < n; q++) cumLen[q] = cumLen[q - 1] + dist(q - 1, q);
58447
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
+
58448
58570
  function cutRun(a, e) {
58449
58571
  // Emit the kept vertices in (a, e]; a has already been emitted. Walk from a
58450
58572
  // and, at each step, find the span [i, j] (chord <= D) with the highest
@@ -58461,7 +58583,8 @@ ${svg}
58461
58583
  var c = dist(i, j);
58462
58584
  if (c > D) continue; // never create a chord longer than the detail scale
58463
58585
  var tort = c > 0 ? (cumLen[j] - cumLen[i]) / c : Infinity;
58464
- if (tort > bestTort) {
58586
+ // roundness gate: keep substantial, rounded bulges at full detail
58587
+ if (tort > bestTort && !isProtected(i, j)) {
58465
58588
  bestTort = tort;
58466
58589
  bestJ = j;
58467
58590
  }
@@ -58469,10 +58592,12 @@ ${svg}
58469
58592
  if (bestJ >= 0) {
58470
58593
  outX.push(xx[bestJ]);
58471
58594
  outY.push(yy[bestJ]);
58595
+ outIdx.push(bestJ);
58472
58596
  i = bestJ;
58473
58597
  } else {
58474
58598
  outX.push(xx[i + 1]);
58475
58599
  outY.push(yy[i + 1]);
58600
+ outIdx.push(i + 1);
58476
58601
  i++;
58477
58602
  }
58478
58603
  }
@@ -58502,7 +58627,9 @@ ${svg}
58502
58627
  if (chordSq(s, u) <= mergeChordSq) {
58503
58628
  var ud = dist(s, u);
58504
58629
  var utort = ud > 0 ? (cumLen[u] - cumLen[s]) / ud : Infinity;
58505
- 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)) {
58506
58633
  mergeTort = utort;
58507
58634
  mergeJ = u;
58508
58635
  }
@@ -58521,13 +58648,49 @@ ${svg}
58521
58648
 
58522
58649
  outX.push(xx[0]);
58523
58650
  outY.push(yy[0]);
58651
+ outIdx.push(0);
58524
58652
  var a = 0;
58525
58653
  while (a !== n - 1) {
58526
58654
  var e = next[a];
58527
58655
  cutRun(a, e);
58528
58656
  a = e;
58529
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
+ }
58530
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
+ }
58531
58694
  }
58532
58695
 
58533
58696
  // Optional preprocessing step before -smooth: collapse intricate sub-scale
@@ -58558,6 +58721,8 @@ ${svg}
58558
58721
  distance: distance,
58559
58722
  tortuosity: opts.tortuosity,
58560
58723
  weighting: opts.weighting,
58724
+ roundness: opts.roundness,
58725
+ minRingArea: opts.min_area,
58561
58726
  spherical: spherical
58562
58727
  });
58563
58728
  var removed = before - arcs.getPointCount();
@@ -58581,6 +58746,8 @@ ${svg}
58581
58746
  distance: opts.distance,
58582
58747
  tortuosity: opts.tortuosity,
58583
58748
  weighting: opts.weighting,
58749
+ roundness: opts.roundness,
58750
+ minRingArea: opts.minRingArea,
58584
58751
  spherical: opts.spherical
58585
58752
  });
58586
58753
  nn.push(res.xx.length);
@@ -63423,7 +63590,7 @@ ${svg}
63423
63590
  });
63424
63591
 
63425
63592
  // Structural-corner detection for -smooth's corner preservation (on by default;
63426
- // disabled with no-corners or corner-bias=0).
63593
+ // disabled with no-corners).
63427
63594
  //
63428
63595
  // Many boundaries alternate between natural, freely-curving stretches (coast,
63429
63596
  // river centerline) and artificial straight-line segments (state/county
@@ -63457,21 +63624,112 @@ ${svg}
63457
63624
  var INNER_WINDOW_FACTOR = 0.4; // concentration probe window = tangentWindow * this
63458
63625
  var CORNER_CONCENTRATION = 0.6; // min ratio of inner-window turn to full-window turn
63459
63626
  var MIN_RUN_LEN_FACTOR = 1.0; // a structural run must be at least tol * this long
63460
- var MIN_RUN_RADIUS_FACTOR = 1.0; // and bend no tighter than radius tol * this
63461
-
63462
- // @cornerBias (optional, default 1) divides the min structural-run length, so a
63463
- // value < 1 lengthens the run a corner must border to be preserved (fewer, only
63464
- // 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.
63465
63722
  function getCornerParams(tol, cornerBias) {
63466
- var bias = cornerBias > 0 ? cornerBias : 1;
63723
+ var ctol = tol / cornerBiasScale(cornerBias);
63467
63724
  return {
63468
63725
  tol: tol,
63469
63726
  cornerAngle: CORNER_ANGLE,
63470
- tangentWindow: TANGENT_WINDOW_FACTOR * tol,
63471
- innerWindow: INNER_WINDOW_FACTOR * TANGENT_WINDOW_FACTOR * tol,
63727
+ tangentWindow: TANGENT_WINDOW_FACTOR * ctol,
63728
+ innerWindow: INNER_WINDOW_FACTOR * TANGENT_WINDOW_FACTOR * ctol,
63472
63729
  concentration: CORNER_CONCENTRATION,
63473
- minRunLen: MIN_RUN_LEN_FACTOR * tol / bias,
63474
- 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
63475
63733
  };
63476
63734
  }
63477
63735
 
@@ -63523,6 +63781,122 @@ ${svg}
63523
63781
  return totalTurn / len <= params.maxTurnRate;
63524
63782
  }
63525
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
+
63526
63900
  // --- internals ---
63527
63901
 
63528
63902
  function ringSegLengths(t, m) {
@@ -63551,6 +63925,14 @@ ${svg}
63551
63925
  return angleBetween(subv(pi, pp, K), subv(pn, pi, K), K);
63552
63926
  }
63553
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
+
63554
63936
  // Walk from vertex i in direction dir (+1/-1) until accumulated arc length
63555
63937
  // reaches W (or a boundary, for open arcs), returning the reached vertex index.
63556
63938
  function reach(t, segLen, n, m, L, i, dir, W, cyclic) {
@@ -63595,6 +63977,25 @@ ${svg}
63595
63977
  return o;
63596
63978
  }
63597
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
+
63598
63999
  function angleBetween(u, v, K) {
63599
64000
  var d = 0, nu = 0, nv = 0;
63600
64001
  for (var c = 0; c < K; c++) {
@@ -63613,13 +64014,15 @@ ${svg}
63613
64014
  // Scale-aware line smoothing primitives, shared by the -smooth command.
63614
64015
  //
63615
64016
  // The smoother treats a path as coordinate signals parameterized by arc length s
63616
- // and applies a length-scaled low-pass filter. The user-facing distance is a
63617
- // resolution: detail with a wavelength around the distance is reduced to roughly
63618
- // half amplitude (the -6 dB cutoff), finer detail is removed, and features much
63619
- // larger than the distance pass through nearly unchanged. The distance is not a
63620
- // deviation bound -- a tall, narrow sub-resolution spike can still be displaced
63621
- // by an amount comparable to its own amplitude (inherent to convolution
63622
- // 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).
63623
64026
  //
63624
64027
  // The filter is a local second-degree polynomial fit whose quadratic term
63625
64028
  // corrects the inward shrinkage that plain weighted averaging causes on curved
@@ -63639,21 +64042,44 @@ ${svg}
63639
64042
  // -simplify treats spherical coordinates. The kernel scale stays in true ground
63640
64043
  // distance because arc length is measured with great-circle distance.
63641
64044
  //
63642
- // The user-facing distance is a *resolution*: detail finer than it is removed, a
63643
- // feature about its size is reduced to roughly half amplitude, and larger
63644
- // features are largely preserved. KERNEL_FROM_DISTANCE maps that resolution onto
63645
- // the internal kernel scale, calibrated empirically so the half-amplitude
63646
- // (-6 dB) wavelength ~= the distance. The remaining calibration constants are
63647
- // expressed relative to that internal scale and map it onto kernel widths and
63648
- // output sampling; they are collected here so the mapping can be retuned in one
63649
- // 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.
63650
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;
63651
64063
  var GAUSSIAN_SIGMA_FACTOR = 0.4; // gaussian sigma = internal scale * this
63652
64064
  var PAEK_SCALE_FACTOR = 0.4; // exponential kernel scale d = internal scale * this
63653
64065
  var WINDOW_RADIUS_FACTOR = 1.2; // window half-length = internal scale * this
63654
64066
  var SOURCE_SPACING_FACTOR = 0.25; // densify source to <= tolerance * this before smoothing
63655
64067
  var MAX_OUTPUT_FACTOR = 8; // cap output (and source) vertices at inputCount * this
63656
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;
63657
64083
 
63658
64084
  // Output resampling. The smoothed curve is a continuous function of arc length;
63659
64085
  // we sample it densely at a uniform step and then thin that dense polyline with a
@@ -63716,12 +64142,29 @@ ${svg}
63716
64142
  return deg * Math.PI / 180;
63717
64143
  }
63718
64144
 
63719
- // Resolve the keep-corners run-length bias (default 1). Its inverse scales the
63720
- // min structural-run length, so a value < 1 protects only longer straight runs
63721
- // (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.
63722
64149
  function resolveCornerBias(opts) {
63723
64150
  var b = opts.cornerBias;
63724
- 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;
63725
64168
  }
63726
64169
 
63727
64170
  function smoothArcCoords(xx, yy, opts) {
@@ -63737,6 +64180,29 @@ ${svg}
63737
64180
  var spherical = !!opts.spherical;
63738
64181
  var keepCorners = !!opts.keepCorners;
63739
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
+ }
63740
64206
  var ctx = {
63741
64207
  tol: tol,
63742
64208
  method: method,
@@ -63749,21 +64215,25 @@ ${svg}
63749
64215
  // segment still turns well under the threshold; never coarsen it beyond the
63750
64216
  // default (the angle filter alone thins the output for larger angles).
63751
64217
  denseStep: tol * DENSE_STEP_FACTOR * Math.min(1, bendAngle / DEFAULT_BEND_ANGLE),
63752
- radius: tol * WINDOW_RADIUS_FACTOR,
63753
- 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
63754
64220
  };
63755
-
63756
- // Cumulative arc length in ground units (meters for spherical data), so the
63757
- // kernel scale stays in true distance regardless of coordinate representation.
63758
- var t = arcLengths(origX, origY, n, spherical);
63759
- if (!(t[n - 1] > 0)) {
63760
- return {xx: origX, yy: origY}; // degenerate (coincident points)
63761
- }
63762
64221
  var channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
63763
64222
 
63764
64223
  if (closed) {
64224
+ var ringParams = getCornerParams(tol, ctx.cornerBias);
63765
64225
  var corners = keepCorners ?
63766
- 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
+ }
63767
64237
  if (corners.length === 0) {
63768
64238
  return smoothClosedCyclic(t, channels, n, ctx);
63769
64239
  }
@@ -63785,9 +64255,18 @@ ${svg}
63785
64255
  }
63786
64256
 
63787
64257
  // Smooth an open path partitioned at @interiorBreaks (sorted interior vertex
63788
- // indices). Structural runs are copied verbatim; other spans are smoothed with
63789
- // their endpoints pinned, so every breakpoint (and the two arc endpoints) keeps
63790
- // 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.
63791
64270
  function smoothOpenSpans(origX, origY, t, channels, n, interiorBreaks, ctx) {
63792
64271
  var bounds = [0].concat(interiorBreaks);
63793
64272
  bounds.push(n - 1);
@@ -63807,17 +64286,28 @@ ${svg}
63807
64286
  return {xx: xx, yy: yy};
63808
64287
  }
63809
64288
 
63810
- // Drop interior breakpoints that don't border any structural run (e.g. spikes
63811
- // inside a wiggly stretch), merging their spans, until the partition is stable.
63812
- // Merging can turn two short straight pieces back into one structural run, so
63813
- // 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.
63814
64303
  function refineBounds(t, channels, bounds, params) {
64304
+ var n = channels[0].length;
63815
64305
  var changed = true;
63816
64306
  while (changed && bounds.length > 2) {
63817
64307
  changed = false;
63818
64308
  for (var i = 1; i < bounds.length - 1; i++) {
63819
- var leftStruct = isStructuralRun(t, channels, bounds[i - 1], bounds[i], params);
63820
- 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);
63821
64311
  if (!leftStruct && !rightStruct) {
63822
64312
  bounds.splice(i, 1);
63823
64313
  changed = true;
@@ -63828,6 +64318,39 @@ ${svg}
63828
64318
  return bounds;
63829
64319
  }
63830
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
+
63831
64354
  // Smooth a single open span [lo, hi] (inclusive) and pin both ends to their
63832
64355
  // original coordinates. Reuses the whole-arc smoothing pipeline on the sub-arc.
63833
64356
  function smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx) {
@@ -63859,6 +64382,10 @@ ${svg}
63859
64382
  var dense = densifyChannels(t, channels, maxSpacing);
63860
64383
  var src = buildSource(dense.t, dense.channels, true, ctx.radius, L);
63861
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);
63862
64389
  var out = ctx.spherical ? xyzChannelsToLngLat(sm) : {xx: sm[0], yy: sm[1]};
63863
64390
  // force an exactly closed ring (the periodic endpoints are equal up to fp)
63864
64391
  out.xx[out.xx.length - 1] = out.xx[0];
@@ -63866,6 +64393,98 @@ ${svg}
63866
64393
  return out;
63867
64394
  }
63868
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
+
63869
64488
  function copySpan(origX, origY, lo, hi) {
63870
64489
  var xx = [], yy = [];
63871
64490
  for (var i = lo; i <= hi; i++) {
@@ -64305,11 +64924,12 @@ ${svg}
64305
64924
  stop$1('Expected prefilter-gate to be a number > 0');
64306
64925
  }
64307
64926
  if (opts.corner_bias !== undefined && opts.corner_bias !== null &&
64308
- !(opts.corner_bias >= 0)) {
64309
- 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');
64310
64929
  }
64311
- // Corner preservation is on by default; no-corners or corner-bias=0 turns it off.
64312
- 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;
64313
64933
  var implicitlySmoothedNames = getImplicitlyTargetedLayerNames(dataset, targetLayers, layerHasPaths);
64314
64934
 
64315
64935
  // Smoothing rewrites coordinates, so lock in any pending (non-destructive)
@@ -64328,6 +64948,8 @@ ${svg}
64328
64948
  filterDetailPaths(arcs, {
64329
64949
  distance: tolerance,
64330
64950
  tortuosity: opts.prefilter_gate,
64951
+ roundness: opts.prefilter_roundness,
64952
+ minRingArea: opts.prefilter_min_area,
64331
64953
  spherical: spherical
64332
64954
  });
64333
64955
  var removed = before - arcs.getPointCount();
@@ -64343,6 +64965,7 @@ ${svg}
64343
64965
  keepCorners: keepCorners,
64344
64966
  cornerBias: opts.corner_bias,
64345
64967
  gain: opts.gain,
64968
+ strength: opts.strength,
64346
64969
  maxBendAngle: opts.max_bend_angle
64347
64970
  });
64348
64971
 
@@ -64371,6 +64994,7 @@ ${svg}
64371
64994
  keepCorners: opts.keepCorners,
64372
64995
  cornerBias: opts.cornerBias,
64373
64996
  gain: opts.gain,
64997
+ strength: opts.strength,
64374
64998
  maxBendAngle: opts.maxBendAngle,
64375
64999
  closed: arcs.arcIsClosed(arcId)
64376
65000
  });
@@ -66188,7 +66812,7 @@ ${svg}
66188
66812
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66189
66813
  }
66190
66814
 
66191
- var version = "0.7.35";
66815
+ var version = "0.7.36";
66192
66816
 
66193
66817
  // Parse command line args into commands and run them
66194
66818
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.