@orbat-mapper/control-measures 0.28.0 → 0.29.0

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,464 @@ 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
+ /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1594
+ function catmullRomAt(p0, p1, p2, p3, t) {
1595
+ const t2 = t * t;
1596
+ const t3 = t2 * t;
1597
+ 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)];
1598
+ }
1599
+ /**
1600
+ * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
1601
+ * segment. Endpoints are duplicated so the curve passes through them, so the
1602
+ * first and last points are preserved exactly. With `samples <= 1` (or fewer
1603
+ * than 3 points) it returns the input unchanged.
1604
+ */
1605
+ function catmullRom(points, samples) {
1606
+ if (points.length < 3 || samples <= 1) return points;
1607
+ const pts = [
1608
+ points[0],
1609
+ ...points,
1610
+ points[points.length - 1]
1611
+ ];
1612
+ const out = [points[0]];
1613
+ for (let i = 1; i < pts.length - 2; i++) {
1614
+ const p0 = pts[i - 1];
1615
+ const p1 = pts[i];
1616
+ const p2 = pts[i + 1];
1617
+ const p3 = pts[i + 2];
1618
+ for (let s = 1; s <= samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
1619
+ }
1620
+ return out;
1621
+ }
1622
+ /**
1623
+ * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
1624
+ * wrapping the control points around the seam. Returns a ring whose first and
1625
+ * last positions coincide (explicit closure). With `samples <= 1` (or fewer
1626
+ * than 3 points) it returns the input unchanged.
1627
+ */
1628
+ function closedCatmullRom(points, samples) {
1629
+ const n = points.length;
1630
+ if (n < 3 || samples <= 1) return points;
1631
+ const out = [];
1632
+ for (let i = 0; i < n; i++) {
1633
+ const p0 = points[(i - 1 + n) % n];
1634
+ const p1 = points[i];
1635
+ const p2 = points[(i + 1) % n];
1636
+ const p3 = points[(i + 2) % n];
1637
+ for (let s = 0; s < samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
1638
+ }
1639
+ out.push([...out[0]]);
1640
+ return out;
1641
+ }
1642
+ //#endregion
1643
+ //#region src/internal/vertex-widths.ts
1644
+ /** The option key holding each side's per-vertex widths. */
1645
+ const VERTEX_WIDTH_OPTION_KEYS = {
1646
+ left: "vertexLeftWidthRatios",
1647
+ right: "vertexRightWidthRatios"
1648
+ };
1649
+ /** The `vertexAlignedOptions` every variable-width arrow body declares. */
1650
+ const VERTEX_WIDTH_ALIGNED_OPTIONS = [VERTEX_WIDTH_OPTION_KEYS.left, VERTEX_WIDTH_OPTION_KEYS.right];
1651
+ /** Maps a body-spine index (rear = 0) to its Axis1 control-point index. */
1652
+ function spineIndexToControlPoint(spineIndex, controlPointCount) {
1653
+ return controlPointCount - 2 - spineIndex;
1654
+ }
1655
+ /**
1656
+ * Resolves both sides' per-vertex widths onto the body spine (rear → neck),
1657
+ * clamping each set ratio to `[min, max]`. Returns `null` when no shaft vertex
1658
+ * carries a usable width on either side, which callers treat as "use the plain
1659
+ * rear → shaft taper" so graphics without the options run exactly the code they
1660
+ * ran before.
1661
+ */
1662
+ function resolveSpineWidthRatios(options, controlPointCount, min, max) {
1663
+ const left = resolveSide(options.vertexLeftWidthRatios, controlPointCount, min, max);
1664
+ const right = resolveSide(options.vertexRightWidthRatios, controlPointCount, min, max);
1665
+ return left || right ? {
1666
+ left,
1667
+ right
1668
+ } : null;
1669
+ }
1670
+ function resolveSide(ratios, controlPointCount, min, max) {
1671
+ if (!ratios?.length) return null;
1672
+ const spineCount = controlPointCount - 2;
1673
+ if (spineCount < 1) return null;
1674
+ let found = false;
1675
+ const resolved = new Array(spineCount).fill(null);
1676
+ for (let j = 0; j < spineCount; j++) {
1677
+ const value = ratios[spineIndexToControlPoint(j, controlPointCount)];
1678
+ if (!isFiniteNumber(value)) continue;
1679
+ resolved[j] = clamp(value, min, max);
1680
+ found = true;
1681
+ }
1682
+ return found ? resolved : null;
1683
+ }
1684
+ /**
1685
+ * One value per polyline vertex, given each vertex's cumulative arc length
1686
+ * (see {@link cumulativeDistances}): pinned to `startValue` / `endValue` at the
1687
+ * two ends, taken from `setValues` where set, and interpolated linearly by arc
1688
+ * length between set neighbours elsewhere. `setValues` is parallel to the
1689
+ * vertices; its first and last entries are ignored and missing entries count
1690
+ * as unset.
1691
+ */
1692
+ function anchoredPolylineValues(distances, startValue, endValue, setValues) {
1693
+ const n = distances.length;
1694
+ if (n === 0) return [];
1695
+ if (n === 1) return [endValue];
1696
+ const values = new Array(n);
1697
+ values[0] = startValue;
1698
+ let previous = 0;
1699
+ for (let j = 1; j < n; j++) {
1700
+ const set = j === n - 1 ? endValue : setValues[j];
1701
+ if (set === null || set === void 0) continue;
1702
+ values[j] = set;
1703
+ const from = values[previous];
1704
+ const span = distances[j] - distances[previous];
1705
+ for (let k = previous + 1; k < j; k++) {
1706
+ const t = span < 1e-6 ? 1 : (distances[k] - distances[previous]) / span;
1707
+ values[k] = from + (set - from) * t;
1708
+ }
1709
+ previous = j;
1710
+ }
1711
+ return values;
1712
+ }
1713
+ /**
1714
+ * Linearly reads per-vertex `values` at arc-length fraction `t`, given each
1715
+ * vertex's cumulative arc length (see {@link cumulativeDistances}).
1716
+ */
1717
+ function valueAtFraction(distances, values, t) {
1718
+ const n = Math.min(distances.length, values.length);
1719
+ if (n === 0) return 0;
1720
+ if (n === 1) return values[0];
1721
+ const target = clamp(t, 0, 1) * distances[n - 1];
1722
+ for (let i = 1; i < n; i++) if (target <= distances[i]) {
1723
+ const length = distances[i] - distances[i - 1];
1724
+ const f = length < 1e-6 ? 1 : (target - distances[i - 1]) / length;
1725
+ return values[i - 1] + (values[i] - values[i - 1]) * f;
1726
+ }
1727
+ return values[n - 1];
1728
+ }
1729
+ /**
1730
+ * Resamples an authored shaft centerline onto a Catmull-Rom curve with
1731
+ * `samples` drawn points per segment, so authored vertex `j` lands at drawn
1732
+ * index `j * stride`. Fewer than three vertices or two samples leave the spine
1733
+ * as authored, with a stride of 1.
1734
+ */
1735
+ function curveSpine(authored, samples) {
1736
+ if (authored.length < 3 || samples < 2) return {
1737
+ spine: authored,
1738
+ stride: 1
1739
+ };
1740
+ return {
1741
+ spine: catmullRom(authored, samples),
1742
+ stride: samples
1743
+ };
1744
+ }
1745
+ /**
1746
+ * Outlines a shaft whose two sides each run from the rear half-width to the
1747
+ * base half-width, following their own set ratios in between. Only the
1748
+ * authored vertices before the shaft end may carry a set width (the head may
1749
+ * have swallowed the rest; `ratios` is a longer prefix then). A side's own rear
1750
+ * entry overrides the shared rear width.
1751
+ *
1752
+ * With `stride` 1 widths change linearly between the authored vertices; on a
1753
+ * resampled curve they ease smoothly between set vertices (see
1754
+ * {@link smoothAnchoredValues}). Without ratios both sides share the plain
1755
+ * rear → base taper.
1756
+ */
1757
+ function variableWidthShaft(input) {
1758
+ const { spine, stride, rearHalfWidth, baseHalfWidth, ratios, ratioToHalfWidth } = input;
1759
+ const distances = cumulativeDistances(spine);
1760
+ const setCount = (spine.length - 1) / stride;
1761
+ const side = (sideRatios) => {
1762
+ const set = [];
1763
+ for (let j = 0; j < setCount; j++) {
1764
+ const ratio = sideRatios?.[j];
1765
+ set.push(ratio === null || ratio === void 0 ? null : ratio * ratioToHalfWidth);
1766
+ }
1767
+ const rear = set[0] ?? rearHalfWidth;
1768
+ return stride === 1 ? anchoredPolylineValues(distances, rear, baseHalfWidth, set) : smoothAnchoredValues(distances, rear, baseHalfWidth, set, stride);
1769
+ };
1770
+ const left = side(ratios?.left);
1771
+ const right = ratios ? side(ratios.right) : left;
1772
+ const edges = offsetPolylineSides(spine, left, input.rounded, input.roundSegments, right);
1773
+ return {
1774
+ leftEdge: edges.left,
1775
+ rightEdge: edges.right,
1776
+ profile: {
1777
+ spine,
1778
+ stride,
1779
+ left,
1780
+ right,
1781
+ ratios,
1782
+ rounded: input.rounded,
1783
+ edgeStarts: {
1784
+ left: edges.leftStarts,
1785
+ right: edges.rightStarts
1786
+ }
1787
+ }
1788
+ };
1789
+ }
1790
+ const REAR_GRIP_SIDES = ["left"];
1791
+ const VERTEX_GRIP_SIDES = ["left", "right"];
1792
+ /** Authored vertices behind a profile's drawn spine (rear … shaft end). */
1793
+ function authoredCount(profile) {
1794
+ return (profile.spine.length - 1) / profile.stride + 1;
1795
+ }
1796
+ function vertexWidthGrip(profile, controlPointCount, side, spineIndex) {
1797
+ const index = spineIndex * profile.stride;
1798
+ const vertex = profile.spine[index];
1799
+ const halfWidth = profile[side][index];
1800
+ if (!vertex || halfWidth === void 0) return null;
1801
+ const direction = offsetDirectionAt(profile.spine, index, side === "left" ? 1 : -1, profile.rounded);
1802
+ if (!direction) return null;
1803
+ return {
1804
+ controlPointIndex: spineIndexToControlPoint(spineIndex, controlPointCount),
1805
+ side,
1806
+ vertex,
1807
+ direction,
1808
+ halfWidth,
1809
+ set: isFiniteNumber(profile.ratios?.[side]?.[spineIndex])
1810
+ };
1811
+ }
1812
+ /**
1813
+ * The width grips for a shaft: a left and a right grip per interior vertex that
1814
+ * came from a control point, each on its own edge, plus a left grip at the rear
1815
+ * (the shared `rear-width` handle already sits on the right rear corner and
1816
+ * moves both sides until the left is set). The shaft end belongs to
1817
+ * `shaftWidthRatio` and gets none.
1818
+ */
1819
+ function buildVertexWidthGrips(profile, controlPointCount) {
1820
+ const grips = [];
1821
+ const count = authoredCount(profile);
1822
+ for (let j = 0; j < count - 1; j++) for (const side of j === 0 ? REAR_GRIP_SIDES : VERTEX_GRIP_SIDES) {
1823
+ const grip = vertexWidthGrip(profile, controlPointCount, side, j);
1824
+ if (grip) grips.push(grip);
1825
+ }
1826
+ return grips;
1827
+ }
1828
+ /** The one grip of {@link buildVertexWidthGrips} for `side` at control point `index`. */
1829
+ function findVertexWidthGrip(profile, controlPointCount, side, controlPointIndex) {
1830
+ const j = spineIndexToControlPoint(controlPointIndex, controlPointCount);
1831
+ if (j < 0 || j >= authoredCount(profile) - 1 || j === 0 && side === "right") return null;
1832
+ return vertexWidthGrip(profile, controlPointCount, side, j);
1833
+ }
1834
+ const VERTEX_WIDTH_HANDLE_PREFIX = "vertex-width-";
1835
+ const VERTEX_WIDTH_HANDLE_PATTERN = new RegExp(`^${VERTEX_WIDTH_HANDLE_PREFIX}(left|right)-(\\d+)$`);
1836
+ /** Handle id for the `side` width grip stored under control point `index`. */
1837
+ function vertexWidthHandleId(side, controlPointIndex) {
1838
+ return `${VERTEX_WIDTH_HANDLE_PREFIX}${side}-${controlPointIndex}`;
1839
+ }
1840
+ /** Parses a {@link vertexWidthHandleId}; `null` for any other handle id. */
1841
+ function parseVertexWidthHandleId(handleId) {
1842
+ const match = VERTEX_WIDTH_HANDLE_PATTERN.exec(handleId);
1843
+ if (!match) return null;
1844
+ return {
1845
+ side: match[1],
1846
+ controlPointIndex: Number(match[2])
1847
+ };
1848
+ }
1849
+ /**
1850
+ * Returns `ratios` with entry `index` replaced by `value`, padding with `null`
1851
+ * as needed. A `null` value trims trailing `null`s, and an all-`null` result
1852
+ * collapses to `undefined` so a fully reset option drops out of the options.
1853
+ */
1854
+ function withVertexWidth(ratios, index, value) {
1855
+ const next = ratios ? [...ratios] : [];
1856
+ while (next.length <= index) next.push(null);
1857
+ next[index] = value;
1858
+ while (next.length > 0 && !isFiniteNumber(next.at(-1))) next.pop();
1859
+ return next.length > 0 ? next : void 0;
1860
+ }
1861
+ /**
1862
+ * Smooth counterpart of {@link anchoredPolylineValues} for a curved (densely
1863
+ * sampled) spine, given each drawn vertex's cumulative arc length. The knots are the two ends and every set authored vertex;
1864
+ * values in between follow a monotone cubic in arc length, so the width eases
1865
+ * in and out of each set vertex without overshooting (a pinch never dips
1866
+ * below its own width). Authored vertex `j` sits at drawn index `j * stride`;
1867
+ * `setValues` is parallel to the authored vertices.
1868
+ */
1869
+ function smoothAnchoredValues(distances, startValue, endValue, setValues, stride) {
1870
+ const n = distances.length;
1871
+ if (n === 0) return [];
1872
+ if (n === 1) return [endValue];
1873
+ const xs = [0];
1874
+ const ys = [startValue];
1875
+ for (let j = 1; j * stride < n - 1; j++) {
1876
+ const value = setValues[j];
1877
+ if (value === null || value === void 0) continue;
1878
+ const x = distances[j * stride];
1879
+ if (x - xs.at(-1) < 1e-6) continue;
1880
+ xs.push(x);
1881
+ ys.push(value);
1882
+ }
1883
+ const total = distances[n - 1];
1884
+ if (total - xs.at(-1) < 1e-6) ys[ys.length - 1] = endValue;
1885
+ else {
1886
+ xs.push(total);
1887
+ ys.push(endValue);
1888
+ }
1889
+ if (xs.length < 2) return distances.map(() => endValue);
1890
+ const slopes = monotoneTangents(xs, ys);
1891
+ const values = new Array(n);
1892
+ let k = 0;
1893
+ for (let i = 0; i < n; i++) {
1894
+ const x = distances[i];
1895
+ while (k < xs.length - 2 && x > xs[k + 1]) k++;
1896
+ const h = xs[k + 1] - xs[k];
1897
+ const t = clamp((x - xs[k]) / h, 0, 1);
1898
+ const t2 = t * t;
1899
+ const t3 = t2 * t;
1900
+ 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];
1901
+ }
1902
+ return values;
1903
+ }
1904
+ /**
1905
+ * Knot tangents for a monotone piecewise-cubic Hermite interpolant
1906
+ * (Fritsch–Butland): zero at local extrema, a weighted harmonic mean of the
1907
+ * neighbouring secants elsewhere, and the adjacent secant at the two ends.
1908
+ */
1909
+ function monotoneTangents(xs, ys) {
1910
+ const m = xs.length;
1911
+ const secants = [];
1912
+ for (let k = 0; k < m - 1; k++) secants.push((ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k]));
1913
+ const tangents = [secants[0]];
1914
+ for (let k = 1; k < m - 1; k++) {
1915
+ const d0 = secants[k - 1];
1916
+ const d1 = secants[k];
1917
+ if (d0 * d1 <= 0) {
1918
+ tangents.push(0);
1919
+ continue;
1920
+ }
1921
+ const h0 = xs[k] - xs[k - 1];
1922
+ const h1 = xs[k + 1] - xs[k];
1923
+ tangents.push(3 * (h0 + h1) / ((2 * h1 + h0) / d0 + (h1 + 2 * h0) / d1));
1924
+ }
1925
+ tangents.push(secants[m - 2]);
1926
+ return tangents;
1927
+ }
1928
+ //#endregion
1356
1929
  //#region src/attack-utils.ts
