@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
2
|
import { degrees, meters, metersPerSecond } from "../types/branded.js";
|
|
3
3
|
import type { TargetDetectionTemplate } from "../types/template.js";
|
|
4
4
|
import { plan } from "./index.js";
|
|
@@ -54,6 +54,49 @@ function template(overrides: { pitch?: number; direction?: number } = {}): Targe
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
describe("plan(targetdetection)", () => {
|
|
57
|
+
it("matches FlightHub recording and target-detection actions without stopRecord", () => {
|
|
58
|
+
const input = template();
|
|
59
|
+
Object.assign(input.Folder.Placemark, {
|
|
60
|
+
recordEnable: true,
|
|
61
|
+
targetDetectionActionEnable: true,
|
|
62
|
+
action: {
|
|
63
|
+
actionId: 0,
|
|
64
|
+
actionActuatorFunc: "targetDetection",
|
|
65
|
+
actionActuatorFuncParam: {
|
|
66
|
+
focalLength: 70,
|
|
67
|
+
payloadPositionIndex: 0,
|
|
68
|
+
useGlobalPayloadLensIndex: false,
|
|
69
|
+
payloadLensIndex: ["visable"],
|
|
70
|
+
actionUUID: "test-target-detection",
|
|
71
|
+
customTitle: "巡逻",
|
|
72
|
+
customText: "检测到异常目标",
|
|
73
|
+
modelIndex: 0,
|
|
74
|
+
modelScore: 55,
|
|
75
|
+
alarmInterval: 2,
|
|
76
|
+
useThirdModel: false,
|
|
77
|
+
algorithmType: "visable_ai",
|
|
78
|
+
targetParam: {
|
|
79
|
+
maxThreshold: 10,
|
|
80
|
+
minThreshold: 10,
|
|
81
|
+
useMaxThreshold: false,
|
|
82
|
+
useMinThreshold: true,
|
|
83
|
+
targetType: "person",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const folder = plan(input, { lineSpacing: meters(25) }).Folder[0]!;
|
|
90
|
+
const startFuncs = folder.startActionGroup!.action.map((a) => a.actionActuatorFunc);
|
|
91
|
+
const routeFuncs = folder.Placemark[0]!.actionGroup![0]!.action.map(
|
|
92
|
+
(a) => a.actionActuatorFunc,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(startFuncs).toEqual(["gimbalRotate", "startRecord"]);
|
|
96
|
+
expect(routeFuncs).toEqual(["gimbalRotate", "startRecord", "targetDetection"]);
|
|
97
|
+
expect(JSON.stringify(folder)).not.toContain("stopRecord");
|
|
98
|
+
});
|
|
99
|
+
|
|
57
100
|
it("centers floor(width / spacing) scan lines", () => {
|
|
58
101
|
const waylines = plan(template(), { lineSpacing: meters(25) });
|
|
59
102
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
|
|
2
|
+
import type { Action } from "../types/action.js";
|
|
2
3
|
import { PlannerError } from "../types/errors.js";
|
|
3
4
|
import type { ExecuteHeightMode } from "../types/enums.js";
|
|
4
5
|
import type { WaylinesPlacemark } from "../types/placemark.js";
|
|
@@ -59,6 +60,18 @@ function gimbalAction(pitch: number) {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function startRecordAction(payloadPositionIndex: number, actionId: number) {
|
|
64
|
+
return {
|
|
65
|
+
actionId,
|
|
66
|
+
actionActuatorFunc: "startRecord" as const,
|
|
67
|
+
actionActuatorFuncParam: {
|
|
68
|
+
payloadPositionIndex,
|
|
69
|
+
useGlobalPayloadLensIndex: true,
|
|
70
|
+
payloadLensIndex: ["visable" as const],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
function pathPoints(
|
|
63
76
|
template: TargetDetectionTemplate,
|
|
64
77
|
lineSpacingM: number,
|
|
@@ -143,6 +156,14 @@ export function planTargetDetection(
|
|
|
143
156
|
if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
|
|
144
157
|
const lastIndex = points.length - 1;
|
|
145
158
|
const action = gimbalAction(pitch);
|
|
159
|
+
const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
|
|
160
|
+
const routeActions: Action[] = [action];
|
|
161
|
+
if (folder.Placemark.recordEnable) {
|
|
162
|
+
routeActions.push(startRecordAction(payloadPositionIndex, routeActions.length));
|
|
163
|
+
}
|
|
164
|
+
if (folder.Placemark.action) {
|
|
165
|
+
routeActions.push({ ...folder.Placemark.action, actionId: routeActions.length });
|
|
166
|
+
}
|
|
146
167
|
|
|
147
168
|
const placemarks: WaylinesPlacemark[] = points.map((point, index) => ({
|
|
148
169
|
Point: { coordinates: point },
|
|
@@ -185,7 +206,7 @@ export function planTargetDetection(
|
|
|
185
206
|
actionGroupEndIndex: lastIndex,
|
|
186
207
|
actionGroupMode: "sequence",
|
|
187
208
|
actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
|
|
188
|
-
action:
|
|
209
|
+
action: routeActions,
|
|
189
210
|
},
|
|
190
211
|
],
|
|
191
212
|
}
|
|
@@ -200,7 +221,11 @@ export function planTargetDetection(
|
|
|
200
221
|
waylineId: 0,
|
|
201
222
|
executeHeightMode: executeHeightMode(folder),
|
|
202
223
|
autoFlightSpeed: folder.autoFlightSpeed,
|
|
203
|
-
startActionGroup: {
|
|
224
|
+
startActionGroup: {
|
|
225
|
+
action: folder.Placemark.recordEnable
|
|
226
|
+
? [gimbalAction(pitch), startRecordAction(payloadPositionIndex, 1)]
|
|
227
|
+
: [gimbalAction(pitch)],
|
|
228
|
+
},
|
|
204
229
|
Placemark: placemarks,
|
|
205
230
|
},
|
|
206
231
|
],
|
package/src/plan/plan.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
2
|
import { degrees, meters, metersPerSecond } from "../types/branded.js";
|
|
3
3
|
import { DroneEnum, PayloadEnum } from "../types/drone-payload.js";
|
|
4
4
|
import type { Mapping2dTemplate, Mapping3dTemplate, WaypointTemplate } from "../types/template.js";
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { selectStripRouteCandidates, type StripRouteCandidate } from "./strip-route-order.js";
|
|
3
|
+
import * as stripOrder from "./strip-route-order.js";
|
|
4
|
+
|
|
5
|
+
function route(patch: Partial<StripRouteCandidate> = {}): StripRouteCandidate {
|
|
6
|
+
return {
|
|
7
|
+
start: [0, 0],
|
|
8
|
+
end: [10, 0],
|
|
9
|
+
entryDirection: [1, 0],
|
|
10
|
+
exitDirection: [1, 0],
|
|
11
|
+
length: 10,
|
|
12
|
+
...patch,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("strip region transition ordering", () => {
|
|
17
|
+
it("chooses a slightly longer transfer when it avoids reversing the entry heading", () => {
|
|
18
|
+
const previous = route({ end: [0, 0] });
|
|
19
|
+
const reverse = route({ start: [10, 0], entryDirection: [-1, 0] });
|
|
20
|
+
const aligned = route({ start: [12, 0] });
|
|
21
|
+
expect(selectStripRouteCandidates([[previous], [reverse, aligned]])).toEqual([0, 1]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("prefers approaching the shared junction over cutting across a bent strip", () => {
|
|
25
|
+
const diagonal = Math.SQRT1_2;
|
|
26
|
+
const previous = route({ end: [0, 0], exitDirection: [diagonal, diagonal] });
|
|
27
|
+
const across = route({ start: [10, 0], entryDirection: [1, 0] });
|
|
28
|
+
const along = route({ start: [0, 12], entryDirection: [0, 1] });
|
|
29
|
+
expect(selectStripRouteCandidates([[previous], [across, along]], undefined, [[0, 10]])).toEqual(
|
|
30
|
+
[0, 1],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("includes the candidate's internal flight length", () => {
|
|
35
|
+
expect(selectStripRouteCandidates([[route({ length: 100 }), route({ length: 10 })]])).toEqual([
|
|
36
|
+
1,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps heading preferences soft when the aligned transfer would be much longer", () => {
|
|
41
|
+
const previous = route({ end: [0, 0] });
|
|
42
|
+
expect(
|
|
43
|
+
selectStripRouteCandidates([
|
|
44
|
+
[previous],
|
|
45
|
+
[route({ start: [10, 0], entryDirection: [-1, 0] }), route({ start: [100, 0] })],
|
|
46
|
+
]),
|
|
47
|
+
).toEqual([0, 0]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses the exit and entry headings even when the endpoints coincide", () => {
|
|
51
|
+
const previous = route({ end: [0, 0] });
|
|
52
|
+
expect(
|
|
53
|
+
selectStripRouteCandidates([[previous], [route({ entryDirection: [-1, 0] }), route()]]),
|
|
54
|
+
).toEqual([0, 1]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("optimizes the entire sequence instead of taking the locally closest start", () => {
|
|
58
|
+
const first = route({ end: [0, 0] });
|
|
59
|
+
const near = route({ start: [1, 0], end: [100, 0] });
|
|
60
|
+
const connected = route({ start: [2, 0], end: [5, 0] });
|
|
61
|
+
const last = route({ start: [6, 0] });
|
|
62
|
+
expect(selectStripRouteCandidates([[first], [near, connected], [last]])).toEqual([0, 1, 0]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses the takeoff point when selecting the first orientation", () => {
|
|
66
|
+
expect(selectStripRouteCandidates([[route({ start: [100, 0] }), route()]], [0, 0])).toEqual([
|
|
67
|
+
1,
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles coincident region endpoints without undefined directions", () => {
|
|
72
|
+
const groups = [[route({ end: [0, 0] })], [route(), route()]];
|
|
73
|
+
expect(selectStripRouteCandidates(groups, undefined, [[0, 0]])).toEqual([0, 0]);
|
|
74
|
+
expect(selectStripRouteCandidates([])).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("includes the final home leg only when the finish action requests it", () => {
|
|
78
|
+
const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
|
|
79
|
+
expect(
|
|
80
|
+
selectStripRouteCandidates(groups, [0, 0], [], {
|
|
81
|
+
flightSpeed: 1,
|
|
82
|
+
transitSpeed: 1,
|
|
83
|
+
finishAction: "goHome",
|
|
84
|
+
}),
|
|
85
|
+
).toEqual([1]);
|
|
86
|
+
for (const finishAction of ["noAction", "autoLand"] as const) {
|
|
87
|
+
expect(
|
|
88
|
+
selectStripRouteCandidates(groups, [0, 0], [], {
|
|
89
|
+
flightSpeed: 1,
|
|
90
|
+
transitSpeed: 1,
|
|
91
|
+
finishAction,
|
|
92
|
+
}),
|
|
93
|
+
).toEqual([0]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses different survey and transit speeds instead of minimizing distance", () => {
|
|
98
|
+
const groups = [
|
|
99
|
+
[route({ end: [0, 0], length: 200 }), route({ start: [100, 0], end: [200, 0], length: 100 })],
|
|
100
|
+
];
|
|
101
|
+
expect(
|
|
102
|
+
selectStripRouteCandidates(groups, [0, 0], [], {
|
|
103
|
+
flightSpeed: 1,
|
|
104
|
+
transitSpeed: 10,
|
|
105
|
+
finishAction: "noAction",
|
|
106
|
+
}),
|
|
107
|
+
).toEqual([1]);
|
|
108
|
+
expect(
|
|
109
|
+
selectStripRouteCandidates(groups, [0, 0], [], {
|
|
110
|
+
flightSpeed: 1,
|
|
111
|
+
transitSpeed: 1,
|
|
112
|
+
finishAction: "noAction",
|
|
113
|
+
}),
|
|
114
|
+
).toEqual([0]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("prefers the nearer entry when total flying times differ by at most two seconds", () => {
|
|
118
|
+
expect(
|
|
119
|
+
selectStripRouteCandidates(
|
|
120
|
+
[
|
|
121
|
+
[
|
|
122
|
+
route({ start: [100, 0], end: [200, 0], length: 100 }),
|
|
123
|
+
route({ start: [0, 0], end: [210, 0], length: 210 }),
|
|
124
|
+
],
|
|
125
|
+
],
|
|
126
|
+
[0, 0],
|
|
127
|
+
[],
|
|
128
|
+
{ flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
|
|
129
|
+
),
|
|
130
|
+
).toEqual([1]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not sacrifice a substantial time saving just to start closer", () => {
|
|
134
|
+
expect(
|
|
135
|
+
selectStripRouteCandidates(
|
|
136
|
+
[
|
|
137
|
+
[
|
|
138
|
+
route({ start: [100, 0], end: [200, 0], length: 100 }),
|
|
139
|
+
route({ start: [0, 0], end: [250, 0], length: 250 }),
|
|
140
|
+
],
|
|
141
|
+
],
|
|
142
|
+
[0, 0],
|
|
143
|
+
[],
|
|
144
|
+
{ flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
|
|
145
|
+
),
|
|
146
|
+
).toEqual([0]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("retains the nearer first entry after both paths reach the same later candidates", () => {
|
|
150
|
+
const groups = [
|
|
151
|
+
[
|
|
152
|
+
route({
|
|
153
|
+
start: [100, 0],
|
|
154
|
+
end: [0, 0],
|
|
155
|
+
entryDirection: [-1, 0],
|
|
156
|
+
exitDirection: [-1, 0],
|
|
157
|
+
length: 100,
|
|
158
|
+
}),
|
|
159
|
+
route({ end: [0, 0], length: 120 }),
|
|
160
|
+
],
|
|
161
|
+
[route({ start: [0, 0], end: [100, 0], length: 100 })],
|
|
162
|
+
[route({ start: [100, 0], end: [200, 0], length: 100 })],
|
|
163
|
+
];
|
|
164
|
+
expect(
|
|
165
|
+
selectStripRouteCandidates(groups, [0, 0], [], {
|
|
166
|
+
flightSpeed: 10,
|
|
167
|
+
transitSpeed: 100,
|
|
168
|
+
finishAction: "goHome",
|
|
169
|
+
}),
|
|
170
|
+
).toEqual([1, 0, 0]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("applies the two-second tolerance once to the complete mission, not once per region", () => {
|
|
174
|
+
const groups = [[route({ end: [0, 0], length: 100 })]];
|
|
175
|
+
for (let index = 0; index < 4; index++)
|
|
176
|
+
groups.push([
|
|
177
|
+
route({ end: [0, 0], length: 100, entryDirection: [-1, 0], exitDirection: [-1, 0] }),
|
|
178
|
+
route({ end: [0, 0], length: 101.5 }),
|
|
179
|
+
]);
|
|
180
|
+
const selected = selectStripRouteCandidates(groups);
|
|
181
|
+
const duration = groups.reduce((sum, group, index) => sum + group[selected[index]!]!.length, 0);
|
|
182
|
+
expect(duration).toBeLessThanOrEqual(502);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns to the first waypoint without needing a takeoff reference", () => {
|
|
186
|
+
const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
|
|
187
|
+
expect(
|
|
188
|
+
selectStripRouteCandidates(groups, undefined, [], {
|
|
189
|
+
flightSpeed: 1,
|
|
190
|
+
transitSpeed: 1,
|
|
191
|
+
finishAction: "gotoFirstWaypoint",
|
|
192
|
+
}),
|
|
193
|
+
).toEqual([1]);
|
|
194
|
+
expect(
|
|
195
|
+
selectStripRouteCandidates(groups, undefined, [], {
|
|
196
|
+
flightSpeed: 1,
|
|
197
|
+
transitSpeed: 1,
|
|
198
|
+
finishAction: "goHome",
|
|
199
|
+
}),
|
|
200
|
+
).toEqual([0]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("measures survey, ingress, between-region and finish legs with their respective speeds", () => {
|
|
204
|
+
const routes = [
|
|
205
|
+
route({ start: [10, 0], end: [30, 0], length: 20 }),
|
|
206
|
+
route({ start: [50, 0], end: [80, 0], length: 30 }),
|
|
207
|
+
];
|
|
208
|
+
for (const [finishAction, distance, duration] of [
|
|
209
|
+
["goHome", 160, 10.5],
|
|
210
|
+
["noAction", 80, 6.5],
|
|
211
|
+
["gotoFirstWaypoint", 150, 10],
|
|
212
|
+
] as const) {
|
|
213
|
+
expect(
|
|
214
|
+
stripOrder.measureStripMission(routes, [0, 0], {
|
|
215
|
+
flightSpeed: 10,
|
|
216
|
+
transitSpeed: 20,
|
|
217
|
+
finishAction,
|
|
218
|
+
}),
|
|
219
|
+
).toMatchObject({ distance, duration });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects invalid speeds instead of producing non-finite costs", () => {
|
|
224
|
+
for (const speed of [0, -1, NaN, Infinity]) {
|
|
225
|
+
expect(() =>
|
|
226
|
+
selectStripRouteCandidates([[route()]], [0, 0], [], {
|
|
227
|
+
flightSpeed: speed,
|
|
228
|
+
transitSpeed: 10,
|
|
229
|
+
finishAction: "goHome",
|
|
230
|
+
}),
|
|
231
|
+
).toThrow();
|
|
232
|
+
expect(() =>
|
|
233
|
+
selectStripRouteCandidates([[route()]], [0, 0], [], {
|
|
234
|
+
flightSpeed: 10,
|
|
235
|
+
transitSpeed: speed,
|
|
236
|
+
finishAction: "goHome",
|
|
237
|
+
}),
|
|
238
|
+
).toThrow();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { EnuPoint } from "./geometry.js";
|
|
2
|
+
import type { FinishAction } from "../types/enums.js";
|
|
3
|
+
import { PlannerError } from "../types/errors.js";
|
|
4
|
+
|
|
5
|
+
// Secondary route-shape preferences, never added to the estimated flying time.
|
|
6
|
+
// A complete reversal costs 20 preference units; junction detours remain soft.
|
|
7
|
+
const HEADING_COST_M = 10;
|
|
8
|
+
const JUNCTION_DETOUR_WEIGHT = 0.5;
|
|
9
|
+
const NEAR_EQUAL_TIME_S = 2;
|
|
10
|
+
const TIME_EPS_S = 1e-8;
|
|
11
|
+
|
|
12
|
+
const distance = (a: EnuPoint, b: EnuPoint) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
13
|
+
const headingChange = (a: EnuPoint, b: EnuPoint) =>
|
|
14
|
+
1 - Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1]));
|
|
15
|
+
|
|
16
|
+
export interface StripRouteCandidate {
|
|
17
|
+
start: EnuPoint;
|
|
18
|
+
end: EnuPoint;
|
|
19
|
+
entryDirection: EnuPoint;
|
|
20
|
+
exitDirection: EnuPoint;
|
|
21
|
+
length: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StripRouteTiming {
|
|
25
|
+
flightSpeed: number;
|
|
26
|
+
transitSpeed: number;
|
|
27
|
+
finishAction: FinishAction;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMING: StripRouteTiming = {
|
|
31
|
+
flightSpeed: 1,
|
|
32
|
+
transitSpeed: 1,
|
|
33
|
+
finishAction: "noAction",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function validateTiming(timing: StripRouteTiming): void {
|
|
37
|
+
if (
|
|
38
|
+
![timing.flightSpeed, timing.transitSpeed].every((speed) => Number.isFinite(speed) && speed > 0)
|
|
39
|
+
)
|
|
40
|
+
throw new PlannerError(
|
|
41
|
+
"mappingStrip flight and transit speeds must be finite and greater than zero",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function finishDistance(
|
|
46
|
+
end: EnuPoint,
|
|
47
|
+
first: EnuPoint,
|
|
48
|
+
home: EnuPoint | undefined,
|
|
49
|
+
finish: FinishAction,
|
|
50
|
+
): number {
|
|
51
|
+
const target = finish === "goHome" ? home : finish === "gotoFirstWaypoint" ? first : undefined;
|
|
52
|
+
return target ? distance(end, target) : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Horizontal-flight estimate; vertical motion, wind and acceleration are not modeled. */
|
|
56
|
+
export function measureStripMission(
|
|
57
|
+
routes: readonly Pick<StripRouteCandidate, "start" | "end" | "length">[],
|
|
58
|
+
home: EnuPoint | undefined,
|
|
59
|
+
timing: StripRouteTiming,
|
|
60
|
+
): { distance: number; duration: number } {
|
|
61
|
+
validateTiming(timing);
|
|
62
|
+
if (routes.length === 0) return { distance: 0, duration: 0 };
|
|
63
|
+
const first = routes[0]!.start;
|
|
64
|
+
const workLength = routes.reduce((sum, route) => sum + route.length, 0);
|
|
65
|
+
let transitLength = home ? distance(home, first) : 0;
|
|
66
|
+
for (let index = 1; index < routes.length; index++)
|
|
67
|
+
transitLength += distance(routes[index - 1]!.end, routes[index]!.start);
|
|
68
|
+
transitLength += finishDistance(routes.at(-1)!.end, first, home, timing.finishAction);
|
|
69
|
+
return {
|
|
70
|
+
distance: workLength + transitLength,
|
|
71
|
+
duration: workLength / timing.flightSpeed + transitLength / timing.transitSpeed,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transitionPreference(
|
|
76
|
+
previous: StripRouteCandidate,
|
|
77
|
+
next: StripRouteCandidate,
|
|
78
|
+
junction?: EnuPoint,
|
|
79
|
+
): number {
|
|
80
|
+
const length = distance(previous.end, next.start);
|
|
81
|
+
const direction: EnuPoint | undefined =
|
|
82
|
+
length > 0.05
|
|
83
|
+
? [(next.start[0] - previous.end[0]) / length, (next.start[1] - previous.end[1]) / length]
|
|
84
|
+
: undefined;
|
|
85
|
+
const turn = direction
|
|
86
|
+
? headingChange(previous.exitDirection, direction) +
|
|
87
|
+
headingChange(direction, next.entryDirection)
|
|
88
|
+
: headingChange(previous.exitDirection, next.entryDirection);
|
|
89
|
+
const detour = junction
|
|
90
|
+
? Math.max(0, distance(previous.end, junction) + distance(junction, next.start) - length)
|
|
91
|
+
: 0;
|
|
92
|
+
return HEADING_COST_M * turn + JUNCTION_DETOUR_WEIGHT * detour;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface FinalChoice {
|
|
96
|
+
lastIndex: number;
|
|
97
|
+
time: number;
|
|
98
|
+
preference: number;
|
|
99
|
+
ingress: number;
|
|
100
|
+
back: number[][];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Optimize mission time, then favor nearer entries within a two-second total budget. */
|
|
104
|
+
export function selectStripRouteCandidates(
|
|
105
|
+
groups: readonly (readonly StripRouteCandidate[])[],
|
|
106
|
+
home?: EnuPoint,
|
|
107
|
+
junctions: readonly EnuPoint[] = [],
|
|
108
|
+
timing: StripRouteTiming = DEFAULT_TIMING,
|
|
109
|
+
): number[] {
|
|
110
|
+
if (groups.length === 0) return [];
|
|
111
|
+
validateTiming(timing);
|
|
112
|
+
if (groups.some((group) => group.length === 0))
|
|
113
|
+
throw new PlannerError("mappingStrip region has no route candidates");
|
|
114
|
+
const finals: FinalChoice[] = [];
|
|
115
|
+
|
|
116
|
+
// Keep each possible first entry separate. Otherwise a local near-equal-time
|
|
117
|
+
// tie can discard the globally fastest path or accumulate a tolerance per cut.
|
|
118
|
+
groups[0]!.forEach((first, firstIndex) => {
|
|
119
|
+
const ingress = home ? distance(home, first.start) : 0;
|
|
120
|
+
let times = groups[0]!.map((candidate, index) =>
|
|
121
|
+
index === firstIndex
|
|
122
|
+
? candidate.length / timing.flightSpeed + ingress / timing.transitSpeed
|
|
123
|
+
: Infinity,
|
|
124
|
+
);
|
|
125
|
+
let preferences = groups[0]!.map((_, index) => (index === firstIndex ? 0 : Infinity));
|
|
126
|
+
const back = [groups[0]!.map(() => -1)];
|
|
127
|
+
for (let index = 1; index < groups.length; index++) {
|
|
128
|
+
const parents: number[] = [];
|
|
129
|
+
const nextPreferences: number[] = [];
|
|
130
|
+
const nextTimes = groups[index]!.map((candidate) => {
|
|
131
|
+
let bestTime = Infinity;
|
|
132
|
+
let bestPreference = Infinity;
|
|
133
|
+
let parent = 0;
|
|
134
|
+
groups[index - 1]!.forEach((previous, previousIndex) => {
|
|
135
|
+
const time =
|
|
136
|
+
times[previousIndex]! +
|
|
137
|
+
candidate.length / timing.flightSpeed +
|
|
138
|
+
distance(previous.end, candidate.start) / timing.transitSpeed;
|
|
139
|
+
const preference =
|
|
140
|
+
preferences[previousIndex]! +
|
|
141
|
+
transitionPreference(previous, candidate, junctions[index - 1]);
|
|
142
|
+
if (
|
|
143
|
+
time < bestTime - TIME_EPS_S ||
|
|
144
|
+
(Math.abs(time - bestTime) <= TIME_EPS_S && preference < bestPreference)
|
|
145
|
+
) {
|
|
146
|
+
bestTime = time;
|
|
147
|
+
bestPreference = preference;
|
|
148
|
+
parent = previousIndex;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
parents.push(parent);
|
|
152
|
+
nextPreferences.push(bestPreference);
|
|
153
|
+
return bestTime;
|
|
154
|
+
});
|
|
155
|
+
times = nextTimes;
|
|
156
|
+
preferences = nextPreferences;
|
|
157
|
+
back.push(parents);
|
|
158
|
+
}
|
|
159
|
+
groups.at(-1)!.forEach((last, lastIndex) => {
|
|
160
|
+
if (!Number.isFinite(times[lastIndex])) return;
|
|
161
|
+
finals.push({
|
|
162
|
+
lastIndex,
|
|
163
|
+
ingress,
|
|
164
|
+
back,
|
|
165
|
+
time:
|
|
166
|
+
times[lastIndex]! +
|
|
167
|
+
finishDistance(last.end, first.start, home, timing.finishAction) / timing.transitSpeed,
|
|
168
|
+
preference: preferences[lastIndex]!,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
const fastest = Math.min(...finals.map((choice) => choice.time));
|
|
173
|
+
const close = finals.filter((choice) => choice.time <= fastest + NEAR_EQUAL_TIME_S + TIME_EPS_S);
|
|
174
|
+
const chosen = close.reduce((best, candidate) => {
|
|
175
|
+
if (candidate.ingress < best.ingress - 0.05) return candidate;
|
|
176
|
+
if (Math.abs(candidate.ingress - best.ingress) > 0.05) return best;
|
|
177
|
+
if (candidate.preference < best.preference - TIME_EPS_S) return candidate;
|
|
178
|
+
if (
|
|
179
|
+
Math.abs(candidate.preference - best.preference) <= TIME_EPS_S &&
|
|
180
|
+
candidate.time < best.time
|
|
181
|
+
)
|
|
182
|
+
return candidate;
|
|
183
|
+
return best;
|
|
184
|
+
});
|
|
185
|
+
let selected = chosen.lastIndex;
|
|
186
|
+
const result = groups.map(() => 0);
|
|
187
|
+
for (let index = groups.length - 1; index >= 0; index--) {
|
|
188
|
+
result[index] = selected;
|
|
189
|
+
selected = chosen.back[index]![selected]!;
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
package/src/types/action.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface ActionTrigger {
|
|
|
34
34
|
|
|
35
35
|
/** Discriminated by `actionActuatorFunc`. */
|
|
36
36
|
export type Action =
|
|
37
|
+
| TargetDetectionAction
|
|
37
38
|
| TakePhotoAction
|
|
38
39
|
| StartRecordAction
|
|
39
40
|
| StopRecordAction
|
|
@@ -64,6 +65,31 @@ interface ActionBase<F extends string, P> {
|
|
|
64
65
|
actionActuatorFuncParam: P;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export interface TargetDetectionParam {
|
|
69
|
+
focalLength?: number;
|
|
70
|
+
payloadPositionIndex: number;
|
|
71
|
+
useGlobalPayloadLensIndex: boolean;
|
|
72
|
+
payloadLensIndex: LensType[];
|
|
73
|
+
actionUUID: string;
|
|
74
|
+
irGainMode?: string;
|
|
75
|
+
irColorPalette?: string;
|
|
76
|
+
customTitle: string;
|
|
77
|
+
customText: string;
|
|
78
|
+
modelIndex: number;
|
|
79
|
+
modelScore: number;
|
|
80
|
+
alarmInterval: number;
|
|
81
|
+
useThirdModel: boolean;
|
|
82
|
+
algorithmType: string;
|
|
83
|
+
targetParam: {
|
|
84
|
+
maxThreshold: number;
|
|
85
|
+
minThreshold: number;
|
|
86
|
+
useMaxThreshold: boolean;
|
|
87
|
+
useMinThreshold: boolean;
|
|
88
|
+
targetType: string;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export type TargetDetectionAction = ActionBase<"targetDetection", TargetDetectionParam>;
|
|
92
|
+
|
|
67
93
|
// ─── Action params (one type per actionActuatorFunc) ─────────────────────────
|
|
68
94
|
|
|
69
95
|
export interface TakePhotoParam {
|
|
@@ -15,6 +15,8 @@ export interface PayloadInfo {
|
|
|
15
15
|
*/
|
|
16
16
|
payloadSubEnumValue?: number;
|
|
17
17
|
payloadPositionIndex: number;
|
|
18
|
+
/** FlightHub 2 patrol-camera selection, in 35mm-equivalent millimeters. */
|
|
19
|
+
equivalentFocalLength?: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** Aircraft `{ droneEnumValue, droneSubEnumValue }` tuples. */
|
package/src/types/enums.ts
CHANGED
package/src/types/placemark.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Degrees, LngLat, Meters, MetersPerSecond } from "./branded.js";
|
|
2
|
-
import type { ActionGroup } from "./action.js";
|
|
2
|
+
import type { ActionGroup, TargetDetectionAction } from "./action.js";
|
|
3
3
|
import type { ExecuteHeightMode, ShootType } from "./enums.js";
|
|
4
4
|
import type {
|
|
5
5
|
MappingHeadingParam,
|
|
@@ -127,6 +127,9 @@ export interface TargetDetectionPlacemark {
|
|
|
127
127
|
mappingHeadingParam: MappingHeadingParam;
|
|
128
128
|
gimbalPitchAngle: Degrees;
|
|
129
129
|
recordEnable: boolean;
|
|
130
|
+
/** FlightHub 2 always emits this action for patrol-area routes. */
|
|
131
|
+
targetDetectionActionEnable?: boolean;
|
|
132
|
+
action?: TargetDetectionAction;
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
|
package/vite.config.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { defineConfig } from "vite-plus";
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
// Vitest v4 compatibility: preserve mock call history.
|
|
6
|
+
// Remove after tests no longer rely on calls from setup or earlier tests.
|
|
7
|
+
// https://viteplus.dev/guide/vitest-v5#remove-unneeded-compatibility-settings
|
|
8
|
+
// https://vitest.dev/guide/migration/#clearmocks-is-enabled-by-default
|
|
9
|
+
clearMocks: false,
|
|
10
|
+
},
|
|
4
11
|
pack: {
|
|
5
12
|
entry: ["src/index.ts"],
|
|
6
13
|
format: "esm",
|