@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
package/dist/index.mjs
CHANGED
|
@@ -184,6 +184,7 @@ const PayloadEnum = {
|
|
|
184
184
|
const EARTH_RADIUS = 6371e3;
|
|
185
185
|
const DEG2RAD = Math.PI / 180;
|
|
186
186
|
const RAD2DEG = 180 / Math.PI;
|
|
187
|
+
const ANGLE_EPS_RAD = 1e-12;
|
|
187
188
|
/** Project lng/lat to local ENU meters around `origin`. */
|
|
188
189
|
function lngLatToEnu(point, origin) {
|
|
189
190
|
const dLat = (point.lat - origin.lat) * DEG2RAD;
|
|
@@ -290,6 +291,7 @@ function dedupeRing(ring, epsM = .5) {
|
|
|
290
291
|
}
|
|
291
292
|
/** Beyond this multiple of `distM`, a corner miter is clamped to avoid spikes. */
|
|
292
293
|
const OFFSET_MITER_LIMIT = 2.5;
|
|
294
|
+
const MIN_SCAN_SEGMENT_LEN_M = 1e-6;
|
|
293
295
|
/**
|
|
294
296
|
* Outward-offset (dilate) a simple polygon ring by `distM` meters in ENU.
|
|
295
297
|
* Each vertex moves along the bisector of its two adjacent outward edge normals
|
|
@@ -335,6 +337,28 @@ function offsetPolygon(ring, distM) {
|
|
|
335
337
|
return out;
|
|
336
338
|
}
|
|
337
339
|
/**
|
|
340
|
+
* Pull same-side boustrophedon turn points inward until adjacent flight lines
|
|
341
|
+
* share one x coordinate. This keeps margin=0 routes inside the polygon while
|
|
342
|
+
* avoiding very acute boundary-following connectors on slanted edges.
|
|
343
|
+
*/
|
|
344
|
+
function stabilizeTurnCorridors(segments) {
|
|
345
|
+
const segmentPts = segments.map(({ a, b, y, leftToRight }) => leftToRight ? [[a, y], [b, y]] : [[b, y], [a, y]]);
|
|
346
|
+
for (let i = 0; i + 1 < segments.length; i++) {
|
|
347
|
+
const cur = segments[i];
|
|
348
|
+
const next = segments[i + 1];
|
|
349
|
+
if (cur.leftToRight === next.leftToRight) continue;
|
|
350
|
+
const overlapMin = Math.max(cur.a, next.a);
|
|
351
|
+
const overlapMax = Math.min(cur.b, next.b);
|
|
352
|
+
if (overlapMin > overlapMax) continue;
|
|
353
|
+
const turnX = cur.leftToRight ? overlapMax : overlapMin;
|
|
354
|
+
segmentPts[i][1] = [turnX, cur.y];
|
|
355
|
+
segmentPts[i + 1][0] = [turnX, next.y];
|
|
356
|
+
}
|
|
357
|
+
const pts = [];
|
|
358
|
+
for (const [start, end] of segmentPts) pts.push(start, end);
|
|
359
|
+
return pts;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
338
362
|
* Boustrophedon survey grid over an arbitrary simple polygon, aligned to `directionDeg`.
|
|
339
363
|
* Flight lines run parallel to the heading; consecutive lines step by `lineSpacing`.
|
|
340
364
|
* `marginM` extends each line lengthwise past the polygon edge.
|
|
@@ -344,7 +368,8 @@ function offsetPolygon(ring, distM) {
|
|
|
344
368
|
function surveyGrid(ring, opts) {
|
|
345
369
|
const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
|
|
346
370
|
const t = directionDeg * DEG2RAD;
|
|
347
|
-
const
|
|
371
|
+
const rawAlpha = Math.atan2(Math.cos(t), Math.sin(t));
|
|
372
|
+
const alpha = Math.abs(rawAlpha) < ANGLE_EPS_RAD ? 0 : rawAlpha;
|
|
348
373
|
const scanRing = ring.map((p) => rotateEnu(p, -alpha));
|
|
349
374
|
let minY = Infinity;
|
|
350
375
|
let maxY = -Infinity;
|
|
@@ -352,18 +377,25 @@ function surveyGrid(ring, opts) {
|
|
|
352
377
|
if (y < minY) minY = y;
|
|
353
378
|
if (y > maxY) maxY = y;
|
|
354
379
|
}
|
|
355
|
-
const
|
|
380
|
+
const scanSegments = [];
|
|
356
381
|
let leftToRight = true;
|
|
357
382
|
for (let y = minY + phaseOffsetM; y <= maxY + lineSpacing * .5; y += lineSpacing) {
|
|
383
|
+
let emitted = false;
|
|
358
384
|
for (const [x1, x2] of clipHorizontal(y, scanRing)) {
|
|
385
|
+
if (Math.abs(x2 - x1) < MIN_SCAN_SEGMENT_LEN_M) continue;
|
|
359
386
|
const a = x1 - marginM + lengthwiseShiftM;
|
|
360
387
|
const b = x2 + marginM + lengthwiseShiftM;
|
|
361
|
-
|
|
362
|
-
|
|
388
|
+
scanSegments.push({
|
|
389
|
+
a,
|
|
390
|
+
b,
|
|
391
|
+
y,
|
|
392
|
+
leftToRight
|
|
393
|
+
});
|
|
394
|
+
emitted = true;
|
|
363
395
|
}
|
|
364
|
-
leftToRight = !leftToRight;
|
|
396
|
+
if (emitted) leftToRight = !leftToRight;
|
|
365
397
|
}
|
|
366
|
-
return
|
|
398
|
+
return stabilizeTurnCorridors(scanSegments).map((p) => rotateEnu(p, alpha));
|
|
367
399
|
}
|
|
368
400
|
//#endregion
|
|
369
401
|
//#region src/plan/plan-mapping2d.ts
|
|
@@ -429,7 +461,7 @@ function enuSegLen(a, b) {
|
|
|
429
461
|
* Clamping each side to MAX_DAMPING_FRAC × its shorter adjacent segment
|
|
430
462
|
* guarantees the sum is at most 2 × MAX_DAMPING_FRAC < 1 of that segment.
|
|
431
463
|
*/
|
|
432
|
-
function turnDamping(enuWps, i) {
|
|
464
|
+
function turnDamping$1(enuWps, i) {
|
|
433
465
|
const dPrev = enuSegLen(enuWps[i - 1], enuWps[i]);
|
|
434
466
|
const dNext = enuSegLen(enuWps[i], enuWps[i + 1]);
|
|
435
467
|
return meters(Math.min(DESIRED_TURN_DAMPING_M, dPrev * MAX_DAMPING_FRAC, dNext * MAX_DAMPING_FRAC));
|
|
@@ -526,7 +558,7 @@ function shootSectionStartActions(pitchDeg, shootInterval, sectionStart, section
|
|
|
526
558
|
actionActuatorFuncParam: {
|
|
527
559
|
payloadPositionIndex: 0,
|
|
528
560
|
useGlobalPayloadLensIndex: false,
|
|
529
|
-
payloadLensIndex: "visable",
|
|
561
|
+
payloadLensIndex: ["visable"],
|
|
530
562
|
minShootInterval: shootInterval
|
|
531
563
|
}
|
|
532
564
|
}
|
|
@@ -574,7 +606,7 @@ function shootSectionStopActions(sectionEnd, groupId) {
|
|
|
574
606
|
actionActuatorFunc: "stopTimeLapse",
|
|
575
607
|
actionActuatorFuncParam: {
|
|
576
608
|
payloadPositionIndex: 0,
|
|
577
|
-
payloadLensIndex: "visable"
|
|
609
|
+
payloadLensIndex: ["visable"]
|
|
578
610
|
}
|
|
579
611
|
}, {
|
|
580
612
|
actionId: 1,
|
|
@@ -712,7 +744,7 @@ function emitOrtho(folder, origin, enuWps, shootInterval, options) {
|
|
|
712
744
|
waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
|
|
713
745
|
waypointTurnParam: index === 0 || isLast ? stopTurn() : {
|
|
714
746
|
waypointTurnMode: "coordinateTurn",
|
|
715
|
-
waypointTurnDampingDist: turnDamping(enuWps, index)
|
|
747
|
+
waypointTurnDampingDist: turnDamping$1(enuWps, index)
|
|
716
748
|
},
|
|
717
749
|
...COMMON_WP_EXTRAS
|
|
718
750
|
};
|
|
@@ -751,7 +783,7 @@ function emitSmartOblique(folder, origin, enuWps, shootInterval, options) {
|
|
|
751
783
|
waypointHeadingParam: mappingHeadingParam(headingAngle, !isLast),
|
|
752
784
|
waypointTurnParam: index === 0 || isLast ? stopTurn() : {
|
|
753
785
|
waypointTurnMode: "coordinateTurn",
|
|
754
|
-
waypointTurnDampingDist: turnDamping(enuWps, index)
|
|
786
|
+
waypointTurnDampingDist: turnDamping$1(enuWps, index)
|
|
755
787
|
},
|
|
756
788
|
...COMMON_WP_EXTRAS
|
|
757
789
|
};
|
|
@@ -1022,64 +1054,450 @@ function heightModeToExecuteMode$2(mode) {
|
|
|
1022
1054
|
}
|
|
1023
1055
|
//#endregion
|
|
1024
1056
|
//#region src/plan/plan-mapping-strip.ts
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
function planMappingStrip(template) {
|
|
1033
|
-
const folder = template.Folder;
|
|
1034
|
-
const coords = parseTriples(folder.Placemark.LineString.coordinates);
|
|
1035
|
-
const origin = centroid(coords.map((c) => ({
|
|
1036
|
-
lng: c.lng,
|
|
1037
|
-
lat: c.lat
|
|
1038
|
-
})));
|
|
1039
|
-
const enuPath = coords.map((c) => lngLatToEnu({
|
|
1040
|
-
lng: c.lng,
|
|
1041
|
-
lat: c.lat
|
|
1042
|
-
}, origin));
|
|
1043
|
-
const placemarks = coords.map((c, index) => {
|
|
1044
|
-
const headingAngle = index === coords.length - 1 ? 0 : bearingDeg(enuPath[index], enuPath[index + 1]);
|
|
1045
|
-
const executeHeight = folder.Placemark.stripUseTemplateAltitude && c.alt !== void 0 ? c.alt : folder.Placemark.height;
|
|
1057
|
+
const POINT_EPS_M = .05;
|
|
1058
|
+
const MITER_LIMIT = 2.5;
|
|
1059
|
+
const MAX_ROUTE_COUNT = 1e3;
|
|
1060
|
+
const TURN_DAMPING_M = 10;
|
|
1061
|
+
function parseTriples(s) {
|
|
1062
|
+
return s.trim().split(/\s+/).filter(Boolean).map((tuple) => {
|
|
1063
|
+
const parts = tuple.split(",").map(Number);
|
|
1046
1064
|
return {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
} },
|
|
1051
|
-
index,
|
|
1052
|
-
executeHeight,
|
|
1053
|
-
waypointSpeed: folder.autoFlightSpeed,
|
|
1054
|
-
waypointHeadingParam: {
|
|
1055
|
-
waypointHeadingMode: "followWayline",
|
|
1056
|
-
waypointHeadingAngle: degrees(headingAngle),
|
|
1057
|
-
waypointHeadingPathMode: "followBadArc"
|
|
1058
|
-
},
|
|
1059
|
-
waypointTurnParam: { waypointTurnMode: "toPointAndPassWithContinuityCurvature" }
|
|
1065
|
+
lng: parts[0],
|
|
1066
|
+
lat: parts[1],
|
|
1067
|
+
...parts[2] !== void 0 ? { alt: parts[2] } : {}
|
|
1060
1068
|
};
|
|
1061
1069
|
});
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1070
|
+
}
|
|
1071
|
+
function cleanLine(points) {
|
|
1072
|
+
const clean = [];
|
|
1073
|
+
for (const point of points) {
|
|
1074
|
+
const previous = clean.at(-1);
|
|
1075
|
+
if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) < POINT_EPS_M) continue;
|
|
1076
|
+
clean.push([point[0], point[1]]);
|
|
1077
|
+
}
|
|
1078
|
+
return clean;
|
|
1079
|
+
}
|
|
1080
|
+
function lineLength(points) {
|
|
1081
|
+
let total = 0;
|
|
1082
|
+
for (let index = 1; index < points.length; index++) total += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);
|
|
1083
|
+
return total;
|
|
1084
|
+
}
|
|
1085
|
+
function localRouteSegments(lines) {
|
|
1086
|
+
const segments = [];
|
|
1087
|
+
lines.forEach((line, index) => {
|
|
1088
|
+
if (index > 0) segments.push({
|
|
1089
|
+
kind: "turn",
|
|
1090
|
+
points: [lines[index - 1].at(-1), line[0]],
|
|
1091
|
+
photoEnabled: false
|
|
1092
|
+
});
|
|
1093
|
+
segments.push({
|
|
1094
|
+
kind: "work",
|
|
1095
|
+
points: line,
|
|
1096
|
+
photoEnabled: true
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
return segments;
|
|
1100
|
+
}
|
|
1101
|
+
function serpentineRouteVariants(lines) {
|
|
1102
|
+
const variants = [];
|
|
1103
|
+
for (const reverseLineOrder of [false, true]) {
|
|
1104
|
+
const ordered = reverseLineOrder ? [...lines].reverse() : [...lines];
|
|
1105
|
+
for (const reverseFirstLine of [false, true]) variants.push(ordered.map((line, index) => index % 2 === Number(reverseFirstLine) ? [...line] : [...line].reverse()));
|
|
1106
|
+
}
|
|
1107
|
+
return variants;
|
|
1108
|
+
}
|
|
1109
|
+
function localRouteCandidate(lines, centerLine, elevationOptimizeEnable) {
|
|
1110
|
+
const segments = localRouteSegments(lines);
|
|
1111
|
+
if (elevationOptimizeEnable) {
|
|
1112
|
+
const normalEnd = joinSegmentPoints(segments).at(-1);
|
|
1113
|
+
let optimizationLine = [...centerLine];
|
|
1114
|
+
const first = optimizationLine[0];
|
|
1115
|
+
const last = optimizationLine.at(-1);
|
|
1116
|
+
if (Math.hypot(last[0] - normalEnd[0], last[1] - normalEnd[1]) < Math.hypot(first[0] - normalEnd[0], first[1] - normalEnd[1])) optimizationLine = optimizationLine.reverse();
|
|
1117
|
+
const optimizationPath = cleanLine([normalEnd, ...optimizationLine]);
|
|
1118
|
+
segments.push({
|
|
1119
|
+
kind: "elevationOptimize",
|
|
1120
|
+
points: [normalEnd, ...optimizationPath],
|
|
1121
|
+
photoEnabled: true
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const executable = joinSegmentPoints(segments);
|
|
1069
1125
|
return {
|
|
1070
|
-
|
|
1071
|
-
|
|
1126
|
+
lines,
|
|
1127
|
+
segments,
|
|
1128
|
+
start: executable[0],
|
|
1129
|
+
end: executable.at(-1)
|
|
1072
1130
|
};
|
|
1073
1131
|
}
|
|
1074
|
-
function
|
|
1075
|
-
|
|
1076
|
-
|
|
1132
|
+
function shortestCandidateIndexes(candidateGroups, startPoint) {
|
|
1133
|
+
if (candidateGroups.length === 0) return [];
|
|
1134
|
+
let costs = candidateGroups[0].map((candidate) => startPoint ? Math.hypot(candidate.start[0] - startPoint[0], candidate.start[1] - startPoint[1]) : 0);
|
|
1135
|
+
const previousIndexes = [candidateGroups[0].map(() => -1)];
|
|
1136
|
+
for (let regionIndex = 1; regionIndex < candidateGroups.length; regionIndex++) {
|
|
1137
|
+
const previousCandidates = candidateGroups[regionIndex - 1];
|
|
1138
|
+
const candidates = candidateGroups[regionIndex];
|
|
1139
|
+
const nextCosts = [];
|
|
1140
|
+
const nextPreviousIndexes = [];
|
|
1141
|
+
for (const candidate of candidates) {
|
|
1142
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
1143
|
+
let bestPreviousIndex = 0;
|
|
1144
|
+
previousCandidates.forEach((previous, previousIndex) => {
|
|
1145
|
+
const transition = Math.hypot(candidate.start[0] - previous.end[0], candidate.start[1] - previous.end[1]);
|
|
1146
|
+
const cost = costs[previousIndex] + transition;
|
|
1147
|
+
if (cost < bestCost) {
|
|
1148
|
+
bestCost = cost;
|
|
1149
|
+
bestPreviousIndex = previousIndex;
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
nextCosts.push(bestCost);
|
|
1153
|
+
nextPreviousIndexes.push(bestPreviousIndex);
|
|
1154
|
+
}
|
|
1155
|
+
costs = nextCosts;
|
|
1156
|
+
previousIndexes.push(nextPreviousIndexes);
|
|
1157
|
+
}
|
|
1158
|
+
let selectedIndex = costs.reduce((best, cost, index) => cost < costs[best] ? index : best, 0);
|
|
1159
|
+
const selected = Array.from({ length: candidateGroups.length }, () => 0);
|
|
1160
|
+
for (let regionIndex = candidateGroups.length - 1; regionIndex >= 0; regionIndex--) {
|
|
1161
|
+
selected[regionIndex] = selectedIndex;
|
|
1162
|
+
selectedIndex = previousIndexes[regionIndex][selectedIndex];
|
|
1163
|
+
}
|
|
1164
|
+
return selected;
|
|
1165
|
+
}
|
|
1166
|
+
function joinSegmentPoints(segments) {
|
|
1167
|
+
const result = [];
|
|
1168
|
+
for (const segment of segments) if (result.length === 0) result.push(...segment.points);
|
|
1169
|
+
else result.push(...segment.points.slice(1));
|
|
1170
|
+
return result;
|
|
1171
|
+
}
|
|
1172
|
+
function splitLineByDistance(points, distance) {
|
|
1173
|
+
const total = lineLength(points);
|
|
1174
|
+
const cut = Number.isFinite(distance) && distance > POINT_EPS_M ? distance : total;
|
|
1175
|
+
if (cut >= total - POINT_EPS_M) return [{
|
|
1176
|
+
id: "region-0",
|
|
1177
|
+
sourceRegionIndexes: [0],
|
|
1178
|
+
startDistance: 0,
|
|
1179
|
+
endDistance: total,
|
|
1180
|
+
centerLine: [...points],
|
|
1181
|
+
polygon: []
|
|
1182
|
+
}];
|
|
1183
|
+
const regions = [];
|
|
1184
|
+
let current = [points[0]];
|
|
1185
|
+
let regionStart = 0;
|
|
1186
|
+
let accumulated = 0;
|
|
1187
|
+
const finishRegion = (endDistance) => {
|
|
1188
|
+
if (current.length < 2) return;
|
|
1189
|
+
regions.push({
|
|
1190
|
+
id: `region-${regions.length}`,
|
|
1191
|
+
sourceRegionIndexes: [regions.length],
|
|
1192
|
+
startDistance: regionStart,
|
|
1193
|
+
endDistance,
|
|
1194
|
+
centerLine: current,
|
|
1195
|
+
polygon: []
|
|
1196
|
+
});
|
|
1197
|
+
current = [current.at(-1)];
|
|
1198
|
+
regionStart = endDistance;
|
|
1199
|
+
};
|
|
1200
|
+
for (let index = 1; index < points.length; index++) {
|
|
1201
|
+
const a = points[index - 1];
|
|
1202
|
+
const b = points[index];
|
|
1203
|
+
const segmentLength = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
1204
|
+
const currentLength = accumulated - regionStart;
|
|
1205
|
+
if (currentLength > POINT_EPS_M && currentLength + segmentLength > cut + POINT_EPS_M) finishRegion(accumulated);
|
|
1206
|
+
let segmentStart = a;
|
|
1207
|
+
let consumed = 0;
|
|
1208
|
+
while (segmentLength - consumed > cut + POINT_EPS_M) {
|
|
1209
|
+
const step = cut;
|
|
1210
|
+
const t = (consumed + step) / segmentLength;
|
|
1211
|
+
const point = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
1212
|
+
if (current.at(-1) !== segmentStart) current.push(segmentStart);
|
|
1213
|
+
current.push(point);
|
|
1214
|
+
finishRegion(accumulated + consumed + step);
|
|
1215
|
+
segmentStart = point;
|
|
1216
|
+
consumed += step;
|
|
1217
|
+
}
|
|
1218
|
+
if (current.at(-1) !== segmentStart) current.push(segmentStart);
|
|
1219
|
+
current.push(b);
|
|
1220
|
+
accumulated += segmentLength;
|
|
1221
|
+
}
|
|
1222
|
+
finishRegion(total);
|
|
1223
|
+
return regions;
|
|
1224
|
+
}
|
|
1225
|
+
function applyMergedRegionRanges(regions, ranges) {
|
|
1226
|
+
if (!ranges || ranges.length === 0) return [...regions];
|
|
1227
|
+
const normalized = ranges.map((range) => {
|
|
1228
|
+
if (range.length !== 2) throw new PlannerError("mappingStrip merged region range must contain [start,end]");
|
|
1229
|
+
const start = range[0] - 1;
|
|
1230
|
+
const end = range[1] - 1;
|
|
1231
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) throw new PlannerError("mappingStrip merged region range is invalid");
|
|
1232
|
+
if (end >= regions.length) throw new PlannerError("mappingStrip merged region range exceeds region count");
|
|
1077
1233
|
return {
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
...parts[2] !== void 0 ? { alt: parts[2] } : {}
|
|
1234
|
+
start,
|
|
1235
|
+
end
|
|
1081
1236
|
};
|
|
1237
|
+
}).sort((a, b) => a.start - b.start);
|
|
1238
|
+
for (let index = 1; index < normalized.length; index++) if (normalized[index].start <= normalized[index - 1].end) throw new PlannerError("mappingStrip merged region ranges cannot overlap");
|
|
1239
|
+
const byStart = new Map(normalized.map((range) => [range.start, range]));
|
|
1240
|
+
const merged = [];
|
|
1241
|
+
for (let index = 0; index < regions.length;) {
|
|
1242
|
+
const range = byStart.get(index);
|
|
1243
|
+
if (!range) {
|
|
1244
|
+
merged.push(regions[index]);
|
|
1245
|
+
index += 1;
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
const members = regions.slice(range.start, range.end + 1);
|
|
1249
|
+
merged.push({
|
|
1250
|
+
id: `region-${range.start}-${range.end}`,
|
|
1251
|
+
sourceRegionIndexes: members.flatMap((region) => region.sourceRegionIndexes),
|
|
1252
|
+
startDistance: members[0].startDistance,
|
|
1253
|
+
endDistance: members.at(-1).endDistance,
|
|
1254
|
+
centerLine: members.flatMap((region, memberIndex) => memberIndex === 0 ? region.centerLine : region.centerLine.slice(1)),
|
|
1255
|
+
polygon: [],
|
|
1256
|
+
beforePoint: members[0].beforePoint,
|
|
1257
|
+
afterPoint: members.at(-1).afterPoint
|
|
1258
|
+
});
|
|
1259
|
+
index = range.end + 1;
|
|
1260
|
+
}
|
|
1261
|
+
return merged;
|
|
1262
|
+
}
|
|
1263
|
+
function cross(a, b) {
|
|
1264
|
+
return a[0] * b[1] - a[1] * b[0];
|
|
1265
|
+
}
|
|
1266
|
+
function segmentUnit(a, b) {
|
|
1267
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
1268
|
+
if (length < POINT_EPS_M) throw new PlannerError("mappingStrip center line has a zero segment");
|
|
1269
|
+
return [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
|
|
1270
|
+
}
|
|
1271
|
+
function leftNormal(direction) {
|
|
1272
|
+
return [-direction[1], direction[0]];
|
|
1273
|
+
}
|
|
1274
|
+
function offsetPolyline(points, distance) {
|
|
1275
|
+
if (distance === 0) return points.map((point) => [point[0], point[1]]);
|
|
1276
|
+
const directions = points.slice(1).map((point, index) => segmentUnit(points[index], point));
|
|
1277
|
+
const out = [];
|
|
1278
|
+
const firstNormal = leftNormal(directions[0]);
|
|
1279
|
+
out.push([points[0][0] + firstNormal[0] * distance, points[0][1] + firstNormal[1] * distance]);
|
|
1280
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
1281
|
+
const previousDirection = directions[index - 1];
|
|
1282
|
+
const nextDirection = directions[index];
|
|
1283
|
+
const previousNormal = leftNormal(previousDirection);
|
|
1284
|
+
const nextNormal = leftNormal(nextDirection);
|
|
1285
|
+
const previousOffset = [points[index][0] + previousNormal[0] * distance, points[index][1] + previousNormal[1] * distance];
|
|
1286
|
+
const nextOffset = [points[index][0] + nextNormal[0] * distance, points[index][1] + nextNormal[1] * distance];
|
|
1287
|
+
const denominator = cross(previousDirection, nextDirection);
|
|
1288
|
+
if (Math.abs(denominator) < 1e-9) {
|
|
1289
|
+
out.push(previousOffset);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const t = cross([nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]], nextDirection) / denominator;
|
|
1293
|
+
const intersection = [previousOffset[0] + previousDirection[0] * t, previousOffset[1] + previousDirection[1] * t];
|
|
1294
|
+
if (Math.hypot(intersection[0] - points[index][0], intersection[1] - points[index][1]) <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
|
|
1295
|
+
else out.push(previousOffset, nextOffset);
|
|
1296
|
+
}
|
|
1297
|
+
const lastNormal = leftNormal(directions.at(-1));
|
|
1298
|
+
const last = points.at(-1);
|
|
1299
|
+
out.push([last[0] + lastNormal[0] * distance, last[1] + lastNormal[1] * distance]);
|
|
1300
|
+
return out;
|
|
1301
|
+
}
|
|
1302
|
+
function corridorSide(points, distance, beforePoint, afterPoint) {
|
|
1303
|
+
const out = [];
|
|
1304
|
+
for (let index = 0; index < points.length; index++) {
|
|
1305
|
+
const point = points[index];
|
|
1306
|
+
const previous = index > 0 ? points[index - 1] : beforePoint;
|
|
1307
|
+
const next = index + 1 < points.length ? points[index + 1] : afterPoint;
|
|
1308
|
+
if (!previous && next) {
|
|
1309
|
+
const normal = leftNormal(segmentUnit(point, next));
|
|
1310
|
+
out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
if (previous && !next) {
|
|
1314
|
+
const normal = leftNormal(segmentUnit(previous, point));
|
|
1315
|
+
out.push([point[0] + normal[0] * distance, point[1] + normal[1] * distance]);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (!previous || !next) continue;
|
|
1319
|
+
const previousDirection = segmentUnit(previous, point);
|
|
1320
|
+
const nextDirection = segmentUnit(point, next);
|
|
1321
|
+
const previousNormal = leftNormal(previousDirection);
|
|
1322
|
+
const nextNormal = leftNormal(nextDirection);
|
|
1323
|
+
const previousOffset = [point[0] + previousNormal[0] * distance, point[1] + previousNormal[1] * distance];
|
|
1324
|
+
const nextOffset = [point[0] + nextNormal[0] * distance, point[1] + nextNormal[1] * distance];
|
|
1325
|
+
const denominator = cross(previousDirection, nextDirection);
|
|
1326
|
+
if (Math.abs(denominator) < 1e-9) {
|
|
1327
|
+
out.push(previousOffset);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (denominator * distance < 0) {
|
|
1331
|
+
out.push(previousOffset, nextOffset);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const t = cross([nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]], nextDirection) / denominator;
|
|
1335
|
+
const intersection = [previousOffset[0] + previousDirection[0] * t, previousOffset[1] + previousDirection[1] * t];
|
|
1336
|
+
if (Math.hypot(intersection[0] - point[0], intersection[1] - point[1]) <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
|
|
1337
|
+
else out.push(previousOffset, nextOffset);
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
}
|
|
1341
|
+
function corridorPolygon(points, left, right, beforePoint, afterPoint) {
|
|
1342
|
+
return [...corridorSide(points, left, beforePoint, afterPoint), ...corridorSide(points, -right, beforePoint, afterPoint).reverse()];
|
|
1343
|
+
}
|
|
1344
|
+
function perpendicularRouteLines(centerLine, leftExtend, rightExtend, margin, lineSpacing) {
|
|
1345
|
+
const left = offsetPolyline(centerLine, leftExtend);
|
|
1346
|
+
const right = offsetPolyline(centerLine, -rightExtend);
|
|
1347
|
+
if (left.length !== centerLine.length || right.length !== centerLine.length) throw new PlannerError("mappingStrip offset corridor cannot be segmented");
|
|
1348
|
+
const lines = [];
|
|
1349
|
+
for (let index = 0; index < centerLine.length - 1; index++) {
|
|
1350
|
+
const segment = [centerLine[index], centerLine[index + 1]];
|
|
1351
|
+
const cell = [
|
|
1352
|
+
left[index],
|
|
1353
|
+
left[index + 1],
|
|
1354
|
+
right[index + 1],
|
|
1355
|
+
right[index]
|
|
1356
|
+
];
|
|
1357
|
+
const direction = segmentUnit(segment[0], segment[1]);
|
|
1358
|
+
const projections = cell.map((point) => point[0] * direction[0] + point[1] * direction[1]);
|
|
1359
|
+
const longitudinalSpan = Math.max(...projections) - Math.min(...projections);
|
|
1360
|
+
const sourceSegmentLength = lineLength(segment);
|
|
1361
|
+
const previousDirection = index > 0 ? segmentUnit(centerLine[index - 1], segment[0]) : void 0;
|
|
1362
|
+
const hasSharpStart = previousDirection !== void 0 && Math.abs(cross(previousDirection, direction)) > Math.sin(5 * Math.PI / 180);
|
|
1363
|
+
const lineCount = Math.max(1, Math.ceil(sourceSegmentLength / lineSpacing), hasSharpStart ? Math.ceil(longitudinalSpan / lineSpacing) : Math.round(longitudinalSpan / lineSpacing));
|
|
1364
|
+
const phaseOffset = Math.max(0, (longitudinalSpan - (lineCount - 1) * lineSpacing) / 2);
|
|
1365
|
+
const grid = surveyGrid(cell, {
|
|
1366
|
+
directionDeg: (bearingDeg(segment[0], segment[1]) + 90) % 360,
|
|
1367
|
+
marginM: margin,
|
|
1368
|
+
lineSpacing,
|
|
1369
|
+
phaseOffsetM: phaseOffset
|
|
1370
|
+
});
|
|
1371
|
+
const flightDirection = [direction[1], -direction[0]];
|
|
1372
|
+
for (let gridIndex = 0; gridIndex + 1 < grid.length; gridIndex += 2) {
|
|
1373
|
+
const a = grid[gridIndex];
|
|
1374
|
+
const b = grid[gridIndex + 1];
|
|
1375
|
+
const followsDirection = (b[0] - a[0]) * flightDirection[0] + (b[1] - a[1]) * flightDirection[1] >= 0;
|
|
1376
|
+
lines.push(followsDirection ? [a, b] : [b, a]);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return lines;
|
|
1380
|
+
}
|
|
1381
|
+
function polygonArea(points) {
|
|
1382
|
+
let area2 = 0;
|
|
1383
|
+
for (let index = 0; index < points.length; index++) {
|
|
1384
|
+
const a = points[index];
|
|
1385
|
+
const b = points[(index + 1) % points.length];
|
|
1386
|
+
area2 += a[0] * b[1] - b[0] * a[1];
|
|
1387
|
+
}
|
|
1388
|
+
return Math.abs(area2) / 2;
|
|
1389
|
+
}
|
|
1390
|
+
function routeOffsets(folder, inputs) {
|
|
1391
|
+
if (folder.Placemark.singleLineEnable) return [0];
|
|
1392
|
+
const left = folder.Placemark.leftExtend;
|
|
1393
|
+
const minimum = -folder.Placemark.rightExtend;
|
|
1394
|
+
const maximum = left;
|
|
1395
|
+
const spacing = inputs.lineSpacing;
|
|
1396
|
+
if (!(spacing > POINT_EPS_M && Number.isFinite(spacing))) throw new PlannerError("mappingStrip lineSpacing must be finite and greater than zero");
|
|
1397
|
+
const span = maximum - minimum;
|
|
1398
|
+
const hasInfrared = folder.payloadParam?.imageFormat?.includes("ir") ?? false;
|
|
1399
|
+
const minimumCount = Math.max(hasInfrared && span > spacing + POINT_EPS_M ? 2 : 1, Math.ceil(Math.max(0, span - inputs.crossTrackFootprint) / spacing) + 1);
|
|
1400
|
+
if (minimumCount === 1) return [0];
|
|
1401
|
+
const count = minimumCount % 2 === 0 ? minimumCount : folder.Placemark.includeCenterEnable ? minimumCount : minimumCount + 1;
|
|
1402
|
+
if (count > MAX_ROUTE_COUNT) throw new PlannerError("mappingStrip route count exceeds limit");
|
|
1403
|
+
const offsets = Array.from({ length: count }, (_, index) => (minimum + maximum - (count - 1) * spacing) / 2 + index * spacing);
|
|
1404
|
+
if (folder.Placemark.includeCenterEnable) {
|
|
1405
|
+
const oddCount = minimumCount % 2 === 1 ? minimumCount : minimumCount + 1;
|
|
1406
|
+
const centered = Array.from({ length: oddCount }, (_, index) => (index - (oddCount - 1) / 2) * spacing);
|
|
1407
|
+
if (centered[0] >= minimum && centered.at(-1) <= maximum) return centered;
|
|
1408
|
+
if (!offsets.some((offset) => Math.abs(offset) < POINT_EPS_M)) offsets.push(0);
|
|
1409
|
+
}
|
|
1410
|
+
return offsets.sort((a, b) => a - b);
|
|
1411
|
+
}
|
|
1412
|
+
function normalizeInputs(folder, options) {
|
|
1413
|
+
const corridorWidth = folder.Placemark.leftExtend + folder.Placemark.rightExtend;
|
|
1414
|
+
return {
|
|
1415
|
+
crossTrackFootprint: Number(options?.crossTrackFootprint ?? corridorWidth),
|
|
1416
|
+
lineSpacing: Number(options?.lineSpacing ?? Math.max(corridorWidth, 1)),
|
|
1417
|
+
photoSpacing: Number(options?.photoSpacing ?? 1),
|
|
1418
|
+
shootInterval: Number(options?.shootInterval ?? 1)
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
function deriveMappingStrip(template, options) {
|
|
1422
|
+
const folder = template.Folder;
|
|
1423
|
+
if (folder.Placemark.leftExtend < 0 || folder.Placemark.rightExtend < 0) throw new PlannerError("mappingStrip extend distances cannot be negative");
|
|
1424
|
+
const triples = parseTriples(folder.Placemark.LineString.coordinates);
|
|
1425
|
+
if (triples.length < 2) throw new PlannerError("mappingStrip requires at least two center points");
|
|
1426
|
+
if (triples.some((point) => !Number.isFinite(point.lng) || !Number.isFinite(point.lat))) throw new PlannerError("mappingStrip center line contains non-finite coordinates");
|
|
1427
|
+
const origin = centroid(triples);
|
|
1428
|
+
const centerLine = cleanLine(triples.map((point) => lngLatToEnu(point, origin)));
|
|
1429
|
+
if (centerLine.length < 2) throw new PlannerError("mappingStrip requires two distinct center points");
|
|
1430
|
+
const inputs = normalizeInputs(folder, options);
|
|
1431
|
+
if (!(inputs.crossTrackFootprint > 0 && Number.isFinite(inputs.crossTrackFootprint))) throw new PlannerError("mappingStrip crossTrackFootprint must be finite and greater than zero");
|
|
1432
|
+
if (!(inputs.photoSpacing > 0 && Number.isFinite(inputs.photoSpacing))) throw new PlannerError("mappingStrip photoSpacing must be finite and greater than zero");
|
|
1433
|
+
const splitRegions = splitLineByDistance(centerLine, folder.Placemark.cuttingDistance);
|
|
1434
|
+
const localRegions = applyMergedRegionRanges(splitRegions.map((region, index) => ({
|
|
1435
|
+
...region,
|
|
1436
|
+
beforePoint: index > 0 ? splitRegions[index - 1].centerLine.at(-2) : void 0,
|
|
1437
|
+
afterPoint: index + 1 < splitRegions.length ? splitRegions[index + 1].centerLine[1] : void 0
|
|
1438
|
+
})), folder.Placemark.stripMergedRegions).map((region) => ({
|
|
1439
|
+
...region,
|
|
1440
|
+
polygon: corridorPolygon(region.centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend, region.beforePoint, region.afterPoint)
|
|
1441
|
+
}));
|
|
1442
|
+
const offsets = routeOffsets(folder, inputs);
|
|
1443
|
+
const executionRegions = options?.regionOrderReversed ? [...localRegions].reverse() : localRegions;
|
|
1444
|
+
const perpendicular = options?.stripDirection === "perpendicular" && !folder.Placemark.singleLineEnable;
|
|
1445
|
+
const candidateGroups = executionRegions.map((region) => {
|
|
1446
|
+
const rawLines = perpendicular ? perpendicularRouteLines(region.centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend, folder.Placemark.margin, inputs.lineSpacing) : offsets.map((offset) => offsetPolyline(region.centerLine, offset));
|
|
1447
|
+
if (rawLines.length === 0) throw new PlannerError("mappingStrip region has no available flight routes");
|
|
1448
|
+
return serpentineRouteVariants(rawLines).map((lines) => localRouteCandidate(lines, region.centerLine, folder.Placemark.elevationOptimizeEnable));
|
|
1449
|
+
});
|
|
1450
|
+
const takeOffRefPoint = template.missionConfig.takeOffRefPoint;
|
|
1451
|
+
const selectedCandidateIndexes = shortestCandidateIndexes(candidateGroups, takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : void 0);
|
|
1452
|
+
const flightRoutes = [];
|
|
1453
|
+
let previousRegionEnd;
|
|
1454
|
+
executionRegions.forEach((region, regionIndex) => {
|
|
1455
|
+
const candidate = candidateGroups[regionIndex][selectedCandidateIndexes[regionIndex]];
|
|
1456
|
+
const localSegments = previousRegionEnd ? [{
|
|
1457
|
+
kind: "transit",
|
|
1458
|
+
points: [previousRegionEnd, candidate.start],
|
|
1459
|
+
photoEnabled: false
|
|
1460
|
+
}, ...candidate.segments] : candidate.segments;
|
|
1461
|
+
const displayPath = joinSegmentPoints(localSegments);
|
|
1462
|
+
const executable = joinSegmentPoints(candidate.segments);
|
|
1463
|
+
flightRoutes.push({
|
|
1464
|
+
regionId: region.id,
|
|
1465
|
+
routeOffsets: perpendicular ? [] : offsets.map(meters),
|
|
1466
|
+
routeLines: candidate.lines.map((line) => line.map((point) => enuToLngLat(point, origin))),
|
|
1467
|
+
segments: localSegments.map((segment) => ({
|
|
1468
|
+
...segment,
|
|
1469
|
+
points: segment.points.map((point) => enuToLngLat(point, origin))
|
|
1470
|
+
})),
|
|
1471
|
+
executablePath: executable.map((point) => enuToLngLat(point, origin)),
|
|
1472
|
+
length: meters(lineLength(displayPath))
|
|
1473
|
+
});
|
|
1474
|
+
previousRegionEnd = candidate.end;
|
|
1082
1475
|
});
|
|
1476
|
+
const surveyAreaLocal = corridorPolygon(centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend);
|
|
1477
|
+
const totalFlightLength = flightRoutes.reduce((sum, route) => sum + Number(route.length), 0);
|
|
1478
|
+
const estimatedPhotoCount = flightRoutes.reduce((sum, route) => sum + route.segments.filter(({ photoEnabled }) => photoEnabled).reduce((lineSum, segment) => {
|
|
1479
|
+
let length = 0;
|
|
1480
|
+
for (let index = 1; index < segment.points.length; index++) length += haversineMeters(segment.points[index - 1], segment.points[index]);
|
|
1481
|
+
return lineSum + Math.max(1, Math.floor(length / inputs.photoSpacing) + 1);
|
|
1482
|
+
}, 0), 0);
|
|
1483
|
+
return {
|
|
1484
|
+
centerLineLength: meters(lineLength(centerLine)),
|
|
1485
|
+
surveyArea: surveyAreaLocal.map((point) => enuToLngLat(point, origin)),
|
|
1486
|
+
surveyAreaSize: localRegions.reduce((sum, region) => sum + polygonArea(region.polygon), 0),
|
|
1487
|
+
regions: localRegions.map((region) => ({
|
|
1488
|
+
id: region.id,
|
|
1489
|
+
sourceRegionIndexes: region.sourceRegionIndexes,
|
|
1490
|
+
startDistance: meters(region.startDistance),
|
|
1491
|
+
endDistance: meters(region.endDistance),
|
|
1492
|
+
centerLine: region.centerLine.map((point) => enuToLngLat(point, origin)),
|
|
1493
|
+
polygon: region.polygon.map((point) => enuToLngLat(point, origin))
|
|
1494
|
+
})),
|
|
1495
|
+
flightRoutes,
|
|
1496
|
+
totalFlightLength: meters(totalFlightLength),
|
|
1497
|
+
photoSpacing: meters(inputs.photoSpacing),
|
|
1498
|
+
estimatedPhotoCount,
|
|
1499
|
+
estimatedDuration: totalFlightLength / Number(folder.autoFlightSpeed)
|
|
1500
|
+
};
|
|
1083
1501
|
}
|
|
1084
1502
|
function heightModeToExecuteMode$1(folder) {
|
|
1085
1503
|
switch (folder.waylineCoordinateSysParam.heightMode) {
|
|
@@ -1089,6 +1507,274 @@ function heightModeToExecuteMode$1(folder) {
|
|
|
1089
1507
|
case "realTimeFollowSurface": return "realTimeFollowSurface";
|
|
1090
1508
|
}
|
|
1091
1509
|
}
|
|
1510
|
+
function turnDamping(points, index) {
|
|
1511
|
+
const previous = haversineMeters(points[index - 1], points[index]);
|
|
1512
|
+
const next = haversineMeters(points[index], points[index + 1]);
|
|
1513
|
+
return meters(Math.min(TURN_DAMPING_M, previous * .45, next * .45));
|
|
1514
|
+
}
|
|
1515
|
+
function shootingGroups(folder, startIndex, endIndex, firstGroupId, inputs) {
|
|
1516
|
+
const lenses = folder.payloadParam?.imageFormat ?? ["visable"];
|
|
1517
|
+
if (folder.Placemark.shootType === "time") return [{
|
|
1518
|
+
actionGroupId: firstGroupId,
|
|
1519
|
+
actionGroupStartIndex: startIndex,
|
|
1520
|
+
actionGroupEndIndex: endIndex,
|
|
1521
|
+
actionGroupMode: "sequence",
|
|
1522
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
|
|
1523
|
+
action: [{
|
|
1524
|
+
actionId: 0,
|
|
1525
|
+
actionActuatorFunc: "startTimeLapse",
|
|
1526
|
+
actionActuatorFuncParam: {
|
|
1527
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
1528
|
+
useGlobalPayloadLensIndex: false,
|
|
1529
|
+
payloadLensIndex: lenses,
|
|
1530
|
+
minShootInterval: inputs.shootInterval
|
|
1531
|
+
}
|
|
1532
|
+
}]
|
|
1533
|
+
}, {
|
|
1534
|
+
actionGroupId: firstGroupId + 1,
|
|
1535
|
+
actionGroupStartIndex: endIndex,
|
|
1536
|
+
actionGroupEndIndex: endIndex,
|
|
1537
|
+
actionGroupMode: "sequence",
|
|
1538
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
1539
|
+
action: [{
|
|
1540
|
+
actionId: 0,
|
|
1541
|
+
actionActuatorFunc: "stopTimeLapse",
|
|
1542
|
+
actionActuatorFuncParam: {
|
|
1543
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
1544
|
+
payloadLensIndex: lenses
|
|
1545
|
+
}
|
|
1546
|
+
}]
|
|
1547
|
+
}];
|
|
1548
|
+
return [{
|
|
1549
|
+
actionGroupId: firstGroupId,
|
|
1550
|
+
actionGroupStartIndex: startIndex,
|
|
1551
|
+
actionGroupEndIndex: endIndex,
|
|
1552
|
+
actionGroupMode: "sequence",
|
|
1553
|
+
actionTrigger: {
|
|
1554
|
+
actionTriggerType: "multipleDistance",
|
|
1555
|
+
actionTriggerParam: inputs.photoSpacing
|
|
1556
|
+
},
|
|
1557
|
+
action: [{
|
|
1558
|
+
actionId: 0,
|
|
1559
|
+
actionActuatorFunc: "takePhoto",
|
|
1560
|
+
actionActuatorFuncParam: {
|
|
1561
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
1562
|
+
useGlobalPayloadLensIndex: false,
|
|
1563
|
+
payloadLensIndex: lenses
|
|
1564
|
+
}
|
|
1565
|
+
}]
|
|
1566
|
+
}];
|
|
1567
|
+
}
|
|
1568
|
+
function nadirGimbalRotateAction(folder, actionId) {
|
|
1569
|
+
return {
|
|
1570
|
+
actionId,
|
|
1571
|
+
actionActuatorFunc: "gimbalRotate",
|
|
1572
|
+
actionActuatorFuncParam: {
|
|
1573
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
1574
|
+
gimbalHeadingYawBase: "aircraft",
|
|
1575
|
+
gimbalRotateMode: "absoluteAngle",
|
|
1576
|
+
gimbalPitchRotateEnable: true,
|
|
1577
|
+
gimbalPitchRotateAngle: degrees(-90),
|
|
1578
|
+
gimbalRollRotateEnable: false,
|
|
1579
|
+
gimbalRollRotateAngle: degrees(0),
|
|
1580
|
+
gimbalYawRotateEnable: false,
|
|
1581
|
+
gimbalYawRotateAngle: degrees(0),
|
|
1582
|
+
gimbalRotateTimeEnable: false,
|
|
1583
|
+
gimbalRotateTime: 10
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
function continuousMappingGroups(folder, endIndex, inputs) {
|
|
1588
|
+
const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
|
|
1589
|
+
const lenses = folder.payloadParam?.imageFormat ?? ["visable"];
|
|
1590
|
+
const startAction = folder.Placemark.shootType === "time" ? {
|
|
1591
|
+
actionId: 2,
|
|
1592
|
+
actionActuatorFunc: "startTimeLapse",
|
|
1593
|
+
actionActuatorFuncParam: {
|
|
1594
|
+
payloadPositionIndex,
|
|
1595
|
+
useGlobalPayloadLensIndex: false,
|
|
1596
|
+
payloadLensIndex: lenses,
|
|
1597
|
+
minShootInterval: inputs.shootInterval
|
|
1598
|
+
}
|
|
1599
|
+
} : {
|
|
1600
|
+
actionId: 2,
|
|
1601
|
+
actionActuatorFunc: "takePhoto",
|
|
1602
|
+
actionActuatorFuncParam: {
|
|
1603
|
+
payloadPositionIndex,
|
|
1604
|
+
useGlobalPayloadLensIndex: false,
|
|
1605
|
+
payloadLensIndex: lenses
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
return [
|
|
1609
|
+
{
|
|
1610
|
+
actionGroupId: 0,
|
|
1611
|
+
actionGroupStartIndex: 0,
|
|
1612
|
+
actionGroupEndIndex: endIndex,
|
|
1613
|
+
actionGroupMode: "sequence",
|
|
1614
|
+
actionTrigger: { actionTriggerType: "betweenAdjacentPoints" },
|
|
1615
|
+
action: [
|
|
1616
|
+
{
|
|
1617
|
+
actionId: 0,
|
|
1618
|
+
actionActuatorFunc: "gimbalAngleLock",
|
|
1619
|
+
actionActuatorFuncParam: { payloadPositionIndex }
|
|
1620
|
+
},
|
|
1621
|
+
nadirGimbalRotateAction(folder, 1),
|
|
1622
|
+
startAction
|
|
1623
|
+
]
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
actionGroupId: 1,
|
|
1627
|
+
actionGroupStartIndex: 0,
|
|
1628
|
+
actionGroupEndIndex: endIndex,
|
|
1629
|
+
actionGroupMode: "sequence",
|
|
1630
|
+
actionTrigger: {
|
|
1631
|
+
actionTriggerType: "multipleTiming",
|
|
1632
|
+
actionTriggerParam: 2
|
|
1633
|
+
},
|
|
1634
|
+
action: [nadirGimbalRotateAction(folder, 0)]
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
actionGroupId: 2,
|
|
1638
|
+
actionGroupStartIndex: endIndex,
|
|
1639
|
+
actionGroupEndIndex: endIndex,
|
|
1640
|
+
actionGroupMode: "sequence",
|
|
1641
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
1642
|
+
action: [...folder.Placemark.shootType === "time" ? [{
|
|
1643
|
+
actionId: 0,
|
|
1644
|
+
actionActuatorFunc: "stopTimeLapse",
|
|
1645
|
+
actionActuatorFuncParam: {
|
|
1646
|
+
payloadPositionIndex,
|
|
1647
|
+
payloadLensIndex: lenses
|
|
1648
|
+
}
|
|
1649
|
+
}] : [], {
|
|
1650
|
+
actionId: 1,
|
|
1651
|
+
actionActuatorFunc: "gimbalAngleUnlock"
|
|
1652
|
+
}]
|
|
1653
|
+
}
|
|
1654
|
+
];
|
|
1655
|
+
}
|
|
1656
|
+
function elevationOptimizeGroups(folder, startIndex, endIndex, firstGroupId, inputs) {
|
|
1657
|
+
const gimbalGroup = {
|
|
1658
|
+
actionGroupId: firstGroupId,
|
|
1659
|
+
actionGroupStartIndex: startIndex,
|
|
1660
|
+
actionGroupEndIndex: startIndex,
|
|
1661
|
+
actionGroupMode: "sequence",
|
|
1662
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
1663
|
+
action: [{
|
|
1664
|
+
actionId: 0,
|
|
1665
|
+
actionActuatorFunc: "gimbalRotate",
|
|
1666
|
+
actionActuatorFuncParam: {
|
|
1667
|
+
payloadPositionIndex: folder.payloadParam?.payloadPositionIndex ?? 0,
|
|
1668
|
+
gimbalHeadingYawBase: "aircraft",
|
|
1669
|
+
gimbalRotateMode: "absoluteAngle",
|
|
1670
|
+
gimbalPitchRotateEnable: true,
|
|
1671
|
+
gimbalPitchRotateAngle: degrees(-45),
|
|
1672
|
+
gimbalRollRotateEnable: false,
|
|
1673
|
+
gimbalRollRotateAngle: degrees(0),
|
|
1674
|
+
gimbalYawRotateEnable: true,
|
|
1675
|
+
gimbalYawRotateAngle: degrees(0),
|
|
1676
|
+
gimbalRotateTimeEnable: false,
|
|
1677
|
+
gimbalRotateTime: 10
|
|
1678
|
+
}
|
|
1679
|
+
}, {
|
|
1680
|
+
actionId: 1,
|
|
1681
|
+
actionActuatorFunc: "hover",
|
|
1682
|
+
actionActuatorFuncParam: { hoverTime: .5 }
|
|
1683
|
+
}]
|
|
1684
|
+
};
|
|
1685
|
+
const shooting = shootingGroups(folder, startIndex, endIndex, firstGroupId + 1, inputs);
|
|
1686
|
+
const unlockGroup = {
|
|
1687
|
+
actionGroupId: firstGroupId + shooting.length + 1,
|
|
1688
|
+
actionGroupStartIndex: endIndex,
|
|
1689
|
+
actionGroupEndIndex: endIndex,
|
|
1690
|
+
actionGroupMode: "sequence",
|
|
1691
|
+
actionTrigger: { actionTriggerType: "reachPoint" },
|
|
1692
|
+
action: [{
|
|
1693
|
+
actionId: 0,
|
|
1694
|
+
actionActuatorFunc: "gimbalAngleUnlock"
|
|
1695
|
+
}]
|
|
1696
|
+
};
|
|
1697
|
+
return [
|
|
1698
|
+
gimbalGroup,
|
|
1699
|
+
...shooting,
|
|
1700
|
+
unlockGroup
|
|
1701
|
+
];
|
|
1702
|
+
}
|
|
1703
|
+
/** Compile a mappingStrip template into one executable Folder per cut region. */
|
|
1704
|
+
function planMappingStrip(template, options) {
|
|
1705
|
+
const folder = template.Folder;
|
|
1706
|
+
const derived = deriveMappingStrip(template, options);
|
|
1707
|
+
const inputs = normalizeInputs(folder, options);
|
|
1708
|
+
const waylineFolders = derived.flightRoutes.map((route, waylineId) => {
|
|
1709
|
+
const points = route.executablePath;
|
|
1710
|
+
const groupsByStart = /* @__PURE__ */ new Map();
|
|
1711
|
+
const forcedStopIndexes = /* @__PURE__ */ new Set();
|
|
1712
|
+
if (!folder.Placemark.elevationOptimizeEnable) for (const group of continuousMappingGroups(folder, points.length - 1, inputs)) {
|
|
1713
|
+
const index = group.actionGroupStartIndex;
|
|
1714
|
+
groupsByStart.set(index, [...groupsByStart.get(index) ?? [], group]);
|
|
1715
|
+
}
|
|
1716
|
+
else {
|
|
1717
|
+
let cursor = 0;
|
|
1718
|
+
let nextActionGroupId = 0;
|
|
1719
|
+
for (const segment of route.segments.filter(({ kind }) => kind !== "transit")) {
|
|
1720
|
+
const startIndex = cursor;
|
|
1721
|
+
const endIndex = startIndex + segment.points.length - 1;
|
|
1722
|
+
if (segment.photoEnabled && endIndex > startIndex) {
|
|
1723
|
+
const shootingStartIndex = segment.kind === "elevationOptimize" ? startIndex + 1 : startIndex;
|
|
1724
|
+
const groups = segment.kind === "elevationOptimize" ? elevationOptimizeGroups(folder, shootingStartIndex, endIndex, nextActionGroupId, inputs) : shootingGroups(folder, shootingStartIndex, endIndex, nextActionGroupId, inputs);
|
|
1725
|
+
nextActionGroupId += groups.length;
|
|
1726
|
+
for (const group of groups) {
|
|
1727
|
+
const index = group.actionGroupStartIndex;
|
|
1728
|
+
groupsByStart.set(index, [...groupsByStart.get(index) ?? [], group]);
|
|
1729
|
+
}
|
|
1730
|
+
if (segment.kind === "elevationOptimize") {
|
|
1731
|
+
forcedStopIndexes.add(startIndex);
|
|
1732
|
+
forcedStopIndexes.add(shootingStartIndex);
|
|
1733
|
+
forcedStopIndexes.add(endIndex);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
cursor = endIndex;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
const placemarks = points.map((point, index) => {
|
|
1740
|
+
const isFirst = index === 0;
|
|
1741
|
+
const isLast = index === points.length - 1;
|
|
1742
|
+
const headingAngle = isLast ? 0 : bearingDeg(lngLatToEnu(point, point), lngLatToEnu(points[index + 1], point));
|
|
1743
|
+
return {
|
|
1744
|
+
Point: { coordinates: point },
|
|
1745
|
+
index,
|
|
1746
|
+
executeHeight: folder.Placemark.height,
|
|
1747
|
+
waypointSpeed: folder.autoFlightSpeed,
|
|
1748
|
+
waypointHeadingParam: {
|
|
1749
|
+
waypointHeadingMode: "followWayline",
|
|
1750
|
+
waypointHeadingAngle: degrees(headingAngle),
|
|
1751
|
+
waypointHeadingPathMode: "followBadArc"
|
|
1752
|
+
},
|
|
1753
|
+
waypointTurnParam: isFirst || isLast || forcedStopIndexes.has(index) ? {
|
|
1754
|
+
waypointTurnMode: "toPointAndStopWithDiscontinuityCurvature",
|
|
1755
|
+
waypointTurnDampingDist: meters(0)
|
|
1756
|
+
} : {
|
|
1757
|
+
waypointTurnMode: "coordinateTurn",
|
|
1758
|
+
waypointTurnDampingDist: turnDamping(points, index)
|
|
1759
|
+
},
|
|
1760
|
+
useStraightLine: true,
|
|
1761
|
+
isRisky: false,
|
|
1762
|
+
...groupsByStart.has(index) ? { actionGroup: groupsByStart.get(index) } : {}
|
|
1763
|
+
};
|
|
1764
|
+
});
|
|
1765
|
+
return {
|
|
1766
|
+
templateId: folder.templateId + waylineId,
|
|
1767
|
+
waylineId,
|
|
1768
|
+
executeHeightMode: heightModeToExecuteMode$1(folder),
|
|
1769
|
+
autoFlightSpeed: folder.autoFlightSpeed,
|
|
1770
|
+
Placemark: placemarks
|
|
1771
|
+
};
|
|
1772
|
+
});
|
|
1773
|
+
return {
|
|
1774
|
+
missionConfig: template.missionConfig,
|
|
1775
|
+
Folder: waylineFolders
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1092
1778
|
//#endregion
|
|
1093
1779
|
//#region src/plan/plan-waypoint.ts
|
|
1094
1780
|
/**
|
|
@@ -1155,7 +1841,7 @@ function plan(template, options) {
|
|
|
1155
1841
|
if (isWaypoint(template)) return planWaypoint(template);
|
|
1156
1842
|
if (isMapping2d(template)) return planMapping2d(template, options);
|
|
1157
1843
|
if (isMapping3d(template)) return planMapping3d(template, options);
|
|
1158
|
-
if (isMappingStrip(template)) return planMappingStrip(template);
|
|
1844
|
+
if (isMappingStrip(template)) return planMappingStrip(template, options);
|
|
1159
1845
|
throw new PlannerError(`Unknown templateType: ${template.Folder.templateType}`);
|
|
1160
1846
|
}
|
|
1161
1847
|
//#endregion
|
|
@@ -1237,13 +1923,46 @@ function validateMapping3dFolder(t, path) {
|
|
|
1237
1923
|
return validatePolygonRing(t.Folder.Placemark.Polygon.outerBoundaryIs.LinearRing.coordinates, `${path}.Placemark.Polygon`);
|
|
1238
1924
|
}
|
|
1239
1925
|
function validateMappingStripFolder(t, path) {
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1926
|
+
const placemark = t.Folder.Placemark;
|
|
1927
|
+
const tuples = placemark.LineString.coordinates.trim().split(/\s+/).filter(Boolean);
|
|
1928
|
+
const distinct = new Set(tuples.map((tuple) => {
|
|
1929
|
+
const [lng, lat] = tuple.split(",");
|
|
1930
|
+
return `${lng},${lat}`;
|
|
1931
|
+
}));
|
|
1932
|
+
const issues = [];
|
|
1933
|
+
if (tuples.length < 2 || distinct.size < 2) issues.push({
|
|
1242
1934
|
path: `${path}.Placemark.LineString`,
|
|
1243
|
-
message:
|
|
1935
|
+
message: "LineString must contain at least 2 distinct vertices",
|
|
1244
1936
|
severity: "error"
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1937
|
+
});
|
|
1938
|
+
if (!Number.isFinite(placemark.leftExtend) || placemark.leftExtend < 0) issues.push({
|
|
1939
|
+
path: `${path}.Placemark.leftExtend`,
|
|
1940
|
+
message: "leftExtend must be finite and non-negative",
|
|
1941
|
+
severity: "error"
|
|
1942
|
+
});
|
|
1943
|
+
if (!Number.isFinite(placemark.rightExtend) || placemark.rightExtend < 0) issues.push({
|
|
1944
|
+
path: `${path}.Placemark.rightExtend`,
|
|
1945
|
+
message: "rightExtend must be finite and non-negative",
|
|
1946
|
+
severity: "error"
|
|
1947
|
+
});
|
|
1948
|
+
if (!Number.isFinite(placemark.cuttingDistance) || placemark.cuttingDistance <= 0) issues.push({
|
|
1949
|
+
path: `${path}.Placemark.cuttingDistance`,
|
|
1950
|
+
message: "cuttingDistance must be finite and greater than zero",
|
|
1951
|
+
severity: "error"
|
|
1952
|
+
});
|
|
1953
|
+
if (placemark.stripMergedRegions) {
|
|
1954
|
+
const ranges = placemark.stripMergedRegions.map((range) => ({
|
|
1955
|
+
start: range[0],
|
|
1956
|
+
end: range[1],
|
|
1957
|
+
length: range.length
|
|
1958
|
+
})).sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
|
|
1959
|
+
if (ranges.some(({ start, end, length }, index) => length !== 2 || !Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start || index > 0 && start <= ranges[index - 1].end)) issues.push({
|
|
1960
|
+
path: `${path}.Placemark.stripMergedRegions`,
|
|
1961
|
+
message: "stripMergedRegions must contain non-overlapping one-based [start,end] ranges",
|
|
1962
|
+
severity: "error"
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
return issues;
|
|
1247
1966
|
}
|
|
1248
1967
|
function validatePolygonRing(coords, path) {
|
|
1249
1968
|
const tuples = coords.trim().split(/\s+/);
|
|
@@ -1310,4 +2029,4 @@ function validate(doc) {
|
|
|
1310
2029
|
return issues;
|
|
1311
2030
|
}
|
|
1312
2031
|
//#endregion
|
|
1313
|
-
export { DroneEnum, KmzError, PayloadEnum, PlannerError, degrees, haversineMeters, meters, metersPerSecond, plan, validate };
|
|
2032
|
+
export { DroneEnum, KmzError, PayloadEnum, PlannerError, degrees, deriveMappingStrip, haversineMeters, meters, metersPerSecond, plan, validate };
|