1357
1930
  const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
1358
1931
  const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
1359
1932
  const SHAFT_MIN_RATIO = .1;
1360
1933
  const SHAFT_MAX_RATIO = .9;
1934
+ const DEFAULT_ATTACK_SMOOTH_MODE = "rounded-corners";
1935
+ /** Curve samples per shaft segment for each unit of `smoothResolution`. */
1936
+ const CURVE_SAMPLES_PER_RESOLUTION = 4;
1937
+ /** Shaft defaults shared by every variable-width attack body. */
1938
+ const DEFAULT_ATTACK_SHAFT_OPTIONS = {
1939
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
1940
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
1941
+ smooth: false,
1942
+ smoothMode: DEFAULT_ATTACK_SMOOTH_MODE,
1943
+ smoothResolution: 5
1944
+ };
1361
1945
  const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
1362
1946
  const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1363
1947
  /**
@@ -1368,30 +1952,72 @@ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1368
1952
  * matching centerline segment, over the reference half-width) live here once.
1369
1953
  */
1370
1954
  function createWidthOptionHandles(config) {
1955
+ const vertexWidthPatch = (options, side, index, value) => {
1956
+ const key = VERTEX_WIDTH_OPTION_KEYS[side];
1957
+ return { [key]: withVertexWidth(options[key], index, value) };
1958
+ };
1959
+ const rightRearOverride = (controlPoints, options) => {
1960
+ const rearIndex = spineIndexToControlPoint(0, controlPoints.length);
1961
+ return isFiniteNumber(options.vertexRightWidthRatios?.[rearIndex]) ? rearIndex : null;
1962
+ };
1371
1963
  return {
1372
- get(controlPoints, options) {
1964
+ get(controlPoints, options, query) {
1373
1965
  const geometry = config.geometry(controlPoints, options);
1374
1966
  const rearRight = geometry?.rightEdge[0];
1375
1967
  const neckRight = geometry?.rightEdge.at(-1);
1376
- if (!rearRight || !neckRight) return [];
1377
- return [{
1968
+ if (!geometry || !rearRight || !neckRight) return [];
1969
+ const handles = [{
1378
1970
  id: REAR_WIDTH_OPTION_HANDLE_ID,
1379
1971
  position: unproject(rearRight[0], rearRight[1])
1380
1972
  }, {
1381
1973
  id: SHAFT_WIDTH_OPTION_HANDLE_ID,
1382
1974
  position: unproject(neckRight[0], neckRight[1])
1383
1975
  }];
1976
+ if (!query?.detail) return handles;
1977
+ for (const grip of buildVertexWidthGrips(geometry.profile, geometry.controlPointCount)) {
1978
+ const point = vecAdd(grip.vertex, vecScale(grip.direction, grip.halfWidth));
1979
+ handles.push({
1980
+ id: vertexWidthHandleId(grip.side, grip.controlPointIndex),
1981
+ position: unproject(point[0], point[1]),
1982
+ ...grip.set ? {} : { unset: true }
1983
+ });
1984
+ }
1985
+ return handles;
1384
1986
  },
1385
1987
  drag({ controlPoints, options, handleId, position }) {
1988
+ const target = parseVertexWidthHandleId(handleId);
1989
+ if (target) {
1990
+ const geometry = config.geometry(controlPoints, options);
1991
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
1992
+ const grip = findVertexWidthGrip(geometry.profile, geometry.controlPointCount, target.side, target.controlPointIndex);
1993
+ if (!grip) return void 0;
1994
+ const lengthSq = vecDot(grip.direction, grip.direction);
1995
+ if (lengthSq < 1e-6) return void 0;
1996
+ const ratio = clamp(vecDot(vecSub(project(position[0], position[1]), grip.vertex), grip.direction) / lengthSq / geometry.referenceHalfWidth, config.minRatio, config.maxRearRatio);
1997
+ return vertexWidthPatch(options, target.side, target.controlPointIndex, ratio);
1998
+ }
1386
1999
  const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
1387
2000
  if (!rear && !(handleId === "shaft-width")) return void 0;
1388
2001
  const geometry = config.geometry(controlPoints, options);
1389
2002
  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);
2003
+ const { spine } = geometry.profile;
2004
+ const from = rear ? spine[0] : spine.at(-2);
2005
+ const to = rear ? spine[1] : spine.at(-1);
1392
2006
  if (!from || !to) return void 0;
1393
2007
  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 };
2008
+ if (!rear) return { shaftWidthRatio: ratio };
2009
+ const rearIndex = rightRearOverride(controlPoints, options);
2010
+ if (rearIndex !== null) return vertexWidthPatch(options, "right", rearIndex, ratio);
2011
+ return { rearWidthRatio: ratio };
2012
+ },
2013
+ reset({ controlPoints, options, handleId }) {
2014
+ if (handleId === "rear-width") {
2015
+ const rearIndex = rightRearOverride(controlPoints, options);
2016
+ return rearIndex === null ? void 0 : vertexWidthPatch(options, "right", rearIndex, null);
2017
+ }
2018
+ const target = parseVertexWidthHandleId(handleId);
2019
+ if (!target || !options[VERTEX_WIDTH_OPTION_KEYS[target.side]]) return void 0;
2020
+ return vertexWidthPatch(options, target.side, target.controlPointIndex, null);
1395
2021
  }
1396
2022
  };
1397
2023
  }
@@ -1410,9 +2036,10 @@ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) =>
1410
2036
  const outerLeft = geometry?.headRing[0];
