@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.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 alpha = Math.atan2(Math.cos(t), Math.sin(t));
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 scanPts = [];
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
- if (leftToRight) scanPts.push([a, y], [b, y]);
362
- else scanPts.push([b, y], [a, y]);
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 scanPts.map((p) => rotateEnu(p, alpha));
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
- * Compile a `mappingStrip` template into a single wayline that follows the
1027
- * supplied LineString. Each LineString vertex becomes one waypoint.
1028
- *
1029
- * When `stripUseTemplateAltitude` is true the altitude in each LineString
1030
- * triple is used as the executeHeight; otherwise the folder's `height` is used.
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
- Point: { coordinates: {
1048
- lng: c.lng,
1049
- lat: c.lat
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
- const waylineFolder = {
1063
- templateId: folder.templateId,
1064
- waylineId: 0,
1065
- executeHeightMode: heightModeToExecuteMode$1(folder),
1066
- autoFlightSpeed: folder.autoFlightSpeed,
1067
- Placemark: placemarks
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
- missionConfig: template.missionConfig,
1071
- Folder: [waylineFolder]
1126
+ lines,
1127
+ segments,
1128
+ start: executable[0],
1129
+ end: executable.at(-1)
1072
1130
  };
1073
1131
  }
1074
- function parseTriples(s) {
1075
- return s.trim().split(/\s+/).map((tuple) => {
1076
- const parts = tuple.split(",").map(Number);
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
- lng: parts[0],
1079
- lat: parts[1],
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 tuples = t.Folder.Placemark.LineString.coordinates.trim().split(/\s+/);
1241
- if (tuples.length < 2) return [{
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: `LineString must have at least 2 vertices, got ${tuples.length}`,
1935
+ message: "LineString must contain at least 2 distinct vertices",
1244
1936
  severity: "error"
1245
- }];
1246
- return [];
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 };