@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.
@@ -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
- function offsetPolyline(points: readonly EnuPoint[], distance: number): EnuPoint[] {
395
- if (distance === 0) return points.map((point) => [point[0], point[1]]);
396
- const directions = points.slice(1).map((point, index) => segmentUnit(points[index]!, point));
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 previousDirection = directions[index - 1]!;
403
- const nextDirection = directions[index]!;
404
- const previousNormal = leftNormal(previousDirection);
405
- const nextNormal = leftNormal(nextDirection);
406
- const previousOffset: EnuPoint = [
407
- points[index]![0] + previousNormal[0] * distance,
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
- const lastNormal = leftNormal(directions.at(-1)!);
434
- const last = points.at(-1)!;
435
- out.push([last[0] + lastNormal[0] * distance, last[1] + lastNormal[1] * distance]);
436
- return out;
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 corridorSide(
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 previousDirection = segmentUnit(previous, point);
463
- const nextDirection = segmentUnit(point, next);
464
- const previousNormal = leftNormal(previousDirection);
465
- const nextNormal = leftNormal(nextDirection);
466
- const previousOffset: EnuPoint = [
467
- point[0] + previousNormal[0] * distance,
468
- point[1] + previousNormal[1] * distance,
469
- ];
470
- const nextOffset: EnuPoint = [
471
- point[0] + nextNormal[0] * distance,
472
- point[1] + nextNormal[1] * distance,
473
- ];
474
- const denominator = cross(previousDirection, nextDirection);
475
- if (Math.abs(denominator) < 1e-9) {
476
- out.push(previousOffset);
477
- continue;
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
- // FlightHub uses a bevel on the outside of every bend. The inside remains
481
- // a miter intersection so the corridor does not overlap itself at corners.
482
- if (denominator * distance < 0) {
483
- out.push(previousOffset, nextOffset);
484
- continue;
485
- }
486
- const delta: EnuPoint = [nextOffset[0] - previousOffset[0], nextOffset[1] - previousOffset[1]];
487
- const t = cross(delta, nextDirection) / denominator;
488
- const intersection: EnuPoint = [
489
- previousOffset[0] + previousDirection[0] * t,
490
- previousOffset[1] + previousDirection[1] * t,
491
- ];
492
- const miterLength = Math.hypot(intersection[0] - point[0], intersection[1] - point[1]);
493
- if (miterLength <= Math.abs(distance) * MITER_LIMIT) out.push(intersection);
494
- else out.push(previousOffset, nextOffset);
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
- ...corridorSide(points, left, beforePoint, afterPoint),
508
- ...corridorSide(points, -right, beforePoint, afterPoint).reverse(),
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
- const left = offsetPolyline(centerLine, leftExtend);
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
- hasSharpStart
542
- ? Math.ceil(longitudinalSpan / lineSpacing)
543
- : Math.round(longitudinalSpan / lineSpacing),
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
- const phaseOffset = Math.max(0, (longitudinalSpan - (lineCount - 1) * lineSpacing) / 2);
546
- const grid = surveyGrid(cell, {
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
- ...region,
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 candidateGroups = executionRegions.map((region) => {
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
- folder.Placemark.margin,
856
+ 0,
680
857
  inputs.lineSpacing,
681
858
  )
682
- : offsets.map((offset) => offsetPolyline(region.centerLine, offset));
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 serpentineRouteVariants(rawLines).map((lines) =>
686
- localRouteCandidate(lines, region.centerLine, folder.Placemark.elevationOptimizeEnable),
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 selectedCandidateIndexes = shortestCandidateIndexes(
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
- takeOffRefPoint ? lngLatToEnu(takeOffRefPoint, origin) : undefined,
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: perpendicular ? [] : offsets.map(meters),
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 totalFlightLength = flightRoutes.reduce((sum, route) => sum + Number(route.length), 0);
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(totalFlightLength),
968
+ totalFlightLength: meters(travel.distance),
762
969
  photoSpacing: meters(inputs.photoSpacing),
763
970
  estimatedPhotoCount,
764
- estimatedDuration: totalFlightLength / Number(folder.autoFlightSpeed),
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 * 0.45, next * 0.45));
990
+ return meters(Math.min(TURN_DAMPING_M, previous / 3, next / 3));
784
991
  }
785
992
 
786
993
  function shootingGroups(