1411
2037
  if (!geometry || !outerLeft) return null;
1412
2038
  return {
1413
- spine: geometry.shaftCenterline,
2039
+ profile: geometry.shaftProfile,
1414
2040
  rightEdge: geometry.shaftRight,
1415
- referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
2041
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase)),
2042
+ controlPointCount: coordinates.length
1416
2043
  };
1417
2044
  },
1418
2045
  minRatio: SHAFT_MIN_RATIO,
@@ -1429,11 +2056,48 @@ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles(
1429
2056
  function resolveRearWidthRatio(options, shaftRatio) {
1430
2057
  return options.rearWidthRatio ?? shaftRatio;
1431
2058
  }
2059
+ /** The options {@link processAttackGeometry} reads; the cache compares only these. */
2060
+ const ATTACK_GEOMETRY_OPTION_KEYS = [
2061
+ "shaftWidthRatio",
2062
+ "rearWidthRatio",
2063
+ "smooth",
2064
+ "smoothResolution",
2065
+ "smoothMode",
2066
+ "vertexLeftWidthRatios",
2067
+ "vertexRightWidthRatios"
2068
+ ];
2069
+ /**
2070
+ * The last body built, by value. A width-grip drag builds the same body up to
2071
+ * three times per pointer move (the drag, the render, the next handle
2072
+ * placement), so one entry is enough. The result is shared, never mutated.
2073
+ */
2074
+ let lastAttackGeometry = null;
2075
+ function sameOptionValue(a, b) {
2076
+ if (Object.is(a, b)) return true;
2077
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2078
+ return a.every((value, i) => Object.is(value, b[i]));
2079
+ }
1432
2080
  /**
1433
2081
  * Processes input coordinates and options to generate the core symbol geometry.
1434
2082
  * This handles validation, default values, projection, and geometry calculation.
1435
2083
  */
1436
2084
  function processAttackGeometry(coordinates, options = {}) {
2085
+ const last = lastAttackGeometry;
2086
+ 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;
2087
+ const result = buildAttackGeometry(coordinates, options);
2088
+ const snapshot = {};
2089
+ for (const key of ATTACK_GEOMETRY_OPTION_KEYS) {
2090
+ const value = options[key];
2091
+ snapshot[key] = Array.isArray(value) ? [...value] : value;
2092
+ }
2093
+ lastAttackGeometry = {
2094
+ coordinates: coordinates.flatMap((c) => [c[0], c[1]]),
2095
+ options: snapshot,
2096
+ result
2097
+ };
2098
+ return result;
2099
+ }
2100
+ function buildAttackGeometry(coordinates, options) {
1437
2101
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
1438
2102
  const smooth = options.smooth ?? false;
1439
2103
  const smoothResolution = options.smoothResolution ?? 5;
@@ -1447,9 +2111,9 @@ function processAttackGeometry(coordinates, options = {}) {
1447
2111
  const p = points[i];
1448
2112
  if (p) spinePoints.push(p);
1449
2113
  }
1450
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
2114
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth ? options.smoothMode ?? "rounded-corners" : null, smoothResolution, resolveSpineWidthRatios(options, numPoints, SHAFT_MIN_RATIO, 3)) };
1451
2115
  }
