@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.
package/dist/index.mjs CHANGED
@@ -81,46 +81,57 @@ const DroneEnum = {
81
81
  const PayloadEnum = {
82
82
  M30Camera: {
83
83
  payloadEnumValue: 52,
84
+ payloadSubEnumValue: 0,
84
85
  payloadPositionIndex: 0
85
86
  },
86
87
  M30TCamera: {
87
88
  payloadEnumValue: 53,
89
+ payloadSubEnumValue: 0,
88
90
  payloadPositionIndex: 0
89
91
  },
90
92
  M3ECamera: {
91
93
  payloadEnumValue: 66,
94
+ payloadSubEnumValue: 0,
92
95
  payloadPositionIndex: 0
93
96
  },
94
97
  M3TCamera: {
95
98
  payloadEnumValue: 67,
99
+ payloadSubEnumValue: 0,
96
100
  payloadPositionIndex: 0
97
101
  },
98
102
  M3TACamera: {
99
103
  payloadEnumValue: 129,
104
+ payloadSubEnumValue: 0,
100
105
  payloadPositionIndex: 0
101
106
  },
102
107
  M3DCamera: {
103
108
  payloadEnumValue: 80,
109
+ payloadSubEnumValue: 0,
104
110
  payloadPositionIndex: 0
105
111
  },
106
112
  M3TDCamera: {
107
113
  payloadEnumValue: 81,
114
+ payloadSubEnumValue: 0,
108
115
  payloadPositionIndex: 0
109
116
  },
110
117
  M4DCamera: {
111
118
  payloadEnumValue: 98,
119
+ payloadSubEnumValue: 0,
112
120
  payloadPositionIndex: 0
113
121
  },
114
122
  M4TDCamera: {
115
123
  payloadEnumValue: 99,
124
+ payloadSubEnumValue: 0,
116
125
  payloadPositionIndex: 0
117
126
  },
118
127
  M4ECamera: {
119
128
  payloadEnumValue: 88,
129
+ payloadSubEnumValue: 0,
120
130
  payloadPositionIndex: 0
121
131
  },
122
132
  M4TCamera: {
123
133
  payloadEnumValue: 89,
134
+ payloadSubEnumValue: 0,
124
135
  payloadPositionIndex: 0
125
136
  },
126
137
  ZenmuseZ30: {
@@ -207,6 +218,27 @@ function centroid(points) {
207
218
  function bearingDeg(from, to) {
208
219
  return Math.atan2(to[0] - from[0], to[1] - from[1]) * RAD2DEG;
209
220
  }
221
+ /**
222
+ * Orient an ordered survey path so its first point is the end nearest `home`.
223
+ * A boustrophedon grid has two natural entry ends; the aircraft should start at
224
+ * whichever is closer to the take-off point. Returns the input unchanged when
225
+ * `home` is absent or the start is already the nearer end.
226
+ */
227
+ function orientPathToHome(enuWps, origin, home) {
228
+ if (!home || enuWps.length < 2) return enuWps;
229
+ const h = lngLatToEnu(home, origin);
230
+ const sqDist = (p) => (p[0] - h[0]) ** 2 + (p[1] - h[1]) ** 2;
231
+ return sqDist(enuWps[enuWps.length - 1]) < sqDist(enuWps[0]) ? [...enuWps].reverse() : enuWps;
232
+ }
233
+ /** Great-circle distance between two WGS-84 coordinates, in meters. */
234
+ function haversineMeters(a, b) {
235
+ const dLat = (b.lat - a.lat) * DEG2RAD;
236
+ const dLng = (b.lng - a.lng) * DEG2RAD;
237
+ const lat1 = a.lat * DEG2RAD;
238
+ const lat2 = b.lat * DEG2RAD;
239
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
240
+ return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
241
+ }
210
242
  /** Clip a horizontal scan line `y = const` against a polygon; returns `[x_enter, x_exit]` pairs. */
211
243
  function clipHorizontal(y, polygon) {
212
244
  const intersections = [];
@@ -224,24 +256,6 @@ function clipHorizontal(y, polygon) {
224
256
  for (let i = 0; i + 1 < intersections.length; i += 2) out.push([intersections[i], intersections[i + 1]]);
225
257
  return out;
226
258
  }
227
- /** Serpentine boustrophedon scan over a polygon; returns ENU waypoints. */
228
- function scanWaypoints(polygon, lineSpacing) {
229
- let minY = Infinity;
230
- let maxY = -Infinity;
231
- for (const [, y] of polygon) {
232
- if (y < minY) minY = y;
233
- if (y > maxY) maxY = y;
234
- }
235
- const waypoints = [];
236
- let leftToRight = true;
237
- for (let y = minY; y <= maxY + lineSpacing * .5; y += lineSpacing) {
238
- const segments = clipHorizontal(y, polygon);
239
- for (const [x1, x2] of segments) if (leftToRight) waypoints.push([x1, y], [x2, y]);
240
- else waypoints.push([x2, y], [x1, y]);
241
- leftToRight = !leftToRight;
242
- }
243
- return waypoints;
244
- }
245
259
  /**
246
260
  * Parse a KML coordinates string like `"lon,lat lon,lat lon,lat"` or `"lon,lat,alt ..."`
247
261
  * into LngLat array (altitude dropped).
@@ -255,211 +269,672 @@ function parseCoordinatesString(s) {
255
269
  };
256
270
  });
257
271
  }
272
+ /** Rotate an ENU point counter-clockwise by `rad` radians about the origin. */
273
+ function rotateEnu([x, y], rad) {
274
+ const c = Math.cos(rad);
275
+ const s = Math.sin(rad);
276
+ return [x * c - y * s, x * s + y * c];
277
+ }
278
+ /** Drop a closing duplicate and any consecutive vertices closer than `epsM`. */
279
+ function dedupeRing(ring, epsM = .5) {
280
+ const pts = [];
281
+ for (const p of ring) {
282
+ const last = pts[pts.length - 1];
283
+ if (last && Math.hypot(p[0] - last[0], p[1] - last[1]) < epsM) continue;
284
+ pts.push([p[0], p[1]]);
285
+ }
286
+ const f = pts[0];
287
+ const l = pts[pts.length - 1];
288
+ if (pts.length >= 2 && f && l && Math.hypot(f[0] - l[0], f[1] - l[1]) < epsM) pts.pop();
289
+ return pts;
290
+ }
291
+ /** Beyond this multiple of `distM`, a corner miter is clamped to avoid spikes. */
292
+ const OFFSET_MITER_LIMIT = 2.5;
293
+ /**
294
+ * Outward-offset (dilate) a simple polygon ring by `distM` meters in ENU.
295
+ * Each vertex moves along the bisector of its two adjacent outward edge normals
296
+ * by `distM / cos(halfAngle)`, clamped by a miter limit. The bisector form is
297
+ * stable when adjacent edges are near-collinear (where edge-intersection would
298
+ * shoot a spike far outward); the clamp bounds sharp corners; degenerate/duplicate
299
+ * vertices are removed first. Intended for convex-ish survey areas.
300
+ */
301
+ function offsetPolygon(ring, distM) {
302
+ const pts = dedupeRing(ring);
303
+ const n = pts.length;
304
+ if (distM === 0 || n < 3) return pts;
305
+ let area2 = 0;
306
+ for (let i = 0; i < n; i++) {
307
+ const a = pts[i];
308
+ const b = pts[(i + 1) % n];
309
+ area2 += a[0] * b[1] - b[0] * a[1];
310
+ }
311
+ const ccw = area2 > 0;
312
+ const outwardNormal = (dx, dy) => ccw ? [dy, -dx] : [-dy, dx];
313
+ const out = [];
314
+ for (let i = 0; i < n; i++) {
315
+ const prev = pts[(i - 1 + n) % n];
316
+ const cur = pts[i];
317
+ const next = pts[(i + 1) % n];
318
+ const inLen = Math.hypot(cur[0] - prev[0], cur[1] - prev[1]) || 1;
319
+ const outLen = Math.hypot(next[0] - cur[0], next[1] - cur[1]) || 1;
320
+ const nIn = outwardNormal((cur[0] - prev[0]) / inLen, (cur[1] - prev[1]) / inLen);
321
+ const nOut = outwardNormal((next[0] - cur[0]) / outLen, (next[1] - cur[1]) / outLen);
322
+ let bx = nIn[0] + nOut[0];
323
+ let by = nIn[1] + nOut[1];
324
+ const bl = Math.hypot(bx, by);
325
+ if (bl < 1e-9) {
326
+ out.push([cur[0] + nOut[0] * distM, cur[1] + nOut[1] * distM]);
327
+ continue;
328
+ }
329
+ bx /= bl;
330
+ by /= bl;
331
+ const cosHalf = nOut[0] * bx + nOut[1] * by;
332
+ const scale = distM / Math.max(cosHalf, 1 / OFFSET_MITER_LIMIT);
333
+ out.push([cur[0] + bx * scale, cur[1] + by * scale]);
334
+ }
335
+ return out;
336
+ }
337
+ /**
338
+ * Boustrophedon survey grid over an arbitrary simple polygon, aligned to `directionDeg`.
339
+ * Flight lines run parallel to the heading; consecutive lines step by `lineSpacing`.
340
+ * `marginM` extends each line lengthwise past the polygon edge.
341
+ * Returns ENU waypoints in flight order — no transit/entry point
342
+ * (entry climb is a missionConfig behaviour, not a grid concern).
343
+ */
344
+ function surveyGrid(ring, opts) {
345
+ const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
346
+ const t = directionDeg * DEG2RAD;
347
+ const alpha = Math.atan2(Math.cos(t), Math.sin(t));
348
+ const scanRing = ring.map((p) => rotateEnu(p, -alpha));
349
+ let minY = Infinity;
350
+ let maxY = -Infinity;
351
+ for (const [, y] of scanRing) {
352
+ if (y < minY) minY = y;
353
+ if (y > maxY) maxY = y;
354
+ }
355
+ const scanPts = [];
356
+ let leftToRight = true;
357
+ for (let y = minY + phaseOffsetM; y <= maxY + lineSpacing * .5; y += lineSpacing) {
358
+ for (const [x1, x2] of clipHorizontal(y, scanRing)) {
359
+ const a = x1 - marginM + lengthwiseShiftM;
360
+ const b = x2 + marginM + lengthwiseShiftM;
361
+ if (leftToRight) scanPts.push([a, y], [b, y]);
362
+ else scanPts.push([b, y], [a, y]);
363
+ }
364
+ leftToRight = !leftToRight;
365
+ }
366
+ return scanPts.map((p) => rotateEnu(p, alpha));
367
+ }
258
368
  //#endregion
259
369
  //#region src/plan/plan-mapping2d.ts
260
- /** Default HFOV (degrees) used to estimate ground footprint when payload-specific FOV unknown. */
261
- const DEFAULT_HFOV_DEG$1 = 84;
262
- function planMapping2d(template) {
370
+ /**
371
+ * `standoffM` dilates the survey polygon outward before gridding (smart-oblique
372
+ * needs the grid to overfly the boundary on every side; 0 for ortho/quick-ortho).
373
+ */
374
+ function buildGrid(template, lineSpacing, standoffM = 0) {
263
375
  const folder = template.Folder;
264
376
  const ring = parseCoordinatesString(folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
265
377
  const origin = centroid(ring);
266
- const enuPolygon = ring.map((p) => lngLatToEnu(p, origin));
267
- const hfovRad = DEFAULT_HFOV_DEG$1 / 2 * (Math.PI / 180);
268
- const enuWps = scanWaypoints(enuPolygon, 2 * folder.height * Math.tan(hfovRad) * (1 - (folder.overlap.orthoCameraOverlapW ?? 70) / 100));
269
- const lngLatWps = enuWps.map((p) => enuToLngLat(p, origin));
270
- let actionGroupId = 0;
271
- const placemarks = lngLatWps.map((coord, index) => {
272
- const isLast = index === lngLatWps.length - 1;
273
- const headingAngle = isLast ? 0 : bearingDeg(enuWps[index], enuWps[index + 1]);
274
- const actionGroup = isLast ? [] : [{
275
- actionGroupId: actionGroupId++,
276
- actionGroupStartIndex: index,
277
- actionGroupEndIndex: index + 1,
378
+ const enuRing = ring.map((p) => lngLatToEnu(p, origin));
379
+ return {
380
+ folder,
381
+ origin,
382
+ enuWps: orientPathToHome(surveyGrid(standoffM > 0 ? offsetPolygon(enuRing, standoffM) : enuRing, {
383
+ directionDeg: folder.Placemark.direction,
384
+ marginM: folder.Placemark.margin,
385
+ lineSpacing
386
+ }), origin, template.missionConfig.takeOffRefPoint)
387
+ };
388
+ }
389
+ /**
390
+ * Smart-oblique stand-off (meters). The gimbal swings up to |swing|° off nadir in
391
+ * all directions during a single pass, so the most-oblique look images h·tan(|swing|)
392
+ * away from the aircraft. The survey polygon is therefore dilated by this distance so
393
+ * the grid overflies the boundary on every side. Clamp to 3·h.
394
+ */
395
+ function smartObliqueStandoff(folder) {
396
+ const h = Number(folder.Placemark.height);
397
+ const swingAbs = Math.abs(Number(folder.Placemark.smartObliqueGimbalPitch ?? degrees(-45)));
398
+ if (!(swingAbs > 0 && swingAbs < 90)) return 0;
399
+ return Math.min(h * Math.tan(swingAbs * Math.PI / 180), h * 3);
400
+ }
401
+ function heightModeToExecuteMode$3(folder) {
402
+ switch (folder.waylineCoordinateSysParam.heightMode) {
403
+ case "EGM96": return "WGS84";
404
+ case "relativeToStartPoint": return "relativeToStartPoint";
405
+ case "aboveGroundLevel":
406
+ case "realTimeFollowSurface": return "realTimeFollowSurface";
407
+ }
408
+ }
409
+ /** Desired damping distance; clamped down when segments are short. */
410
+ const DESIRED_TURN_DAMPING_M = 10;
411
+ /** Never consume more than this fraction of either adjacent segment. */
412
+ const MAX_DAMPING_FRAC = .45;
413
+ /** Flat ENU distance between two grid points (metres). */
414
+ function enuSegLen(a, b) {
415
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
416
+ }
417
+ /**
418
+ * Compute the coordinateTurn damping distance for interior waypoint `i`.
419
+ *
420
+ * Spec constraint: dampingDist[i] + dampingDist[i+1] < segmentLength(i, i+1).
421
+ * Clamping each side to MAX_DAMPING_FRAC × its shorter adjacent segment
422
+ * guarantees the sum is at most 2 × MAX_DAMPING_FRAC < 1 of that segment.
423
+ */
424
+ function turnDamping(enuWps, i) {
425
+ const dPrev = enuSegLen(enuWps[i - 1], enuWps[i]);
426
+ const dNext = enuSegLen(enuWps[i], enuWps[i + 1]);
427
+ return meters(Math.min(DESIRED_TURN_DAMPING_M, dPrev * MAX_DAMPING_FRAC, dNext * MAX_DAMPING_FRAC));
428
+ }
429
+ /** Nadir gimbal-down, hover, manual focus, infinite focus, hover — per reference WPML. */
430
+ function buildStartActionGroup(pitchDeg) {
431
+ return { action: [
432
+ {
433
+ actionId: 0,
434
+ actionActuatorFunc: "gimbalRotate",
435
+ actionActuatorFuncParam: {
436
+ payloadPositionIndex: 0,
437
+ gimbalHeadingYawBase: "aircraft",
438
+ gimbalRotateMode: "absoluteAngle",
439
+ gimbalPitchRotateEnable: true,
440
+ gimbalPitchRotateAngle: degrees(pitchDeg),
441
+ gimbalRollRotateEnable: false,
442
+ gimbalRollRotateAngle: degrees(0),
443
+ gimbalYawRotateEnable: true,
444
+ gimbalYawRotateAngle: degrees(0),
445
+ gimbalRotateTimeEnable: false,
446
+ gimbalRotateTime: 10
447
+ }
448
+ },
449
+ {
450
+ actionId: 1,
451
+ actionActuatorFunc: "hover",
452
+ actionActuatorFuncParam: { hoverTime: .5 }
453
+ },
454
+ {
455
+ actionId: 2,
456
+ actionActuatorFunc: "setFocusType",
457
+ actionActuatorFuncParam: {
458
+ payloadPositionIndex: 0,
459
+ cameraFocusType: "manual"
460
+ }
461
+ },
462
+ {
463
+ actionId: 3,
464
+ actionActuatorFunc: "focus",
465
+ actionActuatorFuncParam: {
466
+ payloadPositionIndex: 0,
467
+ isPointFocus: false,
468
+ focusX: 0,
469
+ focusY: 0,
470
+ focusRegionWidth: 0,
471
+ focusRegionHeight: 0,
472
+ isInfiniteFocus: true,
473
+ isCalibrationFocus: false
474
+ }
475
+ },
476
+ {
477
+ actionId: 4,
478
+ actionActuatorFunc: "hover",
479
+ actionActuatorFuncParam: { hoverTime: 1 }
480
+ }
481
+ ] };
482
+ }
483
+ /** Build the three actions placed at the first waypoint of a shooting section. */
484
+ function shootSectionStartActions(pitchDeg, shootInterval, sectionStart, sectionEnd, groupIdStart) {
485
+ return {
486
+ groups: [{
487
+ actionGroupId: groupIdStart,
488
+ actionGroupStartIndex: sectionStart,
489
+ actionGroupEndIndex: sectionEnd,
278
490
  actionGroupMode: "sequence",
279
491
  actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
492
+ action: [
493
+ {
494
+ actionId: 0,
495
+ actionActuatorFunc: "gimbalAngleLock",
496
+ actionActuatorFuncParam: { payloadPositionIndex: 0 }
497
+ },
498
+ {
499
+ actionId: 1,
500
+ actionActuatorFunc: "gimbalRotate",
501
+ actionActuatorFuncParam: {
502
+ payloadPositionIndex: 0,
503
+ gimbalHeadingYawBase: "aircraft",
504
+ gimbalRotateMode: "absoluteAngle",
505
+ gimbalPitchRotateEnable: true,
506
+ gimbalPitchRotateAngle: degrees(pitchDeg),
507
+ gimbalRollRotateEnable: false,
508
+ gimbalRollRotateAngle: degrees(0),
509
+ gimbalYawRotateEnable: false,
510
+ gimbalYawRotateAngle: degrees(0),
511
+ gimbalRotateTimeEnable: false,
512
+ gimbalRotateTime: 10
513
+ }
514
+ },
515
+ {
516
+ actionId: 2,
517
+ actionActuatorFunc: "startTimeLapse",
518
+ actionActuatorFuncParam: {
519
+ payloadPositionIndex: 0,
520
+ useGlobalPayloadLensIndex: false,
521
+ payloadLensIndex: "visable",
522
+ minShootInterval: shootInterval
523
+ }
524
+ }
525
+ ]
526
+ }, {
527
+ actionGroupId: groupIdStart + 1,
528
+ actionGroupStartIndex: sectionStart,
529
+ actionGroupEndIndex: sectionEnd,
530
+ actionGroupMode: "sequence",
531
+ actionTrigger: {
532
+ actionTriggerType: "multipleTiming",
533
+ actionTriggerParam: shootInterval
534
+ },
280
535
  action: [{
281
536
  actionId: 0,
282
- actionActuatorFunc: "takePhoto",
537
+ actionActuatorFunc: "gimbalRotate",
283
538
  actionActuatorFuncParam: {
284
539
  payloadPositionIndex: 0,
285
- payloadLensIndex: ["wide"],
286
- useGlobalPayloadLensIndex: false
540
+ gimbalHeadingYawBase: "aircraft",
541
+ gimbalRotateMode: "absoluteAngle",
542
+ gimbalPitchRotateEnable: true,
543
+ gimbalPitchRotateAngle: degrees(pitchDeg),
544
+ gimbalRollRotateEnable: false,
545
+ gimbalRollRotateAngle: degrees(0),
546
+ gimbalYawRotateEnable: false,
547
+ gimbalYawRotateAngle: degrees(0),
548
+ gimbalRotateTimeEnable: false,
549
+ gimbalRotateTime: 10
287
550
  }
288
551
  }]
289
- }];
290
- return {
552
+ }],
553
+ nextGroupId: groupIdStart + 2
554
+ };
555
+ }
556
+ /** Build the stop group placed at the last waypoint of a shooting section. */
557
+ function shootSectionStopActions(sectionEnd, groupId) {
558
+ return {
559
+ actionGroupId: groupId,
560
+ actionGroupStartIndex: sectionEnd,
561
+ actionGroupEndIndex: sectionEnd,
562
+ actionGroupMode: "sequence",
563
+ actionTrigger: { actionTriggerType: "reachPoint" },
564
+ action: [{
565
+ actionId: 0,
566
+ actionActuatorFunc: "stopTimeLapse",
567
+ actionActuatorFuncParam: {
568
+ payloadPositionIndex: 0,
569
+ payloadLensIndex: "visable"
570
+ }
571
+ }, {
572
+ actionId: 1,
573
+ actionActuatorFunc: "gimbalAngleUnlock"
574
+ }]
575
+ };
576
+ }
577
+ /** Zero-dampingDist stop-turn (first/last waypoint of a section). */
578
+ function stopTurn() {
579
+ return {
580
+ waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
581
+ waypointTurnDampingDist: meters(0)
582
+ };
583
+ }
584
+ /** Common heading param fields present on every mapping waypoint. */
585
+ function mappingHeadingParam(headingAngle, angleEnable) {
586
+ return {
587
+ waypointHeadingMode: "followWayline",
588
+ waypointHeadingAngle: degrees(headingAngle),
589
+ waypointPoiPoint: {
590
+ lng: 0,
591
+ lat: 0,
592
+ height: meters(0)
593
+ },
594
+ waypointHeadingAngleEnable: angleEnable,
595
+ waypointHeadingPathMode: "followBadArc",
596
+ waypointHeadingPoiIndex: 0
597
+ };
598
+ }
599
+ /** Common extra fields present on every mapping waypoint per reference WPML. */
600
+ const COMMON_WP_EXTRAS = {
601
+ useStraightLine: true,
602
+ waypointGimbalHeadingParam: {
603
+ waypointGimbalPitchAngle: degrees(0),
604
+ waypointGimbalYawAngle: degrees(0)
605
+ },
606
+ isRisky: false,
607
+ waypointWorkType: 0
608
+ };
609
+ /**
610
+ * 高程优化 (elevationOptimizeEnable) excursion — appended to the nadir grid:
611
+ * fly from the last nadir waypoint to the survey centroid and take an oblique shot
612
+ * there to enrich the DSM. WITHOUT this flag the route ends at the last grid
613
+ * waypoint — it does NOT detour to the centre (that detour is elevation optimization,
614
+ * not smart-swing). Mutates `placemarks` (appends two waypoints).
615
+ */
616
+ function appendElevationExcursion(placemarks, folder, origin, enuWps, shootInterval, startGroupId) {
617
+ const lastNadirIdx = enuWps.length - 1;
618
+ const obliqueStartIdx = placemarks.length;
619
+ const obliqueEndIdx = obliqueStartIdx + 1;
620
+ const pitch = Number(folder.Placemark.quickOrthoMappingPitch ?? degrees(-45));
621
+ let nextGroupId = startGroupId;
622
+ const obliqueGimbalGroup = {
623
+ actionGroupId: nextGroupId++,
624
+ actionGroupStartIndex: obliqueStartIdx,
625
+ actionGroupEndIndex: obliqueStartIdx,
626
+ actionGroupMode: "sequence",
627
+ actionTrigger: { actionTriggerType: "reachPoint" },
628
+ action: [{
629
+ actionId: 0,
630
+ actionActuatorFunc: "gimbalRotate",
631
+ actionActuatorFuncParam: {
632
+ payloadPositionIndex: 0,
633
+ gimbalHeadingYawBase: "aircraft",
634
+ gimbalRotateMode: "absoluteAngle",
635
+ gimbalPitchRotateEnable: true,
636
+ gimbalPitchRotateAngle: degrees(pitch),
637
+ gimbalRollRotateEnable: false,
638
+ gimbalRollRotateAngle: degrees(0),
639
+ gimbalYawRotateEnable: true,
640
+ gimbalYawRotateAngle: degrees(0),
641
+ gimbalRotateTimeEnable: false,
642
+ gimbalRotateTime: 10
643
+ }
644
+ }, {
645
+ actionId: 1,
646
+ actionActuatorFunc: "hover",
647
+ actionActuatorFuncParam: { hoverTime: .5 }
648
+ }]
649
+ };
650
+ const obliqueStart = shootSectionStartActions(pitch, shootInterval, obliqueStartIdx, obliqueEndIdx, nextGroupId);
651
+ nextGroupId = obliqueStart.nextGroupId;
652
+ const obliqueStop = shootSectionStopActions(obliqueEndIdx, nextGroupId);
653
+ const lastNadirCoord = enuToLngLat(enuWps[lastNadirIdx], origin);
654
+ const sumEnu = enuWps.reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]], [0, 0]);
655
+ const centroidEnu = [sumEnu[0] / enuWps.length, sumEnu[1] / enuWps.length];
656
+ const centroidCoord = enuToLngLat(centroidEnu, origin);
657
+ const obliqueHeading = bearingDeg(enuWps[lastNadirIdx], centroidEnu);
658
+ placemarks.push({
659
+ Point: { coordinates: lastNadirCoord },
660
+ index: obliqueStartIdx,
661
+ executeHeight: folder.Placemark.height,
662
+ waypointSpeed: folder.autoFlightSpeed,
663
+ waypointHeadingParam: mappingHeadingParam(obliqueHeading, true),
664
+ waypointTurnParam: stopTurn(),
665
+ ...COMMON_WP_EXTRAS,
666
+ actionGroup: [obliqueGimbalGroup, ...obliqueStart.groups]
667
+ });
668
+ placemarks.push({
669
+ Point: { coordinates: centroidCoord },
670
+ index: obliqueEndIdx,
671
+ executeHeight: folder.Placemark.height,
672
+ waypointSpeed: folder.autoFlightSpeed,
673
+ waypointHeadingParam: mappingHeadingParam(0, false),
674
+ waypointTurnParam: stopTurn(),
675
+ ...COMMON_WP_EXTRAS,
676
+ actionGroup: [obliqueStop]
677
+ });
678
+ }
679
+ /**
680
+ * Ortho (nadir) wayline — covers 正射 and 正射+智能摆拍 (the swing is a folder-level
681
+ * quickOrthoMappingEnable flag over the same grid geometry, not extra waypoints).
682
+ *
683
+ * Action pattern (per reference WPML M4TD 正射采集):
684
+ * - WP 0: two action groups (betweenAdjacentPoints startTimeLapse-init + multipleTiming gimbalCorrect)
685
+ * - WP 1…N-2: no action groups
686
+ * - WP N-1 (last): reachPoint stopTimeLapse group
687
+ *
688
+ * When `elevationOptimizeEnable` is set, a centroid excursion is appended (see
689
+ * `appendElevationExcursion`); otherwise the route ends at the last grid waypoint.
690
+ */
691
+ function emitOrtho(folder, origin, enuWps, shootInterval) {
692
+ const lastIndex = enuWps.length - 1;
693
+ const startActions = shootSectionStartActions(-90, shootInterval, 0, lastIndex, 0);
694
+ const stopGroup = shootSectionStopActions(lastIndex, startActions.nextGroupId);
695
+ const placemarks = enuWps.map((enu, index) => {
696
+ const coord = enuToLngLat(enu, origin);
697
+ const isLast = index === lastIndex;
698
+ const headingAngle = isLast ? 0 : bearingDeg(enuWps[index], enuWps[index + 1]);
699
+ const placemark = {
291
700
  Point: { coordinates: coord },
292
701
  index,
293
- executeHeight: folder.height,
702
+ executeHeight: folder.Placemark.height,
294
703
  waypointSpeed: folder.autoFlightSpeed,
295
- waypointHeadingParam: {
296
- waypointHeadingMode: "followWayline",
297
- waypointHeadingAngle: degrees(headingAngle),
298
- waypointHeadingPathMode: "followBadArc"
704
+ waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
705
+ waypointTurnParam: index === 0 || isLast ? stopTurn() : {
706
+ waypointTurnMode: "coordinateTurn",
707
+ waypointTurnDampingDist: turnDamping(enuWps, index)
299
708
  },
300
- waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
301
- ...actionGroup.length > 0 ? { actionGroup } : {}
709
+ ...COMMON_WP_EXTRAS
302
710
  };
711
+ if (index === 0) placemark.actionGroup = startActions.groups;
712
+ else if (isLast) placemark.actionGroup = [stopGroup];
713
+ return placemark;
303
714
  });
304
- const waylineFolder = {
715
+ if (folder.Placemark.elevationOptimizeEnable) appendElevationExcursion(placemarks, folder, origin, enuWps, shootInterval, startActions.nextGroupId + 1);
716
+ return {
305
717
  templateId: folder.templateId,
306
718
  waylineId: 0,
307
719
  executeHeightMode: heightModeToExecuteMode$3(folder),
308
720
  autoFlightSpeed: folder.autoFlightSpeed,
309
- startActionGroup: {
310
- actionGroupId: 65535,
721
+ startActionGroup: buildStartActionGroup(-90),
722
+ Placemark: placemarks
723
+ };
724
+ }
725
+ /**
726
+ * Single-pass smart-oblique / quick-ortho (smartObliqueEnable=true):
727
+ * The aircraft flies one grid while the gimbal swings (startSmartOblique/stopSmartOblique).
728
+ */
729
+ function emitSmartOblique(folder, origin, enuWps) {
730
+ const lastIndex = enuWps.length - 1;
731
+ const pitch = folder.Placemark.smartObliqueGimbalPitch ?? degrees(-45);
732
+ const placemarks = enuWps.map((enu, index) => {
733
+ const coord = enuToLngLat(enu, origin);
734
+ const isLast = index === lastIndex;
735
+ const headingAngle = isLast ? 0 : bearingDeg(enuWps[index], enuWps[index + 1]);
736
+ const placemark = {
737
+ Point: { coordinates: coord },
738
+ index,
739
+ executeHeight: folder.Placemark.height,
740
+ waypointSpeed: folder.autoFlightSpeed,
741
+ waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
742
+ waypointTurnParam: index === 0 || isLast ? stopTurn() : {
743
+ waypointTurnMode: "coordinateTurn",
744
+ waypointTurnDampingDist: turnDamping(enuWps, index)
745
+ },
746
+ ...COMMON_WP_EXTRAS
747
+ };
748
+ if (index === 0) placemark.actionGroup = [{
749
+ actionGroupId: 0,
311
750
  actionGroupStartIndex: 0,
312
- actionGroupEndIndex: 0,
751
+ actionGroupEndIndex: lastIndex,
313
752
  actionGroupMode: "sequence",
314
753
  actionTrigger: { actionTriggerType: "reachPoint" },
315
754
  action: [{
316
755
  actionId: 0,
317
- actionActuatorFunc: "gimbalRotate",
318
- actionActuatorFuncParam: {
319
- payloadPositionIndex: 0,
320
- gimbalHeadingYawBase: "north",
321
- gimbalRotateMode: "absoluteAngle",
322
- gimbalPitchRotateEnable: true,
323
- gimbalPitchRotateAngle: degrees(-90),
324
- gimbalRollRotateEnable: false,
325
- gimbalRollRotateAngle: degrees(0),
326
- gimbalYawRotateEnable: true,
327
- gimbalYawRotateAngle: degrees(0),
328
- gimbalRotateTimeEnable: false,
329
- gimbalRotateTime: 10
330
- }
756
+ actionActuatorFunc: "startSmartOblique",
757
+ actionActuatorFuncParam: { payloadPositionIndex: 0 }
331
758
  }]
332
- },
759
+ }];
760
+ else if (isLast) placemark.actionGroup = [{
761
+ actionGroupId: 1,
762
+ actionGroupStartIndex: lastIndex,
763
+ actionGroupEndIndex: lastIndex,
764
+ actionGroupMode: "sequence",
765
+ actionTrigger: { actionTriggerType: "reachPoint" },
766
+ action: [{
767
+ actionId: 0,
768
+ actionActuatorFunc: "stopSmartOblique",
769
+ actionActuatorFuncParam: { payloadPositionIndex: 0 }
770
+ }]
771
+ }];
772
+ return placemark;
773
+ });
774
+ return {
775
+ templateId: folder.templateId,
776
+ waylineId: 0,
777
+ executeHeightMode: heightModeToExecuteMode$3(folder),
778
+ autoFlightSpeed: folder.autoFlightSpeed,
779
+ startActionGroup: buildStartActionGroup(Number(pitch)),
333
780
  Placemark: placemarks
334
781
  };
782
+ }
783
+ function planMapping2d(template, options) {
784
+ if (options?.lineSpacing == null) throw new PlannerError("mapping2d requires options.lineSpacing — calculate 2·h·tan(HFOV/2)·(1−sideOverlap) in the demo and pass it in.");
785
+ const standoff = template.Folder.Placemark.smartObliqueEnable === true ? smartObliqueStandoff(template.Folder) : 0;
786
+ const { folder, origin, enuWps } = buildGrid(template, Number(options.lineSpacing), standoff);
787
+ const shootInterval = options.shootInterval ?? 2;
788
+ if (folder.Placemark.smartObliqueEnable === true) return {
789
+ missionConfig: template.missionConfig,
790
+ Folder: [emitSmartOblique(folder, origin, enuWps)]
791
+ };
335
792
  return {
336
793
  missionConfig: template.missionConfig,
337
- Folder: [waylineFolder]
794
+ Folder: [emitOrtho(folder, origin, enuWps, shootInterval)]
338
795
  };
339
796
  }
340
- function heightModeToExecuteMode$3(folder) {
341
- switch (folder.waylineCoordinateSysParam.heightMode) {
342
- case "EGM96": return "WGS84";
343
- case "relativeToStartPoint": return "relativeToStartPoint";
344
- case "aboveGroundLevel":
345
- case "realTimeFollowSurface": return "realTimeFollowSurface";
346
- }
347
- }
348
797
  //#endregion
349
798
  //#region src/plan/plan-mapping3d.ts
350
- const DEFAULT_HFOV_DEG = 84;
351
- /**
352
- * Compile a `mapping3d` (oblique photography) template into 5 waylines:
353
- * one nadir (ortho) pass + four oblique passes (front/back/left/right).
354
- */
355
- function planMapping3d(template) {
799
+ const PASSES = [
800
+ {
801
+ axisOffsetDeg: 0,
802
+ headingOffsetDeg: 0,
803
+ pitch: "nadir",
804
+ headingMode: "followWayline",
805
+ halfPhase: false,
806
+ lookSign: 0,
807
+ interval: 2
808
+ },
809
+ {
810
+ axisOffsetDeg: 0,
811
+ headingOffsetDeg: 0,
812
+ pitch: "inclined",
813
+ headingMode: "fixed",
814
+ halfPhase: false,
815
+ lookSign: 1,
816
+ interval: 2.5
817
+ },
818
+ {
819
+ axisOffsetDeg: 90,
820
+ headingOffsetDeg: 90,
821
+ pitch: "inclined",
822
+ headingMode: "fixed",
823
+ halfPhase: false,
824
+ lookSign: 1,
825
+ interval: 2.5
826
+ },
827
+ {
828
+ axisOffsetDeg: 0,
829
+ headingOffsetDeg: 180,
830
+ pitch: "inclined",
831
+ headingMode: "fixed",
832
+ halfPhase: true,
833
+ lookSign: -1,
834
+ interval: 2.5
835
+ },
836
+ {
837
+ axisOffsetDeg: 90,
838
+ headingOffsetDeg: -90,
839
+ pitch: "inclined",
840
+ headingMode: "fixed",
841
+ halfPhase: true,
842
+ lookSign: -1,
843
+ interval: 2.5
844
+ }
845
+ ];
846
+ /** Normalize an angle to (−180, 180]. */
847
+ function wrap180(deg) {
848
+ return ((deg + 180) % 360 + 360) % 360 - 180;
849
+ }
850
+ function planMapping3d(template, options) {
851
+ if (options?.lineSpacing == null) throw new PlannerError("mapping3d requires options.lineSpacing — calculate 2·h·tan(HFOV/2)·(1−sideOverlap) in the demo and pass it in.");
356
852
  const folder = template.Folder;
357
853
  const ring = parseCoordinatesString(folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
358
854
  const origin = centroid(ring);
359
- const enuPolygon = ring.map((p) => lngLatToEnu(p, origin));
360
- const hfovRad = DEFAULT_HFOV_DEG / 2 * (Math.PI / 180);
361
- const enuWps = scanWaypoints(enuPolygon, 2 * folder.height * Math.tan(hfovRad) * (1 - (folder.overlap.orthoCameraOverlapW ?? 70) / 100));
362
- const Folder = [
363
- {
364
- headingAngle: 0,
365
- gimbalPitch: -90,
366
- minShootInterval: 2
367
- },
368
- {
369
- headingAngle: 0,
370
- gimbalPitch: folder.inclinedGimbalPitch,
371
- minShootInterval: 2.5
372
- },
373
- {
374
- headingAngle: 180,
375
- gimbalPitch: folder.inclinedGimbalPitch,
376
- minShootInterval: 2.5
377
- },
378
- {
379
- headingAngle: 90,
380
- gimbalPitch: folder.inclinedGimbalPitch,
381
- minShootInterval: 2.5
382
- },
383
- {
384
- headingAngle: -90,
385
- gimbalPitch: folder.inclinedGimbalPitch,
386
- minShootInterval: 2.5
387
- }
388
- ].map((pass, i) => buildObliquePass(folder, i, origin, enuWps, pass.headingAngle, pass.gimbalPitch, pass.minShootInterval));
855
+ const ringEnu = ring.map((p) => lngLatToEnu(p, origin));
856
+ const baseDirection = Number(folder.Placemark.direction);
857
+ const lineSpacing = Number(options.lineSpacing);
858
+ const inclined = folder.Placemark.inclinedGimbalPitch;
859
+ const pitchAbs = Math.abs(Number(inclined));
860
+ const sinP = pitchAbs > 0 && pitchAbs < 90 ? Math.sin(pitchAbs * Math.PI / 180) : 1;
861
+ const tanP = pitchAbs > 0 && pitchAbs < 90 ? Math.tan(pitchAbs * Math.PI / 180) : Infinity;
862
+ const standoff = Math.min(Number(folder.Placemark.height) / tanP, Number(folder.Placemark.height) * 3);
863
+ const obliqueSpacing = lineSpacing / sinP;
389
864
  return {
390
865
  missionConfig: template.missionConfig,
391
- Folder
866
+ Folder: PASSES.map((pass, i) => {
867
+ const passSpacing = pass.pitch === "inclined" ? obliqueSpacing : lineSpacing;
868
+ return buildPass(folder, i, origin, orientPathToHome(surveyGrid(ringEnu, {
869
+ directionDeg: baseDirection + pass.axisOffsetDeg,
870
+ marginM: folder.Placemark.margin,
871
+ lineSpacing: passSpacing,
872
+ phaseOffsetM: pass.halfPhase ? passSpacing / 2 : 0,
873
+ lengthwiseShiftM: -pass.lookSign * standoff
874
+ }), origin, template.missionConfig.takeOffRefPoint), {
875
+ headingMode: pass.headingMode,
876
+ headingAngle: degrees(wrap180(baseDirection + pass.headingOffsetDeg)),
877
+ gimbalPitch: pass.pitch === "nadir" ? degrees(-90) : inclined,
878
+ interval: pass.interval
879
+ });
880
+ })
392
881
  };
393
882
  }
394
- function buildObliquePass(folder, waylineId, origin, enuWps, headingAngle, gimbalPitch, minShootInterval) {
883
+ function buildPass(folder, waylineId, origin, enuWps, cfg) {
395
884
  const lastIndex = enuWps.length - 1;
396
885
  const placemarks = enuWps.map((enu, index) => {
397
886
  const placemark = {
398
887
  Point: { coordinates: enuToLngLat(enu, origin) },
399
888
  index,
400
- executeHeight: folder.height,
401
- waypointSpeed: folder.autoFlightSpeed,
889
+ executeHeight: folder.Placemark.height,
890
+ waypointSpeed: folder.Placemark.inclinedFlightSpeed,
402
891
  waypointHeadingParam: {
403
- waypointHeadingMode: "fixed",
404
- waypointHeadingAngle: degrees(headingAngle),
892
+ waypointHeadingMode: cfg.headingMode,
893
+ waypointHeadingAngle: cfg.headingAngle,
405
894
  waypointHeadingPathMode: "followBadArc"
406
895
  },
407
896
  waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" }
408
897
  };
409
- const groups = waypointActionGroups(index, lastIndex, gimbalPitch, minShootInterval);
410
- if (groups.length > 0) placemark.actionGroup = groups;
898
+ if (index === 0) placemark.actionGroup = [{
899
+ actionGroupId: 0,
900
+ actionGroupStartIndex: 0,
901
+ actionGroupEndIndex: lastIndex,
902
+ actionGroupMode: "sequence",
903
+ actionTrigger: { actionTriggerType: "reachPoint" },
904
+ action: [gimbalRotate(0, cfg.gimbalPitch)]
905
+ }, {
906
+ actionGroupId: 1,
907
+ actionGroupStartIndex: 0,
908
+ actionGroupEndIndex: lastIndex,
909
+ actionGroupMode: "sequence",
910
+ actionTrigger: {
911
+ actionTriggerType: "multipleTiming",
912
+ actionTriggerParam: cfg.interval
913
+ },
914
+ action: [takePhoto(0)]
915
+ }];
411
916
  return placemark;
412
917
  });
413
918
  return {
414
919
  templateId: folder.templateId,
415
920
  waylineId,
416
921
  executeHeightMode: heightModeToExecuteMode$2(folder.waylineCoordinateSysParam.heightMode),
417
- autoFlightSpeed: folder.autoFlightSpeed,
418
- startActionGroup: startGimbalRotateGroup(gimbalPitch),
922
+ autoFlightSpeed: folder.Placemark.inclinedFlightSpeed,
923
+ startActionGroup: { action: [gimbalRotate(0, cfg.gimbalPitch)] },
419
924
  Placemark: placemarks
420
925
  };
421
926
  }
422
- function waypointActionGroups(index, lastIndex, gimbalPitch, minShootInterval) {
423
- if (index === 0) return [{
424
- actionGroupId: 0,
425
- actionGroupStartIndex: 0,
426
- actionGroupEndIndex: lastIndex,
427
- actionGroupMode: "sequence",
428
- actionTrigger: { actionTriggerType: "reachPoint" },
429
- action: [gimbalRotate(0, gimbalPitch)]
430
- }, {
431
- actionGroupId: 1,
432
- actionGroupStartIndex: 0,
433
- actionGroupEndIndex: lastIndex,
434
- actionGroupMode: "sequence",
435
- actionTrigger: {
436
- actionTriggerType: "multipleTiming",
437
- actionTriggerParam: minShootInterval
438
- },
439
- action: [takePhoto(0)]
440
- }];
441
- return [];
442
- }
443
- function startGimbalRotateGroup(gimbalPitch) {
444
- return {
445
- actionGroupId: 65535,
446
- actionGroupStartIndex: 0,
447
- actionGroupEndIndex: 0,
448
- actionGroupMode: "sequence",
449
- actionTrigger: { actionTriggerType: "reachPoint" },
450
- action: [gimbalRotate(0, gimbalPitch)]
451
- };
452
- }
927
+ /** Oblique gimbal: pitched down relative to the aircraft nose (yaw base = aircraft). */
453
928
  function gimbalRotate(actionId, pitch) {
454
929
  return {
455
930
  actionId,
456
931
  actionActuatorFunc: "gimbalRotate",
457
932
  actionActuatorFuncParam: {
458
933
  payloadPositionIndex: 0,
459
- gimbalHeadingYawBase: "north",
934
+ gimbalHeadingYawBase: "aircraft",
460
935
  gimbalRotateMode: "absoluteAngle",
461
936
  gimbalPitchRotateEnable: true,
462
- gimbalPitchRotateAngle: degrees(pitch),
937
+ gimbalPitchRotateAngle: pitch,
463
938
  gimbalRollRotateEnable: false,
464
939
  gimbalRollRotateAngle: degrees(0),
465
940
  gimbalYawRotateEnable: false,
@@ -510,7 +985,7 @@ function planMappingStrip(template) {
510
985
  }, origin));
511
986
  const placemarks = coords.map((c, index) => {
512
987
  const headingAngle = index === coords.length - 1 ? 0 : bearingDeg(enuPath[index], enuPath[index + 1]);
513
- const executeHeight = folder.stripUseTemplateAltitude && c.alt !== void 0 ? c.alt : folder.height;
988
+ const executeHeight = folder.Placemark.stripUseTemplateAltitude && c.alt !== void 0 ? c.alt : folder.Placemark.height;
514
989
  return {
515
990
  Point: { coordinates: {
516
991
  lng: c.lng,
@@ -616,11 +1091,13 @@ const isMappingStrip = (t) => t.Folder.templateType === "mappingStrip";
616
1091
  /**
617
1092
  * Compile a high-level `Template` into executable `Waylines`.
618
1093
  * Dispatch is by `template.Folder.templateType` per the DJI WPML spec.
1094
+ *
1095
+ * Mapping templates require `options.lineSpacing` (meters between flight lines).
619
1096
  */
620
- function plan(template) {
1097
+ function plan(template, options) {
621
1098
  if (isWaypoint(template)) return planWaypoint(template);
622
- if (isMapping2d(template)) return planMapping2d(template);
623
- if (isMapping3d(template)) return planMapping3d(template);
1099
+ if (isMapping2d(template)) return planMapping2d(template, options);
1100
+ if (isMapping3d(template)) return planMapping3d(template, options);
624
1101
  if (isMappingStrip(template)) return planMappingStrip(template);
625
1102
  throw new PlannerError(`Unknown templateType: ${template.Folder.templateType}`);
626
1103
  }
@@ -776,4 +1253,4 @@ function validate(doc) {
776
1253
  return issues;
777
1254
  }
778
1255
  //#endregion
779
- export { DroneEnum, KmzError, PayloadEnum, PlannerError, degrees, meters, metersPerSecond, plan, validate };
1256
+ export { DroneEnum, KmzError, PayloadEnum, PlannerError, degrees, haversineMeters, meters, metersPerSecond, plan, validate };