@lumikmz/kmz 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +38 -6
- package/package.json +1 -1
- package/src/plan/geometry.test.ts +18 -0
- package/src/plan/geometry.ts +56 -5
package/dist/index.mjs
CHANGED
|
@@ -184,6 +184,7 @@ const PayloadEnum = {
|
|
|
184
184
|
const EARTH_RADIUS = 6371e3;
|
|
185
185
|
const DEG2RAD = Math.PI / 180;
|
|
186
186
|
const RAD2DEG = 180 / Math.PI;
|
|
187
|
+
const ANGLE_EPS_RAD = 1e-12;
|
|
187
188
|
/** Project lng/lat to local ENU meters around `origin`. */
|
|
188
189
|
function lngLatToEnu(point, origin) {
|
|
189
190
|
const dLat = (point.lat - origin.lat) * DEG2RAD;
|
|
@@ -290,6 +291,7 @@ function dedupeRing(ring, epsM = .5) {
|
|
|
290
291
|
}
|
|
291
292
|
/** Beyond this multiple of `distM`, a corner miter is clamped to avoid spikes. */
|
|
292
293
|
const OFFSET_MITER_LIMIT = 2.5;
|
|
294
|
+
const MIN_SCAN_SEGMENT_LEN_M = 1e-6;
|
|
293
295
|
/**
|
|
294
296
|
* Outward-offset (dilate) a simple polygon ring by `distM` meters in ENU.
|
|
295
297
|
* Each vertex moves along the bisector of its two adjacent outward edge normals
|
|
@@ -335,6 +337,28 @@ function offsetPolygon(ring, distM) {
|
|
|
335
337
|
return out;
|
|
336
338
|
}
|
|
337
339
|
/**
|
|
340
|
+
* Pull same-side boustrophedon turn points inward until adjacent flight lines
|
|
341
|
+
* share one x coordinate. This keeps margin=0 routes inside the polygon while
|
|
342
|
+
* avoiding very acute boundary-following connectors on slanted edges.
|
|
343
|
+
*/
|
|
344
|
+
function stabilizeTurnCorridors(segments) {
|
|
345
|
+
const segmentPts = segments.map(({ a, b, y, leftToRight }) => leftToRight ? [[a, y], [b, y]] : [[b, y], [a, y]]);
|
|
346
|
+
for (let i = 0; i + 1 < segments.length; i++) {
|
|
347
|
+
const cur = segments[i];
|
|
348
|
+
const next = segments[i + 1];
|
|
349
|
+
if (cur.leftToRight === next.leftToRight) continue;
|
|
350
|
+
const overlapMin = Math.max(cur.a, next.a);
|
|
351
|
+
const overlapMax = Math.min(cur.b, next.b);
|
|
352
|
+
if (overlapMin > overlapMax) continue;
|
|
353
|
+
const turnX = cur.leftToRight ? overlapMax : overlapMin;
|
|
354
|
+
segmentPts[i][1] = [turnX, cur.y];
|
|
355
|
+
segmentPts[i + 1][0] = [turnX, next.y];
|
|
356
|
+
}
|
|
357
|
+
const pts = [];
|
|
358
|
+
for (const [start, end] of segmentPts) pts.push(start, end);
|
|
359
|
+
return pts;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
338
362
|
* Boustrophedon survey grid over an arbitrary simple polygon, aligned to `directionDeg`.
|
|
339
363
|
* Flight lines run parallel to the heading; consecutive lines step by `lineSpacing`.
|
|
340
364
|
* `marginM` extends each line lengthwise past the polygon edge.
|
|
@@ -344,7 +368,8 @@ function offsetPolygon(ring, distM) {
|
|
|
344
368
|
function surveyGrid(ring, opts) {
|
|
345
369
|
const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
|
|
346
370
|
const t = directionDeg * DEG2RAD;
|
|
347
|
-
const
|
|
371
|
+
const rawAlpha = Math.atan2(Math.cos(t), Math.sin(t));
|
|
372
|
+
const alpha = Math.abs(rawAlpha) < ANGLE_EPS_RAD ? 0 : rawAlpha;
|
|
348
373
|
const scanRing = ring.map((p) => rotateEnu(p, -alpha));
|
|
349
374
|
let minY = Infinity;
|
|
350
375
|
let maxY = -Infinity;
|
|
@@ -352,18 +377,25 @@ function surveyGrid(ring, opts) {
|
|
|
352
377
|
if (y < minY) minY = y;
|
|
353
378
|
if (y > maxY) maxY = y;
|
|
354
379
|
}
|
|
355
|
-
const
|
|
380
|
+
const scanSegments = [];
|
|
356
381
|
let leftToRight = true;
|
|
357
382
|
for (let y = minY + phaseOffsetM; y <= maxY + lineSpacing * .5; y += lineSpacing) {
|
|
383
|
+
let emitted = false;
|
|
358
384
|
for (const [x1, x2] of clipHorizontal(y, scanRing)) {
|
|
385
|
+
if (Math.abs(x2 - x1) < MIN_SCAN_SEGMENT_LEN_M) continue;
|
|
359
386
|
const a = x1 - marginM + lengthwiseShiftM;
|
|
360
387
|
const b = x2 + marginM + lengthwiseShiftM;
|
|
361
|
-
|
|
362
|
-
|
|
388
|
+
scanSegments.push({
|
|
389
|
+
a,
|
|
390
|
+
b,
|
|
391
|
+
y,
|
|
392
|
+
leftToRight
|
|
393
|
+
});
|
|
394
|
+
emitted = true;
|
|
363
395
|
}
|
|
364
|
-
leftToRight = !leftToRight;
|
|
396
|
+
if (emitted) leftToRight = !leftToRight;
|
|
365
397
|
}
|
|
366
|
-
return
|
|
398
|
+
return stabilizeTurnCorridors(scanSegments).map((p) => rotateEnu(p, alpha));
|
|
367
399
|
}
|
|
368
400
|
//#endregion
|
|
369
401
|
//#region src/plan/plan-mapping2d.ts
|
package/package.json
CHANGED
|
@@ -93,4 +93,22 @@ describe("surveyGrid", () => {
|
|
|
93
93
|
const span = (pts: EnuPoint[]) => Math.abs(pts[1]![0] - pts[0]![0]);
|
|
94
94
|
expect(span(widened)).toBeGreaterThan(span(base));
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
it("keeps same-side turns inside the polygon instead of following acute slanted edges", () => {
|
|
98
|
+
const trapezoid: EnuPoint[] = [
|
|
99
|
+
[0, 0],
|
|
100
|
+
[120, 0],
|
|
101
|
+
[80, 100],
|
|
102
|
+
[0, 100],
|
|
103
|
+
];
|
|
104
|
+
const pts = surveyGrid(trapezoid, { directionDeg: 90, marginM: 0, lineSpacing: 25 });
|
|
105
|
+
|
|
106
|
+
// First turn is on the right side. The lower endpoint is pulled back from
|
|
107
|
+
// x=120 to the next line's right boundary x=110, making a vertical in-area
|
|
108
|
+
// connector instead of an acute diagonal along the slanted edge.
|
|
109
|
+
expect(pts[1]![0]).toBeCloseTo(pts[2]![0], 3);
|
|
110
|
+
expect(pts[1]![0]).toBeCloseTo(110, 3);
|
|
111
|
+
expect(pts[1]![1]).toBeCloseTo(0, 3);
|
|
112
|
+
expect(pts[2]![1]).toBeCloseTo(25, 3);
|
|
113
|
+
});
|
|
96
114
|
});
|
package/src/plan/geometry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { LngLat } from "../types/branded.js";
|
|
|
3
3
|
const EARTH_RADIUS = 6_371_000;
|
|
4
4
|
const DEG2RAD = Math.PI / 180;
|
|
5
5
|
const RAD2DEG = 180 / Math.PI;
|
|
6
|
+
const ANGLE_EPS_RAD = 1e-12;
|
|
6
7
|
|
|
7
8
|
export type EnuPoint = readonly [number, number];
|
|
8
9
|
|
|
@@ -133,6 +134,7 @@ function dedupeRing(ring: readonly EnuPoint[], epsM = 0.5): EnuPoint[] {
|
|
|
133
134
|
|
|
134
135
|
/** Beyond this multiple of `distM`, a corner miter is clamped to avoid spikes. */
|
|
135
136
|
const OFFSET_MITER_LIMIT = 2.5;
|
|
137
|
+
const MIN_SCAN_SEGMENT_LEN_M = 1e-6;
|
|
136
138
|
|
|
137
139
|
/**
|
|
138
140
|
* Outward-offset (dilate) a simple polygon ring by `distM` meters in ENU.
|
|
@@ -208,6 +210,51 @@ export interface SurveyGridOptions {
|
|
|
208
210
|
lengthwiseShiftM?: number;
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
interface ScanSegment {
|
|
214
|
+
a: number;
|
|
215
|
+
b: number;
|
|
216
|
+
y: number;
|
|
217
|
+
leftToRight: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Pull same-side boustrophedon turn points inward until adjacent flight lines
|
|
222
|
+
* share one x coordinate. This keeps margin=0 routes inside the polygon while
|
|
223
|
+
* avoiding very acute boundary-following connectors on slanted edges.
|
|
224
|
+
*/
|
|
225
|
+
function stabilizeTurnCorridors(segments: ScanSegment[]): EnuPoint[] {
|
|
226
|
+
const segmentPts = segments.map(({ a, b, y, leftToRight }) =>
|
|
227
|
+
leftToRight
|
|
228
|
+
? ([
|
|
229
|
+
[a, y],
|
|
230
|
+
[b, y],
|
|
231
|
+
] as [EnuPoint, EnuPoint])
|
|
232
|
+
: ([
|
|
233
|
+
[b, y],
|
|
234
|
+
[a, y],
|
|
235
|
+
] as [EnuPoint, EnuPoint]),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i + 1 < segments.length; i++) {
|
|
239
|
+
const cur = segments[i]!;
|
|
240
|
+
const next = segments[i + 1]!;
|
|
241
|
+
if (cur.leftToRight === next.leftToRight) continue;
|
|
242
|
+
|
|
243
|
+
const overlapMin = Math.max(cur.a, next.a);
|
|
244
|
+
const overlapMax = Math.min(cur.b, next.b);
|
|
245
|
+
if (overlapMin > overlapMax) continue;
|
|
246
|
+
|
|
247
|
+
// LTR -> RTL turns on the right edge; RTL -> LTR turns on the left edge.
|
|
248
|
+
const turnX = cur.leftToRight ? overlapMax : overlapMin;
|
|
249
|
+
segmentPts[i]![1] = [turnX, cur.y];
|
|
250
|
+
segmentPts[i + 1]![0] = [turnX, next.y];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const pts: EnuPoint[] = [];
|
|
254
|
+
for (const [start, end] of segmentPts) pts.push(start, end);
|
|
255
|
+
return pts;
|
|
256
|
+
}
|
|
257
|
+
|
|
211
258
|
/**
|
|
212
259
|
* Boustrophedon survey grid over an arbitrary simple polygon, aligned to `directionDeg`.
|
|
213
260
|
* Flight lines run parallel to the heading; consecutive lines step by `lineSpacing`.
|
|
@@ -219,7 +266,8 @@ export function surveyGrid(ring: readonly EnuPoint[], opts: SurveyGridOptions):
|
|
|
219
266
|
const { directionDeg, marginM, lineSpacing, phaseOffsetM = 0, lengthwiseShiftM = 0 } = opts;
|
|
220
267
|
// Map the heading clockwise-from-north → CCW angle from +x axis
|
|
221
268
|
const t = directionDeg * DEG2RAD;
|
|
222
|
-
const
|
|
269
|
+
const rawAlpha = Math.atan2(Math.cos(t), Math.sin(t));
|
|
270
|
+
const alpha = Math.abs(rawAlpha) < ANGLE_EPS_RAD ? 0 : rawAlpha;
|
|
223
271
|
const scanRing = ring.map((p) => rotateEnu(p, -alpha));
|
|
224
272
|
|
|
225
273
|
let minY = Infinity;
|
|
@@ -229,17 +277,20 @@ export function surveyGrid(ring: readonly EnuPoint[], opts: SurveyGridOptions):
|
|
|
229
277
|
if (y > maxY) maxY = y;
|
|
230
278
|
}
|
|
231
279
|
|
|
232
|
-
const
|
|
280
|
+
const scanSegments: ScanSegment[] = [];
|
|
233
281
|
let leftToRight = true;
|
|
234
282
|
for (let y = minY + phaseOffsetM; y <= maxY + lineSpacing * 0.5; y += lineSpacing) {
|
|
283
|
+
let emitted = false;
|
|
235
284
|
for (const [x1, x2] of clipHorizontal(y, scanRing)) {
|
|
285
|
+
if (Math.abs(x2 - x1) < MIN_SCAN_SEGMENT_LEN_M) continue;
|
|
236
286
|
const a = x1 - marginM + lengthwiseShiftM;
|
|
237
287
|
const b = x2 + marginM + lengthwiseShiftM;
|
|
238
|
-
|
|
239
|
-
|
|
288
|
+
scanSegments.push({ a, b, y, leftToRight });
|
|
289
|
+
emitted = true;
|
|
240
290
|
}
|
|
241
|
-
leftToRight = !leftToRight;
|
|
291
|
+
if (emitted) leftToRight = !leftToRight;
|
|
242
292
|
}
|
|
243
293
|
|
|
294
|
+
const scanPts = stabilizeTurnCorridors(scanSegments);
|
|
244
295
|
return scanPts.map((p) => rotateEnu(p, alpha));
|
|
245
296
|
}
|