1452
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
2116
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smoothing, smoothResolution, spineWidthRatios = null) {
1453
2117
  const initialNeck = spine[spine.length - 1];
1454
2118
  if (!initialNeck) return null;
1455
2119
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -1502,15 +2166,46 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRat
1502
2166
  outerLeft
1503
2167
  ];
1504
2168
  const fullSpine = [...remainingSpine, shaftEndCenter];
1505
- const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
2169
+ const rearHalfWidth = headHalfWidth * rearWidthRatio;
2170
+ const { spine: drawnSpine, stride } = smoothing === "curve" ? curveSpine(fullSpine, normalizeSmoothResolution$1(smoothResolution * CURVE_SAMPLES_PER_RESOLUTION, 5 * CURVE_SAMPLES_PER_RESOLUTION)) : {
2171
+ spine: fullSpine,
2172
+ stride: 1
2173
+ };
2174
+ const { leftEdge: shaftLeft, rightEdge: shaftRight, profile: shaftProfile } = variableWidthShaft({
2175
+ spine: drawnSpine,
2176
+ stride,
2177
+ rearHalfWidth,
2178
+ baseHalfWidth: shaftHalfWidth,
2179
+ ratios: spineWidthRatios,
2180
+ ratioToHalfWidth: headHalfWidth,
2181
+ rounded: smoothing !== null && !(stride > 1),
2182
+ roundSegments: smoothResolution
2183
+ });
1506
2184
  return {
1507
- shaftCenterline: fullSpine,
2185
+ shaftCenterline: drawnSpine,
1508
2186
  shaftLeft,
1509
2187
  shaftRight,
1510
2188
  headRing,
1511
2189
  ptTip,
1512
2190
  ptNeck,
1513
- ptBase
2191
+ ptBase,
2192
+ shaftProfile
2193
+ };
2194
+ }
2195
+ /**
2196
+ * The two shaft edges cut at the neck, for bodies whose outline crosses over
2197
+ * from the neck to the far side of the head (Airborne Attack, Attack
2198
+ * Helicopter). The edges run on to the head base; this keeps each edge up to
2199
+ * the neck, the last authored vertex before the base, which sits one `stride`
2200
+ * of drawn vertices back.
2201
+ */
2202
+ function shaftEdgesToNeck(geometry) {
2203
+ const { spine, stride, edgeStarts } = geometry.shaftProfile;
2204
+ const afterNeck = spine.length - stride;
2205
+ const toNeck = (edge, starts) => edge.slice(0, Math.max(1, starts[afterNeck] ?? edge.length));
2206
+ return {
2207
+ left: toNeck(geometry.shaftLeft, edgeStarts.left),
2208
+ right: toNeck(geometry.shaftRight, edgeStarts.right)
1514
2209
  };
1515
2210
  }
