package zhuye;
|
|
import javax.swing.*;
|
import java.awt.*;
|
import java.awt.event.*;
|
import java.awt.FontMetrics;
|
import java.awt.geom.AffineTransform;
|
import java.awt.geom.Ellipse2D;
|
import java.awt.geom.Path2D;
|
import java.awt.geom.Point2D;
|
import java.awt.geom.Rectangle2D;
|
import java.math.BigDecimal;
|
import java.util.ArrayDeque;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.Deque;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Set;
|
import java.io.File;
|
import set.Setsys;
|
import gecaoji.Device;
|
import gecaoji.Gecaoji;
|
import gecaoji.GecaojiMeg;
|
import gecaoji.gecaolunjing;
|
import gecaoji.lujingdraw;
|
import dikuai.Dikuaiguanli;
|
import dikuai.Dikuai;
|
import zhangaiwu.Obstacledraw;
|
import zhangaiwu.Obstacledge;
|
import zhangaiwu.yulanzhangaiwu;
|
import yaokong.Control03;
|
|
/**
|
* 地图渲染器 - 负责坐标系绘制、视图变换等功能
|
*/
|
public class MapRenderer {
|
// 视图变换参数
|
private static final double DEFAULT_SCALE = 20.0; // 默认缩放比例
|
private double scale = DEFAULT_SCALE;
|
private double translateX = 0.0;
|
private double translateY = 0.0;
|
private Point lastDragPoint;
|
private static final double MIN_SCALE = 0.05d;
|
private static final double MAX_SCALE = 50.0d;
|
private static final double SCALE_EPSILON = 1e-6d;
|
private static final String MAP_SCALE_PROPERTY = "mapScale"; // 属性文件中的键名
|
|
// 主题颜色
|
private final Color THEME_COLOR = new Color(46, 139, 87);
|
private final Color BACKGROUND_COLOR = new Color(250, 250, 250);
|
private static final Color GRASS_FILL_COLOR = new Color(144, 238, 144, 120);
|
private static final Color GRASS_BORDER_COLOR = new Color(60, 179, 113);
|
private static final Color BOUNDARY_POINT_COLOR = new Color(128, 0, 128);
|
private static final Color OBSTACLE_POINT_COLOR = new Color(255, 140, 0); // 橙色,用于区分障碍物点
|
private static final Color CIRCLE_SAMPLE_COLOR = new Color(220, 20, 60, 230);
|
private static final double CIRCLE_SAMPLE_SIZE = 0.54d;
|
private static final double BOUNDARY_POINT_MERGE_THRESHOLD = 0.05;
|
private static final double BOUNDARY_CONTAINS_TOLERANCE = 0.05;
|
private static final double PREVIEW_BOUNDARY_MARKER_SCALE = 0.25d;
|
|
// 组件引用
|
private JPanel visualizationPanel;
|
private List<Point2D.Double> currentBoundary;
|
private Rectangle2D.Double boundaryBounds;
|
private Path2D.Double currentBoundaryPath;
|
private List<Point2D.Double> currentPlannedPath;
|
private Rectangle2D.Double plannedPathBounds;
|
private List<Obstacledge.Obstacle> currentObstacles;
|
private Rectangle2D.Double obstacleBounds;
|
private String selectedObstacleName;
|
private String currentObstacleLandNumber;
|
private String boundaryName;
|
private boolean boundaryPointsVisible;
|
private boolean obstaclePointsVisible;
|
private double boundaryPointSizeScale = 1.0d;
|
private boolean previewSizingEnabled;
|
private String currentBoundaryLandNumber;
|
private boolean dragInProgress;
|
private final Gecaoji mower;
|
private final Timer mowerUpdateTimer;
|
private final GecaojiMeg mowerInfoManager;
|
private CircleCaptureOverlay circleCaptureOverlay;
|
private final List<double[]> circleSampleMarkers = new ArrayList<>();
|
private final List<Point2D.Double> realtimeMowingTrack = new ArrayList<>();
|
private final Deque<tuowei.TrailSample> idleMowerTrail = new ArrayDeque<>();
|
private final List<Point2D.Double> handheldBoundaryPreview = new ArrayList<>();
|
private double boundaryPreviewMarkerScale = 1.0d;
|
private boolean realtimeTrackRecording;
|
private String realtimeTrackLandNumber;
|
private double mowerEffectiveWidthMeters;
|
private double defaultMowerWidthMeters;
|
private double totalLandAreaSqMeters;
|
private double trackLengthMeters;
|
private double completedMowingAreaSqMeters;
|
private double mowingCompletionRatio;
|
private long lastTrackPersistTimeMillis;
|
private boolean trackDirty;
|
private boolean handheldBoundaryPreviewActive;
|
private boolean pendingTrackBreak = true;
|
private boolean idleTrailSuppressed;
|
private Path2D.Double realtimeBoundaryPathCache;
|
private String realtimeBoundaryPathLand;
|
|
private static final double TRACK_SAMPLE_MIN_DISTANCE_METERS = 0.2d;
|
private static final double TRACK_DUPLICATE_TOLERANCE_METERS = 1e-3d;
|
private static final long TRACK_PERSIST_INTERVAL_MS = 5_000L;
|
public static final int DEFAULT_IDLE_TRAIL_DURATION_SECONDS = 60;
|
private static final double IDLE_TRAIL_SAMPLE_DISTANCE_METERS = 0.05d;
|
private long idleTrailDurationMs = DEFAULT_IDLE_TRAIL_DURATION_SECONDS * 1_000L;
|
private static final double ZOOM_STEP_FACTOR = 1.2d;
|
|
public MapRenderer(JPanel visualizationPanel) {
|
this.visualizationPanel = visualizationPanel;
|
this.mower = new Gecaoji();
|
this.mowerUpdateTimer = createMowerTimer();
|
this.mowerInfoManager = new GecaojiMeg(visualizationPanel, mower);
|
setupMouseListeners();
|
// 从配置文件读取上次保存的缩放比例和视图中心坐标
|
loadViewSettingsFromProperties();
|
}
|
|
/**
|
* 从配置文件读取缩放比例和视图中心坐标
|
*/
|
private void loadViewSettingsFromProperties() {
|
// 加载缩放比例
|
String scaleValue = Setsys.getPropertyValue(MAP_SCALE_PROPERTY);
|
if (scaleValue != null && !scaleValue.trim().isEmpty()) {
|
try {
|
double savedScale = Double.parseDouble(scaleValue.trim());
|
// 验证缩放比例是否在有效范围内
|
if (savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) {
|
scale = savedScale;
|
} else {
|
scale = DEFAULT_SCALE;
|
}
|
} catch (NumberFormatException e) {
|
// 如果解析失败,使用默认值
|
scale = DEFAULT_SCALE;
|
}
|
} else {
|
// 如果没有保存的值,使用默认值
|
scale = DEFAULT_SCALE;
|
}
|
|
// 加载视图中心坐标
|
String viewCenterXValue = Setsys.getPropertyValue("viewCenterX");
|
String viewCenterYValue = Setsys.getPropertyValue("viewCenterY");
|
if (viewCenterXValue != null && !viewCenterXValue.trim().isEmpty()) {
|
try {
|
translateX = Double.parseDouble(viewCenterXValue.trim());
|
} catch (NumberFormatException e) {
|
translateX = 0.0;
|
}
|
} else {
|
translateX = 0.0;
|
}
|
if (viewCenterYValue != null && !viewCenterYValue.trim().isEmpty()) {
|
try {
|
translateY = Double.parseDouble(viewCenterYValue.trim());
|
} catch (NumberFormatException e) {
|
translateY = 0.0;
|
}
|
} else {
|
translateY = 0.0;
|
}
|
}
|
|
/**
|
* 保存缩放比例到配置文件
|
*/
|
private void saveScaleToProperties() {
|
Setsys setsys = new Setsys();
|
// 保留2位小数
|
setsys.updateProperty(MAP_SCALE_PROPERTY, String.format("%.2f", scale));
|
}
|
|
/**
|
* 设置鼠标监听器
|
*/
|
private void setupMouseListeners() {
|
// 鼠标滚轮缩放
|
visualizationPanel.addMouseWheelListener(e -> {
|
Point referencePoint = e.getPoint();
|
int notches = e.getWheelRotation();
|
double zoomFactor = notches < 0 ? ZOOM_STEP_FACTOR : 1 / ZOOM_STEP_FACTOR;
|
zoomAtPoint(referencePoint, zoomFactor);
|
});
|
|
// 鼠标拖拽移动
|
visualizationPanel.addMouseListener(new MouseAdapter() {
|
public void mousePressed(MouseEvent e) {
|
if (SwingUtilities.isRightMouseButton(e)) {
|
resetView();
|
} else {
|
dragInProgress = false;
|
lastDragPoint = e.getPoint();
|
}
|
}
|
|
public void mouseReleased(MouseEvent e) {
|
lastDragPoint = null;
|
dragInProgress = false;
|
}
|
|
public void mouseClicked(MouseEvent e) {
|
if (dragInProgress) {
|
dragInProgress = false;
|
return;
|
}
|
if (!SwingUtilities.isLeftMouseButton(e) || e.getClickCount() != 1) {
|
return;
|
}
|
if (handleMowerClick(e.getPoint())) {
|
return;
|
}
|
// 优先处理障碍物边界点点击(如果可见)
|
if (obstaclePointsVisible && handleObstaclePointClick(e.getPoint())) {
|
return;
|
}
|
// 然后处理地块边界点点击
|
if (boundaryPointsVisible) {
|
handleBoundaryPointClick(e.getPoint());
|
}
|
}
|
});
|
|
visualizationPanel.addMouseMotionListener(new MouseAdapter() {
|
public void mouseDragged(MouseEvent e) {
|
if (lastDragPoint != null && !SwingUtilities.isRightMouseButton(e)) {
|
int dx = e.getX() - lastDragPoint.x;
|
int dy = e.getY() - lastDragPoint.y;
|
|
translateX += dx / scale;
|
translateY += dy / scale;
|
|
lastDragPoint = e.getPoint();
|
dragInProgress = true;
|
visualizationPanel.repaint();
|
}
|
}
|
});
|
}
|
|
private Timer createMowerTimer() {
|
Timer timer = new Timer(300, e -> {
|
mower.refreshFromDevice();
|
updateIdleMowerTrail();
|
if (realtimeTrackRecording) {
|
captureRealtimeTrackPoint();
|
}
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
});
|
timer.setInitialDelay(0);
|
timer.setRepeats(true);
|
timer.start();
|
return timer;
|
}
|
|
/**
|
* 基于鼠标位置的缩放
|
*/
|
private void zoomAtPoint(Point referencePoint, double zoomFactor) {
|
if (visualizationPanel == null) {
|
return;
|
}
|
if (referencePoint == null) {
|
referencePoint = new Point(visualizationPanel.getWidth() / 2, visualizationPanel.getHeight() / 2);
|
}
|
|
double panelCenterX = visualizationPanel.getWidth() / 2.0;
|
double panelCenterY = visualizationPanel.getHeight() / 2.0;
|
|
double worldX = (referencePoint.x - panelCenterX) / scale - translateX;
|
double worldY = (referencePoint.y - panelCenterY) / scale - translateY;
|
|
scale *= zoomFactor;
|
scale = Math.max(MIN_SCALE, Math.min(scale, MAX_SCALE)); // 限制缩放范围,允许最多缩小至原始的1/20
|
|
double newWorldX = (referencePoint.x - panelCenterX) / scale - translateX;
|
double newWorldY = (referencePoint.y - panelCenterY) / scale - translateY;
|
|
translateX += (newWorldX - worldX);
|
translateY += (newWorldY - worldY);
|
|
// 保存缩放比例到配置文件
|
saveScaleToProperties();
|
visualizationPanel.repaint();
|
}
|
|
public void zoomInFromCenter() {
|
zoomAtPoint(null, ZOOM_STEP_FACTOR);
|
}
|
|
public void zoomOutFromCenter() {
|
zoomAtPoint(null, 1 / ZOOM_STEP_FACTOR);
|
}
|
|
public boolean canZoomIn() {
|
return scale < MAX_SCALE - SCALE_EPSILON;
|
}
|
|
public boolean canZoomOut() {
|
return scale > MIN_SCALE + SCALE_EPSILON;
|
}
|
|
public double getScale() {
|
return scale;
|
}
|
|
public double getMaxScale() {
|
return MAX_SCALE;
|
}
|
|
public double getMinScale() {
|
return MIN_SCALE;
|
}
|
|
/**
|
* 重置视图
|
*/
|
public void resetView() {
|
scale = DEFAULT_SCALE;
|
translateX = 0.0;
|
translateY = 0.0;
|
// 保存缩放比例到配置文件
|
saveScaleToProperties();
|
visualizationPanel.repaint();
|
}
|
|
/**
|
* 绘制地图内容
|
*/
|
public void renderMap(Graphics g) {
|
Graphics2D g2d = (Graphics2D) g;
|
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
|
// 保存原始变换
|
AffineTransform originalTransform = g2d.getTransform();
|
|
// 应用视图变换
|
g2d.translate(visualizationPanel.getWidth()/2, visualizationPanel.getHeight()/2);
|
g2d.scale(scale, scale);
|
g2d.translate(translateX, translateY);
|
|
// 绘制坐标系内容
|
drawCoordinateSystem(g2d);
|
|
boolean hasBoundary = currentBoundary != null && currentBoundary.size() >= 2;
|
boolean hasPlannedPath = currentPlannedPath != null && currentPlannedPath.size() >= 2;
|
boolean hasObstacles = currentObstacles != null && !currentObstacles.isEmpty();
|
|
// 绘制地块边界(底层)
|
if (hasBoundary) {
|
drawCurrentBoundary(g2d);
|
}
|
|
yulanzhangaiwu.renderPreview(g2d, scale);
|
|
if (!circleSampleMarkers.isEmpty()) {
|
drawCircleSampleMarkers(g2d, circleSampleMarkers, scale);
|
}
|
|
if (circleCaptureOverlay != null) {
|
drawCircleCaptureOverlay(g2d, circleCaptureOverlay, scale);
|
}
|
|
adddikuaiyulan.drawPreview(g2d, handheldBoundaryPreview, scale, handheldBoundaryPreviewActive, boundaryPreviewMarkerScale);
|
|
// 绘制导航路径(中层)
|
if (hasPlannedPath) {
|
drawCurrentPlannedPath(g2d);
|
}
|
|
// 绘制障碍物(顶层,显示在地块和导航路径上方)
|
if (hasObstacles) {
|
Obstacledraw.drawObstacles(g2d, currentObstacles, scale, selectedObstacleName);
|
}
|
|
if (boundaryPointsVisible && hasBoundary) {
|
// 预览模式下显示序号
|
if (previewSizingEnabled) {
|
drawBoundaryPointsWithNumbers(g2d, currentBoundary, scale);
|
} else {
|
double markerScale = boundaryPointSizeScale;
|
pointandnumber.drawBoundaryPoints(
|
g2d,
|
currentBoundary,
|
scale,
|
BOUNDARY_POINT_MERGE_THRESHOLD,
|
BOUNDARY_POINT_COLOR,
|
markerScale
|
);
|
}
|
}
|
|
// 绘制障碍物坐标点(带序号)
|
if (obstaclePointsVisible && hasObstacles) {
|
drawObstaclePointsWithNumbers(g2d, currentObstacles, scale);
|
}
|
|
if (shouldRenderIdleTrail()) {
|
tuowei.draw(g2d, idleMowerTrail, scale);
|
}
|
|
if (!realtimeMowingTrack.isEmpty()) {
|
drawRealtimeMowingCoverage(g2d);
|
}
|
|
drawMower(g2d);
|
|
// 恢复原始变换
|
g2d.setTransform(originalTransform);
|
|
// 绘制视图信息
|
drawViewInfo(g2d);
|
}
|
|
/**
|
* 绘制坐标系
|
*/
|
private void drawCoordinateSystem(Graphics2D g2d) {
|
// 绘制原点 - 红色实心小圆圈
|
g2d.setColor(Color.RED);
|
g2d.fill(new Ellipse2D.Double(-0.5d, -0.5d, 1d, 1d));
|
}
|
|
|
|
/**
|
* 绘制割草机
|
*/
|
private void drawMower(Graphics2D g2d) {
|
mower.draw(g2d, scale);
|
}
|
|
private void drawRealtimeMowingCoverage(Graphics2D g2d) {
|
if (realtimeMowingTrack == null || realtimeMowingTrack.size() < 2) {
|
return;
|
}
|
|
Path2D.Double boundaryPath = getRealtimeBoundaryPath();
|
double effectiveWidth = getEffectiveMowerWidthMeters();
|
gecaolunjing.draw(g2d, realtimeMowingTrack, effectiveWidth, boundaryPath);
|
}
|
|
private Path2D.Double getRealtimeBoundaryPath() {
|
if (realtimeTrackLandNumber == null) {
|
return null;
|
}
|
|
if (currentBoundaryLandNumber != null && realtimeTrackLandNumber.equals(currentBoundaryLandNumber)) {
|
if (currentBoundaryPath == null) {
|
currentBoundaryPath = buildBoundaryPath(currentBoundary);
|
}
|
return currentBoundaryPath;
|
}
|
|
if (realtimeBoundaryPathCache != null && realtimeTrackLandNumber.equals(realtimeBoundaryPathLand)) {
|
return realtimeBoundaryPathCache;
|
}
|
|
Dikuai dikuai = Dikuai.getDikuai(realtimeTrackLandNumber);
|
if (dikuai == null) {
|
realtimeBoundaryPathCache = null;
|
realtimeBoundaryPathLand = null;
|
return null;
|
}
|
|
String normalized = normalizeValue(dikuai.getBoundaryCoordinates());
|
if (normalized == null) {
|
realtimeBoundaryPathCache = null;
|
realtimeBoundaryPathLand = null;
|
return null;
|
}
|
|
List<Point2D.Double> parsed = parseBoundary(normalized);
|
if (parsed.size() < 3) {
|
realtimeBoundaryPathCache = null;
|
realtimeBoundaryPathLand = null;
|
return null;
|
}
|
|
realtimeBoundaryPathCache = buildBoundaryPath(parsed);
|
realtimeBoundaryPathLand = realtimeTrackLandNumber;
|
return realtimeBoundaryPathCache;
|
}
|
|
private boolean shouldRenderIdleTrail() {
|
return !idleTrailSuppressed
|
&& !realtimeTrackRecording
|
&& !handheldBoundaryPreviewActive
|
&& idleMowerTrail.size() >= 2;
|
}
|
|
private void captureRealtimeTrackPoint() {
|
if (!realtimeTrackRecording) {
|
return;
|
}
|
if (realtimeTrackLandNumber == null || visualizationPanel == null) {
|
pendingTrackBreak = true;
|
return;
|
}
|
Device device = Device.getGecaoji();
|
if (device == null) {
|
pendingTrackBreak = true;
|
return;
|
}
|
|
String fixQuality = device.getPositioningStatus();
|
if (!isHighPrecisionFix(fixQuality)) {
|
pendingTrackBreak = true;
|
return;
|
}
|
Point2D.Double position = mower.getPosition();
|
if (position == null || !Double.isFinite(position.x) || !Double.isFinite(position.y)) {
|
pendingTrackBreak = true;
|
return;
|
}
|
|
if (!isPointInsideActiveBoundary(position)) {
|
pendingTrackBreak = true;
|
return;
|
}
|
|
Point2D.Double candidate = new Point2D.Double(position.x, position.y);
|
Point2D.Double lastPoint = realtimeMowingTrack.isEmpty() ? null : realtimeMowingTrack.get(realtimeMowingTrack.size() - 1);
|
double distance = Double.NaN;
|
if (lastPoint != null) {
|
double dx = candidate.x - lastPoint.x;
|
double dy = candidate.y - lastPoint.y;
|
distance = Math.hypot(dx, dy);
|
if (distance <= TRACK_DUPLICATE_TOLERANCE_METERS) {
|
return;
|
}
|
if (distance < TRACK_SAMPLE_MIN_DISTANCE_METERS) {
|
return;
|
}
|
}
|
|
realtimeMowingTrack.add(candidate);
|
if (!pendingTrackBreak && lastPoint != null && Double.isFinite(distance)) {
|
trackLengthMeters += distance;
|
}
|
|
updateCompletionMetrics();
|
trackDirty = true;
|
maybePersistRealtimeTrack(false);
|
pendingTrackBreak = false;
|
}
|
|
private void updateIdleMowerTrail() {
|
long now = System.currentTimeMillis();
|
pruneIdleMowerTrail(now);
|
|
if (idleTrailSuppressed || realtimeTrackRecording) {
|
if (!idleMowerTrail.isEmpty()) {
|
clearIdleMowerTrail();
|
}
|
return;
|
}
|
|
Device device = Device.getGecaoji();
|
if (device == null) {
|
return;
|
}
|
// 使用更宽松的定位状态判断,允许状态1和4显示拖尾
|
if (!isValidFixForTrail(device.getPositioningStatus())) {
|
return;
|
}
|
|
Point2D.Double position = mower.getPosition();
|
if (position == null || !Double.isFinite(position.x) || !Double.isFinite(position.y)) {
|
return;
|
}
|
|
tuowei.TrailSample lastSample = idleMowerTrail.peekLast();
|
if (lastSample != null) {
|
Point2D.Double lastPoint = lastSample.getPoint();
|
double dx = position.x - lastPoint.x;
|
double dy = position.y - lastPoint.y;
|
if (Math.hypot(dx, dy) < IDLE_TRAIL_SAMPLE_DISTANCE_METERS) {
|
return;
|
}
|
}
|
|
idleMowerTrail.addLast(new tuowei.TrailSample(now, new Point2D.Double(position.x, position.y)));
|
pruneIdleMowerTrail(now);
|
}
|
|
/**
|
* 强制更新拖尾(用于收到$GNGGA数据时立即更新)
|
* 这个方法会刷新mower位置并立即添加到拖尾
|
*/
|
public void forceUpdateIdleMowerTrail() {
|
long now = System.currentTimeMillis();
|
pruneIdleMowerTrail(now);
|
|
if (idleTrailSuppressed || realtimeTrackRecording) {
|
if (!idleMowerTrail.isEmpty()) {
|
clearIdleMowerTrail();
|
}
|
return;
|
}
|
|
Device device = Device.getGecaoji();
|
if (device == null) {
|
return;
|
}
|
// 使用更宽松的定位状态判断,允许状态1和4显示拖尾
|
if (!isValidFixForTrail(device.getPositioningStatus())) {
|
return;
|
}
|
|
// 刷新mower位置,使用最新的Device数据
|
mower.refreshFromDevice();
|
Point2D.Double position = mower.getPosition();
|
if (position == null || !Double.isFinite(position.x) || !Double.isFinite(position.y)) {
|
return;
|
}
|
|
tuowei.TrailSample lastSample = idleMowerTrail.peekLast();
|
if (lastSample != null) {
|
Point2D.Double lastPoint = lastSample.getPoint();
|
double dx = position.x - lastPoint.x;
|
double dy = position.y - lastPoint.y;
|
if (Math.hypot(dx, dy) < IDLE_TRAIL_SAMPLE_DISTANCE_METERS) {
|
return;
|
}
|
}
|
|
idleMowerTrail.addLast(new tuowei.TrailSample(now, new Point2D.Double(position.x, position.y)));
|
pruneIdleMowerTrail(now);
|
|
// 立即重绘,确保拖尾及时显示
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
private void pruneIdleMowerTrail(long now) {
|
if (idleMowerTrail.isEmpty()) {
|
return;
|
}
|
long cutoff = now - idleTrailDurationMs;
|
boolean modified = false;
|
while (!idleMowerTrail.isEmpty() && idleMowerTrail.peekFirst().getTimestamp() < cutoff) {
|
idleMowerTrail.removeFirst();
|
modified = true;
|
}
|
if (modified && visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
private void clearIdleMowerTrail() {
|
if (idleMowerTrail.isEmpty()) {
|
return;
|
}
|
idleMowerTrail.clear();
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
private void updateCompletionMetrics() {
|
double widthMeters = getEffectiveMowerWidthMeters();
|
if (widthMeters > 0 && trackLengthMeters > 0) {
|
completedMowingAreaSqMeters = trackLengthMeters * widthMeters;
|
} else {
|
completedMowingAreaSqMeters = 0.0;
|
}
|
|
if (totalLandAreaSqMeters > 0 && completedMowingAreaSqMeters >= 0) {
|
mowingCompletionRatio = Math.max(0.0, Math.min(1.0, completedMowingAreaSqMeters / totalLandAreaSqMeters));
|
} else {
|
mowingCompletionRatio = 0.0;
|
}
|
}
|
|
private void maybePersistRealtimeTrack(boolean force) {
|
if (!trackDirty) {
|
return;
|
}
|
long now = System.currentTimeMillis();
|
if (!force && (now - lastTrackPersistTimeMillis) < TRACK_PERSIST_INTERVAL_MS) {
|
return;
|
}
|
persistRealtimeTrack();
|
}
|
|
private void persistRealtimeTrack() {
|
if (realtimeTrackLandNumber == null) {
|
trackDirty = false;
|
return;
|
}
|
String serialized = serializeRealtimeTrack();
|
String storedValue = (serialized == null || serialized.isEmpty()) ? "-1" : serialized;
|
boolean updated = Dikuai.updateField(realtimeTrackLandNumber, "mowingTrack", storedValue);
|
if (updated) {
|
Dikuai dikuai = Dikuai.getDikuai(realtimeTrackLandNumber);
|
if (dikuai != null) {
|
dikuai.setMowingTrack(storedValue);
|
}
|
Dikuai.saveToProperties();
|
trackDirty = false;
|
lastTrackPersistTimeMillis = System.currentTimeMillis();
|
}
|
}
|
|
private String serializeRealtimeTrack() {
|
if (realtimeMowingTrack.isEmpty()) {
|
return "";
|
}
|
StringBuilder builder = new StringBuilder();
|
for (Point2D.Double point : realtimeMowingTrack) {
|
if (point == null) {
|
continue;
|
}
|
if (builder.length() > 0) {
|
builder.append(';');
|
}
|
builder.append(formatTrackCoordinate(point.x)).append(',').append(formatTrackCoordinate(point.y));
|
}
|
return builder.toString();
|
}
|
|
private String formatTrackCoordinate(double value) {
|
if (!Double.isFinite(value)) {
|
return "0";
|
}
|
return String.format(Locale.US, "%.3f", value);
|
}
|
|
private double getEffectiveMowerWidthMeters() {
|
if (mowerEffectiveWidthMeters > 0) {
|
return mowerEffectiveWidthMeters;
|
}
|
if (defaultMowerWidthMeters > 0) {
|
return defaultMowerWidthMeters;
|
}
|
return 0.0;
|
}
|
|
public void applyLandMetadata(Dikuai dikuai) {
|
String landNumber = normalizeValue(dikuai != null ? dikuai.getLandNumber() : null);
|
totalLandAreaSqMeters = parseLandAreaSqMeters(dikuai != null ? dikuai.getLandArea() : null);
|
defaultMowerWidthMeters = parseMowerWidthMeters(dikuai != null ? dikuai.getMowingWidth() : null);
|
|
// 若当前未录制或切换地块,则更新有效割草宽度
|
if (!realtimeTrackRecording || !equalsLand(landNumber, realtimeTrackLandNumber)) {
|
mowerEffectiveWidthMeters = defaultMowerWidthMeters;
|
}
|
|
loadRealtimeTrack(landNumber, dikuai != null ? dikuai.getMowingTrack() : null);
|
visualizationPanel.repaint();
|
}
|
|
public void startRealtimeTrackRecording(String landNumber, double widthMeters) {
|
String normalizedLand = normalizeValue(landNumber);
|
if (normalizedLand == null) {
|
return;
|
}
|
|
if (!equalsLand(normalizedLand, realtimeTrackLandNumber)) {
|
Dikuai dikuai = Dikuai.getDikuai(normalizedLand);
|
totalLandAreaSqMeters = parseLandAreaSqMeters(dikuai != null ? dikuai.getLandArea() : null);
|
defaultMowerWidthMeters = parseMowerWidthMeters(dikuai != null ? dikuai.getMowingWidth() : null);
|
loadRealtimeTrack(normalizedLand, dikuai != null ? dikuai.getMowingTrack() : null);
|
}
|
|
if (widthMeters > 0) {
|
mowerEffectiveWidthMeters = widthMeters;
|
} else if (mowerEffectiveWidthMeters <= 0) {
|
mowerEffectiveWidthMeters = defaultMowerWidthMeters;
|
}
|
|
idleTrailSuppressed = true;
|
clearIdleMowerTrail();
|
|
realtimeTrackLandNumber = normalizedLand;
|
realtimeTrackRecording = true;
|
pendingTrackBreak = true;
|
captureRealtimeTrackPoint();
|
}
|
|
public void pauseRealtimeTrackRecording() {
|
realtimeTrackRecording = false;
|
pendingTrackBreak = true;
|
idleTrailSuppressed = false;
|
maybePersistRealtimeTrack(true);
|
}
|
|
public void stopRealtimeTrackRecording() {
|
realtimeTrackRecording = false;
|
pendingTrackBreak = true;
|
idleTrailSuppressed = false;
|
maybePersistRealtimeTrack(true);
|
}
|
|
public void forceRealtimeTrackSnapshot() {
|
if (!realtimeTrackRecording) {
|
return;
|
}
|
captureRealtimeTrackPoint();
|
}
|
|
public void clearRealtimeTrack() {
|
realtimeTrackRecording = false;
|
realtimeMowingTrack.clear();
|
trackLengthMeters = 0.0;
|
completedMowingAreaSqMeters = 0.0;
|
mowingCompletionRatio = 0.0;
|
trackDirty = true;
|
pendingTrackBreak = true;
|
idleTrailSuppressed = false;
|
maybePersistRealtimeTrack(true);
|
visualizationPanel.repaint();
|
}
|
|
public void clearIdleTrail() {
|
clearIdleMowerTrail();
|
}
|
|
public void setIdleTrailDurationSeconds(int seconds) {
|
int sanitized = seconds;
|
if (sanitized < 5 || sanitized > 600) {
|
sanitized = DEFAULT_IDLE_TRAIL_DURATION_SECONDS;
|
}
|
idleTrailDurationMs = sanitized * 1_000L;
|
pruneIdleMowerTrail(System.currentTimeMillis());
|
}
|
|
public int getIdleTrailDurationSeconds() {
|
long seconds = idleTrailDurationMs / 1_000L;
|
if (seconds <= 0L) {
|
return DEFAULT_IDLE_TRAIL_DURATION_SECONDS;
|
}
|
if (seconds > Integer.MAX_VALUE) {
|
return DEFAULT_IDLE_TRAIL_DURATION_SECONDS;
|
}
|
return (int) seconds;
|
}
|
|
public double getMowingCompletionRatio() {
|
if (!isMowerInsideSelectedBoundary()) {
|
return 0.0;
|
}
|
return mowingCompletionRatio;
|
}
|
|
public double getCompletedMowingAreaSqMeters() {
|
if (!isMowerInsideSelectedBoundary()) {
|
return 0.0;
|
}
|
return completedMowingAreaSqMeters;
|
}
|
|
public double getTotalLandAreaSqMeters() {
|
return totalLandAreaSqMeters;
|
}
|
|
public double getTrackLengthMeters() {
|
return trackLengthMeters;
|
}
|
|
private boolean isMowerInsideSelectedBoundary() {
|
Point2D.Double position = mower.getPosition();
|
if (position == null) {
|
return false;
|
}
|
return isPointInsideActiveBoundary(position);
|
}
|
|
public void flushRealtimeTrack() {
|
maybePersistRealtimeTrack(true);
|
}
|
|
private void loadRealtimeTrack(String landNumber, String trackData) {
|
realtimeTrackRecording = false;
|
realtimeTrackLandNumber = landNumber;
|
realtimeMowingTrack.clear();
|
trackLengthMeters = 0.0;
|
completedMowingAreaSqMeters = 0.0;
|
mowingCompletionRatio = 0.0;
|
trackDirty = false;
|
lastTrackPersistTimeMillis = 0L;
|
pendingTrackBreak = true;
|
realtimeBoundaryPathCache = null;
|
realtimeBoundaryPathLand = null;
|
|
String trimmed = normalizeValue(trackData);
|
if (trimmed == null || trimmed.isEmpty()) {
|
updateCompletionMetrics();
|
return;
|
}
|
|
String[] segments = trimmed.split(";");
|
Path2D.Double boundaryPath = getRealtimeBoundaryPath();
|
Point2D.Double lastPoint = null;
|
for (String segment : segments) {
|
if (segment == null || segment.trim().isEmpty()) {
|
continue;
|
}
|
String[] parts = segment.trim().split(",");
|
if (parts.length < 2) {
|
continue;
|
}
|
try {
|
double x = Double.parseDouble(parts[0].trim());
|
double y = Double.parseDouble(parts[1].trim());
|
if (!Double.isFinite(x) || !Double.isFinite(y)) {
|
continue;
|
}
|
Point2D.Double current = new Point2D.Double(x, y);
|
if (boundaryPath != null && !isPointInsideBoundary(current, boundaryPath)) {
|
continue;
|
}
|
if (lastPoint != null) {
|
double dx = current.x - lastPoint.x;
|
double dy = current.y - lastPoint.y;
|
double distance = Math.hypot(dx, dy);
|
if (distance <= TRACK_DUPLICATE_TOLERANCE_METERS) {
|
continue;
|
}
|
if (distance < TRACK_SAMPLE_MIN_DISTANCE_METERS) {
|
continue;
|
}
|
trackLengthMeters += distance;
|
}
|
realtimeMowingTrack.add(current);
|
lastPoint = current;
|
} catch (NumberFormatException ignored) {
|
// 跳过异常条目
|
}
|
}
|
|
updateCompletionMetrics();
|
}
|
|
private double parseLandAreaSqMeters(String raw) {
|
if (raw == null) {
|
return 0.0;
|
}
|
String trimmed = raw.trim();
|
if (trimmed.isEmpty() || "-1".equals(trimmed)) {
|
return 0.0;
|
}
|
try {
|
double area = Double.parseDouble(trimmed);
|
return area > 0 ? area : 0.0;
|
} catch (NumberFormatException ex) {
|
return 0.0;
|
}
|
}
|
|
private double parseMowerWidthMeters(String raw) {
|
if (raw == null) {
|
return 0.0;
|
}
|
String sanitized = raw.trim().toLowerCase(Locale.ROOT);
|
if (sanitized.isEmpty() || "-1".equals(sanitized)) {
|
return 0.0;
|
}
|
sanitized = sanitized.replace("厘米", "cm");
|
sanitized = sanitized.replace("公分", "cm");
|
sanitized = sanitized.replace("米", "m");
|
sanitized = sanitized.replace("cm", "");
|
sanitized = sanitized.replace("m", "");
|
sanitized = sanitized.trim();
|
if (sanitized.isEmpty()) {
|
return 0.0;
|
}
|
try {
|
double value = Double.parseDouble(sanitized);
|
if (value <= 0) {
|
return 0.0;
|
}
|
if (value > 10) {
|
return value / 100.0; // 视为厘米
|
}
|
return value;
|
} catch (NumberFormatException ex) {
|
return 0.0;
|
}
|
}
|
|
private String normalizeValue(String value) {
|
if (value == null) {
|
return null;
|
}
|
String trimmed = value.trim();
|
if (trimmed.isEmpty() || "-1".equals(trimmed)) {
|
return null;
|
}
|
return trimmed;
|
}
|
|
private boolean equalsLand(String a, String b) {
|
if (a == null && b == null) {
|
return true;
|
}
|
if (a == null || b == null) {
|
return false;
|
}
|
return a.equals(b);
|
}
|
|
private boolean handleMowerClick(Point screenPoint) {
|
if (!mower.hasValidPosition()) {
|
return false;
|
}
|
Point2D.Double mowerPosition = mower.getPosition();
|
if (mowerPosition == null) {
|
return false;
|
}
|
Point2D.Double worldPoint = screenToWorld(screenPoint);
|
double radius = mower.getWorldRadius(scale);
|
if (Double.isNaN(radius)) {
|
return false;
|
}
|
double dx = worldPoint.x - mowerPosition.x;
|
double dy = worldPoint.y - mowerPosition.y;
|
if (dx * dx + dy * dy <= radius * radius) {
|
showMowerInfo();
|
return true;
|
}
|
return false;
|
}
|
|
private Point2D.Double screenToWorld(Point screenPoint) {
|
double worldX = (screenPoint.x - visualizationPanel.getWidth() / 2.0) / scale - translateX;
|
double worldY = (screenPoint.y - visualizationPanel.getHeight() / 2.0) / scale - translateY;
|
return new Point2D.Double(worldX, worldY);
|
}
|
|
private void drawCurrentBoundary(Graphics2D g2d) {
|
bianjiedrwa.drawBoundary(g2d, currentBoundary, scale, GRASS_FILL_COLOR, GRASS_BORDER_COLOR);
|
}
|
|
private void drawCurrentPlannedPath(Graphics2D g2d) {
|
double arrowScale = previewSizingEnabled ? 0.5d : 1.0d;
|
lujingdraw.drawPlannedPath(g2d, currentPlannedPath, scale, arrowScale);
|
}
|
|
private void drawCircleSampleMarkers(Graphics2D g2d, List<double[]> markers, double scale) {
|
if (markers == null || markers.isEmpty()) {
|
return;
|
}
|
Shape markerShape;
|
double half = CIRCLE_SAMPLE_SIZE / 2.0;
|
g2d.setColor(CIRCLE_SAMPLE_COLOR);
|
g2d.setStroke(new BasicStroke((float) (1.2f / scale), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
|
Font originalFont = g2d.getFont();
|
float baseSize = (float) Math.max(12f / scale, 9f);
|
float reducedSize = Math.max(baseSize / 3f, 3f);
|
Font labelFont = originalFont.deriveFont(reducedSize);
|
g2d.setFont(labelFont);
|
FontMetrics metrics = g2d.getFontMetrics();
|
for (double[] pt : markers) {
|
if (pt == null || pt.length < 2 || !Double.isFinite(pt[0]) || !Double.isFinite(pt[1])) {
|
continue;
|
}
|
double x = pt[0];
|
double y = pt[1];
|
markerShape = new Ellipse2D.Double(x - half, y - half, CIRCLE_SAMPLE_SIZE, CIRCLE_SAMPLE_SIZE);
|
g2d.fill(markerShape);
|
String label = String.format(Locale.US, "%.2f,%.2f", x, y);
|
int textWidth = metrics.stringWidth(label);
|
float textX = (float) (x - textWidth / 2.0);
|
float textY = (float) (y - half - 0.2d) - metrics.getDescent();
|
g2d.setColor(new Color(33, 37, 41, 220));
|
g2d.drawString(label, textX, textY);
|
g2d.setColor(CIRCLE_SAMPLE_COLOR);
|
}
|
g2d.setFont(originalFont);
|
}
|
|
private void drawCircleCaptureOverlay(Graphics2D g2d, CircleCaptureOverlay overlay, double scale) {
|
double diameter = overlay.radius * 2.0;
|
Ellipse2D outline = new Ellipse2D.Double(
|
overlay.centerX - overlay.radius,
|
overlay.centerY - overlay.radius,
|
diameter,
|
diameter);
|
|
Color fillColor = new Color(255, 152, 0, 80);
|
Color borderColor = new Color(255, 87, 34, 230);
|
Color centerColor = new Color(46, 139, 87, 230);
|
|
g2d.setColor(fillColor);
|
g2d.fill(outline);
|
|
g2d.setColor(borderColor);
|
g2d.setStroke(new BasicStroke((float) (1.8f / scale), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
|
g2d.draw(outline);
|
|
double markerSize = 0.18d;
|
double centerMarkerSize = 0.22d;
|
|
Ellipse2D centerMarker = new Ellipse2D.Double(
|
overlay.centerX - centerMarkerSize / 2.0,
|
overlay.centerY - centerMarkerSize / 2.0,
|
centerMarkerSize,
|
centerMarkerSize);
|
g2d.setColor(centerColor);
|
g2d.fill(centerMarker);
|
}
|
|
public void showCircleCaptureOverlay(double centerX, double centerY, double radius, List<double[]> samplePoints) {
|
List<double[]> copies = new ArrayList<>();
|
if (samplePoints != null) {
|
for (double[] pt : samplePoints) {
|
if (pt == null || pt.length < 2) {
|
continue;
|
}
|
copies.add(new double[]{pt[0], pt[1]});
|
}
|
}
|
circleCaptureOverlay = new CircleCaptureOverlay(centerX, centerY, radius, copies);
|
updateCircleSampleMarkers(samplePoints);
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
public void clearCircleCaptureOverlay() {
|
circleCaptureOverlay = null;
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
public void updateCircleSampleMarkers(List<double[]> samplePoints) {
|
circleSampleMarkers.clear();
|
if (samplePoints != null) {
|
for (double[] pt : samplePoints) {
|
if (pt == null || pt.length < 2) {
|
continue;
|
}
|
double x = pt[0];
|
double y = pt[1];
|
if (!Double.isFinite(x) || !Double.isFinite(y)) {
|
continue;
|
}
|
circleSampleMarkers.add(new double[]{x, y});
|
}
|
}
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
|
public void clearCircleSampleMarkers() {
|
if (!circleSampleMarkers.isEmpty()) {
|
circleSampleMarkers.clear();
|
if (visualizationPanel != null) {
|
visualizationPanel.repaint();
|
}
|
}
|
}
|
|
private static final class CircleCaptureOverlay {
|
final double centerX;
|
final double centerY;
|
final double radius;
|
final List<double[]> samplePoints;
|
|
CircleCaptureOverlay(double centerX, double centerY, double radius, List<double[]> samplePoints) {
|
this.centerX = centerX;
|
this.centerY = centerY;
|
this.radius = radius;
|
this.samplePoints = samplePoints;
|
}
|
}
|
|
public void showMowerInfo() {
|
if (mowerInfoManager != null) {
|
mowerInfoManager.showMowerInfo();
|
}
|
}
|
|
/**
|
* 处理障碍物边界点点击
|
* @param screenPoint 屏幕坐标点
|
* @return 如果处理了点击返回true,否则返回false
|
*/
|
private boolean handleObstaclePointClick(Point screenPoint) {
|
if (currentObstacles == null || currentObstacles.isEmpty() || currentObstacleLandNumber == null) {
|
return false;
|
}
|
|
double threshold = computeSelectionThresholdPixels();
|
|
// 遍历所有障碍物,找到被点击的点
|
for (Obstacledge.Obstacle obstacle : currentObstacles) {
|
if (obstacle == null) {
|
continue;
|
}
|
|
List<Obstacledge.XYCoordinate> xyCoords = obstacle.getXyCoordinates();
|
if (xyCoords == null || xyCoords.isEmpty()) {
|
continue;
|
}
|
|
// 检查每个点
|
for (int i = 0; i < xyCoords.size(); i++) {
|
Obstacledge.XYCoordinate coord = xyCoords.get(i);
|
Point2D.Double worldPoint = new Point2D.Double(coord.getX(), coord.getY());
|
Point2D.Double screenPosition = worldToScreen(worldPoint);
|
|
double dx = screenPosition.x - screenPoint.x;
|
double dy = screenPosition.y - screenPoint.y;
|
if (Math.hypot(dx, dy) <= threshold) {
|
// 找到被点击的点
|
String obstacleName = obstacle.getObstacleName();
|
String pointLabel = (i + 1) + "";
|
String message = "确定要删除障碍物 \"" + obstacleName + "\" 的第" + pointLabel + "号边界点吗?";
|
|
int choice = JOptionPane.showConfirmDialog(
|
visualizationPanel,
|
message,
|
"删除障碍物边界点",
|
JOptionPane.OK_CANCEL_OPTION,
|
JOptionPane.WARNING_MESSAGE
|
);
|
|
if (choice == JOptionPane.OK_OPTION) {
|
removeObstaclePoint(obstacle, i);
|
}
|
return true;
|
}
|
}
|
}
|
|
return false;
|
}
|
|
/**
|
* 删除障碍物的指定边界点
|
*/
|
private void removeObstaclePoint(Obstacledge.Obstacle obstacle, int pointIndex) {
|
if (obstacle == null || currentObstacleLandNumber == null) {
|
return;
|
}
|
|
List<Obstacledge.XYCoordinate> xyCoords = obstacle.getXyCoordinates();
|
if (xyCoords == null || pointIndex < 0 || pointIndex >= xyCoords.size()) {
|
return;
|
}
|
|
// 检查删除后是否还有足够的点
|
Obstacledge.ObstacleShape shape = obstacle.getShape();
|
int minPoints = (shape == Obstacledge.ObstacleShape.CIRCLE) ? 2 : 3;
|
|
if (xyCoords.size() <= minPoints) {
|
JOptionPane.showMessageDialog(
|
visualizationPanel,
|
"障碍物至少需要" + minPoints + "个点,无法删除",
|
"提示",
|
JOptionPane.INFORMATION_MESSAGE
|
);
|
return;
|
}
|
|
// 创建新的坐标列表(移除指定点)
|
List<Obstacledge.XYCoordinate> updatedCoords = new ArrayList<>(xyCoords);
|
updatedCoords.remove(pointIndex);
|
|
// 更新障碍物坐标
|
obstacle.setXyCoordinates(updatedCoords);
|
|
// 保存到配置文件
|
try {
|
File configFile = new File("Obstacledge.properties");
|
Obstacledge.ConfigManager manager = new Obstacledge.ConfigManager();
|
if (configFile.exists()) {
|
manager.loadFromFile(configFile.getAbsolutePath());
|
}
|
|
Obstacledge.Plot plot = manager.getPlotById(currentObstacleLandNumber.trim());
|
if (plot != null) {
|
// 移除旧障碍物并添加更新后的障碍物
|
plot.removeObstacleByName(obstacle.getObstacleName());
|
plot.addObstacle(obstacle);
|
manager.saveToFile(configFile.getAbsolutePath());
|
|
// 更新地块更新时间
|
Dikuai.updateField(currentObstacleLandNumber.trim(), "updateTime",
|
new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date()));
|
Dikuai.saveToProperties();
|
|
// 重新加载障碍物数据以刷新显示
|
List<Obstacledge.Obstacle> updatedObstacles = new ArrayList<>();
|
for (Obstacledge.Obstacle obs : currentObstacles) {
|
if (obs.getObstacleName().equals(obstacle.getObstacleName())) {
|
updatedObstacles.add(obstacle); // 使用更新后的障碍物
|
} else {
|
updatedObstacles.add(obs); // 保持其他障碍物不变
|
}
|
}
|
applyObstaclesToRenderer(updatedObstacles, currentObstacleLandNumber);
|
visualizationPanel.repaint();
|
}
|
} catch (Exception ex) {
|
ex.printStackTrace();
|
JOptionPane.showMessageDialog(
|
visualizationPanel,
|
"保存失败: " + ex.getMessage(),
|
"错误",
|
JOptionPane.ERROR_MESSAGE
|
);
|
}
|
}
|
|
private void handleBoundaryPointClick(Point screenPoint) {
|
if (currentBoundary == null || currentBoundaryLandNumber == null) {
|
return;
|
}
|
int hitIndex = findBoundaryPointIndex(screenPoint);
|
if (hitIndex < 0) {
|
return;
|
}
|
|
String pointLabel = String.valueOf(hitIndex + 1);
|
int choice = JOptionPane.showConfirmDialog(
|
visualizationPanel,
|
"确定要删除第" + pointLabel + "号边界点吗?",
|
"删除边界点",
|
JOptionPane.OK_CANCEL_OPTION,
|
JOptionPane.WARNING_MESSAGE
|
);
|
|
if (choice == JOptionPane.OK_OPTION) {
|
removeBoundaryPoint(hitIndex);
|
}
|
}
|
|
private int findBoundaryPointIndex(Point screenPoint) {
|
if (currentBoundary == null || currentBoundary.size() < 2) {
|
return -1;
|
}
|
boolean closed = isBoundaryClosed(currentBoundary);
|
int effectiveCount = closed ? currentBoundary.size() - 1 : currentBoundary.size();
|
if (effectiveCount <= 0) {
|
return -1;
|
}
|
|
double threshold = computeSelectionThresholdPixels();
|
|
for (int i = 0; i < effectiveCount; i++) {
|
Point2D.Double worldPoint = currentBoundary.get(i);
|
Point2D.Double screenPosition = worldToScreen(worldPoint);
|
double dx = screenPosition.x - screenPoint.x;
|
double dy = screenPosition.y - screenPoint.y;
|
if (Math.hypot(dx, dy) <= threshold) {
|
return i;
|
}
|
}
|
return -1;
|
}
|
|
private double computeSelectionThresholdPixels() {
|
double scaleFactor = Math.max(0.5, scale);
|
double diameterScale = boundaryPointSizeScale * (previewSizingEnabled ? PREVIEW_BOUNDARY_MARKER_SCALE : 1.0d);
|
if (!Double.isFinite(diameterScale) || diameterScale <= 0.0d) {
|
diameterScale = 1.0d;
|
}
|
double markerDiameterWorld = Math.max(1.0, (10.0 / scaleFactor) * 0.2 * diameterScale);
|
double markerDiameterPixels = markerDiameterWorld * scale;
|
return Math.max(8.0, markerDiameterPixels * 1.5);
|
}
|
|
private Point2D.Double worldToScreen(Point2D.Double worldPoint) {
|
double screenX = (worldPoint.x + translateX) * scale + visualizationPanel.getWidth() / 2.0;
|
double screenY = (worldPoint.y + translateY) * scale + visualizationPanel.getHeight() / 2.0;
|
return new Point2D.Double(screenX, screenY);
|
}
|
|
private void removeBoundaryPoint(int index) {
|
if (currentBoundary == null || currentBoundary.size() < 2) {
|
return;
|
}
|
|
List<Point2D.Double> updated = new ArrayList<>(currentBoundary);
|
boolean closed = isBoundaryClosed(updated);
|
int effectiveCount = closed ? updated.size() - 1 : updated.size();
|
if (index < 0 || index >= effectiveCount) {
|
return;
|
}
|
|
updated.remove(index);
|
|
if (closed && updated.size() >= 2) {
|
Point2D.Double first = updated.get(0);
|
Point2D.Double last = updated.get(updated.size() - 1);
|
if (!arePointsClose(first, last)) {
|
updated.set(updated.size() - 1, new Point2D.Double(first.x, first.y));
|
}
|
}
|
|
boolean success = persistBoundaryChanges(updated);
|
if (!success) {
|
return;
|
}
|
|
if (updated.size() < 2) {
|
currentBoundary = null;
|
currentBoundaryPath = null;
|
boundaryBounds = null;
|
boundaryPointsVisible = false;
|
Dikuaiguanli.updateBoundaryPointVisibility(currentBoundaryLandNumber, false);
|
visualizationPanel.repaint();
|
adjustViewAfterBoundaryReset();
|
} else {
|
currentBoundary = updated;
|
rebuildBoundaryPath();
|
boundaryBounds = computeBounds(updated);
|
Dikuaiguanli.updateBoundaryPointVisibility(currentBoundaryLandNumber, boundaryPointsVisible);
|
visualizationPanel.repaint();
|
}
|
pendingTrackBreak = true;
|
}
|
|
private boolean persistBoundaryChanges(List<Point2D.Double> updatedBoundary) {
|
if (currentBoundaryLandNumber == null) {
|
return false;
|
}
|
|
String serialized = serializeBoundary(updatedBoundary);
|
String storedValue = (serialized == null || serialized.trim().isEmpty()) ? "-1" : serialized;
|
|
boolean updated = Dikuai.updateField(currentBoundaryLandNumber, "boundaryCoordinates", storedValue);
|
if (!updated) {
|
JOptionPane.showMessageDialog(visualizationPanel, "无法更新边界数据", "错误", JOptionPane.ERROR_MESSAGE);
|
return false;
|
}
|
|
Dikuai.saveToProperties();
|
Dikuaiguanli.notifyExternalCreation(currentBoundaryLandNumber);
|
return true;
|
}
|
|
private String serializeBoundary(List<Point2D.Double> boundary) {
|
if (boundary == null || boundary.isEmpty()) {
|
return "";
|
}
|
StringBuilder builder = new StringBuilder();
|
for (int i = 0; i < boundary.size(); i++) {
|
Point2D.Double point = boundary.get(i);
|
builder.append(formatCoordinate(point.x))
|
.append(',')
|
.append(formatCoordinate(point.y));
|
if (i < boundary.size() - 1) {
|
builder.append(';');
|
}
|
}
|
return builder.toString();
|
}
|
|
private String formatCoordinate(double value) {
|
BigDecimal decimal = BigDecimal.valueOf(value).stripTrailingZeros();
|
return decimal.toPlainString();
|
}
|
|
private boolean isBoundaryClosed(List<Point2D.Double> boundary) {
|
if (boundary == null || boundary.size() < 2) {
|
return false;
|
}
|
Point2D.Double first = boundary.get(0);
|
Point2D.Double last = boundary.get(boundary.size() - 1);
|
return arePointsClose(first, last);
|
}
|
|
private boolean arePointsClose(Point2D.Double a, Point2D.Double b) {
|
if (a == null || b == null) {
|
return false;
|
}
|
double dx = a.x - b.x;
|
double dy = a.y - b.y;
|
return Math.hypot(dx, dy) <= BOUNDARY_POINT_MERGE_THRESHOLD;
|
}
|
|
private boolean isHighPrecisionFix(String fixQuality) {
|
if (fixQuality == null) {
|
return false;
|
}
|
String trimmed = fixQuality.trim();
|
if (trimmed.isEmpty()) {
|
return false;
|
}
|
if ("4".equals(trimmed)) {
|
return true;
|
}
|
try {
|
double value = Double.parseDouble(trimmed);
|
return Math.abs(value - 4.0d) < 1e-6;
|
} catch (NumberFormatException ex) {
|
return false;
|
}
|
}
|
|
/**
|
* 判断定位状态是否有效,可用于显示拖尾
|
* 接受状态1(单点定位)、2(码差分)、3(无效PPS)、4(固定解)、5(浮点解)
|
*/
|
private boolean isValidFixForTrail(String fixQuality) {
|
if (fixQuality == null) {
|
return false;
|
}
|
String trimmed = fixQuality.trim();
|
if (trimmed.isEmpty()) {
|
return false;
|
}
|
// 接受状态1,2,3,4,5(只要不是0或无效状态)
|
if ("1".equals(trimmed) || "2".equals(trimmed) || "3".equals(trimmed) ||
|
"4".equals(trimmed) || "5".equals(trimmed)) {
|
return true;
|
}
|
try {
|
double value = Double.parseDouble(trimmed);
|
// 接受1.0, 2.0, 3.0, 4.0, 5.0(只要不是0)
|
return value >= 1.0 && value <= 5.0;
|
} catch (NumberFormatException ex) {
|
return false;
|
}
|
}
|
|
private boolean isPointInsideActiveBoundary(Point2D.Double point) {
|
if (point == null || !Double.isFinite(point.x) || !Double.isFinite(point.y)) {
|
return false;
|
}
|
if (realtimeTrackLandNumber == null) {
|
return false;
|
}
|
Path2D.Double path = getRealtimeBoundaryPath();
|
return isPointInsideBoundary(point, path);
|
}
|
|
private boolean isPointInsideBoundary(Point2D.Double point, Path2D.Double path) {
|
if (point == null || path == null || !Double.isFinite(point.x) || !Double.isFinite(point.y)) {
|
return false;
|
}
|
if (path.contains(point.x, point.y)) {
|
return true;
|
}
|
double size = BOUNDARY_CONTAINS_TOLERANCE * 2.0;
|
return path.intersects(point.x - BOUNDARY_CONTAINS_TOLERANCE, point.y - BOUNDARY_CONTAINS_TOLERANCE, size, size);
|
}
|
|
private void rebuildBoundaryPath() {
|
currentBoundaryPath = buildBoundaryPath(currentBoundary);
|
}
|
|
private Path2D.Double buildBoundaryPath(List<Point2D.Double> boundary) {
|
if (boundary == null || boundary.size() < 3) {
|
return null;
|
}
|
Path2D.Double path = new Path2D.Double();
|
boolean started = false;
|
for (Point2D.Double point : boundary) {
|
if (point == null || !Double.isFinite(point.x) || !Double.isFinite(point.y)) {
|
continue;
|
}
|
if (!started) {
|
path.moveTo(point.x, point.y);
|
started = true;
|
} else {
|
path.lineTo(point.x, point.y);
|
}
|
}
|
if (!started) {
|
return null;
|
}
|
path.closePath();
|
return path;
|
}
|
|
|
/**
|
* 绘制视图信息
|
*/
|
/**
|
* 绘制障碍物坐标点(带序号)
|
* 序号显示在点中心,字体大小与障碍物名称一致(11号),不随缩放变化
|
*/
|
private void drawObstaclePointsWithNumbers(Graphics2D g2d, List<Obstacledge.Obstacle> obstacles, double scale) {
|
if (obstacles == null || obstacles.isEmpty()) {
|
return;
|
}
|
|
// 保存原始变换
|
AffineTransform originalTransform = g2d.getTransform();
|
|
// 设置点的大小(随缩放变化)
|
double scaleFactor = Math.max(0.5, scale);
|
double clampedScale = boundaryPointSizeScale * (previewSizingEnabled ? PREVIEW_BOUNDARY_MARKER_SCALE : 1.0d);
|
if (!Double.isFinite(clampedScale) || clampedScale <= 0.0d) {
|
clampedScale = 1.0d;
|
}
|
double minimumDiameter = clampedScale < 1.0 ? 0.5 : 1.0;
|
double markerDiameter = Math.max(minimumDiameter, (10.0 / scaleFactor) * 0.2 * clampedScale);
|
double markerRadius = markerDiameter / 2.0;
|
|
// 设置字体(与障碍物名称一致,不随缩放变化)
|
Font labelFont = new Font("微软雅黑", Font.PLAIN, 11);
|
g2d.setFont(labelFont);
|
FontMetrics fontMetrics = g2d.getFontMetrics(labelFont);
|
|
// 遍历所有障碍物
|
for (Obstacledge.Obstacle obstacle : obstacles) {
|
if (obstacle == null || !obstacle.isValid()) {
|
continue;
|
}
|
|
List<Obstacledge.XYCoordinate> xyCoords = obstacle.getXyCoordinates();
|
if (xyCoords == null || xyCoords.isEmpty()) {
|
continue;
|
}
|
|
// 绘制每个点及其序号
|
for (int i = 0; i < xyCoords.size(); i++) {
|
Obstacledge.XYCoordinate coord = xyCoords.get(i);
|
double x = coord.getX();
|
double y = coord.getY();
|
|
// 绘制点(在世界坐标系中,随缩放变化)
|
g2d.setColor(OBSTACLE_POINT_COLOR);
|
Ellipse2D.Double marker = new Ellipse2D.Double(
|
x - markerRadius,
|
y - markerRadius,
|
markerDiameter,
|
markerDiameter
|
);
|
g2d.fill(marker);
|
|
// 将世界坐标转换为屏幕坐标以绘制序号(不随缩放变化)
|
Point2D.Double worldPoint = new Point2D.Double(x, y);
|
Point2D.Double screenPoint = new Point2D.Double();
|
originalTransform.transform(worldPoint, screenPoint);
|
|
// 保存当前变换
|
AffineTransform savedTransform = g2d.getTransform();
|
|
// 重置变换为屏幕坐标系统
|
g2d.setTransform(new AffineTransform());
|
|
// 绘制序号(在屏幕坐标系中,不随缩放变化)
|
String numberText = String.valueOf(i + 1);
|
int textWidth = fontMetrics.stringWidth(numberText);
|
int textHeight = fontMetrics.getHeight();
|
|
// 在点中心绘制序号
|
int textX = (int)(screenPoint.x - textWidth / 2.0);
|
int textY = (int)(screenPoint.y + textHeight / 4.0);
|
|
// 绘制序号文字(无背景)
|
g2d.setColor(Color.BLACK);
|
g2d.drawString(numberText, textX, textY);
|
|
// 恢复变换
|
g2d.setTransform(savedTransform);
|
}
|
}
|
|
// 恢复原始变换
|
g2d.setTransform(originalTransform);
|
}
|
|
/**
|
* 绘制边界点(带序号)
|
* 序号显示在点中心,字体大小与障碍物序号一致(11号),不随缩放变化
|
*/
|
private void drawBoundaryPointsWithNumbers(Graphics2D g2d, List<Point2D.Double> boundary, double scale) {
|
if (boundary == null || boundary.size() < 2) {
|
return;
|
}
|
|
// 保存原始变换
|
AffineTransform originalTransform = g2d.getTransform();
|
|
int totalPoints = boundary.size();
|
boolean closed = totalPoints > 2 && areBoundaryPointsClose(boundary.get(0), boundary.get(totalPoints - 1));
|
int effectiveCount = closed ? totalPoints - 1 : totalPoints;
|
if (effectiveCount <= 0) {
|
return;
|
}
|
|
// 设置点的大小(随缩放变化)
|
double scaleFactor = Math.max(0.5, scale);
|
double clampedScale = boundaryPointSizeScale * (previewSizingEnabled ? PREVIEW_BOUNDARY_MARKER_SCALE : 1.0d);
|
if (!Double.isFinite(clampedScale) || clampedScale <= 0.0d) {
|
clampedScale = 1.0d;
|
}
|
double minimumDiameter = clampedScale < 1.0 ? 0.5 : 1.0;
|
double markerDiameter = Math.max(minimumDiameter, (10.0 / scaleFactor) * 0.2 * clampedScale);
|
double markerRadius = markerDiameter / 2.0;
|
|
// 设置字体(与障碍物序号一致,不随缩放变化)
|
Font labelFont = new Font("微软雅黑", Font.PLAIN, 11);
|
g2d.setFont(labelFont);
|
FontMetrics fontMetrics = g2d.getFontMetrics(labelFont);
|
|
// 绘制每个点及其序号
|
for (int i = 0; i < effectiveCount; i++) {
|
Point2D.Double point = boundary.get(i);
|
double x = point.x;
|
double y = point.y;
|
|
// 绘制点(在世界坐标系中,随缩放变化)
|
g2d.setColor(BOUNDARY_POINT_COLOR);
|
Ellipse2D.Double marker = new Ellipse2D.Double(
|
x - markerRadius,
|
y - markerRadius,
|
markerDiameter,
|
markerDiameter
|
);
|
g2d.fill(marker);
|
|
// 将世界坐标转换为屏幕坐标以绘制序号(不随缩放变化)
|
Point2D.Double worldPoint = new Point2D.Double(x, y);
|
Point2D.Double screenPoint = new Point2D.Double();
|
originalTransform.transform(worldPoint, screenPoint);
|
|
// 保存当前变换
|
AffineTransform savedTransform = g2d.getTransform();
|
|
// 重置变换为屏幕坐标系统
|
g2d.setTransform(new AffineTransform());
|
|
// 绘制序号(在屏幕坐标系中,不随缩放变化)
|
String numberText = String.valueOf(i + 1);
|
int textWidth = fontMetrics.stringWidth(numberText);
|
int textHeight = fontMetrics.getHeight();
|
|
// 在点中心绘制序号
|
int textX = (int)(screenPoint.x - textWidth / 2.0);
|
int textY = (int)(screenPoint.y + textHeight / 4.0);
|
|
// 绘制序号文字(无背景)
|
g2d.setColor(Color.BLACK);
|
g2d.drawString(numberText, textX, textY);
|
|
// 恢复变换
|
g2d.setTransform(savedTransform);
|
}
|
|
// 恢复原始变换
|
g2d.setTransform(originalTransform);
|
}
|
|
/**
|
* 检查两个边界点是否接近(用于判断边界是否闭合)
|
*/
|
private boolean areBoundaryPointsClose(Point2D.Double a, Point2D.Double b) {
|
if (a == null || b == null) {
|
return false;
|
}
|
double dx = a.x - b.x;
|
double dy = a.y - b.y;
|
return Math.hypot(dx, dy) <= BOUNDARY_POINT_MERGE_THRESHOLD;
|
}
|
|
private void drawViewInfo(Graphics2D g2d) {
|
g2d.setColor(new Color(80, 80, 80));
|
g2d.setFont(new Font("微软雅黑", Font.PLAIN, 11));
|
|
// 在地图顶部左侧显示遥控摇杆对应速度(若非零)
|
try {
|
int forward = Control03.getCurrentForwardSpeed();
|
int steer = Control03.getCurrentSteeringSpeed();
|
if (forward != 0 || steer != 0) {
|
String speedInfo = String.format("行进:%d 转向:%d", forward, steer);
|
// 背景半透明矩形增强可读性
|
FontMetrics fm = g2d.getFontMetrics();
|
int padding = 6;
|
int w = fm.stringWidth(speedInfo) + padding * 2;
|
int h = fm.getHeight() + padding;
|
int x = 12;
|
int y = 12;
|
Color bg = new Color(255, 255, 255, 180);
|
g2d.setColor(bg);
|
g2d.fillRoundRect(x, y, w, h, 8, 8);
|
g2d.setColor(new Color(120, 120, 120));
|
g2d.drawString(speedInfo, x + padding, y + fm.getAscent() + (padding/2));
|
}
|
} catch (Throwable t) {
|
// 不应阻塞渲染,静默处理任何异常
|
}
|
|
// 保留底部的缩放比例信息
|
String info = String.format("缩放: %.2fx", scale);
|
g2d.setColor(new Color(80, 80, 80));
|
g2d.drawString(info, 15, visualizationPanel.getHeight() - 15);
|
}
|
|
/**
|
* 获取当前平移量X
|
*/
|
public double getTranslateX() {
|
return translateX;
|
}
|
|
/**
|
* 获取当前平移量Y
|
*/
|
public double getTranslateY() {
|
return translateY;
|
}
|
|
/**
|
* 设置视图变换参数(用于程序化控制)
|
*/
|
public void setViewTransform(double scale, double translateX, double translateY) {
|
// 限制缩放范围
|
scale = Math.max(MIN_SCALE, Math.min(scale, MAX_SCALE));
|
// 如果缩放比例改变了,保存到配置文件
|
if (Math.abs(this.scale - scale) > SCALE_EPSILON) {
|
this.scale = scale;
|
saveScaleToProperties();
|
} else {
|
this.scale = scale;
|
}
|
this.translateX = translateX;
|
this.translateY = translateY;
|
visualizationPanel.repaint();
|
}
|
|
public void setCurrentBoundary(String boundaryCoordinates, String landNumber, String landName) {
|
this.boundaryName = landName;
|
this.currentBoundaryLandNumber = landNumber;
|
this.realtimeBoundaryPathCache = null;
|
this.realtimeBoundaryPathLand = null;
|
|
if (boundaryCoordinates == null) {
|
clearBoundaryData();
|
adjustViewAfterBoundaryReset();
|
return;
|
}
|
|
String trimmed = boundaryCoordinates.trim();
|
if (trimmed.isEmpty() || "-1".equals(trimmed)) {
|
clearBoundaryData();
|
adjustViewAfterBoundaryReset();
|
return;
|
}
|
|
List<Point2D.Double> parsed = parseBoundary(trimmed);
|
if (parsed.size() < 2) {
|
clearBoundaryData();
|
adjustViewAfterBoundaryReset();
|
return;
|
}
|
|
currentBoundary = parsed;
|
rebuildBoundaryPath();
|
pendingTrackBreak = true;
|
boundaryBounds = computeBounds(parsed);
|
|
Rectangle2D.Double bounds = boundaryBounds;
|
SwingUtilities.invokeLater(() -> {
|
fitBoundsToView(bounds);
|
visualizationPanel.repaint();
|
});
|
}
|
|
private void clearBoundaryData() {
|
currentBoundary = null;
|
currentBoundaryPath = null;
|
boundaryBounds = null;
|
boundaryName = null;
|
boundaryPointsVisible = false;
|
currentBoundaryLandNumber = null;
|
pendingTrackBreak = true;
|
realtimeBoundaryPathCache = null;
|
realtimeBoundaryPathLand = null;
|
}
|
|
public void setCurrentObstacles(String obstaclesData, String landNumber) {
|
List<Obstacledge.Obstacle> parsed = parseObstacles(obstaclesData, landNumber);
|
applyObstaclesToRenderer(parsed, landNumber);
|
}
|
|
public void setCurrentObstacles(List<Obstacledge.Obstacle> obstacles, String landNumber) {
|
List<Obstacledge.Obstacle> cloned = cloneObstacles(obstacles, landNumber);
|
applyObstaclesToRenderer(cloned, landNumber);
|
}
|
|
private void applyObstaclesToRenderer(List<Obstacledge.Obstacle> obstacles, String landNumber) {
|
List<Obstacledge.Obstacle> safeList = obstacles != null ? obstacles : Collections.emptyList();
|
String normalizedLand = (landNumber != null) ? landNumber.trim() : null;
|
|
if (normalizedLand != null && !normalizedLand.isEmpty()) {
|
safeList = filterObstaclesForLand(safeList, normalizedLand);
|
}
|
|
if (normalizedLand == null || normalizedLand.isEmpty()) {
|
clearObstacleData();
|
if (!hasRenderableBoundary() && !hasRenderablePlannedPath()) {
|
resetView();
|
} else {
|
visualizationPanel.repaint();
|
}
|
return;
|
}
|
|
if (safeList.isEmpty()) {
|
clearObstacleData();
|
if (!hasRenderableBoundary() && !hasRenderablePlannedPath()) {
|
resetView();
|
} else {
|
visualizationPanel.repaint();
|
}
|
return;
|
}
|
|
currentObstacles = Collections.unmodifiableList(new ArrayList<>(safeList));
|
currentObstacleLandNumber = normalizedLand;
|
obstacleBounds = convertObstacleBounds(Obstacledraw.getAllObstaclesBounds(currentObstacles));
|
selectedObstacleName = null;
|
|
if (!hasRenderableBoundary() && !hasRenderablePlannedPath() && obstacleBounds != null) {
|
Rectangle2D.Double bounds = obstacleBounds;
|
SwingUtilities.invokeLater(() -> {
|
fitBoundsToView(bounds);
|
visualizationPanel.repaint();
|
});
|
} else {
|
visualizationPanel.repaint();
|
}
|
}
|
|
private List<Obstacledge.Obstacle> cloneObstacles(List<Obstacledge.Obstacle> obstacles, String landNumber) {
|
List<Obstacledge.Obstacle> result = new ArrayList<>();
|
if (obstacles == null || obstacles.isEmpty()) {
|
return result;
|
}
|
|
String normalizedLandNumber = landNumber != null ? landNumber.trim() : null;
|
Set<String> usedNames = new LinkedHashSet<>();
|
int fallbackIndex = 1;
|
|
for (Obstacledge.Obstacle source : obstacles) {
|
if (source == null) {
|
continue;
|
}
|
|
if (normalizedLandNumber != null && !normalizedLandNumber.isEmpty()) {
|
String sourcePlotId = source.getPlotId();
|
if (sourcePlotId != null && !sourcePlotId.trim().isEmpty()
|
&& !normalizedLandNumber.equalsIgnoreCase(sourcePlotId.trim())) {
|
continue;
|
}
|
}
|
|
Obstacledge.ObstacleShape shape = source.getShape();
|
List<Obstacledge.XYCoordinate> xySource = source.getXyCoordinates();
|
if (shape == null || xySource == null) {
|
continue;
|
}
|
|
List<Obstacledge.XYCoordinate> xyCopy = copyXYCoordinates(xySource);
|
if (shape == Obstacledge.ObstacleShape.CIRCLE && xyCopy.size() < 2) {
|
continue;
|
}
|
if (shape == Obstacledge.ObstacleShape.POLYGON && xyCopy.size() < 3) {
|
continue;
|
}
|
|
String desiredName = source.getObstacleName();
|
if (desiredName == null || desiredName.trim().isEmpty()) {
|
desiredName = "障碍物" + fallbackIndex++;
|
}
|
String uniqueName = ensureUniqueName(usedNames, desiredName.trim());
|
|
Obstacledge.Obstacle copy = new Obstacledge.Obstacle(
|
normalizedLandNumber != null ? normalizedLandNumber : source.getPlotId(),
|
uniqueName,
|
shape
|
);
|
|
copy.setXyCoordinates(xyCopy);
|
|
List<Obstacledge.DMCoordinate> dmCopy = copyDMCoordinates(source.getOriginalCoordinates());
|
if (dmCopy.isEmpty()) {
|
populateDummyOriginalCoordinates(copy, xyCopy.size());
|
} else {
|
copy.setOriginalCoordinates(dmCopy);
|
}
|
|
result.add(copy);
|
}
|
|
return result;
|
}
|
|
private List<Obstacledge.Obstacle> filterObstaclesForLand(List<Obstacledge.Obstacle> obstacles, String landNumber) {
|
if (obstacles == null || obstacles.isEmpty()) {
|
return Collections.emptyList();
|
}
|
if (landNumber == null || landNumber.trim().isEmpty()) {
|
return Collections.emptyList();
|
}
|
String normalized = landNumber.trim();
|
List<Obstacledge.Obstacle> filtered = new ArrayList<>();
|
for (Obstacledge.Obstacle obstacle : obstacles) {
|
if (obstacle == null) {
|
continue;
|
}
|
String plotId = obstacle.getPlotId();
|
if (plotId == null || plotId.trim().isEmpty()) {
|
filtered.add(obstacle);
|
continue;
|
}
|
if (normalized.equalsIgnoreCase(plotId.trim())) {
|
filtered.add(obstacle);
|
}
|
}
|
return filtered;
|
}
|
|
private String ensureUniqueName(Set<String> usedNames, String preferredName) {
|
String base = (preferredName == null || preferredName.trim().isEmpty()) ? "障碍物" : preferredName.trim();
|
String normalized = base.toLowerCase(Locale.ROOT);
|
if (usedNames.add(normalized)) {
|
return base;
|
}
|
int suffix = 2;
|
while (true) {
|
String attempt = base + suffix;
|
String attemptKey = attempt.toLowerCase(Locale.ROOT);
|
if (usedNames.add(attemptKey)) {
|
return attempt;
|
}
|
suffix++;
|
}
|
}
|
|
private List<Obstacledge.XYCoordinate> copyXYCoordinates(List<Obstacledge.XYCoordinate> source) {
|
List<Obstacledge.XYCoordinate> copy = new ArrayList<>();
|
if (source == null) {
|
return copy;
|
}
|
for (Obstacledge.XYCoordinate coord : source) {
|
if (coord == null) {
|
continue;
|
}
|
double x = coord.getX();
|
double y = coord.getY();
|
if (!Double.isFinite(x) || !Double.isFinite(y)) {
|
continue;
|
}
|
copy.add(new Obstacledge.XYCoordinate(x, y));
|
}
|
return copy;
|
}
|
|
private List<Obstacledge.DMCoordinate> copyDMCoordinates(List<Obstacledge.DMCoordinate> source) {
|
List<Obstacledge.DMCoordinate> copy = new ArrayList<>();
|
if (source == null) {
|
return copy;
|
}
|
for (Obstacledge.DMCoordinate coord : source) {
|
if (coord == null) {
|
continue;
|
}
|
copy.add(new Obstacledge.DMCoordinate(coord.getDegreeMinute(), coord.getDirection()));
|
}
|
return copy;
|
}
|
|
private void clearObstacleData() {
|
currentObstacles = null;
|
obstacleBounds = null;
|
selectedObstacleName = null;
|
currentObstacleLandNumber = null;
|
obstaclePointsVisible = false;
|
}
|
|
private List<Obstacledge.Obstacle> parseObstacles(String obstaclesData, String landNumber) {
|
List<Obstacledge.Obstacle> obstacles = new ArrayList<>();
|
if (obstaclesData == null) {
|
return obstacles;
|
}
|
|
String normalized = stripInlineComment(obstaclesData.trim());
|
if (normalized.isEmpty() || "-1".equals(normalized)) {
|
return obstacles;
|
}
|
|
List<String> entries = splitObstacleEntries(normalized);
|
int defaultIndex = 1;
|
|
for (String entry : entries) {
|
String trimmedEntry = stripInlineComment(entry);
|
if (trimmedEntry.isEmpty()) {
|
continue;
|
}
|
|
String nameToken = null;
|
String shapeToken = null;
|
String coordsSection = trimmedEntry;
|
|
if (trimmedEntry.contains("::")) {
|
String[] parts = trimmedEntry.split("::", 3);
|
if (parts.length == 3) {
|
nameToken = parts[0].trim();
|
shapeToken = parts[1].trim();
|
coordsSection = parts[2].trim();
|
}
|
} else if (trimmedEntry.contains("@")) {
|
String[] parts = trimmedEntry.split("@", 3);
|
if (parts.length == 3) {
|
nameToken = parts[0].trim();
|
shapeToken = parts[1].trim();
|
coordsSection = parts[2].trim();
|
} else if (parts.length == 2) {
|
shapeToken = parts[0].trim();
|
coordsSection = parts[1].trim();
|
}
|
} else if (trimmedEntry.contains(":")) {
|
String[] parts = trimmedEntry.split(":", 3);
|
if (parts.length == 3) {
|
nameToken = parts[0].trim();
|
shapeToken = parts[1].trim();
|
coordsSection = parts[2].trim();
|
} else if (parts.length == 2) {
|
if (looksLikeShapeToken(parts[0])) {
|
shapeToken = parts[0].trim();
|
coordsSection = parts[1].trim();
|
} else {
|
nameToken = parts[0].trim();
|
coordsSection = parts[1].trim();
|
}
|
}
|
}
|
|
List<Obstacledge.XYCoordinate> xyCoordinates = parseObstacleCoordinates(coordsSection);
|
if (xyCoordinates.size() < 2) {
|
continue;
|
}
|
|
Obstacledge.ObstacleShape shape = resolveObstacleShape(shapeToken, xyCoordinates.size());
|
if (shape == null) {
|
continue;
|
}
|
|
String obstacleName = (nameToken != null && !nameToken.isEmpty())
|
? nameToken
|
: "障碍物" + defaultIndex++;
|
|
Obstacledge.Obstacle obstacle = new Obstacledge.Obstacle(landNumber, obstacleName, shape);
|
obstacle.setXyCoordinates(new ArrayList<>(xyCoordinates));
|
populateDummyOriginalCoordinates(obstacle, xyCoordinates.size());
|
|
if (obstacle.isValid()) {
|
obstacles.add(obstacle);
|
}
|
}
|
|
return obstacles;
|
}
|
|
private boolean looksLikeShapeToken(String token) {
|
if (token == null) {
|
return false;
|
}
|
String normalized = token.trim().toLowerCase(Locale.ROOT);
|
return "circle".equals(normalized)
|
|| "polygon".equals(normalized)
|
|| "圆形".equals(normalized)
|
|| "多边形".equals(normalized)
|
|| "0".equals(normalized)
|
|| "1".equals(normalized);
|
}
|
|
private List<Obstacledge.XYCoordinate> parseObstacleCoordinates(String coordsSection) {
|
List<Obstacledge.XYCoordinate> coords = new ArrayList<>();
|
if (coordsSection == null) {
|
return coords;
|
}
|
|
String sanitized = stripInlineComment(coordsSection.trim());
|
if (sanitized.isEmpty() || "-1".equals(sanitized)) {
|
return coords;
|
}
|
|
// Remove wrapper characters like parentheses that are used when persisting payloads
|
sanitized = sanitized.replace("(", "").replace(")", "");
|
|
String[] pairs = sanitized.split(";");
|
for (String pair : pairs) {
|
if (pair == null) {
|
continue;
|
}
|
String trimmed = stripInlineComment(pair.trim());
|
if (trimmed.isEmpty()) {
|
continue;
|
}
|
trimmed = trimmed.replace("(", "").replace(")", "");
|
if (trimmed.isEmpty()) {
|
continue;
|
}
|
String[] parts = trimmed.split(",");
|
if (parts.length < 2) {
|
continue;
|
}
|
try {
|
double x = Double.parseDouble(parts[0].trim());
|
double y = Double.parseDouble(parts[1].trim());
|
coords.add(new Obstacledge.XYCoordinate(x, y));
|
} catch (NumberFormatException ignored) {
|
// Skip malformed coordinate pair
|
}
|
}
|
|
return coords;
|
}
|
|
private Obstacledge.ObstacleShape resolveObstacleShape(String shapeToken, int coordinateCount) {
|
if (shapeToken != null && !shapeToken.trim().isEmpty()) {
|
String normalized = shapeToken.trim().toLowerCase(Locale.ROOT);
|
if ("circle".equals(normalized) || "圆形".equals(normalized) || "0".equals(normalized)) {
|
return Obstacledge.ObstacleShape.CIRCLE;
|
}
|
if ("polygon".equals(normalized) || "多边形".equals(normalized) || "1".equals(normalized)) {
|
return Obstacledge.ObstacleShape.POLYGON;
|
}
|
}
|
|
if (coordinateCount == 2) {
|
return Obstacledge.ObstacleShape.CIRCLE;
|
}
|
if (coordinateCount >= 3) {
|
return Obstacledge.ObstacleShape.POLYGON;
|
}
|
return null;
|
}
|
|
private void populateDummyOriginalCoordinates(Obstacledge.Obstacle obstacle, int xyCount) {
|
List<Obstacledge.DMCoordinate> dmCoordinates = new ArrayList<>();
|
int points = Math.max(1, xyCount);
|
for (int i = 0; i < points; i++) {
|
dmCoordinates.add(new Obstacledge.DMCoordinate(0.0, 'N'));
|
dmCoordinates.add(new Obstacledge.DMCoordinate(0.0, 'E'));
|
}
|
obstacle.setOriginalCoordinates(dmCoordinates);
|
}
|
|
private List<String> splitObstacleEntries(String data) {
|
List<String> entries = new ArrayList<>();
|
if (data.indexOf('|') >= 0) {
|
String[] parts = data.split("\\|");
|
for (String part : parts) {
|
if (part != null && !part.trim().isEmpty()) {
|
entries.add(part.trim());
|
}
|
}
|
} else if (data.contains("\n")) {
|
String[] lines = data.split("\r?\n");
|
for (String line : lines) {
|
if (line != null && !line.trim().isEmpty()) {
|
entries.add(line.trim());
|
}
|
}
|
} else {
|
entries.add(data);
|
}
|
return entries;
|
}
|
|
private String stripInlineComment(String text) {
|
if (text == null) {
|
return "";
|
}
|
int hashIndex = text.indexOf('#');
|
if (hashIndex >= 0) {
|
return text.substring(0, hashIndex).trim();
|
}
|
return text.trim();
|
}
|
|
private Rectangle2D.Double convertObstacleBounds(double[] bounds) {
|
if (bounds == null || bounds.length < 4) {
|
return null;
|
}
|
double minX = bounds[0];
|
double minY = bounds[1];
|
double maxX = bounds[2];
|
double maxY = bounds[3];
|
return new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY);
|
}
|
|
private boolean hasRenderableBoundary() {
|
return currentBoundary != null && currentBoundary.size() >= 2;
|
}
|
|
private boolean hasRenderablePlannedPath() {
|
return currentPlannedPath != null && currentPlannedPath.size() >= 2;
|
}
|
|
private void adjustViewAfterBoundaryReset() {
|
if (plannedPathBounds != null) {
|
Rectangle2D.Double bounds = plannedPathBounds;
|
SwingUtilities.invokeLater(() -> {
|
fitBoundsToView(bounds);
|
visualizationPanel.repaint();
|
});
|
return;
|
}
|
|
if (obstacleBounds != null) {
|
Rectangle2D.Double bounds = obstacleBounds;
|
SwingUtilities.invokeLater(() -> {
|
fitBoundsToView(bounds);
|
visualizationPanel.repaint();
|
});
|
return;
|
}
|
|
resetView();
|
}
|
|
public void setCurrentPlannedPath(String plannedPath) {
|
if (plannedPath == null) {
|
currentPlannedPath = null;
|
plannedPathBounds = null;
|
if (!hasRenderableBoundary()) {
|
resetView();
|
} else {
|
visualizationPanel.repaint();
|
}
|
return;
|
}
|
|
List<Point2D.Double> parsed = lujingdraw.parsePlannedPath(plannedPath);
|
if (parsed.size() < 2) {
|
currentPlannedPath = null;
|
plannedPathBounds = null;
|
if (!hasRenderableBoundary()) {
|
resetView();
|
} else {
|
visualizationPanel.repaint();
|
}
|
return;
|
}
|
|
currentPlannedPath = parsed;
|
plannedPathBounds = computeBounds(parsed);
|
|
Rectangle2D.Double bounds = plannedPathBounds;
|
SwingUtilities.invokeLater(() -> {
|
if (!hasRenderableBoundary()) {
|
fitBoundsToView(bounds);
|
}
|
visualizationPanel.repaint();
|
});
|
}
|
|
public void setBoundaryPointsVisible(boolean visible) {
|
this.boundaryPointsVisible = visible;
|
visualizationPanel.repaint();
|
}
|
|
public void setObstaclePointsVisible(boolean visible) {
|
this.obstaclePointsVisible = visible;
|
visualizationPanel.repaint();
|
}
|
|
public void setBoundaryPointSizeScale(double sizeScale) {
|
double normalized = (Double.isFinite(sizeScale) && sizeScale > 0.0d) ? sizeScale : 1.0d;
|
if (Math.abs(boundaryPointSizeScale - normalized) < 1e-6) {
|
return;
|
}
|
boundaryPointSizeScale = normalized;
|
if (visualizationPanel == null) {
|
return;
|
}
|
if (SwingUtilities.isEventDispatchThread()) {
|
visualizationPanel.repaint();
|
} else {
|
SwingUtilities.invokeLater(visualizationPanel::repaint);
|
}
|
}
|
|
public void setPathPreviewSizingEnabled(boolean enabled) {
|
previewSizingEnabled = enabled;
|
if (visualizationPanel == null) {
|
return;
|
}
|
if (SwingUtilities.isEventDispatchThread()) {
|
visualizationPanel.repaint();
|
} else {
|
SwingUtilities.invokeLater(visualizationPanel::repaint);
|
}
|
}
|
|
public void setBoundaryPreviewMarkerScale(double markerScale) {
|
double normalized = Double.isFinite(markerScale) && markerScale > 0.0d ? markerScale : 1.0d;
|
if (Math.abs(boundaryPreviewMarkerScale - normalized) < 1e-6) {
|
return;
|
}
|
boundaryPreviewMarkerScale = normalized;
|
if (visualizationPanel == null) {
|
return;
|
}
|
if (SwingUtilities.isEventDispatchThread()) {
|
visualizationPanel.repaint();
|
} else {
|
SwingUtilities.invokeLater(visualizationPanel::repaint);
|
}
|
}
|
|
public boolean setHandheldMowerIconActive(boolean handheldActive) {
|
if (mower == null) {
|
return false;
|
}
|
boolean changed = mower.useHandheldIcon(handheldActive);
|
if (changed && visualizationPanel != null) {
|
if (SwingUtilities.isEventDispatchThread()) {
|
visualizationPanel.repaint();
|
} else {
|
SwingUtilities.invokeLater(visualizationPanel::repaint);
|
}
|
}
|
return changed;
|
}
|
|
public void beginHandheldBoundaryPreview() {
|
handheldBoundaryPreviewActive = true;
|
handheldBoundaryPreview.clear();
|
visualizationPanel.repaint();
|
}
|
|
public void addHandheldBoundaryPoint(double x, double y) {
|
if (!Double.isFinite(x) || !Double.isFinite(y)) {
|
return;
|
}
|
if (!handheldBoundaryPreviewActive) {
|
beginHandheldBoundaryPreview();
|
}
|
Point2D.Double last = handheldBoundaryPreview.isEmpty() ? null : handheldBoundaryPreview.get(handheldBoundaryPreview.size() - 1);
|
if (last != null) {
|
double dx = x - last.x;
|
double dy = y - last.y;
|
if (Math.hypot(dx, dy) < 1e-6) {
|
visualizationPanel.repaint();
|
return;
|
}
|
}
|
handheldBoundaryPreview.add(new Point2D.Double(x, y));
|
visualizationPanel.repaint();
|
}
|
|
public void clearHandheldBoundaryPreview() {
|
handheldBoundaryPreviewActive = false;
|
handheldBoundaryPreview.clear();
|
boundaryPreviewMarkerScale = 1.0d;
|
visualizationPanel.repaint();
|
}
|
|
public List<Point2D.Double> getHandheldBoundaryPreviewPoints() {
|
return new ArrayList<>(handheldBoundaryPreview);
|
}
|
|
private List<Point2D.Double> parseBoundary(String boundaryCoordinates) {
|
List<Point2D.Double> points = new ArrayList<>();
|
String[] entries = boundaryCoordinates.split(";");
|
|
for (String entry : entries) {
|
if (entry == null || entry.trim().isEmpty()) {
|
continue;
|
}
|
String[] parts = entry.trim().split(",");
|
if (parts.length < 2) {
|
continue;
|
}
|
try {
|
double x = Double.parseDouble(parts[0].trim());
|
double y = Double.parseDouble(parts[1].trim());
|
points.add(new Point2D.Double(x, y));
|
} catch (NumberFormatException ex) {
|
// ignore invalid entries
|
}
|
}
|
return points;
|
}
|
|
private Rectangle2D.Double computeBounds(List<Point2D.Double> points) {
|
double minX = Double.MAX_VALUE;
|
double minY = Double.MAX_VALUE;
|
double maxX = -Double.MAX_VALUE;
|
double maxY = -Double.MAX_VALUE;
|
|
for (Point2D.Double point : points) {
|
if (point.x < minX) minX = point.x;
|
if (point.x > maxX) maxX = point.x;
|
if (point.y < minY) minY = point.y;
|
if (point.y > maxY) maxY = point.y;
|
}
|
|
if (minX == Double.MAX_VALUE) {
|
return null;
|
}
|
|
return new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY);
|
}
|
|
private void fitBoundsToView(Rectangle2D.Double bounds) {
|
if (bounds == null || visualizationPanel.getWidth() <= 0 || visualizationPanel.getHeight() <= 0) {
|
return;
|
}
|
|
Rectangle2D.Double targetBounds = includeMowerInBounds(bounds);
|
|
double width = Math.max(targetBounds.width, 1);
|
double height = Math.max(targetBounds.height, 1);
|
|
double targetWidth = width * 1.2;
|
double targetHeight = height * 1.2;
|
|
double panelWidth = visualizationPanel.getWidth();
|
double panelHeight = visualizationPanel.getHeight();
|
|
double newScale = Math.min(panelWidth / targetWidth, panelHeight / targetHeight);
|
newScale = Math.max(0.05, Math.min(newScale, 50.0));
|
|
this.scale = newScale;
|
this.translateX = -targetBounds.getCenterX();
|
this.translateY = -targetBounds.getCenterY();
|
}
|
|
// Keep the mower marker inside the viewport whenever the camera refits to scene bounds.
|
private Rectangle2D.Double includeMowerInBounds(Rectangle2D.Double bounds) {
|
Rectangle2D.Double expanded = new Rectangle2D.Double(
|
bounds.x,
|
bounds.y,
|
Math.max(0.0, bounds.width),
|
Math.max(0.0, bounds.height)
|
);
|
|
if (mower == null || !mower.hasValidPosition()) {
|
return expanded;
|
}
|
|
Point2D.Double mowerPosition = mower.getPosition();
|
if (mowerPosition == null
|
|| !Double.isFinite(mowerPosition.x)
|
|| !Double.isFinite(mowerPosition.y)) {
|
return expanded;
|
}
|
|
double minX = Math.min(expanded.x, mowerPosition.x);
|
double minY = Math.min(expanded.y, mowerPosition.y);
|
double maxX = Math.max(expanded.x + expanded.width, mowerPosition.x);
|
double maxY = Math.max(expanded.y + expanded.height, mowerPosition.y);
|
|
expanded.x = minX;
|
expanded.y = minY;
|
expanded.width = Math.max(0.0, maxX - minX);
|
expanded.height = Math.max(0.0, maxY - minY);
|
|
return expanded;
|
}
|
|
public void dispose() {
|
mowerUpdateTimer.stop();
|
mowerInfoManager.dispose();
|
}
|
|
/**
|
* 获取当前边界点列表
|
* @return 当前边界点列表,如果没有边界则返回null
|
*/
|
public List<Point2D.Double> getCurrentBoundary() {
|
return currentBoundary;
|
}
|
|
/**
|
* 获取割草机实例
|
* @return 割草机实例
|
*/
|
public Gecaoji getMower() {
|
return mower;
|
}
|
|
}
|