@lumikmz/kmz 0.7.0 → 0.8.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.
@@ -1,59 +1,94 @@
1
- import { degrees } from "../types/branded.js";
2
- import type { ExecuteHeightMode } from "../types/enums.js";
1
+ import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
2
+ import type { ActionGroup } from "../types/action.js";
3
+ import type { ExecuteHeightMode, LensType } from "../types/enums.js";
4
+ import { PlannerError } from "../types/errors.js";
3
5
  import type { MappingStripFolder, MappingStripTemplate } from "../types/template.js";
4
6
  import type { Waylines, WaylinesFolder } from "../types/waylines.js";
5
7
  import type { WaylinesPlacemark } from "../types/placemark.js";
6
- import { bearingDeg, centroid, lngLatToEnu } from "./geometry.js";
7
-
8
- /**
9
- * Compile a `mappingStrip` template into a single wayline that follows the
10
- * supplied LineString. Each LineString vertex becomes one waypoint.
11
- *
12
- * When `stripUseTemplateAltitude` is true the altitude in each LineString
13
- * triple is used as the executeHeight; otherwise the folder's `height` is used.
14
- */
15
- export function planMappingStrip(template: MappingStripTemplate): Waylines {
16
- const folder = template.Folder;
17
- const coords = parseTriples(folder.Placemark.LineString.coordinates);
18
- const origin = centroid(coords.map((c) => ({ lng: c.lng, lat: c.lat })));
19
- const enuPath = coords.map((c) => lngLatToEnu({ lng: c.lng, lat: c.lat }, origin));
20
-
21
- const placemarks: WaylinesPlacemark[] = coords.map((c, index) => {
22
- const isLast = index === coords.length - 1;
23
- const headingAngle = isLast ? 0 : bearingDeg(enuPath[index]!, enuPath[index + 1]!);
24
- const executeHeight =
25
- folder.Placemark.stripUseTemplateAltitude && c.alt !== undefined
26
- ? (c.alt as ReturnType<typeof folder.Placemark.height extends infer T ? () => T : never>)
27
- : folder.Placemark.height;
8
+ import {
9
+ bearingDeg,
10
+ centroid,
11
+ enuToLngLat,
12
+ haversineMeters,
13
+ lngLatToEnu,
14
+ surveyGrid,
15
+ type EnuPoint,
16
+ } from "./geometry.js";
17
+ import type { PlanOptions } from "./index.js";
28
18
 
29
- return {
30
- Point: { coordinates: { lng: c.lng, lat: c.lat } },
31
- index,
32
- executeHeight,
33
- waypointSpeed: folder.autoFlightSpeed,
34
- waypointHeadingParam: {
35
- waypointHeadingMode: "followWayline",
36
- waypointHeadingAngle: degrees(headingAngle),
37
- waypointHeadingPathMode: "followBadArc",
38
- },
39
- waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
40
- };
41
- });
19
+ const POINT_EPS_M = 0.05;
20
+ const MITER_LIMIT = 2.5;
21
+ const MAX_ROUTE_COUNT = 1_000;
22
+ const TURN_DAMPING_M = 10;
42
23
 
43
- const waylineFolder: WaylinesFolder = {
44
- templateId: folder.templateId,
45
- waylineId: 0,
46
- executeHeightMode: heightModeToExecuteMode(folder),
47
- autoFlightSpeed: folder.autoFlightSpeed,
48
- Placemark: placemarks,
49
- };
24
+ export interface MappingStripRegion {
25
+ id: string;
26
+ sourceRegionIndexes: number[];
27
+ startDistance: Meters;
28
+ endDistance: Meters;
29
+ centerLine: LngLat[];
30
+ polygon: LngLat[];
31
+ }
32
+
33
+ export interface MappingStripRegionRoute {
34
+ regionId: string;
35
+ routeOffsets: Meters[];
36
+ routeLines: LngLat[][];
37
+ segments: MappingStripRouteSegment[];
38
+ executablePath: LngLat[];
39
+ length: Meters;
40
+ }
41
+
42
+ export interface MappingStripRouteSegment {
43
+ kind: "work" | "turn" | "transit" | "elevationOptimize";
44
+ points: LngLat[];
45
+ photoEnabled: boolean;
46
+ }
47
+
48
+ export interface MappingStripDerived {
49
+ centerLineLength: Meters;
50
+ surveyArea: LngLat[];
51
+ surveyAreaSize: number;
52
+ regions: MappingStripRegion[];
53
+ flightRoutes: MappingStripRegionRoute[];
54
+ totalFlightLength: Meters;
55
+ photoSpacing: Meters;
56
+ estimatedPhotoCount: number;
57
+ estimatedDuration: number;
58
+ }
59
+
60
+ interface StripPlanInputs {
61
+ crossTrackFootprint: number;
62
+ lineSpacing: number;
63
+ photoSpacing: number;
64
+ shootInterval: number;
65
+ }
66
+
67
+ interface LocalRegion {
68
+ id: string;
69
+ sourceRegionIndexes: number[];
70
+ startDistance: number;
71
+ endDistance: number;
72
+ centerLine: EnuPoint[];
73
+ polygon: EnuPoint[];
74
+ beforePoint?: EnuPoint;
75
+ afterPoint?: EnuPoint;
76
+ }
50
77
 
51
- return { missionConfig: template.missionConfig, Folder: [waylineFolder] };
78
+ interface LocalRouteSegment {
79
+ kind: "work" | "turn" | "elevationOptimize";
80
+ points: EnuPoint[];
81
+ photoEnabled: boolean;
52
82
  }
53
83
 
54
- interface Triple {
55
- lng: number;
56
- lat: number;
84
+ interface LocalRouteCandidate {
85
+ lines: EnuPoint[][];
86
+ segments: LocalRouteSegment[];
87
+ start: EnuPoint;
88
+ end: EnuPoint;
89
+ }
90
+
91
+ interface Triple extends LngLat {
57
92
  alt?: number;
58
93
  }
59
94
 
@@ -61,6 +96,7 @@ function parseTriples(s: string): Triple[] {
61
96
  return s
62
97
  .trim()
63
98
  .split(/\s+/)
99
+ .filter(Boolean)
64
100
  .map((tuple) => {
65
101
  const parts = tuple.split(",").map(Number);
66
102
  return {
@@ -71,6 +107,664 @@ function parseTriples(s: string): Triple[] {
71
107
  });
72
108
  }
73
109
 
110
+ function cleanLine(points: readonly EnuPoint[]): EnuPoint[] {
111
+ const clean: EnuPoint[] = [];
112
+ for (const point of points) {
113
+ const previous = clean.at(-1);
114
+ if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) < POINT_EPS_M)
115
+ continue;
116
+ clean.push([point[0], point[1]]);
117
+ }
118
+ return clean;
119
+ }
120
+
121
+ function lineLength(points: readonly EnuPoint[]): number {
122
+ let total = 0;
123
+ for (let index = 1; index < points.length; index++) {
124
+ total += Math.hypot(
125
+ points[index]![0] - points[index - 1]![0],
126
+ points[index]![1] - points[index - 1]![1],
127
+ );
128
+ }
129
+ return total;
130
+ }
131
+
132
+ function localRouteSegments(lines: readonly EnuPoint[][]) {
133
+ const segments: Array<{
134
+ kind: "work" | "turn";
135
+ points: EnuPoint[];
136
+ photoEnabled: boolean;
137
+ }> = [];
138
+ lines.forEach((line, index) => {
139
+ if (index > 0) {
140
+ segments.push({
141
+ kind: "turn",
142
+ points: [lines[index - 1]!.at(-1)!, line[0]!],
143
+ photoEnabled: false,
144
+ });
145
+ }
146
+ segments.push({ kind: "work", points: line, photoEnabled: true });
147
+ });
148
+ return segments;
149
+ }
150
+
151
+ function serpentineRouteVariants(lines: readonly EnuPoint[][]): EnuPoint[][][] {
152
+ const variants: EnuPoint[][][] = [];
153
+ for (const reverseLineOrder of [false, true]) {
154
+ const ordered = reverseLineOrder ? [...lines].reverse() : [...lines];
155
+ for (const reverseFirstLine of [false, true]) {
156
+ variants.push(
157
+ ordered.map((line, index) =>
158
+ index % 2 === Number(reverseFirstLine) ? [...line] : [...line].reverse(),
159
+ ),
160
+ );
161
+ }
162
+ }
163
+ return variants;
164
+ }
165
+
166
+ function localRouteCandidate(
167
+ lines: EnuPoint[][],
168
+ centerLine: readonly EnuPoint[],
169
+ elevationOptimizeEnable: boolean | undefined,
170
+ ): LocalRouteCandidate {
171
+ const segments: LocalRouteSegment[] = localRouteSegments(lines);
172
+ if (elevationOptimizeEnable) {
173
+ const normalEnd = joinSegmentPoints(segments).at(-1)!;
174
+ let optimizationLine = [...centerLine];
175
+ const first = optimizationLine[0]!;
176
+ const last = optimizationLine.at(-1)!;
177
+ if (
178
+ Math.hypot(last[0] - normalEnd[0], last[1] - normalEnd[1]) <
179
+ Math.hypot(first[0] - normalEnd[0], first[1] - normalEnd[1])
180
+ ) {
181
+ optimizationLine = optimizationLine.reverse();
182
+ }
183
+ const optimizationPath = cleanLine([normalEnd, ...optimizationLine]);
184
+ segments.push({
185
+ kind: "elevationOptimize",
186
+ // Preserve a distinct stop/start waypoint at the same coordinate, as in DJI exports.
187
+ points: [normalEnd, ...optimizationPath],
188
+ photoEnabled: true,
189
+ });
190
+ }
191
+ const executable = joinSegmentPoints(segments);
192
+ return {
193
+ lines,
194
+ segments,
195
+ start: executable[0]!,
196
+ end: executable.at(-1)!,
197
+ };
198
+ }
199
+
200
+ function shortestCandidateIndexes(
201
+ candidateGroups: readonly LocalRouteCandidate[][],
202
+ startPoint: EnuPoint | undefined,
203
+ ): number[] {
204
+ if (candidateGroups.length === 0) return [];
205
+ let costs = candidateGroups[0]!.map((candidate) =>
206
+ startPoint
207
+ ? Math.hypot(candidate.start[0] - startPoint[0], candidate.start[1] - startPoint[1])
208
+ : 0,
209
+ );
210
+ const previousIndexes: number[][] = [candidateGroups[0]!.map(() => -1)];
211
+
212
+ for (let regionIndex = 1; regionIndex < candidateGroups.length; regionIndex++) {
213
+ const previousCandidates = candidateGroups[regionIndex - 1]!;
214
+ const candidates = candidateGroups[regionIndex]!;
215
+ const nextCosts: number[] = [];
216
+ const nextPreviousIndexes: number[] = [];
217
+ for (const candidate of candidates) {
218
+ let bestCost = Number.POSITIVE_INFINITY;
219
+ let bestPreviousIndex = 0;
220
+ previousCandidates.forEach((previous, previousIndex) => {
221
+ const transition = Math.hypot(
222
+ candidate.start[0] - previous.end[0],
223
+ candidate.start[1] - previous.end[1],
224
+ );
225
+ const cost = costs[previousIndex]! + transition;
226
+ if (cost < bestCost) {
227
+ bestCost = cost;
228
+ bestPreviousIndex = previousIndex;
229
+ }
230
+ });
231
+ nextCosts.push(bestCost);
232
+ nextPreviousIndexes.push(bestPreviousIndex);
233
+ }
234
+ costs = nextCosts;
235
+ previousIndexes.push(nextPreviousIndexes);
236
+ }
237
+
238
+ let selectedIndex = costs.reduce((best, cost, index) => (cost < costs[best]! ? index : best), 0);
239
+ const selected = Array.from({ length: candidateGroups.length }, () => 0);
240
+ for (let regionIndex = candidateGroups.length - 1; regionIndex >= 0; regionIndex--) {
241
+ selected[regionIndex] = selectedIndex;
242
+ selectedIndex = previousIndexes[regionIndex]![selectedIndex]!;
243
+ }
244
+ return selected;
245
+ }
246
+
247
+ function joinSegmentPoints<T>(segments: readonly { points: readonly T[] }[]): T[] {
248
+ const result: T[] = [];
249
+ for (const segment of segments) {
250
+ if (result.length === 0) result.push(...segment.points);
251
+ else result.push(...segment.points.slice(1));
252
+ }
253
+ return result;
254
+ }
255
+
256
+ function splitLineByDistance(points: readonly EnuPoint[], distance: number): LocalRegion[] {
257
+ const total = lineLength(points);
258
+ const cut = Number.isFinite(distance) && distance > POINT_EPS_M ? distance : total;
259
+ if (cut >= total - POINT_EPS_M) {
260
+ return [
261
+ {
262
+ id: "region-0",
263
+ sourceRegionIndexes: [0],
264
+ startDistance: 0,
265
+ endDistance: total,
266
+ centerLine: [...points],
267
+ polygon: [],
268
+ },
269
+ ];
270
+ }
271
+
272
+ const regions: LocalRegion[] = [];
273
+ let current: EnuPoint[] = [points[0]!];
274
+ let regionStart = 0;
275
+ let accumulated = 0;
276
+
277
+ const finishRegion = (endDistance: number) => {
278
+ if (current.length < 2) return;
279
+ regions.push({
280
+ id: `region-${regions.length}`,
281
+ sourceRegionIndexes: [regions.length],
282
+ startDistance: regionStart,
283
+ endDistance,
284
+ centerLine: current,
285
+ polygon: [],
286
+ });
287
+ current = [current.at(-1)!];
288
+ regionStart = endDistance;
289
+ };
290
+
291
+ for (let index = 1; index < points.length; index++) {
292
+ const a = points[index - 1]!;
293
+ const b = points[index]!;
294
+ const segmentLength = Math.hypot(b[0] - a[0], b[1] - a[1]);
295
+
296
+ // FlightHub keeps original control points as region boundaries. If the next
297
+ // complete segment would cross the cutting distance, finish at its starting
298
+ // control point instead of synthesizing a point in the middle of the segment.
299
+ const currentLength = accumulated - regionStart;
300
+ if (currentLength > POINT_EPS_M && currentLength + segmentLength > cut + POINT_EPS_M) {
301
+ finishRegion(accumulated);
302
+ }
303
+
304
+ // A single source segment can itself exceed the limit; only that case needs
305
+ // synthetic points so every generated region remains bounded by `cut`.
306
+ let segmentStart = a;
307
+ let consumed = 0;
308
+ while (segmentLength - consumed > cut + POINT_EPS_M) {
309
+ const step = cut;
310
+ const t = (consumed + step) / segmentLength;
311
+ const point: EnuPoint = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
312
+ if (current.at(-1) !== segmentStart) current.push(segmentStart);
313
+ current.push(point);
314
+ finishRegion(accumulated + consumed + step);
315
+ segmentStart = point;
316
+ consumed += step;
317
+ }
318
+ if (current.at(-1) !== segmentStart) current.push(segmentStart);
319
+ current.push(b);
320
+ accumulated += segmentLength;
321
+ }
322
+ finishRegion(total);
323
+ return regions;
324
+ }
325
+
326
+ function applyMergedRegionRanges(
327
+ regions: readonly LocalRegion[],
328
+ ranges: readonly number[][] | undefined,
329
+ ): LocalRegion[] {
330
+ if (!ranges || ranges.length === 0) return [...regions];
331
+ const normalized = ranges
332
+ .map((range) => {
333
+ if (range.length !== 2) {
334
+ throw new PlannerError("mappingStrip merged region range must contain [start,end]");
335
+ }
336
+ const start = range[0]! - 1;
337
+ const end = range[1]! - 1;
338
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
339
+ throw new PlannerError("mappingStrip merged region range is invalid");
340
+ }
341
+ if (end >= regions.length) {
342
+ throw new PlannerError("mappingStrip merged region range exceeds region count");
343
+ }
344
+ return { start, end };
345
+ })
346
+ .sort((a, b) => a.start - b.start);
347
+ for (let index = 1; index < normalized.length; index++) {
348
+ if (normalized[index]!.start <= normalized[index - 1]!.end) {
349
+ throw new PlannerError("mappingStrip merged region ranges cannot overlap");
350
+ }
351
+ }
352
+
353
+ const byStart = new Map(normalized.map((range) => [range.start, range]));
354
+ const merged: LocalRegion[] = [];
355
+ for (let index = 0; index < regions.length; ) {
356
+ const range = byStart.get(index);
357
+ if (!range) {
358
+ merged.push(regions[index]!);
359
+ index += 1;
360
+ continue;
361
+ }
362
+ const members = regions.slice(range.start, range.end + 1);
363
+ merged.push({
364
+ id: `region-${range.start}-${range.end}`,
365
+ sourceRegionIndexes: members.flatMap((region) => region.sourceRegionIndexes),
366
+ startDistance: members[0]!.startDistance,
367
+ endDistance: members.at(-1)!.endDistance,
368
+ centerLine: members.flatMap((region, memberIndex) =>
369
+ memberIndex === 0 ? region.centerLine : region.centerLine.slice(1),
370
+ ),
371
+ polygon: [],
372
+ beforePoint: members[0]!.beforePoint,
373
+ afterPoint: members.at(-1)!.afterPoint,
374
+ });
375
+ index = range.end + 1;
376
+ }
377
+ return merged;
378
+ }
379
+
380
+ function cross(a: EnuPoint, b: EnuPoint): number {
381
+ return a[0] * b[1] - a[1] * b[0];
382
+ }
383
+
384
+ function segmentUnit(a: EnuPoint, b: EnuPoint): EnuPoint {
385
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
386
+ if (length < POINT_EPS_M) throw new PlannerError("mappingStrip center line has a zero segment");
387
+ return [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
388
+ }
389
+
390
+ function leftNormal(direction: EnuPoint): EnuPoint {
391
+ return [-direction[1], direction[0]];
392
+ }
393
+
394
+ function offsetPolyline(points: readonly EnuPoint[], distance: number): EnuPoint[] {
395
+ if (distance === 0) return points.map((point) => [point[0], point[1]]);
396
+ const directions = points.slice(1).map((point, index) => segmentUnit(points[index]!, point));
397
+ const out: EnuPoint[] = [];
398
+ const firstNormal = leftNormal(directions[0]!);
399
+ out.push([points[0]![0] + firstNormal[0] * distance, points[0]![1] + firstNormal[1] * distance]);
400
+
401
+ for (let index = 1; index < points.length - 1; index++) {
402
+ const previousDirection = directions[index - 1]!;
403
+ const nextDirection = directions[index]!;
404
+ const previousNormal = leftNormal(previousDirection);
405
+ const nextNormal = leftNormal(nextDirection);
406
+ const previousOffset: EnuPoint = [
407
+ points[index]![0] + previousNormal[0] * distance,
408
+ points[index]![1] + previousNormal[1] * distance,
409
+ ];
410
+ const nextOffset: EnuPoint = [
411
+ points[index]![0] + nextNormal[0] * distance,
412
+ points[index]![1] + nextNormal[1] * distance,
413
+ ];
414
+ const denominator = cross(previousDirection, nextDirection);
415
+ if (Math.abs(denominator) < 1e-9) {
416
+ out.push(previousOffset);
417
+ continue;
418
+ }
419
+ const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
420
+ const t = cross(delta, nextDirection) / denominator;
421
+ const intersection: EnuPoint = [
422
+ previousOffset[0] + previousDirection[0] * t,
423
+ previousOffset[1] + previousDirection[1] * t,
424
+ ];
425
+ const miterLength = Math.hypot(
426
+ intersection[0] - points[index]![0],
427
+ intersection[1] - points[index]![1],
428
+ );
429
+ if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
430
+ else out.push(previousOffset, nextOffset);
431
+ }
432
+
433
+ const lastNormal = leftNormal(directions.at(-1)!);
434
+ const last = points.at(-1)!;
435
+ out.push([last[0] + lastNormal[0] * distance, last[1] + lastNormal[1] * distance]);
436
+ return out;
437
+ }
438
+
439
+ function corridorSide(
440
+ points: readonly EnuPoint[],
441
+ distance: number,
442
+ beforePoint?: EnuPoint,
443
+ afterPoint?: EnuPoint,
444
+ ): EnuPoint[] {
445
+ const out: EnuPoint[] = [];
446
+ for (let index = 0; index < points.length; index++) {
447
+ const point = points[index]!;
448
+ const previous = index > 0 ? points[index - 1] : beforePoint;
449
+ const next = index + 1 < points.length ? points[index + 1] : afterPoint;
450
+ if (!previous && next) {
451
+ const normal = leftNormal(segmentUnit(point, next));
452
+ out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
453
+ continue;
454
+ }
455
+ if (previous && !next) {
456
+ const normal = leftNormal(segmentUnit(previous, point));
457
+ out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
458
+ continue;
459
+ }
460
+ if (!previous || !next) continue;
461
+
462
+ const previousDirection = segmentUnit(previous, point);
463
+ const nextDirection = segmentUnit(point, next);
464
+ const previousNormal = leftNormal(previousDirection);
465
+ const nextNormal = leftNormal(nextDirection);
466
+ const previousOffset: EnuPoint = [
467
+ point[0] + previousNormal[0] * distance,
468
+ point[1] + previousNormal[1] * distance,
469
+ ];
470
+ const nextOffset: EnuPoint = [
471
+ point[0] + nextNormal[0] * distance,
472
+ point[1] + nextNormal[1] * distance,
473
+ ];
474
+ const denominator = cross(previousDirection, nextDirection);
475
+ if (Math.abs(denominator) < 1e-9) {
476
+ out.push(previousOffset);
477
+ continue;
478
+ }
479
+
480
+ // FlightHub uses a bevel on the outside of every bend. The inside remains
481
+ // a miter intersection so the corridor does not overlap itself at corners.
482
+ if (denominator * distance < 0) {
483
+ out.push(previousOffset, nextOffset);
484
+ continue;
485
+ }
486
+ const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
487
+ const t = cross(delta, nextDirection) / denominator;
488
+ const intersection: EnuPoint = [
489
+ previousOffset[0] + previousDirection[0] * t,
490
+ previousOffset[1] + previousDirection[1] * t,
491
+ ];
492
+ const miterLength = Math.hypot(intersection[0] - point[0], intersection[1] - point[1]);
493
+ if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
494
+ else out.push(previousOffset, nextOffset);
495
+ }
496
+ return out;
497
+ }
498
+
499
+ function corridorPolygon(
500
+ points: readonly EnuPoint[],
501
+ left: number,
502
+ right: number,
503
+ beforePoint?: EnuPoint,
504
+ afterPoint?: EnuPoint,
505
+ ): EnuPoint[] {
506
+ return [
507
+ ...corridorSide(points, left, beforePoint, afterPoint),
508
+ ...corridorSide(points, -right, beforePoint, afterPoint).reverse(),
509
+ ];
510
+ }
511
+
512
+ function perpendicularRouteLines(
513
+ centerLine: readonly EnuPoint[],
514
+ leftExtend: number,
515
+ rightExtend: number,
516
+ margin: number,
517
+ lineSpacing: number,
518
+ ): EnuPoint[][] {
519
+ const left = offsetPolyline(centerLine, leftExtend);
520
+ const right = offsetPolyline(centerLine, -rightExtend);
521
+ if (left.length !== centerLine.length || right.length !== centerLine.length) {
522
+ throw new PlannerError("mappingStrip offset corridor cannot be segmented");
523
+ }
524
+
525
+ const lines: EnuPoint[][] = [];
526
+ for (let index = 0; index < centerLine.length - 1; index++) {
527
+ const segment = [centerLine[index]!, centerLine[index + 1]!] as const;
528
+ const cell = [left[index]!, left[index + 1]!, right[index + 1]!, right[index]!];
529
+ const direction = segmentUnit(segment[0], segment[1]);
530
+ const projections = cell.map((point) => point[0] * direction[0] + point[1] * direction[1]);
531
+ const longitudinalSpan = Math.max(...projections) - Math.min(...projections);
532
+ const sourceSegmentLength = lineLength(segment);
533
+ const previousDirection =
534
+ index > 0 ? segmentUnit(centerLine[index - 1]!, segment[0]) : undefined;
535
+ const hasSharpStart =
536
+ previousDirection !== undefined &&
537
+ Math.abs(cross(previousDirection, direction)) > Math.sin((5 * Math.PI) / 180);
538
+ const lineCount = Math.max(
539
+ 1,
540
+ Math.ceil(sourceSegmentLength / lineSpacing),
541
+ hasSharpStart
542
+ ? Math.ceil(longitudinalSpan / lineSpacing)
543
+ : Math.round(longitudinalSpan / lineSpacing),
544
+ );
545
+ const phaseOffset = Math.max(0, (longitudinalSpan - (lineCount - 1) * lineSpacing) / 2);
546
+ const grid = surveyGrid(cell, {
547
+ directionDeg: (bearingDeg(segment[0], segment[1]) + 90) % 360,
548
+ marginM: margin,
549
+ lineSpacing,
550
+ phaseOffsetM: phaseOffset,
551
+ });
552
+ const flightDirection: EnuPoint = [direction[1], -direction[0]];
553
+ for (let gridIndex = 0; gridIndex + 1 < grid.length; gridIndex += 2) {
554
+ const a = grid[gridIndex]!;
555
+ const b = grid[gridIndex + 1]!;
556
+ const followsDirection =
557
+ (b[0] - a[0]) * flightDirection[0] + (b[1] - a[1]) * flightDirection[1] >= 0;
558
+ lines.push(followsDirection ? [a, b] : [b, a]);
559
+ }
560
+ }
561
+ return lines;
562
+ }
563
+
564
+ function polygonArea(points: readonly EnuPoint[]): number {
565
+ let area2 = 0;
566
+ for (let index = 0; index < points.length; index++) {
567
+ const a = points[index]!;
568
+ const b = points[(index + 1) % points.length]!;
569
+ area2 += a[0] * b[1] - b[0] * a[1];
570
+ }
571
+ return Math.abs(area2) / 2;
572
+ }
573
+
574
+ function routeOffsets(folder: MappingStripFolder, inputs: StripPlanInputs): number[] {
575
+ if (folder.Placemark.singleLineEnable) return [0];
576
+ const left = folder.Placemark.leftExtend;
577
+ const right = folder.Placemark.rightExtend;
578
+ const minimum = -right;
579
+ const maximum = left;
580
+ const spacing = inputs.lineSpacing;
581
+ if (!(spacing > POINT_EPS_M && Number.isFinite(spacing)))
582
+ throw new PlannerError("mappingStrip lineSpacing must be finite and greater than zero");
583
+
584
+ const span = maximum - minimum;
585
+ const hasInfrared = folder.payloadParam?.imageFormat?.includes("ir") ?? false;
586
+ const minimumCount = Math.max(
587
+ hasInfrared && span > spacing + POINT_EPS_M ? 2 : 1,
588
+ Math.ceil(Math.max(0, span - inputs.crossTrackFootprint) / spacing) + 1,
589
+ );
590
+ if (minimumCount === 1) return [0];
591
+ const count =
592
+ minimumCount % 2 === 0
593
+ ? minimumCount
594
+ : folder.Placemark.includeCenterEnable
595
+ ? minimumCount
596
+ : minimumCount + 1;
597
+ if (count > MAX_ROUTE_COUNT) throw new PlannerError("mappingStrip route count exceeds limit");
598
+ const offsets = Array.from(
599
+ { length: count },
600
+ (_, index) => (minimum + maximum - (count - 1) * spacing) / 2 + index * spacing,
601
+ );
602
+ if (folder.Placemark.includeCenterEnable) {
603
+ const oddCount = minimumCount % 2 === 1 ? minimumCount : minimumCount + 1;
604
+ const centered = Array.from(
605
+ { length: oddCount },
606
+ (_, index) => (index - (oddCount - 1) / 2) * spacing,
607
+ );
608
+ if (centered[0]! >= minimum && centered.at(-1)! <= maximum) return centered;
609
+ if (!offsets.some((offset) => Math.abs(offset) < POINT_EPS_M)) offsets.push(0);
610
+ }
611
+ return offsets.sort((a, b) => a - b);
612
+ }
613
+
614
+ function normalizeInputs(folder: MappingStripFolder, options?: PlanOptions): StripPlanInputs {
615
+ const corridorWidth = folder.Placemark.leftExtend + folder.Placemark.rightExtend;
616
+ return {
617
+ crossTrackFootprint: Number(options?.crossTrackFootprint ?? corridorWidth),
618
+ lineSpacing: Number(options?.lineSpacing ?? Math.max(corridorWidth, 1)),
619
+ photoSpacing: Number(options?.photoSpacing ?? 1),
620
+ shootInterval: Number(options?.shootInterval ?? 1),
621
+ };
622
+ }
623
+
624
+ export function deriveMappingStrip(
625
+ template: MappingStripTemplate,
626
+ options?: PlanOptions,
627
+ ): MappingStripDerived {
628
+ const folder = template.Folder;
629
+ if (folder.Placemark.leftExtend < 0 || folder.Placemark.rightExtend < 0)
630
+ throw new PlannerError("mappingStrip extend distances cannot be negative");
631
+ const triples = parseTriples(folder.Placemark.LineString.coordinates);
632
+ if (triples.length < 2)
633
+ throw new PlannerError("mappingStrip requires at least two center points");
634
+ if (triples.some((point) => !Number.isFinite(point.lng) || !Number.isFinite(point.lat)))
635
+ throw new PlannerError("mappingStrip center line contains non-finite coordinates");
636
+
637
+ const origin = centroid(triples);
638
+ const centerLine = cleanLine(triples.map((point) => lngLatToEnu(point, origin)));
639
+ if (centerLine.length < 2)
640
+ throw new PlannerError("mappingStrip requires two distinct center points");
641
+ const inputs = normalizeInputs(folder, options);
642
+ if (!(inputs.crossTrackFootprint > 0 && Number.isFinite(inputs.crossTrackFootprint)))
643
+ throw new PlannerError("mappingStrip crossTrackFootprint must be finite and greater than zero");
644
+ if (!(inputs.photoSpacing > 0 && Number.isFinite(inputs.photoSpacing)))
645
+ throw new PlannerError("mappingStrip photoSpacing must be finite and greater than zero");
646
+
647
+ const splitRegions = splitLineByDistance(centerLine, folder.Placemark.cuttingDistance);
648
+ const contextualRegions = splitRegions.map((region, index) => ({
649
+ ...region,
650
+ beforePoint: index > 0 ? splitRegions[index - 1]!.centerLine.at(-2) : undefined,
651
+ afterPoint:
652
+ index + 1 < splitRegions.length ? splitRegions[index + 1]!.centerLine[1] : undefined,
653
+ }));
654
+ const localRegions = applyMergedRegionRanges(
655
+ contextualRegions,
656
+ folder.Placemark.stripMergedRegions,
657
+ ).map((region) => ({
658
+ ...region,
659
+ polygon: corridorPolygon(
660
+ region.centerLine,
661
+ folder.Placemark.leftExtend,
662
+ folder.Placemark.rightExtend,
663
+ region.beforePoint,
664
+ region.afterPoint,
665
+ ),
666
+ }));
667
+ const offsets = routeOffsets(folder, inputs);
668
+ const executionRegions = options?.regionOrderReversed
669
+ ? [...localRegions].reverse()
670
+ : localRegions;
671
+ const perpendicular =
672
+ options?.stripDirection === "perpendicular" && !folder.Placemark.singleLineEnable;
673
+ const candidateGroups = executionRegions.map((region) => {
674
+ const rawLines = perpendicular
675
+ ? perpendicularRouteLines(
676
+ region.centerLine,
677
+ folder.Placemark.leftExtend,
678
+ folder.Placemark.rightExtend,
679
+ folder.Placemark.margin,
680
+ inputs.lineSpacing,
681
+ )
682
+ : offsets.map((offset) => offsetPolyline(region.centerLine, offset));
683
+ if (rawLines.length === 0)
684
+ throw new PlannerError("mappingStrip region has no available flight routes");
685
+ return serpentineRouteVariants(rawLines).map((lines) =>
686
+ localRouteCandidate(lines, region.centerLine, folder.Placemark.elevationOptimizeEnable),
687
+ );
688
+ });
689
+ const takeOffRefPoint = template.missionConfig.takeOffRefPoint;
690
+ const selectedCandidateIndexes = shortestCandidateIndexes(
691
+ candidateGroups,
692
+ takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : undefined,
693
+ );
694
+ const flightRoutes: MappingStripRegionRoute[] = [];
695
+ let previousRegionEnd: EnuPoint | undefined;
696
+ executionRegions.forEach((region, regionIndex) => {
697
+ const candidate = candidateGroups[regionIndex]![selectedCandidateIndexes[regionIndex]!]!;
698
+ const localSegments: Array<{
699
+ kind: "work" | "turn" | "transit" | "elevationOptimize";
700
+ points: EnuPoint[];
701
+ photoEnabled: boolean;
702
+ }> = previousRegionEnd
703
+ ? [
704
+ {
705
+ kind: "transit",
706
+ points: [previousRegionEnd, candidate.start],
707
+ photoEnabled: false,
708
+ },
709
+ ...candidate.segments,
710
+ ]
711
+ : candidate.segments;
712
+ const displayPath = joinSegmentPoints(localSegments);
713
+ const executable = joinSegmentPoints(candidate.segments);
714
+ flightRoutes.push({
715
+ regionId: region.id,
716
+ routeOffsets: perpendicular ? [] : offsets.map(meters),
717
+ routeLines: candidate.lines.map((line) => line.map((point) => enuToLngLat(point, origin))),
718
+ segments: localSegments.map((segment) => ({
719
+ ...segment,
720
+ points: segment.points.map((point) => enuToLngLat(point, origin)),
721
+ })),
722
+ executablePath: executable.map((point) => enuToLngLat(point, origin)),
723
+ length: meters(lineLength(displayPath)),
724
+ });
725
+ previousRegionEnd = candidate.end;
726
+ });
727
+
728
+ const surveyAreaLocal = corridorPolygon(
729
+ centerLine,
730
+ folder.Placemark.leftExtend,
731
+ folder.Placemark.rightExtend,
732
+ );
733
+ const totalFlightLength = flightRoutes.reduce((sum, route) => sum + Number(route.length), 0);
734
+ const estimatedPhotoCount = flightRoutes.reduce(
735
+ (sum, route) =>
736
+ sum +
737
+ route.segments
738
+ .filter(({ photoEnabled }) => photoEnabled)
739
+ .reduce((lineSum, segment) => {
740
+ let length = 0;
741
+ for (let index = 1; index < segment.points.length; index++)
742
+ length += haversineMeters(segment.points[index - 1]!, segment.points[index]!);
743
+ return lineSum + Math.max(1, Math.floor(length / inputs.photoSpacing) + 1);
744
+ }, 0),
745
+ 0,
746
+ );
747
+
748
+ return {
749
+ centerLineLength: meters(lineLength(centerLine)),
750
+ surveyArea: surveyAreaLocal.map((point) => enuToLngLat(point, origin)),
751
+ surveyAreaSize: localRegions.reduce((sum, region) => sum + polygonArea(region.polygon), 0),
752
+ regions: localRegions.map((region) => ({
753
+ id: region.id,
754
+ sourceRegionIndexes: region.sourceRegionIndexes,
755
+ startDistance: meters(region.startDistance),
756
+ endDistance: meters(region.endDistance),
757
+ centerLine: region.centerLine.map((point) => enuToLngLat(point, origin)),
758
+ polygon: region.polygon.map((point) => enuToLngLat(point, origin)),
759
+ })),
760
+ flightRoutes,
761
+ totalFlightLength: meters(totalFlightLength),
762
+ photoSpacing: meters(inputs.photoSpacing),
763
+ estimatedPhotoCount,
764
+ estimatedDuration: totalFlightLength / Number(folder.autoFlightSpeed),
765
+ };
766
+ }
767
+
74
768
  function heightModeToExecuteMode(folder: MappingStripFolder): ExecuteHeightMode {
75
769
  switch (folder.waylineCoordinateSysParam.heightMode) {
76
770
  case "EGM96":
@@ -82,3 +776,317 @@ function heightModeToExecuteMode(folder: MappingStripFolder): ExecuteHeightMode
82
776
  return "realTimeFollowSurface";
83
777
  }
84
778
  }
779
+
780
+ function turnDamping(points: readonly LngLat[], index: number): Meters {
781
+ const previous = haversineMeters(points[index - 1]!, points[index]!);
782
+ const next = haversineMeters(points[index]!, points[index + 1]!);
783
+ return meters(Math.min(TURN_DAMPING_M, previous * 0.45, next * 0.45));
784
+ }
785
+
786
+ function shootingGroups(
787
+ folder: MappingStripFolder,
788
+ startIndex: number,
789
+ endIndex: number,
790
+ firstGroupId: number,
791
+ inputs: StripPlanInputs,
792
+ ): ActionGroup[] {
793
+ const lenses: LensType[] = folder.payloadParam?.imageFormat ?? ["visable"];
794
+ if (folder.Placemark.shootType === "time") {
795
+ return [
796
+ {
797
+ actionGroupId: firstGroupId,
798
+ actionGroupStartIndex: startIndex,
799
+ actionGroupEndIndex: endIndex,
800
+ actionGroupMode: "sequence",
801
+ actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
802
+ action: [
803
+ {
804
+ actionId: 0,
805
+ actionActuatorFunc: "startTimeLapse",
806
+ actionActuatorFuncParam: {
807
+ payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
808
+ useGlobalPayloadLensIndex: false,
809
+ payloadLensIndex: lenses,
810
+ minShootInterval: inputs.shootInterval,
811
+ },
812
+ },
813
+ ],
814
+ },
815
+ {
816
+ actionGroupId: firstGroupId + 1,
817
+ actionGroupStartIndex: endIndex,
818
+ actionGroupEndIndex: endIndex,
819
+ actionGroupMode: "sequence",
820
+ actionTrigger: { actionTriggerType: "reachPoint" },
821
+ action: [
822
+ {
823
+ actionId: 0,
824
+ actionActuatorFunc: "stopTimeLapse",
825
+ actionActuatorFuncParam: {
826
+ payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
827
+ payloadLensIndex: lenses,
828
+ },
829
+ },
830
+ ],
831
+ },
832
+ ];
833
+ }
834
+ return [
835
+ {
836
+ actionGroupId: firstGroupId,
837
+ actionGroupStartIndex: startIndex,
838
+ actionGroupEndIndex: endIndex,
839
+ actionGroupMode: "sequence",
840
+ actionTrigger: {
841
+ actionTriggerType: "multipleDistance",
842
+ actionTriggerParam: inputs.photoSpacing,
843
+ },
844
+ action: [
845
+ {
846
+ actionId: 0,
847
+ actionActuatorFunc: "takePhoto",
848
+ actionActuatorFuncParam: {
849
+ payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
850
+ useGlobalPayloadLensIndex: false,
851
+ payloadLensIndex: lenses,
852
+ },
853
+ },
854
+ ],
855
+ },
856
+ ];
857
+ }
858
+
859
+ function nadirGimbalRotateAction(folder: MappingStripFolder, actionId: number) {
860
+ return {
861
+ actionId,
862
+ actionActuatorFunc: "gimbalRotate" as const,
863
+ actionActuatorFuncParam: {
864
+ payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
865
+ gimbalHeadingYawBase: "aircraft" as const,
866
+ gimbalRotateMode: "absoluteAngle" as const,
867
+ gimbalPitchRotateEnable: true,
868
+ gimbalPitchRotateAngle: degrees(-90),
869
+ gimbalRollRotateEnable: false,
870
+ gimbalRollRotateAngle: degrees(0),
871
+ gimbalYawRotateEnable: false,
872
+ gimbalYawRotateAngle: degrees(0),
873
+ gimbalRotateTimeEnable: false,
874
+ gimbalRotateTime: 10,
875
+ },
876
+ };
877
+ }
878
+
879
+ function continuousMappingGroups(
880
+ folder: MappingStripFolder,
881
+ endIndex: number,
882
+ inputs: StripPlanInputs,
883
+ ): ActionGroup[] {
884
+ const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
885
+ const lenses: LensType[] = folder.payloadParam?.imageFormat ?? ["visable"];
886
+ const startAction =
887
+ folder.Placemark.shootType === "time"
888
+ ? {
889
+ actionId: 2,
890
+ actionActuatorFunc: "startTimeLapse" as const,
891
+ actionActuatorFuncParam: {
892
+ payloadPositionIndex,
893
+ useGlobalPayloadLensIndex: false,
894
+ payloadLensIndex: lenses,
895
+ minShootInterval: inputs.shootInterval,
896
+ },
897
+ }
898
+ : {
899
+ actionId: 2,
900
+ actionActuatorFunc: "takePhoto" as const,
901
+ actionActuatorFuncParam: {
902
+ payloadPositionIndex,
903
+ useGlobalPayloadLensIndex: false,
904
+ payloadLensIndex: lenses,
905
+ },
906
+ };
907
+ return [
908
+ {
909
+ actionGroupId: 0,
910
+ actionGroupStartIndex: 0,
911
+ actionGroupEndIndex: endIndex,
912
+ actionGroupMode: "sequence",
913
+ actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
914
+ action: [
915
+ {
916
+ actionId: 0,
917
+ actionActuatorFunc: "gimbalAngleLock",
918
+ actionActuatorFuncParam: { payloadPositionIndex },
919
+ },
920
+ nadirGimbalRotateAction(folder, 1),
921
+ startAction,
922
+ ],
923
+ },
924
+ {
925
+ actionGroupId: 1,
926
+ actionGroupStartIndex: 0,
927
+ actionGroupEndIndex: endIndex,
928
+ actionGroupMode: "sequence",
929
+ actionTrigger: { actionTriggerType: "multipleTiming", actionTriggerParam: 2 },
930
+ action: [nadirGimbalRotateAction(folder, 0)],
931
+ },
932
+ {
933
+ actionGroupId: 2,
934
+ actionGroupStartIndex: endIndex,
935
+ actionGroupEndIndex: endIndex,
936
+ actionGroupMode: "sequence",
937
+ actionTrigger: { actionTriggerType: "reachPoint" },
938
+ action: [
939
+ ...(folder.Placemark.shootType === "time"
940
+ ? [
941
+ {
942
+ actionId: 0,
943
+ actionActuatorFunc: "stopTimeLapse" as const,
944
+ actionActuatorFuncParam: { payloadPositionIndex, payloadLensIndex: lenses },
945
+ },
946
+ ]
947
+ : []),
948
+ { actionId: 1, actionActuatorFunc: "gimbalAngleUnlock" as const },
949
+ ],
950
+ },
951
+ ];
952
+ }
953
+
954
+ function elevationOptimizeGroups(
955
+ folder: MappingStripFolder,
956
+ startIndex: number,
957
+ endIndex: number,
958
+ firstGroupId: number,
959
+ inputs: StripPlanInputs,
960
+ ): ActionGroup[] {
961
+ const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
962
+ const gimbalGroup: ActionGroup = {
963
+ actionGroupId: firstGroupId,
964
+ actionGroupStartIndex: startIndex,
965
+ actionGroupEndIndex: startIndex,
966
+ actionGroupMode: "sequence",
967
+ actionTrigger: { actionTriggerType: "reachPoint" },
968
+ action: [
969
+ {
970
+ actionId: 0,
971
+ actionActuatorFunc: "gimbalRotate",
972
+ actionActuatorFuncParam: {
973
+ payloadPositionIndex,
974
+ gimbalHeadingYawBase: "aircraft",
975
+ gimbalRotateMode: "absoluteAngle",
976
+ gimbalPitchRotateEnable: true,
977
+ gimbalPitchRotateAngle: degrees(-45),
978
+ gimbalRollRotateEnable: false,
979
+ gimbalRollRotateAngle: degrees(0),
980
+ gimbalYawRotateEnable: true,
981
+ gimbalYawRotateAngle: degrees(0),
982
+ gimbalRotateTimeEnable: false,
983
+ gimbalRotateTime: 10,
984
+ },
985
+ },
986
+ {
987
+ actionId: 1,
988
+ actionActuatorFunc: "hover",
989
+ actionActuatorFuncParam: { hoverTime: 0.5 },
990
+ },
991
+ ],
992
+ };
993
+ const shooting = shootingGroups(folder, startIndex, endIndex, firstGroupId + 1, inputs);
994
+ const unlockGroup: ActionGroup = {
995
+ actionGroupId: firstGroupId + shooting.length + 1,
996
+ actionGroupStartIndex: endIndex,
997
+ actionGroupEndIndex: endIndex,
998
+ actionGroupMode: "sequence",
999
+ actionTrigger: { actionTriggerType: "reachPoint" },
1000
+ action: [{ actionId: 0, actionActuatorFunc: "gimbalAngleUnlock" }],
1001
+ };
1002
+ return [gimbalGroup, ...shooting, unlockGroup];
1003
+ }
1004
+
1005
+ /** Compile a mappingStrip template into one executable Folder per cut region. */
1006
+ export function planMappingStrip(template: MappingStripTemplate, options?: PlanOptions): Waylines {
1007
+ const folder = template.Folder;
1008
+ const derived = deriveMappingStrip(template, options);
1009
+ const inputs = normalizeInputs(folder, options);
1010
+ const waylineFolders: WaylinesFolder[] = derived.flightRoutes.map((route, waylineId) => {
1011
+ const points = route.executablePath;
1012
+ const groupsByStart = new Map<number, ActionGroup[]>();
1013
+ const forcedStopIndexes = new Set<number>();
1014
+ if (!folder.Placemark.elevationOptimizeEnable) {
1015
+ for (const group of continuousMappingGroups(folder, points.length - 1, inputs)) {
1016
+ const index = group.actionGroupStartIndex;
1017
+ groupsByStart.set(index, [...(groupsByStart.get(index) ?? []), group]);
1018
+ }
1019
+ } else {
1020
+ let cursor = 0;
1021
+ let nextActionGroupId = 0;
1022
+ for (const segment of route.segments.filter(({ kind }) => kind !== "transit")) {
1023
+ const startIndex = cursor;
1024
+ const endIndex = startIndex + segment.points.length - 1;
1025
+ if (segment.photoEnabled && endIndex > startIndex) {
1026
+ const shootingStartIndex =
1027
+ segment.kind === "elevationOptimize" ? startIndex + 1 : startIndex;
1028
+ const groups =
1029
+ segment.kind === "elevationOptimize"
1030
+ ? elevationOptimizeGroups(
1031
+ folder,
1032
+ shootingStartIndex,
1033
+ endIndex,
1034
+ nextActionGroupId,
1035
+ inputs,
1036
+ )
1037
+ : shootingGroups(folder, shootingStartIndex, endIndex, nextActionGroupId, inputs);
1038
+ nextActionGroupId += groups.length;
1039
+ for (const group of groups) {
1040
+ const index = group.actionGroupStartIndex;
1041
+ groupsByStart.set(index, [...(groupsByStart.get(index) ?? []), group]);
1042
+ }
1043
+ if (segment.kind === "elevationOptimize") {
1044
+ forcedStopIndexes.add(startIndex);
1045
+ forcedStopIndexes.add(shootingStartIndex);
1046
+ forcedStopIndexes.add(endIndex);
1047
+ }
1048
+ }
1049
+ cursor = endIndex;
1050
+ }
1051
+ }
1052
+ const placemarks: WaylinesPlacemark[] = points.map((point, index) => {
1053
+ const isFirst = index === 0;
1054
+ const isLast = index === points.length - 1;
1055
+ const headingAngle = isLast
1056
+ ? 0
1057
+ : bearingDeg(lngLatToEnu(point, point), lngLatToEnu(points[index + 1]!, point));
1058
+ return {
1059
+ Point: { coordinates: point },
1060
+ index,
1061
+ executeHeight: folder.Placemark.height,
1062
+ waypointSpeed: folder.autoFlightSpeed,
1063
+ waypointHeadingParam: {
1064
+ waypointHeadingMode: "followWayline",
1065
+ waypointHeadingAngle: degrees(headingAngle),
1066
+ waypointHeadingPathMode: "followBadArc",
1067
+ },
1068
+ waypointTurnParam:
1069
+ isFirst || isLast || forcedStopIndexes.has(index)
1070
+ ? {
1071
+ waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
1072
+ waypointTurnDampingDist: meters(0),
1073
+ }
1074
+ : {
1075
+ waypointTurnMode: "coordinateTurn",
1076
+ waypointTurnDampingDist: turnDamping(points, index),
1077
+ },
1078
+ useStraightLine: true,
1079
+ isRisky: false,
1080
+ ...(groupsByStart.has(index) ? { actionGroup: groupsByStart.get(index) } : {}),
1081
+ };
1082
+ });
1083
+ return {
1084
+ templateId: folder.templateId + waylineId,
1085
+ waylineId,
1086
+ executeHeightMode: heightModeToExecuteMode(folder),
1087
+ autoFlightSpeed: folder.autoFlightSpeed,
1088
+ Placemark: placemarks,
1089
+ };
1090
+ });
1091
+ return { missionConfig: template.missionConfig, Folder: waylineFolders };
1092
+ }