@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
|
@@ -1,59 +1,94 @@
|
|
|
1
|
-
import { degrees } from "../types/branded.js";
|
|
2
|
-
import type {
|
|
1
|
+
import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
|
|
2
|
+
import type { ActionGroup } from "../types/action.js";
|
|
3
|
+
import type { ExecuteHeightMode, LensType } from "../types/enums.js";
|
|
4
|
+
import { PlannerError } from "../types/errors.js";
|
|
3
5
|
import type { MappingStripFolder, MappingStripTemplate } from "../types/template.js";
|
|
4
6
|
import type { Waylines, WaylinesFolder } from "../types/waylines.js";
|
|
5
7
|
import type { WaylinesPlacemark } from "../types/placemark.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const folder = template.Folder;
|
|
17
|
-
const coords = parseTriples(folder.Placemark.LineString.coordinates);
|
|
18
|
-
const origin = centroid(coords.map((c) => ({ lng: c.lng, lat: c.lat })));
|
|
19
|
-
const enuPath = coords.map((c) => lngLatToEnu({ lng: c.lng, lat: c.lat }, origin));
|
|
20
|
-
|
|
21
|
-
const placemarks: WaylinesPlacemark[] = coords.map((c, index) => {
|
|
22
|
-
const isLast = index === coords.length - 1;
|
|
23
|
-
const headingAngle = isLast ? 0 : bearingDeg(enuPath[index]!, enuPath[index + 1]!);
|
|
24
|
-
const executeHeight =
|
|
25
|
-
folder.Placemark.stripUseTemplateAltitude && c.alt !== undefined
|
|
26
|
-
? (c.alt as ReturnType<typeof folder.Placemark.height extends infer T ? () => T : never>)
|
|
27
|
-
: folder.Placemark.height;
|
|
8
|
+
import {
|
|
9
|
+
bearingDeg,
|
|
10
|
+
centroid,
|
|
11
|
+
enuToLngLat,
|
|
12
|
+
haversineMeters,
|
|
13
|
+
lngLatToEnu,
|
|
14
|
+
surveyGrid,
|
|
15
|
+
type EnuPoint,
|
|
16
|
+
} from "./geometry.js";
|
|
17
|
+
import type { PlanOptions } from "./index.js";
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
waypointSpeed: folder.autoFlightSpeed,
|
|
34
|
-
waypointHeadingParam: {
|
|
35
|
-
waypointHeadingMode: "followWayline",
|
|
36
|
-
waypointHeadingAngle: degrees(headingAngle),
|
|
37
|
-
waypointHeadingPathMode: "followBadArc",
|
|
38
|
-
},
|
|
39
|
-
waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" },
|
|
40
|
-
};
|
|
41
|
-
});
|
|
19
|
+
const POINT_EPS_M = 0.05;
|
|
20
|
+
const MITER_LIMIT = 2.5;
|
|
21
|
+
const MAX_ROUTE_COUNT = 1_000;
|
|
22
|
+
const TURN_DAMPING_M = 10;
|
|
42
23
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
24
|
+
export interface MappingStripRegion {
|
|
25
|
+
id: string;
|
|
26
|
+
sourceRegionIndexes: number[];
|
|
27
|
+
startDistance: Meters;
|
|
28
|
+
endDistance: Meters;
|
|
29
|
+
centerLine: LngLat[];
|
|
30
|
+
polygon: LngLat[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MappingStripRegionRoute {
|
|
34
|
+
regionId: string;
|
|
35
|
+
routeOffsets: Meters[];
|
|
36
|
+
routeLines: LngLat[][];
|
|
37
|
+
segments: MappingStripRouteSegment[];
|
|
38
|
+
executablePath: LngLat[];
|
|
39
|
+
length: Meters;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MappingStripRouteSegment {
|
|
43
|
+
kind: "work" | "turn" | "transit" | "elevationOptimize";
|
|
44
|
+
points: LngLat[];
|
|
45
|
+
photoEnabled: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MappingStripDerived {
|
|
49
|
+
centerLineLength: Meters;
|
|
50
|
+
surveyArea: LngLat[];
|
|
51
|
+
surveyAreaSize: number;
|
|
52
|
+
regions: MappingStripRegion[];
|
|
53
|
+
flightRoutes: MappingStripRegionRoute[];
|
|
54
|
+
totalFlightLength: Meters;
|
|
55
|
+
photoSpacing: Meters;
|
|
56
|
+
estimatedPhotoCount: number;
|
|
57
|
+
estimatedDuration: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface StripPlanInputs {
|
|
61
|
+
crossTrackFootprint: number;
|
|
62
|
+
lineSpacing: number;
|
|
63
|
+
photoSpacing: number;
|
|
64
|
+
shootInterval: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface LocalRegion {
|
|
68
|
+
id: string;
|
|
69
|
+
sourceRegionIndexes: number[];
|
|
70
|
+
startDistance: number;
|
|
71
|
+
endDistance: number;
|
|
72
|
+
centerLine: EnuPoint[];
|
|
73
|
+
polygon: EnuPoint[];
|
|
74
|
+
beforePoint?: EnuPoint;
|
|
75
|
+
afterPoint?: EnuPoint;
|
|
76
|
+
}
|
|
50
77
|
|
|
51
|
-
|
|
78
|
+
interface LocalRouteSegment {
|
|
79
|
+
kind: "work" | "turn" | "elevationOptimize";
|
|
80
|
+
points: EnuPoint[];
|
|
81
|
+
photoEnabled: boolean;
|
|
52
82
|
}
|
|
53
83
|
|
|
54
|
-
interface
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
interface LocalRouteCandidate {
|
|
85
|
+
lines: EnuPoint[][];
|
|
86
|
+
segments: LocalRouteSegment[];
|
|
87
|
+
start: EnuPoint;
|
|
88
|
+
end: EnuPoint;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Triple extends LngLat {
|
|
57
92
|
alt?: number;
|
|
58
93
|
}
|
|
59
94
|
|
|
@@ -61,6 +96,7 @@ function parseTriples(s: string): Triple[] {
|
|
|
61
96
|
return s
|
|
62
97
|
.trim()
|
|
63
98
|
.split(/\s+/)
|
|
99
|
+
.filter(Boolean)
|
|
64
100
|
.map((tuple) => {
|
|
65
101
|
const parts = tuple.split(",").map(Number);
|
|
66
102
|
return {
|
|
@@ -71,6 +107,664 @@ function parseTriples(s: string): Triple[] {
|
|
|
71
107
|
});
|
|
72
108
|
}
|
|
73
109
|
|
|
110
|
+
function cleanLine(points: readonly EnuPoint[]): EnuPoint[] {
|
|
111
|
+
const clean: EnuPoint[] = [];
|
|
112
|
+
for (const point of points) {
|
|
113
|
+
const previous = clean.at(-1);
|
|
114
|
+
if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) < POINT_EPS_M)
|
|
115
|
+
continue;
|
|
116
|
+
clean.push([point[0], point[1]]);
|
|
117
|
+
}
|
|
118
|
+
return clean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function lineLength(points: readonly EnuPoint[]): number {
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (let index = 1; index < points.length; index++) {
|
|
124
|
+
total += Math.hypot(
|
|
125
|
+
points[index]![0] - points[index - 1]![0],
|
|
126
|
+
points[index]![1] - points[index - 1]![1],
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return total;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function localRouteSegments(lines: readonly EnuPoint[][]) {
|
|
133
|
+
const segments: Array<{
|
|
134
|
+
kind: "work" | "turn";
|
|
135
|
+
points: EnuPoint[];
|
|
136
|
+
photoEnabled: boolean;
|
|
137
|
+
}> = [];
|
|
138
|
+
lines.forEach((line, index) => {
|
|
139
|
+
if (index > 0) {
|
|
140
|
+
segments.push({
|
|
141
|
+
kind: "turn",
|
|
142
|
+
points: [lines[index - 1]!.at(-1)!, line[0]!],
|
|
143
|
+
photoEnabled: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
segments.push({ kind: "work", points: line, photoEnabled: true });
|
|
147
|
+
});
|
|
148
|
+
return segments;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function serpentineRouteVariants(lines: readonly EnuPoint[][]): EnuPoint[][][] {
|
|
152
|
+
const variants: EnuPoint[][][] = [];
|
|
153
|
+
for (const reverseLineOrder of [false, true]) {
|
|
154
|
+
const ordered = reverseLineOrder ? [...lines].reverse() : [...lines];
|
|
155
|
+
for (const reverseFirstLine of [false, true]) {
|
|
156
|
+
variants.push(
|
|
157
|
+
ordered.map((line, index) =>
|
|
158
|
+
index % 2 === Number(reverseFirstLine) ? [...line] : [...line].reverse(),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return variants;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function localRouteCandidate(
|
|
167
|
+
lines: EnuPoint[][],
|
|
168
|
+
centerLine: readonly EnuPoint[],
|
|
169
|
+
elevationOptimizeEnable: boolean | undefined,
|
|
170
|
+
): LocalRouteCandidate {
|
|
171
|
+
const segments: LocalRouteSegment[] = localRouteSegments(lines);
|
|
172
|
+
if (elevationOptimizeEnable) {
|
|
173
|
+
const normalEnd = joinSegmentPoints(segments).at(-1)!;
|
|
174
|
+
let optimizationLine = [...centerLine];
|
|
175
|
+
const first = optimizationLine[0]!;
|
|
176
|
+
const last = optimizationLine.at(-1)!;
|
|
177
|
+
if (
|
|
178
|
+
Math.hypot(last[0] - normalEnd[0], last[1] - normalEnd[1]) <
|
|
179
|
+
Math.hypot(first[0] - normalEnd[0], first[1] - normalEnd[1])
|
|
180
|
+
) {
|
|
181
|
+
optimizationLine = optimizationLine.reverse();
|
|
182
|
+
}
|
|
183
|
+
const optimizationPath = cleanLine([normalEnd, ...optimizationLine]);
|
|
184
|
+
segments.push({
|
|
185
|
+
kind: "elevationOptimize",
|
|
186
|
+
// Preserve a distinct stop/start waypoint at the same coordinate, as in DJI exports.
|
|
187
|
+
points: [normalEnd, ...optimizationPath],
|
|
188
|
+
photoEnabled: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const executable = joinSegmentPoints(segments);
|
|
192
|
+
return {
|
|
193
|
+
lines,
|
|
194
|
+
segments,
|
|
195
|
+
start: executable[0]!,
|
|
196
|
+
end: executable.at(-1)!,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function shortestCandidateIndexes(
|
|
201
|
+
candidateGroups: readonly LocalRouteCandidate[][],
|
|
202
|
+
startPoint: EnuPoint | undefined,
|
|
203
|
+
): number[] {
|
|
204
|
+
if (candidateGroups.length === 0) return [];
|
|
205
|
+
let costs = candidateGroups[0]!.map((candidate) =>
|
|
206
|
+
startPoint
|
|
207
|
+
? Math.hypot(candidate.start[0] - startPoint[0], candidate.start[1] - startPoint[1])
|
|
208
|
+
: 0,
|
|
209
|
+
);
|
|
210
|
+
const previousIndexes: number[][] = [candidateGroups[0]!.map(() => -1)];
|
|
211
|
+
|
|
212
|
+
for (let regionIndex = 1; regionIndex < candidateGroups.length; regionIndex++) {
|
|
213
|
+
const previousCandidates = candidateGroups[regionIndex - 1]!;
|
|
214
|
+
const candidates = candidateGroups[regionIndex]!;
|
|
215
|
+
const nextCosts: number[] = [];
|
|
216
|
+
const nextPreviousIndexes: number[] = [];
|
|
217
|
+
for (const candidate of candidates) {
|
|
218
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
219
|
+
let bestPreviousIndex = 0;
|
|
220
|
+
previousCandidates.forEach((previous, previousIndex) => {
|
|
221
|
+
const transition = Math.hypot(
|
|
222
|
+
candidate.start[0] - previous.end[0],
|
|
223
|
+
candidate.start[1] - previous.end[1],
|
|
224
|
+
);
|
|
225
|
+
const cost = costs[previousIndex]! + transition;
|
|
226
|
+
if (cost < bestCost) {
|
|
227
|
+
bestCost = cost;
|
|
228
|
+
bestPreviousIndex = previousIndex;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
nextCosts.push(bestCost);
|
|
232
|
+
nextPreviousIndexes.push(bestPreviousIndex);
|
|
233
|
+
}
|
|
234
|
+
costs = nextCosts;
|
|
235
|
+
previousIndexes.push(nextPreviousIndexes);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let selectedIndex = costs.reduce((best, cost, index) => (cost < costs[best]! ? index : best), 0);
|
|
239
|
+
const selected = Array.from({ length: candidateGroups.length }, () => 0);
|
|
240
|
+
for (let regionIndex = candidateGroups.length - 1; regionIndex >= 0; regionIndex--) {
|
|
241
|
+
selected[regionIndex] = selectedIndex;
|
|
242
|
+
selectedIndex = previousIndexes[regionIndex]![selectedIndex]!;
|
|
243
|
+
}
|
|
244
|
+
return selected;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function joinSegmentPoints<T>(segments: readonly { points: readonly T[] }[]): T[] {
|
|
248
|
+
const result: T[] = [];
|
|
249
|
+
for (const segment of segments) {
|
|
250
|
+
if (result.length === 0) result.push(...segment.points);
|
|
251
|
+
else result.push(...segment.points.slice(1));
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function splitLineByDistance(points: readonly EnuPoint[], distance: number): LocalRegion[] {
|
|
257
|
+
const total = lineLength(points);
|
|
258
|
+
const cut = Number.isFinite(distance) && distance > POINT_EPS_M ? distance : total;
|
|
259
|
+
if (cut >= total - POINT_EPS_M) {
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
id: "region-0",
|
|
263
|
+
sourceRegionIndexes: [0],
|
|
264
|
+
startDistance: 0,
|
|
265
|
+
endDistance: total,
|
|
266
|
+
centerLine: [...points],
|
|
267
|
+
polygon: [],
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const regions: LocalRegion[] = [];
|
|
273
|
+
let current: EnuPoint[] = [points[0]!];
|
|
274
|
+
let regionStart = 0;
|
|
275
|
+
let accumulated = 0;
|
|
276
|
+
|
|
277
|
+
const finishRegion = (endDistance: number) => {
|
|
278
|
+
if (current.length < 2) return;
|
|
279
|
+
regions.push({
|
|
280
|
+
id: `region-${regions.length}`,
|
|
281
|
+
sourceRegionIndexes: [regions.length],
|
|
282
|
+
startDistance: regionStart,
|
|
283
|
+
endDistance,
|
|
284
|
+
centerLine: current,
|
|
285
|
+
polygon: [],
|
|
286
|
+
});
|
|
287
|
+
current = [current.at(-1)!];
|
|
288
|
+
regionStart = endDistance;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
for (let index = 1; index < points.length; index++) {
|
|
292
|
+
const a = points[index - 1]!;
|
|
293
|
+
const b = points[index]!;
|
|
294
|
+
const segmentLength = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
295
|
+
|
|
296
|
+
// FlightHub keeps original control points as region boundaries. If the next
|
|
297
|
+
// complete segment would cross the cutting distance, finish at its starting
|
|
298
|
+
// control point instead of synthesizing a point in the middle of the segment.
|
|
299
|
+
const currentLength = accumulated - regionStart;
|
|
300
|
+
if (currentLength > POINT_EPS_M && currentLength + segmentLength > cut + POINT_EPS_M) {
|
|
301
|
+
finishRegion(accumulated);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A single source segment can itself exceed the limit; only that case needs
|
|
305
|
+
// synthetic points so every generated region remains bounded by `cut`.
|
|
306
|
+
let segmentStart = a;
|
|
307
|
+
let consumed = 0;
|
|
308
|
+
while (segmentLength - consumed > cut + POINT_EPS_M) {
|
|
309
|
+
const step = cut;
|
|
310
|
+
const t = (consumed + step) / segmentLength;
|
|
311
|
+
const point: EnuPoint = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
312
|
+
if (current.at(-1) !== segmentStart) current.push(segmentStart);
|
|
313
|
+
current.push(point);
|
|
314
|
+
finishRegion(accumulated + consumed + step);
|
|
315
|
+
segmentStart = point;
|
|
316
|
+
consumed += step;
|
|
317
|
+
}
|
|
318
|
+
if (current.at(-1) !== segmentStart) current.push(segmentStart);
|
|
319
|
+
current.push(b);
|
|
320
|
+
accumulated += segmentLength;
|
|
321
|
+
}
|
|
322
|
+
finishRegion(total);
|
|
323
|
+
return regions;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyMergedRegionRanges(
|
|
327
|
+
regions: readonly LocalRegion[],
|
|
328
|
+
ranges: readonly number[][] | undefined,
|
|
329
|
+
): LocalRegion[] {
|
|
330
|
+
if (!ranges || ranges.length === 0) return [...regions];
|
|
331
|
+
const normalized = ranges
|
|
332
|
+
.map((range) => {
|
|
333
|
+
if (range.length !== 2) {
|
|
334
|
+
throw new PlannerError("mappingStrip merged region range must contain [start,end]");
|
|
335
|
+
}
|
|
336
|
+
const start = range[0]! - 1;
|
|
337
|
+
const end = range[1]! - 1;
|
|
338
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
|
339
|
+
throw new PlannerError("mappingStrip merged region range is invalid");
|
|
340
|
+
}
|
|
341
|
+
if (end >= regions.length) {
|
|
342
|
+
throw new PlannerError("mappingStrip merged region range exceeds region count");
|
|
343
|
+
}
|
|
344
|
+
return { start, end };
|
|
345
|
+
})
|
|
346
|
+
.sort((a, b) => a.start - b.start);
|
|
347
|
+
for (let index = 1; index < normalized.length; index++) {
|
|
348
|
+
if (normalized[index]!.start <= normalized[index - 1]!.end) {
|
|
349
|
+
throw new PlannerError("mappingStrip merged region ranges cannot overlap");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const byStart = new Map(normalized.map((range) => [range.start, range]));
|
|
354
|
+
const merged: LocalRegion[] = [];
|
|
355
|
+
for (let index = 0; index < regions.length; ) {
|
|
356
|
+
const range = byStart.get(index);
|
|
357
|
+
if (!range) {
|
|
358
|
+
merged.push(regions[index]!);
|
|
359
|
+
index += 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const members = regions.slice(range.start, range.end + 1);
|
|
363
|
+
merged.push({
|
|
364
|
+
id: `region-${range.start}-${range.end}`,
|
|
365
|
+
sourceRegionIndexes: members.flatMap((region) => region.sourceRegionIndexes),
|
|
366
|
+
startDistance: members[0]!.startDistance,
|
|
367
|
+
endDistance: members.at(-1)!.endDistance,
|
|
368
|
+
centerLine: members.flatMap((region, memberIndex) =>
|
|
369
|
+
memberIndex === 0 ? region.centerLine : region.centerLine.slice(1),
|
|
370
|
+
),
|
|
371
|
+
polygon: [],
|
|
372
|
+
beforePoint: members[0]!.beforePoint,
|
|
373
|
+
afterPoint: members.at(-1)!.afterPoint,
|
|
374
|
+
});
|
|
375
|
+
index = range.end + 1;
|
|
376
|
+
}
|
|
377
|
+
return merged;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function cross(a: EnuPoint, b: EnuPoint): number {
|
|
381
|
+
return a[0] * b[1] - a[1] * b[0];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function segmentUnit(a: EnuPoint, b: EnuPoint): EnuPoint {
|
|
385
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
386
|
+
if (length < POINT_EPS_M) throw new PlannerError("mappingStrip center line has a zero segment");
|
|
387
|
+
return [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function leftNormal(direction: EnuPoint): EnuPoint {
|
|
391
|
+
return [-direction[1], direction[0]];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function offsetPolyline(points: readonly EnuPoint[], distance: number): EnuPoint[] {
|
|
395
|
+
if (distance === 0) return points.map((point) => [point[0], point[1]]);
|
|
396
|
+
const directions = points.slice(1).map((point, index) => segmentUnit(points[index]!, point));
|
|
397
|
+
const out: EnuPoint[] = [];
|
|
398
|
+
const firstNormal = leftNormal(directions[0]!);
|
|
399
|
+
out.push([points[0]![0] + firstNormal[0] * distance, points[0]![1] + firstNormal[1] * distance]);
|
|
400
|
+
|
|
401
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
402
|
+
const previousDirection = directions[index - 1]!;
|
|
403
|
+
const nextDirection = directions[index]!;
|
|
404
|
+
const previousNormal = leftNormal(previousDirection);
|
|
405
|
+
const nextNormal = leftNormal(nextDirection);
|
|
406
|
+
const previousOffset: EnuPoint = [
|
|
407
|
+
points[index]![0] + previousNormal[0] * distance,
|
|
408
|
+
points[index]![1] + previousNormal[1] * distance,
|
|
409
|
+
];
|
|
410
|
+
const nextOffset: EnuPoint = [
|
|
411
|
+
points[index]![0] + nextNormal[0] * distance,
|
|
412
|
+
points[index]![1] + nextNormal[1] * distance,
|
|
413
|
+
];
|
|
414
|
+
const denominator = cross(previousDirection, nextDirection);
|
|
415
|
+
if (Math.abs(denominator) < 1e-9) {
|
|
416
|
+
out.push(previousOffset);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
|
|
420
|
+
const t = cross(delta, nextDirection) / denominator;
|
|
421
|
+
const intersection: EnuPoint = [
|
|
422
|
+
previousOffset[0] + previousDirection[0] * t,
|
|
423
|
+
previousOffset[1] + previousDirection[1] * t,
|
|
424
|
+
];
|
|
425
|
+
const miterLength = Math.hypot(
|
|
426
|
+
intersection[0] - points[index]![0],
|
|
427
|
+
intersection[1] - points[index]![1],
|
|
428
|
+
);
|
|
429
|
+
if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
|
|
430
|
+
else out.push(previousOffset, nextOffset);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const lastNormal = leftNormal(directions.at(-1)!);
|
|
434
|
+
const last = points.at(-1)!;
|
|
435
|
+
out.push([last[0] + lastNormal[0] * distance, last[1] + lastNormal[1] * distance]);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function corridorSide(
|
|
440
|
+
points: readonly EnuPoint[],
|
|
441
|
+
distance: number,
|
|
442
|
+
beforePoint?: EnuPoint,
|
|
443
|
+
afterPoint?: EnuPoint,
|
|
444
|
+
): EnuPoint[] {
|
|
445
|
+
const out: EnuPoint[] = [];
|
|
446
|
+
for (let index = 0; index < points.length; index++) {
|
|
447
|
+
const point = points[index]!;
|
|
448
|
+
const previous = index > 0 ? points[index - 1] : beforePoint;
|
|
449
|
+
const next = index + 1 < points.length ? points[index + 1] : afterPoint;
|
|
450
|
+
if (!previous && next) {
|
|
451
|
+
const normal = leftNormal(segmentUnit(point, next));
|
|
452
|
+
out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (previous && !next) {
|
|
456
|
+
const normal = leftNormal(segmentUnit(previous, point));
|
|
457
|
+
out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (!previous || !next) continue;
|
|
461
|
+
|
|
462
|
+
const previousDirection = segmentUnit(previous, point);
|
|
463
|
+
const nextDirection = segmentUnit(point, next);
|
|
464
|
+
const previousNormal = leftNormal(previousDirection);
|
|
465
|
+
const nextNormal = leftNormal(nextDirection);
|
|
466
|
+
const previousOffset: EnuPoint = [
|
|
467
|
+
point[0] + previousNormal[0] * distance,
|
|
468
|
+
point[1] + previousNormal[1] * distance,
|
|
469
|
+
];
|
|
470
|
+
const nextOffset: EnuPoint = [
|
|
471
|
+
point[0] + nextNormal[0] * distance,
|
|
472
|
+
point[1] + nextNormal[1] * distance,
|
|
473
|
+
];
|
|
474
|
+
const denominator = cross(previousDirection, nextDirection);
|
|
475
|
+
if (Math.abs(denominator) < 1e-9) {
|
|
476
|
+
out.push(previousOffset);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// FlightHub uses a bevel on the outside of every bend. The inside remains
|
|
481
|
+
// a miter intersection so the corridor does not overlap itself at corners.
|
|
482
|
+
if (denominator * distance < 0) {
|
|
483
|
+
out.push(previousOffset, nextOffset);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
|
|
487
|
+
const t = cross(delta, nextDirection) / denominator;
|
|
488
|
+
const intersection: EnuPoint = [
|
|
489
|
+
previousOffset[0] + previousDirection[0] * t,
|
|
490
|
+
previousOffset[1] + previousDirection[1] * t,
|
|
491
|
+
];
|
|
492
|
+
const miterLength = Math.hypot(intersection[0] - point[0], intersection[1] - point[1]);
|
|
493
|
+
if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
|
|
494
|
+
else out.push(previousOffset, nextOffset);
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function corridorPolygon(
|
|
500
|
+
points: readonly EnuPoint[],
|
|
501
|
+
left: number,
|
|
502
|
+
right: number,
|
|
503
|
+
beforePoint?: EnuPoint,
|
|
504
|
+
afterPoint?: EnuPoint,
|
|
505
|
+
): EnuPoint[] {
|
|
506
|
+
return [
|
|
507
|
+
...corridorSide(points, left, beforePoint, afterPoint),
|
|
508
|
+
...corridorSide(points, -right, beforePoint, afterPoint).reverse(),
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function perpendicularRouteLines(
|
|
513
|
+
centerLine: readonly EnuPoint[],
|
|
514
|
+
leftExtend: number,
|
|
515
|
+
rightExtend: number,
|
|
516
|
+
margin: number,
|
|
517
|
+
lineSpacing: number,
|
|
518
|
+
): EnuPoint[][] {
|
|
519
|
+
const left = offsetPolyline(centerLine, leftExtend);
|
|
520
|
+
const right = offsetPolyline(centerLine, -rightExtend);
|
|
521
|
+
if (left.length !== centerLine.length || right.length !== centerLine.length) {
|
|
522
|
+
throw new PlannerError("mappingStrip offset corridor cannot be segmented");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const lines: EnuPoint[][] = [];
|
|
526
|
+
for (let index = 0; index < centerLine.length - 1; index++) {
|
|
527
|
+
const segment = [centerLine[index]!, centerLine[index + 1]!] as const;
|
|
528
|
+
const cell = [left[index]!, left[index + 1]!, right[index + 1]!, right[index]!];
|
|
529
|
+
const direction = segmentUnit(segment[0], segment[1]);
|
|
530
|
+
const projections = cell.map((point) => point[0] * direction[0] + point[1] * direction[1]);
|
|
531
|
+
const longitudinalSpan = Math.max(...projections) - Math.min(...projections);
|
|
532
|
+
const sourceSegmentLength = lineLength(segment);
|
|
533
|
+
const previousDirection =
|
|
534
|
+
index > 0 ? segmentUnit(centerLine[index - 1]!, segment[0]) : undefined;
|
|
535
|
+
const hasSharpStart =
|
|
536
|
+
previousDirection !== undefined &&
|
|
537
|
+
Math.abs(cross(previousDirection, direction)) > Math.sin((5 * Math.PI) / 180);
|
|
538
|
+
const lineCount = Math.max(
|
|
539
|
+
1,
|
|
540
|
+
Math.ceil(sourceSegmentLength / lineSpacing),
|
|
541
|
+
hasSharpStart
|
|
542
|
+
? Math.ceil(longitudinalSpan / lineSpacing)
|
|
543
|
+
: Math.round(longitudinalSpan / lineSpacing),
|
|
544
|
+
);
|
|
545
|
+
const phaseOffset = Math.max(0, (longitudinalSpan - (lineCount - 1) * lineSpacing) / 2);
|
|
546
|
+
const grid = surveyGrid(cell, {
|
|
547
|
+
directionDeg: (bearingDeg(segment[0], segment[1]) + 90) % 360,
|
|
548
|
+
marginM: margin,
|
|
549
|
+
lineSpacing,
|
|
550
|
+
phaseOffsetM: phaseOffset,
|
|
551
|
+
});
|
|
552
|
+
const flightDirection: EnuPoint = [direction[1], -direction[0]];
|
|
553
|
+
for (let gridIndex = 0; gridIndex + 1 < grid.length; gridIndex += 2) {
|
|
554
|
+
const a = grid[gridIndex]!;
|
|
555
|
+
const b = grid[gridIndex + 1]!;
|
|
556
|
+
const followsDirection =
|
|
557
|
+
(b[0] - a[0]) * flightDirection[0] + (b[1] - a[1]) * flightDirection[1] >= 0;
|
|
558
|
+
lines.push(followsDirection ? [a, b] : [b, a]);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return lines;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function polygonArea(points: readonly EnuPoint[]): number {
|
|
565
|
+
let area2 = 0;
|
|
566
|
+
for (let index = 0; index < points.length; index++) {
|
|
567
|
+
const a = points[index]!;
|
|
568
|
+
const b = points[(index + 1) % points.length]!;
|
|
569
|
+
area2 += a[0] * b[1] - b[0] * a[1];
|
|
570
|
+
}
|
|
571
|
+
return Math.abs(area2) / 2;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function routeOffsets(folder: MappingStripFolder, inputs: StripPlanInputs): number[] {
|
|
575
|
+
if (folder.Placemark.singleLineEnable) return [0];
|
|
576
|
+
const left = folder.Placemark.leftExtend;
|
|
577
|
+
const right = folder.Placemark.rightExtend;
|
|
578
|
+
const minimum = -right;
|
|
579
|
+
const maximum = left;
|
|
580
|
+
const spacing = inputs.lineSpacing;
|
|
581
|
+
if (!(spacing > POINT_EPS_M && Number.isFinite(spacing)))
|
|
582
|
+
throw new PlannerError("mappingStrip lineSpacing must be finite and greater than zero");
|
|
583
|
+
|
|
584
|
+
const span = maximum - minimum;
|
|
585
|
+
const hasInfrared = folder.payloadParam?.imageFormat?.includes("ir") ?? false;
|
|
586
|
+
const minimumCount = Math.max(
|
|
587
|
+
hasInfrared && span > spacing + POINT_EPS_M ? 2 : 1,
|
|
588
|
+
Math.ceil(Math.max(0, span - inputs.crossTrackFootprint) / spacing) + 1,
|
|
589
|
+
);
|
|
590
|
+
if (minimumCount === 1) return [0];
|
|
591
|
+
const count =
|
|
592
|
+
minimumCount % 2 === 0
|
|
593
|
+
? minimumCount
|
|
594
|
+
: folder.Placemark.includeCenterEnable
|
|
595
|
+
? minimumCount
|
|
596
|
+
: minimumCount + 1;
|
|
597
|
+
if (count > MAX_ROUTE_COUNT) throw new PlannerError("mappingStrip route count exceeds limit");
|
|
598
|
+
const offsets = Array.from(
|
|
599
|
+
{ length: count },
|
|
600
|
+
(_, index) => (minimum + maximum - (count - 1) * spacing) / 2 + index * spacing,
|
|
601
|
+
);
|
|
602
|
+
if (folder.Placemark.includeCenterEnable) {
|
|
603
|
+
const oddCount = minimumCount % 2 === 1 ? minimumCount : minimumCount + 1;
|
|
604
|
+
const centered = Array.from(
|
|
605
|
+
{ length: oddCount },
|
|
606
|
+
(_, index) => (index - (oddCount - 1) / 2) * spacing,
|
|
607
|
+
);
|
|
608
|
+
if (centered[0]! >= minimum && centered.at(-1)! <= maximum) return centered;
|
|
609
|
+
if (!offsets.some((offset) => Math.abs(offset) < POINT_EPS_M)) offsets.push(0);
|
|
610
|
+
}
|
|
611
|
+
return offsets.sort((a, b) => a - b);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function normalizeInputs(folder: MappingStripFolder, options?: PlanOptions): StripPlanInputs {
|
|
615
|
+
const corridorWidth = folder.Placemark.leftExtend + folder.Placemark.rightExtend;
|
|
616
|
+
return {
|
|
617
|
+
crossTrackFootprint: Number(options?.crossTrackFootprint ?? corridorWidth),
|
|
618
|
+
lineSpacing: Number(options?.lineSpacing ?? Math.max(corridorWidth, 1)),
|
|
619
|
+
photoSpacing: Number(options?.photoSpacing ?? 1),
|
|
620
|
+
shootInterval: Number(options?.shootInterval ?? 1),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function deriveMappingStrip(
|
|
625
|
+
template: MappingStripTemplate,
|
|
626
|
+
options?: PlanOptions,
|
|
627
|
+
): MappingStripDerived {
|
|
628
|
+
const folder = template.Folder;
|
|
629
|
+
if (folder.Placemark.leftExtend < 0 || folder.Placemark.rightExtend < 0)
|
|
630
|
+
throw new PlannerError("mappingStrip extend distances cannot be negative");
|
|
631
|
+
const triples = parseTriples(folder.Placemark.LineString.coordinates);
|
|
632
|
+
if (triples.length < 2)
|
|
633
|
+
throw new PlannerError("mappingStrip requires at least two center points");
|
|
634
|
+
if (triples.some((point) => !Number.isFinite(point.lng) || !Number.isFinite(point.lat)))
|
|
635
|
+
throw new PlannerError("mappingStrip center line contains non-finite coordinates");
|
|
636
|
+
|
|
637
|
+
const origin = centroid(triples);
|
|
638
|
+
const centerLine = cleanLine(triples.map((point) => lngLatToEnu(point, origin)));
|
|
639
|
+
if (centerLine.length < 2)
|
|
640
|
+
throw new PlannerError("mappingStrip requires two distinct center points");
|
|
641
|
+
const inputs = normalizeInputs(folder, options);
|
|
642
|
+
if (!(inputs.crossTrackFootprint > 0 && Number.isFinite(inputs.crossTrackFootprint)))
|
|
643
|
+
throw new PlannerError("mappingStrip crossTrackFootprint must be finite and greater than zero");
|
|
644
|
+
if (!(inputs.photoSpacing > 0 && Number.isFinite(inputs.photoSpacing)))
|
|
645
|
+
throw new PlannerError("mappingStrip photoSpacing must be finite and greater than zero");
|
|
646
|
+
|
|
647
|
+
const splitRegions = splitLineByDistance(centerLine, folder.Placemark.cuttingDistance);
|
|
648
|
+
const contextualRegions = splitRegions.map((region, index) => ({
|
|
649
|
+
...region,
|
|
650
|
+
beforePoint: index > 0 ? splitRegions[index - 1]!.centerLine.at(-2) : undefined,
|
|
651
|
+
afterPoint:
|
|
652
|
+
index + 1 < splitRegions.length ? splitRegions[index + 1]!.centerLine[1] : undefined,
|
|
653
|
+
}));
|
|
654
|
+
const localRegions = applyMergedRegionRanges(
|
|
655
|
+
contextualRegions,
|
|
656
|
+
folder.Placemark.stripMergedRegions,
|
|
657
|
+
).map((region) => ({
|
|
658
|
+
...region,
|
|
659
|
+
polygon: corridorPolygon(
|
|
660
|
+
region.centerLine,
|
|
661
|
+
folder.Placemark.leftExtend,
|
|
662
|
+
folder.Placemark.rightExtend,
|
|
663
|
+
region.beforePoint,
|
|
664
|
+
region.afterPoint,
|
|
665
|
+
),
|
|
666
|
+
}));
|
|
667
|
+
const offsets = routeOffsets(folder, inputs);
|
|
668
|
+
const executionRegions = options?.regionOrderReversed
|
|
669
|
+
? [...localRegions].reverse()
|
|
670
|
+
: localRegions;
|
|
671
|
+
const perpendicular =
|
|
672
|
+
options?.stripDirection === "perpendicular" && !folder.Placemark.singleLineEnable;
|
|
673
|
+
const candidateGroups = executionRegions.map((region) => {
|
|
674
|
+
const rawLines = perpendicular
|
|
675
|
+
? perpendicularRouteLines(
|
|
676
|
+
region.centerLine,
|
|
677
|
+
folder.Placemark.leftExtend,
|
|
678
|
+
folder.Placemark.rightExtend,
|
|
679
|
+
folder.Placemark.margin,
|
|
680
|
+
inputs.lineSpacing,
|
|
681
|
+
)
|
|
682
|
+
: offsets.map((offset) => offsetPolyline(region.centerLine, offset));
|
|
683
|
+
if (rawLines.length === 0)
|
|
684
|
+
throw new PlannerError("mappingStrip region has no available flight routes");
|
|
685
|
+
return serpentineRouteVariants(rawLines).map((lines) =>
|
|
686
|
+
localRouteCandidate(lines, region.centerLine, folder.Placemark.elevationOptimizeEnable),
|
|
687
|
+
);
|
|
688
|
+
});
|
|
689
|
+
const takeOffRefPoint = template.missionConfig.takeOffRefPoint;
|
|
690
|
+
const selectedCandidateIndexes = shortestCandidateIndexes(
|
|
691
|
+
candidateGroups,
|
|
692
|
+
takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : undefined,
|
|
693
|
+
);
|
|
694
|
+
const flightRoutes: MappingStripRegionRoute[] = [];
|
|
695
|
+
let previousRegionEnd: EnuPoint | undefined;
|
|
696
|
+
executionRegions.forEach((region, regionIndex) => {
|
|
697
|
+
const candidate = candidateGroups[regionIndex]![selectedCandidateIndexes[regionIndex]!]!;
|
|
698
|
+
const localSegments: Array<{
|
|
699
|
+
kind: "work" | "turn" | "transit" | "elevationOptimize";
|
|
700
|
+
points: EnuPoint[];
|
|
701
|
+
photoEnabled: boolean;
|
|
702
|
+
}> = previousRegionEnd
|
|
703
|
+
? [
|
|
704
|
+
{
|
|
705
|
+
kind: "transit",
|
|
706
|
+
points: [previousRegionEnd, candidate.start],
|
|
707
|
+
photoEnabled: false,
|
|
708
|
+
},
|
|
709
|
+
...candidate.segments,
|
|
710
|
+
]
|
|
711
|
+
: candidate.segments;
|
|
712
|
+
const displayPath = joinSegmentPoints(localSegments);
|
|
713
|
+
const executable = joinSegmentPoints(candidate.segments);
|
|
714
|
+
flightRoutes.push({
|
|
715
|
+
regionId: region.id,
|
|
716
|
+
routeOffsets: perpendicular ? [] : offsets.map(meters),
|
|
717
|
+
routeLines: candidate.lines.map((line) => line.map((point) => enuToLngLat(point, origin))),
|
|
718
|
+
segments: localSegments.map((segment) => ({
|
|
719
|
+
...segment,
|
|
720
|
+
points: segment.points.map((point) => enuToLngLat(point, origin)),
|
|
721
|
+
})),
|
|
722
|
+
executablePath: executable.map((point) => enuToLngLat(point, origin)),
|
|
723
|
+
length: meters(lineLength(displayPath)),
|
|
724
|
+
});
|
|
725
|
+
previousRegionEnd = candidate.end;
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const surveyAreaLocal = corridorPolygon(
|
|
729
|
+
centerLine,
|
|
730
|
+
folder.Placemark.leftExtend,
|
|
731
|
+
folder.Placemark.rightExtend,
|
|
732
|
+
);
|
|
733
|
+
const totalFlightLength = flightRoutes.reduce((sum, route) => sum + Number(route.length), 0);
|
|
734
|
+
const estimatedPhotoCount = flightRoutes.reduce(
|
|
735
|
+
(sum, route) =>
|
|
736
|
+
sum +
|
|
737
|
+
route.segments
|
|
738
|
+
.filter(({ photoEnabled }) => photoEnabled)
|
|
739
|
+
.reduce((lineSum, segment) => {
|
|
740
|
+
let length = 0;
|
|
741
|
+
for (let index = 1; index < segment.points.length; index++)
|
|
742
|
+
length += haversineMeters(segment.points[index - 1]!, segment.points[index]!);
|
|
743
|
+
return lineSum + Math.max(1, Math.floor(length / inputs.photoSpacing) + 1);
|
|
744
|
+
}, 0),
|
|
745
|
+
0,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
centerLineLength: meters(lineLength(centerLine)),
|
|
750
|
+
surveyArea: surveyAreaLocal.map((point) => enuToLngLat(point, origin)),
|
|
751
|
+
surveyAreaSize: localRegions.reduce((sum, region) => sum + polygonArea(region.polygon), 0),
|
|
752
|
+
regions: localRegions.map((region) => ({
|
|
753
|
+
id: region.id,
|
|
754
|
+
sourceRegionIndexes: region.sourceRegionIndexes,
|
|
755
|
+
startDistance: meters(region.startDistance),
|
|
756
|
+
endDistance: meters(region.endDistance),
|
|
757
|
+
centerLine: region.centerLine.map((point) => enuToLngLat(point, origin)),
|
|
758
|
+
polygon: region.polygon.map((point) => enuToLngLat(point, origin)),
|
|
759
|
+
})),
|
|
760
|
+
flightRoutes,
|
|
761
|
+
totalFlightLength: meters(totalFlightLength),
|
|
762
|
+
photoSpacing: meters(inputs.photoSpacing),
|
|
763
|
+
estimatedPhotoCount,
|
|
764
|
+
estimatedDuration: totalFlightLength / Number(folder.autoFlightSpeed),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
74
768
|
function heightModeToExecuteMode(folder: MappingStripFolder): ExecuteHeightMode {
|
|
75
769
|
switch (folder.waylineCoordinateSysParam.heightMode) {
|
|
76
770
|
case "EGM96":
|
|
@@ -82,3 +776,317 @@ function heightModeToExecuteMode(folder: MappingStripFolder): ExecuteHeightMode
|
|
|
82
776
|
return "realTimeFollowSurface";
|
|
83
777
|
}
|
|
84
778
|
}
|
|
779
|
+
|
|
780
|
+
function turnDamping(points: readonly LngLat[], index: number): Meters {
|
|
781
|
+
const previous = haversineMeters(points[index - 1]!, points[index]!);
|
|
782
|
+
const next = haversineMeters(points[index]!, points[index + 1]!);
|
|
783
|
+
return meters(Math.min(TURN_DAMPING_M, previous * 0.45, next * 0.45));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function shootingGroups(
|
|
787
|
+
folder: MappingStripFolder,
|
|
788
|
+
startIndex: number,
|
|
789
|
+
endIndex: number,
|
|
790
|
+
firstGroupId: number,
|
|
791
|
+
inputs: StripPlanInputs,
|
|
792
|
+
): ActionGroup[] {
|
|
793
|
+
const lenses: LensType[] = folder.payloadParam?.imageFormat ?? ["visable"];
|
|
794
|
+
if (folder.Placemark.shootType === "time") {
|
|
795
|
+
return [
|
|
796
|
+
{
|
|
797
|
+
actionGroupId: firstGroupId,
|
|
798
|
+
actionGroupStartIndex: startIndex,
|
|
799
|
+
actionGroupEndIndex: endIndex,
|
|
800
|
+
actionGroupMode: "sequence",
|
|
801
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
|
|
802
|
+
action: [
|
|
803
|
+
{
|
|
804
|
+
actionId: 0,
|
|
805
|
+
actionActuatorFunc: "startTimeLapse",
|
|
806
|
+
actionActuatorFuncParam: {
|
|
807
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
808
|
+
useGlobalPayloadLensIndex: false,
|
|
809
|
+
payloadLensIndex: lenses,
|
|
810
|
+
minShootInterval: inputs.shootInterval,
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
actionGroupId: firstGroupId + 1,
|
|
817
|
+
actionGroupStartIndex: endIndex,
|
|
818
|
+
actionGroupEndIndex: endIndex,
|
|
819
|
+
actionGroupMode: "sequence",
|
|
820
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
821
|
+
action: [
|
|
822
|
+
{
|
|
823
|
+
actionId: 0,
|
|
824
|
+
actionActuatorFunc: "stopTimeLapse",
|
|
825
|
+
actionActuatorFuncParam: {
|
|
826
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
827
|
+
payloadLensIndex: lenses,
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
},
|
|
832
|
+
];
|
|
833
|
+
}
|
|
834
|
+
return [
|
|
835
|
+
{
|
|
836
|
+
actionGroupId: firstGroupId,
|
|
837
|
+
actionGroupStartIndex: startIndex,
|
|
838
|
+
actionGroupEndIndex: endIndex,
|
|
839
|
+
actionGroupMode: "sequence",
|
|
840
|
+
actionTrigger: {
|
|
841
|
+
actionTriggerType: "multipleDistance",
|
|
842
|
+
actionTriggerParam: inputs.photoSpacing,
|
|
843
|
+
},
|
|
844
|
+
action: [
|
|
845
|
+
{
|
|
846
|
+
actionId: 0,
|
|
847
|
+
actionActuatorFunc: "takePhoto",
|
|
848
|
+
actionActuatorFuncParam: {
|
|
849
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
850
|
+
useGlobalPayloadLensIndex: false,
|
|
851
|
+
payloadLensIndex: lenses,
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
},
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function nadirGimbalRotateAction(folder: MappingStripFolder, actionId: number) {
|
|
860
|
+
return {
|
|
861
|
+
actionId,
|
|
862
|
+
actionActuatorFunc: "gimbalRotate" as const,
|
|
863
|
+
actionActuatorFuncParam: {
|
|
864
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
865
|
+
gimbalHeadingYawBase: "aircraft" as const,
|
|
866
|
+
gimbalRotateMode: "absoluteAngle" as const,
|
|
867
|
+
gimbalPitchRotateEnable: true,
|
|
868
|
+
gimbalPitchRotateAngle: degrees(-90),
|
|
869
|
+
gimbalRollRotateEnable: false,
|
|
870
|
+
gimbalRollRotateAngle: degrees(0),
|
|
871
|
+
gimbalYawRotateEnable: false,
|
|
872
|
+
gimbalYawRotateAngle: degrees(0),
|
|
873
|
+
gimbalRotateTimeEnable: false,
|
|
874
|
+
gimbalRotateTime: 10,
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function continuousMappingGroups(
|
|
880
|
+
folder: MappingStripFolder,
|
|
881
|
+
endIndex: number,
|
|
882
|
+
inputs: StripPlanInputs,
|
|
883
|
+
): ActionGroup[] {
|
|
884
|
+
const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
|
|
885
|
+
const lenses: LensType[] = folder.payloadParam?.imageFormat ?? ["visable"];
|
|
886
|
+
const startAction =
|
|
887
|
+
folder.Placemark.shootType === "time"
|
|
888
|
+
? {
|
|
889
|
+
actionId: 2,
|
|
890
|
+
actionActuatorFunc: "startTimeLapse" as const,
|
|
891
|
+
actionActuatorFuncParam: {
|
|
892
|
+
payloadPositionIndex,
|
|
893
|
+
useGlobalPayloadLensIndex: false,
|
|
894
|
+
payloadLensIndex: lenses,
|
|
895
|
+
minShootInterval: inputs.shootInterval,
|
|
896
|
+
},
|
|
897
|
+
}
|
|
898
|
+
: {
|
|
899
|
+
actionId: 2,
|
|
900
|
+
actionActuatorFunc: "takePhoto" as const,
|
|
901
|
+
actionActuatorFuncParam: {
|
|
902
|
+
payloadPositionIndex,
|
|
903
|
+
useGlobalPayloadLensIndex: false,
|
|
904
|
+
payloadLensIndex: lenses,
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
return [
|
|
908
|
+
{
|
|
909
|
+
actionGroupId: 0,
|
|
910
|
+
actionGroupStartIndex: 0,
|
|
911
|
+
actionGroupEndIndex: endIndex,
|
|
912
|
+
actionGroupMode: "sequence",
|
|
913
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
|
|
914
|
+
action: [
|
|
915
|
+
{
|
|
916
|
+
actionId: 0,
|
|
917
|
+
actionActuatorFunc: "gimbalAngleLock",
|
|
918
|
+
actionActuatorFuncParam: { payloadPositionIndex },
|
|
919
|
+
},
|
|
920
|
+
nadirGimbalRotateAction(folder, 1),
|
|
921
|
+
startAction,
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
actionGroupId: 1,
|
|
926
|
+
actionGroupStartIndex: 0,
|
|
927
|
+
actionGroupEndIndex: endIndex,
|
|
928
|
+
actionGroupMode: "sequence",
|
|
929
|
+
actionTrigger: { actionTriggerType: "multipleTiming", actionTriggerParam: 2 },
|
|
930
|
+
action: [nadirGimbalRotateAction(folder, 0)],
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
actionGroupId: 2,
|
|
934
|
+
actionGroupStartIndex: endIndex,
|
|
935
|
+
actionGroupEndIndex: endIndex,
|
|
936
|
+
actionGroupMode: "sequence",
|
|
937
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
938
|
+
action: [
|
|
939
|
+
...(folder.Placemark.shootType === "time"
|
|
940
|
+
? [
|
|
941
|
+
{
|
|
942
|
+
actionId: 0,
|
|
943
|
+
actionActuatorFunc: "stopTimeLapse" as const,
|
|
944
|
+
actionActuatorFuncParam: { payloadPositionIndex, payloadLensIndex: lenses },
|
|
945
|
+
},
|
|
946
|
+
]
|
|
947
|
+
: []),
|
|
948
|
+
{ actionId: 1, actionActuatorFunc: "gimbalAngleUnlock" as const },
|
|
949
|
+
],
|
|
950
|
+
},
|
|
951
|
+
];
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function elevationOptimizeGroups(
|
|
955
|
+
folder: MappingStripFolder,
|
|
956
|
+
startIndex: number,
|
|
957
|
+
endIndex: number,
|
|
958
|
+
firstGroupId: number,
|
|
959
|
+
inputs: StripPlanInputs,
|
|
960
|
+
): ActionGroup[] {
|
|
961
|
+
const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
|
|
962
|
+
const gimbalGroup: ActionGroup = {
|
|
963
|
+
actionGroupId: firstGroupId,
|
|
964
|
+
actionGroupStartIndex: startIndex,
|
|
965
|
+
actionGroupEndIndex: startIndex,
|
|
966
|
+
actionGroupMode: "sequence",
|
|
967
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
968
|
+
action: [
|
|
969
|
+
{
|
|
970
|
+
actionId: 0,
|
|
971
|
+
actionActuatorFunc: "gimbalRotate",
|
|
972
|
+
actionActuatorFuncParam: {
|
|
973
|
+
payloadPositionIndex,
|
|
974
|
+
gimbalHeadingYawBase: "aircraft",
|
|
975
|
+
gimbalRotateMode: "absoluteAngle",
|
|
976
|
+
gimbalPitchRotateEnable: true,
|
|
977
|
+
gimbalPitchRotateAngle: degrees(-45),
|
|
978
|
+
gimbalRollRotateEnable: false,
|
|
979
|
+
gimbalRollRotateAngle: degrees(0),
|
|
980
|
+
gimbalYawRotateEnable: true,
|
|
981
|
+
gimbalYawRotateAngle: degrees(0),
|
|
982
|
+
gimbalRotateTimeEnable: false,
|
|
983
|
+
gimbalRotateTime: 10,
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
actionId: 1,
|
|
988
|
+
actionActuatorFunc: "hover",
|
|
989
|
+
actionActuatorFuncParam: { hoverTime: 0.5 },
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
};
|
|
993
|
+
const shooting = shootingGroups(folder, startIndex, endIndex, firstGroupId + 1, inputs);
|
|
994
|
+
const unlockGroup: ActionGroup = {
|
|
995
|
+
actionGroupId: firstGroupId + shooting.length + 1,
|
|
996
|
+
actionGroupStartIndex: endIndex,
|
|
997
|
+
actionGroupEndIndex: endIndex,
|
|
998
|
+
actionGroupMode: "sequence",
|
|
999
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
1000
|
+
action: [{ actionId: 0, actionActuatorFunc: "gimbalAngleUnlock" }],
|
|
1001
|
+
};
|
|
1002
|
+
return [gimbalGroup, ...shooting, unlockGroup];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Compile a mappingStrip template into one executable Folder per cut region. */
|
|
1006
|
+
export function planMappingStrip(template: MappingStripTemplate, options?: PlanOptions): Waylines {
|
|
1007
|
+
const folder = template.Folder;
|
|
1008
|
+
const derived = deriveMappingStrip(template, options);
|
|
1009
|
+
const inputs = normalizeInputs(folder, options);
|
|
1010
|
+
const waylineFolders: WaylinesFolder[] = derived.flightRoutes.map((route, waylineId) => {
|
|
1011
|
+
const points = route.executablePath;
|
|
1012
|
+
const groupsByStart = new Map<number, ActionGroup[]>();
|
|
1013
|
+
const forcedStopIndexes = new Set<number>();
|
|
1014
|
+
if (!folder.Placemark.elevationOptimizeEnable) {
|
|
1015
|
+
for (const group of continuousMappingGroups(folder, points.length - 1, inputs)) {
|
|
1016
|
+
const index = group.actionGroupStartIndex;
|
|
1017
|
+
groupsByStart.set(index, [...(groupsByStart.get(index) ?? []), group]);
|
|
1018
|
+
}
|
|
1019
|
+
} else {
|
|
1020
|
+
let cursor = 0;
|
|
1021
|
+
let nextActionGroupId = 0;
|
|
1022
|
+
for (const segment of route.segments.filter(({ kind }) => kind !== "transit")) {
|
|
1023
|
+
const startIndex = cursor;
|
|
1024
|
+
const endIndex = startIndex + segment.points.length - 1;
|
|
1025
|
+
if (segment.photoEnabled && endIndex > startIndex) {
|
|
1026
|
+
const shootingStartIndex =
|
|
1027
|
+
segment.kind === "elevationOptimize" ? startIndex + 1 : startIndex;
|
|
1028
|
+
const groups =
|
|
1029
|
+
segment.kind === "elevationOptimize"
|
|
1030
|
+
? elevationOptimizeGroups(
|
|
1031
|
+
folder,
|
|
1032
|
+
shootingStartIndex,
|
|
1033
|
+
endIndex,
|
|
1034
|
+
nextActionGroupId,
|
|
1035
|
+
inputs,
|
|
1036
|
+
)
|
|
1037
|
+
: shootingGroups(folder, shootingStartIndex, endIndex, nextActionGroupId, inputs);
|
|
1038
|
+
nextActionGroupId += groups.length;
|
|
1039
|
+
for (const group of groups) {
|
|
1040
|
+
const index = group.actionGroupStartIndex;
|
|
1041
|
+
groupsByStart.set(index, [...(groupsByStart.get(index) ?? []), group]);
|
|
1042
|
+
}
|
|
1043
|
+
if (segment.kind === "elevationOptimize") {
|
|
1044
|
+
forcedStopIndexes.add(startIndex);
|
|
1045
|
+
forcedStopIndexes.add(shootingStartIndex);
|
|
1046
|
+
forcedStopIndexes.add(endIndex);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
cursor = endIndex;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const placemarks: WaylinesPlacemark[] = points.map((point, index) => {
|
|
1053
|
+
const isFirst = index === 0;
|
|
1054
|
+
const isLast = index === points.length - 1;
|
|
1055
|
+
const headingAngle = isLast
|
|
1056
|
+
? 0
|
|
1057
|
+
: bearingDeg(lngLatToEnu(point, point), lngLatToEnu(points[index + 1]!, point));
|
|
1058
|
+
return {
|
|
1059
|
+
Point: { coordinates: point },
|
|
1060
|
+
index,
|
|
1061
|
+
executeHeight: folder.Placemark.height,
|
|
1062
|
+
waypointSpeed: folder.autoFlightSpeed,
|
|
1063
|
+
waypointHeadingParam: {
|
|
1064
|
+
waypointHeadingMode: "followWayline",
|
|
1065
|
+
waypointHeadingAngle: degrees(headingAngle),
|
|
1066
|
+
waypointHeadingPathMode: "followBadArc",
|
|
1067
|
+
},
|
|
1068
|
+
waypointTurnParam:
|
|
1069
|
+
isFirst || isLast || forcedStopIndexes.has(index)
|
|
1070
|
+
? {
|
|
1071
|
+
waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
|
|
1072
|
+
waypointTurnDampingDist: meters(0),
|
|
1073
|
+
}
|
|
1074
|
+
: {
|
|
1075
|
+
waypointTurnMode: "coordinateTurn",
|
|
1076
|
+
waypointTurnDampingDist: turnDamping(points, index),
|
|
1077
|
+
},
|
|
1078
|
+
useStraightLine: true,
|
|
1079
|
+
isRisky: false,
|
|
1080
|
+
...(groupsByStart.has(index) ? { actionGroup: groupsByStart.get(index) } : {}),
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
1083
|
+
return {
|
|
1084
|
+
templateId: folder.templateId + waylineId,
|
|
1085
|
+
waylineId,
|
|
1086
|
+
executeHeightMode: heightModeToExecuteMode(folder),
|
|
1087
|
+
autoFlightSpeed: folder.autoFlightSpeed,
|
|
1088
|
+
Placemark: placemarks,
|
|
1089
|
+
};
|
|
1090
|
+
});
|
|
1091
|
+
return { missionConfig: template.missionConfig, Folder: waylineFolders };
|
|
1092
|
+
}
|