@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.
@@ -1,90 +1,189 @@
1
1
  import { degrees } from "../types/branded.js";
2
- import type { Action, ActionGroup, GimbalRotateAction } from "../types/action.js";
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
- scanWaypoints,
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
- * Compile a `mapping3d` (oblique photography) template into 5 waylines:
20
- * one nadir (ortho) pass + four oblique passes (front/back/left/right).
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
- export function planMapping3d(template: Mapping3dTemplate): Waylines {
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 enuPolygon = ring.map((p) => lngLatToEnu(p, origin));
29
-
30
- const hfovRad = (DEFAULT_HFOV_DEG / 2) * (Math.PI / 180);
31
- const footprintWidth = 2 * folder.height * Math.tan(hfovRad);
32
- const sideOverlap = folder.overlap.orthoCameraOverlapW ?? 70;
33
- const lineSpacing = footprintWidth * (1 - sideOverlap / 100);
34
-
35
- const enuWps = scanWaypoints(enuPolygon, lineSpacing);
36
-
37
- const passes = [
38
- { headingAngle: 0, gimbalPitch: -90, minShootInterval: 2.0 },
39
- { headingAngle: 0, gimbalPitch: folder.inclinedGimbalPitch, minShootInterval: 2.5 },
40
- { headingAngle: 180, gimbalPitch: folder.inclinedGimbalPitch, minShootInterval: 2.5 },
41
- { headingAngle: 90, gimbalPitch: folder.inclinedGimbalPitch, minShootInterval: 2.5 },
42
- { headingAngle: -90, gimbalPitch: folder.inclinedGimbalPitch, minShootInterval: 2.5 },
43
- ];
44
-
45
- const Folder: WaylinesFolder[] = passes.map((pass, i) =>
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
- return { missionConfig: template.missionConfig, Folder };
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
- function buildObliquePass(
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
- headingAngle: number,
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: coord },
156
+ Point: { coordinates: enuToLngLat(enu, origin) },
75
157
  index,
76
- executeHeight: folder.height,
77
- waypointSpeed: folder.autoFlightSpeed,
158
+ executeHeight: folder.Placemark.height,
159
+ waypointSpeed: folder.Placemark.inclinedFlightSpeed,
78
160
  waypointHeadingParam: {
79
- waypointHeadingMode: "fixed",
80
- waypointHeadingAngle: degrees(headingAngle),
161
+ waypointHeadingMode: cfg.headingMode,
162
+ waypointHeadingAngle: cfg.headingAngle,
81
163
  waypointHeadingPathMode: "followBadArc",
82
164
  },
83
165
  waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
84
166
  };
85
-
86
- const groups = waypointActionGroups(index, lastIndex, gimbalPitch, minShootInterval);
87
- if (groups.length > 0) placemark.actionGroup = groups;
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.autoFlightSpeed,
96
- startActionGroup: startGimbalRotateGroup(gimbalPitch),
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
- function waypointActionGroups(
102
- index: number,
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: "north",
209
+ gimbalHeadingYawBase: "aircraft",
151
210
  gimbalRotateMode: "absoluteAngle",
152
211
  gimbalPitchRotateEnable: true,
153
- gimbalPitchRotateAngle: degrees(pitch),
212
+ gimbalPitchRotateAngle: pitch,
154
213
  gimbalRollRotateEnable: false,
155
214
  gimbalRollRotateAngle: degrees(0),
156
215
  gimbalYawRotateEnable: false,
@@ -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
  });