1516
2211
  /**
@@ -1877,162 +2572,6 @@ function keepTextLeftToRight(rotation) {
1877
2572
  return normalized;
1878
2573
  }
1879
2574
  //#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
2575
  //#region src/internal/line-labels.ts
2037
2576
  /**
2038
2577
  * Stored label rotation for text running along projected direction `dir`.
@@ -3879,12 +4418,26 @@ const ATTACK_SHAFT_PARAMS = [
3879
4418
  key: "smoothResolution",
3880
4419
  presentationTier: "advanced",
3881
4420
  label: "Smooth resolution",
3882
- description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
4421
+ 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
4422
  type: "number",
3884
4423
  min: 1,
3885
4424
  max: 20,
3886
4425
  step: 1,
3887
4426
  visibleWhen: (opts) => opts.smooth === true
4427
+ },
4428
+ {
4429
+ key: "smoothMode",
4430
+ label: "Smooth style",
4431
+ description: "Round only the corners where the shaft changes direction, or curve the whole shaft through the control points.",
4432
+ type: "enum",
4433
+ options: [{
4434
+ label: "Rounded corners",
4435
+ value: "rounded-corners"
4436
+ }, {
4437
+ label: "Curve",
4438
+ value: "curve"
4439
+ }],
4440
+ visibleWhen: (opts) => opts.smooth === true
3888
4441
  }
3889
4442
  ];
3890
4443
  const FIRE_ARROW_PARAMS = [
@@ -4122,12 +4675,7 @@ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d)
4122
4675
  const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
4123
4676
  //#endregion
4124
4677
  //#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
- };
4678
+ const DEFAULT_AIRBORNE_ATTACK_OPTIONS = { ...DEFAULT_ATTACK_SHAFT_OPTIONS };
4131
4679
  const AIRBORNE_ATTACK_METADATA = {
4132
4680
  id: "airborne-attack",
4133
4681
  name: "Airborne Attack",
@@ -4163,8 +4711,7 @@ function createAirborneAttack(coordinates, options = {}) {
4163
4711
  type: "FeatureCollection",
4164
4712
  features: []
4165
4713
  };
4166
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
4167
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
4714
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
4168
4715
  return {
4169
4716
  type: "FeatureCollection",
4170
4717
  features: [{
@@ -4190,7 +4737,8 @@ const AIRBORNE_ATTACK = defineControlMeasure({
4190
4737
  generator: createAirborneAttack,
4191
4738
  defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
4192
4739
  rule: axis1DrawRule,
4193
- optionHandles: variableWidthAttackOptionHandles
4740
+ optionHandles: variableWidthAttackOptionHandles,
4741
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
4194
4742
  });
4195
4743
  /**
4196
4744
  * Reacts to an **input-contract** violation according to `mode`: `throw`
@@ -5205,7 +5753,7 @@ function projectRingPoints(positions) {
5205
5753
  */
