@orbat-mapper/control-measures 0.27.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]];
@@ -992,6 +1123,24 @@ const bridgeOrGapDrawRule = {
992
1123
  }
993
1124
  };
994
1125
  //#endregion
1126
+ //#region src/draw-rules/line12.ts
1127
+ /**
1128
+ * Line12 anchor draw rule for Roadblocks, Craters and Blown Bridges.
1129
+ *
1130
+ * PT. 1 and PT. 2 are the endpoints of the symbol's centerline. PT. 3 is
1131
+ * constrained to the perpendicular axis through `midpoint(PT. 1, PT. 2)` and
1132
+ * locates one side of the symbol; its distance from the centerline is the
1133
+ * half-width. All three points are placed explicitly, with a two-point live
1134
+ * preview before commit.
1135
+ */
1136
+ const line12DrawRule = createMidpointPerpendicularDrawRule({
1137
+ id: "line12:roadblock",
1138
+ defaultDistanceRatio: .1,
1139
+ minimumUserPoints: 3,
1140
+ minimumPreviewPoints: 2,
1141
+ finishOnLastPointRepeat: true
1142
+ });
1143
+ //#endregion
995
1144
  //#region src/draw-rules/line23.ts
996
1145
  /**
997
1146
  * Line23 anchor draw rule for the Clear mission task.
@@ -1335,11 +1484,464 @@ const supportByFireDrawRule = {
1335
1484
  }
1336
1485
  };
1337
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
1338
1929
  //#region src/attack-utils.ts
1339
1930
  const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
1340
1931
  const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
1341
1932
  const SHAFT_MIN_RATIO = .1;
1342
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
+ };
1343
1945
  const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
1344
1946
  const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1345
1947
  /**
@@ -1350,30 +1952,72 @@ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1350
1952
  * matching centerline segment, over the reference half-width) live here once.
1351
1953
  */
1352
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
+ };
1353
1963
  return {
1354
- get(controlPoints, options) {
1964
+ get(controlPoints, options, query) {
1355
1965
  const geometry = config.geometry(controlPoints, options);
1356
1966
  const rearRight = geometry?.rightEdge[0];
1357
1967
  const neckRight = geometry?.rightEdge.at(-1);
1358
- if (!rearRight || !neckRight) return [];
1359
- return [{
1968
+ if (!geometry || !rearRight || !neckRight) return [];
1969
+ const handles = [{
1360
1970
  id: REAR_WIDTH_OPTION_HANDLE_ID,
1361
1971
  position: unproject(rearRight[0], rearRight[1])
1362
1972
  }, {
1363
1973
  id: SHAFT_WIDTH_OPTION_HANDLE_ID,
1364
1974
  position: unproject(neckRight[0], neckRight[1])
1365
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;
1366
1986
  },
1367
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
+ }
1368
1999
  const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
1369
2000
  if (!rear && !(handleId === "shaft-width")) return void 0;
1370
2001
  const geometry = config.geometry(controlPoints, options);
1371
2002
  if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
1372
- const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
1373
- 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);
1374
2006
  if (!from || !to) return void 0;
1375
2007
  const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
1376
- 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);
1377
2021
  }
1378
2022
  };
1379
2023
  }
@@ -1392,9 +2036,10 @@ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) =>
1392
2036
  const outerLeft = geometry?.headRing[0];
1393
2037
  if (!geometry || !outerLeft) return null;
1394
2038
  return {
1395
- spine: geometry.shaftCenterline,
2039
+ profile: geometry.shaftProfile,
1396
2040
  rightEdge: geometry.shaftRight,
1397
- referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
2041
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase)),
2042
+ controlPointCount: coordinates.length
1398
2043
  };
1399
2044
  },
1400
2045
  minRatio: SHAFT_MIN_RATIO,
@@ -1411,11 +2056,48 @@ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles(
1411
2056
  function resolveRearWidthRatio(options, shaftRatio) {
1412
2057
  return options.rearWidthRatio ?? shaftRatio;
1413
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
+ }
1414
2080
  /**
1415
2081
  * Processes input coordinates and options to generate the core symbol geometry.
1416
2082
  * This handles validation, default values, projection, and geometry calculation.
1417
2083
  */
1418
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) {
1419
2101
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
1420
2102
  const smooth = options.smooth ?? false;
1421
2103
  const smoothResolution = options.smoothResolution ?? 5;
@@ -1429,9 +2111,9 @@ function processAttackGeometry(coordinates, options = {}) {
1429
2111
  const p = points[i];
1430
2112
  if (p) spinePoints.push(p);
1431
2113
  }
1432
- 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)) };
1433
2115
  }
1434
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
2116
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smoothing, smoothResolution, spineWidthRatios = null) {
1435
2117
  const initialNeck = spine[spine.length - 1];
1436
2118
  if (!initialNeck) return null;
1437
2119
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -1484,15 +2166,46 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRat
1484
2166
  outerLeft
1485
2167
  ];
1486
2168
  const fullSpine = [...remainingSpine, shaftEndCenter];
1487
- 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
+ });
1488
2184
  return {
1489
- shaftCenterline: fullSpine,
2185
+ shaftCenterline: drawnSpine,
1490
2186
  shaftLeft,
1491
2187
  shaftRight,
1492
2188
  headRing,
1493
2189
  ptTip,
1494
2190
  ptNeck,
1495
- 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)
1496
2209
  };
1497
2210
  }
