@lumikmz/kmz 0.2.0 → 0.3.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/README.md +69 -66
- package/dist/index.d.mts +215 -76
- package/dist/index.mjs +634 -157
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/plan/geometry.test.ts +96 -0
- package/src/plan/geometry.ts +161 -25
- package/src/plan/index.ts +23 -3
- package/src/plan/plan-mapping-strip.ts +3 -3
- package/src/plan/plan-mapping2d.ts +512 -89
- package/src/plan/plan-mapping3d.ts +157 -98
- package/src/plan/plan.test.ts +263 -9
- package/src/types/action.ts +65 -1
- package/src/types/drone-payload.ts +18 -12
- package/src/types/enums.ts +17 -3
- package/src/types/mission-config.ts +5 -0
- package/src/types/placemark.ts +92 -15
- package/src/types/template.ts +12 -76
- package/src/types/waylines.ts +12 -3
- package/src/types/waypoint-params.ts +18 -0
|
@@ -1,90 +1,189 @@
|
|
|
1
1
|
import { degrees } from "../types/branded.js";
|
|
2
|
-
import type { Action,
|
|
3
|
-
import type { ExecuteHeightMode } from "../types/enums.js";
|
|
2
|
+
import type { Action, GimbalRotateAction } from "../types/action.js";
|
|
3
|
+
import type { ExecuteHeightMode, WaypointHeadingMode } from "../types/enums.js";
|
|
4
4
|
import type { Mapping3dFolder, Mapping3dTemplate } from "../types/template.js";
|
|
5
5
|
import type { Waylines, WaylinesFolder } from "../types/waylines.js";
|
|
6
6
|
import type { WaylinesPlacemark } from "../types/placemark.js";
|
|
7
|
+
import { PlannerError } from "../types/errors.js";
|
|
7
8
|
import {
|
|
8
9
|
centroid,
|
|
9
10
|
enuToLngLat,
|
|
10
11
|
type EnuPoint,
|
|
11
12
|
lngLatToEnu,
|
|
13
|
+
orientPathToHome,
|
|
12
14
|
parseCoordinatesString,
|
|
13
|
-
|
|
15
|
+
surveyGrid,
|
|
14
16
|
} from "./geometry.js";
|
|
15
|
-
|
|
16
|
-
const DEFAULT_HFOV_DEG = 84;
|
|
17
|
+
import type { PlanOptions } from "./index.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
* one nadir
|
|
20
|
+
* mapping3d expands to 5 wayline folders for a 5-direction oblique survey:
|
|
21
|
+
* one nadir pass plus four 45°-oblique passes looking forward / right / back / left.
|
|
22
|
+
*
|
|
23
|
+
* Each pass is its own boustrophedon grid:
|
|
24
|
+
* - nadir + forward + backward fly the **main axis** (θ = folder.Placemark.direction);
|
|
25
|
+
* - right + left fly the **perpendicular axis** (θ ± 90°) so the aircraft flies
|
|
26
|
+
* nose-forward while the gimbal looks sideways.
|
|
27
|
+
* The two passes sharing an axis are interleaved by half a line spacing so they
|
|
28
|
+
* don't overlap and together give denser coverage.
|
|
29
|
+
*
|
|
30
|
+
* The look direction is the aircraft heading: each oblique pass fixes its heading
|
|
31
|
+
* to its look azimuth and the gimbal is pitched down relative to the **aircraft**
|
|
32
|
+
* (gimbalHeadingYawBase: "aircraft", yaw 0) — NOT relative to north.
|
|
21
33
|
*/
|
|
22
|
-
|
|
34
|
+
|
|
35
|
+
type PassPitch = "nadir" | "inclined";
|
|
36
|
+
|
|
37
|
+
interface PassSpec {
|
|
38
|
+
/** Grid flight-line axis, relative to folder.Placemark.direction (°). */
|
|
39
|
+
axisOffsetDeg: number;
|
|
40
|
+
/** Fixed heading / look azimuth, relative to folder.Placemark.direction (°). */
|
|
41
|
+
headingOffsetDeg: number;
|
|
42
|
+
/** nadir → −90° gimbal, inclined → folder.Placemark.inclinedGimbalPitch. */
|
|
43
|
+
pitch: PassPitch;
|
|
44
|
+
/** followWayline for nadir, fixed for obliques. */
|
|
45
|
+
headingMode: WaypointHeadingMode;
|
|
46
|
+
/** Shift the grid by half a line spacing to interleave with its axis-mate. */
|
|
47
|
+
halfPhase: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Look direction along the flight axis: +1 = looks toward +axis, −1 = toward
|
|
50
|
+
* −axis, 0 = nadir. The grid is shifted by −lookSign·standoff so it overhangs
|
|
51
|
+
* the boundary only on the side opposite the look (the camera images ahead).
|
|
52
|
+
*/
|
|
53
|
+
lookSign: -1 | 0 | 1;
|
|
54
|
+
/** Timed-shot interval (s). */
|
|
55
|
+
interval: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PASSES: readonly PassSpec[] = [
|
|
59
|
+
// 0 — nadir (ortho)
|
|
60
|
+
{ axisOffsetDeg: 0, headingOffsetDeg: 0, pitch: "nadir", headingMode: "followWayline", halfPhase: false, lookSign: 0, interval: 2.0 }, // prettier-ignore
|
|
61
|
+
// 1 — forward oblique (look +θ)
|
|
62
|
+
{ axisOffsetDeg: 0, headingOffsetDeg: 0, pitch: "inclined", headingMode: "fixed", halfPhase: false, lookSign: 1, interval: 2.5 }, // prettier-ignore
|
|
63
|
+
// 2 — right oblique (look θ+90), perpendicular grid
|
|
64
|
+
{ axisOffsetDeg: 90, headingOffsetDeg: 90, pitch: "inclined", headingMode: "fixed", halfPhase: false, lookSign: 1, interval: 2.5 }, // prettier-ignore
|
|
65
|
+
// 3 — backward oblique (look θ+180), main axis, interleaved with forward
|
|
66
|
+
{ axisOffsetDeg: 0, headingOffsetDeg: 180, pitch: "inclined", headingMode: "fixed", halfPhase: true, lookSign: -1, interval: 2.5 }, // prettier-ignore
|
|
67
|
+
// 4 — left oblique (look θ−90), perpendicular grid, interleaved with right
|
|
68
|
+
{ axisOffsetDeg: 90, headingOffsetDeg: -90, pitch: "inclined", headingMode: "fixed", halfPhase: true, lookSign: -1, interval: 2.5 }, // prettier-ignore
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Normalize an angle to (−180, 180]. */
|
|
72
|
+
function wrap180(deg: number): number {
|
|
73
|
+
return ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function planMapping3d(template: Mapping3dTemplate, options?: PlanOptions): Waylines {
|
|
77
|
+
if (options?.lineSpacing == null) {
|
|
78
|
+
throw new PlannerError(
|
|
79
|
+
"mapping3d requires options.lineSpacing — calculate 2·h·tan(HFOV/2)·(1−sideOverlap) in the demo and pass it in.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
23
82
|
const folder = template.Folder;
|
|
24
83
|
const ring = parseCoordinatesString(
|
|
25
84
|
folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates,
|
|
26
85
|
);
|
|
27
86
|
const origin = centroid(ring);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
buildObliquePass(
|
|
47
|
-
folder,
|
|
48
|
-
i,
|
|
49
|
-
origin,
|
|
50
|
-
enuWps,
|
|
51
|
-
pass.headingAngle,
|
|
52
|
-
pass.gimbalPitch,
|
|
53
|
-
pass.minShootInterval,
|
|
54
|
-
),
|
|
87
|
+
const ringEnu = ring.map((p) => lngLatToEnu(p, origin));
|
|
88
|
+
const baseDirection = Number(folder.Placemark.direction);
|
|
89
|
+
const lineSpacing = Number(options.lineSpacing);
|
|
90
|
+
const inclined = folder.Placemark.inclinedGimbalPitch;
|
|
91
|
+
|
|
92
|
+
const pitchAbs = Math.abs(Number(inclined));
|
|
93
|
+
const sinP = pitchAbs > 0 && pitchAbs < 90 ? Math.sin((pitchAbs * Math.PI) / 180) : 1;
|
|
94
|
+
const tanP = pitchAbs > 0 && pitchAbs < 90 ? Math.tan((pitchAbs * Math.PI) / 180) : Infinity;
|
|
95
|
+
|
|
96
|
+
// Oblique stand-off: a camera pitched |p|° below horizontal at height h images a
|
|
97
|
+
// ground point h/tan(|p|) ahead of the aircraft along its look (= flight) axis. The
|
|
98
|
+
// oblique grid is therefore shifted opposite the look by this distance, so it
|
|
99
|
+
// overhangs the boundary only on the back side (and pulls in on the look side) —
|
|
100
|
+
// matching how the camera actually covers the area. Clamp to 3·h so a near-
|
|
101
|
+
// horizontal pitch can't produce a runaway grid.
|
|
102
|
+
const standoff = Math.min(
|
|
103
|
+
Number(folder.Placemark.height) / tanP,
|
|
104
|
+
Number(folder.Placemark.height) * 3,
|
|
55
105
|
);
|
|
56
106
|
|
|
57
|
-
|
|
107
|
+
// Oblique line spacing is wider than nadir: a tilted camera images from a longer
|
|
108
|
+
// slant range (h/sin|p|), so its cross-track footprint is 1/sin|p| larger and the
|
|
109
|
+
// same overlap is reached with fewer lines. The four oblique directions together
|
|
110
|
+
// supply the multi-angle coverage, so each pass is intentionally sparse.
|
|
111
|
+
const obliqueSpacing = lineSpacing / sinP;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
missionConfig: template.missionConfig,
|
|
115
|
+
Folder: PASSES.map((pass, i) => {
|
|
116
|
+
const isOblique = pass.pitch === "inclined";
|
|
117
|
+
const passSpacing = isOblique ? obliqueSpacing : lineSpacing;
|
|
118
|
+
const enuWps = orientPathToHome(
|
|
119
|
+
surveyGrid(ringEnu, {
|
|
120
|
+
directionDeg: baseDirection + pass.axisOffsetDeg,
|
|
121
|
+
marginM: folder.Placemark.margin,
|
|
122
|
+
lineSpacing: passSpacing,
|
|
123
|
+
phaseOffsetM: pass.halfPhase ? passSpacing / 2 : 0,
|
|
124
|
+
lengthwiseShiftM: -pass.lookSign * standoff,
|
|
125
|
+
}),
|
|
126
|
+
origin,
|
|
127
|
+
template.missionConfig.takeOffRefPoint,
|
|
128
|
+
);
|
|
129
|
+
return buildPass(folder, i, origin, enuWps, {
|
|
130
|
+
headingMode: pass.headingMode,
|
|
131
|
+
headingAngle: degrees(wrap180(baseDirection + pass.headingOffsetDeg)),
|
|
132
|
+
gimbalPitch: pass.pitch === "nadir" ? degrees(-90) : inclined,
|
|
133
|
+
interval: pass.interval,
|
|
134
|
+
});
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
58
137
|
}
|
|
59
138
|
|
|
60
|
-
|
|
139
|
+
interface PassConfig {
|
|
140
|
+
headingMode: WaypointHeadingMode;
|
|
141
|
+
headingAngle: ReturnType<typeof degrees>;
|
|
142
|
+
gimbalPitch: ReturnType<typeof degrees>;
|
|
143
|
+
interval: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildPass(
|
|
61
147
|
folder: Mapping3dFolder,
|
|
62
148
|
waylineId: number,
|
|
63
149
|
origin: { lng: number; lat: number },
|
|
64
150
|
enuWps: readonly EnuPoint[],
|
|
65
|
-
|
|
66
|
-
gimbalPitch: number,
|
|
67
|
-
minShootInterval: number,
|
|
151
|
+
cfg: PassConfig,
|
|
68
152
|
): WaylinesFolder {
|
|
69
153
|
const lastIndex = enuWps.length - 1;
|
|
70
|
-
|
|
71
154
|
const placemarks: WaylinesPlacemark[] = enuWps.map((enu, index) => {
|
|
72
|
-
const coord = enuToLngLat(enu, origin);
|
|
73
155
|
const placemark: WaylinesPlacemark = {
|
|
74
|
-
Point: { coordinates:
|
|
156
|
+
Point: { coordinates: enuToLngLat(enu, origin) },
|
|
75
157
|
index,
|
|
76
|
-
executeHeight: folder.height,
|
|
77
|
-
waypointSpeed: folder.
|
|
158
|
+
executeHeight: folder.Placemark.height,
|
|
159
|
+
waypointSpeed: folder.Placemark.inclinedFlightSpeed,
|
|
78
160
|
waypointHeadingParam: {
|
|
79
|
-
waypointHeadingMode:
|
|
80
|
-
waypointHeadingAngle:
|
|
161
|
+
waypointHeadingMode: cfg.headingMode,
|
|
162
|
+
waypointHeadingAngle: cfg.headingAngle,
|
|
81
163
|
waypointHeadingPathMode: "followBadArc",
|
|
82
164
|
},
|
|
83
165
|
waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
|
|
84
166
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
167
|
+
if (index === 0) {
|
|
168
|
+
placemark.actionGroup = [
|
|
169
|
+
{
|
|
170
|
+
actionGroupId: 0,
|
|
171
|
+
actionGroupStartIndex: 0,
|
|
172
|
+
actionGroupEndIndex: lastIndex,
|
|
173
|
+
actionGroupMode: "sequence",
|
|
174
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
175
|
+
action: [gimbalRotate(0, cfg.gimbalPitch)],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
actionGroupId: 1,
|
|
179
|
+
actionGroupStartIndex: 0,
|
|
180
|
+
actionGroupEndIndex: lastIndex,
|
|
181
|
+
actionGroupMode: "sequence",
|
|
182
|
+
actionTrigger: { actionTriggerType: "multipleTiming", actionTriggerParam: cfg.interval },
|
|
183
|
+
action: [takePhoto(0)],
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}
|
|
88
187
|
return placemark;
|
|
89
188
|
});
|
|
90
189
|
|
|
@@ -92,65 +191,25 @@ function buildObliquePass(
|
|
|
92
191
|
templateId: folder.templateId,
|
|
93
192
|
waylineId,
|
|
94
193
|
executeHeightMode: heightModeToExecuteMode(folder.waylineCoordinateSysParam.heightMode),
|
|
95
|
-
autoFlightSpeed: folder.
|
|
96
|
-
startActionGroup:
|
|
194
|
+
autoFlightSpeed: folder.Placemark.inclinedFlightSpeed,
|
|
195
|
+
startActionGroup: {
|
|
196
|
+
action: [gimbalRotate(0, cfg.gimbalPitch)],
|
|
197
|
+
},
|
|
97
198
|
Placemark: placemarks,
|
|
98
199
|
};
|
|
99
200
|
}
|
|
100
201
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
lastIndex: number,
|
|
104
|
-
gimbalPitch: number,
|
|
105
|
-
minShootInterval: number,
|
|
106
|
-
): ActionGroup[] {
|
|
107
|
-
if (index === 0) {
|
|
108
|
-
return [
|
|
109
|
-
{
|
|
110
|
-
actionGroupId: 0,
|
|
111
|
-
actionGroupStartIndex: 0,
|
|
112
|
-
actionGroupEndIndex: lastIndex,
|
|
113
|
-
actionGroupMode: "sequence",
|
|
114
|
-
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
115
|
-
action: [gimbalRotate(0, gimbalPitch)],
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
actionGroupId: 1,
|
|
119
|
-
actionGroupStartIndex: 0,
|
|
120
|
-
actionGroupEndIndex: lastIndex,
|
|
121
|
-
actionGroupMode: "sequence",
|
|
122
|
-
actionTrigger: {
|
|
123
|
-
actionTriggerType: "multipleTiming",
|
|
124
|
-
actionTriggerParam: minShootInterval,
|
|
125
|
-
},
|
|
126
|
-
action: [takePhoto(0)],
|
|
127
|
-
},
|
|
128
|
-
];
|
|
129
|
-
}
|
|
130
|
-
return [];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function startGimbalRotateGroup(gimbalPitch: number): ActionGroup {
|
|
134
|
-
return {
|
|
135
|
-
actionGroupId: 65535,
|
|
136
|
-
actionGroupStartIndex: 0,
|
|
137
|
-
actionGroupEndIndex: 0,
|
|
138
|
-
actionGroupMode: "sequence",
|
|
139
|
-
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
140
|
-
action: [gimbalRotate(0, gimbalPitch)],
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function gimbalRotate(actionId: number, pitch: number): GimbalRotateAction {
|
|
202
|
+
/** Oblique gimbal: pitched down relative to the aircraft nose (yaw base = aircraft). */
|
|
203
|
+
function gimbalRotate(actionId: number, pitch: ReturnType<typeof degrees>): GimbalRotateAction {
|
|
145
204
|
return {
|
|
146
205
|
actionId,
|
|
147
206
|
actionActuatorFunc: "gimbalRotate",
|
|
148
207
|
actionActuatorFuncParam: {
|
|
149
208
|
payloadPositionIndex: 0,
|
|
150
|
-
gimbalHeadingYawBase: "
|
|
209
|
+
gimbalHeadingYawBase: "aircraft",
|
|
151
210
|
gimbalRotateMode: "absoluteAngle",
|
|
152
211
|
gimbalPitchRotateEnable: true,
|
|
153
|
-
gimbalPitchRotateAngle:
|
|
212
|
+
gimbalPitchRotateAngle: pitch,
|
|
154
213
|
gimbalRollRotateEnable: false,
|
|
155
214
|
gimbalRollRotateAngle: degrees(0),
|
|
156
215
|
gimbalYawRotateEnable: false,
|
package/src/plan/plan.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { degrees, meters, metersPerSecond } from "../types/branded.js";
|
|
3
3
|
import { DroneEnum, PayloadEnum } from "../types/drone-payload.js";
|
|
4
|
-
import type { Mapping2dTemplate, WaypointTemplate } from "../types/template.js";
|
|
4
|
+
import type { Mapping2dTemplate, Mapping3dTemplate, WaypointTemplate } from "../types/template.js";
|
|
5
5
|
import { plan } from "./index.js";
|
|
6
6
|
|
|
7
7
|
const missionConfig = {
|
|
@@ -69,14 +69,14 @@ describe("plan", () => {
|
|
|
69
69
|
templateId: 1,
|
|
70
70
|
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "relativeToStartPoint" },
|
|
71
71
|
autoFlightSpeed: metersPerSecond(10),
|
|
72
|
-
elevationOptimizeEnable: false,
|
|
73
|
-
shootType: "distance",
|
|
74
|
-
direction: degrees(0),
|
|
75
|
-
margin: 0,
|
|
76
|
-
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
77
|
-
ellipsoidHeight: meters(150),
|
|
78
|
-
height: meters(100),
|
|
79
72
|
Placemark: {
|
|
73
|
+
elevationOptimizeEnable: false,
|
|
74
|
+
shootType: "distance",
|
|
75
|
+
direction: degrees(0),
|
|
76
|
+
margin: 0,
|
|
77
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
78
|
+
ellipsoidHeight: meters(150),
|
|
79
|
+
height: meters(100),
|
|
80
80
|
Polygon: {
|
|
81
81
|
outerBoundaryIs: {
|
|
82
82
|
LinearRing: {
|
|
@@ -89,8 +89,262 @@ describe("plan", () => {
|
|
|
89
89
|
},
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
const waylines = plan(template);
|
|
92
|
+
const waylines = plan(template, { lineSpacing: meters(45) });
|
|
93
93
|
expect(waylines.Folder).toHaveLength(1);
|
|
94
94
|
expect(waylines.Folder[0]!.Placemark.length).toBeGreaterThan(0);
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
it("accepts surface-follow + dsmFile on a mapping2d waylineCoordinateSysParam", () => {
|
|
98
|
+
const template: Mapping2dTemplate = {
|
|
99
|
+
missionConfig,
|
|
100
|
+
Folder: {
|
|
101
|
+
templateType: "mapping2d",
|
|
102
|
+
templateId: 2,
|
|
103
|
+
waylineCoordinateSysParam: {
|
|
104
|
+
coordinateMode: "WGS84",
|
|
105
|
+
heightMode: "aboveGroundLevel",
|
|
106
|
+
surfaceFollowModeEnable: true,
|
|
107
|
+
isRealtimeSurfaceFollow: false,
|
|
108
|
+
dsmFile: "wpmz/res/dsm/test.tif",
|
|
109
|
+
},
|
|
110
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
111
|
+
Placemark: {
|
|
112
|
+
elevationOptimizeEnable: false,
|
|
113
|
+
shootType: "distance",
|
|
114
|
+
direction: degrees(0),
|
|
115
|
+
margin: 0,
|
|
116
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
117
|
+
ellipsoidHeight: meters(150),
|
|
118
|
+
height: meters(100),
|
|
119
|
+
Polygon: {
|
|
120
|
+
outerBoundaryIs: {
|
|
121
|
+
LinearRing: {
|
|
122
|
+
coordinates:
|
|
123
|
+
"113.938,22.5397,0 113.940,22.5397,0 113.940,22.5407,0 113.938,22.5407,0 113.938,22.5397,0",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
expect(plan(template, { lineSpacing: meters(30) }).Folder).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("mapping2d ortho: grid density follows provided lineSpacing", () => {
|
|
134
|
+
const template: Mapping2dTemplate = {
|
|
135
|
+
missionConfig,
|
|
136
|
+
Folder: {
|
|
137
|
+
templateType: "mapping2d",
|
|
138
|
+
templateId: 1,
|
|
139
|
+
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "relativeToStartPoint" },
|
|
140
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
141
|
+
Placemark: {
|
|
142
|
+
elevationOptimizeEnable: false,
|
|
143
|
+
shootType: "distance",
|
|
144
|
+
direction: degrees(0),
|
|
145
|
+
margin: 0,
|
|
146
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
147
|
+
ellipsoidHeight: meters(150),
|
|
148
|
+
height: meters(100),
|
|
149
|
+
Polygon: {
|
|
150
|
+
outerBoundaryIs: {
|
|
151
|
+
LinearRing: {
|
|
152
|
+
// ~400m × ~400m square
|
|
153
|
+
coordinates:
|
|
154
|
+
"113.9380,22.5397,0 113.9419,22.5397,0 113.9419,22.5433,0 113.9380,22.5433,0 113.9380,22.5397,0",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
// lineSpacing=45m → ~9 lines across 400m → ~18 placemarks
|
|
162
|
+
const wl = plan(template, { lineSpacing: meters(45) });
|
|
163
|
+
expect(wl.Folder).toHaveLength(1);
|
|
164
|
+
const n = wl.Folder[0]!.Placemark.length;
|
|
165
|
+
expect(n).toBeGreaterThanOrEqual(12);
|
|
166
|
+
expect(n).toBeLessThanOrEqual(28);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("elevationOptimizeEnable appends a centroid excursion; off adds nothing", () => {
|
|
170
|
+
const ortho = (elevationOptimizeEnable: boolean): Mapping2dTemplate => ({
|
|
171
|
+
missionConfig,
|
|
172
|
+
Folder: {
|
|
173
|
+
templateType: "mapping2d",
|
|
174
|
+
templateId: 1,
|
|
175
|
+
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "relativeToStartPoint" },
|
|
176
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
177
|
+
Placemark: {
|
|
178
|
+
elevationOptimizeEnable,
|
|
179
|
+
shootType: "distance",
|
|
180
|
+
direction: degrees(0),
|
|
181
|
+
margin: 0,
|
|
182
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
183
|
+
ellipsoidHeight: meters(150),
|
|
184
|
+
height: meters(100),
|
|
185
|
+
Polygon: {
|
|
186
|
+
outerBoundaryIs: {
|
|
187
|
+
LinearRing: {
|
|
188
|
+
coordinates:
|
|
189
|
+
"113.9380,22.5397,0 113.9419,22.5397,0 113.9419,22.5433,0 113.9380,22.5433,0 113.9380,22.5397,0",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const off = plan(ortho(false), { lineSpacing: meters(45) }).Folder[0]!;
|
|
197
|
+
const on = plan(ortho(true), { lineSpacing: meters(45) }).Folder[0]!;
|
|
198
|
+
// The excursion is exactly two extra waypoints ending at the survey centroid.
|
|
199
|
+
expect(on.Placemark.length).toBe(off.Placemark.length + 2);
|
|
200
|
+
const last = on.Placemark[on.Placemark.length - 1]!.Point.coordinates;
|
|
201
|
+
expect(last.lng).toBeCloseTo(113.93995, 3); // centroid lng
|
|
202
|
+
expect(last.lat).toBeCloseTo(22.5415, 3); // centroid lat
|
|
203
|
+
// Without elevation optimization the route must NOT detour to the centroid.
|
|
204
|
+
const offLast = off.Placemark[off.Placemark.length - 1]!.Point.coordinates;
|
|
205
|
+
expect(
|
|
206
|
+
Math.abs(offLast.lng - 113.93995) > 0.0005 || Math.abs(offLast.lat - 22.5415) > 0.0005,
|
|
207
|
+
).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("mapping2d + smartObliqueEnable emits one folder with smart-oblique actions", () => {
|
|
211
|
+
const template: Mapping2dTemplate = {
|
|
212
|
+
missionConfig,
|
|
213
|
+
Folder: {
|
|
214
|
+
templateType: "mapping2d",
|
|
215
|
+
templateId: 1,
|
|
216
|
+
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "relativeToStartPoint" },
|
|
217
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
218
|
+
Placemark: {
|
|
219
|
+
elevationOptimizeEnable: false,
|
|
220
|
+
smartObliqueEnable: true,
|
|
221
|
+
smartObliqueGimbalPitch: degrees(-45),
|
|
222
|
+
shootType: "time",
|
|
223
|
+
direction: degrees(0),
|
|
224
|
+
margin: 0,
|
|
225
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
226
|
+
ellipsoidHeight: meters(150),
|
|
227
|
+
height: meters(100),
|
|
228
|
+
Polygon: {
|
|
229
|
+
outerBoundaryIs: {
|
|
230
|
+
LinearRing: {
|
|
231
|
+
coordinates:
|
|
232
|
+
"113.9380,22.5397,0 113.9419,22.5397,0 113.9419,22.5433,0 113.9380,22.5433,0 113.9380,22.5397,0",
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const wl = plan(template, { lineSpacing: meters(45) });
|
|
240
|
+
expect(wl.Folder).toHaveLength(1);
|
|
241
|
+
const funcs = wl.Folder[0]!.Placemark.flatMap((p) =>
|
|
242
|
+
(p.actionGroup ?? []).flatMap((g) => g.action.map((a) => a.actionActuatorFunc)),
|
|
243
|
+
);
|
|
244
|
+
expect(funcs).toContain("startSmartOblique");
|
|
245
|
+
expect(funcs).toContain("stopSmartOblique");
|
|
246
|
+
|
|
247
|
+
// The camera swings omnidirectionally, so the polygon is dilated by the
|
|
248
|
+
// stand-off and the grid overflies the boundary on EVERY side. Polygon spans
|
|
249
|
+
// lat 22.5397–22.5433, lng 113.9380–113.9419; at h=100, swing 45° the stand-off
|
|
250
|
+
// ≈ 100 m ≈ 0.0009°, so waypoints extend past all four edges.
|
|
251
|
+
const pts = wl.Folder[0]!.Placemark.map((p) => p.Point.coordinates);
|
|
252
|
+
const lats = pts.map((p) => p.lat);
|
|
253
|
+
const lngs = pts.map((p) => p.lng);
|
|
254
|
+
expect(Math.max(...lats)).toBeGreaterThan(22.5436); // past north edge
|
|
255
|
+
expect(Math.min(...lats)).toBeLessThan(22.5394); // past south edge
|
|
256
|
+
expect(Math.max(...lngs)).toBeGreaterThan(113.942); // past east edge
|
|
257
|
+
expect(Math.min(...lngs)).toBeLessThan(113.9377); // past west edge
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("mapping3d expands to 5 distinct passes: nadir + forward/right/back/left oblique", () => {
|
|
261
|
+
const template: Mapping3dTemplate = {
|
|
262
|
+
missionConfig,
|
|
263
|
+
Folder: {
|
|
264
|
+
templateType: "mapping3d",
|
|
265
|
+
templateId: 0,
|
|
266
|
+
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "relativeToStartPoint" },
|
|
267
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
268
|
+
Placemark: {
|
|
269
|
+
inclinedGimbalPitch: degrees(-45),
|
|
270
|
+
inclinedFlightSpeed: metersPerSecond(10),
|
|
271
|
+
shootType: "time",
|
|
272
|
+
direction: degrees(0),
|
|
273
|
+
margin: 0,
|
|
274
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
275
|
+
ellipsoidHeight: meters(150),
|
|
276
|
+
height: meters(100),
|
|
277
|
+
Polygon: {
|
|
278
|
+
outerBoundaryIs: {
|
|
279
|
+
LinearRing: {
|
|
280
|
+
// ~800 m (E-W) × ~220 m (N-S) rectangle — non-square so the
|
|
281
|
+
// main-axis and perpendicular-axis grids differ in line count.
|
|
282
|
+
coordinates:
|
|
283
|
+
"113.9380,22.5397,0 113.9460,22.5397,0 113.9460,22.5417,0 113.9380,22.5417,0 113.9380,22.5397,0",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const wl = plan(template, { lineSpacing: meters(45) });
|
|
291
|
+
expect(wl.Folder).toHaveLength(5);
|
|
292
|
+
expect(wl.Folder.map((f) => f.waylineId)).toEqual([0, 1, 2, 3, 4]);
|
|
293
|
+
|
|
294
|
+
const gimbalParam = (i: number) => {
|
|
295
|
+
const action = wl.Folder[i]!.startActionGroup!.action[0]!;
|
|
296
|
+
if (action.actionActuatorFunc !== "gimbalRotate") throw new Error("expected gimbalRotate");
|
|
297
|
+
return action.actionActuatorFuncParam;
|
|
298
|
+
};
|
|
299
|
+
const startPitch = (i: number) => Number(gimbalParam(i).gimbalPitchRotateAngle);
|
|
300
|
+
const yawBase = (i: number) => gimbalParam(i).gimbalHeadingYawBase;
|
|
301
|
+
const heading = (i: number) => wl.Folder[i]!.Placemark[0]!.waypointHeadingParam!;
|
|
302
|
+
|
|
303
|
+
// Nadir looks straight down; the four obliques are pitched to the inclined angle.
|
|
304
|
+
expect(startPitch(0)).toBe(-90);
|
|
305
|
+
expect([startPitch(1), startPitch(2), startPitch(3), startPitch(4)]).toEqual([
|
|
306
|
+
-45, -45, -45, -45,
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
// Gimbal pitch is relative to the aircraft nose (not north) for every pass.
|
|
310
|
+
expect([0, 1, 2, 3, 4].every((i) => yawBase(i) === "aircraft")).toBe(true);
|
|
311
|
+
|
|
312
|
+
// Nadir follows the path; obliques fix heading to their look azimuth.
|
|
313
|
+
expect(heading(0).waypointHeadingMode).toBe("followWayline");
|
|
314
|
+
expect([1, 2, 3, 4].every((i) => heading(i).waypointHeadingMode === "fixed")).toBe(true);
|
|
315
|
+
// direction=0 → forward 0°, right +90°, back ±180°, left −90°.
|
|
316
|
+
expect(Number(heading(1).waypointHeadingAngle)).toBe(0);
|
|
317
|
+
expect(Number(heading(2).waypointHeadingAngle)).toBe(90);
|
|
318
|
+
expect(Math.abs(Number(heading(3).waypointHeadingAngle))).toBe(180);
|
|
319
|
+
expect(Number(heading(4).waypointHeadingAngle)).toBe(-90);
|
|
320
|
+
|
|
321
|
+
// Perpendicular passes (2,4) run a different axis → different line count
|
|
322
|
+
// than the main-axis passes (0,1,3) on this non-square polygon.
|
|
323
|
+
expect(wl.Folder[2]!.Placemark.length).not.toBe(wl.Folder[0]!.Placemark.length);
|
|
324
|
+
|
|
325
|
+
// Forward (1) and backward (3) share the main axis but are interleaved by
|
|
326
|
+
// half a line spacing → their first waypoints differ.
|
|
327
|
+
expect(wl.Folder[1]!.Placemark[0]!.Point.coordinates).not.toEqual(
|
|
328
|
+
wl.Folder[3]!.Placemark[0]!.Point.coordinates,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// direction=0 → main-axis lines run N-S; the stand-off shifts each oblique grid
|
|
332
|
+
// along that axis, opposite its look, so it overhangs the boundary on ONE side.
|
|
333
|
+
const latRange = (i: number) => {
|
|
334
|
+
const lats = wl.Folder[i]!.Placemark.map((p) => p.Point.coordinates.lat);
|
|
335
|
+
return { min: Math.min(...lats), max: Math.max(...lats) };
|
|
336
|
+
};
|
|
337
|
+
const nadir = latRange(0);
|
|
338
|
+
const fwd = latRange(1); // looks +θ (north) → shifted south
|
|
339
|
+
const back = latRange(3); // looks −θ (south) → shifted north
|
|
340
|
+
// Forward overhangs the south edge but pulls in from the north (look) side.
|
|
341
|
+
expect(fwd.min).toBeLessThan(nadir.min);
|
|
342
|
+
expect(fwd.max).toBeLessThan(nadir.max);
|
|
343
|
+
// Backward overhangs the opposite (north) edge.
|
|
344
|
+
expect(back.max).toBeGreaterThan(nadir.max);
|
|
345
|
+
|
|
346
|
+
// Oblique passes use wider line spacing (slant-range factor) → fewer flight
|
|
347
|
+
// lines than the same-axis nadir pass, despite covering the same width.
|
|
348
|
+
expect(wl.Folder[1]!.Placemark.length).toBeLessThan(wl.Folder[0]!.Placemark.length);
|
|
349
|
+
});
|
|
96
350
|
});
|