@lumikmz/kmz 0.11.0 → 0.11.2

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
@@ -366,7 +366,7 @@ function stabilizeTurnCorridors(segments) {
366
366
  * (entry climb is a missionConfig behaviour, not a grid concern).
367
367
  */
368
368
  function surveyGrid(ring, opts) {
369
- const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
369
+ const { directionDeg, marginM, lineSpacing, lengthwiseShiftM = 0 } = opts;
370
370
  const t = directionDeg * DEG2RAD$1;
371
371
  const rawAlpha = Math.atan2(Math.cos(t), Math.sin(t));
372
372
  const alpha = Math.abs(rawAlpha) < ANGLE_EPS_RAD ? 0 : rawAlpha;
@@ -377,6 +377,7 @@ function surveyGrid(ring, opts) {
377
377
  if (y < minY) minY = y;
378
378
  if (y > maxY) maxY = y;
379
379
  }
380
+ const phaseOffsetM = opts.phaseOffsetM ?? (maxY - minY <= lineSpacing ? (maxY - minY) / 2 : 0);
380
381
  const scanSegments = [];
381
382
  let leftToRight = true;
382
383
  for (let y = minY + phaseOffsetM; y <= maxY + lineSpacing * .5; y += lineSpacing) {
@@ -944,13 +945,14 @@ function planMapping3d(template, options) {
944
945
  missionConfig: template.missionConfig,
945
946
  Folder: PASSES.map((pass, i) => {
946
947
  const passSpacing = pass.pitch === "inclined" ? obliqueSpacing : lineSpacing;
947
- return buildPass(folder, i, origin, orientPathToHome(surveyGrid(ringEnu, {
948
+ const enuWps = orientPathToHome(surveyGrid(ringEnu, {
948
949
  directionDeg: baseDirection + pass.axisOffsetDeg,
949
950
  marginM: folder.Placemark.margin,
950
951
  lineSpacing: passSpacing,
951
952
  phaseOffsetM: pass.halfPhase ? passSpacing / 2 : 0,
952
953
  lengthwiseShiftM: -pass.lookSign * standoff
953
- }), origin, template.missionConfig.takeOffRefPoint), {
954
+ }), origin, template.missionConfig.takeOffRefPoint);
955
+ return buildPass(folder, i, origin, enuWps, {
954
956
  headingMode: pass.headingMode,
955
957
  headingAngle: degrees(wrap180(baseDirection + pass.headingOffsetDeg)),
956
958
  gimbalPitch: pass.pitch === "nadir" ? degrees(-90) : inclined,
@@ -1053,9 +1055,113 @@ function heightModeToExecuteMode$2(mode) {
1053
1055
  }
1054
1056
  }
1055
1057
  //#endregion
1058
+ //#region src/plan/strip-route-order.ts
1059
+ const HEADING_COST_M = 10;
1060
+ const JUNCTION_DETOUR_WEIGHT = .5;
1061
+ const NEAR_EQUAL_TIME_S = 2;
1062
+ const TIME_EPS_S = 1e-8;
1063
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
1064
+ const headingChange = (a, b) => 1 - Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1]));
1065
+ const DEFAULT_TIMING = {
1066
+ flightSpeed: 1,
1067
+ transitSpeed: 1,
1068
+ finishAction: "noAction"
1069
+ };
1070
+ function validateTiming(timing) {
1071
+ if (![timing.flightSpeed, timing.transitSpeed].every((speed) => Number.isFinite(speed) && speed > 0)) throw new PlannerError("mappingStrip flight and transit speeds must be finite and greater than zero");
1072
+ }
1073
+ function finishDistance(end, first, home, finish) {
1074
+ const target = finish === "goHome" ? home : finish === "gotoFirstWaypoint" ? first : void 0;
1075
+ return target ? distance(end, target) : 0;
1076
+ }
1077
+ /** Horizontal-flight estimate; vertical motion, wind and acceleration are not modeled. */
1078
+ function measureStripMission(routes, home, timing) {
1079
+ validateTiming(timing);
1080
+ if (routes.length === 0) return {
1081
+ distance: 0,
1082
+ duration: 0
1083
+ };
1084
+ const first = routes[0].start;
1085
+ const workLength = routes.reduce((sum, route) => sum + route.length, 0);
1086
+ let transitLength = home ? distance(home, first) : 0;
1087
+ for (let index = 1; index < routes.length; index++) transitLength += distance(routes[index - 1].end, routes[index].start);
1088
+ transitLength += finishDistance(routes.at(-1).end, first, home, timing.finishAction);
1089
+ return {
1090
+ distance: workLength + transitLength,
1091
+ duration: workLength / timing.flightSpeed + transitLength / timing.transitSpeed
1092
+ };
1093
+ }
1094
+ function transitionPreference(previous, next, junction) {
1095
+ const length = distance(previous.end, next.start);
1096
+ const direction = length > .05 ? [(next.start[0] - previous.end[0]) / length, (next.start[1] - previous.end[1]) / length] : void 0;
1097
+ const turn = direction ? headingChange(previous.exitDirection, direction) + headingChange(direction, next.entryDirection) : headingChange(previous.exitDirection, next.entryDirection);
1098
+ const detour = junction ? Math.max(0, distance(previous.end, junction) + distance(junction, next.start) - length) : 0;
1099
+ return HEADING_COST_M * turn + JUNCTION_DETOUR_WEIGHT * detour;
1100
+ }
1101
+ /** Optimize mission time, then favor nearer entries within a two-second total budget. */
1102
+ function selectStripRouteCandidates(groups, home, junctions = [], timing = DEFAULT_TIMING) {
1103
+ if (groups.length === 0) return [];
1104
+ validateTiming(timing);
1105
+ if (groups.some((group) => group.length === 0)) throw new PlannerError("mappingStrip region has no route candidates");
1106
+ const finals = [];
1107
+ groups[0].forEach((first, firstIndex) => {
1108
+ const ingress = home ? distance(home, first.start) : 0;
1109
+ let times = groups[0].map((candidate, index) => index === firstIndex ? candidate.length / timing.flightSpeed + ingress / timing.transitSpeed : Infinity);
1110
+ let preferences = groups[0].map((_, index) => index === firstIndex ? 0 : Infinity);
1111
+ const back = [groups[0].map(() => -1)];
1112
+ for (let index = 1; index < groups.length; index++) {
1113
+ const parents = [];
1114
+ const nextPreferences = [];
1115
+ times = groups[index].map((candidate) => {
1116
+ let bestTime = Infinity;
1117
+ let bestPreference = Infinity;
1118
+ let parent = 0;
1119
+ groups[index - 1].forEach((previous, previousIndex) => {
1120
+ const time = times[previousIndex] + candidate.length / timing.flightSpeed + distance(previous.end, candidate.start) / timing.transitSpeed;
1121
+ const preference = preferences[previousIndex] + transitionPreference(previous, candidate, junctions[index - 1]);
1122
+ if (time < bestTime - TIME_EPS_S || Math.abs(time - bestTime) <= TIME_EPS_S && preference < bestPreference) {
1123
+ bestTime = time;
1124
+ bestPreference = preference;
1125
+ parent = previousIndex;
1126
+ }
1127
+ });
1128
+ parents.push(parent);
1129
+ nextPreferences.push(bestPreference);
1130
+ return bestTime;
1131
+ });
1132
+ preferences = nextPreferences;
1133
+ back.push(parents);
1134
+ }
1135
+ groups.at(-1).forEach((last, lastIndex) => {
1136
+ if (!Number.isFinite(times[lastIndex])) return;
1137
+ finals.push({
1138
+ lastIndex,
1139
+ ingress,
1140
+ back,
1141
+ time: times[lastIndex] + finishDistance(last.end, first.start, home, timing.finishAction) / timing.transitSpeed,
1142
+ preference: preferences[lastIndex]
1143
+ });
1144
+ });
1145
+ });
1146
+ const fastest = Math.min(...finals.map((choice) => choice.time));
1147
+ const chosen = finals.filter((choice) => choice.time <= fastest + NEAR_EQUAL_TIME_S + TIME_EPS_S).reduce((best, candidate) => {
1148
+ if (candidate.ingress < best.ingress - .05) return candidate;
1149
+ if (Math.abs(candidate.ingress - best.ingress) > .05) return best;
1150
+ if (candidate.preference < best.preference - TIME_EPS_S) return candidate;
1151
+ if (Math.abs(candidate.preference - best.preference) <= TIME_EPS_S && candidate.time < best.time) return candidate;
1152
+ return best;
1153
+ });
1154
+ let selected = chosen.lastIndex;
1155
+ const result = groups.map(() => 0);
1156
+ for (let index = groups.length - 1; index >= 0; index--) {
1157
+ result[index] = selected;
1158
+ selected = chosen.back[index][selected];
1159
+ }
1160
+ return result;
1161
+ }
1162
+ //#endregion
1056
1163
  //#region src/plan/plan-mapping-strip.ts
1057
1164
  const POINT_EPS_M = .05;
1058
- const MITER_LIMIT = 2.5;
1059
1165
  const MAX_ROUTE_COUNT = 1e3;
1060
1166
  const TURN_DAMPING_M = 10;
1061
1167
  function parseTriples(s) {
@@ -1122,47 +1228,17 @@ function localRouteCandidate(lines, centerLine, elevationOptimizeEnable) {
1122
1228
  });
1123
1229
  }
1124
1230
  const executable = joinSegmentPoints(segments);
1231
+ const distinct = cleanLine(executable);
1125
1232
  return {
1126
1233
  lines,
1127
1234
  segments,
1128
1235
  start: executable[0],
1129
- end: executable.at(-1)
1236
+ end: executable.at(-1),
1237
+ entryDirection: segmentUnit(distinct[0], distinct[1]),
1238
+ exitDirection: segmentUnit(distinct.at(-2), distinct.at(-1)),
1239
+ length: lineLength(executable)
1130
1240
  };
1131
1241
  }
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
1242
  function joinSegmentPoints(segments) {
1167
1243
  const result = [];
1168
1244
  for (const segment of segments) if (result.length === 0) result.push(...segment.points);
@@ -1271,35 +1347,41 @@ function segmentUnit(a, b) {
1271
1347
  function leftNormal(direction) {
1272
1348
  return [-direction[1], direction[0]];
1273
1349
  }
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]);
1350
+ /** Simplify geometry only; source control points still determine distance cuts. */
1351
+ function simplifyStraightRuns(points) {
1352
+ const result = [points[0]];
1280
1353
  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);
1354
+ const incoming = segmentUnit(result.at(-1), points[index]);
1355
+ const outgoing = segmentUnit(points[index], points[index + 1]);
1356
+ if (Math.abs(cross(incoming, outgoing)) < 1e-8 && incoming[0] * outgoing[0] + incoming[1] * outgoing[1] > 0) continue;
1357
+ result.push(points[index]);
1296
1358
  }
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;
1359
+ result.push(points.at(-1));
1360
+ return result;
1361
+ }
1362
+ /**
1363
+ * FlightHub square joins extend both outer offset edges to a line perpendicular
1364
+ * to their bisector, one offset distance from the source vertex. Inner joins
1365
+ * keep their true intersection: beveling a long inner miter creates a bow tie.
1366
+ */
1367
+ function offsetJoint(point, incoming, outgoing, distance) {
1368
+ const previousNormal = leftNormal(incoming);
1369
+ const nextNormal = leftNormal(outgoing);
1370
+ const previousOffset = [point[0] + previousNormal[0] * distance, point[1] + previousNormal[1] * distance];
1371
+ const nextOffset = [point[0] + nextNormal[0] * distance, point[1] + nextNormal[1] * distance];
1372
+ const denominator = cross(incoming, outgoing);
1373
+ if (Math.abs(denominator) < 1e-9) return [previousOffset];
1374
+ if (denominator * distance < 0) {
1375
+ const turn = Math.atan2(denominator, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
1376
+ const extension = Math.abs(distance) * Math.tan(Math.abs(turn) / 4);
1377
+ return [[previousOffset[0] + incoming[0] * extension, previousOffset[1] + incoming[1] * extension], [nextOffset[0] - outgoing[0] * extension, nextOffset[1] - outgoing[1] * extension]];
1378
+ }
1379
+ const t = cross([nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]], outgoing) / denominator;
1380
+ return [[previousOffset[0] + incoming[0] * t, previousOffset[1] + incoming[1] * t]];
1301
1381
  }
1302
- function corridorSide(points, distance, beforePoint, afterPoint) {
1382
+ function offsetSide(points, distance, beforePoint, afterPoint) {
1383
+ if (distance === 0) return points.map((point) => [point[0], point[1]]);
1384
+ points = simplifyStraightRuns(points);
1303
1385
  const out = [];
1304
1386
  for (let index = 0; index < points.length; index++) {
1305
1387
  const point = points[index];
@@ -1316,56 +1398,119 @@ function corridorSide(points, distance, beforePoint, afterPoint) {
1316
1398
  continue;
1317
1399
  }
1318
1400
  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;
1401
+ const joint = offsetJoint(point, segmentUnit(previous, point), segmentUnit(point, next), distance);
1402
+ if (joint.length === 2 && (index === 0 || index === points.length - 1)) {
1403
+ const midpoint = [(joint[0][0] + joint[1][0]) / 2, (joint[0][1] + joint[1][1]) / 2];
1404
+ out.push(...index === 0 ? [midpoint, joint[1]] : [joint[0], midpoint]);
1405
+ } else out.push(...joint);
1406
+ }
1407
+ return cleanLine(out);
1408
+ }
1409
+ function validateCorridorJoins(points, left, right) {
1410
+ points = simplifyStraightRuns(points);
1411
+ const directions = points.slice(1).map((point, index) => segmentUnit(points[index], point));
1412
+ const leftCuts = points.map(() => 0);
1413
+ const rightCuts = points.map(() => 0);
1414
+ for (let index = 1; index < points.length - 1; index++) {
1415
+ const incoming = directions[index - 1];
1416
+ const outgoing = directions[index];
1417
+ const dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1];
1418
+ const turn = Math.atan2(cross(incoming, outgoing), dot);
1419
+ if (Math.PI - Math.abs(turn) < 1e-6) throw new PlannerError("mappingStrip center line reverses direction at a corner");
1420
+ const tangent = Math.tan(Math.abs(turn) / 2);
1421
+ if (turn > 0) leftCuts[index] = left * tangent;
1422
+ else rightCuts[index] = right * tangent;
1423
+ }
1424
+ for (let index = 0; index < directions.length; index++) {
1425
+ const length = Math.hypot(points[index + 1][0] - points[index][0], points[index + 1][1] - points[index][1]);
1426
+ if (Math.max(leftCuts[index] + leftCuts[index + 1], rightCuts[index] + rightCuts[index + 1]) >= length - POINT_EPS_M) throw new PlannerError("mappingStrip corner offsets exceed the available center-line segment");
1427
+ }
1428
+ }
1429
+ function corridorPolygon(points, left, right, beforePoint, afterPoint) {
1430
+ return [...offsetSide(points, left, beforePoint, afterPoint), ...offsetSide(points, -right, beforePoint, afterPoint).reverse()];
1431
+ }
1432
+ /**
1433
+ * Bisector normal of the joint shared by two regions at a cut vertex. The seam is
1434
+ * the line through the vertex perpendicular to this normal, i.e. the angle
1435
+ * bisector: the incoming region keeps `dot <= 0`, the outgoing region `dot >= 0`.
1436
+ */
1437
+ function jointSeamNormal(incoming, outgoing) {
1438
+ const normal = [incoming[0] + outgoing[0], incoming[1] + outgoing[1]];
1439
+ const length = Math.hypot(normal[0], normal[1]);
1440
+ return length < 1e-9 ? void 0 : [normal[0] / length, normal[1] / length];
1441
+ }
1442
+ /** Keep the half-plane `dot(normal, point - origin) <= 0` (Sutherland–Hodgman). */
1443
+ function clipHalfPlane(ring, origin, normal) {
1444
+ const signedDistance = (point) => {
1445
+ const distance = normal[0] * (point[0] - origin[0]) + normal[1] * (point[1] - origin[1]);
1446
+ return Math.abs(distance) < 1e-8 ? 0 : distance;
1447
+ };
1448
+ const out = [];
1449
+ const push = (point) => {
1450
+ const last = out.at(-1);
1451
+ if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) < POINT_EPS_M) return;
1452
+ out.push(point);
1453
+ };
1454
+ for (let index = 0; index < ring.length; index++) {
1455
+ const a = ring[index];
1456
+ const b = ring[(index + 1) % ring.length];
1457
+ const da = signedDistance(a);
1458
+ const db = signedDistance(b);
1459
+ if (da <= 0) push([a[0], a[1]]);
1460
+ if (da * db < 0) {
1461
+ const t = da / (da - db);
1462
+ push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
1333
1463
  }
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
1464
  }
1465
+ while (out.length > 1 && Math.hypot(out[0][0] - out.at(-1)[0], out[0][1] - out.at(-1)[1]) < POINT_EPS_M) out.pop();
1339
1466
  return out;
1340
1467
  }
