@lumikmz/kmz 0.9.1 → 0.11.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.d.mts CHANGED
@@ -36,7 +36,7 @@ type FlyToWaylineMode = "safely" | "pointToPoint";
36
36
  type FinishAction = "goHome" | "noAction" | "autoLand" | "gotoFirstWaypoint";
37
37
  type ExitOnRCLost = "goContinue" | "executeLostAction";
38
38
  type ExecuteRCLostAction = "goBack" | "landing" | "hover";
39
- type TemplateType = "waypoint" | "mapping2d" | "mapping3d" | "mappingStrip";
39
+ type TemplateType = "waypoint" | "mapping2d" | "mapping3d" | "mappingStrip" | "targetdetection";
40
40
  type CoordinateMode = "WGS84";
41
41
  /** Used in template.kml `waylineCoordinateSysParam.heightMode`. */
42
42
  type TemplateHeightMode = "EGM96" | "relativeToStartPoint" | "aboveGroundLevel" | "realTimeFollowSurface";
@@ -58,8 +58,8 @@ type MeteringMode = "average" | "spot";
58
58
  type LidarReturnMode = "singleReturnStrongest" | "dualReturn" | "tripleReturn";
59
59
  type LidarScanningMode = "repetitive" | "nonRepetitive";
60
60
  type ActionGroupMode = "sequence";
61
- type ActionTriggerType = "reachPoint" | "betweenAdjacentPoints" | "multipleTiming" | "multipleDistance";
62
- type ActionActuatorFunc = "takePhoto" | "startRecord" | "stopRecord" | "focus" | "zoom" | "customDirName" | "gimbalRotate" | "rotateYaw" | "hover" | "gimbalEvenlyRotate" | "accurateShoot" | "orientedShoot" | "panoShot" | "recordPointCloud" | "megaphone" | "searchlight" /** ⚠️ NON-SPEC — observed in real DJI KMZ exports, absent from WPML 1.15 docs. */ | "startSmartOblique" /** ⚠️ NON-SPEC — observed in real DJI KMZ exports, absent from WPML 1.15 docs. */ | "stopSmartOblique" /** Starts time-lapse (interval) shooting for a mapping strip. */ | "startTimeLapse" /** Stops time-lapse shooting. */ | "stopTimeLapse" /** Locks gimbal angle during a mapping strip. */ | "gimbalAngleLock" /** Unlocks gimbal angle (no params in XML). */ | "gimbalAngleUnlock" /** Sets camera focus mode (manual / auto). */ | "setFocusType";
61
+ type ActionTriggerType = "reachPoint" | "betweenAdjacentPoints" | "betweenAdjacentPointsIncludeFirstPoint" | "multipleTiming" | "multipleDistance";
62
+ type ActionActuatorFunc = "targetDetection" | "takePhoto" | "startRecord" | "stopRecord" | "focus" | "zoom" | "customDirName" | "gimbalRotate" | "rotateYaw" | "hover" | "gimbalEvenlyRotate" | "accurateShoot" | "orientedShoot" | "panoShot" | "recordPointCloud" | "megaphone" | "searchlight" /** ⚠️ NON-SPEC — observed in real DJI KMZ exports, absent from WPML 1.15 docs. */ | "startSmartOblique" /** ⚠️ NON-SPEC — observed in real DJI KMZ exports, absent from WPML 1.15 docs. */ | "stopSmartOblique" /** Starts time-lapse (interval) shooting for a mapping strip. */ | "startTimeLapse" /** Stops time-lapse shooting. */ | "stopTimeLapse" /** Locks gimbal angle during a mapping strip. */ | "gimbalAngleLock" /** Unlocks gimbal angle (no params in XML). */ | "gimbalAngleUnlock" /** Sets camera focus mode (manual / auto). */ | "setFocusType";
63
63
  type GimbalHeadingYawBase = "north" | "aircraft";
64
64
  type GimbalRotateMode = "absoluteAngle";
65
65
  type AircraftPathMode = "clockwise" | "counterClockwise";
@@ -81,6 +81,8 @@ interface PayloadInfo {
81
81
  */
82
82
  payloadSubEnumValue?: number;
83
83
  payloadPositionIndex: number;
84
+ /** FlightHub 2 patrol-camera selection, in 35mm-equivalent millimeters. */
85
+ equivalentFocalLength?: number;
84
86
  }
85
87
  /** Aircraft `{ droneEnumValue, droneSubEnumValue }` tuples. */
