@lumikmz/kmz 0.11.0 → 0.11.1

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