1498
2211
  /**
@@ -1777,242 +2490,86 @@ const line25DrawRule = {
1777
2490
  /**
1778
2491
  * Per-feature style hint pinning a filled part to a solid interior. Attach to
1779
2492
  * a generator's intrinsic doctrinal accents — arrowheads, teeth, echelon
1780
- * glyphs, barbs, blades — so a patterned (`hatch`, `dots`, …) fill set at the graphicsStyle
1781
- * or measure layer never bleeds into a small silhouette and ruins its
1782
- * legibility. The renderer only reads style hints, so one shared instance is
1783
- * safe to reuse across every accent feature.
1784
- */
1785
- const SOLID_ACCENT_FILL = { fillPattern: "solid" };
1786
- /**
1787
- * Ultimate fallback for the symbol color when no `color` or per-channel
1788
- * override or doctrinal metadata default is supplied. Keeps other measures
1789
- * monocolor black and guarantees filled parts still render filled. See ADR-0011.
1790
- */
1791
- const DEFAULT_SYMBOL_COLOR = "#000000";
1792
- /** MIL-STD-2525D Tables XV–XVI. Fill shades are neutral identity palette samples. */
1793
- const MIL_STD_GREEN = {
1794
- neon: "#00ff00",
1795
- dark: "#00a000",
1796
- medium: "#00e200",
1797
- light: "#aaffaa"
1798
- };
1799
- //#endregion
1800
- //#region src/portrayal.ts
1801
- /**
1802
- * Convert true-ground portrayal scale to the Web Mercator metres used by legacy
1803
- * geometry and authored text sizes. Use the graphic's control-point bounding-box
1804
- * midpoint for both label layout and painting, including moved labels.
1805
- */
1806
- function resolveConstructionMetersPerCssPixel(points, groundMetersPerCssPixel) {
1807
- if (!(groundMetersPerCssPixel !== void 0 && groundMetersPerCssPixel > 0)) return void 0;
1808
- let minLatitude = Infinity;
1809
- let maxLatitude = -Infinity;
1810
- for (const point of points) {
1811
- minLatitude = Math.min(minLatitude, point[1]);
1812
- maxLatitude = Math.max(maxLatitude, point[1]);
1813
- }
1814
- const midpointLatitude = (minLatitude + maxLatitude) / 2;
1815
- const mercatorScale = Math.cos(midpointLatitude * Math.PI / 180);
1816
- return groundMetersPerCssPixel / Math.max(mercatorScale, Number.EPSILON);
1817
- }
1818
- const DEFAULT_STROKE_WIDTH_CSS_PIXELS = 2;
1819
- const DEFAULT_STROKE_DASH_CSS_PIXELS = Object.freeze([]);
1820
- const DEFAULT_LINE_CAP = "round";
1821
- const DEFAULT_LINE_JOIN = "round";
1822
- const DEFAULT_LABEL_HEIGHT_CSS_PIXELS = 14;
1823
- const DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS = {
1824
- min: 8,
1825
- max: 24
1826
- };
1827
- /** Shared absent-value portrayal defaults; render output remains sparse. */
1828
- const DEFAULT_PORTRAYAL = Object.freeze({
1829
- symbolColor: DEFAULT_SYMBOL_COLOR,
1830
- strokeWidthCssPixels: 2,
1831
- strokeDashCssPixels: DEFAULT_STROKE_DASH_CSS_PIXELS,
1832
- lineCap: DEFAULT_LINE_CAP,
1833
- lineJoin: DEFAULT_LINE_JOIN,
1834
- labelHeightCssPixels: 14
1835
- });
1836
- //#endregion
1837
- //#region src/internal/angle-utils.ts
1838
- /**
1839
- * Wraps an angle (radians) into the half-open range (-π, π].
1840
- */
1841
- function normalizeRadians(angle) {
1842
- let normalized = angle % (Math.PI * 2);
1843
- if (normalized > Math.PI) normalized -= Math.PI * 2;
1844
- if (normalized <= -Math.PI) normalized += Math.PI * 2;
1845
- return normalized;
1846
- }
1847
- /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
1848
- function normalizeDegrees(degrees) {
1849
- return (degrees % 360 + 360) % 360;
1850
- }
1851
- /**
1852
- * Flips a text rotation by π when it would otherwise read upside-down, keeping
1853
- * the baseline pointing left-to-right (within ±90° of horizontal).
1854
- */
1855
- function keepTextLeftToRight(rotation) {
1856
- const normalized = normalizeRadians(rotation);
1857
- if (normalized > Math.PI / 2) return normalized - Math.PI;
1858
- if (normalized < -Math.PI / 2) return normalized + Math.PI;
1859
- return normalized;
1860
- }
1861
- //#endregion
1862
- //#region src/internal/sketch.ts
1863
- /**
1864
- * Projects `coordinates` and derives the arrow's tip, heading and total length.
1865
- * Returns `null` for degenerate input (fewer than two points, a zero-length
1866
- * path, or a zero-length final segment) so the caller can decide what to emit.
1867
- */
1868
- function arrowAxis(coordinates) {
1869
- if (coordinates.length < 2) return null;
1870
- const pts = coordinates.map((c) => project(c[0], c[1]));
1871
- let pathLength = 0;
1872
- 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]);
1873
- const tip = pts[pts.length - 1];
1874
- const prev = pts[pts.length - 2];
1875
- const hx = tip[0] - prev[0];
1876
- const hy = tip[1] - prev[1];
1877
- const segLength = Math.hypot(hx, hy);
1878
- if (segLength === 0 || pathLength === 0) return null;
1879
- const dir = [hx / segLength, hy / segLength];
1880
- const perp = [-dir[1], dir[0]];
1881
- return {
1882
- pts,
1883
- pathLength,
1884
- tip,
1885
- dir,
1886
- perp,
1887
- segLength
1888
- };
1889
- }
1890
- /**
1891
- * Clamps and rounds a smooth-resolution option (samples per segment) onto
1892
- * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1893
- * for non-finite input. The default varies per measure, so it's passed in.
1894
- */
1895
- function normalizeSmoothResolution$2(value, fallback) {
1896
- if (!Number.isFinite(value)) return fallback;
1897
- return Math.min(64, Math.max(2, Math.round(value)));
1898
- }
1899
- /**
1900
- * Shared param descriptors for {@link SmoothLineOptions}, modeled on
1901
- * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
1902
- * their own params should spread these at the top of the array.
1903
- */
1904
- const SMOOTH_LINE_PARAMS = [{
1905
- key: "smooth",
1906
- label: "Smooth",
1907
- description: "Round the line's corners by curving the path through the control points.",
1908
- type: "boolean"
1909
- }, {
1910
- key: "smoothResolution",
1911
- presentationTier: "advanced",
1912
- label: "Smooth resolution",
1913
- description: "Number of samples per segment when smooth mode is enabled.",
1914
- type: "number",
1915
- min: 2,
1916
- max: 64,
1917
- step: 1,
1918
- visibleWhen: (opts) => Boolean(opts.smooth)
1919
- }];
2493
+ * glyphs, barbs, blades — so a patterned (`hatch`, `dots`, …) fill set at the graphicsStyle
2494
+ * or measure layer never bleeds into a small silhouette and ruins its
2495
+ * legibility. The renderer only reads style hints, so one shared instance is
2496
+ * safe to reuse across every accent feature.
2497
+ */
2498
+ const SOLID_ACCENT_FILL = { fillPattern: "solid" };
1920
2499
  /**
1921
- * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
1922
- * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
1923
- * is set, else returns `verts` unchanged.
2500
+ * Ultimate fallback for the symbol color when no `color` or per-channel
2501
+ * override or doctrinal metadata default is supplied. Keeps other measures
2502
+ * monocolor black and guarantees filled parts still render filled. See ADR-0011.
1924
2503
  */
