@lumikmz/kmz 0.2.1 → 0.4.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,128 +1,551 @@
1
- import { degrees, metersPerSecond } from "../types/branded.js";
2
- import type { ActionGroup } from "../types/action.js";
1
+ import { degrees, meters, type Meters } from "../types/branded.js";
2
+ import type { Action } from "../types/action.js";
3
3
  import type { ExecuteHeightMode } from "../types/enums.js";
4
- import type { Mapping2dTemplate, Mapping2dFolder } from "../types/template.js";
4
+ import type { Mapping2dFolder, Mapping2dTemplate } from "../types/template.js";
5
+ import type { StartActionGroup } from "../types/waylines.js";
5
6
  import type { Waylines, WaylinesFolder } from "../types/waylines.js";
6
7
  import type { WaylinesPlacemark } from "../types/placemark.js";
8
+ import { PlannerError } from "../types/errors.js";
7
9
  import {
8
10
  bearingDeg,
9
11
  centroid,
10
12
  enuToLngLat,
13
+ type EnuPoint,
11
14
  lngLatToEnu,
15
+ offsetPolygon,
16
+ orientPathToHome,
12
17
  parseCoordinatesString,
13
- scanWaypoints,
18
+ surveyGrid,
14
19
  } from "./geometry.js";
20
+ import type { PlanOptions } from "./index.js";
15
21
 