1341
- function corridorPolygon(points, left, right, beforePoint, afterPoint) {
1342
- return [...corridorSide(points, left, beforePoint, afterPoint), ...corridorSide(points, -right, beforePoint, afterPoint).reverse()];
1468
+ /**
1469
+ * Adjacent regions share a cut vertex and each included the full corridor joint
1470
+ * there, so their rings used to overlap at the corner. Cut every shared end
1471
+ * along the bisector seam instead: both rings then tile the corridor edge to
1472
+ * edge while their union (the outer contour) is unchanged. A shorter join is not
1473
+ * enough here — when the path folds back, the two corridor bands cross and the
1474
+ * overlap extends far beyond the corner patch, which only a half-plane cut
1475
+ * removes.
1476
+ */
1477
+ function trimJointOverlap(polygon, centerLine, beforePoint, afterPoint) {
1478
+ if (simplifyStraightRuns(centerLine).length > 2) return [...polygon];
1479
+ let trimmed = [...polygon];
1480
+ const first = centerLine[0];
1481
+ const last = centerLine.at(-1);
1482
+ if (beforePoint) {
1483
+ const normal = jointSeamNormal(segmentUnit(beforePoint, first), segmentUnit(first, centerLine[1]));
1484
+ if (normal) trimmed = clipHalfPlane(trimmed, first, [-normal[0], -normal[1]]);
1485
+ }
1486
+ if (afterPoint) {
1487
+ const normal = jointSeamNormal(segmentUnit(centerLine.at(-2), last), segmentUnit(last, afterPoint));
1488
+ if (normal) trimmed = clipHalfPlane(trimmed, last, normal);
1489
+ }
1490
+ return trimmed.length >= 3 ? trimmed : [...polygon];
1343
1491
  }
1344
1492
  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");
1493
+ centerLine = simplifyStraightRuns(centerLine);
1348
1494
  const lines = [];
1349
1495
  for (let index = 0; index < centerLine.length - 1; index++) {
1350
1496
  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
1497
  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
1498
  const sourceSegmentLength = lineLength(segment);
1361
1499
  const previousDirection = index > 0 ? segmentUnit(centerLine[index - 1], segment[0]) : void 0;
1362
1500
  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, {
1501
+ const before = index > 0 ? centerLine[index - 1] : void 0;
1502
+ const after = centerLine[index + 2];
1503
+ const scanCell = trimJointOverlap(corridorPolygon(segment, leftExtend, rightExtend, before, after), segment, before, after);
1504
+ const scanProjections = scanCell.map((point) => point[0] * direction[0] + point[1] * direction[1]);
1505
+ const scanSpan = Math.max(...scanProjections) - Math.min(...scanProjections);
1506
+ const lineCount = Math.max(1, Math.ceil(sourceSegmentLength / lineSpacing), hasSharpStart ? Math.ceil(scanSpan / lineSpacing) + 1 : Math.round(scanSpan / lineSpacing));
1507
+ if (lines.length + lineCount > MAX_ROUTE_COUNT) throw new PlannerError("mappingStrip route count exceeds limit");
1508
+ const scanSpacing = lineCount > 1 ? Math.min(lineSpacing, Math.max(POINT_EPS_M, scanSpan - POINT_EPS_M) / (lineCount - 1)) : lineSpacing;
1509
+ const phaseOffset = Math.max(0, (scanSpan - (lineCount - 1) * scanSpacing) / 2);
1510
+ const grid = surveyGrid(scanCell, {
1366
1511
  directionDeg: (bearingDeg(segment[0], segment[1]) + 90) % 360,
1367
1512
  marginM: margin,
1368
- lineSpacing,
1513
+ lineSpacing: scanSpacing,
1369
1514
  phaseOffsetM: phaseOffset
1370
1515
  });
1371
1516
  const flightDirection = [direction[1], -direction[0]];
@@ -1378,6 +1523,75 @@ function perpendicularRouteLines(centerLine, leftExtend, rightExtend, margin, li
1378
1523
  }
1379
1524
  return lines;
1380
1525
  }
1526
+ function pointInRing(point, polygon) {
1527
+ let inside = false;
1528
+ for (let index = 0; index < polygon.length; index++) {
1529
+ const a = polygon[index];
1530
+ const b = polygon[(index + 1) % polygon.length];
1531
+ const edge = [b[0] - a[0], b[1] - a[1]];
1532
+ const delta = [point[0] - a[0], point[1] - a[1]];
1533
+ const length = Math.hypot(...edge);
1534
+ const projection = edge[0] * delta[0] + edge[1] * delta[1];
1535
+ if (length > 0 && Math.abs(cross(edge, delta)) < length * 1e-7 && projection >= -1e-7 && projection <= length * length + 1e-7) return true;
1536
+ if (a[1] > point[1] !== b[1] > point[1] && point[0] < a[0] + (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1])) inside = !inside;
1537
+ }
1538
+ return inside;
1539
+ }
1540
+ /** Clip an open work polyline to a possibly concave region, retaining each span. */
1541
+ function clipWorkLine(line, polygon) {
1542
+ if (polygonArea(polygon) < 1e-8) return [[...line]];
1543
+ const result = [];
1544
+ let current;
1545
+ for (let index = 1; index < line.length; index++) {
1546
+ const a = line[index - 1];
1547
+ const b = line[index];
1548
+ const direction = [b[0] - a[0], b[1] - a[1]];
1549
+ const length = Math.hypot(...direction);
1550
+ if (length < POINT_EPS_M) continue;
1551
+ const cuts = [0, 1];
1552
+ for (let edgeIndex = 0; edgeIndex < polygon.length; edgeIndex++) {
1553
+ const c = polygon[edgeIndex];
1554
+ const d = polygon[(edgeIndex + 1) % polygon.length];
1555
+ const edge = [d[0] - c[0], d[1] - c[1]];
1556
+ const denominator = cross(direction, edge);
1557
+ if (Math.abs(denominator) < 1e-9) continue;
1558
+ const delta = [c[0] - a[0], c[1] - a[1]];
1559
+ const t = cross(delta, edge) / denominator;
1560
+ const u = cross(delta, direction) / denominator;
1561
+ if (t > 0 && t < 1 && u >= -1e-9 && u <= 1 + 1e-9) cuts.push(t);
1562
+ }
1563
+ cuts.sort((x, y) => x - y);
1564
+ const at = (t) => [a[0] + t * direction[0], a[1] + t * direction[1]];
1565
+ for (let cutIndex = 1; cutIndex < cuts.length; cutIndex++) {
1566
+ const start = cuts[cutIndex - 1];
1567
+ const end = cuts[cutIndex];
1568
+ if ((end - start) * length < POINT_EPS_M) continue;
1569
+ if (!pointInRing(at((start + end) / 2), polygon)) {
1570
+ current = void 0;
1571
+ continue;
1572
+ }
1573
+ const first = at(start);
1574
+ const last = at(end);
1575
+ if (current && Math.hypot(current.at(-1)[0] - first[0], current.at(-1)[1] - first[1]) < POINT_EPS_M) current.push(last);
1576
+ else {
1577
+ current = [first, last];
1578
+ result.push(current);
1579
+ }
1580
+ }
1581
+ }
1582
+ return result.map((points) => cleanLine(points));
1583
+ }
1584
+ function extendWorkLine(line, margin) {
1585
+ if (margin === 0) return line;
1586
+ const first = line[0];
1587
+ const last = line.at(-1);
1588
+ const direction = segmentUnit(first, last);
1589
+ return [
1590
+ [first[0] - direction[0] * margin, first[1] - direction[1] * margin],
1591
+ ...line.slice(1, -1),
1592
+ [last[0] + direction[0] * margin, last[1] + direction[1] * margin]
1593
+ ];
1594
+ }
1381
1595
  function polygonArea(points) {
1382
1596
  let area2 = 0;
1383
1597
  for (let index = 0; index < points.length; index++) {
@@ -1427,6 +1641,7 @@ function deriveMappingStrip(template, options) {
1427
1641
  const origin = centroid(triples);
1428
1642
  const centerLine = cleanLine(triples.map((point) => lngLatToEnu(point, origin)));
1429
1643
  if (centerLine.length < 2) throw new PlannerError("mappingStrip requires two distinct center points");
1644
+ validateCorridorJoins(centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend);
1430
1645
  const inputs = normalizeInputs(folder, options);
1431
1646
  if (!(inputs.crossTrackFootprint > 0 && Number.isFinite(inputs.crossTrackFootprint))) throw new PlannerError("mappingStrip crossTrackFootprint must be finite and greater than zero");
1432
1647
  if (!(inputs.photoSpacing > 0 && Number.isFinite(inputs.photoSpacing))) throw new PlannerError("mappingStrip photoSpacing must be finite and greater than zero");
@@ -1435,20 +1650,40 @@ function deriveMappingStrip(template, options) {
1435
1650
  ...region,
1436
1651
  beforePoint: index > 0 ? splitRegions[index - 1].centerLine.at(-2) : void 0,
1437
1652
  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
- }));
1653
+ })), folder.Placemark.stripMergedRegions).map((region) => {
1654
+ const polygon = corridorPolygon(region.centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend, region.beforePoint, region.afterPoint);
1655
+ return {
1656
+ ...region,
1657
+ polygon: trimJointOverlap(polygon, region.centerLine, region.beforePoint, region.afterPoint)
1658
+ };
1659
+ });
1442
1660
  const offsets = routeOffsets(folder, inputs);
