@lumikmz/kmz 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +150 -132
- package/dist/index.mjs +347 -115
- 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 +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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { degrees, meters, metersPerSecond, type LngLat } from "../types/branded.js";
|
|
3
|
+
import type { MappingStripTemplate } from "../types/template.js";
|
|
4
|
+
import { PlannerError } from "../types/errors.js";
|
|
5
|
+
import { deriveMappingStrip, planMappingStrip } from "./plan-mapping-strip.js";
|
|
6
|
+
import { enuToLngLat, haversineMeters, lngLatToEnu, type EnuPoint } from "./geometry.js";
|
|
7
|
+
|
|
8
|
+
const ORIGIN: LngLat = { lng: 118.35, lat: 35.05 };
|
|
9
|
+
const OPTIONS = {
|
|
10
|
+
crossTrackFootprint: meters(90),
|
|
11
|
+
lineSpacing: meters(27),
|
|
12
|
+
photoSpacing: meters(10),
|
|
13
|
+
shootInterval: 1,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function template(
|
|
17
|
+
points: readonly LngLat[],
|
|
18
|
+
patch: Partial<MappingStripTemplate["Folder"]["Placemark"]> = {},
|
|
19
|
+
): MappingStripTemplate {
|
|
20
|
+
return {
|
|
21
|
+
missionConfig: {
|
|
22
|
+
flyToWaylineMode: "safely",
|
|
23
|
+
finishAction: "goHome",
|
|
24
|
+
exitOnRCLost: "goContinue",
|
|
25
|
+
executeRCLostAction: "goBack",
|
|
26
|
+
takeOffSecurityHeight: meters(20),
|
|
27
|
+
globalTransitionalSpeed: metersPerSecond(10),
|
|
28
|
+
globalRTHHeight: meters(100),
|
|
29
|
+
droneInfo: { droneEnumValue: 100, droneSubEnumValue: 1 },
|
|
30
|
+
payloadInfo: [{ payloadEnumValue: 99, payloadPositionIndex: 0 }],
|
|
31
|
+
},
|
|
32
|
+
Folder: {
|
|
33
|
+
templateType: "mappingStrip",
|
|
34
|
+
templateId: 0,
|
|
35
|
+
waylineCoordinateSysParam: { coordinateMode: "WGS84", heightMode: "EGM96" },
|
|
36
|
+
autoFlightSpeed: metersPerSecond(10),
|
|
37
|
+
payloadParam: { payloadPositionIndex: 0, imageFormat: ["visable", "ir"] },
|
|
38
|
+
Placemark: {
|
|
39
|
+
caliFlightEnable: false,
|
|
40
|
+
shootType: "time",
|
|
41
|
+
direction: degrees(0),
|
|
42
|
+
margin: 0,
|
|
43
|
+
singleLineEnable: false,
|
|
44
|
+
cuttingDistance: 350,
|
|
45
|
+
boundaryOptimEnable: false,
|
|
46
|
+
leftExtend: 20,
|
|
47
|
+
rightExtend: 20,
|
|
48
|
+
includeCenterEnable: false,
|
|
49
|
+
overlap: { orthoCameraOverlapH: 80, orthoCameraOverlapW: 70 },
|
|
50
|
+
LineString: { coordinates: points.map((p) => `${p.lng},${p.lat},0`).join(" ") },
|
|
51
|
+
ellipsoidHeight: meters(100),
|
|
52
|
+
height: meters(100),
|
|
53
|
+
stripUseTemplateAltitude: false,
|
|
54
|
+
...patch,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fromMeters(points: readonly EnuPoint[], patch = {}) {
|
|
61
|
+
return template(
|
|
62
|
+
points.map((point) => enuToLngLat(point, ORIGIN)),
|
|
63
|
+
patch,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasCrossing(ring: readonly LngLat[]): boolean {
|
|
68
|
+
const points = ring.map((point) => lngLatToEnu(point, ring[0]!));
|
|
69
|
+
const side = (a: EnuPoint, b: EnuPoint, c: EnuPoint) =>
|
|
70
|
+
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
71
|
+
for (let i = 0; i < points.length; i++) {
|
|
72
|
+
for (let j = i + 2; j < points.length; j++) {
|
|
73
|
+
if (i === 0 && j === points.length - 1) continue;
|
|
74
|
+
const a = points[i]!;
|
|
75
|
+
const b = points[(i + 1) % points.length]!;
|
|
76
|
+
const c = points[j]!;
|
|
77
|
+
const d = points[(j + 1) % points.length]!;
|
|
78
|
+
if (side(a, b, c) * side(a, b, d) < -1e-6 && side(c, d, a) * side(c, d, b) < -1e-6)
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function covers(ring: readonly LngLat[], point: LngLat): boolean {
|
|
86
|
+
const vertices = ring.map((p) => lngLatToEnu(p, point));
|
|
87
|
+
let inside = false;
|
|
88
|
+
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
|
|
89
|
+
const a = vertices[i]!;
|
|
90
|
+
const b = vertices[j]!;
|
|
91
|
+
const dx = b[0] - a[0];
|
|
92
|
+
const dy = b[1] - a[1];
|
|
93
|
+
const t = Math.max(0, Math.min(1, -(a[0] * dx + a[1] * dy) / (dx * dx + dy * dy)));
|
|
94
|
+
if (Math.hypot(a[0] + t * dx, a[1] + t * dy) < 0.05) return true;
|
|
95
|
+
if (a[1] > 0 !== b[1] > 0 && ((b[0] - a[0]) * -a[1]) / (b[1] - a[1]) + a[0] > 0)
|
|
96
|
+
inside = !inside;
|
|
97
|
+
}
|
|
98
|
+
return inside;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("mapping strip corner geometry", () => {
|
|
102
|
+
it("reports the complete horizontal mission with the configured transit speed and finish action", () => {
|
|
103
|
+
const mission = fromMeters(
|
|
104
|
+
[
|
|
105
|
+
[0, 0],
|
|
106
|
+
[300, 0],
|
|
107
|
+
],
|
|
108
|
+
{ singleLineEnable: true, cuttingDistance: 1_000 },
|
|
109
|
+
);
|
|
110
|
+
mission.missionConfig.takeOffRefPoint = {
|
|
111
|
+
...enuToLngLat([-100, 0], ORIGIN),
|
|
112
|
+
height: meters(0),
|
|
113
|
+
};
|
|
114
|
+
mission.missionConfig.globalTransitionalSpeed = metersPerSecond(20);
|
|
115
|
+
const returning = deriveMappingStrip(mission, OPTIONS);
|
|
116
|
+
expect(returning.estimatedDuration).toBeCloseTo(55, 2);
|
|
117
|
+
expect(Number(returning.totalFlightLength)).toBeCloseTo(800, 2);
|
|
118
|
+
mission.missionConfig.finishAction = "noAction";
|
|
119
|
+
const staying = deriveMappingStrip(mission, OPTIONS);
|
|
120
|
+
expect(staying.estimatedDuration).toBeCloseTo(35, 2);
|
|
121
|
+
expect(Number(staying.totalFlightLength)).toBeCloseTo(400, 2);
|
|
122
|
+
});
|
|
123
|
+
for (const angle of [15, 30, 45, 60, 90]) {
|
|
124
|
+
for (const sign of [-1, 1]) {
|
|
125
|
+
it(`keeps ${angle} degree ${sign < 0 ? "right" : "left"} corners simple when split or merged`, () => {
|
|
126
|
+
const rad = (angle * Math.PI) / 180;
|
|
127
|
+
const points: EnuPoint[] = [
|
|
128
|
+
[300, 0],
|
|
129
|
+
[0, 0],
|
|
130
|
+
[300 * Math.cos(rad), sign * 300 * Math.sin(rad)],
|
|
131
|
+
];
|
|
132
|
+
for (const cuttingDistance of [350, 1_000]) {
|
|
133
|
+
for (const [leftExtend, rightExtend] of [
|
|
134
|
+
[20, 20],
|
|
135
|
+
[10, 35],
|
|
136
|
+
]) {
|
|
137
|
+
const derived = deriveMappingStrip(
|
|
138
|
+
fromMeters(points, { cuttingDistance, leftExtend, rightExtend }),
|
|
139
|
+
OPTIONS,
|
|
140
|
+
);
|
|
141
|
+
expect(hasCrossing(derived.surveyArea)).toBe(false);
|
|
142
|
+
for (const region of derived.regions) expect(hasCrossing(region.polygon)).toBe(false);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it("shares the offset seam endpoints between adjacent parallel flight routes", () => {
|
|
150
|
+
const derived = deriveMappingStrip(
|
|
151
|
+
fromMeters([
|
|
152
|
+
[300, 0],
|
|
153
|
+
[0, 0],
|
|
154
|
+
[260, 150],
|
|
155
|
+
]),
|
|
156
|
+
OPTIONS,
|
|
157
|
+
);
|
|
158
|
+
expect(derived.flightRoutes).toHaveLength(2);
|
|
159
|
+
const endpoints = derived.flightRoutes.map((route) =>
|
|
160
|
+
route.routeLines
|
|
161
|
+
.flatMap((line) => [line[0]!, line.at(-1)!])
|
|
162
|
+
.filter((p) => haversineMeters(p, ORIGIN) < 100),
|
|
163
|
+
);
|
|
164
|
+
expect(endpoints[0]).toHaveLength(2);
|
|
165
|
+
expect(endpoints[1]).toHaveLength(2);
|
|
166
|
+
for (const endpoint of endpoints[0]!) {
|
|
167
|
+
expect(
|
|
168
|
+
Math.min(...endpoints[1]!.map((other) => haversineMeters(endpoint, other))),
|
|
169
|
+
).toBeLessThan(0.05);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps acute perpendicular work lines inside their square-joined region", () => {
|
|
174
|
+
for (const cuttingDistance of [350, 1_000]) {
|
|
175
|
+
const derived = deriveMappingStrip(
|
|
176
|
+
fromMeters(
|
|
177
|
+
[
|
|
178
|
+
[300, 0],
|
|
179
|
+
[0, 0],
|
|
180
|
+
[260, 150],
|
|
181
|
+
],
|
|
182
|
+
{ cuttingDistance },
|
|
183
|
+
),
|
|
184
|
+
{ ...OPTIONS, stripDirection: "perpendicular" },
|
|
185
|
+
);
|
|
186
|
+
for (const route of derived.flightRoutes) {
|
|
187
|
+
const region = derived.regions.find((r) => r.id === route.regionId)!;
|
|
188
|
+
for (const point of route.routeLines.flat())
|
|
189
|
+
expect(covers(region.polygon, point)).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("bounds perpendicular line counts by the square corridor for a narrow one-sided bend", () => {
|
|
195
|
+
const angle = Math.PI / 180;
|
|
196
|
+
const derived = deriveMappingStrip(
|
|
197
|
+
fromMeters(
|
|
198
|
+
[
|
|
199
|
+
[100, 0],
|
|
200
|
+
[0, 0],
|
|
201
|
+
[100 * Math.cos(angle), -100 * Math.sin(angle)],
|
|
202
|
+
],
|
|
203
|
+
{
|
|
204
|
+
cuttingDistance: 1_000,
|
|
205
|
+
leftExtend: 0,
|
|
206
|
+
rightExtend: 20,
|
|
207
|
+
},
|
|
208
|
+
),
|
|
209
|
+
{ ...OPTIONS, stripDirection: "perpendicular" },
|
|
210
|
+
);
|
|
211
|
+
expect(derived.flightRoutes[0]!.routeLines.length).toBeLessThanOrEqual(12);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rejects excessive perpendicular line counts before generating the grid", () => {
|
|
215
|
+
expect(() =>
|
|
216
|
+
deriveMappingStrip(
|
|
217
|
+
fromMeters([
|
|
218
|
+
[0, 0],
|
|
219
|
+
[300, 0],
|
|
220
|
+
]),
|
|
221
|
+
{
|
|
222
|
+
...OPTIONS,
|
|
223
|
+
stripDirection: "perpendicular",
|
|
224
|
+
lineSpacing: meters(0.1),
|
|
225
|
+
},
|
|
226
|
+
),
|
|
227
|
+
).toThrow(PlannerError);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("clips parallel work lines when distance cuts fall inside an acute join", () => {
|
|
231
|
+
const derived = deriveMappingStrip(
|
|
232
|
+
fromMeters(
|
|
233
|
+
[
|
|
234
|
+
[300, 0],
|
|
235
|
+
[0, 0],
|
|
236
|
+
[260, 150],
|
|
237
|
+
],
|
|
238
|
+
{ cuttingDistance: 50 },
|
|
239
|
+
),
|
|
240
|
+
{ ...OPTIONS, crossTrackFootprint: meters(10), lineSpacing: meters(10) },
|
|
241
|
+
);
|
|
242
|
+
for (const route of derived.flightRoutes) {
|
|
243
|
+
const region = derived.regions.find((r) => r.id === route.regionId)!;
|
|
244
|
+
expect(route.routeOffsets).toHaveLength(route.routeLines.length);
|
|
245
|
+
for (const line of route.routeLines) {
|
|
246
|
+
for (const point of line) expect(covers(region.polygon, point)).toBe(true);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("accepts an acute bend with a redundant collinear control point", () => {
|
|
252
|
+
const derived = deriveMappingStrip(
|
|
253
|
+
fromMeters(
|
|
254
|
+
[
|
|
255
|
+
[300, 0],
|
|
256
|
+
[5, 0],
|
|
257
|
+
[0, 0],
|
|
258
|
+
[260, 150],
|
|
259
|
+
],
|
|
260
|
+
{ cuttingDistance: 1_000 },
|
|
261
|
+
),
|
|
262
|
+
OPTIONS,
|
|
263
|
+
);
|
|
264
|
+
expect(hasCrossing(derived.surveyArea)).toBe(false);
|
|
265
|
+
for (const route of derived.flightRoutes) {
|
|
266
|
+
const region = derived.regions.find((r) => r.id === route.regionId)!;
|
|
267
|
+
for (const point of route.routeLines.flat()) expect(covers(region.polygon, point)).toBe(true);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("matches the outer square join and inner intersection in the supplied FlightHub export", () => {
|
|
272
|
+
// Geometry-only excerpts from docs/wpmz/template.kml and waylines.wpml.
|
|
273
|
+
// Allow 0.3 m for local ENU versus FlightHub's geodesic projection.
|
|
274
|
+
const source = [
|
|
275
|
+
{ lng: 113.928721193053, lat: 22.5853407272811 },
|
|
276
|
+
{ lng: 113.94147792621, lat: 22.5841462210489 },
|
|
277
|
+
{ lng: 113.939227972615, lat: 22.5786467306102 },
|
|
278
|
+
{ lng: 113.931390810642, lat: 22.5791428669172 },
|
|
279
|
+
{ lng: 113.935648166968, lat: 22.5807314873742 },
|
|
280
|
+
];
|
|
281
|
+
const derived = deriveMappingStrip(
|
|
282
|
+
template(source, {
|
|
283
|
+
cuttingDistance: 1_000,
|
|
284
|
+
leftExtend: 40,
|
|
285
|
+
rightExtend: 40,
|
|
286
|
+
stripMergedRegions: [[2, 3]],
|
|
287
|
+
}),
|
|
288
|
+
OPTIONS,
|
|
289
|
+
);
|
|
290
|
+
const expected = [
|
|
291
|
+
{ lng: 113.931277468103, lat: 22.5790284117379 },
|
|
292
|
+
{ lng: 113.931261027934, lat: 22.5791237956315 },
|
|
293
|
+
{ lng: 113.931244587743, lat: 22.5792191795235 },
|
|
294
|
+
{ lng: 113.931969360415, lat: 22.5792278822111 },
|
|
295
|
+
];
|
|
296
|
+
const actual = derived.flightRoutes.flatMap((route) => route.executablePath);
|
|
297
|
+
for (const point of expected) {
|
|
298
|
+
expect(Math.min(...actual.map((other) => haversineMeters(point, other)))).toBeLessThan(0.3);
|
|
299
|
+
}
|
|
300
|
+
expect(hasCrossing(derived.surveyArea)).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("limits turn damping to one third of the shorter adjacent leg as in FlightHub", () => {
|
|
304
|
+
const waylines = planMappingStrip(
|
|
305
|
+
fromMeters(
|
|
306
|
+
[
|
|
307
|
+
[0, 0],
|
|
308
|
+
[300, 0],
|
|
309
|
+
],
|
|
310
|
+
{ cuttingDistance: 1_000 },
|
|
311
|
+
),
|
|
312
|
+
OPTIONS,
|
|
313
|
+
);
|
|
314
|
+
const points = waylines.Folder[0]!.Placemark;
|
|
315
|
+
expect(Number(points[1]!.waypointTurnParam.waypointTurnDampingDist)).toBeCloseTo(9, 2);
|
|
316
|
+
expect(points[0]!.waypointTurnParam.waypointTurnMode).toBe(
|
|
317
|
+
"toPointAndStopWithDiscontinuityCurvature",
|
|
318
|
+
);
|
|
319
|
+
expect(points.at(-1)!.waypointTurnParam.waypointTurnMode).toBe(
|
|
320
|
+
"toPointAndStopWithDiscontinuityCurvature",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("rejects a complete reversal instead of emitting a crossed corridor", () => {
|
|
325
|
+
expect(() =>
|
|
326
|
+
deriveMappingStrip(
|
|
327
|
+
fromMeters([
|
|
328
|
+
[0, 0],
|
|
329
|
+
[300, 0],
|
|
330
|
+
[0, 0],
|
|
331
|
+
]),
|
|
332
|
+
OPTIONS,
|
|
333
|
+
),
|
|
334
|
+
).toThrow(PlannerError);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("rejects an inner join that runs past the available short legs", () => {
|
|
338
|
+
expect(() =>
|
|
339
|
+
deriveMappingStrip(
|
|
340
|
+
fromMeters([
|
|
341
|
+
[10, 0],
|
|
342
|
+
[0, 0],
|
|
343
|
+
[10, 2],
|
|
344
|
+
]),
|
|
345
|
+
OPTIONS,
|
|
346
|
+
),
|
|
347
|
+
).toThrow(PlannerError);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
2
|
-
import { degrees, meters, metersPerSecond } from "../types/branded.js";
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { degrees, meters, metersPerSecond, type LngLat } from "../types/branded.js";
|
|
3
3
|
import type { MappingStripTemplate } from "../types/template.js";
|
|
4
4
|
import { deriveMappingStrip } from "./plan-mapping-strip.js";
|
|
5
5
|
import { plan } from "./index.js";
|
|
6
|
-
import { haversineMeters } from "./geometry.js";
|
|
6
|
+
import { centroid, enuToLngLat, haversineMeters, lngLatToEnu, type EnuPoint } from "./geometry.js";
|
|
7
7
|
import { validate } from "../validate/index.js";
|
|
8
8
|
|
|
9
9
|
const missionConfig = {
|
|
@@ -538,3 +538,134 @@ describe("plan(mappingStrip)", () => {
|
|
|
538
538
|
expect(pitches(true)).toContain(-45);
|
|
539
539
|
});
|
|
540
540
|
});
|
|
541
|
+
|
|
542
|
+
const ORIGIN: LngLat = { lng: 118.35, lat: 35.05 };
|
|
543
|
+
|
|
544
|
+
function templateFromMeters(
|
|
545
|
+
points: readonly EnuPoint[],
|
|
546
|
+
cuttingDistance: number,
|
|
547
|
+
leftExtend = 20,
|
|
548
|
+
rightExtend = 20,
|
|
549
|
+
): MappingStripTemplate {
|
|
550
|
+
return stripTemplate({
|
|
551
|
+
cuttingDistance,
|
|
552
|
+
leftExtend,
|
|
553
|
+
rightExtend,
|
|
554
|
+
LineString: {
|
|
555
|
+
coordinates: points
|
|
556
|
+
.map((point) => {
|
|
557
|
+
const { lng, lat } = enuToLngLat(point, ORIGIN);
|
|
558
|
+
return `${lng},${lat},0`;
|
|
559
|
+
})
|
|
560
|
+
.join(" "),
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function ringArea(ring: readonly EnuPoint[]): number {
|
|
566
|
+
let doubleArea = 0;
|
|
567
|
+
for (let index = 0; index < ring.length; index++) {
|
|
568
|
+
const a = ring[index]!;
|
|
569
|
+
const b = ring[(index + 1) % ring.length]!;
|
|
570
|
+
doubleArea += a[0] * b[1] - b[0] * a[1];
|
|
571
|
+
}
|
|
572
|
+
return Math.abs(doubleArea) / 2;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function ringContains(ring: readonly EnuPoint[], [x, y]: EnuPoint): boolean {
|
|
576
|
+
let inside = false;
|
|
577
|
+
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
578
|
+
const a = ring[index]!;
|
|
579
|
+
const b = ring[previous]!;
|
|
580
|
+
if (a[1] > y !== b[1] > y && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0])
|
|
581
|
+
inside = !inside;
|
|
582
|
+
}
|
|
583
|
+
return inside;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function analyzeStrip(
|
|
587
|
+
points: readonly EnuPoint[],
|
|
588
|
+
cuttingDistance: number,
|
|
589
|
+
leftExtend = 20,
|
|
590
|
+
rightExtend = 20,
|
|
591
|
+
) {
|
|
592
|
+
const template = templateFromMeters(points, cuttingDistance, leftExtend, rightExtend);
|
|
593
|
+
const derived = deriveMappingStrip(template, options);
|
|
594
|
+
const tuples = template.Folder.Placemark.LineString.coordinates.split(/\s+/).map((tuple) => {
|
|
595
|
+
const [lng, lat] = tuple.split(",").map(Number) as [number, number];
|
|
596
|
+
return { lng, lat } as LngLat;
|
|
597
|
+
});
|
|
598
|
+
const origin = centroid(tuples);
|
|
599
|
+
return {
|
|
600
|
+
derived,
|
|
601
|
+
rings: derived.regions.map((region) =>
|
|
602
|
+
region.polygon.map((point) => lngLatToEnu(point, origin)),
|
|
603
|
+
),
|
|
604
|
+
corridor: derived.surveyArea.map((point) => lngLatToEnu(point, origin)),
|
|
605
|
+
centerLine: tuples.map((point) => lngLatToEnu(point, origin)),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function midpointProbes(centerLine: readonly EnuPoint[]): EnuPoint[] {
|
|
610
|
+
return centerLine.slice(1).flatMap((point, index) => {
|
|
611
|
+
const start = centerLine[index]!;
|
|
612
|
+
return [0.25, 0.5, 0.75].map(
|
|
613
|
+
(t) =>
|
|
614
|
+
[start[0] + (point[0] - start[0]) * t, start[1] + (point[1] - start[1]) * t] as EnuPoint,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
describe("mapping strip region tiling", () => {
|
|
620
|
+
it("cuts adjacent regions along the shared bisector seam", () => {
|
|
621
|
+
const { derived, rings, corridor, centerLine } = analyzeStrip(
|
|
622
|
+
[
|
|
623
|
+
[0, 0],
|
|
624
|
+
[300, 0],
|
|
625
|
+
[300, 300],
|
|
626
|
+
[600, 300],
|
|
627
|
+
],
|
|
628
|
+
350,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
expect(rings).toHaveLength(3);
|
|
632
|
+
const sum = rings.reduce((total, ring) => total + ringArea(ring), 0);
|
|
633
|
+
expect(sum).toBeCloseTo(ringArea(corridor), 6);
|
|
634
|
+
expect(derived.surveyAreaSize).toBeCloseTo(sum, 4);
|
|
635
|
+
for (const probe of midpointProbes(centerLine)) {
|
|
636
|
+
expect(rings.filter((ring) => ringContains(ring, probe))).toHaveLength(1);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("splits a fold-back corner into exactly one region per point", () => {
|
|
641
|
+
const { rings, centerLine } = analyzeStrip(
|
|
642
|
+
[
|
|
643
|
+
[0, 0],
|
|
644
|
+
[300, 0],
|
|
645
|
+
[300, 300],
|
|
646
|
+
[0, 300],
|
|
647
|
+
[220, 180],
|
|
648
|
+
],
|
|
649
|
+
350,
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
expect(rings).toHaveLength(4);
|
|
653
|
+
for (const probe of midpointProbes(centerLine)) {
|
|
654
|
+
expect(rings.filter((ring) => ringContains(ring, probe))).toHaveLength(1);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("keeps collinear synthetic cuts exact", () => {
|
|
659
|
+
const { rings, corridor } = analyzeStrip(
|
|
660
|
+
[
|
|
661
|
+
[0, 0],
|
|
662
|
+
[800, 0],
|
|
663
|
+
],
|
|
664
|
+
200,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
expect(rings).toHaveLength(4);
|
|
668
|
+
const sum = rings.reduce((total, ring) => total + ringArea(ring), 0);
|
|
669
|
+
expect(sum).toBeCloseTo(ringArea(corridor), 6);
|
|
670
|
+
});
|
|
671
|
+
});
|