16
- /** Default HFOV (degrees) used to estimate ground footprint when payload-specific FOV unknown. */
17
- const DEFAULT_HFOV_DEG = 84;
18
-
19
- export function planMapping2d(template: Mapping2dTemplate): Waylines {
22
+ /**
23
+ * `standoffM` dilates the survey polygon outward before gridding (smart-oblique
24
+ * needs the grid to overfly the boundary on every side; 0 for ortho/quick-ortho).
25
+ */
26
+ function buildGrid(template: Mapping2dTemplate, lineSpacing: number, standoffM = 0) {
20
27
  const folder = template.Folder;
21
28
  const ring = parseCoordinatesString(
22
29
  folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates,
23
30
  );
24
31
  const origin = centroid(ring);
25
- const enuPolygon = ring.map((p) => lngLatToEnu(p, origin));
32
+ const enuRing = ring.map((p) => lngLatToEnu(p, origin));
33
+ const gridRing = standoffM > 0 ? offsetPolygon(enuRing, standoffM) : enuRing;
34
+ const enuWps = orientPathToHome(
35
+ surveyGrid(gridRing, {
36
+ directionDeg: folder.Placemark.direction,
37
+ marginM: folder.Placemark.margin,
38
+ lineSpacing,
39
+ }),
40
+ origin,
41
+ template.missionConfig.takeOffRefPoint,
42
+ );
43
+ return { folder, origin, enuWps };
44
+ }
26
45
 
27
- const hfovRad = (DEFAULT_HFOV_DEG / 2) * (Math.PI / 180);
28
- const footprintWidth = 2 * folder.height * Math.tan(hfovRad);
29
- const sideOverlap = folder.overlap.orthoCameraOverlapW ?? 70;
30
- const lineSpacing = footprintWidth * (1 - sideOverlap / 100);
46
+ /**
47
+ * Smart-oblique stand-off (meters). The gimbal swings up to |swing|° off nadir in
48
+ * all directions during a single pass, so the most-oblique look images h·tan(|swing|)
49
+ * away from the aircraft. The survey polygon is therefore dilated by this distance so
50
+ * the grid overflies the boundary on every side. Clamp to 3·h.
51
+ */
52
+ function smartObliqueStandoff(folder: Mapping2dFolder): number {
53
+ const h = Number(folder.Placemark.height);
54
+ const swingAbs = Math.abs(Number(folder.Placemark.smartObliqueGimbalPitch ?? degrees(-45)));
55
+ if (!(swingAbs > 0 && swingAbs < 90)) return 0;
56
+ return Math.min(h * Math.tan((swingAbs * Math.PI) / 180), h * 3);
57
+ }
31
58
 
32
- const enuWps = scanWaypoints(enuPolygon, lineSpacing);
33
- const lngLatWps = enuWps.map((p) => enuToLngLat(p, origin));
59
+ function heightModeToExecuteMode(folder: Mapping2dFolder): ExecuteHeightMode {
60
+ switch (folder.waylineCoordinateSysParam.heightMode) {
61
+ case "EGM96":
62
+ return "WGS84";
63
+ case "relativeToStartPoint":
64
+ return "relativeToStartPoint";
65
+ case "aboveGroundLevel":
66
+ case "realTimeFollowSurface":
67
+ return "realTimeFollowSurface";
68
+ }
69
+ }
34
70
 
35
- let actionGroupId = 0;
36
- const placemarks: WaylinesPlacemark[] = lngLatWps.map((coord, index) => {
37
- const isLast = index === lngLatWps.length - 1;
38
- const headingAngle = isLast ? 0 : bearingDeg(enuWps[index]!, enuWps[index + 1]!);
71
+ /** Desired damping distance; clamped down when segments are short. */
72
+ const DESIRED_TURN_DAMPING_M = 10;
39
73
 
40
- const actionGroup: ActionGroup[] = isLast
41
- ? []
42
- : [
43
- {
44
- actionGroupId: actionGroupId++,
45
- actionGroupStartIndex: index,
46
- actionGroupEndIndex: index + 1,
47
- actionGroupMode: "sequence",
48
- actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
49
- action: [
50
- {
51
- actionId: 0,
52
- actionActuatorFunc: "takePhoto",
53
- actionActuatorFuncParam: {
54
- payloadPositionIndex: 0,
55
- payloadLensIndex: ["wide"],
56
- useGlobalPayloadLensIndex: false,
57
- },
58
- },
59
- ],
60
- },
61
- ];
74
+ /** Never consume more than this fraction of either adjacent segment. */
75
+ const MAX_DAMPING_FRAC = 0.45;
62
76
 
63
- return {
77
+ /** Flat ENU distance between two grid points (metres). */
78
+ function enuSegLen(a: EnuPoint, b: EnuPoint): number {
79
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
80
+ }
81
+
82
+ /**
83
+ * Compute the coordinateTurn damping distance for interior waypoint `i`.
84
+ *
85
+ * Spec constraint: dampingDist[i] + dampingDist[i+1] < segmentLength(i, i+1).
86
+ * Clamping each side to MAX_DAMPING_FRAC × its shorter adjacent segment
87
+ * guarantees the sum is at most 2 × MAX_DAMPING_FRAC < 1 of that segment.
88
+ */
89
+ function turnDamping(enuWps: readonly EnuPoint[], i: number): Meters {
90
+ const dPrev = enuSegLen(enuWps[i - 1]!, enuWps[i]!);
91
+ const dNext = enuSegLen(enuWps[i]!, enuWps[i + 1]!);
92
+ return meters(
93
+ Math.min(DESIRED_TURN_DAMPING_M, dPrev * MAX_DAMPING_FRAC, dNext * MAX_DAMPING_FRAC),
94
+ );
95
+ }
96
+
97
+ /** Nadir gimbal-down, hover, manual focus, infinite focus, hover — per reference WPML. */
98
+ function buildStartActionGroup(pitchDeg: number): StartActionGroup {
99
+ return {
100
+ action: [
101
+ {
102
+ actionId: 0,
103
+ actionActuatorFunc: "gimbalRotate",
104
+ actionActuatorFuncParam: {
105
+ payloadPositionIndex: 0,
106
+ gimbalHeadingYawBase: "aircraft",
107
+ gimbalRotateMode: "absoluteAngle",
108
+ gimbalPitchRotateEnable: true,
109
+ gimbalPitchRotateAngle: degrees(pitchDeg),
110
+ gimbalRollRotateEnable: false,
111
+ gimbalRollRotateAngle: degrees(0),
112
+ gimbalYawRotateEnable: true,
113
+ gimbalYawRotateAngle: degrees(0),
114
+ gimbalRotateTimeEnable: false,
115
+ gimbalRotateTime: 10,
116
+ },
117
+ },
118
+ {
119
+ actionId: 1,
120
+ actionActuatorFunc: "hover",
121
+ actionActuatorFuncParam: { hoverTime: 0.5 },
122
+ },
123
+ {
124
+ actionId: 2,
125
+ actionActuatorFunc: "setFocusType",
126
+ actionActuatorFuncParam: { payloadPositionIndex: 0, cameraFocusType: "manual" },
127
+ },
128
+ {
129
+ actionId: 3,
130
+ actionActuatorFunc: "focus",
131
+ actionActuatorFuncParam: {
132
+ payloadPositionIndex: 0,
133
+ isPointFocus: false,
134
+ focusX: 0,
135
+ focusY: 0,
136
+ focusRegionWidth: 0,
137
+ focusRegionHeight: 0,
138
+ isInfiniteFocus: true,
139
+ isCalibrationFocus: false,
140
+ },
141
+ },
142
+ {
143
+ actionId: 4,
144
+ actionActuatorFunc: "hover",
145
+ actionActuatorFuncParam: { hoverTime: 1 },
146
+ },
147
+ ],
148
+ };
149
+ }
150
+
151
+ /** Build the three actions placed at the first waypoint of a shooting section. */
152
+ function shootSectionStartActions(
153
+ pitchDeg: number,
154
+ shootInterval: number,
155
+ sectionStart: number,
156
+ sectionEnd: number,
157
+ groupIdStart: number,
158
+ ): { groups: import("../types/action.js").ActionGroup[]; nextGroupId: number } {
159
+ const betweenGroup: import("../types/action.js").ActionGroup = {
160
+ actionGroupId: groupIdStart,
161
+ actionGroupStartIndex: sectionStart,
162
+ actionGroupEndIndex: sectionEnd,
163
+ actionGroupMode: "sequence",
164
+ actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
165
+ action: [
166
+ {
167
+ actionId: 0,
168
+ actionActuatorFunc: "gimbalAngleLock",
169
+ actionActuatorFuncParam: { payloadPositionIndex: 0 },
170
+ },
171
+ {
172
+ actionId: 1,
173
+ actionActuatorFunc: "gimbalRotate",
174
+ actionActuatorFuncParam: {
175
+ payloadPositionIndex: 0,
176
+ gimbalHeadingYawBase: "aircraft",
177
+ gimbalRotateMode: "absoluteAngle",
178
+ gimbalPitchRotateEnable: true,
179
+ gimbalPitchRotateAngle: degrees(pitchDeg),
180
+ gimbalRollRotateEnable: false,
181
+ gimbalRollRotateAngle: degrees(0),
182
+ gimbalYawRotateEnable: false,
183
+ gimbalYawRotateAngle: degrees(0),
184
+ gimbalRotateTimeEnable: false,
185
+ gimbalRotateTime: 10,
186
+ },
187
+ },
188
+ {
189
+ actionId: 2,
190
+ actionActuatorFunc: "startTimeLapse",
191
+ actionActuatorFuncParam: {
192
+ payloadPositionIndex: 0,
193
+ useGlobalPayloadLensIndex: false,
194
+ payloadLensIndex: "visable",
195
+ minShootInterval: shootInterval,
196
+ },
197
+ },
198
+ ],
199
+ };
200
+
201
+ const timingGroup: import("../types/action.js").ActionGroup = {
202
+ actionGroupId: groupIdStart + 1,
203
+ actionGroupStartIndex: sectionStart,
204
+ actionGroupEndIndex: sectionEnd,
205
+ actionGroupMode: "sequence",
206
+ actionTrigger: { actionTriggerType: "multipleTiming", actionTriggerParam: shootInterval },
207
+ action: [
208
+ {
209
+ actionId: 0,
210
+ actionActuatorFunc: "gimbalRotate",
211
+ actionActuatorFuncParam: {
212
+ payloadPositionIndex: 0,
213
+ gimbalHeadingYawBase: "aircraft",
214
+ gimbalRotateMode: "absoluteAngle",
215
+ gimbalPitchRotateEnable: true,
216
+ gimbalPitchRotateAngle: degrees(pitchDeg),
217
+ gimbalRollRotateEnable: false,
218
+ gimbalRollRotateAngle: degrees(0),
219
+ gimbalYawRotateEnable: false,
220
+ gimbalYawRotateAngle: degrees(0),
221
+ gimbalRotateTimeEnable: false,
222
+ gimbalRotateTime: 10,
223
+ },
224
+ },
225
+ ],
226
+ };
227
+
228
+ return { groups: [betweenGroup, timingGroup], nextGroupId: groupIdStart + 2 };
229
+ }
230
+
231
+ /** Build the stop group placed at the last waypoint of a shooting section. */
232
+ function shootSectionStopActions(
233
+ sectionEnd: number,
234
+ groupId: number,
235
+ ): import("../types/action.js").ActionGroup {
236
+ return {
237
+ actionGroupId: groupId,
238
+ actionGroupStartIndex: sectionEnd,
239
+ actionGroupEndIndex: sectionEnd,
240
+ actionGroupMode: "sequence",
241
+ actionTrigger: { actionTriggerType: "reachPoint" },
242
+ action: [
243
+ {
244
+ actionId: 0,
245
+ actionActuatorFunc: "stopTimeLapse",
246
+ actionActuatorFuncParam: { payloadPositionIndex: 0, payloadLensIndex: "visable" },
247
+ },
248
+ { actionId: 1, actionActuatorFunc: "gimbalAngleUnlock" } satisfies Action,
249
+ ],
250
+ };
251
+ }
252
+
253
+ /** Zero-dampingDist stop-turn (first/last waypoint of a section). */
254
+ function stopTurn() {
255
+ return {
256
+ waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature" as const,
257
+ waypointTurnDampingDist: meters(0),
258
+ };
259
+ }
260
+
261
+ /** Common heading param fields present on every mapping waypoint. */
262
+ function mappingHeadingParam(headingAngle: number, angleEnable: boolean) {
263
+ return {
264
+ waypointHeadingMode: "followWayline" as const,
265
+ waypointHeadingAngle: degrees(headingAngle),
266
+ waypointPoiPoint: { lng: 0, lat: 0, height: meters(0) },
267
+ waypointHeadingAngleEnable: angleEnable,
268
+ waypointHeadingPathMode: "followBadArc" as const,
269
+ waypointHeadingPoiIndex: 0,
270
+ };
271
+ }
272
+
273
+ /** Common extra fields present on every mapping waypoint per reference WPML. */
274
+ const COMMON_WP_EXTRAS = {
275
+ useStraightLine: true as const,
276
+ waypointGimbalHeadingParam: {
277
+ waypointGimbalPitchAngle: degrees(0),
278
+ waypointGimbalYawAngle: degrees(0),
279
+ },
280
+ isRisky: false as const,
281
+ waypointWorkType: 0,
282
+ };
283
+
284
+ /**
285
+ * 高程优化 (elevationOptimizeEnable) excursion — appended to the nadir grid:
286
+ * fly from the last nadir waypoint to the survey centroid and take an oblique shot
287
+ * there to enrich the DSM. WITHOUT this flag the route ends at the last grid
288
+ * waypoint — it does NOT detour to the centre (that detour is elevation optimization,
289
+ * not smart-swing). Mutates `placemarks` (appends two waypoints).
290
+ */
291
+ function appendElevationExcursion(
292
+ placemarks: WaylinesPlacemark[],
293
+ folder: Mapping2dFolder,
294
+ origin: { lng: number; lat: number },
295
+ enuWps: readonly EnuPoint[],
296
+ shootInterval: number,
297
+ startGroupId: number,
298
+ ): void {
299
+ const lastNadirIdx = enuWps.length - 1;
300
+ const obliqueStartIdx = placemarks.length;
301
+ const obliqueEndIdx = obliqueStartIdx + 1;
302
+ const pitch = Number(folder.Placemark.quickOrthoMappingPitch ?? degrees(-45));
303
+ let nextGroupId = startGroupId;
304
+
305
+ // reachPoint — tilt gimbal to the oblique pitch then hover
306
+ const obliqueGimbalGroup: import("../types/action.js").ActionGroup = {
307
+ actionGroupId: nextGroupId++,
308
+ actionGroupStartIndex: obliqueStartIdx,
309
+ actionGroupEndIndex: obliqueStartIdx,
310
+ actionGroupMode: "sequence",
311
+ actionTrigger: { actionTriggerType: "reachPoint" },
312
+ action: [
313
+ {
314
+ actionId: 0,
315
+ actionActuatorFunc: "gimbalRotate",
316
+ actionActuatorFuncParam: {
317
+ payloadPositionIndex: 0,
318
+ gimbalHeadingYawBase: "aircraft",
319
+ gimbalRotateMode: "absoluteAngle",
320
+ gimbalPitchRotateEnable: true,
321
+ gimbalPitchRotateAngle: degrees(pitch),
322
+ gimbalRollRotateEnable: false,
323
+ gimbalRollRotateAngle: degrees(0),
324
+ gimbalYawRotateEnable: true,
325
+ gimbalYawRotateAngle: degrees(0),
326
+ gimbalRotateTimeEnable: false,
327
+ gimbalRotateTime: 10,
328
+ },
329
+ },
330
+ { actionId: 1, actionActuatorFunc: "hover", actionActuatorFuncParam: { hoverTime: 0.5 } },
331
+ ],
332
+ };
333
+
334
+ const obliqueStart = shootSectionStartActions(
335
+ pitch,
336
+ shootInterval,
337
+ obliqueStartIdx,
338
+ obliqueEndIdx,
339
+ nextGroupId,
340
+ );
341
+ nextGroupId = obliqueStart.nextGroupId;
342
+ const obliqueStop = shootSectionStopActions(obliqueEndIdx, nextGroupId);
343
+
344
+ const lastNadirCoord = enuToLngLat(enuWps[lastNadirIdx]!, origin);
345
+ const sumEnu = enuWps.reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]] as EnuPoint, [
346
+ 0, 0,
347
+ ] as EnuPoint);
348
+ const centroidEnu: EnuPoint = [sumEnu[0] / enuWps.length, sumEnu[1] / enuWps.length];
349
+ const centroidCoord = enuToLngLat(centroidEnu, origin);
350
+ const obliqueHeading = bearingDeg(enuWps[lastNadirIdx]!, centroidEnu);
351
+
352
+ placemarks.push({
353
+ Point: { coordinates: lastNadirCoord }, // same position as last nadir
354
+ index: obliqueStartIdx,
355
+ executeHeight: folder.Placemark.height,
356
+ waypointSpeed: folder.autoFlightSpeed,
357
+ waypointHeadingParam: mappingHeadingParam(obliqueHeading, true),
358
+ waypointTurnParam: stopTurn(),
359
+ ...COMMON_WP_EXTRAS,
360
+ actionGroup: [obliqueGimbalGroup, ...obliqueStart.groups],
361
+ });
362
+
363
+ placemarks.push({
364
+ Point: { coordinates: centroidCoord },
365
+ index: obliqueEndIdx,
366
+ executeHeight: folder.Placemark.height,
367
+ waypointSpeed: folder.autoFlightSpeed,
368
+ waypointHeadingParam: mappingHeadingParam(0, false),
369
+ waypointTurnParam: stopTurn(),
370
+ ...COMMON_WP_EXTRAS,
371
+ actionGroup: [obliqueStop],
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Ortho (nadir) wayline — covers 正射 and 正射+智能摆拍 (the swing is a folder-level
377
+ * quickOrthoMappingEnable flag over the same grid geometry, not extra waypoints).
378
+ *
379
+ * Action pattern (per reference WPML M4TD 正射采集):
380
+ * - WP 0: two action groups (betweenAdjacentPoints startTimeLapse-init + multipleTiming gimbalCorrect)
381
+ * - WP 1…N-2: no action groups
382
+ * - WP N-1 (last): reachPoint stopTimeLapse group
383
+ *
384
+ * When `elevationOptimizeEnable` is set, a centroid excursion is appended (see
385
+ * `appendElevationExcursion`); otherwise the route ends at the last grid waypoint.
386
+ */
387
+ function emitOrtho(
388
+ folder: Mapping2dFolder,
389
+ origin: { lng: number; lat: number },
390
+ enuWps: readonly EnuPoint[],
391
+ shootInterval: number,
392
+ ): WaylinesFolder {
393
+ const lastIndex = enuWps.length - 1;
394
+ const startActions = shootSectionStartActions(-90, shootInterval, 0, lastIndex, 0);
395
+ const stopGroup = shootSectionStopActions(lastIndex, startActions.nextGroupId);
396
+
397
+ const placemarks: WaylinesPlacemark[] = enuWps.map((enu, index) => {
398
+ const coord = enuToLngLat(enu, origin);
399
+ const isLast = index === lastIndex;
400
+ const headingAngle = isLast ? 0 : bearingDeg(enuWps[index]!, enuWps[index + 1]!);
401
+ const placemark: WaylinesPlacemark = {
64
402
  Point: { coordinates: coord },
65
403
  index,
66
- executeHeight: folder.height,
404
+ executeHeight: folder.Placemark.height,
67
405
  waypointSpeed: folder.autoFlightSpeed,
68
- waypointHeadingParam: {
69
- waypointHeadingMode: "followWayline",
70
- waypointHeadingAngle: degrees(headingAngle),
71
- waypointHeadingPathMode: "followBadArc",
72
- },
73
- waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
74
- ...(actionGroup.length > 0 ? { actionGroup } : {}),
406
+ waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
407
+ waypointTurnParam:
408
+ index === 0 || isLast
409
+ ? stopTurn()
410
+ : {
411
+ waypointTurnMode: "coordinateTurn",
412
+ waypointTurnDampingDist: turnDamping(enuWps, index),
413
+ },
414
+ ...COMMON_WP_EXTRAS,
75
415
  };
416
+ if (index === 0) {
417
+ placemark.actionGroup = startActions.groups;
418
+ } else if (isLast) {
419
+ placemark.actionGroup = [stopGroup];
420
+ }
421
+ return placemark;
76
422
  });
77
423
 
78
- const waylineFolder: WaylinesFolder = {
424
+ if (folder.Placemark.elevationOptimizeEnable) {
425
+ appendElevationExcursion(
426
+ placemarks,
427
+ folder,
428
+ origin,
429
+ enuWps,
430
+ shootInterval,
431
+ startActions.nextGroupId + 1,
432
+ );
433
+ }
434
+
435
+ return {
79
436
  templateId: folder.templateId,
80
437
  waylineId: 0,
81
438
  executeHeightMode: heightModeToExecuteMode(folder),
82
439
  autoFlightSpeed: folder.autoFlightSpeed,
83
- startActionGroup: {
84
- actionGroupId: 65535,
85
- actionGroupStartIndex: 0,
86
- actionGroupEndIndex: 0,
87
- actionGroupMode: "sequence",
88
- actionTrigger: { actionTriggerType: "reachPoint" },
89
- action: [
440
+ startActionGroup: buildStartActionGroup(-90),
441
+ Placemark: placemarks,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Single-pass smart-oblique / quick-ortho (smartObliqueEnable=true):
447
+ * The aircraft flies one grid while the gimbal swings (startSmartOblique/stopSmartOblique).
448
+ */
449
+ function emitSmartOblique(
450
+ folder: Mapping2dFolder,
451
+ origin: { lng: number; lat: number },
452
+ enuWps: readonly EnuPoint[],
453
+ ): WaylinesFolder {
454
+ const lastIndex = enuWps.length - 1;
455
+ const pitch = folder.Placemark.smartObliqueGimbalPitch ?? degrees(-45);
456
+
457
+ const placemarks: WaylinesPlacemark[] = enuWps.map((enu, index) => {
458
+ const coord = enuToLngLat(enu, origin);
459
+ const isLast = index === lastIndex;
460
+ const headingAngle = isLast ? 0 : bearingDeg(enuWps[index]!, enuWps[index + 1]!);
461
+ const placemark: WaylinesPlacemark = {
462
+ Point: { coordinates: coord },
463
+ index,
464
+ executeHeight: folder.Placemark.height,
465
+ waypointSpeed: folder.autoFlightSpeed,
466
+ waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
467
+ waypointTurnParam:
468
+ index === 0 || isLast
469
+ ? stopTurn()
470
+ : {
471
+ waypointTurnMode: "coordinateTurn",
472
+ waypointTurnDampingDist: turnDamping(enuWps, index),
473
+ },
474
+ ...COMMON_WP_EXTRAS,
475
+ };
476
+ if (index === 0) {
477
+ placemark.actionGroup = [
90
478
  {
91
- actionId: 0,
92
- actionActuatorFunc: "gimbalRotate",
93
- actionActuatorFuncParam: {
94
- payloadPositionIndex: 0,
95
- gimbalHeadingYawBase: "north",
96
- gimbalRotateMode: "absoluteAngle",
97
- gimbalPitchRotateEnable: true,
98
- gimbalPitchRotateAngle: degrees(-90),
99
- gimbalRollRotateEnable: false,
100
- gimbalRollRotateAngle: degrees(0),
101
- gimbalYawRotateEnable: true,
102
- gimbalYawRotateAngle: degrees(0),
103
- gimbalRotateTimeEnable: false,
104
- gimbalRotateTime: 10,
105
- },
479
+ actionGroupId: 0,
480
+ actionGroupStartIndex: 0,
481
+ actionGroupEndIndex: lastIndex,
482
+ actionGroupMode: "sequence",
483
+ actionTrigger: { actionTriggerType: "reachPoint" },
484
+ action: [
485
+ {
486
+ actionId: 0,
487
+ actionActuatorFunc: "startSmartOblique",
488
+ actionActuatorFuncParam: { payloadPositionIndex: 0 },
489
+ },
490
+ ],
106
491
  },
107
- ],
108
- },
492
+ ];
493
+ } else if (isLast) {
494
+ placemark.actionGroup = [
495
+ {
496
+ actionGroupId: 1,
497
+ actionGroupStartIndex: lastIndex,
498
+ actionGroupEndIndex: lastIndex,
499
+ actionGroupMode: "sequence",
500
+ actionTrigger: { actionTriggerType: "reachPoint" },
501
+ action: [
502
+ {
503
+ actionId: 0,
504
+ actionActuatorFunc: "stopSmartOblique",
505
+ actionActuatorFuncParam: { payloadPositionIndex: 0 },
506
+ },
507
+ ],
508
+ },
509
+ ];
510
+ }
511
+ return placemark;
512
+ });
513
+
514
+ return {
515
+ templateId: folder.templateId,
516
+ waylineId: 0,
517
+ executeHeightMode: heightModeToExecuteMode(folder),
518
+ autoFlightSpeed: folder.autoFlightSpeed,
519
+ startActionGroup: buildStartActionGroup(Number(pitch)),
109
520
  Placemark: placemarks,
110
521
  };
111
-
112
- return { missionConfig: template.missionConfig, Folder: [waylineFolder] };
113
522
  }
114
523
 
115
- function heightModeToExecuteMode(folder: Mapping2dFolder): ExecuteHeightMode {
116
- switch (folder.waylineCoordinateSysParam.heightMode) {
117
- case "EGM96":
118
- return "WGS84";
119
- case "relativeToStartPoint":
120
- return "relativeToStartPoint";
121
- case "aboveGroundLevel":
122
- case "realTimeFollowSurface":
123
- return "realTimeFollowSurface";
524
+ export function planMapping2d(template: Mapping2dTemplate, options?: PlanOptions): Waylines {
525
+ if (options?.lineSpacing == null) {
526
+ throw new PlannerError(
527
+ "mapping2d requires options.lineSpacing — calculate 2·h·tan(HFOV/2)·(1−sideOverlap) in the demo and pass it in.",
528
+ );
124
529
  }
125
- }
530
+ // Smart-oblique flies a single omnidirectional swinging pass that must overfly the
531
+ // boundary on every side; ortho / quick-ortho stay tight to the area.
532
+ const standoff =
533
+ template.Folder.Placemark.smartObliqueEnable === true
534
+ ? smartObliqueStandoff(template.Folder)
535
+ : 0;
536
+ const { folder, origin, enuWps } = buildGrid(template, Number(options.lineSpacing), standoff);
537
+ const shootInterval = options.shootInterval ?? 2;
126
538
 
127
- // Suppress unused import warnings for re-exports
128
- void metersPerSecond;
539
+ if (folder.Placemark.smartObliqueEnable === true) {
540
+ return {
541
+ missionConfig: template.missionConfig,
542
+ Folder: [emitSmartOblique(folder, origin, enuWps)],
543
+ };
544
+ }
545
+ // 正射 and 正射+智能摆拍 share the nadir grid; the centroid detour is driven by
546
+ // elevationOptimizeEnable inside emitOrtho, not by quickOrthoMappingEnable.
547
+ return {
548
+ missionConfig: template.missionConfig,
549
+ Folder: [emitOrtho(folder, origin, enuWps, shootInterval)],
550
+ };
551
+ }