@lumikmz/kmz 0.6.4 → 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.d.mts CHANGED
@@ -572,8 +572,19 @@ interface MegaphoneParam {
572
572
  megaphoneFileMd5: string;
573
573
  /** 1=8000, 2=16000, 3=24000, 4=32000, 5=48000, 6=64000 (only 4 supported today). */
574
574
  megaphoneFileBitrate: 1 | 2 | 3 | 4 | 5 | 6;
575
+ /** Non-spec DJI Pilot TTS params observed in M4D/M4TD megaphone actions. */
576
+ ttsparam?: MegaphoneTtsParam;
575
577
  }
576
578
  type MegaphoneAction = ActionBase<"megaphone", MegaphoneParam>;
579
+ interface MegaphoneTtsParam {
580
+ text: string;
581
+ role: "male" | "female";
582
+ language: "CN";
583
+ /** 0-100. */
584
+ volume: number;
585
+ /** 0-100. DJI Pilot exports commonly use 50. */
586
+ speed: number;
587
+ }
577
588
  /** M4D/M4TD only — starts gimbal swing in smart-oblique mode. */
578
589
  interface SmartObliquePoint {
579
590
  /** seconds before this pose starts; DJI exports currently use 0. */
@@ -943,4 +954,4 @@ declare function validate(doc: Template | Waylines): Issue[];
943
954
  /** Great-circle distance between two WGS-84 coordinates, in meters. */
944
955
  declare function haversineMeters(a: LngLat, b: LngLat): number;
945
956
  //#endregion
946
- export { AccurateShootAction, AccurateShootParam, Action, ActionActuatorFunc, ActionGroup, ActionGroupMode, ActionTrigger, ActionTriggerType, AircraftPathMode, AutoRerouteInfo, CoordinateMode, CustomDirNameAction, CustomDirNameParam, Degrees, DroneEnum, DroneInfo, ExecuteHeightMode, ExecuteRCLostAction, ExitOnRCLost, FinishAction, FlyToWaylineMode, FocusAction, FocusMode, FocusParam, GimbalAngleLockAction, GimbalAngleLockParam, GimbalAngleUnlockAction, GimbalEvenlyRotateAction, GimbalEvenlyRotateParam, GimbalHeadingYawBase, GimbalPitchMode, GimbalRotateAction, GimbalRotateMode, GimbalRotateParam, HoverAction, HoverParam, Issue, KmzError, LensType, LidarReturnMode, LidarScanningMode, LngLat, LngLatHeight, Mapping2dFolder, Mapping2dPlacemark, Mapping2dTemplate, Mapping3dFolder, Mapping3dPlacemark, Mapping3dTemplate, MappingHeadingMode, MappingHeadingParam, MappingStripFolder, MappingStripPlacemark, MappingStripTemplate, MegaphoneAction, MegaphoneParam, MeteringMode, Meters, MetersPerSecond, MissionConfig, OrientedPhotoMode, OrientedShootAction, OrientedShootParam, Overlap, PanoShotAction, PanoShotParam, PanoShotSubMode, PayloadEnum, PayloadInfo, PayloadParam, type PlanOptions, PlannerError, PositioningType, RecordPointCloudAction, RecordPointCloudOperate, RecordPointCloudParam, RotateYawAction, RotateYawParam, SearchlightAction, SearchlightParam, SetFocusTypeAction, SetFocusTypeParam, ShootType, SmartObliquePoint, StartActionGroup, StartRecordAction, StartRecordParam, StartSmartObliqueAction, StartSmartObliqueParam, StartTimeLapseAction, StartTimeLapseParam, StopRecordAction, StopRecordParam, StopSmartObliqueAction, StopSmartObliqueParam, StopTimeLapseAction, StopTimeLapseParam, TakePhotoAction, TakePhotoParam, Template, TemplateHeightMode, TemplatePolygon, TemplateType, TemplateWaypointPlacemark, WaylineCoordinateSysParam, Waylines, WaylinesFolder, WaylinesPlacemark, WaypointFolder, WaypointHeadingMode, WaypointHeadingParam, WaypointHeadingPathMode, WaypointTemplate, WaypointTurnMode, WaypointTurnParam, ZoomAction, ZoomParam, degrees, haversineMeters, meters, metersPerSecond, plan, validate };
957
+ export { AccurateShootAction, AccurateShootParam, Action, ActionActuatorFunc, ActionGroup, ActionGroupMode, ActionTrigger, ActionTriggerType, AircraftPathMode, AutoRerouteInfo, CoordinateMode, CustomDirNameAction, CustomDirNameParam, Degrees, DroneEnum, DroneInfo, ExecuteHeightMode, ExecuteRCLostAction, ExitOnRCLost, FinishAction, FlyToWaylineMode, FocusAction, FocusMode, FocusParam, GimbalAngleLockAction, GimbalAngleLockParam, GimbalAngleUnlockAction, GimbalEvenlyRotateAction, GimbalEvenlyRotateParam, GimbalHeadingYawBase, GimbalPitchMode, GimbalRotateAction, GimbalRotateMode, GimbalRotateParam, HoverAction, HoverParam, Issue, KmzError, LensType, LidarReturnMode, LidarScanningMode, LngLat, LngLatHeight, Mapping2dFolder, Mapping2dPlacemark, Mapping2dTemplate, Mapping3dFolder, Mapping3dPlacemark, Mapping3dTemplate, MappingHeadingMode, MappingHeadingParam, MappingStripFolder, MappingStripPlacemark, MappingStripTemplate, MegaphoneAction, MegaphoneParam, MegaphoneTtsParam, MeteringMode, Meters, MetersPerSecond, MissionConfig, OrientedPhotoMode, OrientedShootAction, OrientedShootParam, Overlap, PanoShotAction, PanoShotParam, PanoShotSubMode, PayloadEnum, PayloadInfo, PayloadParam, type PlanOptions, PlannerError, PositioningType, RecordPointCloudAction, RecordPointCloudOperate, RecordPointCloudParam, RotateYawAction, RotateYawParam, SearchlightAction, SearchlightParam, SetFocusTypeAction, SetFocusTypeParam, ShootType, SmartObliquePoint, StartActionGroup, StartRecordAction, StartRecordParam, StartSmartObliqueAction, StartSmartObliqueParam, StartTimeLapseAction, StartTimeLapseParam, StopRecordAction, StopRecordParam, StopSmartObliqueAction, StopSmartObliqueParam, StopTimeLapseAction, StopTimeLapseParam, TakePhotoAction, TakePhotoParam, Template, TemplateHeightMode, TemplatePolygon, TemplateType, TemplateWaypointPlacemark, WaylineCoordinateSysParam, Waylines, WaylinesFolder, WaylinesPlacemark, WaypointFolder, WaypointHeadingMode, WaypointHeadingParam, WaypointHeadingPathMode, WaypointTemplate, WaypointTurnMode, WaypointTurnParam, ZoomAction, ZoomParam, degrees, haversineMeters, meters, metersPerSecond, plan, validate };
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 alpha = Math.atan2(Math.cos(t), Math.sin(t));
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 scanPts = [];
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
- if (leftToRight) scanPts.push([a, y], [b, y]);
362
- else scanPts.push([b, y], [a, y]);
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 scanPts.map((p) => rotateEnu(p, alpha));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumikmz/kmz",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "DJI KMZ/WPML domain model + plan() algorithm + validate(), spec-aligned types mirroring docs/kmz/",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
  });
