@orbat-mapper/control-measures 0.28.0 → 0.29.1

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.
@@ -144,40 +144,45 @@ const destinationPoint = (origin, distance, bearing) => {
144
144
  * Offsets a polyline to both sides at once, using Miter Joins or Round Joins.
145
145
  * `offsets` carries one half-width per vertex, so the outline may taper along
146
146
  * its centerline; both sides read the same array rather than materialising a
147
- * negated copy per geometry pass.
147
+ * negated copy per geometry pass. Pass `rightOffsets` to give the right side
148
+ * its own half-widths (an asymmetric outline).
149
+ *
150
+ * `leftStarts` / `rightStarts` give, per centerline vertex, the index of its
151
+ * first point in that edge (rounded joins emit several), after any loop was
152
+ * trimmed out: `edge.slice(0, starts[i])` is the edge before vertex `i`.
148
153
  */
149
- function offsetPolylineSides(points, offsets, rounded = false, segments = 5) {
154
+ function offsetPolylineSides(points, offsets, rounded = false, segments = 5, rightOffsets = offsets) {
155
+ const left = offsetPolylineWithOffsets(points, offsets, rounded, segments, 1);
156
+ const right = offsetPolylineWithOffsets(points, rightOffsets, rounded, segments, -1);
150
157
  return {
151
- left: offsetPolylineWithOffsets(points, offsets, rounded, segments, 1),
152
- right: offsetPolylineWithOffsets(points, offsets, rounded, segments, -1)
158
+ left: left.edge,
159
+ right: right.edge,
160
+ leftStarts: left.starts,
161
+ rightStarts: right.starts
153
162
  };
154
163
  }
155
164
  /** One side of {@link offsetPolylineSides}; `sign` picks which. */
156
165
  function offsetPolylineWithOffsets(points, offsets, rounded, segments, sign) {
157
- if (points.length < 2) return points;
158
- if (offsets.length !== points.length) return points;
166
+ if (points.length < 2 || offsets.length !== points.length) return {
167
+ edge: points,
168
+ starts: points.map((_, i) => i)
169
+ };
159
170
  const result = [];
160
171
  const N = points.length;
172
+ const vertexStarts = new Array(N);
161
173
  for (let i = 0; i < N; i++) {
174
+ vertexStarts[i] = result.length;
162
175
  const p = points[i];
163
176
  if (!p) continue;
164
177
  const offset = offsets[i] * sign;
165
178
  if (i === 0) {
166
179
  const next = points[i + 1];
167
- if (next) {
168
- const dir = vecNorm(vecSub(next, p));
169
- const normal = [-dir[1], dir[0]];
170
- result.push(vecAdd(p, vecScale(normal, offset)));
171
- }
180
+ if (next) result.push(vecAdd(p, vecScale(segmentNormal(p, next), offset)));
172
181
  continue;
173
182
  }
174
183
  if (i === N - 1) {
175
184
  const prev = points[i - 1];
176
- if (prev) {
177
- const dir = vecNorm(vecSub(p, prev));
178
- const normal = [-dir[1], dir[0]];
179
- result.push(vecAdd(p, vecScale(normal, offset)));
180
- }
185
+ if (prev) result.push(vecAdd(p, vecScale(segmentNormal(prev, p), offset)));
181
186
  continue;
182
187
  }
183
188
  const prev = points[i - 1];
@@ -185,10 +190,9 @@ function offsetPolylineWithOffsets(points, offsets, rounded, segments, sign) {
185
190
  if (!prev || !next) continue;
186
191
  const v1 = vecNorm(vecSub(p, prev));
187
192
  const v2 = vecNorm(vecSub(next, p));
188
- const n1 = [-v1[1], v1[0]];
189
- const n2 = [-v2[1], v2[0]];
190
- const cross = v1[0] * v2[1] - v1[1] * v2[0];
191
- if (rounded && (offset > 0 && cross < -1e-6 || offset < 0 && cross > 1e-6)) {
193
+ if (rounded && isOuterJoin(v1, v2, offset)) {
194
+ const n1 = [-v1[1], v1[0]];
195
+ const n2 = [-v2[1], v2[0]];
192
196
  const angle1 = Math.atan2(n1[1], n1[0]);
193
197
  let diff = Math.atan2(n2[1], n2[0]) - angle1;
194
198
  if (offset > 0) {
@@ -207,27 +211,154 @@ function offsetPolylineWithOffsets(points, offsets, rounded, segments, sign) {
207
211
  result.push([p[0] + nx * offset, p[1] + ny * offset]);
208
212
  }
209
213
  } else {
210
- const tangent = vecNorm(vecAdd(v1, v2));
211
- const miter = [-tangent[1], tangent[0]];
212
- const dot = vecDot(miter, n1);
213
- const miterLimit = 3;
214
- const scaleFactor = Math.abs(dot) < 1e-6 ? 1 : 1 / dot;
215
- const normal = vecScale(miter, Math.min(scaleFactor, miterLimit));
214
+ const { miter, scale } = miterJoin(v1, v2);
215
+ const normal = vecScale(miter, scale);
216
216
  result.push(vecAdd(p, vecScale(normal, offset)));
217
217
  }
218
218
  }
219
+ const folded = foldedSegments(points, result, vertexStarts);
220
+ if (folded.length === 0) return {
221
+ edge: result,
222
+ starts: vertexStarts
223
+ };
224
+ let maxOffset = 0;
225
+ for (const offset of offsets) maxOffset = Math.max(maxOffset, Math.abs(offset));
226
+ return {
227
+ edge: trimFolds(result, folded, vertexStarts, FOLD_REACH_OFFSETS * MITER_LIMIT * maxOffset),
228
+ starts: vertexStarts
229
+ };
230
+ }
231
+ /**
232
+ * Edge segments that run against the centerline. The segment from vertex
233
+ * `i - 1`'s last edge point to vertex `i`'s first offsets centerline segment
234
+ * `i - 1 → i`; where the bend is tighter than the offset it points backwards
235
+ * and the edge folds over itself.
236
+ */
237
+ function foldedSegments(points, edge, vertexStarts) {
238
+ const folded = [];
239
+ for (let i = 1; i < points.length; i++) {
240
+ const k = vertexStarts[i] - 1;
241
+ const from = edge[k];
242
+ const to = edge[k + 1];
243
+ if (!from || !to) continue;
244
+ if (vecDot(vecSub(to, from), vecSub(points[i], points[i - 1])) < 0) folded.push(k);
245
+ }
246
+ return folded;
247
+ }
248
+ /**
249
+ * Cuts the loop each fold makes: the edge runs back across itself, so the
250
+ * nearest pair of segments around a folded run that cross are joined at their
251
+ * crossing and everything between them dropped. The inner edge then comes to
252
+ * a point in a tight bend instead of looping. Edges without folds, and
253
+ * folds that never cross back, are returned unchanged; the two end points are
254
+ * always kept. `starts` (edge indices, as in {@link offsetPolylineSides}) is
255
+ * updated in place to follow the cuts; a start whose point was cut moves to
256
+ * just past the crossing, which replaces it.
257
+ */
258
+ function trimFolds(edge, folded, starts, reach) {
259
+ let result = edge;
260
+ let limit = edge.length - 1;
261
+ for (let r = folded.length - 1; r >= 0; r--) {
262
+ const last = folded[r];
263
+ let first = last;
264
+ while (r > 0 && folded[r - 1] === first - 1) first = folded[--r];
265
+ if (last >= limit) continue;
266
+ const cut = foldCrossing(result, first, last, limit, reach);
267
+ if (!cut) continue;
268
+ result = [
269
+ ...result.slice(0, cut.from + 1),
270
+ cut.point,
271
+ ...result.slice(cut.to + 1)
272
+ ];
273
+ for (let i = 0; i < starts.length; i++) {
274
+ const start = starts[i];
275
+ if (start > cut.to) starts[i] = start - (cut.to - cut.from) + 1;
276
+ else if (start > cut.from) starts[i] = cut.from + 2;
277
+ }
278
+ limit = cut.from;
279
+ }
219
280
  return result;
220
281
  }
221
- /** Linearly interpolates values by distance along a polyline, not vertex count. */
222
- function interpolatePolylineValues(points, startValue, endValue) {
223
- const distances = [0];
282
+ /** Edge length, in offsets, within which {@link foldCrossing} looks for a fold's loop. */
283
+ const FOLD_REACH_OFFSETS = 4;
284
+ /**
285
+ * The crossing that closes the loop around folded segments `first … last`:
286
+ * segment `from` at or before the run crossing segment `to` at or after it,
287
+ * picking the tightest such pair. Only segments within `reach` of edge length
288
+ * of the run are tried, which keeps the search local on densely sampled
289
+ * curves.
290
+ */
291
+ function foldCrossing(edge, first, last, limit, reach) {
292
+ let best = null;
293
+ let back = 0;
294
+ for (let from = first; from >= 0; from--) {
295
+ if (from < first - 1) back += vecMag(vecSub(edge[from + 2], edge[from + 1]));
296
+ if (back > reach) break;
297
+ let ahead = 0;
298
+ for (let to = Math.max(last, from + 2); to < limit; to++) {
299
+ if (to > last + 1) ahead += vecMag(vecSub(edge[to], edge[to - 1]));
300
+ if (ahead > reach) break;
301
+ if (best && to - from >= best.to - best.from) break;
302
+ const point = lineIntersection(edge[from], edge[from + 1], edge[to], edge[to + 1]);
303
+ if (point) best = {
304
+ from,
305
+ to,
306
+ point
307
+ };
308
+ }
309
+ if (best && last - from >= best.to - best.from) break;
310
+ }
311
+ return best;
312
+ }
313
+ const MITER_LIMIT = 3;
314
+ /** Unit left normal of the segment `a → b`. */
315
+ function segmentNormal(a, b) {
316
+ const dir = vecNorm(vecSub(b, a));
317
+ return [-dir[1], dir[0]];
318
+ }
319
+ /**
320
+ * The unit bisector normal at a join of unit directions `v1` → `v2`, and the
321
+ * miter-limited factor that scales it to one unit of perpendicular offset.
322
+ */
323
+ function miterJoin(v1, v2) {
324
+ const tangent = vecNorm(vecAdd(v1, v2));
325
+ const miter = [-tangent[1], tangent[0]];
326
+ const dot = vecDot(miter, [-v1[1], v1[0]]);
327
+ const scaleFactor = Math.abs(dot) < 1e-6 ? 1 : 1 / dot;
328
+ return {
329
+ miter,
330
+ scale: Math.min(scaleFactor, MITER_LIMIT)
331
+ };
332
+ }
333
+ /** Whether an offset of sign `side` lies on the outside of the `v1` → `v2` turn. */
334
+ function isOuterJoin(v1, v2, side) {
335
+ const cross = v1[0] * v2[1] - v1[1] * v2[0];
336
+ return side > 0 && cross < -1e-6 || side < 0 && cross > 1e-6;
337
+ }
338
+ /**
339
+ * The offset vector that {@link offsetPolylineSides} applies per unit half-width
340
+ * at vertex `index` on side `sign` (`1` left, `-1` right): the end-segment
341
+ * normal at the ends, the capped miter at interior vertices, and the unit
342
+ * bisector on the outer side of a rounded join (the midpoint of its arc). So
343
+ * `vertex + direction * halfWidth` lies on the drawn edge.
344
+ */
345
+ function offsetDirectionAt(points, index, sign, rounded) {
346
+ const p = points[index];
347
+ if (!p || points.length < 2) return null;
348
+ const prev = points[index - 1];
349
+ const next = points[index + 1];
350
+ if (!prev) return vecScale(segmentNormal(p, next), sign);
351
+ if (!next) return vecScale(segmentNormal(prev, p), sign);
352
+ const v1 = vecNorm(vecSub(p, prev));
353
+ const v2 = vecNorm(vecSub(next, p));
354
+ const { miter, scale } = miterJoin(v1, v2);
355
+ return vecScale(miter, rounded && isOuterJoin(v1, v2, sign) ? sign : scale * sign);
356
+ }
357
+ /** Cumulative arc length at each polyline vertex (`0` at the first). */
358
+ function cumulativeDistances(points) {
359
+ const distances = points.length > 0 ? [0] : [];
224
360
  for (let i = 1; i < points.length; i++) distances.push(distances[i - 1] + vecMag(vecSub(points[i], points[i - 1])));
225
- const totalLength = distances.at(-1) ?? 0;
226
- if (totalLength < 1e-6) return points.map(() => endValue);
227
- return distances.map((distance) => {
228
- const progress = distance / totalLength;
229
- return startValue + (endValue - startValue) * progress;
230
- });
361
+ return distances;
231
362
  }
232
363
  function vecAdd(a, b) {
233
364
  return [a[0] + b[0], a[1] + b[1]];
@@ -1353,11 +1484,486 @@ const supportByFireDrawRule = {
1353
1484
  }
1354
1485
  };
1355
1486
  //#endregion
1487
+ //#region src/internal/sketch.ts
1488
+ /**
1489
+ * Projects `coordinates` and derives the arrow's tip, heading and total length.
1490
+ * Returns `null` for degenerate input (fewer than two points, a zero-length
1491
+ * path, or a zero-length final segment) so the caller can decide what to emit.
1492
+ */
1493
+ function arrowAxis(coordinates) {
1494
+ if (coordinates.length < 2) return null;
1495
+ const pts = coordinates.map((c) => project(c[0], c[1]));
1496
+ let pathLength = 0;
1497
+ for (let i = 0; i < pts.length - 1; i++) pathLength += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
1498
+ const tip = pts[pts.length - 1];
1499
+ const prev = pts[pts.length - 2];
1500
+ const hx = tip[0] - prev[0];
1501
+ const hy = tip[1] - prev[1];
1502
+ const segLength = Math.hypot(hx, hy);
1503
+ if (segLength === 0 || pathLength === 0) return null;
1504
+ const dir = [hx / segLength, hy / segLength];
1505
+ const perp = [-dir[1], dir[0]];
1506
+ return {
1507
+ pts,
1508
+ pathLength,
1509
+ tip,
1510
+ dir,
1511
+ perp,
1512
+ segLength
1513
+ };
1514
+ }
1515
+ /**
1516
+ * Clamps and rounds a smooth-resolution option (samples per segment) onto
1517
+ * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1518
+ * for non-finite input. The default varies per measure, so it's passed in.
1519
+ */
1520
+ function normalizeSmoothResolution$1(value, fallback) {
1521
+ if (!Number.isFinite(value)) return fallback;
1522
+ return Math.min(64, Math.max(2, Math.round(value)));
1523
+ }
1524
+ /**
1525
+ * Shared param descriptors for {@link SmoothLineOptions}, modeled on
1526
+ * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
1527
+ * their own params should spread these at the top of the array.
1528
+ */
1529
+ const SMOOTH_LINE_PARAMS = [{
1530
+ key: "smooth",
1531
+ label: "Smooth",
1532
+ description: "Round the line's corners by curving the path through the control points.",
1533
+ type: "boolean"
1534
+ }, {
1535
+ key: "smoothResolution",
1536
+ presentationTier: "advanced",
1537
+ label: "Smooth resolution",
1538
+ description: "Number of samples per segment when smooth mode is enabled.",
1539
+ type: "number",
1540
+ min: 2,
1541
+ max: 64,
1542
+ step: 1,
1543
+ visibleWhen: (opts) => Boolean(opts.smooth)
1544
+ }];
1545
+ /**
1546
+ * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
1547
+ * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
1548
+ * is set, else returns `verts` unchanged.
1549
+ */
1550
+ function smoothLineVerts(verts, options, defaultResolution) {
1551
+ return options.smooth ? catmullRom(verts, normalizeSmoothResolution$1(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1552
+ }
1553
+ /**
1554
+ * Resamples a path into equal arc-length segments.
1555
+ *
1556
+ * This is useful for a repeated motif (such as a fortified tooth or FLOT
1557
+ * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1558
+ * parameter, not distance, so using them directly as motif boundaries causes
1559
+ * the motifs to bunch up where the curve's samples are closer together.
1560
+ */
1561
+ function evenlySpacePath(points, targetSegmentLength) {
1562
+ if (points.length < 2) return points;
1563
+ const segmentLengths = [];
1564
+ let totalLength = 0;
1565
+ for (let i = 0; i < points.length - 1; i++) {
1566
+ const length = vecMag(vecSub(points[i + 1], points[i]));
1567
+ segmentLengths.push(length);
1568
+ totalLength += length;
1569
+ }
1570
+ if (totalLength < 1e-6) return [points[0]];
1571
+ const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1572
+ const segmentLength = totalLength / segmentCount;
1573
+ const out = [points[0]];
1574
+ let sourceIndex = 0;
1575
+ let sourceStart = points[0];
1576
+ let consumed = 0;
1577
+ for (let segment = 1; segment < segmentCount; segment++) {
1578
+ const target = segment * segmentLength;
1579
+ while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1580
+ consumed += segmentLengths[sourceIndex];
1581
+ sourceIndex++;
1582
+ sourceStart = points[sourceIndex];
1583
+ }
1584
+ const length = segmentLengths[sourceIndex];
1585
+ if (length < 1e-6) continue;
1586
+ const t = (target - consumed) / length;
1587
+ const sourceEnd = points[sourceIndex + 1];
1588
+ out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
1589
+ }
1590
+ out.push(points[points.length - 1]);
1591
+ return out;
1592
+ }
1593
+ /**
1594
+ * Samples one centripetal (alpha = 0.5) Catmull-Rom segment from `p1` to `p2`
1595
+ * at `t = s / samples` for `s` in `[first, last]`, appending to `out`. Uses the
1596
+ * Hermite form (Yuksel et al.); centripetal knots keep the curve from
1597
+ * overshooting or looping next to a much shorter neighbouring segment.
1598
+ */
1599
+ function sampleCatmullRomSegment(out, [p0, p1, p2, p3], samples, first, last) {
1600
+ const knot = (a, b) => Math.sqrt(vecMag(vecSub(b, a)));
1601
+ const d1 = knot(p1, p2);
1602
+ const d0 = knot(p0, p1) || d1;
1603
+ const d2 = knot(p2, p3) || d1;
1604
+ const tangent = (a, b, c, da, db) => d1 < 1e-12 ? [0, 0] : [((b[0] - a[0]) / da - (c[0] - a[0]) / (da + db) + (c[0] - b[0]) / db) * d1, ((b[1] - a[1]) / da - (c[1] - a[1]) / (da + db) + (c[1] - b[1]) / db) * d1];
1605
+ const m1 = tangent(p0, p1, p2, d0, d1);
1606
+ const m2 = tangent(p1, p2, p3, d1, d2);
1607
+ for (let s = first; s <= last; s++) {
1608
+ const t = s / samples;
1609
+ const t2 = t * t;
1610
+ const t3 = t2 * t;
1611
+ const h00 = 2 * t3 - 3 * t2 + 1;
1612
+ const h10 = t3 - 2 * t2 + t;
1613
+ const h01 = -2 * t3 + 3 * t2;
1614
+ const h11 = t3 - t2;
1615
+ out.push([h00 * p1[0] + h10 * m1[0] + h01 * p2[0] + h11 * m2[0], h00 * p1[1] + h10 * m1[1] + h01 * p2[1] + h11 * m2[1]]);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
1620
+ * segment. Endpoints are duplicated so the curve passes through them, so the
1621
+ * first and last points are preserved exactly. An `endDirection` instead makes
1622
+ * the curve arrive at the last point heading that way. With `samples <= 1` (or
1623
+ * fewer than 3 points) it returns the input unchanged.
1624
+ */
1625
+ function catmullRom(points, samples, endDirection) {
1626
+ if (points.length < 3 || samples <= 1) return points;
1627
+ const last = points[points.length - 1];
1628
+ const beforeLast = points[points.length - 2];
1629
+ const reach = vecMag(vecSub(last, beforeLast));
1630
+ const end = endDirection ? [last[0] + endDirection[0] * reach, last[1] + endDirection[1] * reach] : last;
1631
+ const pts = [
1632
+ points[0],
1633
+ ...points,
1634
+ end
1635
+ ];
1636
+ const out = [points[0]];
1637
+ for (let i = 1; i < pts.length - 2; i++) sampleCatmullRomSegment(out, [
1638
+ pts[i - 1],
1639
+ pts[i],
1640
+ pts[i + 1],
1641
+ pts[i + 2]
1642
+ ], samples, 1, samples);
1643
+ return out;
1644
+ }
1645
+ /**
1646
+ * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
1647
+ * wrapping the control points around the seam. Returns a ring whose first and
1648
+ * last positions coincide (explicit closure). With `samples <= 1` (or fewer
1649
+ * than 3 points) it returns the input unchanged.
1650
+ */
1651
+ function closedCatmullRom(points, samples) {
1652
+ const n = points.length;
1653
+ if (n < 3 || samples <= 1) return points;
1654
+ const out = [];
1655
+ for (let i = 0; i < n; i++) sampleCatmullRomSegment(out, [
1656
+ points[(i - 1 + n) % n],
1657
+ points[i],
1658
+ points[(i + 1) % n],
1659
+ points[(i + 2) % n]
1660
+ ], samples, 0, samples - 1);
1661
+ out.push([...out[0]]);
1662
+ return out;
1663
+ }
1664
+ //#endregion
1665
+ //#region src/internal/vertex-widths.ts
1666
+ /** The option key holding each side's per-vertex widths. */
1667
+ const VERTEX_WIDTH_OPTION_KEYS = {
1668
+ left: "vertexLeftWidthRatios",
1669
+ right: "vertexRightWidthRatios"
1670
+ };
1671
+ /** The `vertexAlignedOptions` every variable-width arrow body declares. */
1672
+ const VERTEX_WIDTH_ALIGNED_OPTIONS = [VERTEX_WIDTH_OPTION_KEYS.left, VERTEX_WIDTH_OPTION_KEYS.right];
1673
+ /** Maps a body-spine index (rear = 0) to its Axis1 control-point index. */
1674
+ function spineIndexToControlPoint(spineIndex, controlPointCount) {
1675
+ return controlPointCount - 2 - spineIndex;
1676
+ }
1677
+ /**
1678
+ * Resolves both sides' per-vertex widths onto the body spine (rear → neck),
1679
+ * clamping each set ratio to `[min, max]`. Returns `null` when no shaft vertex
1680
+ * carries a usable width on either side, which callers treat as "use the plain
1681
+ * rear → shaft taper" so graphics without the options run exactly the code they
1682
+ * ran before.
1683
+ */
1684
+ function resolveSpineWidthRatios(options, controlPointCount, min, max) {
1685
+ const left = resolveSide(options.vertexLeftWidthRatios, controlPointCount, min, max);
1686
+ const right = resolveSide(options.vertexRightWidthRatios, controlPointCount, min, max);
1687
+ return left || right ? {
1688
+ left,
1689
+ right
1690
+ } : null;
1691
+ }
1692
+ function resolveSide(ratios, controlPointCount, min, max) {
1693
+ if (!ratios?.length) return null;
1694
+ const spineCount = controlPointCount - 2;
1695
+ if (spineCount < 1) return null;
1696
+ let found = false;
1697
+ const resolved = new Array(spineCount).fill(null);
1698
+ for (let j = 0; j < spineCount; j++) {
1699
+ const value = ratios[spineIndexToControlPoint(j, controlPointCount)];
1700
+ if (!isFiniteNumber(value)) continue;
1701
+ resolved[j] = clamp(value, min, max);
1702
+ found = true;
1703
+ }
1704
+ return found ? resolved : null;
1705
+ }
1706
+ /**
1707
+ * One value per polyline vertex, given each vertex's cumulative arc length
1708
+ * (see {@link cumulativeDistances}): pinned to `startValue` / `endValue` at the
1709
+ * two ends, taken from `setValues` where set, and interpolated linearly by arc
1710
+ * length between set neighbours elsewhere. `setValues` is parallel to the
1711
+ * vertices; its first and last entries are ignored and missing entries count
1712
+ * as unset.
1713
+ */
1714
+ function anchoredPolylineValues(distances, startValue, endValue, setValues) {
1715
+ const n = distances.length;
1716
+ if (n === 0) return [];
1717
+ if (n === 1) return [endValue];
1718
+ const values = new Array(n);
1719
+ values[0] = startValue;
1720
+ let previous = 0;
1721
+ for (let j = 1; j < n; j++) {
1722
+ const set = j === n - 1 ? endValue : setValues[j];
1723
+ if (set === null || set === void 0) continue;
1724
+ values[j] = set;
1725
+ const from = values[previous];
1726
+ const span = distances[j] - distances[previous];
1727
+ for (let k = previous + 1; k < j; k++) {
1728
+ const t = span < 1e-6 ? 1 : (distances[k] - distances[previous]) / span;
1729
+ values[k] = from + (set - from) * t;
1730
+ }
1731
+ previous = j;
1732
+ }
1733
+ return values;
1734
+ }
1735
+ /**
1736
+ * Linearly reads per-vertex `values` at arc-length fraction `t`, given each
1737
+ * vertex's cumulative arc length (see {@link cumulativeDistances}).
1738
+ */
1739
+ function valueAtFraction(distances, values, t) {
1740
+ const n = Math.min(distances.length, values.length);
1741
+ if (n === 0) return 0;
1742
+ if (n === 1) return values[0];
1743
+ const target = clamp(t, 0, 1) * distances[n - 1];
1744
+ for (let i = 1; i < n; i++) if (target <= distances[i]) {
1745
+ const length = distances[i] - distances[i - 1];
1746
+ const f = length < 1e-6 ? 1 : (target - distances[i - 1]) / length;
1747
+ return values[i - 1] + (values[i] - values[i - 1]) * f;
1748
+ }
1749
+ return values[n - 1];
1750
+ }
1751
+ /**
1752
+ * Resamples an authored shaft centerline onto a Catmull-Rom curve with
1753
+ * `samples` drawn points per segment, so authored vertex `j` lands at drawn
1754
+ * index `j * stride`. Fewer than three vertices or two samples leave the spine
1755
+ * as authored, with a stride of 1.
1756
+ */
1757
+ function curveSpine(authored, samples, endDirection) {
1758
+ if (authored.length < 3 || samples < 2) return {
1759
+ spine: authored,
1760
+ stride: 1
1761
+ };
1762
+ return {
1763
+ spine: catmullRom(authored, samples, endDirection),
1764
+ stride: samples
1765
+ };
1766
+ }
1767
+ /**
1768
+ * Outlines a shaft whose two sides each run from the rear half-width to the
1769
+ * base half-width, following their own set ratios in between. Only the
1770
+ * authored vertices before the shaft end may carry a set width (the head may
1771
+ * have swallowed the rest; `ratios` is a longer prefix then). A side's own rear
1772
+ * entry overrides the shared rear width.
1773
+ *
1774
+ * With `stride` 1 widths change linearly between the authored vertices; on a
1775
+ * resampled curve they ease smoothly between set vertices (see
1776
+ * {@link smoothAnchoredValues}). Without ratios both sides share the plain
1777
+ * rear → base taper.
1778
+ */
1779
+ function variableWidthShaft(input) {
1780
+ const { spine, stride, rearHalfWidth, baseHalfWidth, ratios, ratioToHalfWidth } = input;
1781
+ const distances = cumulativeDistances(spine);
1782
+ const setCount = (spine.length - 1) / stride;
1783
+ const side = (sideRatios) => {
1784
+ const set = [];
1785
+ for (let j = 0; j < setCount; j++) {
1786
+ const ratio = sideRatios?.[j];
1787
+ set.push(ratio === null || ratio === void 0 ? null : ratio * ratioToHalfWidth);
1788
+ }
1789
+ const rear = set[0] ?? rearHalfWidth;
1790
+ return stride === 1 ? anchoredPolylineValues(distances, rear, baseHalfWidth, set) : smoothAnchoredValues(distances, rear, baseHalfWidth, set, stride);
1791
+ };
1792
+ const left = side(ratios?.left);
1793
+ const right = ratios ? side(ratios.right) : left;
1794
+ const edges = offsetPolylineSides(spine, left, input.rounded, input.roundSegments, right);
1795
+ return {
1796
+ leftEdge: edges.left,
1797
+ rightEdge: edges.right,
1798
+ profile: {
1799
+ spine,
1800
+ stride,
1801
+ left,
1802
+ right,
1803
+ ratios,
1804
+ rounded: input.rounded,
1805
+ edgeStarts: {
1806
+ left: edges.leftStarts,
1807
+ right: edges.rightStarts
1808
+ }
1809
+ }
1810
+ };
1811
+ }
1812
+ const REAR_GRIP_SIDES = ["left"];
1813
+ const VERTEX_GRIP_SIDES = ["left", "right"];
1814
+ /** Authored vertices behind a profile's drawn spine (rear … shaft end). */
1815
+ function authoredCount(profile) {
1816
+ return (profile.spine.length - 1) / profile.stride + 1;
1817
+ }
1818
+ function vertexWidthGrip(profile, controlPointCount, side, spineIndex) {
1819
+ const index = spineIndex * profile.stride;
1820
+ const vertex = profile.spine[index];
1821
+ const halfWidth = profile[side][index];
1822
+ if (!vertex || halfWidth === void 0) return null;
1823
+ const direction = offsetDirectionAt(profile.spine, index, side === "left" ? 1 : -1, profile.rounded);
1824
+ if (!direction) return null;
1825
+ return {
1826
+ controlPointIndex: spineIndexToControlPoint(spineIndex, controlPointCount),
1827
+ side,
1828
+ vertex,
1829
+ direction,
1830
+ halfWidth,
1831
+ set: isFiniteNumber(profile.ratios?.[side]?.[spineIndex])
1832
+ };
1833
+ }
1834
+ /**
1835
+ * The width grips for a shaft: a left and a right grip per interior vertex that
1836
+ * came from a control point, each on its own edge, plus a left grip at the rear
1837
+ * (the shared `rear-width` handle already sits on the right rear corner and
1838
+ * moves both sides until the left is set). The shaft end belongs to
1839
+ * `shaftWidthRatio` and gets none.
1840
+ */
1841
+ function buildVertexWidthGrips(profile, controlPointCount) {
1842
+ const grips = [];
1843
+ const count = authoredCount(profile);
1844
+ for (let j = 0; j < count - 1; j++) for (const side of j === 0 ? REAR_GRIP_SIDES : VERTEX_GRIP_SIDES) {
1845
+ const grip = vertexWidthGrip(profile, controlPointCount, side, j);
1846
+ if (grip) grips.push(grip);
1847
+ }
1848
+ return grips;
1849
+ }
1850
+ /** The one grip of {@link buildVertexWidthGrips} for `side` at control point `index`. */
1851
+ function findVertexWidthGrip(profile, controlPointCount, side, controlPointIndex) {
1852
+ const j = spineIndexToControlPoint(controlPointIndex, controlPointCount);
1853
+ if (j < 0 || j >= authoredCount(profile) - 1 || j === 0 && side === "right") return null;
1854
+ return vertexWidthGrip(profile, controlPointCount, side, j);
1855
+ }
1856
+ const VERTEX_WIDTH_HANDLE_PREFIX = "vertex-width-";
1857
+ const VERTEX_WIDTH_HANDLE_PATTERN = new RegExp(`^${VERTEX_WIDTH_HANDLE_PREFIX}(left|right)-(\\d+)$`);
1858
+ /** Handle id for the `side` width grip stored under control point `index`. */
1859
+ function vertexWidthHandleId(side, controlPointIndex) {
1860
+ return `${VERTEX_WIDTH_HANDLE_PREFIX}${side}-${controlPointIndex}`;
1861
+ }
1862
+ /** Parses a {@link vertexWidthHandleId}; `null` for any other handle id. */
1863
+ function parseVertexWidthHandleId(handleId) {
1864
+ const match = VERTEX_WIDTH_HANDLE_PATTERN.exec(handleId);
1865
+ if (!match) return null;
1866
+ return {
1867
+ side: match[1],
1868
+ controlPointIndex: Number(match[2])
1869
+ };
1870
+ }
1871
+ /**
1872
+ * Returns `ratios` with entry `index` replaced by `value`, padding with `null`
1873
+ * as needed. A `null` value trims trailing `null`s, and an all-`null` result
1874
+ * collapses to `undefined` so a fully reset option drops out of the options.
1875
+ */
1876
+ function withVertexWidth(ratios, index, value) {
1877
+ const next = ratios ? [...ratios] : [];
1878
+ while (next.length <= index) next.push(null);
1879
+ next[index] = value;
1880
+ while (next.length > 0 && !isFiniteNumber(next.at(-1))) next.pop();
1881
+ return next.length > 0 ? next : void 0;
1882
+ }
1883
+ /**
1884
+ * Smooth counterpart of {@link anchoredPolylineValues} for a curved (densely
1885
+ * sampled) spine, given each drawn vertex's cumulative arc length. The knots are the two ends and every set authored vertex;
1886
+ * values in between follow a monotone cubic in arc length, so the width eases
1887
+ * in and out of each set vertex without overshooting (a pinch never dips
1888
+ * below its own width). Authored vertex `j` sits at drawn index `j * stride`;
1889
+ * `setValues` is parallel to the authored vertices.
1890
+ */
1891
+ function smoothAnchoredValues(distances, startValue, endValue, setValues, stride) {
1892
+ const n = distances.length;
1893
+ if (n === 0) return [];
1894
+ if (n === 1) return [endValue];
1895
+ const xs = [0];
1896
+ const ys = [startValue];
1897
+ for (let j = 1; j * stride < n - 1; j++) {
1898
+ const value = setValues[j];
1899
+ if (value === null || value === void 0) continue;
1900
+ const x = distances[j * stride];
1901
+ if (x - xs.at(-1) < 1e-6) continue;
1902
+ xs.push(x);
1903
+ ys.push(value);
1904
+ }
1905
+ const total = distances[n - 1];
1906
+ if (total - xs.at(-1) < 1e-6) ys[ys.length - 1] = endValue;
1907
+ else {
1908
+ xs.push(total);
1909
+ ys.push(endValue);
1910
+ }
1911
+ if (xs.length < 2) return distances.map(() => endValue);
1912
+ const slopes = monotoneTangents(xs, ys);
1913
+ const values = new Array(n);
1914
+ let k = 0;
1915
+ for (let i = 0; i < n; i++) {
1916
+ const x = distances[i];
1917
+ while (k < xs.length - 2 && x > xs[k + 1]) k++;
1918
+ const h = xs[k + 1] - xs[k];
1919
+ const t = clamp((x - xs[k]) / h, 0, 1);
1920
+ const t2 = t * t;
1921
+ const t3 = t2 * t;
1922
+ values[i] = (2 * t3 - 3 * t2 + 1) * ys[k] + (t3 - 2 * t2 + t) * h * slopes[k] + (-2 * t3 + 3 * t2) * ys[k + 1] + (t3 - t2) * h * slopes[k + 1];
1923
+ }
1924
+ return values;
1925
+ }
1926
+ /**
1927
+ * Knot tangents for a monotone piecewise-cubic Hermite interpolant
1928
+ * (Fritsch–Butland): zero at local extrema, a weighted harmonic mean of the
1929
+ * neighbouring secants elsewhere, and the adjacent secant at the two ends.
1930
+ */
1931
+ function monotoneTangents(xs, ys) {
1932
+ const m = xs.length;
1933
+ const secants = [];
1934
+ for (let k = 0; k < m - 1; k++) secants.push((ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k]));
1935
+ const tangents = [secants[0]];
1936
+ for (let k = 1; k < m - 1; k++) {
1937
+ const d0 = secants[k - 1];
1938
+ const d1 = secants[k];
1939
+ if (d0 * d1 <= 0) {
1940
+ tangents.push(0);
1941
+ continue;
1942
+ }
1943
+ const h0 = xs[k] - xs[k - 1];
1944
+ const h1 = xs[k + 1] - xs[k];
1945
+ tangents.push(3 * (h0 + h1) / ((2 * h1 + h0) / d0 + (h1 + 2 * h0) / d1));
1946
+ }
1947
+ tangents.push(secants[m - 2]);
1948
+ return tangents;
1949
+ }
1950
+ //#endregion
1356
1951
  //#region src/attack-utils.ts
1357
1952
  const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
1358
1953
  const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
1359
1954
  const SHAFT_MIN_RATIO = .1;
1360
1955
  const SHAFT_MAX_RATIO = .9;
1956
+ const DEFAULT_ATTACK_SMOOTH_MODE = "rounded-corners";
1957
+ /** Curve samples per shaft segment for each unit of `smoothResolution`. */
1958
+ const CURVE_SAMPLES_PER_RESOLUTION = 4;
1959
+ /** Shaft defaults shared by every variable-width attack body. */
1960
+ const DEFAULT_ATTACK_SHAFT_OPTIONS = {
1961
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
1962
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
1963
+ smooth: false,
1964
+ smoothMode: DEFAULT_ATTACK_SMOOTH_MODE,
1965
+ smoothResolution: 5
1966
+ };
1361
1967
  const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
1362
1968
  const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1363
1969
  /**
@@ -1368,30 +1974,72 @@ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1368
1974
  * matching centerline segment, over the reference half-width) live here once.
1369
1975
  */
1370
1976
  function createWidthOptionHandles(config) {
1977
+ const vertexWidthPatch = (options, side, index, value) => {
1978
+ const key = VERTEX_WIDTH_OPTION_KEYS[side];
1979
+ return { [key]: withVertexWidth(options[key], index, value) };
1980
+ };
1981
+ const rightRearOverride = (controlPoints, options) => {
1982
+ const rearIndex = spineIndexToControlPoint(0, controlPoints.length);
1983
+ return isFiniteNumber(options.vertexRightWidthRatios?.[rearIndex]) ? rearIndex : null;
1984
+ };
1371
1985
  return {
1372
- get(controlPoints, options) {
1986
+ get(controlPoints, options, query) {
1373
1987
  const geometry = config.geometry(controlPoints, options);
1374
1988
  const rearRight = geometry?.rightEdge[0];
1375
1989
  const neckRight = geometry?.rightEdge.at(-1);
1376
- if (!rearRight || !neckRight) return [];
1377
- return [{
1990
+ if (!geometry || !rearRight || !neckRight) return [];
1991
+ const handles = [{
1378
1992
  id: REAR_WIDTH_OPTION_HANDLE_ID,
1379
1993
  position: unproject(rearRight[0], rearRight[1])
1380
1994
  }, {
1381
1995
  id: SHAFT_WIDTH_OPTION_HANDLE_ID,
1382
1996
  position: unproject(neckRight[0], neckRight[1])
1383
1997
  }];
1998
+ if (!query?.detail) return handles;
1999
+ for (const grip of buildVertexWidthGrips(geometry.profile, geometry.controlPointCount)) {
2000
+ const point = vecAdd(grip.vertex, vecScale(grip.direction, grip.halfWidth));
2001
+ handles.push({
2002
+ id: vertexWidthHandleId(grip.side, grip.controlPointIndex),
2003
+ position: unproject(point[0], point[1]),
2004
+ ...grip.set ? {} : { unset: true }
2005
+ });
2006
+ }
2007
+ return handles;
1384
2008
  },
1385
2009
  drag({ controlPoints, options, handleId, position }) {
2010
+ const target = parseVertexWidthHandleId(handleId);
2011
+ if (target) {
2012
+ const geometry = config.geometry(controlPoints, options);
2013
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
2014
+ const grip = findVertexWidthGrip(geometry.profile, geometry.controlPointCount, target.side, target.controlPointIndex);
2015
+ if (!grip) return void 0;
2016
+ const lengthSq = vecDot(grip.direction, grip.direction);
2017
+ if (lengthSq < 1e-6) return void 0;
2018
+ const ratio = clamp(vecDot(vecSub(project(position[0], position[1]), grip.vertex), grip.direction) / lengthSq / geometry.referenceHalfWidth, config.minRatio, config.maxRearRatio);
2019
+ return vertexWidthPatch(options, target.side, target.controlPointIndex, ratio);
2020
+ }
1386
2021
  const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
1387
2022
  if (!rear && !(handleId === "shaft-width")) return void 0;
1388
2023
  const geometry = config.geometry(controlPoints, options);
1389
2024
  if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
1390
- const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
1391
- const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
2025
+ const { spine } = geometry.profile;
2026
+ const from = rear ? spine[0] : spine.at(-2);
2027
+ const to = rear ? spine[1] : spine.at(-1);
1392
2028
  if (!from || !to) return void 0;
1393
2029
  const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
1394
- return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
2030
+ if (!rear) return { shaftWidthRatio: ratio };
2031
+ const rearIndex = rightRearOverride(controlPoints, options);
2032
+ if (rearIndex !== null) return vertexWidthPatch(options, "right", rearIndex, ratio);
2033
+ return { rearWidthRatio: ratio };
2034
+ },
2035
+ reset({ controlPoints, options, handleId }) {
2036
+ if (handleId === "rear-width") {
2037
+ const rearIndex = rightRearOverride(controlPoints, options);
2038
+ return rearIndex === null ? void 0 : vertexWidthPatch(options, "right", rearIndex, null);
2039
+ }
2040
+ const target = parseVertexWidthHandleId(handleId);
2041
+ if (!target || !options[VERTEX_WIDTH_OPTION_KEYS[target.side]]) return void 0;
2042
+ return vertexWidthPatch(options, target.side, target.controlPointIndex, null);
1395
2043
  }
1396
2044
  };
1397
2045
  }
@@ -1410,9 +2058,10 @@ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) =>
1410
2058
  const outerLeft = geometry?.headRing[0];
1411
2059
  if (!geometry || !outerLeft) return null;
1412
2060
  return {
1413
- spine: geometry.shaftCenterline,
2061
+ profile: geometry.shaftProfile,
1414
2062
  rightEdge: geometry.shaftRight,
1415
- referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
2063
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase)),
2064
+ controlPointCount: coordinates.length
1416
2065
  };
1417
2066
  },
1418
2067
  minRatio: SHAFT_MIN_RATIO,
@@ -1429,11 +2078,48 @@ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles(
1429
2078
  function resolveRearWidthRatio(options, shaftRatio) {
1430
2079
  return options.rearWidthRatio ?? shaftRatio;
1431
2080
  }
2081
+ /** The options {@link processAttackGeometry} reads; the cache compares only these. */
2082
+ const ATTACK_GEOMETRY_OPTION_KEYS = [
2083
+ "shaftWidthRatio",
2084
+ "rearWidthRatio",
2085
+ "smooth",
2086
+ "smoothResolution",
2087
+ "smoothMode",
2088
+ "vertexLeftWidthRatios",
2089
+ "vertexRightWidthRatios"
2090
+ ];
2091
+ /**
2092
+ * The last body built, by value. A width-grip drag builds the same body up to
2093
+ * three times per pointer move (the drag, the render, the next handle
2094
+ * placement), so one entry is enough. The result is shared, never mutated.
2095
+ */
2096
+ let lastAttackGeometry = null;
2097
+ function sameOptionValue(a, b) {
2098
+ if (Object.is(a, b)) return true;
2099
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2100
+ return a.every((value, i) => Object.is(value, b[i]));
2101
+ }
1432
2102
  /**
1433
2103
  * Processes input coordinates and options to generate the core symbol geometry.
1434
2104
  * This handles validation, default values, projection, and geometry calculation.
1435
2105
  */
1436
2106
  function processAttackGeometry(coordinates, options = {}) {
2107
+ const last = lastAttackGeometry;
2108
+ if (last && last.coordinates.length === coordinates.length * 2 && coordinates.every((c, i) => c[0] === last.coordinates[2 * i] && c[1] === last.coordinates[2 * i + 1]) && ATTACK_GEOMETRY_OPTION_KEYS.every((key) => sameOptionValue(options[key], last.options[key]))) return last.result;
2109
+ const result = buildAttackGeometry(coordinates, options);
2110
+ const snapshot = {};
2111
+ for (const key of ATTACK_GEOMETRY_OPTION_KEYS) {
2112
+ const value = options[key];
2113
+ snapshot[key] = Array.isArray(value) ? [...value] : value;
2114
+ }
2115
+ lastAttackGeometry = {
2116
+ coordinates: coordinates.flatMap((c) => [c[0], c[1]]),
2117
+ options: snapshot,
2118
+ result
2119
+ };
2120
+ return result;
2121
+ }
2122
+ function buildAttackGeometry(coordinates, options) {
1437
2123
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
1438
2124
  const smooth = options.smooth ?? false;
1439
2125
  const smoothResolution = options.smoothResolution ?? 5;
@@ -1447,9 +2133,9 @@ function processAttackGeometry(coordinates, options = {}) {
1447
2133
  const p = points[i];
1448
2134
  if (p) spinePoints.push(p);
1449
2135
  }
1450
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
2136
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth ? options.smoothMode ?? "rounded-corners" : null, smoothResolution, resolveSpineWidthRatios(options, numPoints, SHAFT_MIN_RATIO, 3)) };
1451
2137
  }
1452
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
2138
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smoothing, smoothResolution, spineWidthRatios = null) {
1453
2139
  const initialNeck = spine[spine.length - 1];
1454
2140
  if (!initialNeck) return null;
1455
2141
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -1502,15 +2188,48 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRat
1502
2188
  outerLeft
1503
2189
  ];
1504
2190
  const fullSpine = [...remainingSpine, shaftEndCenter];
1505
- const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
2191
+ const rearHalfWidth = headHalfWidth * rearWidthRatio;
2192
+ const { spine: drawnSpine, stride } = smoothing === "curve" ? curveSpine(fullSpine, normalizeSmoothResolution$1(smoothResolution * CURVE_SAMPLES_PER_RESOLUTION, 5 * CURVE_SAMPLES_PER_RESOLUTION), tipDir) : {
2193
+ spine: fullSpine,
2194
+ stride: 1
2195
+ };
2196
+ const { leftEdge: shaftLeft, rightEdge: shaftRight, profile: shaftProfile } = variableWidthShaft({
2197
+ spine: drawnSpine,
2198
+ stride,
2199
+ rearHalfWidth,
2200
+ baseHalfWidth: shaftHalfWidth,
2201
+ ratios: spineWidthRatios,
2202
+ ratioToHalfWidth: headHalfWidth,
2203
+ rounded: smoothing !== null && !(stride > 1),
2204
+ roundSegments: smoothResolution
2205
+ });
2206
+ shaftLeft[shaftLeft.length - 1] = innerLeft;
2207
+ shaftRight[shaftRight.length - 1] = innerRight;
1506
2208
  return {
1507
- shaftCenterline: fullSpine,
2209
+ shaftCenterline: drawnSpine,
1508
2210
  shaftLeft,
1509
2211
  shaftRight,
1510
2212
  headRing,
1511
2213
  ptTip,
1512
2214
  ptNeck,
1513
- ptBase
2215
+ ptBase,
2216
+ shaftProfile
2217
+ };
2218
+ }
2219
+ /**
2220
+ * The two shaft edges cut at the neck, for bodies whose outline crosses over
2221
+ * from the neck to the far side of the head (Airborne Attack, Attack
2222
+ * Helicopter). The edges run on to the head base; this keeps each edge up to
2223
+ * the neck, the last authored vertex before the base, which sits one `stride`
2224
+ * of drawn vertices back.
2225
+ */
2226
+ function shaftEdgesToNeck(geometry) {
2227
+ const { spine, stride, edgeStarts } = geometry.shaftProfile;
2228
+ const afterNeck = spine.length - stride;
2229
+ const toNeck = (edge, starts) => edge.slice(0, Math.max(1, starts[afterNeck] ?? edge.length));
2230
+ return {
2231
+ left: toNeck(geometry.shaftLeft, edgeStarts.left),
2232
+ right: toNeck(geometry.shaftRight, edgeStarts.right)
1514
2233
  };
1515
2234
  }
1516
2235
  /**
@@ -1877,162 +2596,6 @@ function keepTextLeftToRight(rotation) {
1877
2596
  return normalized;
1878
2597
  }
1879
2598
  //#endregion
1880
- //#region src/internal/sketch.ts
1881
- /**
1882
- * Projects `coordinates` and derives the arrow's tip, heading and total length.
1883
- * Returns `null` for degenerate input (fewer than two points, a zero-length
1884
- * path, or a zero-length final segment) so the caller can decide what to emit.
1885
- */
1886
- function arrowAxis(coordinates) {
1887
- if (coordinates.length < 2) return null;
1888
- const pts = coordinates.map((c) => project(c[0], c[1]));
1889
- let pathLength = 0;
1890
- for (let i = 0; i < pts.length - 1; i++) pathLength += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
1891
- const tip = pts[pts.length - 1];
1892
- const prev = pts[pts.length - 2];
1893
- const hx = tip[0] - prev[0];
1894
- const hy = tip[1] - prev[1];
1895
- const segLength = Math.hypot(hx, hy);
1896
- if (segLength === 0 || pathLength === 0) return null;
1897
- const dir = [hx / segLength, hy / segLength];
1898
- const perp = [-dir[1], dir[0]];
1899
- return {
1900
- pts,
1901
- pathLength,
1902
- tip,
1903
- dir,
1904
- perp,
1905
- segLength
1906
- };
1907
- }
1908
- /**
1909
- * Clamps and rounds a smooth-resolution option (samples per segment) onto
1910
- * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1911
- * for non-finite input. The default varies per measure, so it's passed in.
1912
- */
1913
- function normalizeSmoothResolution$2(value, fallback) {
1914
- if (!Number.isFinite(value)) return fallback;
1915
- return Math.min(64, Math.max(2, Math.round(value)));
1916
- }
1917
- /**
1918
- * Shared param descriptors for {@link SmoothLineOptions}, modeled on
1919
- * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
1920
- * their own params should spread these at the top of the array.
1921
- */
1922
- const SMOOTH_LINE_PARAMS = [{
1923
- key: "smooth",
1924
- label: "Smooth",
1925
- description: "Round the line's corners by curving the path through the control points.",
1926
- type: "boolean"
1927
- }, {
1928
- key: "smoothResolution",
1929
- presentationTier: "advanced",
1930
- label: "Smooth resolution",
1931
- description: "Number of samples per segment when smooth mode is enabled.",
1932
- type: "number",
1933
- min: 2,
1934
- max: 64,
1935
- step: 1,
1936
- visibleWhen: (opts) => Boolean(opts.smooth)
1937
- }];
1938
- /**
1939
- * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
1940
- * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
1941
- * is set, else returns `verts` unchanged.
1942
- */
1943
- function smoothLineVerts(verts, options, defaultResolution) {
1944
- return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1945
- }
1946
- /**
1947
- * Resamples a path into equal arc-length segments.
1948
- *
1949
- * This is useful for a repeated motif (such as a fortified tooth or FLOT
1950
- * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1951
- * parameter, not distance, so using them directly as motif boundaries causes
1952
- * the motifs to bunch up where the curve's samples are closer together.
1953
- */
1954
- function evenlySpacePath(points, targetSegmentLength) {
1955
- if (points.length < 2) return points;
1956
- const segmentLengths = [];
1957
- let totalLength = 0;
1958
- for (let i = 0; i < points.length - 1; i++) {
1959
- const length = vecMag(vecSub(points[i + 1], points[i]));
1960
- segmentLengths.push(length);
1961
- totalLength += length;
1962
- }
1963
- if (totalLength < 1e-6) return [points[0]];
1964
- const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1965
- const segmentLength = totalLength / segmentCount;
1966
- const out = [points[0]];
1967
- let sourceIndex = 0;
1968
- let sourceStart = points[0];
1969
- let consumed = 0;
1970
- for (let segment = 1; segment < segmentCount; segment++) {
1971
- const target = segment * segmentLength;
1972
- while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1973
- consumed += segmentLengths[sourceIndex];
1974
- sourceIndex++;
1975
- sourceStart = points[sourceIndex];
1976
- }
1977
- const length = segmentLengths[sourceIndex];
1978
- if (length < 1e-6) continue;
1979
- const t = (target - consumed) / length;
1980
- const sourceEnd = points[sourceIndex + 1];
1981
- out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
1982
- }
1983
- out.push(points[points.length - 1]);
1984
- return out;
1985
- }
1986
- /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1987
- function catmullRomAt(p0, p1, p2, p3, t) {
1988
- const t2 = t * t;
1989
- const t3 = t2 * t;
1990
- return [.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), .5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3)];
1991
- }
1992
- /**
1993
- * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
1994
- * segment. Endpoints are duplicated so the curve passes through them, so the
1995
- * first and last points are preserved exactly. With `samples <= 1` (or fewer
1996
- * than 3 points) it returns the input unchanged.
1997
- */
1998
- function catmullRom(points, samples) {
1999
- if (points.length < 3 || samples <= 1) return points;
2000
- const pts = [
2001
- points[0],
2002
- ...points,
2003
- points[points.length - 1]
2004
- ];
2005
- const out = [points[0]];
2006
- for (let i = 1; i < pts.length - 2; i++) {
2007
- const p0 = pts[i - 1];
2008
- const p1 = pts[i];
2009
- const p2 = pts[i + 1];
2010
- const p3 = pts[i + 2];
2011
- for (let s = 1; s <= samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
2012
- }
2013
- return out;
2014
- }
2015
- /**
2016
- * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
2017
- * wrapping the control points around the seam. Returns a ring whose first and
2018
- * last positions coincide (explicit closure). With `samples <= 1` (or fewer
2019
- * than 3 points) it returns the input unchanged.
2020
- */
2021
- function closedCatmullRom(points, samples) {
2022
- const n = points.length;
2023
- if (n < 3 || samples <= 1) return points;
2024
- const out = [];
2025
- for (let i = 0; i < n; i++) {
2026
- const p0 = points[(i - 1 + n) % n];
2027
- const p1 = points[i];
2028
- const p2 = points[(i + 1) % n];
2029
- const p3 = points[(i + 2) % n];
2030
- for (let s = 0; s < samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
2031
- }
2032
- out.push([...out[0]]);
2033
- return out;
2034
- }
2035
- //#endregion
2036
2599
  //#region src/internal/line-labels.ts
2037
2600
  /**
2038
2601
  * Stored label rotation for text running along projected direction `dir`.
@@ -3879,12 +4442,26 @@ const ATTACK_SHAFT_PARAMS = [
3879
4442
  key: "smoothResolution",
3880
4443
  presentationTier: "advanced",
3881
4444
  label: "Smooth resolution",
3882
- description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
4445
+ description: "Number of segments approximating each rounded bend when smooth mode is enabled. In curve style, each shaft segment is sampled at four times this resolution.",
3883
4446
  type: "number",
3884
4447
  min: 1,
3885
4448
  max: 20,
3886
4449
  step: 1,
3887
4450
  visibleWhen: (opts) => opts.smooth === true
4451
+ },
4452
+ {
4453
+ key: "smoothMode",
4454
+ label: "Smooth style",
4455
+ description: "Round only the corners where the shaft changes direction, or curve the whole shaft through the control points.",
4456
+ type: "enum",
4457
+ options: [{
4458
+ label: "Rounded corners",
4459
+ value: "rounded-corners"
4460
+ }, {
4461
+ label: "Curve",
4462
+ value: "curve"
4463
+ }],
4464
+ visibleWhen: (opts) => opts.smooth === true
3888
4465
  }
3889
4466
  ];
3890
4467
  const FIRE_ARROW_PARAMS = [
@@ -4122,12 +4699,7 @@ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d)
4122
4699
  const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
4123
4700
  //#endregion
4124
4701
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
4125
- const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
4126
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
4127
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
4128
- smooth: false,
4129
- smoothResolution: 5
4130
- };
4702
+ const DEFAULT_AIRBORNE_ATTACK_OPTIONS = { ...DEFAULT_ATTACK_SHAFT_OPTIONS };
4131
4703
  const AIRBORNE_ATTACK_METADATA = {
4132
4704
  id: "airborne-attack",
4133
4705
  name: "Airborne Attack",
@@ -4163,8 +4735,7 @@ function createAirborneAttack(coordinates, options = {}) {
4163
4735
  type: "FeatureCollection",
4164
4736
  features: []
4165
4737
  };
4166
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
4167
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
4738
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
4168
4739
  return {
4169
4740
  type: "FeatureCollection",
4170
4741
  features: [{
@@ -4190,7 +4761,8 @@ const AIRBORNE_ATTACK = defineControlMeasure({
4190
4761
  generator: createAirborneAttack,
4191
4762
  defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
4192
4763
  rule: axis1DrawRule,
4193
- optionHandles: variableWidthAttackOptionHandles
4764
+ optionHandles: variableWidthAttackOptionHandles,
4765
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
4194
4766
  });
4195
4767
  /**
4196
4768
  * Reacts to an **input-contract** violation according to `mode`: `throw`
@@ -5205,7 +5777,7 @@ function projectRingPoints(positions) {
5205
5777
  */
5206
5778
  function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
5207
5779
  const ringPts = projectRingPoints(positions);
5208
- return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5780
+ return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$1(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5209
5781
  }
5210
5782
  /**
5211
5783
  * Walks a closed ring into per-segment metadata (unit direction, outward
@@ -7337,10 +7909,7 @@ const ATTACK_BY_FIRE = defineControlMeasure({
7337
7909
  //#endregion
7338
7910
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
7339
7911
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
7340
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
7341
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
7342
- smooth: false,
7343
- smoothResolution: 5,
7912
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
7344
7913
  symbolHeightRatio: .45,
7345
7914
  triangleSizeRatio: .25,
7346
7915
  bottomBarWidthRatio: 1.5,
@@ -7423,15 +7992,14 @@ function createAttackHelicopter(coordinates, options = {}) {
7423
7992
  type: "FeatureCollection",
7424
7993
  features: []
7425
7994
  };
7426
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
7427
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
7428
7995
  if (geometry.shaftLeft.length < 2 || geometry.shaftRight.length < 2) return {
7429
7996
  type: "FeatureCollection",
7430
7997
  features: []
7431
7998
  };
7432
- const pL = geometry.shaftLeft[geometry.shaftLeft.length - 2];
7999
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
8000
+ const pL = shaftLeftToNeck.at(-1);
7433
8001
  const pIR = geometry.headRing[3];
7434
- const pR = geometry.shaftRight[geometry.shaftRight.length - 2];
8002
+ const pR = shaftRightToNeck.at(-1);
7435
8003
  const pIL = geometry.headRing[5];
7436
8004
  if (!pL || !pIR || !pR || !pIL) return {
7437
8005
  type: "FeatureCollection",
@@ -7558,7 +8126,8 @@ const ATTACK_HELICOPTER = defineControlMeasure({
7558
8126
  generator: createAttackHelicopter,
7559
8127
  defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
7560
8128
  rule: axis1DrawRule,
7561
- optionHandles: variableWidthAttackOptionHandles
8129
+ optionHandles: variableWidthAttackOptionHandles,
8130
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
7562
8131
  });
7563
8132
  //#endregion
7564
8133
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
@@ -8322,11 +8891,14 @@ const ROADBLOCK_COMPLETE = definition$1("roadblock-complete", "271204", "complet
8322
8891
  //#endregion
8323
8892
  //#region src/generators/cm99-generic-arrows/blockArrow.ts
8324
8893
  const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
8325
- const MIN_SMOOTH_RESOLUTION$1 = 2;
8326
- const MAX_SMOOTH_RESOLUTION$1 = 64;
8327
8894
  const DEFAULT_SHAFT_WIDTH_RATIO = .06;
8328
8895
  const MIN_SHAFT_WIDTH_RATIO = .01;
8329
8896
  const MAX_SHAFT_WIDTH_RATIO = .3;
8897
+ /**
8898
+ * Rear and per-vertex widths may flare well past the arrowhead; only the shaft
8899
+ * end, which meets the head, keeps the tighter shaft cap.
8900
+ */
8901
+ const MAX_FLARE_WIDTH_RATIO = 1;
8330
8902
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
8331
8903
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
8332
8904
  rearWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
@@ -8335,6 +8907,7 @@ const DEFAULT_BLOCK_ARROW_OPTIONS = {
8335
8907
  arrowheadLengthRatio: .22,
8336
8908
  smooth: false,
8337
8909
  smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4,
8910
+ smoothMode: "curve",
8338
8911
  filled: true
8339
8912
  };
8340
8913
  const BLOCK_ARROW_METADATA = {
@@ -8372,7 +8945,7 @@ const BLOCK_ARROW_METADATA = {
8372
8945
  description: "Width at the rear of the shaft as a fraction of the total path length",
8373
8946
  type: "number",
8374
8947
  min: MIN_SHAFT_WIDTH_RATIO,
8375
- max: MAX_SHAFT_WIDTH_RATIO,
8948
+ max: MAX_FLARE_WIDTH_RATIO,
8376
8949
  step: .01
8377
8950
  },
8378
8951
  {
@@ -8434,20 +9007,34 @@ const BLOCK_ARROW_METADATA = {
8434
9007
  {
8435
9008
  key: "smooth",
8436
9009
  label: "Smooth",
8437
- description: "Round the shaft's corners by curving it through the control points.",
9010
+ description: "Curve the shaft through the control points, or round its corners.",
8438
9011
  type: "boolean"
8439
9012
  },
8440
9013
  {
8441
9014
  key: "smoothResolution",
8442
9015
  presentationTier: "advanced",
8443
9016
  label: "Smooth resolution",
8444
- description: "Number of samples per segment when smooth mode is enabled",
9017
+ description: "Number of samples per segment when smooth mode is enabled. In rounded-corners style, the number of segments approximating each rounded corner.",
8445
9018
  type: "number",
8446
9019
  min: 2,
8447
9020
  max: 64,
8448
9021
  step: 1,
8449
9022
  visibleWhen: (opts) => Boolean(opts.smooth)
8450
9023
  },
9024
+ {
9025
+ key: "smoothMode",
9026
+ label: "Smooth style",
9027
+ description: "Curve the whole shaft through the control points, or round only the corners where it changes direction.",
9028
+ type: "enum",
9029
+ options: [{
9030
+ label: "Curve",
9031
+ value: "curve"
9032
+ }, {
9033
+ label: "Rounded corners",
9034
+ value: "rounded-corners"
9035
+ }],
9036
+ visibleWhen: (opts) => Boolean(opts.smooth)
9037
+ },
8451
9038
  {
8452
9039
  key: "filled",
8453
9040
  label: "Filled",
@@ -8555,10 +9142,23 @@ function calculateBlockArrowGeometry(coordinates, options) {
8555
9142
  ];
8556
9143
  break;
8557
9144
  }
8558
- let spine = [...pts.slice(0, -1), onAxis(shaftEndDist)];
8559
- if (options.smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(options.smoothResolution));
8560
- const widths = interpolatePolylineValues(spine, halfRear, halfShaft);
8561
- const { left: leftSide, right: rightSide } = offsetPolylineSides(spine, widths);
9145
+ const authored = [...pts.slice(0, -1), onAxis(shaftEndDist)];
9146
+ const samples = normalizeSmoothResolution$1(options.smoothResolution, DEFAULT_SMOOTH_RESOLUTION$4);
9147
+ const curved = options.smooth && options.smoothMode === "curve";
9148
+ const { spine, stride } = curved ? curveSpine(authored, samples) : {
9149
+ spine: authored,
9150
+ stride: 1
9151
+ };
9152
+ const { leftEdge: leftSide, rightEdge: rightSide, profile } = variableWidthShaft({
9153
+ spine,
9154
+ stride,
9155
+ rearHalfWidth: halfRear,
9156
+ baseHalfWidth: halfShaft,
9157
+ ratios: resolveSpineWidthRatios(options, coordinates.length, MIN_SHAFT_WIDTH_RATIO, MAX_FLARE_WIDTH_RATIO),
9158
+ ratioToHalfWidth: pathLength / 2,
9159
+ rounded: options.smooth && !curved,
9160
+ roundSegments: samples
9161
+ });
8562
9162
  const ring = [
8563
9163
  ...leftSide,
8564
9164
  ...headPts,
@@ -8567,10 +9167,9 @@ function calculateBlockArrowGeometry(coordinates, options) {
8567
9167
  ring.push(ring[0]);
8568
9168
  return {
8569
9169
  ring,
8570
- spine,
8571
- leftSide,
8572
9170
  rightSide,
8573
- pathLength
9171
+ pathLength,
9172
+ profile
8574
9173
  };
8575
9174
  }
8576
9175
  function resolveBlockArrowOptions(options) {
@@ -8593,15 +9192,17 @@ const BLOCK_ARROW = defineControlMeasure({
8593
9192
  const geometry = calculateBlockArrowGeometry(controlPoints, resolveBlockArrowOptions(options));
8594
9193
  if (!geometry) return null;
8595
9194
  return {
8596
- spine: geometry.spine,
9195
+ profile: geometry.profile,
8597
9196
  rightEdge: geometry.rightSide,
8598
- referenceHalfWidth: geometry.pathLength / 2
9197
+ referenceHalfWidth: geometry.pathLength / 2,
9198
+ controlPointCount: controlPoints.length
8599
9199
  };
8600
9200
  },
8601
9201
  minRatio: MIN_SHAFT_WIDTH_RATIO,
8602
- maxRearRatio: MAX_SHAFT_WIDTH_RATIO,
9202
+ maxRearRatio: MAX_FLARE_WIDTH_RATIO,
8603
9203
  maxShaftRatio: MAX_SHAFT_WIDTH_RATIO
8604
9204
  }),
9205
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
8605
9206
  previewSample: {
8606
9207
  controlPoints: [
8607
9208
  [1.4, 0],
@@ -8623,10 +9224,6 @@ function axis1Geometry(coordinates) {
8623
9224
  ...metrics && Math.abs(metrics.lateral) > 1e-6 ? { headHalfWidth: Math.abs(metrics.lateral) } : {}
8624
9225
  };
8625
9226
  }
8626
- function normalizeSmoothResolution$1(value) {
8627
- if (!Number.isFinite(value)) return DEFAULT_SMOOTH_RESOLUTION$4;
8628
- return Math.min(MAX_SMOOTH_RESOLUTION$1, Math.max(MIN_SMOOTH_RESOLUTION$1, Math.round(value)));
8629
- }
8630
9227
  //#endregion
8631
9228
  //#region src/internal/line-echelon.ts
8632
9229
  /**
@@ -10265,7 +10862,7 @@ function createGenericLine(coordinates, options = {}) {
10265
10862
  ...DEFAULT_GENERIC_LINE_OPTIONS,
10266
10863
  ...options
10267
10864
  };
10268
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10865
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10269
10866
  return {
10270
10867
  type: "FeatureCollection",
10271
10868
  features: [{
@@ -10326,7 +10923,7 @@ function createGenericPolygon(coordinates, options = {}) {
10326
10923
  ...DEFAULT_GENERIC_POLYGON_OPTIONS,
10327
10924
  ...options
10328
10925
  };
10329
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10926
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10330
10927
  const ring = smooth ? closedCatmullRom(ringPoints, resolution) : [...ringPoints, ringPoints[0]];
10331
10928
  return {
10332
10929
  type: "FeatureCollection",
@@ -11243,10 +11840,7 @@ const DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS = {
11243
11840
  labelPadding: 0
11244
11841
  };
11245
11842
  const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
11246
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11247
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11248
- smooth: false,
11249
- smoothResolution: 5,
11843
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11250
11844
  crossbarLengthRatio: 1.1,
11251
11845
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11252
11846
  };
@@ -11312,8 +11906,9 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
11312
11906
  const crossbarFrame = pointAlongPolyline(segments, totalLength, shaftPosition);
11313
11907
  crossbarCenter = crossbarFrame.point;
11314
11908
  crossbarAlong = crossbarFrame.along;
11315
- const rearWidth = vecMag(vecSub(geometry.shaftLeft[0], geometry.shaftRight[0]));
11316
- crossbarBaseWidth = rearWidth + (vecMag(vecSub(geometry.shaftLeft.at(-1), geometry.shaftRight.at(-1))) - rearWidth) * shaftPosition;
11909
+ const { left, right } = geometry.shaftProfile;
11910
+ const distances = cumulativeDistances(centerline);
11911
+ crossbarBaseWidth = 2 * Math.max(valueAtFraction(distances, left, shaftPosition), valueAtFraction(distances, right, shaftPosition));
11317
11912
  }
11318
11913
  const crossbarLength = crossbarBaseWidth * Math.max(1, resolved.crossbarLengthRatio);
11319
11914
  const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
@@ -11414,10 +12009,7 @@ const MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS = [
11414
12009
  //#endregion
11415
12010
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
11416
12011
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
11417
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11418
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11419
- smooth: false,
11420
- smoothResolution: 5,
12012
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11421
12013
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11422
12014
  };
11423
12015
  const SUPPORTING_ATTACK_METADATA = {
@@ -11492,6 +12084,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11492
12084
  defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
11493
12085
  rule: axis1DrawRule,
11494
12086
  optionHandles: variableWidthAttackOptionHandles,
12087
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11495
12088
  previewSample: {
11496
12089
  controlPoints: [
11497
12090
  [1, 0],
@@ -11510,10 +12103,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11510
12103
  //#endregion
11511
12104
  //#region src/generators/cm34-mission-tasks/counterattack.ts
11512
12105
  const DEFAULT_COUNTERATTACK_OPTIONS = {
11513
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11514
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11515
- smooth: false,
11516
- smoothResolution: 5,
12106
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11517
12107
  labelPosition: 1
11518
12108
  };
11519
12109
  /** Intrinsic dash and gap lengths for the complete Counterattack outline. */
@@ -11603,6 +12193,7 @@ const COUNTERATTACK = defineControlMeasure({
11603
12193
  defaultOptions: DEFAULT_COUNTERATTACK_OPTIONS,
11604
12194
  rule: axis1DrawRule,
11605
12195
  optionHandles: variableWidthAttackOptionHandles,
12196
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11606
12197
  previewSample: {
11607
12198
  controlPoints: [
11608
12199
  [1, 0],
@@ -11764,6 +12355,7 @@ const COUNTERATTACK_BY_FIRE = defineControlMeasure({
11764
12355
  defaultOptions: DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS,
11765
12356
  rule: counterattackByFireDrawRule,
11766
12357
  optionHandles: createVariableWidthAttackOptionHandles(counterattackByFireBodyCoordinates),
12358
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11767
12359
  previewSample: {
11768
12360
  controlPoints: [
11769
12361
  [1.35, 0],
@@ -15402,7 +15994,7 @@ function createFortifiedLine(positions, options = {}, textAmplifiers = {}, conte
15402
15994
  const { smooth = DEFAULT_FORTIFIED_LINE_OPTIONS.smooth, smoothResolution = DEFAULT_FORTIFIED_LINE_OPTIONS.smoothResolution } = options;
15403
15995
  const safeSize = calculateEffectiveSize(options);
15404
15996
  const projectedPoints = positions.map((p) => project(p[0], p[1]));
15405
- const smoothedPath = smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints;
15997
+ const smoothedPath = smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$1(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints;
15406
15998
  const feature = {
15407
15999
  type: "Feature",
15408
16000
  properties: {},
@@ -15479,7 +16071,7 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}, conte
15479
16071
  const safeSize = calculateEffectiveSize(options);
15480
16072
  const projected = positions.map((p) => project(p[0], p[1]));
15481
16073
  const ringPts = projected.length > 1 && vecMag(vecSub(projected[0], projected[projected.length - 1])) < 1e-6 ? projected.slice(0, -1) : projected;
15482
- const boundaryInput = smooth ? evenlySpacePath(closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)), 2 * safeSize) : [...ringPts, ringPts[0]];
16074
+ const boundaryInput = smooth ? evenlySpacePath(closedCatmullRom(ringPts, normalizeSmoothResolution$1(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)), 2 * safeSize) : [...ringPts, ringPts[0]];
15483
16075
  const boundaryPoints = generateFortifiedPoints(boundaryInput, safeSize);
15484
16076
  if (boundaryPoints.length > 0) {
15485
16077
  const firstPt = boundaryPoints[0];
@@ -15559,6 +16151,7 @@ const FRONTAL_ATTACK = defineControlMeasure({
15559
16151
  defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
15560
16152
  rule: axis1DrawRule,
15561
16153
  optionHandles: variableWidthAttackOptionHandles,
16154
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15562
16155
  previewSample: {
15563
16156
  controlPoints: [
15564
16157
  [1, 0],
@@ -15624,10 +16217,7 @@ const MOVEMENT_TO_CONTACT_BOLT_HANDLE_ID = "bolt-tip";
15624
16217
  const MOVEMENT_TO_CONTACT_STEM_SETBACK_RATIO = .22;
15625
16218
  const MOVEMENT_TO_CONTACT_STEM_OFFSET_RATIO = .55;
15626
16219
  const DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS = {
15627
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
15628
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
15629
- smooth: false,
15630
- smoothResolution: 5,
16220
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
15631
16221
  ...DEFAULT_TACTICAL_ARROW_OPTIONS,
15632
16222
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS,
15633
16223
  boltLengthRatio: MOVEMENT_TO_CONTACT_BOLT_LENGTH_RATIO,
@@ -15771,8 +16361,8 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15771
16361
  defaultOptions: DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS,
15772
16362
  rule: axis1DrawRule,
15773
16363
  optionHandles: {
15774
- get(controlPoints, options) {
15775
- const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options);
16364
+ get(controlPoints, options, query) {
16365
+ const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options, query);
15776
16366
  const resolved = resolveMovementToContactGeometry(controlPoints, options);
15777
16367
  if (!resolved) return widthHandles;
15778
16368
  return [...widthHandles, {
@@ -15793,8 +16383,12 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15793
16383
  boltLengthRatio: length / resolved.arrowBaseWidth,
15794
16384
  boltAngle: clamp(Math.abs(Math.atan2(cross, dot) * 180 / Math.PI), 0, 90)
15795
16385
  };
16386
+ },
16387
+ reset(event) {
16388
+ return variableWidthAttackOptionHandles.reset?.(event);
15796
16389
  }
15797
16390
  },
16391
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15798
16392
  previewSample: {
15799
16393
  controlPoints: [
15800
16394
  [1, 0],
@@ -17390,10 +17984,7 @@ const NO_FIRE_AREA_IRREGULAR = defineControlMeasure({
17390
17984
  //#endregion
17391
17985
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
17392
17986
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
17393
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
17394
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
17395
- smooth: false,
17396
- smoothResolution: 5,
17987
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
17397
17988
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
17398
17989
  };
17399
17990
  const MAIN_ATTACK_METADATA = {
@@ -17463,6 +18054,7 @@ const MAIN_ATTACK = defineControlMeasure({
17463
18054
  defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
17464
18055
  rule: axis1DrawRule,
17465
18056
  optionHandles: variableWidthAttackOptionHandles,
18057
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
17466
18058
  previewSample: {
17467
18059
  controlPoints: [
17468
18060
  [1, 0],
@@ -19935,6 +20527,7 @@ const DEFINITIONS = {
19935
20527
  defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
19936
20528
  rule: axis1DrawRule,
19937
20529
  optionHandles: variableWidthAttackOptionHandles,
20530
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
19938
20531
  previewSample: {
19939
20532
  controlPoints: [
19940
20533
  [1, 0],