From ef58d663b57e43d781449fa4acdc809b4a111734 Mon Sep 17 00:00:00 2001 From: menghao <1584479611@qq.com> Date: Fri, 13 Mar 2026 08:39:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=98=E6=97=8B=E5=9C=86=E3=80=81=E6=A4=AD?= =?UTF-8?q?=E5=9C=86=E8=BD=A8=E8=BF=B9=EF=BC=8C=E6=97=B6=E9=97=B4=E8=BD=B4?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E5=B1=95=E7=A4=BA=E6=97=B6=E9=97=B4=EF=BC=8C?= =?UTF-8?q?=E9=A1=BA=E6=97=B6=E9=92=88=E9=80=86=E6=97=B6=E9=92=88=E6=96=B9?= =?UTF-8?q?=E5=90=91=E4=BF=AE=E6=AD=A3=EF=BC=8C=E5=AE=9A=E6=97=B6=E5=AE=9A?= =?UTF-8?q?=E9=80=9F=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ruoyi/system/domain/RouteWaypoints.java | 27 + .../ruoyi/system/domain/WaypointDisplayStyle.java | 30 + .../system/route_waypoints_display_style_json.sql | 27 - ruoyi-ui/src/views/cesiumMap/index.vue | 839 ++++++++++++++++----- ruoyi-ui/src/views/childRoom/index.vue | 528 ++++++++++--- ruoyi-ui/src/views/dialogs/RouteEditDialog.vue | 106 ++- ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue | 174 +++-- 7 files changed, 1352 insertions(+), 379 deletions(-) delete mode 100644 ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java index eca27b8..298f6d0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java @@ -245,6 +245,33 @@ public class RouteWaypoints extends BaseEntity return displayStyle != null && displayStyle.getOutlineColor() != null ? displayStyle.getOutlineColor() : "#000000"; } + /** 航段模式:fixed_speed-定速,fixed_time-定时,空或null-普通 */ + public String getSegmentMode() { + return displayStyle != null ? displayStyle.getSegmentMode() : null; + } + + public void setSegmentMode(String segmentMode) { + getOrCreateDisplayStyle().setSegmentMode(segmentMode); + } + + /** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时,可与相对K时分离支持“定时到达+盘旋至相对K时出发” */ + public Double getSegmentTargetMinutes() { + return displayStyle != null ? displayStyle.getSegmentTargetMinutes() : null; + } + + public void setSegmentTargetMinutes(Double segmentTargetMinutes) { + getOrCreateDisplayStyle().setSegmentTargetMinutes(segmentTargetMinutes); + } + + /** 定速目标(km/h):fixed_speed 时表示本航段固定速度 */ + public Double getSegmentTargetSpeed() { + return displayStyle != null ? displayStyle.getSegmentTargetSpeed() : null; + } + + public void setSegmentTargetSpeed(Double segmentTargetSpeed) { + getOrCreateDisplayStyle().setSegmentTargetSpeed(segmentTargetSpeed); + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java index 7371896..e7433f0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java @@ -19,6 +19,12 @@ public class WaypointDisplayStyle implements Serializable { private Integer pixelSize; /** 航点圆点边框颜色,默认 #000000 */ private String outlineColor; + /** 航段模式:fixed_speed-定速(移动下一航点改下一航点相对K时), fixed_time-定时(移动本航点改上一航点速度), 空或null-普通 */ + private String segmentMode; + /** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时(分),与相对K时分离后可支持“定时到达+盘旋至相对K时出发” */ + private Double segmentTargetMinutes; + /** 定速目标(km/h):fixed_speed 时表示本航段使用的固定速度,用于重算下一航点相对K时 */ + private Double segmentTargetSpeed; public Integer getLabelFontSize() { return labelFontSize; @@ -59,4 +65,28 @@ public class WaypointDisplayStyle implements Serializable { public void setOutlineColor(String outlineColor) { this.outlineColor = outlineColor; } + + public String getSegmentMode() { + return segmentMode; + } + + public void setSegmentMode(String segmentMode) { + this.segmentMode = segmentMode; + } + + public Double getSegmentTargetMinutes() { + return segmentTargetMinutes; + } + + public void setSegmentTargetMinutes(Double segmentTargetMinutes) { + this.segmentTargetMinutes = segmentTargetMinutes; + } + + public Double getSegmentTargetSpeed() { + return segmentTargetSpeed; + } + + public void setSegmentTargetSpeed(Double segmentTargetSpeed) { + this.segmentTargetSpeed = segmentTargetSpeed; + } } diff --git a/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql b/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql deleted file mode 100644 index e304b4d..0000000 --- a/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 将航点显示相关字段合并为单列 JSON:display_style --- 执行前请备份。若表带 schema(如 ry.route_waypoints)请自行替换表名。 - --- 1. 新增 JSON 列 -ALTER TABLE route_waypoints - ADD COLUMN display_style JSON DEFAULT NULL COMMENT '航点显示样式JSON: labelFontSize,labelColor,color,pixelSize,outlineColor'; - --- 2. 从旧列回填(仅存在 label_font_size / label_color 时) -UPDATE route_waypoints -SET display_style = JSON_OBJECT( - 'labelFontSize', COALESCE(label_font_size, 16), - 'labelColor', COALESCE(label_color, '#000000'), - 'color', '#ffffff', - 'pixelSize', 12, - 'outlineColor', '#000000' -) -WHERE display_style IS NULL; - --- 3. 删除旧列(若你曾加过 color/pixel_size/outline_color 三列,也一并删除) -ALTER TABLE route_waypoints - DROP COLUMN label_font_size, - DROP COLUMN label_color; - --- 若存在以下列则逐条执行(没有则跳过): --- ALTER TABLE route_waypoints DROP COLUMN color; --- ALTER TABLE route_waypoints DROP COLUMN pixel_size; --- ALTER TABLE route_waypoints DROP COLUMN outline_color; diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 27c29c8..be4f0ad 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -582,10 +582,8 @@ export default { // 空域位置调整:{ entityData },预览实体用 CallbackProperty 实时跟随鼠标(与测距绘制一致) airspacePositionEditContext: null, airspacePositionEditPreviewEntity: null, - // 盘旋最小尺寸(米):圆半径 3km,椭圆长半轴 5km、短半轴 3.5km - MIN_HOLD_RADIUS_M: 3000, - MIN_ELLIPSE_SEMI_MAJOR_M: 5000, - MIN_ELLIPSE_SEMI_MINOR_M: 3500 + // 跑道形“椭圆”盘旋:两条直边 + 两段弧。边长默认 20km;弧转弯半径由航点转弯坡度+速度计算,不再单独存储 + DEFAULT_RACE_TRACK_EDGE_LENGTH_M: 20000 } }, components: { @@ -1386,24 +1384,43 @@ export default { const center = this.missionPendingHold.center; const p = holdParams; const isCircle = p.radius != null; + const isRaceTrack = !isCircle && p.edgeLength != null; const radius = isCircle ? (p.radius || 500) : 500; const semiMajor = !isCircle ? (p.semiMajor ?? p.semiMajorAxis ?? 500) : radius; const semiMinor = !isCircle ? (p.semiMinor ?? p.semiMinorAxis ?? 300) : radius; const headingRad = !isCircle ? ((p.headingDeg ?? 0) * Math.PI) / 180 : 0; - this.tempHoldOutlineEntity = this.viewer.entities.add({ - id: 'temp_hold_outline', - position: center, - ellipse: { - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - rotation: headingRad, - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.ORANGE.withAlpha(0.8), - outlineWidth: 2, - arcType: Cesium.ArcType.NONE - } - }); + if (isRaceTrack) { + const edgeLengthM = Math.max(1000, p.edgeLength ?? this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ speed: p.speed ?? 800, turnAngle: p.turnAngle ?? 45 }) || 500; + const clockwise = p.clockwise !== false; + const prev = this.drawingPoints && this.drawingPoints.length >= 1 ? this.drawingPoints[this.drawingPoints.length - 1] : null; + const directionRad = this.getRaceTrackDirectionRad(center, prev, null, p.headingDeg ?? 0); + const trackPositions = this.getRaceTrackLoopPositions(center, directionRad, edgeLengthM, arcRadiusM, clockwise, 24); + this.tempHoldOutlineEntity = this.viewer.entities.add({ + id: 'temp_hold_outline', + polyline: { + positions: trackPositions, + width: 2, + material: Cesium.Color.ORANGE.withAlpha(0.8), + arcType: Cesium.ArcType.NONE + } + }); + } else { + this.tempHoldOutlineEntity = this.viewer.entities.add({ + id: 'temp_hold_outline', + position: center, + ellipse: { + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + rotation: headingRad, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.ORANGE.withAlpha(0.8), + outlineWidth: 2, + arcType: Cesium.ArcType.NONE + } + }); + } this.tempHoldEntity = this.viewer.entities.add({ id: 'temp_hold_preview', name: 'HOLD', @@ -2036,25 +2053,31 @@ export default { }); if (!this._routeWaypointIdsByRoute) this._routeWaypointIdsByRoute = {}; this._routeWaypointIdsByRoute[routeId] = waypoints.map((wp) => wp.id); + if (!this._routeWaypointsByRoute) this._routeWaypointsByRoute = {}; + this._routeWaypointsByRoute[routeId] = waypoints; if (!this._routeHoldRadiiByRoute) this._routeHoldRadiiByRoute = {}; if (!this._routeHoldEllipseParamsByRoute) this._routeHoldEllipseParamsByRoute = {}; // 判断航点 i 是否为“转弯半径”航点(将用弧线两端两个点替代中心点);非首尾默认 45° 坡度 const isTurnWaypointWithArc = (i) => { if (i < 1 || i >= waypoints.length - 1) return false; + // 下一航点是盘旋点时,不隐藏当前航点(只画直线到盘旋入口,不画转弯弧),否则会丢失航点显示 + if (this.isHoldWaypoint(waypoints[i + 1])) return false; const wp = waypoints[i]; const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); if (this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }) <= 0) return false; const nextPos = originalPositions[i + 1]; let nextLogical = nextPos; if (this.isHoldWaypoint(waypoints[i + 1])) { - const holdParams = this.parseHoldParams(waypoints[i + 1]); - const clock = holdParams && holdParams.clockwise !== false; - const r = holdParams && holdParams.radius != null ? Math.max(this.MIN_HOLD_RADIUS_M, holdParams.radius) : this.MIN_HOLD_RADIUS_M; - const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500); - const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300); - nextLogical = holdParams && holdParams.radius != null - ? this.getCircleTangentEntryPoint(originalPositions[i + 1], originalPositions[i], r, clock) - : this.getEllipseTangentEntryPoint(originalPositions[i + 1], originalPositions[i], smj, smn, ((holdParams.headingDeg || 0) * Math.PI) / 180, clock); + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const clock = holdParams && holdParams.clockwise !== false; + const r = holdParams && holdParams.radius != null ? holdParams.radius : 500; + nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], originalPositions[i], r, clock); + } else { + nextLogical = originalPositions[i + 1]; + } } return !!nextLogical; }; @@ -2347,14 +2370,17 @@ export default { if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); const legIndexHold = i - 1; - const defaultRadius = Math.max(this.MIN_HOLD_RADIUS_M, params && params.radius != null ? params.radius : 500); - const radius = Math.max(this.MIN_HOLD_RADIUS_M, (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][legIndexHold] != null) + const pt = wp.pointType || wp.point_type; + const useCircle = pt === 'hold_circle'; + const defaultRadius = params && params.radius != null ? params.radius : 500; + const radius = (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][legIndexHold] != null) ? this._routeHoldRadiiByRoute[routeId][legIndexHold] - : defaultRadius); - const useCircle = (params && params.radius != null) || (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][legIndexHold] != null); - const defaultSemiMajor = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500); - const defaultSemiMinor = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300); + : defaultRadius; + const defaultSemiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; + const defaultSemiMinor = params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300; const defaultHeadingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; + const edgeLengthM = Math.max(1000, params && params.edgeLength != null ? params.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ ...wp, turnAngle: this.getEffectiveTurnAngle(wp, i, waypoints.length) }) || 500; const clockwise = params && params.clockwise !== false; const currPosCloned = Cesium.Cartesian3.clone(currPos); const lastPosCloned = Cesium.Cartesian3.clone(lastPos); @@ -2373,37 +2399,47 @@ export default { const exit = useCircle ? that.getCircleTangentExitPoint(currPosCloned, nextPosCloned || currPosCloned, R, clockwise) : that.getEllipseTangentExitPoint(currPosCloned, nextPosCloned || currPosCloned, smj, smn, hd, clockwise); - let fullCirclePoints; let arcPoints; if (useCircle) { - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPosCloned); - const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); - const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toEntry = Cesium.Cartesian3.subtract(entry, currPosCloned, new Cesium.Cartesian3()); - const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); - fullCirclePoints = that.getCircleFullCircle(currPosCloned, R, entryAngle, clockwise, 48); - arcPoints = that.getCircleArcEntryToExit(currPosCloned, R, entry, exit, clockwise, 48); + // 圆形盘旋:先画闭合整圆(360°),再接 entry→exit 弧段以正确连到下一航段 + const entryAngle = (() => { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPosCloned); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toP = Cesium.Cartesian3.subtract(entry, currPosCloned, new Cesium.Cartesian3()); + return Math.atan2(Cesium.Cartesian3.dot(toP, east), Cesium.Cartesian3.dot(toP, north)); + })(); + const circlePts = that.getCircleFullCircle(currPosCloned, R, entryAngle, clockwise, 64); + const arcToExit = that.getCircleArcEntryToExit(currPosCloned, R, entry, exit, clockwise, 24); + arcPoints = circlePts && circlePts.length + ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) + : (arcToExit && arcToExit.length ? arcToExit : [Cesium.Cartesian3.clone(entry), Cesium.Cartesian3.clone(exit)]); + return arcPoints; } else { const tEntry = that.cartesianToEllipseParam(currPosCloned, smj, smn, hd, entry); const entryLocalAngle = Math.atan2(smn * Math.sin(tEntry), smj * Math.cos(tEntry)); - fullCirclePoints = that.getEllipseFullCircle(currPosCloned, smj, smn, hd, entryLocalAngle, clockwise, 64); - arcPoints = that.buildEllipseHoldArc(currPosCloned, smj, smn, hd, entry, exit, clockwise, 80); + const fullCirclePoints = that.getEllipseFullCircle(currPosCloned, smj, smn, hd, entryLocalAngle, clockwise, 128); + arcPoints = that.buildEllipseHoldArc(currPosCloned, smj, smn, hd, entry, exit, clockwise, 120); + return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)]; } - return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)]; }; - const holdPositions = useCircle ? buildHoldPositions(radius) : buildHoldPositions({ semiMajor: defaultSemiMajor, semiMinor: defaultSemiMinor, headingDeg: params && params.headingDeg != null ? params.headingDeg : 0 }); + const raceTrackDirectionRad = this.getRaceTrackDirectionRad( + currPosCloned, + lastPosCloned, + nextPosCloned, + params && params.headingDeg != null ? params.headingDeg : 0 + ); + const buildRaceTrackPositions = () => that.getRaceTrackLoopPositions(currPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise, 24); + const holdPositions = useCircle ? buildHoldPositions(radius) : buildRaceTrackPositions(); for (let k = 0; k < holdPositions.length; k++) finalPathPositions.push(holdPositions[k]); const getHoldPositions = () => { if (useCircle) { - const R = Math.max(that.MIN_HOLD_RADIUS_M, (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null) + const R = (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null) ? that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] - : defaultRadius); + : defaultRadius; return buildHoldPositions(R); } - const oe = that._routeHoldEllipseParamsByRoute && that._routeHoldEllipseParamsByRoute[routeIdHold] && that._routeHoldEllipseParamsByRoute[routeIdHold][legIndexHold]; - const smj = oe && oe.semiMajor != null ? Math.max(that.MIN_ELLIPSE_SEMI_MAJOR_M, oe.semiMajor) : defaultSemiMajor; - const smn = oe && oe.semiMinor != null ? Math.max(that.MIN_ELLIPSE_SEMI_MINOR_M, oe.semiMinor) : defaultSemiMinor; - return buildHoldPositions(oe ? { ...oe, semiMajor: smj, semiMinor: smn } : { semiMajor: smj, semiMinor: smn, headingDeg: (params && params.headingDeg != null ? params.headingDeg : 0) }); + return buildRaceTrackPositions(); }; this.viewer.entities.add({ id: `hold-line-${routeId}-${i}`, @@ -2417,22 +2453,32 @@ export default { }, properties: { routeId: routeId } }); - lastPos = holdPositions[holdPositions.length - 1]; + if (!useCircle && nextPosCloned) { + lastPos = this.getRaceTrackTangentExitPoint(currPosCloned, nextPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise); + } else { + lastPos = holdPositions[holdPositions.length - 1]; + } } else { const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { - const holdParams = this.parseHoldParams(waypoints[i + 1]); - const holdRadius = Math.max(this.MIN_HOLD_RADIUS_M, (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null) + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const holdRadius = (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null) ? this._routeHoldRadiiByRoute[routeId][i] - : (holdParams && holdParams.radius != null ? holdParams.radius : 500)); - const holdClock = holdParams && holdParams.clockwise !== false; - const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500); - const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300); - nextLogical = holdParams && (holdParams.radius != null || (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null)) - ? this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdRadius, holdClock) - : this.getEllipseTangentEntryPoint(originalPositions[i + 1], lastPos, smj, smn, ((holdParams.headingDeg || 0) * Math.PI) / 180, holdClock); + : (holdParams && holdParams.radius != null ? holdParams.radius : 500); + const holdClock = holdParams && holdParams.clockwise !== false; + nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdRadius, holdClock); + } else { + const nextNext = i + 2 < waypoints.length ? originalPositions[i + 2] : null; + const nextDir = this.getRaceTrackDirectionRad(originalPositions[i + 1], currPos, nextNext, holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0); + const nextEdge = Math.max(1000, holdParams && holdParams.edgeLength != null ? holdParams.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const nextArcR = this.getWaypointRadius({ ...nextWp, turnAngle: this.getEffectiveTurnAngle(nextWp, i + 1, waypoints.length) }) || 500; + nextLogical = this.getRaceTrackTangentEntryPoint(originalPositions[i + 1], lastPos, nextDir, nextEdge, nextArcR, holdParams && holdParams.clockwise !== false); + } } if (i < waypoints.length - 1 && nextLogical) { const lastPosCloned = Cesium.Cartesian3.clone(lastPos); @@ -2504,7 +2550,12 @@ export default { // 先加一层透明加宽“点击区域”,便于左键点中航线(细线难触发) const that = this; const hitLineId = lineId + '-hit'; + const hasHold = waypoints.some((wp) => that.isHoldWaypoint(wp)); const routePositionsCallback = new Cesium.CallbackProperty(function () { + if (hasHold) { + const pathPos = that.getRoutePathPositionsForLine(routeId); + if (pathPos && pathPos.length > 0) return pathPos; + } return that.getRouteLinePositionsFromWaypointEntities(routeId) || finalPathPositions; }, false); const hitEntity = this.viewer.entities.add({ @@ -2540,6 +2591,21 @@ export default { } } }, + /** 获取与推演位置计算一致的完整航线路径(含盘旋),保证平台图标严格沿航线运动 */ + getRoutePathPositionsForLine(routeId) { + const waypoints = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId]; + if (!waypoints || waypoints.length < 1 || !this.viewer) return null; + const holdRadii = this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId]; + const holdEllipse = this._routeHoldEllipseParamsByRoute && this._routeHoldEllipseParamsByRoute[routeId]; + const opts = { holdRadiusByLegIndex: holdRadii || {}, holdEllipseParamsByLegIndex: holdEllipse || {} }; + const pathData = this.getRoutePathWithSegmentIndices(waypoints, opts); + if (!pathData.path || pathData.path.length < 1) return null; + const ellipsoid = this.viewer.scene.globe.ellipsoid; + return pathData.path.map(p => + Cesium.Cartesian3.fromDegrees(Number(p.lng), Number(p.lat), Number(p.alt != null ? p.alt : 0), ellipsoid) + ); + }, + /** 从各航点/弧线/盘旋实体取当前位置,供主航线折线实时连线(拖拽时动态跟随)。转弯处优先用弧线,不经过航点中心,只展示转弯半径 */ getRouteLinePositionsFromWaypointEntities(routeId) { const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[routeId]; @@ -2628,19 +2694,356 @@ export default { semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, - clockwise: p.clockwise !== false + clockwise: p.clockwise !== false, + edgeLength: p.edgeLength != null ? Number(p.edgeLength) : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M }; } catch (e) { return null; } }, - /** 圆上整圈(360°)采样,从 startAngleRad 起按顺时针/逆时针,用于盘旋段渲染为整圆 */ + /** + * 跑道形轴线方向角(弧度)。椭圆盘旋:飞机到达该航点时依旧以当前速度方向航行,直至设定边长,该段即跑道第一边。 + * 故轴线 = 进近方向(上一航点→本航点)。无上一航点时用下一航点方向或航向角。 + */ + getRaceTrackDirectionRad(centerCartesian, lastPos, nextPos, headingDegDefault) { + const deg = headingDegDefault != null ? Number(headingDegDefault) : 0; + const defaultRad = (deg * Math.PI) / 180; + if (!centerCartesian) return defaultRad; + if (lastPos) { + const toCurr = Cesium.Cartesian3.subtract(centerCartesian, lastPos, new Cesium.Cartesian3()); + if (Cesium.Cartesian3.magnitude(toCurr) > 1e-6) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + return Math.atan2(Cesium.Cartesian3.dot(toCurr, east), Cesium.Cartesian3.dot(toCurr, north)); + } + } + if (nextPos) { + const toNext = Cesium.Cartesian3.subtract(nextPos, centerCartesian, new Cesium.Cartesian3()); + if (Cesium.Cartesian3.magnitude(toNext) > 1e-6) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + return Math.atan2(Cesium.Cartesian3.dot(toNext, east), Cesium.Cartesian3.dot(toNext, north)); + } + } + return defaultRad; + }, + + /** + * 椭圆跑道(Loiter)轨迹点数组:在 ENU 局部坐标系中计算,保证顺/逆时针偏向正确。 + * - 进入航向 dir:轴线方向(上一航点→本航点)。 + * - 顺时针:另一长边与圆心在 dir 的右侧(法向量方向);圆弧角度递减(减 step)。 + * - 逆时针:另一长边与圆心在 dir 的左侧(法向量反方向);圆弧角度递增(加 step)。 + * 路径:[直线1 -> 半圆1 -> 直线2 -> 半圆2] 闭合到起点。 + * @returns {Cesium.Cartesian3[]} + */ + calculateLoiterPath(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc) { + if (!centerCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return []; + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const origin = Cesium.Cartesian3.clone(centerCartesian); + + const toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add( + Cesium.Cartesian3.clone(origin), + Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + + // 进入航向单位向量 (east, north),0=北 + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + // 航向右侧法向量(顺时针时弧在这一侧) + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + // 区分偏移方向:顺时针=右侧(+1),逆时针=左侧(-1) + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + + const A = { e: 0, n: 0 }; + const B = { e: L * dirE, n: L * dirN }; + const center1 = { e: B.e + R * offsetE, n: B.n + R * offsetN }; + const C = { e: B.e + 2 * R * offsetE, n: B.n + 2 * R * offsetN }; + const D_pt = { e: C.e - L * dirE, n: C.n - L * dirN }; + const center2 = { e: A.e + R * offsetE, n: A.n + R * offsetN }; + + const nArc = Math.max(8, numPointsPerArc || 24); + const semicircle = (centerENU, fromENU, toENU, isClockwise) => { + const dFromE = fromENU.e - centerENU.e; + const dFromN = fromENU.n - centerENU.n; + const dToE = toENU.e - centerENU.e; + const dToN = toENU.n - centerENU.n; + let a0 = Math.atan2(dFromE, dFromN); + let a1 = Math.atan2(dToE, dToN); + let d = a1 - a0; + if (d > Math.PI) d -= 2 * Math.PI; + if (d < -Math.PI) d += 2 * Math.PI; + // 这里刻意与之前逻辑“反向”:当前 d 产生的半圆开口向内,将条件翻转后得到向外鼓出的半圆。 + if (isClockwise) { + if (d <= 0) d += 2 * Math.PI; + } else { + if (d >= 0) d -= 2 * Math.PI; + } + const pts = []; + for (let i = 1; i < nArc; i++) { + const t = i / nArc; + const a = a0 + d * t; + pts.push(toCartesian(centerENU.e + R * Math.sin(a), centerENU.n + R * Math.cos(a))); + } + return pts; + }; + + const points = []; + points.push(toCartesian(A.e, A.n)); + points.push(toCartesian(B.e, B.n)); + points.push(...semicircle(center1, B, C, clockwise)); + points.push(toCartesian(C.e, C.n)); + points.push(toCartesian(D_pt.e, D_pt.n)); + points.push(...semicircle(center2, D_pt, A, clockwise)); + points.push(Cesium.Cartesian3.clone(points[0])); + return points; + }, + + /** 兼容旧调用:委托给 calculateLoiterPath */ + getRaceTrackLoopPositions(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc) { + return this.calculateLoiterPath(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc); + }, + + /** + * 跑道弧上切线出口点:从跑道飞往下一航点时,弧上与下一航点连成切线的点(航线=弧上该点→下一航点)。 + * 与圆轨迹一致:当前盘旋点与下一航点之间的航线为弧上一点与下一航点之间形成的切线。 + */ + getRaceTrackTangentExitPoint(centerCartesian, nextPointCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise) { + if (!centerCartesian || !nextPointCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return Cesium.Cartesian3.clone(centerCartesian); + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add(Cesium.Cartesian3.clone(centerCartesian), Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + const B = { e: L * dirE, n: L * dirN }; + const A = Cesium.Cartesian3.clone(centerCartesian); + const arc1Center = toCartesian(B.e + R * offsetE, B.n + R * offsetN); + const arc2Center = toCartesian(R * offsetE, R * offsetN); + const B_cart = toCartesian(B.e, B.n); + const tangentFromCircle = (circleCenter, oppositePoint, revDir) => { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toNext); + if (d < 1e-6) return null; + if (R >= d) return null; + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const onArc = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const toOpp = Cesium.Cartesian3.subtract(oppositePoint, circleCenter, new Cesium.Cartesian3()); + return Cesium.Cartesian3.dot(toP, toOpp) <= 0; + }; + const exitDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(nextPointCartesian, p, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + let best = null; + let bestDot = -2; + [p1, p2].forEach((p) => { + if (!onArc(p)) return; + const ex = exitDir(p); + const ta = tangentAt(p); + const dot = Cesium.Cartesian3.dot(ex, ta); + if (dot > bestDot) { bestDot = dot; best = p; } + }); + return best; + }; + // 弧的遍历方向与跑道一致:顺时针=角度递减,切线方向 revDir=clockwise + const t1 = tangentFromCircle(arc1Center, A, clockwise); + const t2 = tangentFromCircle(arc2Center, B_cart, clockwise); + let candidates = [t1, t2].filter(Boolean); + if (candidates.length === 0) { + const fallback = (circleCenter, revDir) => { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toNext); + if (d < 1e-6 || R >= d) return null; + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const exitDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(nextPointCartesian, p, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + const d1 = Cesium.Cartesian3.dot(exitDir(p1), tangentAt(p1)); + const d2 = Cesium.Cartesian3.dot(exitDir(p2), tangentAt(p2)); + return d1 >= d2 ? p1 : p2; + }; + const f1 = fallback(arc1Center, clockwise); + const f2 = fallback(arc2Center, clockwise); + candidates = [f1, f2].filter(Boolean); + } + if (candidates.length === 0) return Cesium.Cartesian3.clone(A); + if (candidates.length === 1) return Cesium.Cartesian3.clone(candidates[0]); + const dist0 = Cesium.Cartesian3.distance(candidates[0], nextPointCartesian); + const dist1 = Cesium.Cartesian3.distance(candidates[1], nextPointCartesian); + return Cesium.Cartesian3.clone(dist0 <= dist1 ? candidates[0] : candidates[1]); + }, + + /** + * 跑道弧上切线入口点:从上一航点飞向跑道时,弧上与上一航点连成切线的点(航线=上一航点→弧上该点)。 + */ + getRaceTrackTangentEntryPoint(centerCartesian, prevPointCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise) { + if (!centerCartesian || !prevPointCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return Cesium.Cartesian3.clone(centerCartesian); + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add(Cesium.Cartesian3.clone(centerCartesian), Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + const B = { e: L * dirE, n: L * dirN }; + const A = Cesium.Cartesian3.clone(centerCartesian); + const arc1Center = toCartesian(B.e + R * offsetE, B.n + R * offsetN); + const arc2Center = toCartesian(R * offsetE, R * offsetN); + const B_cart = toCartesian(B.e, B.n); + const entryFromCircle = (circleCenter, oppositePoint, revDir) => { + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toPrev); + if (d < 1e-6) return null; + if (R >= d) return null; + const e = Cesium.Cartesian3.dot(toPrev, east); + const n = Cesium.Cartesian3.dot(toPrev, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const onArc = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const toOpp = Cesium.Cartesian3.subtract(oppositePoint, circleCenter, new Cesium.Cartesian3()); + return Cesium.Cartesian3.dot(toP, toOpp) <= 0; + }; + const approachDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(p, prevPointCartesian, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + let best = null; + let bestDot = -2; + [p1, p2].forEach((p) => { + if (!onArc(p)) return; + const ap = approachDir(p); + const ta = tangentAt(p); + const dot = Cesium.Cartesian3.dot(ap, ta); + if (dot > bestDot) { bestDot = dot; best = p; } + }); + return best; + }; + // 与跑道弧遍历一致:顺时针=角度递减,切线方向 revDir=clockwise + const t1 = entryFromCircle(arc1Center, A, clockwise); + const t2 = entryFromCircle(arc2Center, B_cart, clockwise); + const candidates = [t1, t2].filter(Boolean); + if (candidates.length === 0) return Cesium.Cartesian3.clone(A); + if (candidates.length === 1) return Cesium.Cartesian3.clone(candidates[0]); + const dist0 = Cesium.Cartesian3.distance(candidates[0], prevPointCartesian); + const dist1 = Cesium.Cartesian3.distance(candidates[1], prevPointCartesian); + return Cesium.Cartesian3.clone(dist0 <= dist1 ? candidates[0] : candidates[1]); + }, + + /** 圆上整圈(360°)采样,从 startAngleRad 起按顺时针/逆时针,用于盘旋段渲染为整圆。顺时针=12→3→6→9,逆时针=12→9→6→3 */ getCircleFullCircle(centerCartesian, radiusMeters, startAngleRad, clockwise, numPoints) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; const points = []; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; @@ -2667,7 +3070,7 @@ export default { let entryAngle = toAngle(entryCartesian); let exitAngle = toAngle(exitCartesian); let diff = exitAngle - entryAngle; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; if (sign * diff <= 0) diff += sign * 2 * Math.PI; const points = []; for (let i = 0; i <= numPoints; i++) { @@ -2728,6 +3131,7 @@ export default { const tEntry = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian); const tExit = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, exitCartesian); const d = (tExit - tEntry + 2 * Math.PI) % (2 * Math.PI); + /* 长弧必须沿 -t 方向才能从 entry 到 exit,故此处保持 clockwise ? -1 : 1;世界方向由 getEllipseFullCircle 等处的 sign 统一 */ const sign = clockwise ? -1 : 1; const longSpan = clockwise ? (2 * Math.PI - d) : (d > Math.PI ? d : 2 * Math.PI - d); const n = Math.max(2, numPoints || 80); @@ -2755,7 +3159,7 @@ export default { const tEntry = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian); let tExit = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, exitCartesian); let diff = (tExit - tEntry + 2 * Math.PI) % (2 * Math.PI); - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; if (sign * (diff - Math.PI) > 0) diff -= 2 * Math.PI; const n = Math.max(2, numPoints || 48); const points = []; @@ -2766,10 +3170,10 @@ export default { return points; }, - /** 椭圆整圈(用于弧长计算等),从 startLocalAngle 起按顺时针/逆时针 */ + /** 椭圆整圈(用于弧长计算等),从 startLocalAngle 起按顺时针/逆时针。顺时针=12→3→6→9,逆时针=12→9→6→3 */ getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, startLocalAngle, clockwise, numPoints) { const t0 = this.polarToEllipseParam(startLocalAngle, semiMajorM, semiMinorM); - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; const n = Math.max(2, numPoints || 64); const points = []; for (let i = 0; i <= n; i++) { @@ -2807,14 +3211,14 @@ export default { return arc; }, - /** 圆:中心( Cartesian3 )、半径(米)、顺时针、采样数 → 世界坐标点数组(从进入点沿圆到出口点需外部指定起止角或由 entry/exit 截取) */ + /** 圆:中心( Cartesian3 )、半径(米)、顺时针、采样数 → 世界坐标点数组。顺时针=12→3→6→9,逆时针=12→9→6→3 */ computeCirclePositions(centerCartesian, radiusMeters, clockwise, numPoints) { if (!this.viewer || !centerCartesian || radiusMeters <= 0) return []; const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); const points = []; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; const angle = sign * t * 2 * Math.PI; @@ -2828,10 +3232,10 @@ export default { return points; }, - /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组(与 ellipsePointAtParam 同一约定) */ + /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组。顺时针=12→3→6→9,逆时针=12→9→6→3 */ computeEllipsePositions(centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise, numPoints) { if (!this.viewer || !centerCartesian || semiMajorM <= 0 || semiMinorM <= 0) return []; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; const points = []; for (let i = 0; i <= numPoints; i++) { const t = sign * (i / numPoints) * 2 * Math.PI; @@ -2851,7 +3255,7 @@ export default { return Cesium.Cartesian3.add(prevPointCartesian, Cesium.Cartesian3.multiplyByScalar(unit, dist - radiusMeters, new Cesium.Cartesian3()), new Cesium.Cartesian3()); }, - /** 圆上切线进入点:从 prev 飞向圆时在圆上的切点(与出口切点对称,选使进入后沿顺时针/逆时针顺滑的那一侧) */ + /** 圆上切线进入点:从 prev 飞向圆时在圆上的切点。选使「接近方向」与「圆上飞行方向」最一致的那侧,进入时转弯最小、顺滑(见图三:顺着大方向盘旋) */ getCircleTangentEntryPoint(centerCartesian, prevPointCartesian, radiusMeters, clockwise) { const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); const d = Cesium.Cartesian3.magnitude(toPrev); @@ -2864,19 +3268,41 @@ export default { const n = Cesium.Cartesian3.dot(toPrev, north); const theta = Math.atan2(e, n); const alpha = Math.acos(Math.min(1, radiusMeters / d)); - const sign = clockwise ? 1 : -1; - const entryAngle = theta - sign * alpha; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(entryAngle) * radiusMeters, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(entryAngle) * radiusMeters, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + const angle1 = theta - alpha; + const angle2 = theta + alpha; + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(angle1); + const p2 = pointAt(angle2); + const approach1 = Cesium.Cartesian3.subtract(p1, prevPointCartesian, new Cesium.Cartesian3()); + const approach2 = Cesium.Cartesian3.subtract(p2, prevPointCartesian, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(approach1, approach1); + Cesium.Cartesian3.normalize(approach2, approach2); + const tangentAt = (angle) => { + const tn = clockwise ? -Math.sin(angle) : Math.sin(angle); + const te = clockwise ? Math.cos(angle) : -Math.cos(angle); + return Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + }; + const t1 = tangentAt(angle1); + const t2 = tangentAt(angle2); + const dot1 = Cesium.Cartesian3.dot(approach1, t1); + const dot2 = Cesium.Cartesian3.dot(approach2, t2); + return dot1 >= dot2 ? p1 : p2; }, /** * 根据盘旋总距离反算圆半径,使从进入点到切点出口的弧长 = totalHoldDistM(整圈 + entry→exit 弧), - * 从而在 k+10 时刻飞机自然落在切点,无需强制位移。返回值不小于 MIN_HOLD_RADIUS_M。 + * 从而在相对K时时刻飞机自然落在切点。使用与 getRoutePath 相同的 circleHoldArcLengthM 度量,保证一致性。 * @param centerCartesian - 盘旋中心 * @param prevPointCartesian - 上一航点 * @param nextPointCartesian - 下一航点 @@ -2886,47 +3312,24 @@ export default { */ computeHoldRadiusForDuration(centerCartesian, prevPointCartesian, nextPointCartesian, clockwise, totalHoldDistM) { if (!totalHoldDistM || totalHoldDistM <= 0) return null; - const minR = this.MIN_HOLD_RADIUS_M; const dToNext = Cesium.Cartesian3.distance(centerCartesian, nextPointCartesian); const dFromPrev = Cesium.Cartesian3.distance(centerCartesian, prevPointCartesian); if (dToNext < 1e-6) return null; const Rmax = Math.min(dToNext * 0.999, Math.max(1, dFromPrev - 1)); - if (Rmax < minR) return minR; - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); - const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); - const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); - const thetaPrev = Math.atan2(Cesium.Cartesian3.dot(toPrev, east), Cesium.Cartesian3.dot(toPrev, north)); - const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); - const theta = Math.atan2(Cesium.Cartesian3.dot(toNext, east), Cesium.Cartesian3.dot(toNext, north)); - const sign = clockwise ? 1 : -1; - const arcAngleForR = (R) => { - if (R >= dToNext || R >= dFromPrev) return NaN; - const alphaNext = Math.acos(Math.min(1, R / dToNext)); - const alphaPrev = Math.acos(Math.min(1, R / dFromPrev)); - const entryAngle = thetaPrev - sign * alphaPrev; - const exitAngle = theta + sign * alphaNext; - const rawDiff = exitAngle - entryAngle; - const arcAngle = (sign === 1) - ? (rawDiff <= 0 ? -rawDiff : 2 * Math.PI - rawDiff) - : (rawDiff >= 0 ? rawDiff : 2 * Math.PI + rawDiff); - return arcAngle; - }; - const fullCirclePlusArcForR = (R) => { - const arc = arcAngleForR(R); - if (!Number.isFinite(arc)) return NaN; - return 2 * Math.PI + arc; - }; - let R = Math.min(500, Rmax * 0.5); - R = Math.max(minR, R); - for (let iter = 0; iter < 50; iter++) { - const totalAngle = fullCirclePlusArcForR(R); - if (!Number.isFinite(totalAngle) || totalAngle < 1e-6) return minR; - const Rnew = totalHoldDistM / totalAngle; - if (Math.abs(Rnew - R) < 0.5) return Math.max(minR, Rnew); - R = Math.max(minR, Math.min(Rmax, Rnew)); - } - return Math.max(minR, R); + if (Rmax < 1) return null; + const toleranceM = 0.1; + let Rlo = 10; + let Rhi = Rmax; + for (let iter = 0; iter < 80; iter++) { + const R = (Rlo + Rhi) / 2; + const len = this.circleHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian || centerCartesian, R, clockwise); + if (!Number.isFinite(len) || len <= 0) return null; + if (Math.abs(len - totalHoldDistM) < toleranceM) return Math.min(Rmax, R); + if (len < totalHoldDistM) Rlo = R; else Rhi = R; + if (Rhi - Rlo < 0.01) break; + } + const R = (Rlo + Rhi) / 2; + return Math.min(Rmax, R); }, /** @@ -2947,8 +3350,45 @@ export default { if (this.viewer && this.viewer.scene) this.viewer.scene.requestRender(); }, - /** 椭圆盘旋总弧长(切线入口 → 整椭圆 → 弧至切线出口),用于反算尺寸 */ + /** 与 childRoom.segmentDistance 一致:经纬度+高度求距离(米),保证弧长与时间线 pathSliceDistance 同度量 */ + segmentDistanceFromCartesian(cartesian1, cartesian2) { + const c1 = Cesium.Cartographic.fromCartesian(cartesian1); + const c2 = Cesium.Cartographic.fromCartesian(cartesian2); + const lat1 = c1.latitude; + const lat2 = c2.latitude; + const dlat = c2.latitude - c1.latitude; + const dlng = c2.longitude - c1.longitude; + const R = 6371000; + const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const horizontal = R * c; + const dalt = (c2.height || 0) - (c1.height || 0); + return Math.sqrt(horizontal * horizontal + dalt * dalt); + }, + + /** 圆形盘旋总弧长(切线入口 → 整圆一圈 → 弧至切线出口),与 getRoutePath 构建的路径同度量(弦长累加) */ + circleHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian, radiusMeters, clockwise) { + const CIRCLE_POINTS = 64; + const ARC_POINTS = 24; + const entry = this.getCircleTangentEntryPoint(centerCartesian, prevPointCartesian, radiusMeters, clockwise); + const exit = this.getCircleTangentExitPoint(centerCartesian, nextPointCartesian || centerCartesian, radiusMeters, clockwise); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entry, centerCartesian, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + const circlePts = this.getCircleFullCircle(centerCartesian, radiusMeters, entryAngle, clockwise, CIRCLE_POINTS); + const arcToExit = this.getCircleArcEntryToExit(centerCartesian, radiusMeters, entry, exit, clockwise, ARC_POINTS); + const pts = circlePts && circlePts.length ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) : [entry, exit]; + let len = 0; + for (let i = 1; i < pts.length; i++) len += this.segmentDistanceFromCartesian(pts[i - 1], pts[i]); + return len; + }, + + /** 椭圆盘旋总弧长(切线入口 → 整椭圆一圈 → 弧至切线出口),用于反算尺寸;与 getRoutePath 构建的路径同度量(弦长累加,采样数一致) */ ellipseHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const ELLIPSE_CIRCLE_POINTS = 128; + const ELLIPSE_ARC_POINTS = 120; const entry = this.getEllipseTangentEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise); const exit = this.getEllipseTangentExitPoint(centerCartesian, nextPointCartesian || centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); @@ -2959,46 +3399,45 @@ export default { const n0 = Cesium.Cartesian3.dot(toEntry, north); const theta0 = Math.atan2(e0, n0); const entryLocalAngle = theta0 - headingRad; - const fullCircle = this.getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, entryLocalAngle, clockwise, 64); - const arcToExit = this.getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entry, exit, clockwise, 48); + const fullCircle = this.getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, entryLocalAngle, clockwise, ELLIPSE_CIRCLE_POINTS); + const arcToExit = this.getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entry, exit, clockwise, ELLIPSE_ARC_POINTS, true); const pts = [entry, ...fullCircle.slice(1), ...arcToExit.slice(1)]; let len = 0; - for (let i = 1; i < pts.length; i++) len += Cesium.Cartesian3.distance(pts[i - 1], pts[i]); + for (let i = 1; i < pts.length; i++) len += this.segmentDistanceFromCartesian(pts[i - 1], pts[i]); return len; }, /** * 根据盘旋总距离反算椭圆半轴,使切线入口→整椭圆→切线出口的弧长 = totalHoldDistM(椭圆保持长轴方位与长短轴比)。 - * 返回值不小于 MIN_ELLIPSE_SEMI_MAJOR_M / MIN_ELLIPSE_SEMI_MINOR_M,保证飞机能盘旋开。 - * @param headingDeg - 长轴方位(度) + * 返回计算所得半轴(可能小于 MIN,由调用方与最小值比较并报冲突)。 + * @param headingDeg - 长轴方位(度),未传则用 0 * @param aspectMajor - 长半轴参考(用于比例),米 * @param aspectMinor - 短半轴参考(用于比例),米 * @returns { semiMajor, semiMinor } 或 null */ computeEllipseParamsForDuration(centerCartesian, prevPointCartesian, nextPointCartesian, clockwise, totalHoldDistM, headingDeg, aspectMajor, aspectMinor) { if (!totalHoldDistM || totalHoldDistM <= 0) return null; - const minA = this.MIN_ELLIPSE_SEMI_MAJOR_M; - const minB = this.MIN_ELLIPSE_SEMI_MINOR_M; const a0 = Math.max(50, aspectMajor || 500); const b0 = Math.max(30, aspectMinor || 300); const headingRad = ((headingDeg != null ? headingDeg : 0) * Math.PI) / 180; const next = nextPointCartesian || centerCartesian; let kLo = 0.1; let kHi = 20; - for (let iter = 0; iter < 40; iter++) { + const toleranceM = 0.1; + for (let iter = 0; iter < 80; iter++) { const k = (kLo + kHi) / 2; const smj = k * a0; const smn = k * b0; const len = this.ellipseHoldArcLengthM(centerCartesian, prevPointCartesian, next, smj, smn, headingRad, clockwise); - if (!Number.isFinite(len) || len <= 0) return { semiMajor: minA, semiMinor: minB }; - if (Math.abs(len - totalHoldDistM) < 2) return { semiMajor: Math.max(minA, smj), semiMinor: Math.max(minB, smn) }; + if (!Number.isFinite(len) || len <= 0) return null; + if (Math.abs(len - totalHoldDistM) < toleranceM) return { semiMajor: smj, semiMinor: smn }; if (len < totalHoldDistM) kLo = k; else kHi = k; } const k = (kLo + kHi) / 2; - return { semiMajor: Math.max(minA, k * a0), semiMinor: Math.max(minB, k * b0) }; + return { semiMajor: k * a0, semiMinor: k * b0 }; }, - /** 圆上切线出口点:从圆飞往 next 时在圆上的切点(选顺时针/逆时针中朝向 next 的那一侧) */ + /** 圆上切线出口点:从圆飞往 next 时在圆上的切点。选使「飞出方向」与「圆上飞行方向」最一致的那侧,离开时转弯最小、顺滑 */ getCircleTangentExitPoint(centerCartesian, nextPointCartesian, radiusMeters, clockwise) { const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); const d = Cesium.Cartesian3.magnitude(toNext); @@ -3010,17 +3449,39 @@ export default { const n = Cesium.Cartesian3.dot(toNext, north); const theta = Math.atan2(e, n); const alpha = Math.acos(Math.min(1, radiusMeters / d)); - const sign = clockwise ? 1 : -1; - const exitAngle = theta + sign * alpha; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(exitAngle) * radiusMeters, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(exitAngle) * radiusMeters, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + const angle1 = theta + alpha; + const angle2 = theta - alpha; + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(angle1); + const p2 = pointAt(angle2); + const exitDir1 = Cesium.Cartesian3.subtract(nextPointCartesian, p1, new Cesium.Cartesian3()); + const exitDir2 = Cesium.Cartesian3.subtract(nextPointCartesian, p2, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(exitDir1, exitDir1); + Cesium.Cartesian3.normalize(exitDir2, exitDir2); + const tangentAt = (angle) => { + const tn = clockwise ? -Math.sin(angle) : Math.sin(angle); + const te = clockwise ? Math.cos(angle) : -Math.cos(angle); + return Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + }; + const t1 = tangentAt(angle1); + const t2 = tangentAt(angle2); + const dot1 = Cesium.Cartesian3.dot(exitDir1, t1); + const dot2 = Cesium.Cartesian3.dot(exitDir2, t2); + return dot1 >= dot2 ? p1 : p2; }, - /** 椭圆在参数 t 处的单位切向量(世界坐标,t 增加方向);clockwise 为 true 时返回飞行方向(t 减少) */ + /** 椭圆在参数 t 处的单位切向量(世界坐标);clockwise 为 true 时返回飞行方向(顺时针=12→3→6→9) */ ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t, clockwise) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); @@ -3032,7 +3493,7 @@ export default { const northComp = dx * c - dy * s; const eastComp = dx * s + dy * c; const len = Math.sqrt(northComp * northComp + eastComp * eastComp) || 1; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; return Cesium.Cartesian3.add( Cesium.Cartesian3.multiplyByScalar(north, (sign * northComp) / len, new Cesium.Cartesian3()), Cesium.Cartesian3.multiplyByScalar(east, (sign * eastComp) / len, new Cesium.Cartesian3()), @@ -3186,6 +3647,7 @@ export default { const segmentEndIndices = []; const holdArcRanges = {}; let lastPos = originalPositions[0]; + let prevWasHold = false; for (let i = 1; i < waypoints.length; i++) { const currPos = originalPositions[i]; const wp = waypoints[i]; @@ -3193,67 +3655,79 @@ export default { if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); const legIndex = i - 1; - const overrideRadius = holdRadiusByLegIndex[legIndex]; - const radius = Math.max(this.MIN_HOLD_RADIUS_M, (overrideRadius != null && Number.isFinite(overrideRadius)) - ? overrideRadius - : (params && params.radius != null ? params.radius : 500)); - const useCircle = (overrideRadius != null && Number.isFinite(overrideRadius)) || (params && params.radius != null); - const overrideEllipse = holdEllipseParamsByLegIndex[legIndex]; - const semiMajor = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, (overrideEllipse && overrideEllipse.semiMajor != null) ? overrideEllipse.semiMajor : (params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500)); - const semiMinor = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, (overrideEllipse && overrideEllipse.semiMinor != null) ? overrideEllipse.semiMinor : (params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300)); - const headingRad = ((overrideEllipse && overrideEllipse.headingDeg != null) ? overrideEllipse.headingDeg : (params && params.headingDeg != null ? params.headingDeg : 0)) * Math.PI / 180; + const pt = wp.pointType || wp.point_type; + const useCircle = pt === 'hold_circle'; const clockwise = params && params.clockwise !== false; - const entry = useCircle - ? this.getCircleTangentEntryPoint(currPos, lastPos, radius, clockwise) - : this.getEllipseTangentEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad, clockwise); - const exit = useCircle - ? this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise) - : this.getEllipseTangentExitPoint(currPos, nextPos || currPos, semiMajor, semiMinor, headingRad, clockwise); const arcStartIdx = path.length; - let fullCirclePoints; - let arcPoints; + let holdPositions; if (useCircle) { + const overrideRadius = holdRadiusByLegIndex[legIndex]; + const radius = (overrideRadius != null && Number.isFinite(overrideRadius)) + ? overrideRadius + : (params && params.radius != null ? params.radius : 500); + const entry = this.getCircleTangentEntryPoint(currPos, lastPos, radius, clockwise); + const exit = this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toEntry = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); - const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); - fullCirclePoints = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 48); - arcPoints = this.getCircleArcEntryToExit(currPos, radius, entry, exit, clockwise, 48); + const toP = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toP, east), Cesium.Cartesian3.dot(toP, north)); + const circlePts = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 64); + const arcToExit = this.getCircleArcEntryToExit(currPos, radius, entry, exit, clockwise, 24); + const arcPoints = circlePts && circlePts.length + ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) + : (arcToExit && arcToExit.length ? arcToExit : [entry, exit]); + holdPositions = arcPoints && arcPoints.length ? arcPoints : [entry, exit]; + lastPos = exit; } else { - const tEntry = this.cartesianToEllipseParam(currPos, semiMajor, semiMinor, headingRad, entry); - const entryLocalAngle = Math.atan2(semiMinor * Math.sin(tEntry), semiMajor * Math.cos(tEntry)); - fullCirclePoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 64); - arcPoints = this.buildEllipseHoldArc(currPos, semiMajor, semiMinor, headingRad, entry, exit, clockwise, 80); - } - const holdPositions = useCircle - ? [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)] - : [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)]; + const edgeLengthM = Math.max(1000, params && params.edgeLength != null ? params.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ ...wp, turnAngle: this.getEffectiveTurnAngle(wp, i, waypoints.length) }) || 500; + const directionRad = this.getRaceTrackDirectionRad( + currPos, + lastPos, + nextPos, + params && params.headingDeg != null ? params.headingDeg : 0 + ); + holdPositions = this.getRaceTrackLoopPositions(currPos, directionRad, edgeLengthM, arcRadiusM, clockwise, 24); + if (nextPos) { + lastPos = this.getRaceTrackTangentExitPoint(currPos, nextPos, directionRad, edgeLengthM, arcRadiusM, clockwise); + } else { + lastPos = holdPositions.length ? holdPositions[holdPositions.length - 1] : currPos; + } + } for (let k = 0; k < holdPositions.length; k++) path.push(toLngLatAlt(holdPositions[k])); holdArcRanges[i - 1] = { start: arcStartIdx, end: path.length - 1 }; segmentEndIndices[i - 1] = path.length - 1; - lastPos = exit; + prevWasHold = true; } else { let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { - const holdParams = this.parseHoldParams(waypoints[i + 1]); - const holdClock = holdParams && holdParams.clockwise !== false; - if (holdParams && (holdParams.radius != null || (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null))) { - const holdR = Math.max(this.MIN_HOLD_RADIUS_M, (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null) ? holdRadiusByLegIndex[i] : (holdParams.radius ?? 500)); + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const holdClock = holdParams && holdParams.clockwise !== false; + const holdR = (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null) ? holdRadiusByLegIndex[i] : (holdParams && holdParams.radius != null ? holdParams.radius : 500); nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdR, holdClock); - } else if (holdParams) { - const he = holdEllipseParamsByLegIndex[i] || holdParams; - const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, he.semiMajor ?? he.semiMajorAxis ?? 500); - const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, he.semiMinor ?? he.semiMinorAxis ?? 300); - const hd = ((he.headingDeg != null ? he.headingDeg : 0) * Math.PI) / 180; - nextLogical = this.getEllipseTangentEntryPoint(originalPositions[i + 1], lastPos, smj, smn, hd, holdClock); + } else { + const nextNext = i + 2 < waypoints.length ? originalPositions[i + 2] : null; + const nextDir = this.getRaceTrackDirectionRad(originalPositions[i + 1], currPos, nextNext, holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0); + const nextEdge = Math.max(1000, holdParams && holdParams.edgeLength != null ? holdParams.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const nextArcR = this.getWaypointRadius({ ...nextWp, turnAngle: this.getEffectiveTurnAngle(nextWp, i + 1, waypoints.length) }) || 500; + nextLogical = this.getRaceTrackTangentEntryPoint(originalPositions[i + 1], lastPos, nextDir, nextEdge, nextArcR, holdParams && holdParams.clockwise !== false); } } const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); const nextIsHold = nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1]); if (i < waypoints.length - 1 && nextLogical) { - if (nextIsHold) { + if (prevWasHold) { + // 上一航点为盘旋时:航线严格为「盘旋弧上切点→下一航点」的切线,保证平台移动流畅 + path.push(toLngLatAlt(lastPos)); + path.push(toLngLatAlt(nextLogical)); + lastPos = nextLogical; + prevWasHold = false; + } else if (nextIsHold) { path.push(toLngLatAlt(nextLogical)); lastPos = nextLogical; } else if (radius > 0) { @@ -3268,6 +3742,7 @@ export default { path.push(toLngLatAlt(currPos)); lastPos = currPos; } + prevWasHold = false; segmentEndIndices[i - 1] = path.length - 1; } } diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 0a92091..ae9c140 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -227,7 +227,19 @@ {{ currentTime }} -