resamplePath function
Samples a cubic subpath at n points equidistant by arc length, anchoring
corners and endpoints as exact samples. Returns a Float64List(2n). Closed
paths distribute n intervals around the loop (without duplicating the
first point); the circular start-point freedom is resolved by the plan's
circular correspondence.
Implementation
Float64List resamplePath(
CubicPath path, [
int n = 64,
double? cornerThreshold,
]) {
final p = path.pts;
final m = (p.length ~/ 2 - 1) ~/ 3;
final out = Float64List(2 * n);
Float64List fill() {
for (var i = 0; i < n; i++) {
out[2 * i] = p[0];
out[2 * i + 1] = p[1];
}
return out;
}
if (m < 1) return fill();
final lens = List<double>.filled(m, 0);
var l = 0.0;
for (var k = 0; k < m; k++) {
lens[k] = _segLen(p, k);
l += lens[k];
}
if (l < 1e-12) return fill();
// Anchors: segment boundaries. For open paths, endpoints + corners. For
// closed paths, ONLY corners: sampling must be intrinsic to the shape and
// not to the arbitrary M point — two congruent loops with different start
// points produce the same sample set (modulo index rotation, which the
// plan's circular correspondence resolves). With no corners (a circle)
// the path start is the only possible reference.
final cs = detectCorners(path, cornerThreshold);
final List<int> anchors;
if (path.closed) {
anchors = cs.isNotEmpty ? cs : <int>[0];
} else {
anchors = <int>{0, ...cs, m}.toList()..sort();
}
// Runs between anchors; for closed paths the last wraps to anchors[0] + m.
final runs = <(int, int)>[];
if (path.closed) {
for (var j = 0; j < anchors.length; j++) {
final a = anchors[j];
final b = j + 1 < anchors.length ? anchors[j + 1] : anchors[0] + m;
runs.add((a, b));
}
} else {
for (var j = 0; j + 1 < anchors.length; j++) {
runs.add((anchors[j], anchors[j + 1]));
}
}
final rl = runs.map((r) {
var s = 0.0;
for (var k = r.$1; k < r.$2; k++) {
s += lens[k % m];
}
return s;
}).toList();
final intervals = path.closed ? n : n - 1;
if (runs.length > intervals) {
morphFail('N=$n too small (${runs.length} runs)');
}
// Largest-remainder apportionment: proportional to length, min 1, exact sum.
final sum = rl.fold<double>(0, (a, b) => a + b);
final total = sum == 0 ? 1.0 : sum;
final ideal = rl.map((x) => intervals * x / total).toList();
final counts = ideal.map((q) => math.max(1, q.floor())).toList();
var r = intervals - counts.fold<int>(0, (a, b) => a + b);
if (r > 0) {
// Quantized fraction: the quadrature's fp noise (~1e-15) must not decide
// the tie-break — runs congruent under rotation must apportion the same
// in both icons or Procrustes loses the exact congruence. The explicit
// index tie-break is load-bearing, and doubly so here: Dart's List.sort
// is not stable, so equal fractions would otherwise resolve arbitrarily.
final order =
<(double, int)>[
for (var idx = 0; idx < ideal.length; idx++)
(jsRound((ideal[idx] - ideal[idx].floorToDouble()) * 1e9), idx),
]..sort((a, b) {
final c = b.$1.compareTo(a.$1);
return c != 0 ? c : a.$2.compareTo(b.$2);
});
for (var j = 0; j < r; j++) {
counts[order[j % counts.length].$2]++;
}
}
while (r < 0) {
var bi = 0;
for (var idx = 1; idx < counts.length; idx++) {
if (counts[idx] > counts[bi]) bi = idx;
}
if (counts[bi] <= 1) break;
counts[bi]--;
r++;
}
// Sampling: exact anchor at the start of each run + interiors by inversion.
var w = 0;
for (var ri = 0; ri < runs.length; ri++) {
final (k0, k1) = runs[ri];
final cnt = counts[ri];
final lr = rl[ri];
final vi = 6 * (k0 % m);
out[2 * w] = p[vi];
out[2 * w + 1] = p[vi + 1];
w++;
var seg = k0;
var acc = 0.0;
for (var j = 1; j < cnt; j++) {
final target = lr * j / cnt;
while (seg < k1 - 1 && acc + lens[seg % m] < target) {
acc += lens[seg % m];
seg++;
}
final k = seg % m;
final ls = lens[k];
final t = ls > 1e-12 ? _invert(p, k, target - acc, ls) : 0.0;
_point(p, k, t, out, 2 * w);
w++;
}
}
if (!path.closed) {
final vi = 6 * m;
out[2 * w] = p[vi];
out[2 * w + 1] = p[vi + 1];
}
return out;
}