5206
5754
  function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
5207
5755
  const ringPts = projectRingPoints(positions);
5208
- return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5756
+ return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$1(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5209
5757
  }
5210
5758
  /**
5211
5759
  * Walks a closed ring into per-segment metadata (unit direction, outward
@@ -7337,10 +7885,7 @@ const ATTACK_BY_FIRE = defineControlMeasure({
7337
7885
  //#endregion
7338
7886
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
7339
7887
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
7340
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
7341
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
7342
- smooth: false,
7343
- smoothResolution: 5,
7888
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
7344
7889
  symbolHeightRatio: .45,
7345
7890
  triangleSizeRatio: .25,
7346
7891
  bottomBarWidthRatio: 1.5,
@@ -7423,15 +7968,14 @@ function createAttackHelicopter(coordinates, options = {}) {
7423
7968
  type: "FeatureCollection",
7424
7969
  features: []
7425
7970
  };
7426
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
7427
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
7428
7971
  if (geometry.shaftLeft.length < 2 || geometry.shaftRight.length < 2) return {
7429
7972
  type: "FeatureCollection",
7430
7973
  features: []
7431
7974
  };
7432
- const pL = geometry.shaftLeft[geometry.shaftLeft.length - 2];
7975
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
7976
+ const pL = shaftLeftToNeck.at(-1);
7433
7977
  const pIR = geometry.headRing[3];
7434
- const pR = geometry.shaftRight[geometry.shaftRight.length - 2];
7978
+ const pR = shaftRightToNeck.at(-1);
7435
7979
  const pIL = geometry.headRing[5];
7436
7980
  if (!pL || !pIR || !pR || !pIL) return {
7437
7981
  type: "FeatureCollection",
@@ -7558,7 +8102,8 @@ const ATTACK_HELICOPTER = defineControlMeasure({
7558
8102
  generator: createAttackHelicopter,
7559
8103
  defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
7560
8104
  rule: axis1DrawRule,
7561
- optionHandles: variableWidthAttackOptionHandles
8105
+ optionHandles: variableWidthAttackOptionHandles,
8106
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
7562
8107
  });
7563
8108
  //#endregion
7564
8109
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
@@ -8322,11 +8867,14 @@ const ROADBLOCK_COMPLETE = definition$1("roadblock-complete", "271204", "complet
8322
8867
  //#endregion
8323
8868
  //#region src/generators/cm99-generic-arrows/blockArrow.ts
8324
8869
  const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
8325
- const MIN_SMOOTH_RESOLUTION$1 = 2;
8326
- const MAX_SMOOTH_RESOLUTION$1 = 64;
8327
8870
  const DEFAULT_SHAFT_WIDTH_RATIO = .06;
8328
8871
  const MIN_SHAFT_WIDTH_RATIO = .01;
8329
8872
  const MAX_SHAFT_WIDTH_RATIO = .3;
8873
+ /**
8874
+ * Rear and per-vertex widths may flare well past the arrowhead; only the shaft
8875
+ * end, which meets the head, keeps the tighter shaft cap.
8876
+ */
8877
+ const MAX_FLARE_WIDTH_RATIO = 1;
8330
8878
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
8331
8879
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
8332
8880
  rearWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
@@ -8335,6 +8883,7 @@ const DEFAULT_BLOCK_ARROW_OPTIONS = {
8335
8883
  arrowheadLengthRatio: .22,
8336
8884
  smooth: false,
8337
8885
  smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4,
8886
+ smoothMode: "curve",
8338
8887
  filled: true
8339
8888
  };