86
88
  declare const DroneEnum: {
@@ -264,6 +266,8 @@ interface MissionConfig {
264
266
  globalRTHHeight: Meters;
265
267
  droneInfo: DroneInfo;
266
268
  payloadInfo: PayloadInfo[];
269
+ /** FlightHub 2 custom restricted-area avoidance switch. */
270
+ waylineAvoidLimitAreaMode?: boolean;
267
271
  /** Only M3D/M3TD, M4D/M4TD, M4E/M4T. */
268
272
  autoRerouteInfo?: AutoRerouteInfo;
269
273
  }
@@ -397,12 +401,36 @@ interface ActionTrigger {
397
401
  actionTriggerParam?: number;
398
402
  }
399
403
  /** Discriminated by `actionActuatorFunc`. */
400
- type Action = TakePhotoAction | StartRecordAction | StopRecordAction | FocusAction | ZoomAction | CustomDirNameAction | GimbalRotateAction | RotateYawAction | HoverAction | GimbalEvenlyRotateAction | AccurateShootAction | OrientedShootAction | PanoShotAction | RecordPointCloudAction | MegaphoneAction | SearchlightAction | StartSmartObliqueAction | StopSmartObliqueAction | StartTimeLapseAction | StopTimeLapseAction | GimbalAngleLockAction | GimbalAngleUnlockAction | SetFocusTypeAction;
404
+ type Action = TargetDetectionAction | TakePhotoAction | StartRecordAction | StopRecordAction | FocusAction | ZoomAction | CustomDirNameAction | GimbalRotateAction | RotateYawAction | HoverAction | GimbalEvenlyRotateAction | AccurateShootAction | OrientedShootAction | PanoShotAction | RecordPointCloudAction | MegaphoneAction | SearchlightAction | StartSmartObliqueAction | StopSmartObliqueAction | StartTimeLapseAction | StopTimeLapseAction | GimbalAngleLockAction | GimbalAngleUnlockAction | SetFocusTypeAction;
401
405
  interface ActionBase<F extends string, P> {
402
406
  actionId: number;
403
407
  actionActuatorFunc: F;
404
408
  actionActuatorFuncParam: P;
405
409
  }
410
+ interface TargetDetectionParam {
411
+ focalLength?: number;
412
+ payloadPositionIndex: number;
413
+ useGlobalPayloadLensIndex: boolean;
414
+ payloadLensIndex: LensType[];
415
+ actionUUID: string;
416
+ irGainMode?: string;
417
+ irColorPalette?: string;
418
+ customTitle: string;
419
+ customText: string;
420
+ modelIndex: number;
421
+ modelScore: number;
422
+ alarmInterval: number;
423
+ useThirdModel: boolean;
424
+ algorithmType: string;
425
+ targetParam: {
426
+ maxThreshold: number;
427
+ minThreshold: number;
428
+ useMaxThreshold: boolean;
429
+ useMinThreshold: boolean;
430
+ targetType: string;
431
+ };
432
+ }
433
+ type TargetDetectionAction = ActionBase<"targetDetection", TargetDetectionParam>;
406
434
  interface TakePhotoParam {
407
435
  payloadPositionIndex: number;
408
436
  fileSuffix?: string;
@@ -749,6 +777,22 @@ interface Mapping3dPlacemark {
749
777
  ellipsoidHeight: Meters;
750
778
  height: Meters;
751
779
  }
780
+ /** `Placemark` inside a FlightHub 2 patrol-area `targetdetection` Folder. */
781
+ interface TargetDetectionPlacemark {
782
+ direction: Degrees;
783
+ margin: number;
784
+ gimbalPitchMode: "fixed";
785
+ overlap: Overlap;
786
+ Polygon: TemplatePolygon;
787
+ ellipsoidHeight: Meters;
788
+ height: Meters;
789
+ mappingHeadingParam: MappingHeadingParam;
790
+ gimbalPitchAngle: Degrees;
791
+ recordEnable: boolean;
792
+ /** FlightHub 2 always emits this action for patrol-area routes. */
793
+ targetDetectionActionEnable?: boolean;
794
+ action?: TargetDetectionAction;
795
+ }
752
796
  /** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
753
797
  interface MappingStripPlacemark {
754
798
  caliFlightEnable: boolean;
@@ -810,7 +854,7 @@ interface WaylinesPlacemark {
810
854
  * `template.kml` document. Mirrors `<kml><Document>` structure.
811
855
  * See `docs/kmz/template-kml.html`.
812
856
  */
813
- type Template = WaypointTemplate | Mapping2dTemplate | Mapping3dTemplate | MappingStripTemplate;
857
+ type Template = WaypointTemplate | Mapping2dTemplate | Mapping3dTemplate | MappingStripTemplate | TargetDetectionTemplate;
814
858
  interface TemplateDocumentBase {
815
859
  /** Unix milliseconds. */
816
860
  author?: string;
@@ -870,6 +914,17 @@ interface Mapping3dFolder {
870
914
  /** Holds the survey polygon AND all shooting/mapping params. */
871
915
  Placemark: Mapping3dPlacemark;
872
916
  }
917
+ interface TargetDetectionTemplate extends TemplateDocumentBase {
918
+ Folder: TargetDetectionFolder;
919
+ }
920
+ interface TargetDetectionFolder {
921
+ templateType: "targetdetection";
922
+ templateId: number;
923
+ waylineCoordinateSysParam: WaylineCoordinateSysParam;
924
+ autoFlightSpeed: MetersPerSecond;
925
+ payloadParam?: PayloadParam;
926
+ Placemark: TargetDetectionPlacemark;
927
+ }
873
928
  interface MappingStripTemplate extends TemplateDocumentBase {
874
929
  Folder: MappingStripFolder;
875
930
  }
@@ -940,6 +995,8 @@ interface PlanOptions {
940
995
  stripDirection?: "parallel" | "perpendicular";
941
996
  /** Reverse mappingStrip region execution without changing region identities. */
942
997
  regionOrderReversed?: boolean;
998
+ /** Preferred entry location for path variants that can be reversed as a whole. */
999
+ preferredStartPoint?: LngLat;
943
1000
  resolveExecuteHeight?: (point: {
944
1001
  lng: number;
945
1002
  lat: number;
@@ -1003,4 +1060,4 @@ declare function validate(doc: Template | Waylines): Issue[];
1003
1060
  /** Great-circle distance between two WGS-84 coordinates, in meters. */
1004
1061
  declare function haversineMeters(a: LngLat, b: LngLat): number;
1005
1062
  //#endregion
1006
- export { AccurateShootAction, AccurateShootParam, Action, ActionActuatorFunc, ActionGroup, ActionGroupMode, ActionTrigger, ActionTriggerType, AircraftPathMode, AutoRerouteInfo, CoordinateMode, CustomDirNameAction, CustomDirNameParam, Degrees, DroneEnum, DroneInfo, ExecuteHeightMode, ExecuteRCLostAction, ExitOnRCLost, FinishAction, FlyToWaylineMode, FocusAction, FocusMode, FocusParam, GimbalAngleLockAction, GimbalAngleLockParam, GimbalAngleUnlockAction, GimbalEvenlyRotateAction, GimbalEvenlyRotateParam, GimbalHeadingYawBase, GimbalPitchMode, GimbalRotateAction, GimbalRotateMode, GimbalRotateParam, HoverAction, HoverParam, Issue, KmzError, LensType, LidarReturnMode, LidarScanningMode, LngLat, LngLatHeight, Mapping2dFolder, Mapping2dPlacemark, Mapping2dTemplate, Mapping3dFolder, Mapping3dPlacemark, Mapping3dTemplate, MappingHeadingMode, MappingHeadingParam, type MappingStripDerived, MappingStripFolder, MappingStripPlacemark, type MappingStripRegion, type MappingStripRegionRoute, MappingStripTemplate, MegaphoneAction, MegaphoneParam, MegaphoneTtsParam, MeteringMode, Meters, MetersPerSecond, MissionConfig, OrientedPhotoMode, OrientedShootAction, OrientedShootParam, Overlap, PanoShotAction, PanoShotParam, PanoShotSubMode, PayloadEnum, PayloadInfo, PayloadParam, type PlanOptions, PlannerError, PositioningType, RecordPointCloudAction, RecordPointCloudOperate, RecordPointCloudParam, RotateYawAction, RotateYawParam, SearchlightAction, SearchlightParam, SetFocusTypeAction, SetFocusTypeParam, ShootType, SmartObliquePoint, StartActionGroup, StartRecordAction, StartRecordParam, StartSmartObliqueAction, StartSmartObliqueParam, StartTimeLapseAction, StartTimeLapseParam, StopRecordAction, StopRecordParam, StopSmartObliqueAction, StopSmartObliqueParam, StopTimeLapseAction, StopTimeLapseParam, TakePhotoAction, TakePhotoParam, Template, TemplateHeightMode, TemplatePolygon, TemplateType, TemplateWaypointPlacemark, WaylineCoordinateSysParam, Waylines, WaylinesFolder, WaylinesPlacemark, WaypointFolder, WaypointHeadingMode, WaypointHeadingParam, WaypointHeadingPathMode, WaypointTemplate, WaypointTurnMode, WaypointTurnParam, ZoomAction, ZoomParam, degrees, deriveMappingStrip, haversineMeters, meters, metersPerSecond, plan, validate };
1063
+ export { AccurateShootAction, AccurateShootParam, Action, ActionActuatorFunc, ActionGroup, ActionGroupMode, ActionTrigger, ActionTriggerType, AircraftPathMode, AutoRerouteInfo, CoordinateMode, CustomDirNameAction, CustomDirNameParam, Degrees, DroneEnum, DroneInfo, ExecuteHeightMode, ExecuteRCLostAction, ExitOnRCLost, FinishAction, FlyToWaylineMode, FocusAction, FocusMode, FocusParam, GimbalAngleLockAction, GimbalAngleLockParam, GimbalAngleUnlockAction, GimbalEvenlyRotateAction, GimbalEvenlyRotateParam, GimbalHeadingYawBase, GimbalPitchMode, GimbalRotateAction, GimbalRotateMode, GimbalRotateParam, HoverAction, HoverParam, Issue, KmzError, LensType, LidarReturnMode, LidarScanningMode, LngLat, LngLatHeight, Mapping2dFolder, Mapping2dPlacemark, Mapping2dTemplate, Mapping3dFolder, Mapping3dPlacemark, Mapping3dTemplate, MappingHeadingMode, MappingHeadingParam, type MappingStripDerived, MappingStripFolder, MappingStripPlacemark, type MappingStripRegion, type MappingStripRegionRoute, MappingStripTemplate, MegaphoneAction, MegaphoneParam, MegaphoneTtsParam, MeteringMode, Meters, MetersPerSecond, MissionConfig, OrientedPhotoMode, OrientedShootAction, OrientedShootParam, Overlap, PanoShotAction, PanoShotParam, PanoShotSubMode, PayloadEnum, PayloadInfo, PayloadParam, type PlanOptions, PlannerError, PositioningType, RecordPointCloudAction, RecordPointCloudOperate, RecordPointCloudParam, RotateYawAction, RotateYawParam, SearchlightAction, SearchlightParam, SetFocusTypeAction, SetFocusTypeParam, ShootType, SmartObliquePoint, StartActionGroup, StartRecordAction, StartRecordParam, StartSmartObliqueAction, StartSmartObliqueParam, StartTimeLapseAction, StartTimeLapseParam, StopRecordAction, StopRecordParam, StopSmartObliqueAction, StopSmartObliqueParam, StopTimeLapseAction, StopTimeLapseParam, TakePhotoAction, TakePhotoParam, TargetDetectionAction, TargetDetectionFolder, TargetDetectionParam, TargetDetectionPlacemark, TargetDetectionTemplate, Template, TemplateHeightMode, TemplatePolygon, TemplateType, TemplateWaypointPlacemark, WaylineCoordinateSysParam, Waylines, WaylinesFolder, WaylinesPlacemark, WaypointFolder, WaypointHeadingMode, WaypointHeadingParam, WaypointHeadingPathMode, WaypointTemplate, WaypointTurnMode, WaypointTurnParam, ZoomAction, ZoomParam, degrees, deriveMappingStrip, haversineMeters, meters, metersPerSecond, plan, validate };
package/dist/index.mjs CHANGED
@@ -182,22 +182,22 @@ const PayloadEnum = {
182
182
  //#endregion
183
183
  //#region src/plan/geometry.ts
184
184
  const EARTH_RADIUS = 6371e3;
185
- const DEG2RAD = Math.PI / 180;
186
- const RAD2DEG = 180 / Math.PI;
185
+ const DEG2RAD$1 = Math.PI / 180;
186
+ const RAD2DEG$1 = 180 / Math.PI;
187
187
  const ANGLE_EPS_RAD = 1e-12;
188
188
  /** Project lng/lat to local ENU meters around `origin`. */
189
189
  function lngLatToEnu(point, origin) {
190
- const dLat = (point.lat - origin.lat) * DEG2RAD;
191
- const dLng = (point.lng - origin.lng) * DEG2RAD;
192
- const avgLat = (point.lat + origin.lat) / 2 * DEG2RAD;
190
+ const dLat = (point.lat - origin.lat) * DEG2RAD$1;
191
+ const dLng = (point.lng - origin.lng) * DEG2RAD$1;
192
+ const avgLat = (point.lat + origin.lat) / 2 * DEG2RAD$1;
193
193
  return [dLng * EARTH_RADIUS * Math.cos(avgLat), dLat * EARTH_RADIUS];
194
194
  }
195
195
  /** Inverse of `lngLatToEnu`. */
196
196
  function enuToLngLat([x, y], origin) {
197
- const avgLat = origin.lat * DEG2RAD;
197
+ const avgLat = origin.lat * DEG2RAD$1;
198
198
  return {
199
- lng: origin.lng + x / (EARTH_RADIUS * Math.cos(avgLat)) * RAD2DEG,
200
- lat: origin.lat + y / EARTH_RADIUS * RAD2DEG
199
+ lng: origin.lng + x / (EARTH_RADIUS * Math.cos(avgLat)) * RAD2DEG$1,
200
+ lat: origin.lat + y / EARTH_RADIUS * RAD2DEG$1
201
201
  };
202
202
  }
203
203
  function centroid(points) {
@@ -217,7 +217,7 @@ function centroid(points) {
217
217
  * ENU axes: x=east, y=north.
218
218
  */
219
219
  function bearingDeg(from, to) {
220
- return Math.atan2(to[0] - from[0], to[1] - from[1]) * RAD2DEG;
220
+ return Math.atan2(to[0] - from[0], to[1] - from[1]) * RAD2DEG$1;
221
221
  }
222
222
  /**
223
223
  * Orient an ordered survey path so its first point is the end nearest `home`.
@@ -233,10 +233,10 @@ function orientPathToHome(enuWps, origin, home) {
233
233
  }
234
234
  /** Great-circle distance between two WGS-84 coordinates, in meters. */
235
235
  function haversineMeters(a, b) {
236
- const dLat = (b.lat - a.lat) * DEG2RAD;
237
- const dLng = (b.lng - a.lng) * DEG2RAD;
238
- const lat1 = a.lat * DEG2RAD;
239
- const lat2 = b.lat * DEG2RAD;
236
+ const dLat = (b.lat - a.lat) * DEG2RAD$1;
237
+ const dLng = (b.lng - a.lng) * DEG2RAD$1;
238
+ const lat1 = a.lat * DEG2RAD$1;
239
+ const lat2 = b.lat * DEG2RAD$1;
240
240
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
241
241
  return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
242
242
  }
@@ -367,7 +367,7 @@ function stabilizeTurnCorridors(segments) {
367
367
  */
368
368
  function surveyGrid(ring, opts) {
369
369
  const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
370
- const t = directionDeg * DEG2RAD;
370
+ const t = directionDeg * DEG2RAD$1;
371
371
  const rawAlpha = Math.atan2(Math.cos(t), Math.sin(t));
372
372
  const alpha = Math.abs(rawAlpha) < ANGLE_EPS_RAD ? 0 : rawAlpha;
373
373
  const scanRing = ring.map((p) => rotateEnu(p, -alpha));
@@ -461,7 +461,7 @@ function enuSegLen(a, b) {
461
461
  * Clamping each side to MAX_DAMPING_FRAC × its shorter adjacent segment
462
462
  * guarantees the sum is at most 2 × MAX_DAMPING_FRAC < 1 of that segment.
463
463
  */
464
- function turnDamping$1(enuWps, i) {
464
+ function turnDamping$2(enuWps, i) {
465
465
  const dPrev = enuSegLen(enuWps[i - 1], enuWps[i]);
466
466
  const dNext = enuSegLen(enuWps[i], enuWps[i + 1]);
467
467
  return meters(Math.min(DESIRED_TURN_DAMPING_M, dPrev * MAX_DAMPING_FRAC, dNext * MAX_DAMPING_FRAC));
@@ -744,7 +744,7 @@ function emitOrtho(folder, origin, enuWps, shootInterval, options) {
744
744
  waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
745
745
  waypointTurnParam: index === 0 || isLast ? stopTurn() : {
746
746
  waypointTurnMode: "coordinateTurn",
747
- waypointTurnDampingDist: turnDamping$1(enuWps, index)
747
+ waypointTurnDampingDist: turnDamping$2(enuWps, index)
748
748
  },
749
749
  ...COMMON_WP_EXTRAS
750
750
  };
@@ -783,7 +783,7 @@ function emitSmartOblique(folder, origin, enuWps, shootInterval, options) {
783
783
  waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
784
784
  waypointTurnParam: index === 0 || isLast ? stopTurn() : {
785
785
  waypointTurnMode: "coordinateTurn",
786
- waypointTurnDampingDist: turnDamping$1(enuWps, index)
786
+ waypointTurnDampingDist: turnDamping$2(enuWps, index)
787
787
  },
788
788
  ...COMMON_WP_EXTRAS
789
789
  };
@@ -1507,7 +1507,7 @@ function heightModeToExecuteMode$1(folder) {
1507
1507
  case "realTimeFollowSurface": return "realTimeFollowSurface";
1508
1508
  }
1509
1509
  }
1510
- function turnDamping(points, index) {
1510
+ function turnDamping$1(points, index) {
1511
1511
  const previous = haversineMeters(points[index - 1], points[index]);
1512
1512
  const next = haversineMeters(points[index], points[index + 1]);
1513
1513
  return meters(Math.min(TURN_DAMPING_M, previous * .45, next * .45));
@@ -1755,7 +1755,7 @@ function planMappingStrip(template, options) {
1755
1755
  waypointTurnDampingDist: meters(0)
1756
1756
  } : {
1757
1757
  waypointTurnMode: "coordinateTurn",
1758
- waypointTurnDampingDist: turnDamping(points, index)
1758
+ waypointTurnDampingDist: turnDamping$1(points, index)
1759
1759
  },
1760
1760
  useStraightLine: true,
1761
1761
  isRisky: false,
@@ -1826,11 +1826,175 @@ function heightModeToExecuteMode(mode) {
1826
1826
  }
1827
1827
  }
1828
1828
  //#endregion
1829
+ //#region src/plan/plan-target-detection.ts
1830
+ const EARTH_RADIUS_M = 6378137;
1831
+ const DEG2RAD = Math.PI / 180;
1832
+ const RAD2DEG = 180 / Math.PI;
1833
+ function project({ lng, lat }) {
1834
+ return [EARTH_RADIUS_M * lng * DEG2RAD, EARTH_RADIUS_M * Math.log(Math.tan(Math.PI / 4 + lat * DEG2RAD / 2))];
1835
+ }
1836
+ function unproject([x, y]) {
1837
+ return {
1838
+ lng: x / EARTH_RADIUS_M * RAD2DEG,
1839
+ lat: (2 * Math.atan(Math.exp(y / EARTH_RADIUS_M)) - Math.PI / 2) * RAD2DEG
1840
+ };
1841
+ }
1842
+ function executeHeightMode(folder) {
1843
+ switch (folder.waylineCoordinateSysParam.heightMode) {
1844
+ case "EGM96": return "WGS84";
1845
+ case "relativeToStartPoint": return "relativeToStartPoint";
1846
+ case "aboveGroundLevel":
1847
+ case "realTimeFollowSurface": return "realTimeFollowSurface";
1848
+ }
1849
+ }
1850
+ function gimbalAction(pitch) {
1851
+ return {
1852
+ actionId: 0,
1853
+ actionActuatorFunc: "gimbalRotate",
1854
+ actionActuatorFuncParam: {
1855
+ payloadPositionIndex: 0,
1856
+ gimbalHeadingYawBase: "aircraft",
1857
+ gimbalRotateMode: "absoluteAngle",
1858
+ gimbalPitchRotateEnable: true,
1859
+ gimbalPitchRotateAngle: degrees(pitch),
1860
+ gimbalRollRotateEnable: false,
1861
+ gimbalRollRotateAngle: degrees(0),
1862
+ gimbalYawRotateEnable: false,
1863
+ gimbalYawRotateAngle: degrees(0),
1864
+ gimbalRotateTimeEnable: false,
1865
+ gimbalRotateTime: 10
1866
+ }
1867
+ };
1868
+ }
1869
+ function startRecordAction(payloadPositionIndex, actionId) {
1870
+ return {
1871
+ actionId,
1872
+ actionActuatorFunc: "startRecord",
1873
+ actionActuatorFuncParam: {
1874
+ payloadPositionIndex,
1875
+ useGlobalPayloadLensIndex: true,
1876
+ payloadLensIndex: ["visable"]
1877
+ }
1878
+ };
1879
+ }
1880
+ function pathPoints(template, lineSpacingM, preferredStartPoint) {
1881
+ const placemark = template.Folder.Placemark;
1882
+ const ring = parseCoordinatesString(placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
1883
+ if (ring.length < 3) throw new PlannerError("targetdetection requires at least 3 polygon vertices");
1884
+ const centerLat = ring.reduce((sum, point) => sum + point.lat, 0) / ring.length;
1885
+ const scale = 1 / Math.cos(centerLat * DEG2RAD);
1886
+ const theta = Number(placemark.direction) * DEG2RAD;
1887
+ const u = [Math.sin(theta), Math.cos(theta)];
1888
+ const n = [-Math.cos(theta), Math.sin(theta)];
1889
+ const height = Number(placemark.height);
1890
+ const pitch = Math.abs(Number(placemark.gimbalPitchAngle));
1891
+ const lookAheadM = pitch >= 89.999999 ? 0 : height / Math.tan(pitch * DEG2RAD);
1892
+ const scanRing = offsetPolygon(ring.map((point) => {
1893
+ const [x, y] = project(point);
1894
+ return [x - u[0] * lookAheadM * scale, y - u[1] * lookAheadM * scale];
1895
+ }), placemark.margin * scale).map(([x, y]) => [x * u[0] + y * u[1], x * n[0] + y * n[1]]);
1896
+ const cross = scanRing.map((point) => point[1]);
1897
+ const min = Math.min(...cross);
1898
+ const max = Math.max(...cross);
1899
+ const spacing = lineSpacingM * scale;
1900
+ const stripCount = Math.max(1, Math.floor((max - min) / spacing));
1901
+ const center = (min + max) / 2;
1902
+ const segments = [];
1903
+ for (let i = 0; i < stripCount; i++) {
1904
+ const q = center + (i - (stripCount - 1) / 2) * spacing;
1905
+ for (const [a, b] of clipHorizontal(q, scanRing)) segments.push([[u[0] * a + n[0] * q, u[1] * a + n[1] * q], [u[0] * b + n[0] * q, u[1] * b + n[1] * q]]);
1906
+ }
1907
+ let points = segments.flatMap(([a, b], index) => index % 2 === 0 ? [a, b] : [b, a]);
1908
+ if (preferredStartPoint && points.length > 1) {
1909
+ const preferred = project(preferredStartPoint);
1910
+ const distance2 = ([x, y]) => (x - preferred[0]) ** 2 + (y - preferred[1]) ** 2;
1911
+ if (distance2(points.at(-1)) < distance2(points[0])) points = points.reverse();
1912
+ }
1913
+ return points.map(unproject);
1914
+ }
1915
+ function turnDamping(points, index) {
1916
+ if (index === 0 || index === points.length - 1) return meters(0);
1917
+ const groundDistance = (a, b) => {
1918
+ const [ax, ay] = project(a);
1919
+ const [bx, by] = project(b);
1920
+ return Math.hypot(bx - ax, by - ay) * Math.cos((a.lat + b.lat) / 2 * DEG2RAD);
1921
+ };
1922
+ return meters(Math.min(10, groundDistance(points[index - 1], points[index]) / 3, groundDistance(points[index], points[index + 1]) / 3));
1923
+ }
1924
+ function planTargetDetection(template, options) {
1925
+ const spacing = Number(options?.lineSpacing);
1926
+ if (!(spacing > 0 && Number.isFinite(spacing))) throw new PlannerError("targetdetection requires options.lineSpacing");
1927
+ const folder = template.Folder;
1928
+ const pitch = Number(folder.Placemark.gimbalPitchAngle);
1929
+ const direction = Number(folder.Placemark.direction);
1930
+ const points = pathPoints(template, spacing, options?.preferredStartPoint);
1931
+ if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
1932
+ const lastIndex = points.length - 1;
1933
+ const action = gimbalAction(pitch);
1934
+ const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
1935
+ const routeActions = [action];
1936
+ if (folder.Placemark.recordEnable) routeActions.push(startRecordAction(payloadPositionIndex, routeActions.length));
1937
+ if (folder.Placemark.action) routeActions.push({
1938
+ ...folder.Placemark.action,
1939
+ actionId: routeActions.length
1940
+ });
1941
+ const placemarks = points.map((point, index) => ({
1942
+ Point: { coordinates: point },
1943
+ index,
1944
+ executeHeight: folder.waylineCoordinateSysParam.heightMode === "aboveGroundLevel" ? options?.resolveExecuteHeight?.({
1945
+ ...point,
1946
+ index,
1947
+ waylineId: 0
1948
+ }) ?? folder.Placemark.height : folder.Placemark.height,
1949
+ waypointSpeed: folder.autoFlightSpeed,
1950
+ waypointHeadingParam: index === lastIndex ? {
1951
+ waypointHeadingMode: "followWayline",
1952
+ waypointHeadingPathMode: "followBadArc"
1953
+ } : {
1954
+ waypointHeadingMode: "fixed",
1955
+ waypointHeadingAngle: degrees(direction),
1956
+ waypointHeadingAngleEnable: true,
1957
+ waypointHeadingPathMode: "followBadArc",
1958
+ waypointHeadingPoiIndex: 0
1959
+ },
1960
+ waypointTurnParam: index === 0 || index === lastIndex ? {
1961
+ waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
1962
+ waypointTurnDampingDist: meters(0)
1963
+ } : {
1964
+ waypointTurnMode: "coordinateTurn",
1965
+ waypointTurnDampingDist: turnDamping(points, index)
1966
+ },
1967
+ useStraightLine: true,
1968
+ isRisky: false,
1969
+ waypointWorkType: 0,
1970
+ ...index === 0 ? { actionGroup: [{
1971
+ actionGroupId: 0,
1972
+ actionGroupStartIndex: 0,
1973
+ actionGroupEndIndex: lastIndex,
1974
+ actionGroupMode: "sequence",
1975
+ actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
1976
+ action: routeActions
1977
+ }] } : {}
1978
+ }));
1979
+ return {
1980
+ missionConfig: template.missionConfig,
1981
+ Folder: [{
1982
+ templateId: folder.templateId,
1983
+ waylineId: 0,
1984
+ executeHeightMode: executeHeightMode(folder),
1985
+ autoFlightSpeed: folder.autoFlightSpeed,
1986
+ startActionGroup: { action: folder.Placemark.recordEnable ? [gimbalAction(pitch), startRecordAction(payloadPositionIndex, 1)] : [gimbalAction(pitch)] },
1987
+ Placemark: placemarks
1988
+ }]
1989
+ };
1990
+ }
1991
+ //#endregion
1829
1992
  //#region src/plan/index.ts
1830
1993
  const isWaypoint = (t) => t.Folder.templateType === "waypoint";
1831
1994
  const isMapping2d = (t) => t.Folder.templateType === "mapping2d";
1832
1995
  const isMapping3d = (t) => t.Folder.templateType === "mapping3d";
1833
1996
  const isMappingStrip = (t) => t.Folder.templateType === "mappingStrip";
1997
+ const isTargetDetection = (t) => t.Folder.templateType === "targetdetection";
1834
1998
  /**
1835
1999
  * Compile a high-level `Template` into executable `Waylines`.
1836
2000
  * Dispatch is by `template.Folder.templateType` per the DJI WPML spec.
@@ -1842,6 +2006,7 @@ function plan(template, options) {
1842
2006
  if (isMapping2d(template)) return planMapping2d(template, options);
1843
2007
  if (isMapping3d(template)) return planMapping3d(template, options);
1844
2008
  if (isMappingStrip(template)) return planMappingStrip(template, options);
2009
+ if (isTargetDetection(template)) return planTargetDetection(template, options);
1845
2010
  throw new PlannerError(`Unknown templateType: ${template.Folder.templateType}`);
1846
2011
  }
1847
2012
  //#endregion
@@ -1904,6 +2069,9 @@ function validateTemplate(template) {
1904
2069
  case "mappingStrip":
1905
2070
  issues.push(...validateMappingStripFolder(template, path));
1906
2071
  break;
2072
+ case "targetdetection":
2073
+ issues.push(...validateTargetDetectionFolder(template, path));
2074
+ break;
1907
2075
  }
1908
2076
  return issues;
1909
2077
  }
@@ -1922,6 +2090,9 @@ function validateMapping2dFolder(t, path) {
1922
2090
  function validateMapping3dFolder(t, path) {
1923
2091
  return validatePolygonRing(t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates, `${path}.Placemark.Polygon`);
1924
2092
  }
2093
+ function validateTargetDetectionFolder(t, path) {
2094
+ return validatePolygonRing(t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates, `${path}.Placemark.Polygon`);
2095
+ }
1925
2096
  function validateMappingStripFolder(t, path) {
1926
2097
  const placemark = t.Folder.Placemark;
1927
2098
  const tuples = placemark.LineString.coordinates.trim().split(/\s+/).filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumikmz/kmz",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "DJI KMZ/WPML domain model + plan() algorithm + validate(), spec-aligned types mirroring docs/kmz/",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -70,7 +70,7 @@ export function haversineMeters(a: LngLat, b: LngLat): number {
70
70
  }
71
71
 
72
72
  /** Clip a horizontal scan line `y = const` against a polygon; returns `[x_enter, x_exit]` pairs. */
73
- function clipHorizontal(y: number, polygon: readonly EnuPoint[]): [number, number][] {
73
+ export function clipHorizontal(y: number, polygon: readonly EnuPoint[]): [number, number][] {
74
74
  const intersections: number[] = [];
75
75
  const n = polygon.length;
76
76
  for (let i = 0; i < n; i++) {
package/src/plan/index.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  Mapping2dTemplate,
5
5
  Mapping3dTemplate,
6
6
  MappingStripTemplate,
7
+ TargetDetectionTemplate,
7
8
  Template,
8
9
  WaypointTemplate,
9
10
  } from "../types/template.js";
@@ -12,6 +13,8 @@ import { planMapping2d } from "./plan-mapping2d.js";
12
13
  import { planMapping3d } from "./plan-mapping3d.js";
13
14
  import { planMappingStrip } from "./plan-mapping-strip.js";
14
15
  import { planWaypoint } from "./plan-waypoint.js";
16
+ import { planTargetDetection } from "./plan-target-detection.js";
17
+ import type { LngLat } from "../types/branded.js";
15
18
 
16
19
  /**
17
20
  * Options for `plan()`.
@@ -34,6 +37,8 @@ export interface PlanOptions {
34
37
  stripDirection?: "parallel" | "perpendicular";
35
38
  /** Reverse mappingStrip region execution without changing region identities. */
36
39
  regionOrderReversed?: boolean;
40
+ /** Preferred entry location for path variants that can be reversed as a whole. */
41
+ preferredStartPoint?: LngLat;
37
42
  resolveExecuteHeight?: (point: {
38
43
  lng: number;
39
44
  lat: number;
@@ -47,6 +52,8 @@ const isMapping2d = (t: Template): t is Mapping2dTemplate => t.Folder.templateTy
47
52
  const isMapping3d = (t: Template): t is Mapping3dTemplate => t.Folder.templateType === "mapping3d";
48
53
  const isMappingStrip = (t: Template): t is MappingStripTemplate =>
49
54
  t.Folder.templateType === "mappingStrip";
55
+ const isTargetDetection = (t: Template): t is TargetDetectionTemplate =>
56
+ t.Folder.templateType === "targetdetection";
50
57
 
51
58
  /**
52
59
  * Compile a high-level `Template` into executable `Waylines`.
@@ -59,5 +66,6 @@ export function plan(template: Template, options?: PlanOptions): Waylines {
59
66
  if (isMapping2d(template)) return planMapping2d(template, options);
60
67
  if (isMapping3d(template)) return planMapping3d(template, options);
61
68
  if (isMappingStrip(template)) return planMappingStrip(template, options);
69
+ if (isTargetDetection(template)) return planTargetDetection(template, options);
62
70
  throw new PlannerError(`Unknown templateType: ${(template as Template).Folder.templateType}`);
63
71
  }
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { degrees, meters, metersPerSecond } from "../types/branded.js";
3
+ import type { TargetDetectionTemplate } from "../types/template.js";
4
+ import { plan } from "./index.js";
5
+
6
+ function template(overrides: { pitch?: number; direction?: number } = {}): TargetDetectionTemplate {
7
+ const pitch = overrides.pitch ?? -90;
8
+ const direction = overrides.direction ?? 0;
9
+ return {
10
+ missionConfig: {
11
+ flyToWaylineMode: "safely",
12
+ finishAction: "goHome",
13
+ exitOnRCLost: "goContinue",
14
+ takeOffSecurityHeight: meters(20),
15
+ globalTransitionalSpeed: metersPerSecond(15),
16
+ globalRTHHeight: meters(100),
17
+ droneInfo: { droneEnumValue: 100, droneSubEnumValue: 1 },
18
+ payloadInfo: [{ payloadEnumValue: 99, payloadSubEnumValue: 2, payloadPositionIndex: 0 }],
19
+ },
20
+ Folder: {
21
+ templateType: "targetdetection",
22
+ templateId: 0,
23
+ waylineCoordinateSysParam: {
24
+ coordinateMode: "WGS84",
25
+ heightMode: "relativeToStartPoint",
26
+ globalShootHeight: meters(60),
27
+ surfaceFollowModeEnable: false,
28
+ },
29
+ autoFlightSpeed: metersPerSecond(10),
30
+ payloadParam: { payloadPositionIndex: 0, imageFormat: ["ir"] },
31
+ Placemark: {
32
+ direction: degrees(direction),
33
+ margin: 0,
34
+ gimbalPitchMode: "fixed",
35
+ overlap: { orthoCameraOverlapH: 1, orthoCameraOverlapW: 20 },
36
+ Polygon: {
37
+ outerBoundaryIs: {
38
+ LinearRing: {
39
+ coordinates: "0,0,0 0.001,0,0 0.001,0.002,0 0,0.002,0",
40
+ },
41
+ },
42
+ },
43
+ ellipsoidHeight: meters(60),
44
+ height: meters(60),
45
+ mappingHeadingParam: {
46
+ mappingHeadingMode: "fixed",
47
+ mappingHeadingAngle: degrees(direction),
48
+ },
49
+ gimbalPitchAngle: degrees(pitch),
50
+ recordEnable: false,
51
+ },
52
+ },
53
+ };
54
+ }
55
+
56
+ describe("plan(targetdetection)", () => {
57
+ it("matches FlightHub recording and target-detection actions without stopRecord", () => {
58
+ const input = template();
59
+ Object.assign(input.Folder.Placemark, {
60
+ recordEnable: true,
61
+ targetDetectionActionEnable: true,
62
+ action: {
63
+ actionId: 0,
64
+ actionActuatorFunc: "targetDetection",
65
+ actionActuatorFuncParam: {
66
+ focalLength: 70,
67
+ payloadPositionIndex: 0,
68
+ useGlobalPayloadLensIndex: false,
69
+ payloadLensIndex: ["visable"],
70
+ actionUUID: "test-target-detection",
71
+ customTitle: "巡逻",
72
+ customText: "检测到异常目标",
73
+ modelIndex: 0,
74
+ modelScore: 55,
75
+ alarmInterval: 2,
76
+ useThirdModel: false,
77
+ algorithmType: "visable_ai",
78
+ targetParam: {
79
+ maxThreshold: 10,
80
+ minThreshold: 10,
81
+ useMaxThreshold: false,
82
+ useMinThreshold: true,
83
+ targetType: "person",
84
+ },
85
+ },
86
+ },
87
+ });
88
+
89
+ const folder = plan(input, { lineSpacing: meters(25) }).Folder[0]!;
90
+ const startFuncs = folder.startActionGroup!.action.map((a) => a.actionActuatorFunc);
91
+ const routeFuncs = folder.Placemark[0]!.actionGroup![0]!.action.map(
92
+ (a) => a.actionActuatorFunc,
93
+ );
94
+
95
+ expect(startFuncs).toEqual(["gimbalRotate", "startRecord"]);
96
+ expect(routeFuncs).toEqual(["gimbalRotate", "startRecord", "targetDetection"]);
97
+ expect(JSON.stringify(folder)).not.toContain("stopRecord");
98
+ });
99
+
100
+ it("centers floor(width / spacing) scan lines", () => {
101
+ const waylines = plan(template(), { lineSpacing: meters(25) });
102
+
103
+ expect(waylines.Folder).toHaveLength(1);
104
+ expect(waylines.Folder[0]!.Placemark).toHaveLength(8);
105
+ });
106
+
107
+ it("shifts the flight path backward by height / tan(abs(pitch))", () => {
108
+ const placemarks = plan(template({ pitch: -45 }), {
109
+ lineSpacing: meters(25),
110
+ }).Folder[0]!.Placemark;
111
+ const centerLat =
112
+ placemarks.reduce((sum, point) => sum + point.Point.coordinates.lat, 0) / placemarks.length;
113
+
114
+ expect(centerLat).toBeCloseTo(0.000461, 5);
115
+ });
116
+
117
+ it("retains every clipped segment of a concave polygon", () => {
118
+ const concave = template({ direction: 90 });
119
+ concave.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates =
120
+ "0,0,0 0.003,0,0 0.003,0.003,0 0.002,0.003,0 0.002,0.001,0 0.001,0.001,0 0.001,0.003,0 0,0.003,0";
121
+
122
+ const placemarks = plan(concave, { lineSpacing: meters(100) }).Folder[0]!.Placemark;
123
+
124
+ expect(placemarks).toHaveLength(10);
125
+ });
126
+
127
+ it("keeps fixed heading and pitch with one-third turn damping", () => {
128
+ const placemarks = plan(template({ pitch: -45, direction: 0 }), {
129
+ lineSpacing: meters(25),
130
+ }).Folder[0]!.Placemark;
131
+
132
+ expect(
133
+ placemarks.slice(0, -1).every((p) => p.waypointHeadingParam.waypointHeadingMode === "fixed"),
134
+ ).toBe(true);
135
+ expect(placemarks[0]!.actionGroup?.[0]!.action[0]).toMatchObject({
136
+ actionActuatorFunc: "gimbalRotate",
137
+ actionActuatorFuncParam: { gimbalPitchRotateAngle: -45 },
138
+ });
139
+ expect(Number(placemarks[1]!.waypointTurnParam.waypointTurnDampingDist)).toBeCloseTo(25 / 3, 5);
140
+ expect(Number(placemarks[0]!.waypointTurnParam.waypointTurnDampingDist)).toBe(0);
141
+ expect(Number(placemarks.at(-1)!.waypointTurnParam.waypointTurnDampingDist)).toBe(0);
142
+ });
143
+ });
@@ -0,0 +1,233 @@
1
+ import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
2
+ import type { Action } from "../types/action.js";
3
+ import { PlannerError } from "../types/errors.js";
4
+ import type { ExecuteHeightMode } from "../types/enums.js";
5
+ import type { WaylinesPlacemark } from "../types/placemark.js";
6
+ import type { TargetDetectionFolder, TargetDetectionTemplate } from "../types/template.js";
7
+ import type { Waylines } from "../types/waylines.js";
8
+ import { clipHorizontal, offsetPolygon, parseCoordinatesString } from "./geometry.js";
9
+ import type { PlanOptions } from "./index.js";
10
+
11
+ const EARTH_RADIUS_M = 6_378_137;
12
+ const DEG2RAD = Math.PI / 180;
13
+ const RAD2DEG = 180 / Math.PI;
14
+
15
+ type Point = readonly [number, number];
16
+
17
+ function project({ lng, lat }: LngLat): Point {
18
+ return [
19
+ EARTH_RADIUS_M * lng * DEG2RAD,
20
+ EARTH_RADIUS_M * Math.log(Math.tan(Math.PI / 4 + (lat * DEG2RAD) / 2)),
21
+ ];
22
+ }
23
+
24
+ function unproject([x, y]: Point): LngLat {
25
+ return {
26
+ lng: (x / EARTH_RADIUS_M) * RAD2DEG,
27
+ lat: (2 * Math.atan(Math.exp(y / EARTH_RADIUS_M)) - Math.PI / 2) * RAD2DEG,
28
+ };
29
+ }
30
+
31
+ function executeHeightMode(folder: TargetDetectionFolder): ExecuteHeightMode {
32
+ switch (folder.waylineCoordinateSysParam.heightMode) {
33
+ case "EGM96":
34
+ return "WGS84";
35
+ case "relativeToStartPoint":
36
+ return "relativeToStartPoint";
37
+ case "aboveGroundLevel":
38
+ case "realTimeFollowSurface":
39
+ return "realTimeFollowSurface";
40
+ }
41
+ }
42
+
43
+ function gimbalAction(pitch: number) {
44
+ return {
45
+ actionId: 0,
46
+ actionActuatorFunc: "gimbalRotate" as const,
47
+ actionActuatorFuncParam: {
48
+ payloadPositionIndex: 0,
49
+ gimbalHeadingYawBase: "aircraft" as const,
50
+ gimbalRotateMode: "absoluteAngle" as const,
51
+ gimbalPitchRotateEnable: true,
52
+ gimbalPitchRotateAngle: degrees(pitch),
53
+ gimbalRollRotateEnable: false,
54
+ gimbalRollRotateAngle: degrees(0),
55
+ gimbalYawRotateEnable: false,
56
+ gimbalYawRotateAngle: degrees(0),
57
+ gimbalRotateTimeEnable: false,
58
+ gimbalRotateTime: 10,
59
+ },
60
+ };
61
+ }
62
+
63
+ function startRecordAction(payloadPositionIndex: number, actionId: number) {
64
+ return {
65
+ actionId,
66
+ actionActuatorFunc: "startRecord" as const,
67
+ actionActuatorFuncParam: {
68
+ payloadPositionIndex,
69
+ useGlobalPayloadLensIndex: true,
70
+ payloadLensIndex: ["visable" as const],
71
+ },
72
+ };
73
+ }
74
+
75
+ function pathPoints(
76
+ template: TargetDetectionTemplate,
77
+ lineSpacingM: number,
78
+ preferredStartPoint?: LngLat,
79
+ ): LngLat[] {
80
+ const placemark = template.Folder.Placemark;
81
+ const ring = parseCoordinatesString(placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
82
+ if (ring.length < 3)
83
+ throw new PlannerError("targetdetection requires at least 3 polygon vertices");
84
+
85
+ const centerLat = ring.reduce((sum, point) => sum + point.lat, 0) / ring.length;
86
+ const scale = 1 / Math.cos(centerLat * DEG2RAD);
87
+ const theta = Number(placemark.direction) * DEG2RAD;
88
+ const u: Point = [Math.sin(theta), Math.cos(theta)];
89
+ const n: Point = [-Math.cos(theta), Math.sin(theta)];
90
+ const height = Number(placemark.height);
91
+ const pitch = Math.abs(Number(placemark.gimbalPitchAngle));
92
+ const lookAheadM = pitch >= 89.999999 ? 0 : height / Math.tan(pitch * DEG2RAD);
93
+
94
+ const translated = ring.map((point) => {
95
+ const [x, y] = project(point);
96
+ return [x - u[0] * lookAheadM * scale, y - u[1] * lookAheadM * scale] as Point;
97
+ });
98
+ const flightRing = offsetPolygon(translated, placemark.margin * scale);
99
+ const scanRing = flightRing.map(([x, y]) => [x * u[0] + y * u[1], x * n[0] + y * n[1]] as Point);
100
+ const cross = scanRing.map((point) => point[1]);
101
+ const min = Math.min(...cross);
102
+ const max = Math.max(...cross);
103
+ const spacing = lineSpacingM * scale;
104
+ const stripCount = Math.max(1, Math.floor((max - min) / spacing));
105
+ const center = (min + max) / 2;
106
+
107
+ const segments: [Point, Point][] = [];
108
+ for (let i = 0; i < stripCount; i++) {
109
+ const q = center + (i - (stripCount - 1) / 2) * spacing;
110
+ for (const [a, b] of clipHorizontal(q, scanRing)) {
111
+ segments.push([
112
+ [u[0] * a + n[0] * q, u[1] * a + n[1] * q],
113
+ [u[0] * b + n[0] * q, u[1] * b + n[1] * q],
114
+ ]);
115
+ }
116
+ }
117
+
118
+ let points = segments.flatMap(([a, b], index) => (index % 2 === 0 ? [a, b] : [b, a]));
119
+ if (preferredStartPoint && points.length > 1) {
120
+ const preferred = project(preferredStartPoint);
121
+ const distance2 = ([x, y]: Point) => (x - preferred[0]) ** 2 + (y - preferred[1]) ** 2;
122
+ if (distance2(points.at(-1)!) < distance2(points[0]!)) points = points.reverse();
123
+ }
124
+ return points.map(unproject);
125
+ }
126
+
127
+ function turnDamping(points: readonly LngLat[], index: number): Meters {
128
+ if (index === 0 || index === points.length - 1) return meters(0);
129
+ const groundDistance = (a: LngLat, b: LngLat) => {
130
+ const [ax, ay] = project(a);
131
+ const [bx, by] = project(b);
132
+ return Math.hypot(bx - ax, by - ay) * Math.cos(((a.lat + b.lat) / 2) * DEG2RAD);
133
+ };
134
+ return meters(
135
+ Math.min(
136
+ 10,
137
+ groundDistance(points[index - 1]!, points[index]!) / 3,
138
+ groundDistance(points[index]!, points[index + 1]!) / 3,
139
+ ),
140
+ );
141
+ }
142
+
143
+ export function planTargetDetection(
144
+ template: TargetDetectionTemplate,
145
+ options?: PlanOptions,
146
+ ): Waylines {
147
+ const spacing = Number(options?.lineSpacing);
148
+ if (!(spacing > 0 && Number.isFinite(spacing))) {
149
+ throw new PlannerError("targetdetection requires options.lineSpacing");
150
+ }
151
+
152
+ const folder = template.Folder;
153
+ const pitch = Number(folder.Placemark.gimbalPitchAngle);
154
+ const direction = Number(folder.Placemark.direction);
155
+ const points = pathPoints(template, spacing, options?.preferredStartPoint);
156
+ if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
157
+ const lastIndex = points.length - 1;
158
+ const action = gimbalAction(pitch);
159
+ const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
160
+ const routeActions: Action[] = [action];
161
+ if (folder.Placemark.recordEnable) {
162
+ routeActions.push(startRecordAction(payloadPositionIndex, routeActions.length));
163
+ }
164
+ if (folder.Placemark.action) {
165
+ routeActions.push({ ...folder.Placemark.action, actionId: routeActions.length });
166
+ }
167
+
168
+ const placemarks: WaylinesPlacemark[] = points.map((point, index) => ({
169
+ Point: { coordinates: point },
170
+ index,
171
+ executeHeight:
172
+ folder.waylineCoordinateSysParam.heightMode === "aboveGroundLevel"
173
+ ? (options?.resolveExecuteHeight?.({ ...point, index, waylineId: 0 }) ??
174
+ folder.Placemark.height)
175
+ : folder.Placemark.height,
176
+ waypointSpeed: folder.autoFlightSpeed,
177
+ waypointHeadingParam:
178
+ index === lastIndex
179
+ ? { waypointHeadingMode: "followWayline", waypointHeadingPathMode: "followBadArc" }
180
+ : {
181
+ waypointHeadingMode: "fixed",
182
+ waypointHeadingAngle: degrees(direction),
183
+ waypointHeadingAngleEnable: true,
184
+ waypointHeadingPathMode: "followBadArc",
185
+ waypointHeadingPoiIndex: 0,
186
+ },
187
+ waypointTurnParam:
188
+ index === 0 || index === lastIndex
189
+ ? {
190
+ waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
191
+ waypointTurnDampingDist: meters(0),
192
+ }
193
+ : {
194
+ waypointTurnMode: "coordinateTurn",
195
+ waypointTurnDampingDist: turnDamping(points, index),
196
+ },
197
+ useStraightLine: true,
198
+ isRisky: false,
199
+ waypointWorkType: 0,
200
+ ...(index === 0
201
+ ? {
202
+ actionGroup: [
203
+ {
204
+ actionGroupId: 0,
205
+ actionGroupStartIndex: 0,
206
+ actionGroupEndIndex: lastIndex,
207
+ actionGroupMode: "sequence",
208
+ actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
209
+ action: routeActions,
210
+ },
211
+ ],
212
+ }
213
+ : {}),
214
+ }));
215
+
216
+ return {
217
+ missionConfig: template.missionConfig,
218
+ Folder: [
219
+ {
220
+ templateId: folder.templateId,
221
+ waylineId: 0,
222
+ executeHeightMode: executeHeightMode(folder),
223
+ autoFlightSpeed: folder.autoFlightSpeed,
224
+ startActionGroup: {
225
+ action: folder.Placemark.recordEnable
226
+ ? [gimbalAction(pitch), startRecordAction(payloadPositionIndex, 1)]
227
+ : [gimbalAction(pitch)],
228
+ },
229
+ Placemark: placemarks,
230
+ },
231
+ ],
232
+ };
233
+ }
@@ -34,6 +34,7 @@ export interface ActionTrigger {
34
34
 
35
35
  /** Discriminated by `actionActuatorFunc`. */
36
36
  export type Action =
37
+ | TargetDetectionAction
37
38
  | TakePhotoAction
38
39
  | StartRecordAction
39
40
  | StopRecordAction
@@ -64,6 +65,31 @@ interface ActionBase<F extends string, P> {
64
65
  actionActuatorFuncParam: P;
65
66
  }
66
67
 
68
+ export interface TargetDetectionParam {
69
+ focalLength?: number;
70
+ payloadPositionIndex: number;
71
+ useGlobalPayloadLensIndex: boolean;
72
+ payloadLensIndex: LensType[];
73
+ actionUUID: string;
74
+ irGainMode?: string;
75
+ irColorPalette?: string;
76
+ customTitle: string;
77
+ customText: string;
78
+ modelIndex: number;
79
+ modelScore: number;
80
+ alarmInterval: number;
81
+ useThirdModel: boolean;
82
+ algorithmType: string;
83
+ targetParam: {
84
+ maxThreshold: number;
85
+ minThreshold: number;
86
+ useMaxThreshold: boolean;
87
+ useMinThreshold: boolean;
88
+ targetType: string;
89
+ };
90
+ }
91
+ export type TargetDetectionAction = ActionBase<"targetDetection", TargetDetectionParam>;
92
+
67
93
  // ─── Action params (one type per actionActuatorFunc) ─────────────────────────
68
94
 
69
95
  export interface TakePhotoParam {
@@ -15,6 +15,8 @@ export interface PayloadInfo {
15
15
  */
16
16
  payloadSubEnumValue?: number;
17
17
  payloadPositionIndex: number;
18
+ /** FlightHub 2 patrol-camera selection, in 35mm-equivalent millimeters. */
19
+ equivalentFocalLength?: number;
18
20
  }
19
21
 
20
22
  /** Aircraft `{ droneEnumValue, droneSubEnumValue }` tuples. */
@@ -8,7 +8,12 @@ export type ExitOnRCLost = "goContinue" | "executeLostAction";
8
8
 
9
9
  export type ExecuteRCLostAction = "goBack" | "landing" | "hover";
10
10
 
11
- export type TemplateType = "waypoint" | "mapping2d" | "mapping3d" | "mappingStrip";
11
+ export type TemplateType =
12
+ | "waypoint"
13
+ | "mapping2d"
14
+ | "mapping3d"
15
+ | "mappingStrip"
16
+ | "targetdetection";
12
17
 
13
18
  export type CoordinateMode = "WGS84";
14
19
 
@@ -63,10 +68,12 @@ export type ActionGroupMode = "sequence";
63
68
  export type ActionTriggerType =
64
69
  | "reachPoint"
65
70
  | "betweenAdjacentPoints"
71
+ | "betweenAdjacentPointsIncludeFirstPoint"
66
72
  | "multipleTiming"
67
73
  | "multipleDistance";
68
74
 
69
75
  export type ActionActuatorFunc =
76
+ | "targetDetection"
70
77
  | "takePhoto"
71
78
  | "startRecord"
72
79
  | "stopRecord"
@@ -31,6 +31,8 @@ export interface MissionConfig {
31
31
  globalRTHHeight: Meters;
32
32
  droneInfo: DroneInfo;
33
33
  payloadInfo: PayloadInfo[];
34
+ /** FlightHub 2 custom restricted-area avoidance switch. */
35
+ waylineAvoidLimitAreaMode?: boolean;
34
36
  /** Only M3D/M3TD, M4D/M4TD, M4E/M4T. */
35
37
  autoRerouteInfo?: AutoRerouteInfo;
36
38
  }
@@ -1,5 +1,5 @@
1
1
  import type { Degrees, LngLat, Meters, MetersPerSecond } from "./branded.js";
2
- import type { ActionGroup } from "./action.js";
2
+ import type { ActionGroup, TargetDetectionAction } from "./action.js";
3
3
  import type { ExecuteHeightMode, ShootType } from "./enums.js";
4
4
  import type {
5
5
  MappingHeadingParam,
@@ -115,6 +115,23 @@ export interface Mapping3dPlacemark {
115
115
  height: Meters;
116
116
  }
117
117
 
118
+ /** `Placemark` inside a FlightHub 2 patrol-area `targetdetection` Folder. */
119
+ export interface TargetDetectionPlacemark {
120
+ direction: Degrees;
121
+ margin: number;
122
+ gimbalPitchMode: "fixed";
123
+ overlap: Overlap;
124
+ Polygon: TemplatePolygon;
125
+ ellipsoidHeight: Meters;
126
+ height: Meters;
127
+ mappingHeadingParam: MappingHeadingParam;
128
+ gimbalPitchAngle: Degrees;
129
+ recordEnable: boolean;
130
+ /** FlightHub 2 always emits this action for patrol-area routes. */
131
+ targetDetectionActionEnable?: boolean;
132
+ action?: TargetDetectionAction;
133
+ }
134
+
118
135
  /** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
119
136
  export interface MappingStripPlacemark {
120
137
  caliFlightEnable: boolean;
@@ -6,6 +6,7 @@ import type {
6
6
  Mapping2dPlacemark,
7
7
  Mapping3dPlacemark,
8
8
  MappingStripPlacemark,
9
+ TargetDetectionPlacemark,
9
10
  TemplateWaypointPlacemark,
10
11
  } from "./placemark.js";
11
12
 
@@ -17,7 +18,8 @@ export type Template =
17
18
  | WaypointTemplate
18
19
  | Mapping2dTemplate
19
20
  | Mapping3dTemplate
20
- | MappingStripTemplate;
21
+ | MappingStripTemplate
22
+ | TargetDetectionTemplate;
21
23
 
22
24
  interface TemplateDocumentBase {
23
25
  /** Unix milliseconds. */
@@ -93,6 +95,21 @@ export interface Mapping3dFolder {
93
95
  Placemark: Mapping3dPlacemark;
94
96
  }
95
97
 
98
+ // ─── targetdetection (patrol area) ──────────────────────────────────────────
99
+
100
+ export interface TargetDetectionTemplate extends TemplateDocumentBase {
101
+ Folder: TargetDetectionFolder;
102
+ }
103
+
104
+ export interface TargetDetectionFolder {
105
+ templateType: "targetdetection";
106
+ templateId: number;
107
+ waylineCoordinateSysParam: WaylineCoordinateSysParam;
108
+ autoFlightSpeed: MetersPerSecond;
109
+ payloadParam?: PayloadParam;
110
+ Placemark: TargetDetectionPlacemark;
111
+ }
112
+
96
113
  // ─── mappingStrip ────────────────────────────────────────────────────────────
97
114
 
98
115
  export interface MappingStripTemplate extends TemplateDocumentBase {
@@ -3,6 +3,7 @@ import type {
3
3
  Mapping2dTemplate,
4
4
  Mapping3dTemplate,
5
5
  MappingStripTemplate,
6
+ TargetDetectionTemplate,
6
7
  Template,
7
8
  WaypointTemplate,
8
9
  } from "../types/template.js";
@@ -41,6 +42,9 @@ export function validateTemplate(template: Template): Issue[] {
41
42
  case "mappingStrip":
42
43
  issues.push(...validateMappingStripFolder(template as MappingStripTemplate, path));
43
44
  break;
45
+ case "targetdetection":
46
+ issues.push(...validateTargetDetectionFolder(template as TargetDetectionTemplate, path));
47
+ break;
44
48
  }
45
49
 
46
50
  return issues;
@@ -72,6 +76,13 @@ function validateMapping3dFolder(t: Mapping3dTemplate, path: string): Issue[] {
72
76
  );
73
77
  }
74
78
 
79
+ function validateTargetDetectionFolder(t: TargetDetectionTemplate, path: string): Issue[] {
80
+ return validatePolygonRing(
81
+ t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates,
82
+ `${path}.Placemark.Polygon`,
83
+ );
84
+ }
85
+
75
86
  function validateMappingStripFolder(t: MappingStripTemplate, path: string): Issue[] {
76
87
  const placemark = t.Folder.Placemark;
77
88
  const tuples = placemark.LineString.coordinates.trim().split(/\s+/).filter(Boolean);