@@ -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 alpha = Math.atan2(Math.cos(t), Math.sin(t));
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 scanPts: EnuPoint[] = [];
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
- if (leftToRight) scanPts.push([a, y], [b, y]);
239
- else scanPts.push([b, y], [a, y]);
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
  }
@@ -249,9 +249,21 @@ export interface MegaphoneParam {
249
249
  megaphoneFileMd5: string;
250
250
  /** 1=8000, 2=16000, 3=24000, 4=32000, 5=48000, 6=64000 (only 4 supported today). */
251
251
  megaphoneFileBitrate: 1 | 2 | 3 | 4 | 5 | 6;
252
+ /** Non-spec DJI Pilot TTS params observed in M4D/M4TD megaphone actions. */
253
+ ttsparam?: MegaphoneTtsParam;
252
254
  }
253
255
  export type MegaphoneAction = ActionBase<"megaphone", MegaphoneParam>;
254
256
 
257
+ export interface MegaphoneTtsParam {
258
+ text: string;
259
+ role: "male" | "female";
260
+ language: "CN";
261
+ /** 0-100. */
262
+ volume: number;
263
+ /** 0-100. DJI Pilot exports commonly use 50. */
264
+ speed: number;
265
+ }
266
+
255
267
  /** M4D/M4TD only — starts gimbal swing in smart-oblique mode. */
256
268
  export interface SmartObliquePoint {
257
269
  /** seconds before this pose starts; DJI exports currently use 0. */