@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.d.mts +150 -132
- package/dist/index.mjs +349 -116
- package/package.json +2 -3
- package/src/plan/geometry.test.ts +50 -1
- package/src/plan/geometry.ts +6 -2
- package/src/plan/plan-mapping-strip-corners.test.ts +349 -0
- package/src/plan/plan-mapping-strip.test.ts +134 -3
- package/src/plan/plan-mapping-strip.ts +366 -159
- package/src/plan/plan-target-detection.test.ts +1 -1
- package/src/plan/plan.test.ts +1 -1
- package/src/plan/strip-route-order.test.ts +241 -0
- package/src/plan/strip-route-order.ts +192 -0
- package/vite.config.ts +7 -0
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,
|
|
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
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
const
|
|
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
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
|
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
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
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
|
|
1364
|
-
const
|
|
1365
|
-
const
|
|
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
|
-
|
|
1440
|
-
|
|
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
|
|
1446
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
1731
|
+
totalFlightLength: meters(travel.distance),
|
|
1497
1732
|
photoSpacing: meters(inputs.photoSpacing),
|
|
1498
1733
|
estimatedPhotoCount,
|
|
1499
|
-
estimatedDuration:
|
|
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
|
|
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.
|
|
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": "
|
|
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"
|