@lumikmz/kmz 0.7.0 → 0.8.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 +49 -4
- package/dist/index.mjs +786 -67
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/plan/geometry.test.ts +18 -0
- package/src/plan/geometry.ts +56 -5
- package/src/plan/index.ts +7 -1
- package/src/plan/plan-mapping-strip.test.ts +540 -0
- package/src/plan/plan-mapping-strip.ts +1056 -48
- package/src/plan/plan-mapping2d.ts +2 -2
- package/src/types/action.ts +3 -3
- package/src/types/placemark.ts +4 -0
- package/src/validate/validate-template.ts +57 -9
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { degrees, meters, metersPerSecond } from "../types/branded.js";
|
|
3
|
+
import type { MappingStripTemplate } from "../types/template.js";
|
|
4
|
+
import { deriveMappingStrip } from "./plan-mapping-strip.js";
|
|
5
|
+
import { plan } from "./index.js";
|
|
6
|
+
import { haversineMeters } from "./geometry.js";
|
|
7
|
+
import { validate } from "../validate/index.js";
|
|
8
|
+
|
|
9
|
+
const missionConfig = {
|
|
10
|
+
flyToWaylineMode: "safely" as const,
|
|
11
|
+
finishAction: "goHome" as const,
|
|
12
|
+
exitOnRCLost: "goContinue" as const,
|
|
13
|
+
executeRCLostAction: "goBack" as const,
|
|
14
|
+
takeOffSecurityHeight: meters(20),
|
|
15
|
+
globalTransitionalSpeed: metersPerSecond(10),
|
|
16
|
+
globalRTHHeight: meters(100),
|
|
17
|
+
droneInfo: { droneEnumValue: 100, droneSubEnumValue: 1 },
|
|
18
|
+
payloadInfo: [{ payloadEnumValue: 99, payloadSubEnumValue: 2, payloadPositionIndex: 0 }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function stripTemplate(
|
|
22
|
+
patch: Partial<MappingStripTemplate["Folder"]["Placemark"]> = {},
|
|
23
|
+
): MappingStripTemplate {
|
|
24
|
+
return {
|
|
25
|
+
missionConfig,
|
|
26
|
+
Folder: {
|
|
27
|
+
templateType: "mappingStrip",
|
|
28
|
+
templateId: 0,
|
|
29
|
+
waylineCoordinateSysParam: {
|
|
30
|
+
coordinateMode: "WGS84",
|
|
31
|
+
heightMode: "relativeToStartPoint",
|
|
32
|
+
globalShootHeight: meters(100),
|
|
33
|
+
},
|
|
34
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
35
|
+
payloadParam: { payloadPositionIndex: 0, imageFormat: ["visable"] },
|
|
36
|
+
Placemark: {
|
|
37
|
+
caliFlightEnable: false,
|
|
38
|
+
shootType: "time",
|
|
39
|
+
direction: degrees(0),
|
|
40
|
+
margin: 0,
|
|
41
|
+
singleLineEnable: false,
|
|
42
|
+
cuttingDistance: 100,
|
|
43
|
+
boundaryOptimEnable: false,
|
|
44
|
+
leftExtend: 10,
|
|
45
|
+
rightExtend: 20,
|
|
46
|
+
includeCenterEnable: false,
|
|
47
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
48
|
+
LineString: { coordinates: "0,0,0 0.0025,0,0" },
|
|
49
|
+
ellipsoidHeight: meters(100),
|
|
50
|
+
height: meters(100),
|
|
51
|
+
stripUseTemplateAltitude: false,
|
|
52
|
+
...patch,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const options = {
|
|
59
|
+
crossTrackFootprint: meters(20),
|
|
60
|
+
lineSpacing: meters(20),
|
|
61
|
+
photoSpacing: meters(10),
|
|
62
|
+
shootInterval: 1,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function officialPerpendicularTemplate(): MappingStripTemplate {
|
|
66
|
+
const template = stripTemplate({
|
|
67
|
+
direction: degrees(90),
|
|
68
|
+
cuttingDistance: 500,
|
|
69
|
+
leftExtend: 20,
|
|
70
|
+
rightExtend: 20,
|
|
71
|
+
LineString: {
|
|
72
|
+
coordinates:
|
|
73
|
+
"113.93997035048,22.5865507192322,0 " +
|
|
74
|
+
"113.940518577471,22.5865663826002,0 " +
|
|
75
|
+
"113.941066804979,22.5865820441085,0 " +
|
|
76
|
+
"113.942725461619,22.5866167444266,0 " +
|
|
77
|
+
"113.942561470892,22.5850786058152,0 " +
|
|
78
|
+
"113.941014448042,22.5844259068028,0",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
template.missionConfig.takeOffRefPoint = {
|
|
82
|
+
lng: 113.937913,
|
|
83
|
+
lat: 22.586278,
|
|
84
|
+
height: meters(52.064712),
|
|
85
|
+
};
|
|
86
|
+
template.Folder.autoFlightSpeed = metersPerSecond(11);
|
|
87
|
+
template.Folder.payloadParam!.imageFormat = ["visable", "ir"];
|
|
88
|
+
return template;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const officialPerpendicularOptions = {
|
|
92
|
+
crossTrackFootprint: meters(58.0385533694263),
|
|
93
|
+
lineSpacing: meters(17.41156601082789),
|
|
94
|
+
photoSpacing: meters(9.286168539108209),
|
|
95
|
+
shootInterval: 9.286168539108209 / 11,
|
|
96
|
+
stripDirection: "perpendicular" as const,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function officialParallelTemplate(): MappingStripTemplate {
|
|
100
|
+
const template = stripTemplate({
|
|
101
|
+
direction: degrees(0),
|
|
102
|
+
cuttingDistance: 500,
|
|
103
|
+
leftExtend: 10,
|
|
104
|
+
rightExtend: 10,
|
|
105
|
+
stripMergedRegions: [[1, 3]],
|
|
106
|
+
LineString: {
|
|
107
|
+
coordinates:
|
|
108
|
+
"113.941248708397,22.5864247808395,0 " +
|
|
109
|
+
"113.941253956609,22.587043600882,0 " +
|
|
110
|
+
"113.943240592044,22.5870606460195,0 " +
|
|
111
|
+
"113.943272505926,22.5835995315534,0 " +
|
|
112
|
+
"113.939088405347,22.5834895035782,0 " +
|
|
113
|
+
"113.939067471512,22.5855352765462,0 " +
|
|
114
|
+
"113.941243230473,22.5855676449574,0",
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
template.Folder.payloadParam!.imageFormat = ["visable", "ir"];
|
|
118
|
+
return template;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const officialParallelOptions = {
|
|
122
|
+
crossTrackFootprint: meters(89.995),
|
|
123
|
+
lineSpacing: meters(26.9985),
|
|
124
|
+
photoSpacing: meters(14),
|
|
125
|
+
shootInterval: 1.4,
|
|
126
|
+
stripDirection: "parallel" as const,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
describe("deriveMappingStrip", () => {
|
|
130
|
+
it("validates distinct center points and positive geometry parameters", () => {
|
|
131
|
+
const template = stripTemplate({
|
|
132
|
+
LineString: { coordinates: "0,0,0 0,0,0" },
|
|
133
|
+
leftExtend: -1,
|
|
134
|
+
cuttingDistance: 0,
|
|
135
|
+
stripMergedRegions: [[2, 1]],
|
|
136
|
+
});
|
|
137
|
+
const issues = validate(template);
|
|
138
|
+
|
|
139
|
+
expect(issues.map(({ path }) => path)).toEqual(
|
|
140
|
+
expect.arrayContaining([
|
|
141
|
+
"Folder.Placemark.LineString",
|
|
142
|
+
"Folder.Placemark.leftExtend",
|
|
143
|
+
"Folder.Placemark.cuttingDistance",
|
|
144
|
+
"Folder.Placemark.stripMergedRegions",
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("cuts the center line at exact accumulated distances", () => {
|
|
150
|
+
const derived = deriveMappingStrip(stripTemplate(), options);
|
|
151
|
+
|
|
152
|
+
expect(derived.regions).toHaveLength(3);
|
|
153
|
+
expect(derived.regions.map((region) => Number(region.startDistance))).toEqual([0, 100, 200]);
|
|
154
|
+
expect(Number(derived.regions[0]!.endDistance)).toBeCloseTo(100, 6);
|
|
155
|
+
expect(Number(derived.regions[1]!.endDistance)).toBeCloseTo(200, 6);
|
|
156
|
+
expect(Number(derived.regions[2]!.endDistance)).toBeCloseTo(277.99, 0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("applies FlightHub one-based inclusive merged-region ranges", () => {
|
|
160
|
+
const template = stripTemplate({
|
|
161
|
+
cuttingDistance: 500,
|
|
162
|
+
stripMergedRegions: [[1, 3]],
|
|
163
|
+
LineString: { coordinates: "0,0,0 0.016,0,0" },
|
|
164
|
+
});
|
|
165
|
+
const merged = deriveMappingStrip(template, options);
|
|
166
|
+
const split = deriveMappingStrip(
|
|
167
|
+
stripTemplate({
|
|
168
|
+
cuttingDistance: 500,
|
|
169
|
+
LineString: { coordinates: "0,0,0 0.016,0,0" },
|
|
170
|
+
}),
|
|
171
|
+
options,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(split.regions).toHaveLength(4);
|
|
175
|
+
expect(merged.regions).toHaveLength(2);
|
|
176
|
+
expect(merged.regions[0]!.sourceRegionIndexes).toEqual([0, 1, 2]);
|
|
177
|
+
expect(Number(merged.regions[0]!.endDistance)).toBeCloseTo(1_500, 5);
|
|
178
|
+
expect(plan(template, options).Folder).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("builds an asymmetric flat-capped corridor", () => {
|
|
182
|
+
const derived = deriveMappingStrip(
|
|
183
|
+
stripTemplate({ cuttingDistance: 1_000, singleLineEnable: true }),
|
|
184
|
+
options,
|
|
185
|
+
);
|
|
186
|
+
const polygon = derived.regions[0]!.polygon;
|
|
187
|
+
const north = Math.max(...polygon.map((point) => point.lat));
|
|
188
|
+
const south = Math.min(...polygon.map((point) => point.lat));
|
|
189
|
+
|
|
190
|
+
expect(north).toBeCloseTo(10 / 111_195, 6);
|
|
191
|
+
expect(south).toBeCloseTo(-20 / 111_195, 6);
|
|
192
|
+
expect(polygon[0]!.lng).toBeCloseTo(0, 8);
|
|
193
|
+
expect(polygon.at(-1)!.lng).toBeCloseTo(0, 8);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("chamfers the outside of every bend in one strip region", () => {
|
|
197
|
+
const derived = deriveMappingStrip(
|
|
198
|
+
stripTemplate({
|
|
199
|
+
cuttingDistance: 10_000,
|
|
200
|
+
leftExtend: 20,
|
|
201
|
+
rightExtend: 20,
|
|
202
|
+
LineString: {
|
|
203
|
+
coordinates: "0,0,0 0.001,0,0 0.001,0.001,0 0.002,0.001,0",
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
options,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(derived.regions).toHaveLength(1);
|
|
210
|
+
expect(derived.regions[0]!.polygon).toHaveLength(10);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("uses neighboring center-line directions when a region ends at a bend", () => {
|
|
214
|
+
const derived = deriveMappingStrip(
|
|
215
|
+
stripTemplate({
|
|
216
|
+
cuttingDistance: 100,
|
|
217
|
+
leftExtend: 20,
|
|
218
|
+
rightExtend: 20,
|
|
219
|
+
LineString: { coordinates: "0,0,0 0.0007,0,0 0.0007,0.0007,0" },
|
|
220
|
+
}),
|
|
221
|
+
options,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(derived.regions).toHaveLength(2);
|
|
225
|
+
expect(derived.regions.map((region) => region.polygon.length)).toEqual([5, 5]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("forces the center line into a symmetric multi-line route", () => {
|
|
229
|
+
const derived = deriveMappingStrip(
|
|
230
|
+
stripTemplate({
|
|
231
|
+
cuttingDistance: 1_000,
|
|
232
|
+
leftExtend: 50,
|
|
233
|
+
rightExtend: 50,
|
|
234
|
+
includeCenterEnable: true,
|
|
235
|
+
}),
|
|
236
|
+
options,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(derived.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([-40, -20, 0, 20, 40]);
|
|
240
|
+
expect(derived.flightRoutes[0]!.routeLines).toHaveLength(5);
|
|
241
|
+
expect(derived.flightRoutes[0]!.executablePath).toHaveLength(10);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps one route when the camera footprint is wider than the corridor", () => {
|
|
245
|
+
const derived = deriveMappingStrip(stripTemplate({ cuttingDistance: 1_000 }), {
|
|
246
|
+
...options,
|
|
247
|
+
crossTrackFootprint: meters(100),
|
|
248
|
+
lineSpacing: meters(30),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(derived.flightRoutes[0]!.routeOffsets).toHaveLength(1);
|
|
252
|
+
expect(Number(derived.flightRoutes[0]!.routeOffsets[0])).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("lays out infrared routes by overlap spacing without shrinking by half-footprint", () => {
|
|
256
|
+
const infraredTemplate = stripTemplate({
|
|
257
|
+
cuttingDistance: 1_000,
|
|
258
|
+
leftExtend: 20,
|
|
259
|
+
rightExtend: 20,
|
|
260
|
+
});
|
|
261
|
+
infraredTemplate.Folder.payloadParam!.imageFormat = ["ir"];
|
|
262
|
+
const infrared = deriveMappingStrip(infraredTemplate, {
|
|
263
|
+
...options,
|
|
264
|
+
crossTrackFootprint: meters(90),
|
|
265
|
+
lineSpacing: meters(27),
|
|
266
|
+
});
|
|
267
|
+
const visible = deriveMappingStrip(
|
|
268
|
+
stripTemplate({ cuttingDistance: 1_000, leftExtend: 20, rightExtend: 20 }),
|
|
269
|
+
{
|
|
270
|
+
...options,
|
|
271
|
+
crossTrackFootprint: meters(200),
|
|
272
|
+
lineSpacing: meters(60.5),
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(infrared.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([-13.5, 13.5]);
|
|
277
|
+
expect(visible.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([0]);
|
|
278
|
+
expect(infrared.flightRoutes[0]!.segments.map(({ kind }) => kind)).toEqual([
|
|
279
|
+
"work",
|
|
280
|
+
"turn",
|
|
281
|
+
"work",
|
|
282
|
+
]);
|
|
283
|
+
expect(infrared.flightRoutes[0]!.segments[1]!.photoEnabled).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("adds the center line without removing infrared side routes", () => {
|
|
287
|
+
const template = stripTemplate({
|
|
288
|
+
cuttingDistance: 1_000,
|
|
289
|
+
leftExtend: 20,
|
|
290
|
+
rightExtend: 20,
|
|
291
|
+
includeCenterEnable: true,
|
|
292
|
+
});
|
|
293
|
+
template.Folder.payloadParam!.imageFormat = ["ir"];
|
|
294
|
+
const derived = deriveMappingStrip(template, {
|
|
295
|
+
...options,
|
|
296
|
+
crossTrackFootprint: meters(90),
|
|
297
|
+
lineSpacing: meters(27),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(derived.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([-13.5, 0, 13.5]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("uses an even off-center layout unless includeCenterEnable is enabled", () => {
|
|
304
|
+
const withoutCenterTemplate = stripTemplate({
|
|
305
|
+
cuttingDistance: 1_000,
|
|
306
|
+
leftExtend: 40,
|
|
307
|
+
rightExtend: 40,
|
|
308
|
+
includeCenterEnable: false,
|
|
309
|
+
});
|
|
310
|
+
withoutCenterTemplate.Folder.payloadParam!.imageFormat = ["visable", "ir"];
|
|
311
|
+
const withCenterTemplate = stripTemplate({
|
|
312
|
+
cuttingDistance: 1_000,
|
|
313
|
+
leftExtend: 40,
|
|
314
|
+
rightExtend: 40,
|
|
315
|
+
includeCenterEnable: true,
|
|
316
|
+
});
|
|
317
|
+
withCenterTemplate.Folder.payloadParam!.imageFormat = ["visable", "ir"];
|
|
318
|
+
const withoutCenter = deriveMappingStrip(withoutCenterTemplate, {
|
|
319
|
+
...options,
|
|
320
|
+
crossTrackFootprint: meters(90),
|
|
321
|
+
lineSpacing: meters(27),
|
|
322
|
+
});
|
|
323
|
+
const withCenter = deriveMappingStrip(withCenterTemplate, {
|
|
324
|
+
...options,
|
|
325
|
+
crossTrackFootprint: meters(90),
|
|
326
|
+
lineSpacing: meters(27),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(withoutCenter.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([-13.5, 13.5]);
|
|
330
|
+
expect(withCenter.flightRoutes[0]!.routeOffsets.map(Number)).toEqual([-27, 0, 27]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("adds a separate oblique center-line segment only for elevation optimization", () => {
|
|
334
|
+
const off = deriveMappingStrip(
|
|
335
|
+
stripTemplate({ cuttingDistance: 1_000, elevationOptimizeEnable: false }),
|
|
336
|
+
options,
|
|
337
|
+
);
|
|
338
|
+
const on = deriveMappingStrip(
|
|
339
|
+
stripTemplate({ cuttingDistance: 1_000, elevationOptimizeEnable: true }),
|
|
340
|
+
options,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(off.flightRoutes[0]!.segments.some(({ kind }) => kind === "elevationOptimize")).toBe(
|
|
344
|
+
false,
|
|
345
|
+
);
|
|
346
|
+
expect(on.flightRoutes[0]!.segments.at(-1)).toMatchObject({
|
|
347
|
+
kind: "elevationOptimize",
|
|
348
|
+
photoEnabled: true,
|
|
349
|
+
});
|
|
350
|
+
expect(Number(on.flightRoutes[0]!.length)).toBeGreaterThan(Number(off.flightRoutes[0]!.length));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("generates perpendicular work lines through each strip region", () => {
|
|
354
|
+
const derived = deriveMappingStrip(stripTemplate({ cuttingDistance: 1_000 }), {
|
|
355
|
+
...options,
|
|
356
|
+
stripDirection: "perpendicular",
|
|
357
|
+
});
|
|
358
|
+
const firstLine = derived.flightRoutes[0]!.routeLines[0]!;
|
|
359
|
+
|
|
360
|
+
expect(firstLine).toHaveLength(2);
|
|
361
|
+
expect(Math.abs(firstLine[0]!.lng - firstLine[1]!.lng)).toBeLessThan(
|
|
362
|
+
Math.abs(firstLine[0]!.lat - firstLine[1]!.lat),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("matches FlightHub perpendicular segmentation and local scan-line counts", () => {
|
|
367
|
+
const derived = deriveMappingStrip(
|
|
368
|
+
officialPerpendicularTemplate(),
|
|
369
|
+
officialPerpendicularOptions,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
expect(derived.regions.map((region) => Number(region.endDistance))).toEqual([
|
|
373
|
+
expect.closeTo(454.815, 2),
|
|
374
|
+
expect.closeTo(629.44, 2),
|
|
375
|
+
]);
|
|
376
|
+
expect(derived.flightRoutes.map((route) => route.routeLines.length)).toEqual([31, 11]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("connects adjacent perpendicular scan lines at their nearest ends", () => {
|
|
380
|
+
const derived = deriveMappingStrip(
|
|
381
|
+
officialPerpendicularTemplate(),
|
|
382
|
+
officialPerpendicularOptions,
|
|
383
|
+
);
|
|
384
|
+
const lines = derived.flightRoutes[0]!.routeLines.slice(0, 8);
|
|
385
|
+
|
|
386
|
+
for (let index = 1; index < lines.length; index++) {
|
|
387
|
+
const previousEnd = lines[index - 1]!.at(-1)!;
|
|
388
|
+
const next = lines[index]!;
|
|
389
|
+
expect(haversineMeters(previousEnd, next[0]!)).toBeLessThan(
|
|
390
|
+
haversineMeters(previousEnd, next.at(-1)!),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("matches FlightHub parallel region and route-line counts", () => {
|
|
396
|
+
const derived = deriveMappingStrip(officialParallelTemplate(), officialParallelOptions);
|
|
397
|
+
|
|
398
|
+
expect(derived.regions).toHaveLength(2);
|
|
399
|
+
expect(derived.flightRoutes.map((route) => route.routeLines.length)).toEqual([1, 1]);
|
|
400
|
+
expect(derived.flightRoutes.map((route) => route.executablePath.length)).toEqual([5, 3]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("reverses only the region execution order", () => {
|
|
404
|
+
const normal = deriveMappingStrip(stripTemplate(), options);
|
|
405
|
+
const reversed = deriveMappingStrip(stripTemplate(), {
|
|
406
|
+
...options,
|
|
407
|
+
regionOrderReversed: true,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
expect(reversed.flightRoutes.map(({ regionId }) => regionId)).toEqual(
|
|
411
|
+
normal.flightRoutes.map(({ regionId }) => regionId).reverse(),
|
|
412
|
+
);
|
|
413
|
+
expect(reversed.regions.map(({ id }) => id)).toEqual(normal.regions.map(({ id }) => id));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("orients the first region toward the reference takeoff point", () => {
|
|
417
|
+
const template = stripTemplate({ cuttingDistance: 1_000, singleLineEnable: true });
|
|
418
|
+
template.missionConfig.takeOffRefPoint = { lng: 0.0026, lat: 0, height: meters(0) };
|
|
419
|
+
const derived = deriveMappingStrip(template, options);
|
|
420
|
+
|
|
421
|
+
expect(derived.flightRoutes[0]!.executablePath[0]!.lng).toBeGreaterThan(0.002);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("keeps map transit segments separate from each region executable path", () => {
|
|
425
|
+
const derived = deriveMappingStrip(stripTemplate(), options);
|
|
426
|
+
|
|
427
|
+
for (let index = 1; index < derived.flightRoutes.length; index++) {
|
|
428
|
+
const previous = derived.flightRoutes[index - 1]!;
|
|
429
|
+
const current = derived.flightRoutes[index]!;
|
|
430
|
+
expect(current.segments[0]).toMatchObject({ kind: "transit", photoEnabled: false });
|
|
431
|
+
expect(current.segments[0]!.points[0]).toEqual(previous.executablePath.at(-1));
|
|
432
|
+
expect(current.segments[0]!.points[1]).toEqual(current.executablePath[0]);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("chooses the shortest transition from all serpentine route orientations", () => {
|
|
437
|
+
const template = stripTemplate({
|
|
438
|
+
cuttingDistance: 100,
|
|
439
|
+
leftExtend: 50,
|
|
440
|
+
rightExtend: 50,
|
|
441
|
+
includeCenterEnable: true,
|
|
442
|
+
});
|
|
443
|
+
template.missionConfig.takeOffRefPoint = {
|
|
444
|
+
lng: 0,
|
|
445
|
+
lat: -40 / 111_195,
|
|
446
|
+
height: meters(0),
|
|
447
|
+
};
|
|
448
|
+
const derived = deriveMappingStrip(template, options);
|
|
449
|
+
|
|
450
|
+
const transitionLengths = derived.flightRoutes.slice(1).map((route) => {
|
|
451
|
+
const transition = route.segments[0]!;
|
|
452
|
+
expect(transition).toMatchObject({ kind: "transit", photoEnabled: false });
|
|
453
|
+
return haversineMeters(transition.points[0]!, transition.points[1]!);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(transitionLengths).toHaveLength(2);
|
|
457
|
+
expect(transitionLengths.every((length) => length < 1)).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("plan(mappingStrip)", () => {
|
|
462
|
+
it("matches FlightHub perpendicular waypoint and continuous-capture group counts", () => {
|
|
463
|
+
const waylines = plan(officialPerpendicularTemplate(), officialPerpendicularOptions);
|
|
464
|
+
|
|
465
|
+
expect(waylines.Folder.map((folder) => folder.Placemark.length)).toEqual([62, 22]);
|
|
466
|
+
expect(
|
|
467
|
+
waylines.Folder.map(
|
|
468
|
+
(folder) => folder.Placemark.flatMap((point) => point.actionGroup ?? []).length,
|
|
469
|
+
),
|
|
470
|
+
).toEqual([3, 3]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("emits one wayline folder per distance-cut region with damped turns", () => {
|
|
474
|
+
const waylines = plan(stripTemplate(), options);
|
|
475
|
+
|
|
476
|
+
expect(waylines.Folder).toHaveLength(3);
|
|
477
|
+
expect(waylines.Folder.map((folder) => folder.waylineId)).toEqual([0, 1, 2]);
|
|
478
|
+
expect(waylines.Folder[0]!.Placemark[1]!.waypointTurnParam.waypointTurnMode).toBe(
|
|
479
|
+
"coordinateTurn",
|
|
480
|
+
);
|
|
481
|
+
expect(
|
|
482
|
+
Number(waylines.Folder[0]!.Placemark[1]!.waypointTurnParam.waypointTurnDampingDist),
|
|
483
|
+
).toBeLessThanOrEqual(10);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("uses the selected payload lenses in shooting actions", () => {
|
|
487
|
+
const template = stripTemplate({ cuttingDistance: 1_000 });
|
|
488
|
+
template.Folder.payloadParam!.imageFormat = ["visable", "ir"];
|
|
489
|
+
const waylines = plan(template, options);
|
|
490
|
+
const actions = waylines.Folder[0]!.Placemark.flatMap((point) =>
|
|
491
|
+
(point.actionGroup ?? []).flatMap((group) => group.action),
|
|
492
|
+
);
|
|
493
|
+
const start = actions.find((action) => action.actionActuatorFunc === "startTimeLapse");
|
|
494
|
+
|
|
495
|
+
expect(start?.actionActuatorFuncParam.payloadLensIndex).toEqual(["visable", "ir"]);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("uses one continuous time-lapse run across all work lines", () => {
|
|
499
|
+
const template = stripTemplate({ cuttingDistance: 1_000, leftExtend: 20, rightExtend: 20 });
|
|
500
|
+
template.Folder.payloadParam!.imageFormat = ["ir"];
|
|
501
|
+
const waylines = plan(template, {
|
|
502
|
+
...options,
|
|
503
|
+
crossTrackFootprint: meters(90),
|
|
504
|
+
lineSpacing: meters(27),
|
|
505
|
+
});
|
|
506
|
+
const groups = waylines.Folder[0]!.Placemark.flatMap((point) => point.actionGroup ?? []);
|
|
507
|
+
const starts = groups.filter((group) =>
|
|
508
|
+
group.action.some((action) => action.actionActuatorFunc === "startTimeLapse"),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(groups).toHaveLength(3);
|
|
512
|
+
expect(starts).toHaveLength(1);
|
|
513
|
+
expect(starts[0]!.actionGroupStartIndex).toBe(0);
|
|
514
|
+
expect(starts[0]!.actionGroupEndIndex).toBe(waylines.Folder[0]!.Placemark.length - 1);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("exports each region executable path without its map-only transit segment", () => {
|
|
518
|
+
const template = stripTemplate();
|
|
519
|
+
const derived = deriveMappingStrip(template, options);
|
|
520
|
+
const waylines = plan(template, options);
|
|
521
|
+
|
|
522
|
+
waylines.Folder.forEach((folder, index) => {
|
|
523
|
+
expect(folder.Placemark.map(({ Point }) => Point.coordinates)).toEqual(
|
|
524
|
+
derived.flightRoutes[index]!.executablePath,
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("uses a -45 degree gimbal pitch only for elevation optimization", () => {
|
|
530
|
+
const pitches = (enabled: boolean) =>
|
|
531
|
+
plan(stripTemplate({ cuttingDistance: 1_000, elevationOptimizeEnable: enabled }), options)
|
|
532
|
+
.Folder[0]!.Placemark.flatMap((point) => point.actionGroup ?? [])
|
|
533
|
+
.flatMap((group) => group.action)
|
|
534
|
+
.filter((action) => action.actionActuatorFunc === "gimbalRotate")
|
|
535
|
+
.map((action) => Number(action.actionActuatorFuncParam.gimbalPitchRotateAngle));
|
|
536
|
+
|
|
537
|
+
expect(pitches(false)).not.toContain(-45);
|
|
538
|
+
expect(pitches(true)).toContain(-45);
|
|
539
|
+
});
|
|
540
|
+
});
|