1925
- function smoothLineVerts(verts, options, defaultResolution) {
1926
- return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1927
- }
2504
+ const DEFAULT_SYMBOL_COLOR = "#000000";
2505
+ /** MIL-STD-2525D Tables XV–XVI. Fill shades are neutral identity palette samples. */
2506
+ const MIL_STD_GREEN = {
2507
+ neon: "#00ff00",
2508
+ dark: "#00a000",
2509
+ medium: "#00e200",
2510
+ light: "#aaffaa"
2511
+ };
2512
+ //#endregion
2513
+ //#region src/portrayal.ts
1928
2514
  /**
1929
- * Resamples a path into equal arc-length segments.
1930
- *
1931
- * This is useful for a repeated motif (such as a fortified tooth or FLOT
1932
- * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1933
- * parameter, not distance, so using them directly as motif boundaries causes
1934
- * the motifs to bunch up where the curve's samples are closer together.
2515
+ * Convert true-ground portrayal scale to the Web Mercator metres used by legacy
2516
+ * geometry and authored text sizes. Use the graphic's control-point bounding-box
2517
+ * midpoint for both label layout and painting, including moved labels.
1935
2518
  */
1936
- function evenlySpacePath(points, targetSegmentLength) {
1937
- if (points.length < 2) return points;
1938
- const segmentLengths = [];
1939
- let totalLength = 0;
1940
- for (let i = 0; i < points.length - 1; i++) {
1941
- const length = vecMag(vecSub(points[i + 1], points[i]));
1942
- segmentLengths.push(length);
1943
- totalLength += length;
1944
- }
1945
- if (totalLength < 1e-6) return [points[0]];
1946
- const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1947
- const segmentLength = totalLength / segmentCount;
1948
- const out = [points[0]];
1949
- let sourceIndex = 0;
1950
- let sourceStart = points[0];
1951
- let consumed = 0;
1952
- for (let segment = 1; segment < segmentCount; segment++) {
1953
- const target = segment * segmentLength;
1954
- while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1955
- consumed += segmentLengths[sourceIndex];
1956
- sourceIndex++;
1957
- sourceStart = points[sourceIndex];
1958
- }
1959
- const length = segmentLengths[sourceIndex];
1960
- if (length < 1e-6) continue;
1961
- const t = (target - consumed) / length;
1962
- const sourceEnd = points[sourceIndex + 1];
1963
- out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
2519
+ function resolveConstructionMetersPerCssPixel(points, groundMetersPerCssPixel) {
2520
+ if (!(groundMetersPerCssPixel !== void 0 && groundMetersPerCssPixel > 0)) return void 0;
2521
+ let minLatitude = Infinity;
2522
+ let maxLatitude = -Infinity;
2523
+ for (const point of points) {
2524
+ minLatitude = Math.min(minLatitude, point[1]);
2525
+ maxLatitude = Math.max(maxLatitude, point[1]);
1964
2526
  }
1965
- out.push(points[points.length - 1]);
1966
- return out;
1967
- }
1968
- /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1969
- function catmullRomAt(p0, p1, p2, p3, t) {
1970
- const t2 = t * t;
1971
- const t3 = t2 * t;
1972
- 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)];
2527
+ const midpointLatitude = (minLatitude + maxLatitude) / 2;
2528
+ const mercatorScale = Math.cos(midpointLatitude * Math.PI / 180);
2529
+ return groundMetersPerCssPixel / Math.max(mercatorScale, Number.EPSILON);
1973
2530
  }
2531
+ const DEFAULT_STROKE_WIDTH_CSS_PIXELS = 2;
2532
+ const DEFAULT_STROKE_DASH_CSS_PIXELS = Object.freeze([]);
2533
+ const DEFAULT_LINE_CAP = "round";
2534
+ const DEFAULT_LINE_JOIN = "round";
2535
+ const DEFAULT_LABEL_HEIGHT_CSS_PIXELS = 14;
2536
+ const DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS = {
2537
+ min: 8,
2538
+ max: 24
2539
+ };
2540
+ /** Shared absent-value portrayal defaults; render output remains sparse. */
2541
+ const DEFAULT_PORTRAYAL = Object.freeze({
2542
+ symbolColor: DEFAULT_SYMBOL_COLOR,
2543
+ strokeWidthCssPixels: 2,
2544
+ strokeDashCssPixels: DEFAULT_STROKE_DASH_CSS_PIXELS,
2545
+ lineCap: DEFAULT_LINE_CAP,
2546
+ lineJoin: DEFAULT_LINE_JOIN,
2547
+ labelHeightCssPixels: 14
2548
+ });
2549
+ //#endregion
2550
+ //#region src/internal/angle-utils.ts
1974
2551
  /**
1975
- * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
1976
- * segment. Endpoints are duplicated so the curve passes through them, so the
1977
- * first and last points are preserved exactly. With `samples <= 1` (or fewer
1978
- * than 3 points) it returns the input unchanged.
2552
+ * Wraps an angle (radians) into the half-open range (-π, π].
1979
2553
  */
