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