@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.
- package/README.md +4 -0
- package/dist/{index-DqX3wWxV.d.mts → index-Da8nAQ7C.d.mts} +132 -17
- 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-Cil8mQzg.mjs → renderControlMeasure-ZpYrUlKM.mjs} +1069 -350
- package/media/roadblock-complete.svg +1 -0
- package/media/roadblock-explosives-armed.svg +1 -0
- package/media/roadblock-explosives-safe.svg +1 -0
- package/media/roadblock-planned.svg +1 -0
- 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]];
|
|
@@ -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
|
-
|
|
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
|
|
1373
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
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
|
-
|
|
1926
|
-
|
|
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
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
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
|
|
1937
|
-
if (
|
|
1938
|
-
|
|
1939
|
-
let
|
|
1940
|
-
for (
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
-
|
|
1966
|
-
|
|
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
|
-
*
|
|
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
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
*
|
|
1999
|
-
*
|
|
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
|
|
2004
|
-
const
|
|
2005
|
-
if (
|
|
2006
|
-
|
|
2007
|
-
|
|
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$
|
|
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$
|
|
3044
|
-
const SINGLE_FENCE = definition$
|
|
3045
|
-
const DOUBLE_FENCE = definition$
|
|
3046
|
-
const DOUBLE_APRON_FENCE = definition$
|
|
3047
|
-
const LOW_WIRE_FENCE = definition$
|
|
3048
|
-
const HIGH_WIRE_FENCE = definition$
|
|
3049
|
-
const SINGLE_CONCERTINA = definition$
|
|
3050
|
-
const DOUBLE_STRAND_CONCERTINA = definition$
|
|
3051
|
-
const TRIPLE_STRAND_CONCERTINA = definition$
|
|
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
|
|
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$
|
|
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
|
-
|
|
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
|
|
7975
|
+
const { left: shaftLeftToNeck, right: shaftRightToNeck } = shaftEdgesToNeck(geometry);
|
|
7976
|
+
const pL = shaftLeftToNeck.at(-1);
|
|
7415
7977
|
const pIR = geometry.headRing[3];
|
|
7416
|
-
const pR =
|
|
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:
|
|
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: "
|
|
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
|
-
|
|
8413
|
-
|
|
8414
|
-
const
|
|
8415
|
-
const {
|
|
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
|
-
|
|
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:
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
|
11170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
-
|
|
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,
|