1980
- function catmullRom(points, samples) {
1981
- if (points.length < 3 || samples <= 1) return points;
1982
- const pts = [
1983
- points[0],
1984
- ...points,
1985
- points[points.length - 1]
1986
- ];
1987
- const out = [points[0]];
1988
- for (let i = 1; i < pts.length - 2; i++) {
1989
- const p0 = pts[i - 1];
1990
- const p1 = pts[i];
1991
- const p2 = pts[i + 1];
1992
- const p3 = pts[i + 2];
1993
- for (let s = 1; s <= samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
1994
- }
1995
- return out;
2554
+ function normalizeRadians(angle) {
2555
+ let normalized = angle % (Math.PI * 2);
2556
+ if (normalized > Math.PI) normalized -= Math.PI * 2;
2557
+ if (normalized <= -Math.PI) normalized += Math.PI * 2;
2558
+ return normalized;
2559
+ }
2560
+ /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
2561
+ function normalizeDegrees(degrees) {
2562
+ return (degrees % 360 + 360) % 360;
1996
2563
  }
1997
2564
  /**
1998
- * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
1999
- * wrapping the control points around the seam. Returns a ring whose first and
2000
- * last positions coincide (explicit closure). With `samples <= 1` (or fewer
2001
- * than 3 points) it returns the input unchanged.
2565
+ * Flips a text rotation by π when it would otherwise read upside-down, keeping
2566
+ * the baseline pointing left-to-right (within ±90° of horizontal).
2002
2567
  */
2003
- function closedCatmullRom(points, samples) {
2004
- const n = points.length;
2005
- if (n < 3 || samples <= 1) return points;
2006
- const out = [];
2007
- for (let i = 0; i < n; i++) {
2008
- const p0 = points[(i - 1 + n) % n];
2009
- const p1 = points[i];
2010
- const p2 = points[(i + 1) % n];
2011
- const p3 = points[(i + 2) % n];
2012
- for (let s = 0; s < samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
2013
- }
2014
- out.push([...out[0]]);
2015
- return out;
2568
+ function keepTextLeftToRight(rotation) {
2569
+ const normalized = normalizeRadians(rotation);
2570
+ if (normalized > Math.PI / 2) return normalized - Math.PI;
2571
+ if (normalized < -Math.PI / 2) return normalized + Math.PI;
2572
+ return normalized;
2016
2573
  }
2017
2574
  //#endregion
2018
2575
  //#region src/internal/line-labels.ts
@@ -2981,7 +3538,7 @@ function createWireObstacle(positions, options, text, context, kind) {
2981
3538
  features: [...features, ...labels]
2982
3539
  };
2983
3540
  }
2984
- function definition$1(id, name, kind) {
3541
+ function definition$2(id, name, kind) {
2985
3542
  const spec = WIRE_KINDS[kind];
2986
3543
  return defineControlMeasure({
2987
3544
  metadata: {
@@ -3040,15 +3597,15 @@ function definition$1(id, name, kind) {
3040
3597
  }
3041
3598
  });
3042
3599
  }
3043
- const WIRE_OBSTACLE_UNSPECIFIED = definition$1("wire-obstacle-unspecified", "Wire Obstacles – Unspecified", "unspecified");
3044
- const SINGLE_FENCE = definition$1("single-fence", "Single Fence", "single");
3045
- const DOUBLE_FENCE = definition$1("double-fence", "Double Fence", "double");
3046
- const DOUBLE_APRON_FENCE = definition$1("double-apron-fence", "Double Apron Fence", "apron");
3047
- const LOW_WIRE_FENCE = definition$1("low-wire-fence", "Low Wire Fence", "low");
3048
- const HIGH_WIRE_FENCE = definition$1("high-wire-fence", "High Wire Fence", "high");
3049
- const SINGLE_CONCERTINA = definition$1("single-concertina", "Single Concertina", "concertina");
3050
- const DOUBLE_STRAND_CONCERTINA = definition$1("double-strand-concertina", "Double Strand Concertina", "double-concertina");
3051
- const TRIPLE_STRAND_CONCERTINA = definition$1("triple-strand-concertina", "Triple Strand Concertina", "triple-concertina");
3600
+ const WIRE_OBSTACLE_UNSPECIFIED = definition$2("wire-obstacle-unspecified", "Wire Obstacles – Unspecified", "unspecified");
3601
+ const SINGLE_FENCE = definition$2("single-fence", "Single Fence", "single");
3602
+ const DOUBLE_FENCE = definition$2("double-fence", "Double Fence", "double");
3603
+ const DOUBLE_APRON_FENCE = definition$2("double-apron-fence", "Double Apron Fence", "apron");
3604
+ const LOW_WIRE_FENCE = definition$2("low-wire-fence", "Low Wire Fence", "low");
3605
+ const HIGH_WIRE_FENCE = definition$2("high-wire-fence", "High Wire Fence", "high");
3606
+ const SINGLE_CONCERTINA = definition$2("single-concertina", "Single Concertina", "concertina");
3607
+ const DOUBLE_STRAND_CONCERTINA = definition$2("double-strand-concertina", "Double Strand Concertina", "double-concertina");
3608
+ const TRIPLE_STRAND_CONCERTINA = definition$2("triple-strand-concertina", "Triple Strand Concertina", "triple-concertina");
3052
3609
  //#endregion
3053
3610
  //#region src/generators/cm34-mission-tasks/shared.ts
3054
3611
  /**
@@ -3861,12 +4418,26 @@ const ATTACK_SHAFT_PARAMS = [
3861
4418
  key: "smoothResolution",
3862
4419
  presentationTier: "advanced",
3863
4420
  label: "Smooth resolution",
3864
- 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.",
3865
4422
  type: "number",
3866
4423
  min: 1,
3867
4424
  max: 20,
3868
4425
  step: 1,
3869
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
3870
4441
  }
3871
4442
  ];
3872
4443
  const FIRE_ARROW_PARAMS = [
@@ -4104,12 +4675,7 @@ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d)
4104
4675
  const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
4105
4676
  //#endregion
4106
4677
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
4107
- const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
4108
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
4109
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
4110
- smooth: false,
4111
- smoothResolution: 5
4112
- };
4678
+ const DEFAULT_AIRBORNE_ATTACK_OPTIONS = { ...DEFAULT_ATTACK_SHAFT_OPTIONS };
4113
4679
  const AIRBORNE_ATTACK_METADATA = {
4114
4680
  id: "airborne-attack",
4115
4681
  name: "Airborne Attack",
@@ -4145,8 +4711,7 @@ function createAirborneAttack(coordinates, options = {}) {
4145
4711
  type: "FeatureCollection",
4146
4712
  features: []
4147
4713
  };
4148
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
4149
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
4714
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
4150
4715
  return {
4151
4716
  type: "FeatureCollection",
4152
4717
  features: [{
@@ -4172,7 +4737,8 @@ const AIRBORNE_ATTACK = defineControlMeasure({
4172
4737
  generator: createAirborneAttack,
4173
4738
  defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
4174
4739
  rule: axis1DrawRule,
4175
- optionHandles: variableWidthAttackOptionHandles
4740
+ optionHandles: variableWidthAttackOptionHandles,
4741
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
4176
4742
  });
4177
4743
  /**
4178
4744
  * Reacts to an **input-contract** violation according to `mode`: `throw`
@@ -5187,7 +5753,7 @@ function projectRingPoints(positions) {
5187
5753
  */
5188
5754
  function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
5189
5755
  const ringPts = projectRingPoints(positions);
5190
- return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5756
+ return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$1(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
5191
5757
  }
5192
5758
  /**
5193
5759
  * Walks a closed ring into per-segment metadata (unit direction, outward
@@ -7319,10 +7885,7 @@ const ATTACK_BY_FIRE = defineControlMeasure({
7319
7885
  //#endregion
7320
7886
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
7321
7887
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
7322
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
7323
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
7324
- smooth: false,
7325
- smoothResolution: 5,
7888
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
7326
7889
  symbolHeightRatio: .45,
7327
7890
  triangleSizeRatio: .25,
7328
7891
  bottomBarWidthRatio: 1.5,
@@ -7405,15 +7968,14 @@ function createAttackHelicopter(coordinates, options = {}) {
7405
7968
  type: "FeatureCollection",
7406
7969
  features: []
7407
7970
  };
7408
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
7409
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
7410
7971
  if (geometry.shaftLeft.length < 2 || geometry.shaftRight.length < 2) return {
7411
7972
  type: "FeatureCollection",
7412
7973
  features: []
7413
7974
  };
7414
- const pL = geometry.shaftLeft[geometry.shaftLeft.length - 2];
7975
+ const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
7976
+ const pL = shaftLeftToNeck.at(-1);
7415
7977
  const pIR = geometry.headRing[3];
7416
- const pR = geometry.shaftRight[geometry.shaftRight.length - 2];
7978
+ const pR = shaftRightToNeck.at(-1);
7417
7979
  const pIL = geometry.headRing[5];
7418
7980
  if (!pL || !pIR || !pR || !pIL) return {
7419
7981
  type: "FeatureCollection",
@@ -7540,7 +8102,8 @@ const ATTACK_HELICOPTER = defineControlMeasure({
7540
8102
  generator: createAttackHelicopter,
7541
8103
  defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
7542
8104
  rule: axis1DrawRule,
7543
- optionHandles: variableWidthAttackOptionHandles
8105
+ optionHandles: variableWidthAttackOptionHandles,
8106
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS
7544
8107
  });
7545
8108
  //#endregion
7546
8109
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
@@ -8174,13 +8737,144 @@ const BRIDGE_OR_GAP = defineControlMeasure({
8174
8737
  }
8175
8738
  });
8176
8739
  //#endregion
8740
+ //#region src/generators/cm27-protection-areas/roadblocks.ts
8741
+ /**
8742
+ * Angle the executed roadblock's crossing pair is rotated from the anchored
8743
+ * pair, swinging PT. 1's end toward PT. 3 — measured off the MIL-STD-2525E
8744
+ * example.
8745
+ */
8746
+ const COMPLETE_CROSSING_ANGLE = 75 * Math.PI / 180;
8747
+ /** Intrinsic doctrinal dash pattern for the not-yet-emplaced lines. */
8748
+ const ROADBLOCK_DASH = [6, 4];
8749
+ const DEFAULT_ROADBLOCK_OPTIONS = {};
8750
+ const PREVIEW_POINTS$1 = [
8751
+ [.35, .7],
8752
+ [-.35, -.7],
8753
+ [-.134, .067]
8754
+ ];
8755
+ const ROADBLOCK_KINDS = {
8756
+ planned: {
8757
+ name: "Roadblock - Planned",
8758
+ entitySubtype: "Planned",
8759
+ previewPoints: PREVIEW_POINTS$1,
8760
+ description: "A planned roadblock, crater, or blown bridge not yet emplaced."
8761
+ },
8762
+ safe: {
8763
+ name: "Roadblock - Explosives, State of Readiness 1 (Safe)",
8764
+ entitySubtype: "Explosives, State of Readiness 1 (Safe)",
8765
+ previewPoints: PREVIEW_POINTS$1,
8766
+ description: "A roadblock, crater, or blown bridge with explosives emplaced but not armed."
8767
+ },
8768
+ armed: {
8769
+ name: "Roadblock - Explosives, State of Readiness 2 (Armed but Passable)",
8770
+ entitySubtype: "Explosives, State of Readiness 2 (Armed but Passable)",
8771
+ previewPoints: PREVIEW_POINTS$1,
8772
+ description: "A roadblock, crater, or blown bridge with explosives armed; the route is still passable."
8773
+ },
8774
+ complete: {
8775
+ name: "Roadblock Complete (Executed)",
8776
+ entitySubtype: "Roadblock Complete (Executed)",
8777
+ description: "An executed roadblock, crater, or blown bridge that blocks the route.",
8778
+ previewPoints: [
8779
+ [.45, .6],
8780
+ [-.45, -.6],
8781
+ [-.096, .072]
8782
+ ]
8783
+ }
8784
+ };
8785
+ function linesFeature(part, lines, dashed) {
8786
+ return {
8787
+ type: "Feature",
8788
+ properties: dashed ? {
8789
+ part,
8790
+ style: { strokeDash: [...ROADBLOCK_DASH] }
8791
+ } : { part },
8792
+ geometry: {
8793
+ type: "MultiLineString",
8794
+ coordinates: lines
8795
+ }
8796
+ };
8797
+ }
8798
+ /**
8799
+ * Generates a roadblock of the given subtype from PT. 1 and PT. 2 (centerline
8800
+ * endpoints) and PT. 3 (one side). The input contract is enforced at the render
8801
+ * seam (ADR-0014).
8802
+ */
8803
+ function createRoadblock(coordinates, kind) {
8804
+ const p1 = project(coordinates[0][0], coordinates[0][1]);
8805
+ const p2 = project(coordinates[1][0], coordinates[1][1]);
8806
+ const frame = createProjectedBaselineFrame(p1, p2, {
8807
+ origin: "midpoint",
8808
+ normal: "right"
8809
+ });
8810
+ if (!frame) return {
8811
+ type: "FeatureCollection",
8812
+ features: []
8813
+ };
8814
+ const distance = frame.signedNormalDistance(coordinates[2]);
8815
+ const offset = vecScale(frame.normal, distance);
8816
+ const near = [vecAdd(p1, offset), vecAdd(p2, offset)];
8817
+ const far = [vecSub(p1, offset), vecSub(p2, offset)];
8818
+ if (kind === "safe") return {
8819
+ type: "FeatureCollection",
8820
+ features: [linesFeature("dashed-side", [near.map(toPosition)], true), linesFeature("solid-side", [far.map(toPosition)], false)]
8821
+ };
8822
+ const features = [linesFeature("sides", [near.map(toPosition), far.map(toPosition)], kind === "planned")];
8823
+ if (kind === "complete") {
8824
+ const angle = (distance < 0 ? -1 : 1) * COMPLETE_CROSSING_ANGLE;
8825
+ const center = frame.origin;
8826
+ const rotated = (line) => line.map((point) => toPosition(vecAdd(center, vecRotate(vecSub(point, center), angle))));
8827
+ features.push(linesFeature("crossing-sides", [rotated(near), rotated(far)], false));
8828
+ }
8829
+ return {
8830
+ type: "FeatureCollection",
8831
+ features
8832
+ };
8833
+ }
8834
+ function definition$1(id, value, kind) {
8835
+ const { name, entitySubtype, description, previewPoints } = ROADBLOCK_KINDS[kind];
8836
+ return defineControlMeasure({
8837
+ metadata: {
8838
+ id,
8839
+ name,
8840
+ description,
8841
+ entity: "Protection Areas",
8842
+ colorRole: "obstacle",
8843
+ entityType: "Roadblocks, Craters and Blown Bridges",
8844
+ entitySubtype,
8845
+ value,
8846
+ minCoordinates: 3,
8847
+ maxCoordinates: 3,
8848
+ geometry: "line",
8849
+ geometryTypes: ["MultiLineString"],
8850
+ paints: {
8851
+ stroke: true,
8852
+ fill: "none",
8853
+ text: false
8854
+ },
8855
+ drawRule: "Line12"
8856
+ },
8857
+ generator: (positions, _options = {}) => createRoadblock(positions, kind),
8858
+ defaultOptions: DEFAULT_ROADBLOCK_OPTIONS,
8859
+ rule: line12DrawRule,
8860
+ previewSample: { controlPoints: previewPoints }
8861
+ });
8862
+ }
8863
+ const ROADBLOCK_PLANNED = definition$1("roadblock-planned", "271201", "planned");
8864
+ const ROADBLOCK_EXPLOSIVES_SAFE = definition$1("roadblock-explosives-safe", "271202", "safe");
8865
+ const ROADBLOCK_EXPLOSIVES_ARMED = definition$1("roadblock-explosives-armed", "271203", "armed");
8866
+ const ROADBLOCK_COMPLETE = definition$1("roadblock-complete", "271204", "complete");
8867
+ //#endregion
8177
8868
  //#region src/generators/cm99-generic-arrows/blockArrow.ts
8178
8869
  const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
8179
- const MIN_SMOOTH_RESOLUTION$1 = 2;
8180
- const MAX_SMOOTH_RESOLUTION$1 = 64;
8181
8870
  const DEFAULT_SHAFT_WIDTH_RATIO = .06;
8182
8871
  const MIN_SHAFT_WIDTH_RATIO = .01;
8183
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;
8184
8878
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
8185
8879
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
8186
8880
  rearWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
@@ -8189,6 +8883,7 @@ const DEFAULT_BLOCK_ARROW_OPTIONS = {
8189
8883
  arrowheadLengthRatio: .22,
8190
8884
  smooth: false,
8191
8885
  smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4,
8886
+ smoothMode: "curve",
8192
8887
  filled: true
8193
8888
  };
8194
8889
  const BLOCK_ARROW_METADATA = {
@@ -8226,7 +8921,7 @@ const BLOCK_ARROW_METADATA = {
8226
8921
  description: "Width at the rear of the shaft as a fraction of the total path length",
8227
8922
  type: "number",
8228
8923
  min: MIN_SHAFT_WIDTH_RATIO,
8229
- max: MAX_SHAFT_WIDTH_RATIO,
8924
+ max: MAX_FLARE_WIDTH_RATIO,
8230
8925
  step: .01
8231
8926
  },
8232
8927
  {
@@ -8288,20 +8983,34 @@ const BLOCK_ARROW_METADATA = {
8288
8983
  {
8289
8984
  key: "smooth",
8290
8985
  label: "Smooth",
8291
- 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.",
8292
8987
  type: "boolean"
8293
8988
  },
8294
8989
  {
8295
8990
  key: "smoothResolution",
8296
8991
  presentationTier: "advanced",
8297
8992
  label: "Smooth resolution",
8298
- 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.",
8299
8994
  type: "number",
8300
8995
  min: 2,
8301
8996
  max: 64,
8302
8997
  step: 1,
8303
8998
  visibleWhen: (opts) => Boolean(opts.smooth)
8304
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
+ },
8305
9014
  {
8306
9015
  key: "filled",
8307
9016
  label: "Filled",
@@ -8409,10 +9118,23 @@ function calculateBlockArrowGeometry(coordinates, options) {
8409
9118
  ];
8410
9119
  break;
8411
9120
  }
8412
- let spine = [...pts.slice(0, -1), onAxis(shaftEndDist)];
8413
- if (options.smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(options.smoothResolution));
8414
- const widths = interpolatePolylineValues(spine, halfRear, halfShaft);
8415
- 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
+ });
8416
9138
  const ring = [
8417
9139
  ...leftSide,
8418
9140
  ...headPts,
@@ -8421,10 +9143,9 @@ function calculateBlockArrowGeometry(coordinates, options) {
8421
9143
  ring.push(ring[0]);
8422
9144
  return {
8423
9145
  ring,
8424
- spine,
8425
- leftSide,
8426
9146
  rightSide,
8427
- pathLength
9147
+ pathLength,
9148
+ profile
8428
9149
  };
8429
9150
  }
8430
9151
  function resolveBlockArrowOptions(options) {
@@ -8447,15 +9168,17 @@ const BLOCK_ARROW = defineControlMeasure({
8447
9168
  const geometry = calculateBlockArrowGeometry(controlPoints, resolveBlockArrowOptions(options));
8448
9169
  if (!geometry) return null;
8449
9170
  return {
8450
- spine: geometry.spine,
9171
+ profile: geometry.profile,
8451
9172
  rightEdge: geometry.rightSide,
8452
- referenceHalfWidth: geometry.pathLength / 2
9173
+ referenceHalfWidth: geometry.pathLength / 2,
9174
+ controlPointCount: controlPoints.length
8453
9175
  };
8454
9176
  },
8455
9177
  minRatio: MIN_SHAFT_WIDTH_RATIO,
8456
- maxRearRatio: MAX_SHAFT_WIDTH_RATIO,
9178
+ maxRearRatio: MAX_FLARE_WIDTH_RATIO,
8457
9179
  maxShaftRatio: MAX_SHAFT_WIDTH_RATIO
8458
9180
  }),
9181
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
8459
9182
  previewSample: {
8460
9183
  controlPoints: [
8461
9184
  [1.4, 0],
@@ -8477,10 +9200,6 @@ function axis1Geometry(coordinates) {
8477
9200
  ...metrics && Math.abs(metrics.lateral) > 1e-6 ? { headHalfWidth: Math.abs(metrics.lateral) } : {}
8478
9201
  };
8479
9202
  }
8480
- function normalizeSmoothResolution$1(value) {
8481
- if (!Number.isFinite(value)) return DEFAULT_SMOOTH_RESOLUTION$4;
8482
- return Math.min(MAX_SMOOTH_RESOLUTION$1, Math.max(MIN_SMOOTH_RESOLUTION$1, Math.round(value)));
8483
- }
8484
9203
  //#endregion
8485
9204
  //#region src/internal/line-echelon.ts
8486
9205
  /**
@@ -10119,7 +10838,7 @@ function createGenericLine(coordinates, options = {}) {
10119
10838
  ...DEFAULT_GENERIC_LINE_OPTIONS,
10120
10839
  ...options
10121
10840
  };
10122
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10841
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10123
10842
  return {
10124
10843
  type: "FeatureCollection",
10125
10844
  features: [{
@@ -10180,7 +10899,7 @@ function createGenericPolygon(coordinates, options = {}) {
10180
10899
  ...DEFAULT_GENERIC_POLYGON_OPTIONS,
10181
10900
  ...options
10182
10901
  };
10183
- const resolution = normalizeSmoothResolution$2(smoothResolution, 12);
10902
+ const resolution = normalizeSmoothResolution$1(smoothResolution, 12);
10184
10903
  const ring = smooth ? closedCatmullRom(ringPoints, resolution) : [...ringPoints, ringPoints[0]];
10185
10904
  return {
10186
10905
  type: "FeatureCollection",
@@ -11097,10 +11816,7 @@ const DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS = {
11097
11816
  labelPadding: 0
11098
11817
  };
11099
11818
  const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
11100
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11101
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11102
- smooth: false,
11103
- smoothResolution: 5,
11819
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11104
11820
  crossbarLengthRatio: 1.1,
11105
11821
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11106
11822
  };
@@ -11166,8 +11882,9 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
11166
11882
  const crossbarFrame = pointAlongPolyline(segments, totalLength, shaftPosition);
11167
11883
  crossbarCenter = crossbarFrame.point;
11168
11884
  crossbarAlong = crossbarFrame.along;
11169
- const rearWidth = vecMag(vecSub(geometry.shaftLeft[0], geometry.shaftRight[0]));
11170
- 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));
11171
11888
  }
11172
11889
  const crossbarLength = crossbarBaseWidth * Math.max(1, resolved.crossbarLengthRatio);
11173
11890
  const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
@@ -11268,10 +11985,7 @@ const MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS = [
11268
11985
  //#endregion
11269
11986
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
11270
11987
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
11271
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11272
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11273
- smooth: false,
11274
- smoothResolution: 5,
11988
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11275
11989
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
11276
11990
  };
11277
11991
  const SUPPORTING_ATTACK_METADATA = {
@@ -11346,6 +12060,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11346
12060
  defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
11347
12061
  rule: axis1DrawRule,
11348
12062
  optionHandles: variableWidthAttackOptionHandles,
12063
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11349
12064
  previewSample: {
11350
12065
  controlPoints: [
11351
12066
  [1, 0],
@@ -11364,10 +12079,7 @@ const SUPPORTING_ATTACK = defineControlMeasure({
11364
12079
  //#endregion
11365
12080
  //#region src/generators/cm34-mission-tasks/counterattack.ts
11366
12081
  const DEFAULT_COUNTERATTACK_OPTIONS = {
11367
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
11368
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
11369
- smooth: false,
11370
- smoothResolution: 5,
12082
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
11371
12083
  labelPosition: 1
11372
12084
  };
11373
12085
  /** Intrinsic dash and gap lengths for the complete Counterattack outline. */
@@ -11457,6 +12169,7 @@ const COUNTERATTACK = defineControlMeasure({
11457
12169
  defaultOptions: DEFAULT_COUNTERATTACK_OPTIONS,
11458
12170
  rule: axis1DrawRule,
11459
12171
  optionHandles: variableWidthAttackOptionHandles,
12172
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11460
12173
  previewSample: {
11461
12174
  controlPoints: [
11462
12175
  [1, 0],
@@ -11618,6 +12331,7 @@ const COUNTERATTACK_BY_FIRE = defineControlMeasure({
11618
12331
  defaultOptions: DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS,
11619
12332
  rule: counterattackByFireDrawRule,
11620
12333
  optionHandles: createVariableWidthAttackOptionHandles(counterattackByFireBodyCoordinates),
12334
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
11621
12335
  previewSample: {
11622
12336
  controlPoints: [
11623
12337
  [1.35, 0],
@@ -15256,7 +15970,7 @@ function createFortifiedLine(positions, options = {}, textAmplifiers = {}, conte
15256
15970
  const { smooth = DEFAULT_FORTIFIED_LINE_OPTIONS.smooth, smoothResolution = DEFAULT_FORTIFIED_LINE_OPTIONS.smoothResolution } = options;
15257
15971
  const safeSize = calculateEffectiveSize(options);
15258
15972
  const projectedPoints = positions.map((p) => project(p[0], p[1]));
15259
- 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;
15260
15974
  const feature = {
15261
15975
  type: "Feature",
15262
15976
  properties: {},
@@ -15333,7 +16047,7 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}, conte
15333
16047
  const safeSize = calculateEffectiveSize(options);
15334
16048
  const projected = positions.map((p) => project(p[0], p[1]));
15335
16049
  const ringPts = projected.length > 1 && vecMag(vecSub(projected[0], projected[projected.length - 1])) < 1e-6 ? projected.slice(0, -1) : projected;
15336
- 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]];
15337
16051
  const boundaryPoints = generateFortifiedPoints(boundaryInput, safeSize);
15338
16052
  if (boundaryPoints.length > 0) {
15339
16053
  const firstPt = boundaryPoints[0];
@@ -15413,6 +16127,7 @@ const FRONTAL_ATTACK = defineControlMeasure({
15413
16127
  defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
15414
16128
  rule: axis1DrawRule,
15415
16129
  optionHandles: variableWidthAttackOptionHandles,
16130
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15416
16131
  previewSample: {
15417
16132
  controlPoints: [
15418
16133
  [1, 0],
@@ -15478,10 +16193,7 @@ const MOVEMENT_TO_CONTACT_BOLT_HANDLE_ID = "bolt-tip";
15478
16193
  const MOVEMENT_TO_CONTACT_STEM_SETBACK_RATIO = .22;
15479
16194
  const MOVEMENT_TO_CONTACT_STEM_OFFSET_RATIO = .55;
15480
16195
  const DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS = {
15481
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
15482
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
15483
- smooth: false,
15484
- smoothResolution: 5,
16196
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
15485
16197
  ...DEFAULT_TACTICAL_ARROW_OPTIONS,
15486
16198
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS,
15487
16199
  boltLengthRatio: MOVEMENT_TO_CONTACT_BOLT_LENGTH_RATIO,
@@ -15625,8 +16337,8 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15625
16337
  defaultOptions: DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS,
15626
16338
  rule: axis1DrawRule,
15627
16339
  optionHandles: {
15628
- get(controlPoints, options) {
15629
- const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options);
16340
+ get(controlPoints, options, query) {
16341
+ const widthHandles = variableWidthAttackOptionHandles.get(controlPoints, options, query);
15630
16342
  const resolved = resolveMovementToContactGeometry(controlPoints, options);
15631
16343
  if (!resolved) return widthHandles;
15632
16344
  return [...widthHandles, {
@@ -15647,8 +16359,12 @@ const MOVEMENT_TO_CONTACT = defineControlMeasure({
15647
16359
  boltLengthRatio: length / resolved.arrowBaseWidth,
15648
16360
  boltAngle: clamp(Math.abs(Math.atan2(cross, dot) * 180 / Math.PI), 0, 90)
15649
16361
  };
16362
+ },
16363
+ reset(event) {
16364
+ return variableWidthAttackOptionHandles.reset?.(event);
15650
16365
  }
15651
16366
  },
16367
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
15652
16368
  previewSample: {
15653
16369
  controlPoints: [
15654
16370
  [1, 0],
@@ -17244,10 +17960,7 @@ const NO_FIRE_AREA_IRREGULAR = defineControlMeasure({
17244
17960
  //#endregion
17245
17961
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
17246
17962
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
17247
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
17248
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
17249
- smooth: false,
17250
- smoothResolution: 5,
17963
+ ...DEFAULT_ATTACK_SHAFT_OPTIONS,
17251
17964
  ...DEFAULT_MANEUVER_ARROW_TASK_AMPLIFIER_OPTIONS
17252
17965
  };
17253
17966
  const MAIN_ATTACK_METADATA = {
@@ -17317,6 +18030,7 @@ const MAIN_ATTACK = defineControlMeasure({
17317
18030
  defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
17318
18031
  rule: axis1DrawRule,
17319
18032
  optionHandles: variableWidthAttackOptionHandles,
18033
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
17320
18034
  previewSample: {
17321
18035
  controlPoints: [
17322
18036
  [1, 0],
@@ -19789,6 +20503,7 @@ const DEFINITIONS = {
19789
20503
  defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
19790
20504
  rule: axis1DrawRule,
19791
20505
  optionHandles: variableWidthAttackOptionHandles,
20506
+ vertexAlignedOptions: VERTEX_WIDTH_ALIGNED_OPTIONS,
19792
20507
  previewSample: {
19793
20508
  controlPoints: [
19794
20509
  [1, 0],
@@ -19806,6 +20521,10 @@ const DEFINITIONS = {
19806
20521
  }),
19807
20522
  block: BLOCK,
19808
20523
  "bridge-or-gap": BRIDGE_OR_GAP,
20524
+ "roadblock-planned": ROADBLOCK_PLANNED,
20525
+ "roadblock-explosives-safe": ROADBLOCK_EXPLOSIVES_SAFE,
20526
+ "roadblock-explosives-armed": ROADBLOCK_EXPLOSIVES_ARMED,
20527
+ "roadblock-complete": ROADBLOCK_COMPLETE,
19809
20528
  disrupt: DISRUPT,
19810
20529
  fix: FIX,
19811
20530
  "fix-mission-task": FIX_MISSION_TASK,