@lumikmz/kmz 0.10.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.d.mts +177 -130
- package/dist/index.mjs +367 -117
- package/package.json +2 -3
- package/src/plan/geometry.test.ts +1 -1
- 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 +44 -1
- package/src/plan/plan-target-detection.ts +27 -2
- 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/src/types/action.ts +26 -0
- package/src/types/drone-payload.ts +2 -0
- package/src/types/enums.ts +1 -0
- package/src/types/placemark.ts +4 -1
- package/vite.config.ts +7 -0
|
@@ -15,9 +15,13 @@ import {
|
|
|
15
15
|
type EnuPoint,
|
|
16
16
|
} from "./geometry.js";
|
|
17
17
|
import type { PlanOptions } from "./index.js";
|
|
18
|
+
import {
|
|
19
|
+
measureStripMission,
|
|
20
|
+
selectStripRouteCandidates,
|
|
21
|
+
type StripRouteCandidate,
|
|
22
|
+
} from "./strip-route-order.js";
|
|
18
23
|
|
|
19
24
|
const POINT_EPS_M = 0.05;
|
|
20
|
-
const MITER_LIMIT = 2.5;
|
|
21
25
|
const MAX_ROUTE_COUNT = 1_000;
|
|
22
26
|
const TURN_DAMPING_M = 10;
|
|
23
27
|
|
|
@@ -51,9 +55,11 @@ export interface MappingStripDerived {
|
|
|
51
55
|
surveyAreaSize: number;
|
|
52
56
|
regions: MappingStripRegion[];
|
|
53
57
|
flightRoutes: MappingStripRegionRoute[];
|
|
58
|
+
/** Horizontal mission distance, including known ingress and finish travel. */
|
|
54
59
|
totalFlightLength: Meters;
|
|
55
60
|
photoSpacing: Meters;
|
|
56
61
|
estimatedPhotoCount: number;
|
|
62
|
+
/** Estimated horizontal flying seconds at the configured work/transit speeds. */
|
|
57
63
|
estimatedDuration: number;
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -81,11 +87,9 @@ interface LocalRouteSegment {
|
|
|
81
87
|
photoEnabled: boolean;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
interface LocalRouteCandidate {
|
|
90
|
+
interface LocalRouteCandidate extends StripRouteCandidate {
|
|
85
91
|
lines: EnuPoint[][];
|
|
86
92
|
segments: LocalRouteSegment[];
|
|
87
|
-
start: EnuPoint;
|
|
88
|
-
end: EnuPoint;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
interface Triple extends LngLat {
|
|
@@ -189,61 +193,18 @@ function localRouteCandidate(
|
|
|
189
193
|
});
|
|
190
194
|
}
|
|
191
195
|
const executable = joinSegmentPoints(segments);
|
|
196
|
+
const distinct = cleanLine(executable);
|
|
192
197
|
return {
|
|
193
198
|
lines,
|
|
194
199
|
segments,
|
|
195
200
|
start: executable[0]!,
|
|
196
201
|
end: executable.at(-1)!,
|
|
202
|
+
entryDirection: segmentUnit(distinct[0]!, distinct[1]!),
|
|
203
|
+
exitDirection: segmentUnit(distinct.at(-2)!, distinct.at(-1)!),
|
|
204
|
+
length: lineLength(executable),
|
|
197
205
|
};
|
|
198
206
|
}
|
|
199
207
|
|
|
200
|
-
function shortestCandidateIndexes(
|
|
201
|
-
candidateGroups: readonly LocalRouteCandidate[][],
|
|
202
|
-
startPoint: EnuPoint | undefined,
|
|
203
|
-
): number[] {
|
|
204
|
-
if (candidateGroups.length === 0) return [];
|
|
205
|
-
let costs = candidateGroups[0]!.map((candidate) =>
|
|
206
|
-
startPoint
|
|
207
|
-
? Math.hypot(candidate.start[0] - startPoint[0], candidate.start[1] - startPoint[1])
|
|
208
|
-
: 0,
|
|
209
|
-
);
|
|
210
|
-
const previousIndexes: number[][] = [candidateGroups[0]!.map(() => -1)];
|
|
211
|
-
|
|
212
|
-
for (let regionIndex = 1; regionIndex < candidateGroups.length; regionIndex++) {
|
|
213
|
-
const previousCandidates = candidateGroups[regionIndex - 1]!;
|
|
214
|
-
const candidates = candidateGroups[regionIndex]!;
|
|
215
|
-
const nextCosts: number[] = [];
|
|
216
|
-
const nextPreviousIndexes: number[] = [];
|
|
217
|
-
for (const candidate of candidates) {
|
|
218
|
-
let bestCost = Number.POSITIVE_INFINITY;
|
|
219
|
-
let bestPreviousIndex = 0;
|
|
220
|
-
previousCandidates.forEach((previous, previousIndex) => {
|
|
221
|
-
const transition = Math.hypot(
|
|
222
|
-
candidate.start[0] - previous.end[0],
|
|
223
|
-
candidate.start[1] - previous.end[1],
|
|
224
|
-
);
|
|
225
|
-
const cost = costs[previousIndex]! + transition;
|
|
226
|
-
if (cost < bestCost) {
|
|
227
|
-
bestCost = cost;
|
|
228
|
-
bestPreviousIndex = previousIndex;
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
nextCosts.push(bestCost);
|
|
232
|
-
nextPreviousIndexes.push(bestPreviousIndex);
|
|
233
|
-
}
|
|
234
|
-
costs = nextCosts;
|
|
235
|
-
previousIndexes.push(nextPreviousIndexes);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
let selectedIndex = costs.reduce((best, cost, index) => (cost < costs[best]! ? index : best), 0);
|
|
239
|
-
const selected = Array.from({ length: candidateGroups.length }, () => 0);
|
|
240
|
-
for (let regionIndex = candidateGroups.length - 1; regionIndex >= 0; regionIndex--) {
|
|
241
|
-
selected[regionIndex] = selectedIndex;
|
|
242
|
-
selectedIndex = previousIndexes[regionIndex]![selectedIndex]!;
|
|
243
|
-
}
|
|
244
|
-
return selected;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
208
|
function joinSegmentPoints<T>(segments: readonly { points: readonly T[] }[]): T[] {
|
|
248
209
|
const result: T[] = [];
|
|
249
210
|
for (const segment of segments) {
|
|
@@ -352,7 +313,7 @@ function applyMergedRegionRanges(
|
|
|
352
313
|
|
|
353
314
|
const byStart = new Map(normalized.map((range) => [range.start, range]));
|
|
354
315
|
const merged: LocalRegion[] = [];
|
|
355
|
-
for (let index = 0; index < regions.length;
|
|
316
|
+
for (let index = 0; index < regions.length;) {
|
|
356
317
|
const range = byStart.get(index);
|
|
357
318
|
if (!range) {
|
|
358
319
|
merged.push(regions[index]!);
|
|
@@ -391,57 +352,68 @@ function leftNormal(direction: EnuPoint): EnuPoint {
|
|
|
391
352
|
return [-direction[1], direction[0]];
|
|
392
353
|
}
|
|
393
354
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
const out: EnuPoint[] = [];
|
|
398
|
-
const firstNormal = leftNormal(directions[0]!);
|
|
399
|
-
out.push([points[0]![0] + firstNormal[0] * distance, points[0]![1] + firstNormal[1] * distance]);
|
|
400
|
-
|
|
355
|
+
/** Simplify geometry only; source control points still determine distance cuts. */
|
|
356
|
+
function simplifyStraightRuns(points: readonly EnuPoint[]): EnuPoint[] {
|
|
357
|
+
const result: EnuPoint[] = [points[0]!];
|
|
401
358
|
for (let index = 1; index < points.length - 1; index++) {
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
points[index]![1] + previousNormal[1] * distance,
|
|
409
|
-
];
|
|
410
|
-
const nextOffset: EnuPoint = [
|
|
411
|
-
points[index]![0] + nextNormal[0] * distance,
|
|
412
|
-
points[index]![1] + nextNormal[1] * distance,
|
|
413
|
-
];
|
|
414
|
-
const denominator = cross(previousDirection, nextDirection);
|
|
415
|
-
if (Math.abs(denominator) < 1e-9) {
|
|
416
|
-
out.push(previousOffset);
|
|
359
|
+
const incoming = segmentUnit(result.at(-1)!, points[index]!);
|
|
360
|
+
const outgoing = segmentUnit(points[index]!, points[index + 1]!);
|
|
361
|
+
if (
|
|
362
|
+
Math.abs(cross(incoming, outgoing)) < 1e-8 &&
|
|
363
|
+
incoming[0] * outgoing[0] + incoming[1] * outgoing[1] > 0
|
|
364
|
+
)
|
|
417
365
|
continue;
|
|
418
|
-
|
|
419
|
-
const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
|
|
420
|
-
const t = cross(delta, nextDirection) / denominator;
|
|
421
|
-
const intersection: EnuPoint = [
|
|
422
|
-
previousOffset[0] + previousDirection[0] * t,
|
|
423
|
-
previousOffset[1] + previousDirection[1] * t,
|
|
424
|
-
];
|
|
425
|
-
const miterLength = Math.hypot(
|
|
426
|
-
intersection[0] - points[index]![0],
|
|
427
|
-
intersection[1] - points[index]![1],
|
|
428
|
-
);
|
|
429
|
-
if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
|
|
430
|
-
else out.push(previousOffset, nextOffset);
|
|
366
|
+
result.push(points[index]!);
|
|
431
367
|
}
|
|
368
|
+
result.push(points.at(-1)!);
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
432
371
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
372
|
+
/**
|
|
373
|
+
* FlightHub square joins extend both outer offset edges to a line perpendicular
|
|
374
|
+
* to their bisector, one offset distance from the source vertex. Inner joins
|
|
375
|
+
* keep their true intersection: beveling a long inner miter creates a bow tie.
|
|
376
|
+
*/
|
|
377
|
+
function offsetJoint(
|
|
378
|
+
point: EnuPoint,
|
|
379
|
+
incoming: EnuPoint,
|
|
380
|
+
outgoing: EnuPoint,
|
|
381
|
+
distance: number,
|
|
382
|
+
): EnuPoint[] {
|
|
383
|
+
const previousNormal = leftNormal(incoming);
|
|
384
|
+
const nextNormal = leftNormal(outgoing);
|
|
385
|
+
const previousOffset: EnuPoint = [
|
|
386
|
+
point[0] + previousNormal[0] * distance,
|
|
387
|
+
point[1] + previousNormal[1] * distance,
|
|
388
|
+
];
|
|
389
|
+
const nextOffset: EnuPoint = [
|
|
390
|
+
point[0] + nextNormal[0] * distance,
|
|
391
|
+
point[1] + nextNormal[1] * distance,
|
|
392
|
+
];
|
|
393
|
+
const denominator = cross(incoming, outgoing);
|
|
394
|
+
if (Math.abs(denominator) < 1e-9) return [previousOffset];
|
|
395
|
+
|
|
396
|
+
if (denominator * distance < 0) {
|
|
397
|
+
const turn = Math.atan2(denominator, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
|
|
398
|
+
const extension = Math.abs(distance) * Math.tan(Math.abs(turn) / 4);
|
|
399
|
+
return [
|
|
400
|
+
[previousOffset[0] + incoming[0] * extension, previousOffset[1] + incoming[1] * extension],
|
|
401
|
+
[nextOffset[0] - outgoing[0] * extension, nextOffset[1] - outgoing[1] * extension],
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
|
|
405
|
+
const t = cross(delta, outgoing) / denominator;
|
|
406
|
+
return [[previousOffset[0] + incoming[0] * t, previousOffset[1] + incoming[1] * t]];
|
|
437
407
|
}
|
|
438
408
|
|
|
439
|
-
function
|
|
409
|
+
function offsetSide(
|
|
440
410
|
points: readonly EnuPoint[],
|
|
441
411
|
distance: number,
|
|
442
412
|
beforePoint?: EnuPoint,
|
|
443
413
|
afterPoint?: EnuPoint,
|
|
444
414
|
): EnuPoint[] {
|
|
415
|
+
if (distance === 0) return points.map((point) => [point[0], point[1]]);
|
|
416
|
+
points = simplifyStraightRuns(points);
|
|
445
417
|
const out: EnuPoint[] = [];
|
|
446
418
|
for (let index = 0; index < points.length; index++) {
|
|
447
419
|
const point = points[index]!;
|
|
@@ -459,41 +431,57 @@ function corridorSide(
|
|
|
459
431
|
}
|
|
460
432
|
if (!previous || !next) continue;
|
|
461
433
|
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
434
|
+
const joint = offsetJoint(
|
|
435
|
+
point,
|
|
436
|
+
segmentUnit(previous, point),
|
|
437
|
+
segmentUnit(point, next),
|
|
438
|
+
distance,
|
|
439
|
+
);
|
|
440
|
+
if (joint.length === 2 && (index === 0 || index === points.length - 1)) {
|
|
441
|
+
// A cut vertex owns only its half of the square join. Both neighboring
|
|
442
|
+
// regions and their flight lines meet at this exact bisector midpoint.
|
|
443
|
+
const midpoint: EnuPoint = [
|
|
444
|
+
(joint[0]![0] + joint[1]![0]) / 2,
|
|
445
|
+
(joint[0]![1] + joint[1]![1]) / 2,
|
|
446
|
+
];
|
|
447
|
+
out.push(...(index === 0 ? [midpoint, joint[1]!] : [joint[0]!, midpoint]));
|
|
448
|
+
} else out.push(...joint);
|
|
449
|
+
}
|
|
450
|
+
return cleanLine(out);
|
|
451
|
+
}
|
|
479
452
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
if (
|
|
494
|
-
else
|
|
453
|
+
function validateCorridorJoins(points: readonly EnuPoint[], left: number, right: number): void {
|
|
454
|
+
points = simplifyStraightRuns(points);
|
|
455
|
+
const directions = points.slice(1).map((point, index) => segmentUnit(points[index]!, point));
|
|
456
|
+
const leftCuts = points.map(() => 0);
|
|
457
|
+
const rightCuts = points.map(() => 0);
|
|
458
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
459
|
+
const incoming = directions[index - 1]!;
|
|
460
|
+
const outgoing = directions[index]!;
|
|
461
|
+
const dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1];
|
|
462
|
+
const turn = Math.atan2(cross(incoming, outgoing), dot);
|
|
463
|
+
if (Math.PI - Math.abs(turn) < 1e-6)
|
|
464
|
+
throw new PlannerError("mappingStrip center line reverses direction at a corner");
|
|
465
|
+
const tangent = Math.tan(Math.abs(turn) / 2);
|
|
466
|
+
if (turn > 0) leftCuts[index] = left * tangent;
|
|
467
|
+
else rightCuts[index] = right * tangent;
|
|
468
|
+
}
|
|
469
|
+
for (let index = 0; index < directions.length; index++) {
|
|
470
|
+
const length = Math.hypot(
|
|
471
|
+
points[index + 1]![0] - points[index]![0],
|
|
472
|
+
points[index + 1]![1] - points[index]![1],
|
|
473
|
+
);
|
|
474
|
+
if (
|
|
475
|
+
Math.max(
|
|
476
|
+
leftCuts[index]! + leftCuts[index + 1]!,
|
|
477
|
+
rightCuts[index]! + rightCuts[index + 1]!,
|
|
478
|
+
) >=
|
|
479
|
+
length - POINT_EPS_M
|
|
480
|
+
)
|
|
481
|
+
throw new PlannerError(
|
|
482
|
+
"mappingStrip corner offsets exceed the available center-line segment",
|
|
483
|
+
);
|
|
495
484
|
}
|
|
496
|
-
return out;
|
|
497
485
|
}
|
|
498
486
|
|
|
499
487
|
function corridorPolygon(
|
|
@@ -504,11 +492,96 @@ function corridorPolygon(
|
|
|
504
492
|
afterPoint?: EnuPoint,
|
|
505
493
|
): EnuPoint[] {
|
|
506
494
|
return [
|
|
507
|
-
...
|
|
508
|
-
...
|
|
495
|
+
...offsetSide(points, left, beforePoint, afterPoint),
|
|
496
|
+
...offsetSide(points, -right, beforePoint, afterPoint).reverse(),
|
|
509
497
|
];
|
|
510
498
|
}
|
|
511
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Bisector normal of the joint shared by two regions at a cut vertex. The seam is
|
|
502
|
+
* the line through the vertex perpendicular to this normal, i.e. the angle
|
|
503
|
+
* bisector: the incoming region keeps `dot <= 0`, the outgoing region `dot >= 0`.
|
|
504
|
+
*/
|
|
505
|
+
function jointSeamNormal(incoming: EnuPoint, outgoing: EnuPoint): EnuPoint | undefined {
|
|
506
|
+
const normal: EnuPoint = [incoming[0] + outgoing[0], incoming[1] + outgoing[1]];
|
|
507
|
+
const length = Math.hypot(normal[0], normal[1]);
|
|
508
|
+
return length < 1e-9 ? undefined : [normal[0] / length, normal[1] / length];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Keep the half-plane `dot(normal, point - origin) <= 0` (Sutherland–Hodgman). */
|
|
512
|
+
function clipHalfPlane(ring: readonly EnuPoint[], origin: EnuPoint, normal: EnuPoint): EnuPoint[] {
|
|
513
|
+
const signedDistance = (point: EnuPoint) => {
|
|
514
|
+
const distance = normal[0] * (point[0] - origin[0]) + normal[1] * (point[1] - origin[1]);
|
|
515
|
+
// Roundoff at vertices already on the seam must not create extra crossings
|
|
516
|
+
// along the shared edge or remove a neighboring route's joint endpoint.
|
|
517
|
+
return Math.abs(distance) < 1e-8 ? 0 : distance;
|
|
518
|
+
};
|
|
519
|
+
const out: EnuPoint[] = [];
|
|
520
|
+
const push = (point: EnuPoint) => {
|
|
521
|
+
const last = out.at(-1);
|
|
522
|
+
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) < POINT_EPS_M) return;
|
|
523
|
+
out.push(point);
|
|
524
|
+
};
|
|
525
|
+
for (let index = 0; index < ring.length; index++) {
|
|
526
|
+
const a = ring[index]!;
|
|
527
|
+
const b = ring[(index + 1) % ring.length]!;
|
|
528
|
+
const da = signedDistance(a);
|
|
529
|
+
const db = signedDistance(b);
|
|
530
|
+
if (da <= 0) push([a[0], a[1]]);
|
|
531
|
+
if (da * db < 0) {
|
|
532
|
+
const t = da / (da - db);
|
|
533
|
+
push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
while (
|
|
537
|
+
out.length > 1 &&
|
|
538
|
+
Math.hypot(out[0]![0] - out.at(-1)![0], out[0]![1] - out.at(-1)![1]) < POINT_EPS_M
|
|
539
|
+
)
|
|
540
|
+
out.pop();
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Adjacent regions share a cut vertex and each included the full corridor joint
|
|
546
|
+
* there, so their rings used to overlap at the corner. Cut every shared end
|
|
547
|
+
* along the bisector seam instead: both rings then tile the corridor edge to
|
|
548
|
+
* edge while their union (the outer contour) is unchanged. A shorter join is not
|
|
549
|
+
* enough here — when the path folds back, the two corridor bands cross and the
|
|
550
|
+
* overlap extends far beyond the corner patch, which only a half-plane cut
|
|
551
|
+
* removes.
|
|
552
|
+
*/
|
|
553
|
+
function trimJointOverlap(
|
|
554
|
+
polygon: readonly EnuPoint[],
|
|
555
|
+
centerLine: readonly EnuPoint[],
|
|
556
|
+
beforePoint: EnuPoint | undefined,
|
|
557
|
+
afterPoint: EnuPoint | undefined,
|
|
558
|
+
): EnuPoint[] {
|
|
559
|
+
// Contextual offset sides already split each end join at its seam. A global
|
|
560
|
+
// half-plane is only safe for a straight region: on a merged/folded region it
|
|
561
|
+
// would cut off distant bends lying behind the starting seam (including the
|
|
562
|
+
// acute corner in docs/wpmz). Straight cuts still need clipping when an inner
|
|
563
|
+
// miter extends beyond a nearby synthetic distance cut.
|
|
564
|
+
if (simplifyStraightRuns(centerLine).length > 2) return [...polygon];
|
|
565
|
+
let trimmed = [...polygon];
|
|
566
|
+
const first = centerLine[0]!;
|
|
567
|
+
const last = centerLine.at(-1)!;
|
|
568
|
+
if (beforePoint) {
|
|
569
|
+
const normal = jointSeamNormal(
|
|
570
|
+
segmentUnit(beforePoint, first),
|
|
571
|
+
segmentUnit(first, centerLine[1]!),
|
|
572
|
+
);
|
|
573
|
+
if (normal) trimmed = clipHalfPlane(trimmed, first, [-normal[0], -normal[1]]);
|
|
574
|
+
}
|
|
575
|
+
if (afterPoint) {
|
|
576
|
+
const normal = jointSeamNormal(
|
|
577
|
+
segmentUnit(centerLine.at(-2)!, last),
|
|
578
|
+
segmentUnit(last, afterPoint),
|
|
579
|
+
);
|
|
580
|
+
if (normal) trimmed = clipHalfPlane(trimmed, last, normal);
|
|
581
|
+
}
|
|
582
|
+
return trimmed.length >= 3 ? trimmed : [...polygon];
|
|
583
|
+
}
|
|
584
|
+
|
|
512
585
|
function perpendicularRouteLines(
|
|
513
586
|
centerLine: readonly EnuPoint[],
|
|
514
587
|
leftExtend: number,
|
|
@@ -516,37 +589,49 @@ function perpendicularRouteLines(
|
|
|
516
589
|
margin: number,
|
|
517
590
|
lineSpacing: number,
|
|
518
591
|
): EnuPoint[][] {
|
|
519
|
-
|
|
520
|
-
const right = offsetPolyline(centerLine, -rightExtend);
|
|
521
|
-
if (left.length !== centerLine.length || right.length !== centerLine.length) {
|
|
522
|
-
throw new PlannerError("mappingStrip offset corridor cannot be segmented");
|
|
523
|
-
}
|
|
524
|
-
|
|
592
|
+
centerLine = simplifyStraightRuns(centerLine);
|
|
525
593
|
const lines: EnuPoint[][] = [];
|
|
526
594
|
for (let index = 0; index < centerLine.length - 1; index++) {
|
|
527
595
|
const segment = [centerLine[index]!, centerLine[index + 1]!] as const;
|
|
528
|
-
const cell = [left[index]!, left[index + 1]!, right[index + 1]!, right[index]!];
|
|
529
596
|
const direction = segmentUnit(segment[0], segment[1]);
|
|
530
|
-
const projections = cell.map((point) => point[0] * direction[0] + point[1] * direction[1]);
|
|
531
|
-
const longitudinalSpan = Math.max(...projections) - Math.min(...projections);
|
|
532
597
|
const sourceSegmentLength = lineLength(segment);
|
|
533
598
|
const previousDirection =
|
|
534
599
|
index > 0 ? segmentUnit(centerLine[index - 1]!, segment[0]) : undefined;
|
|
535
600
|
const hasSharpStart =
|
|
536
601
|
previousDirection !== undefined &&
|
|
537
602
|
Math.abs(cross(previousDirection, direction)) > Math.sin((5 * Math.PI) / 180);
|
|
603
|
+
const before = index > 0 ? centerLine[index - 1] : undefined;
|
|
604
|
+
const after = centerLine[index + 2];
|
|
605
|
+
const scanCell = trimJointOverlap(
|
|
606
|
+
corridorPolygon(segment, leftExtend, rightExtend, before, after),
|
|
607
|
+
segment,
|
|
608
|
+
before,
|
|
609
|
+
after,
|
|
610
|
+
);
|
|
611
|
+
const scanProjections = scanCell.map(
|
|
612
|
+
(point) => point[0] * direction[0] + point[1] * direction[1],
|
|
613
|
+
);
|
|
614
|
+
const scanSpan = Math.max(...scanProjections) - Math.min(...scanProjections);
|
|
538
615
|
const lineCount = Math.max(
|
|
539
616
|
1,
|
|
540
617
|
Math.ceil(sourceSegmentLength / lineSpacing),
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
618
|
+
// A direction change needs an additional boundary pass at the joint;
|
|
619
|
+
// count it against the finite square span, never the unbounded miter tip.
|
|
620
|
+
hasSharpStart ? Math.ceil(scanSpan / lineSpacing) + 1 : Math.round(scanSpan / lineSpacing),
|
|
544
621
|
);
|
|
545
|
-
|
|
546
|
-
|
|
622
|
+
if (lines.length + lineCount > MAX_ROUTE_COUNT)
|
|
623
|
+
throw new PlannerError("mappingStrip route count exceeds limit");
|
|
624
|
+
// Center the requested lines on the actual square cell. Tightening spacing
|
|
625
|
+
// increases overlap without emitting lines outside the work area.
|
|
626
|
+
const scanSpacing =
|
|
627
|
+
lineCount > 1
|
|
628
|
+
? Math.min(lineSpacing, Math.max(POINT_EPS_M, scanSpan - POINT_EPS_M) / (lineCount - 1))
|
|
629
|
+
: lineSpacing;
|
|
630
|
+
const phaseOffset = Math.max(0, (scanSpan - (lineCount - 1) * scanSpacing) / 2);
|
|
631
|
+
const grid = surveyGrid(scanCell, {
|
|
547
632
|
directionDeg: (bearingDeg(segment[0], segment[1]) + 90) % 360,
|
|
548
633
|
marginM: margin,
|
|
549
|
-
lineSpacing,
|
|
634
|
+
lineSpacing: scanSpacing,
|
|
550
635
|
phaseOffsetM: phaseOffset,
|
|
551
636
|
});
|
|
552
637
|
const flightDirection: EnuPoint = [direction[1], -direction[0]];
|
|
@@ -561,6 +646,93 @@ function perpendicularRouteLines(
|
|
|
561
646
|
return lines;
|
|
562
647
|
}
|
|
563
648
|
|
|
649
|
+
function pointInRing(point: EnuPoint, polygon: readonly EnuPoint[]): boolean {
|
|
650
|
+
let inside = false;
|
|
651
|
+
for (let index = 0; index < polygon.length; index++) {
|
|
652
|
+
const a = polygon[index]!;
|
|
653
|
+
const b = polygon[(index + 1) % polygon.length]!;
|
|
654
|
+
const edge: EnuPoint = [b[0] - a[0], b[1] - a[1]];
|
|
655
|
+
const delta: EnuPoint = [point[0] - a[0], point[1] - a[1]];
|
|
656
|
+
const length = Math.hypot(...edge);
|
|
657
|
+
const projection = edge[0] * delta[0] + edge[1] * delta[1];
|
|
658
|
+
if (
|
|
659
|
+
length > 0 &&
|
|
660
|
+
Math.abs(cross(edge, delta)) < length * 1e-7 &&
|
|
661
|
+
projection >= -1e-7 &&
|
|
662
|
+
projection <= length * length + 1e-7
|
|
663
|
+
)
|
|
664
|
+
return true;
|
|
665
|
+
if (
|
|
666
|
+
a[1] > point[1] !== b[1] > point[1] &&
|
|
667
|
+
point[0] < a[0] + ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1])
|
|
668
|
+
)
|
|
669
|
+
inside = !inside;
|
|
670
|
+
}
|
|
671
|
+
return inside;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Clip an open work polyline to a possibly concave region, retaining each span. */
|
|
675
|
+
function clipWorkLine(line: readonly EnuPoint[], polygon: readonly EnuPoint[]): EnuPoint[][] {
|
|
676
|
+
// A zero-width single-line strip has no area to clip against.
|
|
677
|
+
if (polygonArea(polygon) < 1e-8) return [[...line]];
|
|
678
|
+
const result: EnuPoint[][] = [];
|
|
679
|
+
let current: EnuPoint[] | undefined;
|
|
680
|
+
for (let index = 1; index < line.length; index++) {
|
|
681
|
+
const a = line[index - 1]!;
|
|
682
|
+
const b = line[index]!;
|
|
683
|
+
const direction: EnuPoint = [b[0] - a[0], b[1] - a[1]];
|
|
684
|
+
const length = Math.hypot(...direction);
|
|
685
|
+
if (length < POINT_EPS_M) continue;
|
|
686
|
+
const cuts = [0, 1];
|
|
687
|
+
for (let edgeIndex = 0; edgeIndex < polygon.length; edgeIndex++) {
|
|
688
|
+
const c = polygon[edgeIndex]!;
|
|
689
|
+
const d = polygon[(edgeIndex + 1) % polygon.length]!;
|
|
690
|
+
const edge: EnuPoint = [d[0] - c[0], d[1] - c[1]];
|
|
691
|
+
const denominator = cross(direction, edge);
|
|
692
|
+
if (Math.abs(denominator) < 1e-9) continue;
|
|
693
|
+
const delta: EnuPoint = [c[0] - a[0], c[1] - a[1]];
|
|
694
|
+
const t = cross(delta, edge) / denominator;
|
|
695
|
+
const u = cross(delta, direction) / denominator;
|
|
696
|
+
if (t > 0 && t < 1 && u >= -1e-9 && u <= 1 + 1e-9) cuts.push(t);
|
|
697
|
+
}
|
|
698
|
+
cuts.sort((x, y) => x - y);
|
|
699
|
+
const at = (t: number): EnuPoint => [a[0] + t * direction[0], a[1] + t * direction[1]];
|
|
700
|
+
for (let cutIndex = 1; cutIndex < cuts.length; cutIndex++) {
|
|
701
|
+
const start = cuts[cutIndex - 1]!;
|
|
702
|
+
const end = cuts[cutIndex]!;
|
|
703
|
+
if ((end - start) * length < POINT_EPS_M) continue;
|
|
704
|
+
if (!pointInRing(at((start + end) / 2), polygon)) {
|
|
705
|
+
current = undefined;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const first = at(start);
|
|
709
|
+
const last = at(end);
|
|
710
|
+
if (
|
|
711
|
+
current &&
|
|
712
|
+
Math.hypot(current.at(-1)![0] - first[0], current.at(-1)![1] - first[1]) < POINT_EPS_M
|
|
713
|
+
)
|
|
714
|
+
current.push(last);
|
|
715
|
+
else {
|
|
716
|
+
current = [first, last];
|
|
717
|
+
result.push(current);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return result.map((points) => cleanLine(points));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function extendWorkLine(line: EnuPoint[], margin: number): EnuPoint[] {
|
|
725
|
+
if (margin === 0) return line;
|
|
726
|
+
const first = line[0]!;
|
|
727
|
+
const last = line.at(-1)!;
|
|
728
|
+
const direction = segmentUnit(first, last);
|
|
729
|
+
return [
|
|
730
|
+
[first[0] - direction[0] * margin, first[1] - direction[1] * margin],
|
|
731
|
+
...line.slice(1, -1),
|
|
732
|
+
[last[0] + direction[0] * margin, last[1] + direction[1] * margin],
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
|
|
564
736
|
function polygonArea(points: readonly EnuPoint[]): number {
|
|
565
737
|
let area2 = 0;
|
|
566
738
|
for (let index = 0; index < points.length; index++) {
|
|
@@ -638,6 +810,7 @@ export function deriveMappingStrip(
|
|
|
638
810
|
const centerLine = cleanLine(triples.map((point) => lngLatToEnu(point, origin)));
|
|
639
811
|
if (centerLine.length < 2)
|
|
640
812
|
throw new PlannerError("mappingStrip requires two distinct center points");
|
|
813
|
+
validateCorridorJoins(centerLine, folder.Placemark.leftExtend, folder.Placemark.rightExtend);
|
|
641
814
|
const inputs = normalizeInputs(folder, options);
|
|
642
815
|
if (!(inputs.crossTrackFootprint > 0 && Number.isFinite(inputs.crossTrackFootprint)))
|
|
643
816
|
throw new PlannerError("mappingStrip crossTrackFootprint must be finite and greater than zero");
|
|
@@ -654,42 +827,72 @@ export function deriveMappingStrip(
|
|
|
654
827
|
const localRegions = applyMergedRegionRanges(
|
|
655
828
|
contextualRegions,
|
|
656
829
|
folder.Placemark.stripMergedRegions,
|
|
657
|
-
).map((region) =>
|
|
658
|
-
|
|
659
|
-
polygon: corridorPolygon(
|
|
830
|
+
).map((region) => {
|
|
831
|
+
const polygon = corridorPolygon(
|
|
660
832
|
region.centerLine,
|
|
661
833
|
folder.Placemark.leftExtend,
|
|
662
834
|
folder.Placemark.rightExtend,
|
|
663
835
|
region.beforePoint,
|
|
664
836
|
region.afterPoint,
|
|
665
|
-
)
|
|
666
|
-
|
|
837
|
+
);
|
|
838
|
+
return {
|
|
839
|
+
...region,
|
|
840
|
+
polygon: trimJointOverlap(polygon, region.centerLine, region.beforePoint, region.afterPoint),
|
|
841
|
+
};
|
|
842
|
+
});
|
|
667
843
|
const offsets = routeOffsets(folder, inputs);
|
|
668
844
|
const executionRegions = options?.regionOrderReversed
|
|
669
845
|
? [...localRegions].reverse()
|
|
670
846
|
: localRegions;
|
|
671
847
|
const perpendicular =
|
|
672
848
|
options?.stripDirection === "perpendicular" && !folder.Placemark.singleLineEnable;
|
|
673
|
-
const
|
|
849
|
+
const regionGeometry = executionRegions.map((region) => {
|
|
850
|
+
const routeOffsets: number[] = [];
|
|
674
851
|
const rawLines = perpendicular
|
|
675
852
|
? perpendicularRouteLines(
|
|
676
853
|
region.centerLine,
|
|
677
854
|
folder.Placemark.leftExtend,
|
|
678
855
|
folder.Placemark.rightExtend,
|
|
679
|
-
|
|
856
|
+
0,
|
|
680
857
|
inputs.lineSpacing,
|
|
681
858
|
)
|
|
682
|
-
|
|
859
|
+
.flatMap((line) => clipWorkLine(line, region.polygon))
|
|
860
|
+
.map((line) => extendWorkLine(line, folder.Placemark.margin))
|
|
861
|
+
: offsets.flatMap((offset) => {
|
|
862
|
+
const line = offsetSide(region.centerLine, offset, region.beforePoint, region.afterPoint);
|
|
863
|
+
const clipped = clipWorkLine(line, region.polygon);
|
|
864
|
+
routeOffsets.push(...clipped.map(() => offset));
|
|
865
|
+
return clipped;
|
|
866
|
+
});
|
|
683
867
|
if (rawLines.length === 0)
|
|
684
868
|
throw new PlannerError("mappingStrip region has no available flight routes");
|
|
685
|
-
return
|
|
686
|
-
|
|
869
|
+
return { lines: rawLines, offsets: routeOffsets };
|
|
870
|
+
});
|
|
871
|
+
const candidateGroups = regionGeometry.map((geometry, index) => {
|
|
872
|
+
return serpentineRouteVariants(geometry.lines).map((lines) =>
|
|
873
|
+
localRouteCandidate(
|
|
874
|
+
lines,
|
|
875
|
+
executionRegions[index]!.centerLine,
|
|
876
|
+
folder.Placemark.elevationOptimizeEnable,
|
|
877
|
+
),
|
|
687
878
|
);
|
|
688
879
|
});
|
|
689
880
|
const takeOffRefPoint = template.missionConfig.takeOffRefPoint;
|
|
690
|
-
const
|
|
881
|
+
const home = takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : undefined;
|
|
882
|
+
const timing = {
|
|
883
|
+
flightSpeed: Number(folder.autoFlightSpeed),
|
|
884
|
+
transitSpeed: Number(template.missionConfig.globalTransitionalSpeed),
|
|
885
|
+
finishAction: template.missionConfig.finishAction,
|
|
886
|
+
};
|
|
887
|
+
const selectedCandidateIndexes = selectStripRouteCandidates(
|
|
691
888
|
candidateGroups,
|
|
692
|
-
|
|
889
|
+
home,
|
|
890
|
+
executionRegions
|
|
891
|
+
.slice(1)
|
|
892
|
+
.map((region) =>
|
|
893
|
+
options?.regionOrderReversed ? region.centerLine.at(-1)! : region.centerLine[0]!,
|
|
894
|
+
),
|
|
895
|
+
timing,
|
|
693
896
|
);
|
|
694
897
|
const flightRoutes: MappingStripRegionRoute[] = [];
|
|
695
898
|
let previousRegionEnd: EnuPoint | undefined;
|
|
@@ -713,7 +916,7 @@ export function deriveMappingStrip(
|
|
|
713
916
|
const executable = joinSegmentPoints(candidate.segments);
|
|
714
917
|
flightRoutes.push({
|
|
715
918
|
regionId: region.id,
|
|
716
|
-
routeOffsets:
|
|
919
|
+
routeOffsets: regionGeometry[regionIndex]!.offsets.map(meters),
|
|
717
920
|
routeLines: candidate.lines.map((line) => line.map((point) => enuToLngLat(point, origin))),
|
|
718
921
|
segments: localSegments.map((segment) => ({
|
|
719
922
|
...segment,
|
|
@@ -730,7 +933,11 @@ export function deriveMappingStrip(
|
|
|
730
933
|
folder.Placemark.leftExtend,
|
|
731
934
|
folder.Placemark.rightExtend,
|
|
732
935
|
);
|
|
733
|
-
const
|
|
936
|
+
const travel = measureStripMission(
|
|
937
|
+
candidateGroups.map((group, index) => group[selectedCandidateIndexes[index]!]!),
|
|
938
|
+
home,
|
|
939
|
+
timing,
|
|
940
|
+
);
|
|
734
941
|
const estimatedPhotoCount = flightRoutes.reduce(
|
|
735
942
|
(sum, route) =>
|
|
736
943
|
sum +
|
|
@@ -758,10 +965,10 @@ export function deriveMappingStrip(
|
|
|
758
965
|
polygon: region.polygon.map((point) => enuToLngLat(point, origin)),
|
|
759
966
|
})),
|
|
760
967
|
flightRoutes,
|
|
761
|
-
totalFlightLength: meters(
|
|
968
|
+
totalFlightLength: meters(travel.distance),
|
|
762
969
|
photoSpacing: meters(inputs.photoSpacing),
|
|
763
970
|
estimatedPhotoCount,
|
|
764
|
-
estimatedDuration:
|
|
971
|
+
estimatedDuration: travel.duration,
|
|
765
972
|
};
|
|
766
973
|
}
|
|
767
974
|
|
|
@@ -780,7 +987,7 @@ function heightModeToExecuteMode(folder: MappingStripFolder): ExecuteHeightMode
|
|
|
780
987
|
function turnDamping(points: readonly LngLat[], index: number): Meters {
|
|
781
988
|
const previous = haversineMeters(points[index - 1]!, points[index]!);
|
|
782
989
|
const next = haversineMeters(points[index]!, points[index + 1]!);
|
|
783
|
-
return meters(Math.min(TURN_DAMPING_M, previous
|
|
990
|
+
return meters(Math.min(TURN_DAMPING_M, previous / 3, next / 3));
|
|
784
991
|
}
|
|
785
992
|
|
|
786
993
|
function shootingGroups(
|