8340
8889
  const BLOCK_ARROW_METADATA = {
@@ -8372,7 +8921,7 @@ const BLOCK_ARROW_METADATA = {
8372
8921
  description: "Width at the rear of the shaft as a fraction of the total path length",
8373
8922
  type: "number",
8374
8923
  min: MIN_SHAFT_WIDTH_RATIO,
8375
- max: MAX_SHAFT_WIDTH_RATIO,
8924
+ max: MAX_FLARE_WIDTH_RATIO,
8376
8925
  step: .01
8377
8926
  },
8378
8927
  {
@@ -8434,20 +8983,34 @@ const BLOCK_ARROW_METADATA = {
8434
8983
  {
8435
8984
  key: "smooth",
8436
8985
  label: "Smooth",
8437
- description: "Round the shaft's corners by curving it through the control points.",
8986
+ description: "Curve the shaft through the control points, or round its corners.",
8438
8987
  type: "boolean"
8439
8988
  },
8440
8989
  {
8441
8990
  key: "smoothResolution",
8442
8991
  presentationTier: "advanced",
8443
8992
  label: "Smooth resolution",
8444
- description: "Number of samples per segment when smooth mode is enabled",
8993
+ description: "Number of samples per segment when smooth mode is enabled. In rounded-corners style, the number of segments approximating each rounded corner.",
8445
8994
  type: "number",
8446
8995
  min: 2,
8447
8996
  max: 64,
8448
8997
  step: 1,
8449
8998
  visibleWhen: (opts) => Boolean(opts.smooth)
8450
8999
  },
9000
+ {
9001
+ key: "smoothMode",
9002
+ label: "Smooth style",
9003
+ description: "Curve the whole shaft through the control points, or round only the corners where it changes direction.",
9004
+ type: "enum",
9005
+ options: [{
9006
+ label: "Curve",
9007
+ value: "curve"
9008
+ }, {
9009
+ label: "Rounded corners",
9010
+ value: "rounded-corners"
9011
+ }],
9012
+ visibleWhen: (opts) => Boolean(opts.smooth)
9013
+ },
8451
9014
  {
8452
9015
  key: "filled",
8453
9016
  label: "Filled",
@@ -8555,10 +9118,23 @@ function calculateBlockArrowGeometry(coordinates, options) {
8555
9118
  ];
8556
9119
  break;
8557
9120
  }
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);
9121
+ const authored = [...pts.slice(0, -1), onAxis(shaftEndDist)];
9122
+ const samples = normalizeSmoothResolution$1(options.smoothResolution, DEFAULT_SMOOTH_RESOLUTION$4);
9123
+ const curved = options.smooth && options.smoothMode === "curve";
9124
+ const { spine, stride } = curved ? curveSpine(authored, samples) : {
9125
+ spine: authored,
9126
+ stride: 1
9127
+ };
9128
+ const { leftEdge: leftSide, rightEdge: rightSide, profile } = variableWidthShaft({
9129
+ spine,
9130
+ stride,
9131
+ rearHalfWidth: halfRear,
9132
+ baseHalfWidth: halfShaft,
9133
+ ratios: resolveSpineWidthRatios(options, coordinates.length, MIN_SHAFT_WIDTH_RATIO, MAX_FLARE_WIDTH_RATIO),
9134
+ ratioToHalfWidth: pathLength / 2,
9135
+ rounded: options.smooth && !curved,
9136
+ roundSegments: samples
9137
+ });
8562
9138
  const ring = [
8563
9139
  ...leftSide,
8564
9140
  ...headPts,
@@ -8567,10 +9143,9 @@ function calculateBlockArrowGeometry(coordinates, options) {
8567
9143
  ring.push(ring[0]);
8568
9144
  return {
8569
9145
  ring,
8570
- spine,
8571
- leftSide,
8572
9146
  rightSide,
8573
- pathLength
9147
+ pathLength,
9148
+ profile
8574
9149
  };
8575
9150
  }
8576
9151
  function resolveBlockArrowOptions(options) {
@@ -8593,15 +9168,17 @@ const BLOCK_ARROW = defineControlMeasure({
8593
9168
  const geometry = calculateBlockArrowGeometry(controlPoints, resolveBlockArrowOptions(options));
8594
9169
  if (!geometry) return null;
8595
9170
  return {
8596
- spine: geometry.spine,
9171
+ profile: geometry.profile,
8597
9172
  rightEdge: geometry.rightSide,
8598
- referenceHalfWidth: geometry.pathLength / 2
9173
+ referenceHalfWidth: geometry.pathLength / 2,
9174
+ controlPointCount: controlPoints.length
8599
9175
  };
8600
9176
  },
8601
9177
  minRatio: MIN_SHAFT_WIDTH_RATIO,
8602
- maxRearRatio: MAX_SHAFT_WIDTH_RATIO,
9178
+ maxRearRatio: MAX_FLARE_WIDTH_RATIO,
8603
9179
  maxShaftRatio: MAX_SHAFT_WIDTH_RATIO
8604
9180
  }),
9181
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
8605
9182
  previewSample: {
8606
9183
  controlPoints: [
8607
9184
  [1.4, 0],
@@ -8623,10 +9200,6 @@ function axis1Geometry(coordinates) {
8623
9200
  ...metrics && Math.abs(metrics.lateral) > 1e-6 ? { headHalfWidth: Math.abs(metrics.lateral) } : {}
8624
9201
  };
8625
9202
  }
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
9203
  //#endregion
8631
9204
  //#region src/internal/line-echelon.ts
8632
9205
  /**
@@ -10265,7 +10838,7 @@ function createGenericLine(coordinates, options = {}) {
10265
10838
  ...DEFAULT_GENERIC_LINE_OPTIONS,
10266
10839
  ...options
10267
10840
  };
10268
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10841
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10269
10842
  return {
10270
10843
  type: "FeatureCollection",
10271
10844
  features: [{
@@ -10326,7 +10899,7 @@ function createGenericPolygon(coordinates, options = {}) {
10326
10899
  ...DEFAULT_GENERIC_POLYGON_OPTIONS,
10327
10900
  ...options
10328
10901
  };
10329
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10902
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10330
10903
  const ring = smooth ? closedCatmullRom(ringPoints, resolution) : [...ringPoints, ringPoints[0]];
10331
10904
  return {
10332
10905
  type: "FeatureCollection",
@@ -11243,10 +11816,7 @@ const DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS = {
11243
11816
  labelPadding: 0
11244
11817
  };
11245
11818
  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,
11819
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11250
11820
  crossbarLengthRatio: 1.1,
11251
11821
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11252
11822
  };
@@ -11312,8 +11882,9 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
11312
11882
  const crossbarFrame = pointAlongPolyline(segments, totalLength, shaftPosition);
11313
11883
  crossbarCenter = crossbarFrame.point;
11314
11884
  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;
11885
+ const { left, right } = geometry.shaftProfile;
11886
+ const distances = cumulativeDistances(centerline);
11887
+ crossbarBaseWidth = 2 * Math.max(valueAtFraction(distances, left, shaftPosition), valueAtFraction(distances, right, shaftPosition));
11317
11888
  }
11318
11889
  const crossbarLength = crossbarBaseWidth * Math.max(1, resolved.crossbarLengthRatio);
11319
11890
  const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
@@ -11414,10 +11985,7 @@ const MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS = [
11414
11985
  //#endregion
11415
11986
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
11416
11987
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
11417
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11418
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11419
- smooth: false,
11420
- smoothResolution: 5,
11988
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11421
11989
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11422
11990
  };
11423
11991
  const SUPPORTING_ATTACK_METADATA = {
@@ -11492,6 +12060,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11492
12060
  defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
11493
12061
  rule: axis1DrawRule,
11494
12062
  optionHandles: variableWidthAttackOptionHandles,
12063
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11495
12064
  previewSample: {
11496
12065
  controlPoints: [
11497
12066
  [1, 0],
@@ -11510,10 +12079,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11510
12079
  //#endregion
11511
12080
  //#region src/generators/cm34-mission-tasks/counterattack.ts
11512
12081
  const DEFAULT_COUNTERATTACK_OPTIONS = {
11513
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11514
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11515
- smooth: false,
11516
- smoothResolution: 5,
12082
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11517
12083
  labelPosition: 1
11518
12084
  };
11519
12085
  /** Intrinsic dash and gap lengths for the complete Counterattack outline. */
@@ -11603,6 +12169,7 @@ const COUNTERATTACK = defineControlMeasure({
11603
12169
  defaultOptions: DEFAULT_COUNTERATTACK_OPTIONS,
11604
12170
  rule: axis1DrawRule,
11605
12171
  optionHandles: variableWidthAttackOptionHandles,
12172
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11606
12173
  previewSample: {
11607
12174
  controlPoints: [
11608
12175
  [1, 0],
@@ -11764,6 +12331,7 @@ const COUNTERATTACK_BY_FIRE = defineControlMeasure({
11764
12331
  defaultOptions: DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS,
11765
12332
  rule: counterattackByFireDrawRule,
11766
12333
  optionHandles: createVariableWidthAttackOptionHandles(counterattackByFireBodyCoordinates),
12334
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11767
12335
  previewSample: {
11768
12336
  controlPoints: [
11769
12337
  [1.35, 0],
@@ -15402,7 +15970,7 @@ function createFortifiedLine(positions, options = {}, textAmplifiers = {}, conte
15402
15970
  const { smooth = DEFAULT_FORTIFIED_LINE_OPTIONS.smooth, smoothResolution = DEFAULT_FORTIFIED_LINE_OPTIONS.smoothResolution } = options;
15403
15971
  const safeSize = calculateEffectiveSize(options);
15404
15972
  const projectedPoints = positions.map((p) => project(p[0], p[1]));
15405
- const smoothedPath = smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints;
15973
+ const smoothedPath = smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$1(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints;
15406
15974
  const feature = {
15407
15975
  type: "Feature",
15408
15976
  properties: {},
@@ -15479,7 +16047,7 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}, conte
15479
16047
  const safeSize = calculateEffectiveSize(options);
15480
16048
  const projected = positions.map((p) => project(p[0], p[1]));
15481
16049
  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]];
16050
+ const boundaryInput = smooth ? evenlySpacePath(closedCatmullRom(ringPts, normalizeSmoothResolution$1(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)), 2 * safeSize) : [...ringPts, ringPts[0]];
15483
16051
  const boundaryPoints = generateFortifiedPoints(boundaryInput, safeSize);
15484
16052
  if (boundaryPoints.length > 0) {
15485
16053
  const firstPt = boundaryPoints[0];
@@ -15559,6 +16127,7 @@ const FRONTAL_ATTACK = defineControlMeasure({
15559
16127
  defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
15560
16128
  rule: axis1DrawRule,
15561
16129
  optionHandles: variableWidthAttackOptionHandles,
16130
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15562
16131
  previewSample: {
15563
16132
  controlPoints: [
15564
16133
  [1, 0],
@@ -15624,10 +16193,7 @@ const MOVEMENT_TO_CONTACT_BOLT_HANDLE_ID = "bolt-tip";
15624
16193
  const MOVEMENT_TO_CONTACT_STEM_SETBACK_RATIO = .22;
15625
16194
  const MOVEMENT_TO_CONTACT_STEM_OFFSET_RATIO = .55;
15626
16195
  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,
16196
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
15631
16197
  ...DEFAULT_TACTICAL_ARROW_OPTIONS,
15632
16198
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS,
15633
16199
  boltLengthRatio: MOVEMENT_TO_CONTACT_BOLT_LENGTH_RATIO,
@@ -15771,8 +16337,8 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15771
16337
  defaultOptions: DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS,
15772
16338
  rule: axis1DrawRule,
15773
16339
  optionHandles: {
15774
- get(controlPoints, options) {
15775
- const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options);
16340
+ get(controlPoints, options, query) {
16341
+ const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options, query);
15776
16342
  const resolved = resolveMovementToContactGeometry(controlPoints, options);
15777
16343
  if (!resolved) return widthHandles;
15778
16344
  return [...widthHandles, {
@@ -15793,8 +16359,12 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15793
16359
  boltLengthRatio: length / resolved.arrowBaseWidth,
15794
16360
  boltAngle: clamp(Math.abs(Math.atan2(cross, dot) * 180 / Math.PI), 0, 90)
15795
16361
  };
16362
+ },
16363
+ reset(event) {
16364
+ return variableWidthAttackOptionHandles.reset?.(event);
15796
16365
  }
15797
16366
  },
16367
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15798
16368
  previewSample: {
15799
16369
  controlPoints: [
15800
16370
  [1, 0],
@@ -17390,10 +17960,7 @@ const NO_FIRE_AREA_IRREGULAR = defineControlMeasure({
17390
17960
  //#endregion
17391
17961
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
17392
17962
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
17393
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
17394
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
17395
- smooth: false,
17396
- smoothResolution: 5,
17963
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
17397
17964
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
17398
17965
  };
17399
17966
  const MAIN_ATTACK_METADATA = {
@@ -17463,6 +18030,7 @@ const MAIN_ATTACK = defineControlMeasure({
17463
18030
  defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
17464
18031
  rule: axis1DrawRule,
17465
18032
  optionHandles: variableWidthAttackOptionHandles,
18033
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
17466
18034
  previewSample: {
17467
18035
  controlPoints: [
17468
18036
  [1, 0],
@@ -19935,6 +20503,7 @@ const DEFINITIONS = {
19935
20503
  defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
19936
20504
  rule: axis1DrawRule,
19937
20505
  optionHandles: variableWidthAttackOptionHandles,
20506
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
19938
20507
  previewSample: {
19939
20508
  controlPoints: [
19940
20509
  [1, 0],