@lumikmz/kmz 0.9.0 → 0.10.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 +32 -4
- package/dist/index.mjs +172 -19
- package/package.json +1 -1
- package/src/plan/geometry.ts +1 -1
- package/src/plan/index.ts +8 -0
- package/src/plan/plan-target-detection.test.ts +100 -0
- package/src/plan/plan-target-detection.ts +208 -0
- package/src/types/enums.ts +7 -1
- package/src/types/mission-config.ts +2 -0
- package/src/types/placemark.ts +14 -0
- package/src/types/template.ts +18 -1
- package/src/validate/validate-template.ts +11 -0
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,7 +58,7 @@ 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";
|
|
61
|
+
type ActionTriggerType = "reachPoint" | "betweenAdjacentPoints" | "betweenAdjacentPointsIncludeFirstPoint" | "multipleTiming" | "multipleDistance";
|
|
62
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";
|
|
63
63
|
type GimbalHeadingYawBase = "north" | "aircraft";
|
|
64
64
|
type GimbalRotateMode = "absoluteAngle";
|
|
@@ -264,6 +264,8 @@ interface MissionConfig {
|
|
|
264
264
|
globalRTHHeight: Meters;
|
|
265
265
|
droneInfo: DroneInfo;
|
|
266
266
|
payloadInfo: PayloadInfo[];
|
|
267
|
+
/** FlightHub 2 custom restricted-area avoidance switch. */
|
|
268
|
+
waylineAvoidLimitAreaMode?: boolean;
|
|
267
269
|
/** Only M3D/M3TD, M4D/M4TD, M4E/M4T. */
|
|
268
270
|
autoRerouteInfo?: AutoRerouteInfo;
|
|
269
271
|
}
|
|
@@ -749,6 +751,19 @@ interface Mapping3dPlacemark {
|
|
|
749
751
|
ellipsoidHeight: Meters;
|
|
750
752
|
height: Meters;
|
|
751
753
|
}
|
|
754
|
+
/** `Placemark` inside a FlightHub 2 patrol-area `targetdetection` Folder. */
|
|
755
|
+
interface TargetDetectionPlacemark {
|
|
756
|
+
direction: Degrees;
|
|
757
|
+
margin: number;
|
|
758
|
+
gimbalPitchMode: "fixed";
|
|
759
|
+
overlap: Overlap;
|
|
760
|
+
Polygon: TemplatePolygon;
|
|
761
|
+
ellipsoidHeight: Meters;
|
|
762
|
+
height: Meters;
|
|
763
|
+
mappingHeadingParam: MappingHeadingParam;
|
|
764
|
+
gimbalPitchAngle: Degrees;
|
|
765
|
+
recordEnable: boolean;
|
|
766
|
+
}
|
|
752
767
|
/** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
|
|
753
768
|
interface MappingStripPlacemark {
|
|
754
769
|
caliFlightEnable: boolean;
|
|
@@ -810,7 +825,7 @@ interface WaylinesPlacemark {
|
|
|
810
825
|
* `template.kml` document. Mirrors `<kml><Document>` structure.
|
|
811
826
|
* See `docs/kmz/template-kml.html`.
|
|
812
827
|
*/
|
|
813
|
-
type Template = WaypointTemplate | Mapping2dTemplate | Mapping3dTemplate | MappingStripTemplate;
|
|
828
|
+
type Template = WaypointTemplate | Mapping2dTemplate | Mapping3dTemplate | MappingStripTemplate | TargetDetectionTemplate;
|
|
814
829
|
interface TemplateDocumentBase {
|
|
815
830
|
/** Unix milliseconds. */
|
|
816
831
|
author?: string;
|
|
@@ -870,6 +885,17 @@ interface Mapping3dFolder {
|
|
|
870
885
|
/** Holds the survey polygon AND all shooting/mapping params. */
|
|
871
886
|
Placemark: Mapping3dPlacemark;
|
|
872
887
|
}
|
|
888
|
+
interface TargetDetectionTemplate extends TemplateDocumentBase {
|
|
889
|
+
Folder: TargetDetectionFolder;
|
|
890
|
+
}
|
|
891
|
+
interface TargetDetectionFolder {
|
|
892
|
+
templateType: "targetdetection";
|
|
893
|
+
templateId: number;
|
|
894
|
+
waylineCoordinateSysParam: WaylineCoordinateSysParam;
|
|
895
|
+
autoFlightSpeed: MetersPerSecond;
|
|
896
|
+
payloadParam?: PayloadParam;
|
|
897
|
+
Placemark: TargetDetectionPlacemark;
|
|
898
|
+
}
|
|
873
899
|
interface MappingStripTemplate extends TemplateDocumentBase {
|
|
874
900
|
Folder: MappingStripFolder;
|
|
875
901
|
}
|
|
@@ -940,6 +966,8 @@ interface PlanOptions {
|
|
|
940
966
|
stripDirection?: "parallel" | "perpendicular";
|
|
941
967
|
/** Reverse mappingStrip region execution without changing region identities. */
|
|
942
968
|
regionOrderReversed?: boolean;
|
|
969
|
+
/** Preferred entry location for path variants that can be reversed as a whole. */
|
|
970
|
+
preferredStartPoint?: LngLat;
|
|
943
971
|
resolveExecuteHeight?: (point: {
|
|
944
972
|
lng: number;
|
|
945
973
|
lat: number;
|
|
@@ -1003,4 +1031,4 @@ declare function validate(doc: Template | Waylines): Issue[];
|
|
|
1003
1031
|
/** Great-circle distance between two WGS-84 coordinates, in meters. */
|
|
1004
1032
|
declare function haversineMeters(a: LngLat, b: LngLat): number;
|
|
1005
1033
|
//#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 };
|
|
1034
|
+
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, TargetDetectionFolder, 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$
|
|
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$
|
|
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$
|
|
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,157 @@ 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 pathPoints(template, lineSpacingM, preferredStartPoint) {
|
|
1870
|
+
const placemark = template.Folder.Placemark;
|
|
1871
|
+
const ring = parseCoordinatesString(placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
|
|
1872
|
+
if (ring.length < 3) throw new PlannerError("targetdetection requires at least 3 polygon vertices");
|
|
1873
|
+
const centerLat = ring.reduce((sum, point) => sum + point.lat, 0) / ring.length;
|
|
1874
|
+
const scale = 1 / Math.cos(centerLat * DEG2RAD);
|
|
1875
|
+
const theta = Number(placemark.direction) * DEG2RAD;
|
|
1876
|
+
const u = [Math.sin(theta), Math.cos(theta)];
|
|
1877
|
+
const n = [-Math.cos(theta), Math.sin(theta)];
|
|
1878
|
+
const height = Number(placemark.height);
|
|
1879
|
+
const pitch = Math.abs(Number(placemark.gimbalPitchAngle));
|
|
1880
|
+
const lookAheadM = pitch >= 89.999999 ? 0 : height / Math.tan(pitch * DEG2RAD);
|
|
1881
|
+
const scanRing = offsetPolygon(ring.map((point) => {
|
|
1882
|
+
const [x, y] = project(point);
|
|
1883
|
+
return [x - u[0] * lookAheadM * scale, y - u[1] * lookAheadM * scale];
|
|
1884
|
+
}), placemark.margin * scale).map(([x, y]) => [x * u[0] + y * u[1], x * n[0] + y * n[1]]);
|
|
1885
|
+
const cross = scanRing.map((point) => point[1]);
|
|
1886
|
+
const min = Math.min(...cross);
|
|
1887
|
+
const max = Math.max(...cross);
|
|
1888
|
+
const spacing = lineSpacingM * scale;
|
|
1889
|
+
const stripCount = Math.max(1, Math.floor((max - min) / spacing));
|
|
1890
|
+
const center = (min + max) / 2;
|
|
1891
|
+
const segments = [];
|
|
1892
|
+
for (let i = 0; i < stripCount; i++) {
|
|
1893
|
+
const q = center + (i - (stripCount - 1) / 2) * spacing;
|
|
1894
|
+
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]]);
|
|
1895
|
+
}
|
|
1896
|
+
let points = segments.flatMap(([a, b], index) => index % 2 === 0 ? [a, b] : [b, a]);
|
|
1897
|
+
if (preferredStartPoint && points.length > 1) {
|
|
1898
|
+
const preferred = project(preferredStartPoint);
|
|
1899
|
+
const distance2 = ([x, y]) => (x - preferred[0]) ** 2 + (y - preferred[1]) ** 2;
|
|
1900
|
+
if (distance2(points.at(-1)) < distance2(points[0])) points = points.reverse();
|
|
1901
|
+
}
|
|
1902
|
+
return points.map(unproject);
|
|
1903
|
+
}
|
|
1904
|
+
function turnDamping(points, index) {
|
|
1905
|
+
if (index === 0 || index === points.length - 1) return meters(0);
|
|
1906
|
+
const groundDistance = (a, b) => {
|
|
1907
|
+
const [ax, ay] = project(a);
|
|
1908
|
+
const [bx, by] = project(b);
|
|
1909
|
+
return Math.hypot(bx - ax, by - ay) * Math.cos((a.lat + b.lat) / 2 * DEG2RAD);
|
|
1910
|
+
};
|
|
1911
|
+
return meters(Math.min(10, groundDistance(points[index - 1], points[index]) / 3, groundDistance(points[index], points[index + 1]) / 3));
|
|
1912
|
+
}
|
|
1913
|
+
function planTargetDetection(template, options) {
|
|
1914
|
+
const spacing = Number(options?.lineSpacing);
|
|
1915
|
+
if (!(spacing > 0 && Number.isFinite(spacing))) throw new PlannerError("targetdetection requires options.lineSpacing");
|
|
1916
|
+
const folder = template.Folder;
|
|
1917
|
+
const pitch = Number(folder.Placemark.gimbalPitchAngle);
|
|
1918
|
+
const direction = Number(folder.Placemark.direction);
|
|
1919
|
+
const points = pathPoints(template, spacing, options?.preferredStartPoint);
|
|
1920
|
+
if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
|
|
1921
|
+
const lastIndex = points.length - 1;
|
|
1922
|
+
const action = gimbalAction(pitch);
|
|
1923
|
+
const placemarks = points.map((point, index) => ({
|
|
1924
|
+
Point: { coordinates: point },
|
|
1925
|
+
index,
|
|
1926
|
+
executeHeight: folder.waylineCoordinateSysParam.heightMode === "aboveGroundLevel" ? options?.resolveExecuteHeight?.({
|
|
1927
|
+
...point,
|
|
1928
|
+
index,
|
|
1929
|
+
waylineId: 0
|
|
1930
|
+
}) ?? folder.Placemark.height : folder.Placemark.height,
|
|
1931
|
+
waypointSpeed: folder.autoFlightSpeed,
|
|
1932
|
+
waypointHeadingParam: index === lastIndex ? {
|
|
1933
|
+
waypointHeadingMode: "followWayline",
|
|
1934
|
+
waypointHeadingPathMode: "followBadArc"
|
|
1935
|
+
} : {
|
|
1936
|
+
waypointHeadingMode: "fixed",
|
|
1937
|
+
waypointHeadingAngle: degrees(direction),
|
|
1938
|
+
waypointHeadingAngleEnable: true,
|
|
1939
|
+
waypointHeadingPathMode: "followBadArc",
|
|
1940
|
+
waypointHeadingPoiIndex: 0
|
|
1941
|
+
},
|
|
1942
|
+
waypointTurnParam: index === 0 || index === lastIndex ? {
|
|
1943
|
+
waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
|
|
1944
|
+
waypointTurnDampingDist: meters(0)
|
|
1945
|
+
} : {
|
|
1946
|
+
waypointTurnMode: "coordinateTurn",
|
|
1947
|
+
waypointTurnDampingDist: turnDamping(points, index)
|
|
1948
|
+
},
|
|
1949
|
+
useStraightLine: true,
|
|
1950
|
+
isRisky: false,
|
|
1951
|
+
waypointWorkType: 0,
|
|
1952
|
+
...index === 0 ? { actionGroup: [{
|
|
1953
|
+
actionGroupId: 0,
|
|
1954
|
+
actionGroupStartIndex: 0,
|
|
1955
|
+
actionGroupEndIndex: lastIndex,
|
|
1956
|
+
actionGroupMode: "sequence",
|
|
1957
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
|
|
1958
|
+
action: [action]
|
|
1959
|
+
}] } : {}
|
|
1960
|
+
}));
|
|
1961
|
+
return {
|
|
1962
|
+
missionConfig: template.missionConfig,
|
|
1963
|
+
Folder: [{
|
|
1964
|
+
templateId: folder.templateId,
|
|
1965
|
+
waylineId: 0,
|
|
1966
|
+
executeHeightMode: executeHeightMode(folder),
|
|
1967
|
+
autoFlightSpeed: folder.autoFlightSpeed,
|
|
1968
|
+
startActionGroup: { action: [gimbalAction(pitch)] },
|
|
1969
|
+
Placemark: placemarks
|
|
1970
|
+
}]
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1829
1974
|
//#region src/plan/index.ts
|
|
1830
1975
|
const isWaypoint = (t) => t.Folder.templateType === "waypoint";
|
|
1831
1976
|
const isMapping2d = (t) => t.Folder.templateType === "mapping2d";
|
|
1832
1977
|
const isMapping3d = (t) => t.Folder.templateType === "mapping3d";
|
|
1833
1978
|
const isMappingStrip = (t) => t.Folder.templateType === "mappingStrip";
|
|
1979
|
+
const isTargetDetection = (t) => t.Folder.templateType === "targetdetection";
|
|
1834
1980
|
/**
|
|
1835
1981
|
* Compile a high-level `Template` into executable `Waylines`.
|
|
1836
1982
|
* Dispatch is by `template.Folder.templateType` per the DJI WPML spec.
|
|
@@ -1842,6 +1988,7 @@ function plan(template, options) {
|
|
|
1842
1988
|
if (isMapping2d(template)) return planMapping2d(template, options);
|
|
1843
1989
|
if (isMapping3d(template)) return planMapping3d(template, options);
|
|
1844
1990
|
if (isMappingStrip(template)) return planMappingStrip(template, options);
|
|
1991
|
+
if (isTargetDetection(template)) return planTargetDetection(template, options);
|
|
1845
1992
|
throw new PlannerError(`Unknown templateType: ${template.Folder.templateType}`);
|
|
1846
1993
|
}
|
|
1847
1994
|
//#endregion
|
|
@@ -1904,6 +2051,9 @@ function validateTemplate(template) {
|
|
|
1904
2051
|
case "mappingStrip":
|
|
1905
2052
|
issues.push(...validateMappingStripFolder(template, path));
|
|
1906
2053
|
break;
|
|
2054
|
+
case "targetdetection":
|
|
2055
|
+
issues.push(...validateTargetDetectionFolder(template, path));
|
|
2056
|
+
break;
|
|
1907
2057
|
}
|
|
1908
2058
|
return issues;
|
|
1909
2059
|
}
|
|
@@ -1922,6 +2072,9 @@ function validateMapping2dFolder(t, path) {
|
|
|
1922
2072
|
function validateMapping3dFolder(t, path) {
|
|
1923
2073
|
return validatePolygonRing(t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates, `${path}.Placemark.Polygon`);
|
|
1924
2074
|
}
|
|
2075
|
+
function validateTargetDetectionFolder(t, path) {
|
|
2076
|
+
return validatePolygonRing(t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates, `${path}.Placemark.Polygon`);
|
|
2077
|
+
}
|
|
1925
2078
|
function validateMappingStripFolder(t, path) {
|
|
1926
2079
|
const placemark = t.Folder.Placemark;
|
|
1927
2080
|
const tuples = placemark.LineString.coordinates.trim().split(/\s+/).filter(Boolean);
|
package/package.json
CHANGED
package/src/plan/geometry.ts
CHANGED
|
@@ -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,100 @@
|
|
|
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("centers floor(width / spacing) scan lines", () => {
|
|
58
|
+
const waylines = plan(template(), { lineSpacing: meters(25) });
|
|
59
|
+
|
|
60
|
+
expect(waylines.Folder).toHaveLength(1);
|
|
61
|
+
expect(waylines.Folder[0]!.Placemark).toHaveLength(8);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("shifts the flight path backward by height / tan(abs(pitch))", () => {
|
|
65
|
+
const placemarks = plan(template({ pitch: -45 }), {
|
|
66
|
+
lineSpacing: meters(25),
|
|
67
|
+
}).Folder[0]!.Placemark;
|
|
68
|
+
const centerLat =
|
|
69
|
+
placemarks.reduce((sum, point) => sum + point.Point.coordinates.lat, 0) / placemarks.length;
|
|
70
|
+
|
|
71
|
+
expect(centerLat).toBeCloseTo(0.000461, 5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retains every clipped segment of a concave polygon", () => {
|
|
75
|
+
const concave = template({ direction: 90 });
|
|
76
|
+
concave.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates =
|
|
77
|
+
"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";
|
|
78
|
+
|
|
79
|
+
const placemarks = plan(concave, { lineSpacing: meters(100) }).Folder[0]!.Placemark;
|
|
80
|
+
|
|
81
|
+
expect(placemarks).toHaveLength(10);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps fixed heading and pitch with one-third turn damping", () => {
|
|
85
|
+
const placemarks = plan(template({ pitch: -45, direction: 0 }), {
|
|
86
|
+
lineSpacing: meters(25),
|
|
87
|
+
}).Folder[0]!.Placemark;
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
placemarks.slice(0, -1).every((p) => p.waypointHeadingParam.waypointHeadingMode === "fixed"),
|
|
91
|
+
).toBe(true);
|
|
92
|
+
expect(placemarks[0]!.actionGroup?.[0]!.action[0]).toMatchObject({
|
|
93
|
+
actionActuatorFunc: "gimbalRotate",
|
|
94
|
+
actionActuatorFuncParam: { gimbalPitchRotateAngle: -45 },
|
|
95
|
+
});
|
|
96
|
+
expect(Number(placemarks[1]!.waypointTurnParam.waypointTurnDampingDist)).toBeCloseTo(25 / 3, 5);
|
|
97
|
+
expect(Number(placemarks[0]!.waypointTurnParam.waypointTurnDampingDist)).toBe(0);
|
|
98
|
+
expect(Number(placemarks.at(-1)!.waypointTurnParam.waypointTurnDampingDist)).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
|
|
2
|
+
import { PlannerError } from "../types/errors.js";
|
|
3
|
+
import type { ExecuteHeightMode } from "../types/enums.js";
|
|
4
|
+
import type { WaylinesPlacemark } from "../types/placemark.js";
|
|
5
|
+
import type { TargetDetectionFolder, TargetDetectionTemplate } from "../types/template.js";
|
|
6
|
+
import type { Waylines } from "../types/waylines.js";
|
|
7
|
+
import { clipHorizontal, offsetPolygon, parseCoordinatesString } from "./geometry.js";
|
|
8
|
+
import type { PlanOptions } from "./index.js";
|
|
9
|
+
|
|
10
|
+
const EARTH_RADIUS_M = 6_378_137;
|
|
11
|
+
const DEG2RAD = Math.PI / 180;
|
|
12
|
+
const RAD2DEG = 180 / Math.PI;
|
|
13
|
+
|
|
14
|
+
type Point = readonly [number, number];
|
|
15
|
+
|
|
16
|
+
function project({ lng, lat }: LngLat): Point {
|
|
17
|
+
return [
|
|
18
|
+
EARTH_RADIUS_M * lng * DEG2RAD,
|
|
19
|
+
EARTH_RADIUS_M * Math.log(Math.tan(Math.PI / 4 + (lat * DEG2RAD) / 2)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unproject([x, y]: Point): LngLat {
|
|
24
|
+
return {
|
|
25
|
+
lng: (x / EARTH_RADIUS_M) * RAD2DEG,
|
|
26
|
+
lat: (2 * Math.atan(Math.exp(y / EARTH_RADIUS_M)) - Math.PI / 2) * RAD2DEG,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function executeHeightMode(folder: TargetDetectionFolder): ExecuteHeightMode {
|
|
31
|
+
switch (folder.waylineCoordinateSysParam.heightMode) {
|
|
32
|
+
case "EGM96":
|
|
33
|
+
return "WGS84";
|
|
34
|
+
case "relativeToStartPoint":
|
|
35
|
+
return "relativeToStartPoint";
|
|
36
|
+
case "aboveGroundLevel":
|
|
37
|
+
case "realTimeFollowSurface":
|
|
38
|
+
return "realTimeFollowSurface";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gimbalAction(pitch: number) {
|
|
43
|
+
return {
|
|
44
|
+
actionId: 0,
|
|
45
|
+
actionActuatorFunc: "gimbalRotate" as const,
|
|
46
|
+
actionActuatorFuncParam: {
|
|
47
|
+
payloadPositionIndex: 0,
|
|
48
|
+
gimbalHeadingYawBase: "aircraft" as const,
|
|
49
|
+
gimbalRotateMode: "absoluteAngle" as const,
|
|
50
|
+
gimbalPitchRotateEnable: true,
|
|
51
|
+
gimbalPitchRotateAngle: degrees(pitch),
|
|
52
|
+
gimbalRollRotateEnable: false,
|
|
53
|
+
gimbalRollRotateAngle: degrees(0),
|
|
54
|
+
gimbalYawRotateEnable: false,
|
|
55
|
+
gimbalYawRotateAngle: degrees(0),
|
|
56
|
+
gimbalRotateTimeEnable: false,
|
|
57
|
+
gimbalRotateTime: 10,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pathPoints(
|
|
63
|
+
template: TargetDetectionTemplate,
|
|
64
|
+
lineSpacingM: number,
|
|
65
|
+
preferredStartPoint?: LngLat,
|
|
66
|
+
): LngLat[] {
|
|
67
|
+
const placemark = template.Folder.Placemark;
|
|
68
|
+
const ring = parseCoordinatesString(placemark.Polygon.outerBoundaryIs.LinearRing.coordinates);
|
|
69
|
+
if (ring.length < 3)
|
|
70
|
+
throw new PlannerError("targetdetection requires at least 3 polygon vertices");
|
|
71
|
+
|
|
72
|
+
const centerLat = ring.reduce((sum, point) => sum + point.lat, 0) / ring.length;
|
|
73
|
+
const scale = 1 / Math.cos(centerLat * DEG2RAD);
|
|
74
|
+
const theta = Number(placemark.direction) * DEG2RAD;
|
|
75
|
+
const u: Point = [Math.sin(theta), Math.cos(theta)];
|
|
76
|
+
const n: Point = [-Math.cos(theta), Math.sin(theta)];
|
|
77
|
+
const height = Number(placemark.height);
|
|
78
|
+
const pitch = Math.abs(Number(placemark.gimbalPitchAngle));
|
|
79
|
+
const lookAheadM = pitch >= 89.999999 ? 0 : height / Math.tan(pitch * DEG2RAD);
|
|
80
|
+
|
|
81
|
+
const translated = ring.map((point) => {
|
|
82
|
+
const [x, y] = project(point);
|
|
83
|
+
return [x - u[0] * lookAheadM * scale, y - u[1] * lookAheadM * scale] as Point;
|
|
84
|
+
});
|
|
85
|
+
const flightRing = offsetPolygon(translated, placemark.margin * scale);
|
|
86
|
+
const scanRing = flightRing.map(([x, y]) => [x * u[0] + y * u[1], x * n[0] + y * n[1]] as Point);
|
|
87
|
+
const cross = scanRing.map((point) => point[1]);
|
|
88
|
+
const min = Math.min(...cross);
|
|
89
|
+
const max = Math.max(...cross);
|
|
90
|
+
const spacing = lineSpacingM * scale;
|
|
91
|
+
const stripCount = Math.max(1, Math.floor((max - min) / spacing));
|
|
92
|
+
const center = (min + max) / 2;
|
|
93
|
+
|
|
94
|
+
const segments: [Point, Point][] = [];
|
|
95
|
+
for (let i = 0; i < stripCount; i++) {
|
|
96
|
+
const q = center + (i - (stripCount - 1) / 2) * spacing;
|
|
97
|
+
for (const [a, b] of clipHorizontal(q, scanRing)) {
|
|
98
|
+
segments.push([
|
|
99
|
+
[u[0] * a + n[0] * q, u[1] * a + n[1] * q],
|
|
100
|
+
[u[0] * b + n[0] * q, u[1] * b + n[1] * q],
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let points = segments.flatMap(([a, b], index) => (index % 2 === 0 ? [a, b] : [b, a]));
|
|
106
|
+
if (preferredStartPoint && points.length > 1) {
|
|
107
|
+
const preferred = project(preferredStartPoint);
|
|
108
|
+
const distance2 = ([x, y]: Point) => (x - preferred[0]) ** 2 + (y - preferred[1]) ** 2;
|
|
109
|
+
if (distance2(points.at(-1)!) < distance2(points[0]!)) points = points.reverse();
|
|
110
|
+
}
|
|
111
|
+
return points.map(unproject);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function turnDamping(points: readonly LngLat[], index: number): Meters {
|
|
115
|
+
if (index === 0 || index === points.length - 1) return meters(0);
|
|
116
|
+
const groundDistance = (a: LngLat, b: LngLat) => {
|
|
117
|
+
const [ax, ay] = project(a);
|
|
118
|
+
const [bx, by] = project(b);
|
|
119
|
+
return Math.hypot(bx - ax, by - ay) * Math.cos(((a.lat + b.lat) / 2) * DEG2RAD);
|
|
120
|
+
};
|
|
121
|
+
return meters(
|
|
122
|
+
Math.min(
|
|
123
|
+
10,
|
|
124
|
+
groundDistance(points[index - 1]!, points[index]!) / 3,
|
|
125
|
+
groundDistance(points[index]!, points[index + 1]!) / 3,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function planTargetDetection(
|
|
131
|
+
template: TargetDetectionTemplate,
|
|
132
|
+
options?: PlanOptions,
|
|
133
|
+
): Waylines {
|
|
134
|
+
const spacing = Number(options?.lineSpacing);
|
|
135
|
+
if (!(spacing > 0 && Number.isFinite(spacing))) {
|
|
136
|
+
throw new PlannerError("targetdetection requires options.lineSpacing");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const folder = template.Folder;
|
|
140
|
+
const pitch = Number(folder.Placemark.gimbalPitchAngle);
|
|
141
|
+
const direction = Number(folder.Placemark.direction);
|
|
142
|
+
const points = pathPoints(template, spacing, options?.preferredStartPoint);
|
|
143
|
+
if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
|
|
144
|
+
const lastIndex = points.length - 1;
|
|
145
|
+
const action = gimbalAction(pitch);
|
|
146
|
+
|
|
147
|
+
const placemarks: WaylinesPlacemark[] = points.map((point, index) => ({
|
|
148
|
+
Point: { coordinates: point },
|
|
149
|
+
index,
|
|
150
|
+
executeHeight:
|
|
151
|
+
folder.waylineCoordinateSysParam.heightMode === "aboveGroundLevel"
|
|
152
|
+
? (options?.resolveExecuteHeight?.({ ...point, index, waylineId: 0 }) ??
|
|
153
|
+
folder.Placemark.height)
|
|
154
|
+
: folder.Placemark.height,
|
|
155
|
+
waypointSpeed: folder.autoFlightSpeed,
|
|
156
|
+
waypointHeadingParam:
|
|
157
|
+
index === lastIndex
|
|
158
|
+
? { waypointHeadingMode: "followWayline", waypointHeadingPathMode: "followBadArc" }
|
|
159
|
+
: {
|
|
160
|
+
waypointHeadingMode: "fixed",
|
|
161
|
+
waypointHeadingAngle: degrees(direction),
|
|
162
|
+
waypointHeadingAngleEnable: true,
|
|
163
|
+
waypointHeadingPathMode: "followBadArc",
|
|
164
|
+
waypointHeadingPoiIndex: 0,
|
|
165
|
+
},
|
|
166
|
+
waypointTurnParam:
|
|
167
|
+
index === 0 || index === lastIndex
|
|
168
|
+
? {
|
|
169
|
+
waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
|
|
170
|
+
waypointTurnDampingDist: meters(0),
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
waypointTurnMode: "coordinateTurn",
|
|
174
|
+
waypointTurnDampingDist: turnDamping(points, index),
|
|
175
|
+
},
|
|
176
|
+
useStraightLine: true,
|
|
177
|
+
isRisky: false,
|
|
178
|
+
waypointWorkType: 0,
|
|
179
|
+
...(index === 0
|
|
180
|
+
? {
|
|
181
|
+
actionGroup: [
|
|
182
|
+
{
|
|
183
|
+
actionGroupId: 0,
|
|
184
|
+
actionGroupStartIndex: 0,
|
|
185
|
+
actionGroupEndIndex: lastIndex,
|
|
186
|
+
actionGroupMode: "sequence",
|
|
187
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
|
|
188
|
+
action: [action],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
missionConfig: template.missionConfig,
|
|
197
|
+
Folder: [
|
|
198
|
+
{
|
|
199
|
+
templateId: folder.templateId,
|
|
200
|
+
waylineId: 0,
|
|
201
|
+
executeHeightMode: executeHeightMode(folder),
|
|
202
|
+
autoFlightSpeed: folder.autoFlightSpeed,
|
|
203
|
+
startActionGroup: { action: [gimbalAction(pitch)] },
|
|
204
|
+
Placemark: placemarks,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
}
|
package/src/types/enums.ts
CHANGED
|
@@ -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 =
|
|
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,6 +68,7 @@ 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
|
|
|
@@ -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
|
}
|
package/src/types/placemark.ts
CHANGED
|
@@ -115,6 +115,20 @@ 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
|
+
}
|
|
131
|
+
|
|
118
132
|
/** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
|
|
119
133
|
export interface MappingStripPlacemark {
|
|
120
134
|
caliFlightEnable: boolean;
|
package/src/types/template.ts
CHANGED
|
@@ -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);
|