1443
1661
  const executionRegions = options?.regionOrderReversed ? [...localRegions].reverse() : localRegions;
1444
1662
  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));
1663
+ const regionGeometry = executionRegions.map((region) => {
1664
+ const routeOffsets = [];
1665
+ const rawLines = perpendicular ? perpendicularRouteLines(region.centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend, 0, inputs.lineSpacing).flatMap((line) => clipWorkLine(line, region.polygon)).map((line) => extendWorkLine(line, folder.Placemark.margin)) : offsets.flatMap((offset) => {
1666
+ const clipped = clipWorkLine(offsetSide(region.centerLine, offset, region.beforePoint, region.afterPoint), region.polygon);
1667
+ routeOffsets.push(...clipped.map(() => offset));
1668
+ return clipped;
1669
+ });
1447
1670
  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));
1671
+ return {
1672
+ lines: rawLines,
1673
+ offsets: routeOffsets
1674
+ };
1675
+ });
1676
+ const candidateGroups = regionGeometry.map((geometry, index) => {
1677
+ return serpentineRouteVariants(geometry.lines).map((lines) => localRouteCandidate(lines, executionRegions[index].centerLine, folder.Placemark.elevationOptimizeEnable));
1449
1678
  });
1450
1679
  const takeOffRefPoint = template.missionConfig.takeOffRefPoint;
1451
- const selectedCandidateIndexes = shortestCandidateIndexes(candidateGroups, takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : void 0);
1680
+ const home = takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : void 0;
1681
+ const timing = {
1682
+ flightSpeed: Number(folder.autoFlightSpeed),
1683
+ transitSpeed: Number(template.missionConfig.globalTransitionalSpeed),
1684
+ finishAction: template.missionConfig.finishAction
1685
+ };
1686
+ const selectedCandidateIndexes = selectStripRouteCandidates(candidateGroups, home, executionRegions.slice(1).map((region) => options?.regionOrderReversed ? region.centerLine.at(-1) : region.centerLine[0]), timing);
1452
1687
  const flightRoutes = [];
1453
1688
  let previousRegionEnd;
1454
1689
  executionRegions.forEach((region, regionIndex) => {
@@ -1462,7 +1697,7 @@ function deriveMappingStrip(template, options) {
1462
1697
  const executable = joinSegmentPoints(candidate.segments);
1463
1698
  flightRoutes.push({
1464
1699
  regionId: region.id,
1465
- routeOffsets: perpendicular ? [] : offsets.map(meters),
1700
+ routeOffsets: regionGeometry[regionIndex].offsets.map(meters),
1466
1701
  routeLines: candidate.lines.map((line) => line.map((point) => enuToLngLat(point, origin))),
1467
1702
  segments: localSegments.map((segment) => ({
1468
1703
  ...segment,
@@ -1474,7 +1709,7 @@ function deriveMappingStrip(template, options) {
1474
1709
  previousRegionEnd = candidate.end;
1475
1710
  });
1476
1711
  const surveyAreaLocal = corridorPolygon(centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend);
1477
- const totalFlightLength = flightRoutes.reduce((sum, route) => sum + Number(route.length), 0);
1712
+ const travel = measureStripMission(candidateGroups.map((group, index) => group[selectedCandidateIndexes[index]]), home, timing);
1478
1713
  const estimatedPhotoCount = flightRoutes.reduce((sum, route) => sum + route.segments.filter(({ photoEnabled }) => photoEnabled).reduce((lineSum, segment) => {
1479
1714
  let length = 0;
1480
1715
  for (let index = 1; index < segment.points.length; index++) length += haversineMeters(segment.points[index - 1], segment.points[index]);
@@ -1493,10 +1728,10 @@ function deriveMappingStrip(template, options) {
1493
1728
  polygon: region.polygon.map((point) => enuToLngLat(point, origin))
1494
1729
  })),
1495
1730
  flightRoutes,
1496
- totalFlightLength: meters(totalFlightLength),
1731
+ totalFlightLength: meters(travel.distance),
1497
1732
  photoSpacing: meters(inputs.photoSpacing),
1498
1733
  estimatedPhotoCount,
1499
- estimatedDuration: totalFlightLength / Number(folder.autoFlightSpeed)
1734
+ estimatedDuration: travel.duration
1500
1735
  };
1501
1736
  }
1502
1737
  function heightModeToExecuteMode$1(folder) {
@@ -1510,7 +1745,7 @@ function heightModeToExecuteMode$1(folder) {
1510
1745
  function turnDamping$1(points, index) {
1511
1746
  const previous = haversineMeters(points[index - 1], points[index]);
1512
1747
  const next = haversineMeters(points[index], points[index + 1]);
1513
- return meters(Math.min(TURN_DAMPING_M, previous * .45, next * .45));
1748
+ return meters(Math.min(TURN_DAMPING_M, previous / 3, next / 3));
1514
1749
  }
1515
1750
  function shootingGroups(folder, startIndex, endIndex, firstGroupId, inputs) {
1516
1751
  const lenses = folder.payloadParam?.imageFormat ?? ["visable"];
@@ -2069,9 +2304,7 @@ function validateTemplate(template) {
2069
2304
  case "mappingStrip":
2070
2305
  issues.push(...validateMappingStripFolder(template, path));
2071
2306
  break;
2072
- case "targetdetection":
2073
- issues.push(...validateTargetDetectionFolder(template, path));
2074
- break;
2307
+ case "targetdetection": issues.push(...validateTargetDetectionFolder(template, path));
2075
2308
  }
2076
2309
  return issues;
2077
2310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumikmz/kmz",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "DJI KMZ/WPML domain model + plan() algorithm + validate(), spec-aligned types mirroring docs/kmz/",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,8 +15,7 @@
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "devDependencies": {
18
- "vite-plus": "latest",
19
- "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
18
+ "vite-plus": "1.3.0"
20
19
  },
21
20
  "scripts": {
22
21
  "build": "vp pack"