feat: add circle drawing support in path planner

This commit is contained in:
xin
2026-06-18 15:16:11 +08:00
parent c16d3d8586
commit 703b4ff865
11 changed files with 119 additions and 52 deletions

View File

@ -1,7 +1,7 @@
{
"name": "happa-mission-plan",
"private": true,
"version": "0.1.0",
"version": "0.0.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,6 +1,6 @@
[package]
name = "spectral-insight-mission-plan"
version = "0.0.2"
version = "0.0.3"
description = "Spectral Insight Mission Plan"
authors = ["you"]
edition = "2021"

View File

@ -27,9 +27,11 @@ pub fn estimate_scan_time_minutes(params: ScanParams) -> f64 {
services::path_generator::estimate_scan_time(&params)
}
#[command]
pub fn generate_scan_paths_multi(
regions: Vec<ScanRegion>,
shapes: Vec<String>,
camera: CameraParams,
coverage_rate: f64,
speed_y_cm_s: f64,
@ -38,7 +40,7 @@ pub fn generate_scan_paths_multi(
mode: ScanMode,
) -> Result<PathLineFile, AppError> {
let records = services::path_generator::generate_paths_multi(
&regions, &camera, coverage_rate,
&regions, &shapes, &camera, coverage_rate,
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
);
let count = records.len() as u64;
@ -48,6 +50,7 @@ pub fn generate_scan_paths_multi(
#[command]
pub fn estimate_scan_time_multi_minutes(
regions: Vec<ScanRegion>,
shapes: Vec<String>,
camera: CameraParams,
coverage_rate: f64,
speed_y_cm_s: f64,
@ -56,7 +59,7 @@ pub fn estimate_scan_time_multi_minutes(
mode: ScanMode,
) -> f64 {
services::path_generator::estimate_scan_time_multi(
&regions, &camera, coverage_rate,
&regions, &shapes, &camera, coverage_rate,
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
)
}

View File

@ -103,8 +103,10 @@ pub fn estimate_scan_time(params: &ScanParams) -> f64 {
}
/// Generate paths for multiple regions, concatenated into one record list.
pub fn generate_paths_multi(
regions: &[ScanRegion],
shapes: &[String],
camera: &crate::models::CameraParams,
coverage_rate: f64,
speed_y_cm_s: f64,
@ -113,24 +115,54 @@ pub fn generate_paths_multi(
mode: &ScanMode,
) -> Vec<PathLineRecord> {
let mut all_records = Vec::new();
for region in regions {
let params = ScanParams {
region: region.clone(),
camera: camera.clone(),
coverage_rate,
speed_y_cm_s,
speed_x_start_cm_s,
speed_x_scan_cm_s,
mode: mode.clone(),
for (i, region) in regions.iter().enumerate() {
let is_circle = shapes.get(i).map(|s| s == "Circle").unwrap_or(false);
let step = calc_footprint(camera.height_cm, camera.fov_degrees)
* (1.0 - coverage_rate / 100.0);
let line_count = if step <= 0.0 { 1 } else { ((region.y_max - region.y_min) / step).ceil() as u64 + 1 };
for li in 0..line_count {
let y = region.y_min + (li as f64) * step;
if y > region.y_max { break; }
let (x_start, x_end) = if is_circle {
let cx = (region.x_min + region.x_max) / 2.0;
let cy = (region.y_min + region.y_max) / 2.0;
let rx = (region.x_max - region.x_min) / 2.0;
let ry = (region.y_max - region.y_min) / 2.0;
// Ellipse: ((x-cx)/rx)^2 + ((y-cy)/ry)^2 = 1
let dy = (y - cy) / ry;
if dy.abs() > 1.0 { continue; } // outside circle
let half_w = rx * (1.0 - dy * dy).sqrt();
(cx - half_w, cx + half_w)
} else {
(region.x_min, region.x_max)
};
all_records.extend(generate_path(&params));
let is_forward = li % 2 == 0;
let (scan_start, scan_end) = match mode {
ScanMode::Zigzag => {
if is_forward { (x_start, x_end) } else { (x_end, x_start) }
}
ScanMode::OneWay => (x_start, x_end),
};
all_records.push(PathLineRecord {
target_y_position: y,
speed_target_y_position: speed_y_cm_s,
target_x_min_position: scan_start,
speed_target_x_min_position: speed_x_start_cm_s,
target_x_max_position: scan_end,
speed_target_x_max_position: speed_x_scan_cm_s,
});
}
}
all_records
}
/// Estimate total time for multiple regions.
pub fn estimate_scan_time_multi(
regions: &[ScanRegion],
shapes: &[String],
camera: &crate::models::CameraParams,
coverage_rate: f64,
speed_y_cm_s: f64,
@ -138,18 +170,13 @@ pub fn estimate_scan_time_multi(
speed_x_scan_cm_s: f64,
mode: &ScanMode,
) -> f64 {
let mut total = 0.0;
for region in regions {
let params = ScanParams {
region: region.clone(),
camera: camera.clone(),
coverage_rate,
speed_y_cm_s,
speed_x_start_cm_s,
speed_x_scan_cm_s,
mode: mode.clone(),
};
total += estimate_scan_time(&params);
}
total
let records = generate_paths_multi(regions, shapes, camera, coverage_rate,
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, mode);
if records.is_empty() { return 0.0; }
let total_x = records.len() as f64 * (regions.iter().map(|r| (r.x_max - r.x_min).abs()).sum::<f64>() / regions.len() as f64) / speed_x_scan_cm_s;
let return_time = if matches!(mode, ScanMode::OneWay) {
(records.len() as f64 - 1.0) * speed_x_scan_cm_s / speed_x_start_cm_s.max(0.01)
} else { 0.0 };
(total_x + return_time) / 60.0
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Spectral Insight Mission Plan",
"version": "0.0.2",
"version": "0.0.3",
"identifier": "com.spectral-insight.mission-plan",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -18,7 +18,7 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import type { ScanRegion, ScanMode } from '../../types/path-plan';
import type { ScanRegion, ScanMode, ScanShape } from '../../types/path-plan';
import type { PathLineRecord } from '../../types/path-line';
const props = defineProps<{
@ -27,6 +27,7 @@ const props = defineProps<{
mode: ScanMode;
backgroundImage?: string;
drawingMode: boolean;
drawingShape?: ScanShape;
}>();
const emit = defineEmits<{
@ -114,31 +115,51 @@ function draw() {
const x = padding + (i / 10) * drawW;
const y = padding + (i / 10) * drawH;
ctx.beginPath(); ctx.moveTo(x, padding); ctx.lineTo(x, padding + drawH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(padding, y); ctx.lineTo(padding + drawW, y); ctx.stroke();
}
// Draw all rectangles
// Draw all regions (rect or circle)
for (let i = 0; i < props.regions.length; i++) {
const r = props.regions[i];
const p1 = worldToCanvas(r.xMin, r.yMin);
const p2 = worldToCanvas(r.xMax, r.yMax);
ctx.strokeStyle = COLORS[i % COLORS.length];
ctx.lineWidth = 2;
if (r.shape === 'Circle') {
const cx = (p1.x + p2.x) / 2;
const cy = (p1.y + p2.y) / 2;
const rx = Math.abs(p2.x - p1.x) / 2;
const ry = Math.abs(p2.y - p1.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
}
// Label
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.font = 'bold 12px sans-serif';
ctx.fillText(`#${i + 1}`, p1.x + 4, p1.y - 4);
}
// Draw current drawing rect
// Draw current drawing shape
if (drawingRect.value) {
const p1 = worldToCanvas(drawingRect.value.xMin, drawingRect.value.yMin);
const p2 = worldToCanvas(drawingRect.value.xMax, drawingRect.value.yMax);
ctx.strokeStyle = '#f00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
if (props.drawingShape === 'Circle') {
const cx = (p1.x + p2.x) / 2;
const cy = (p1.y + p2.y) / 2;
const rx = Math.abs(p2.x - p1.x) / 2;
const ry = Math.abs(p2.y - p1.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
}
ctx.setLineDash([]);
ctx.setLineDash([]);
}
@ -171,6 +192,7 @@ function onMouseMove(e: MouseEvent) {
const p1 = toWorld(dragStart.value.x, dragStart.value.y);
const p2 = toWorld(p.x, p.y);
drawingRect.value = {
shape: props.drawingShape ? props.drawingShape : "Rect",
xMin: Math.min(p1.x, p2.x), xMax: Math.max(p1.x, p2.x),
yMin: Math.min(p1.y, p2.y), yMax: Math.max(p1.y, p2.y),
};
@ -183,7 +205,7 @@ function onMouseUp() {
if (drawingRect.value) {
const r = drawingRect.value;
if (r.xMax - r.xMin > 1 && r.yMax - r.yMin > 1) {
emit('add-region', r);
emit('add-region', { ...r, shape: props.drawingShape ? props.drawingShape : 'Rect' });
}
drawingRect.value = null;
draw();
@ -195,7 +217,7 @@ onMounted(() => {
window.addEventListener('resize', draw);
});
watch(() => [props.regions, props.records, props.mode, props.drawingMode], () => { draw(); });
watch(() => [props.regions, props.records, props.mode, props.drawingMode, props.drawingShape], () => { draw(); });
watch(() => props.backgroundImage, (v) => { loadBackgroundImage(v); });
</script>

View File

@ -1,8 +1,11 @@
export type ScanShape = 'Rect' | 'Circle';
export interface ScanRegion {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
shape: ScanShape;
}
export interface CameraParams {

View File

@ -1,7 +1,7 @@
<template>
<div class="home">
<n-space vertical size="large" class="home-content">
<h1>Happa Mission Plan</h1>
<h1>Spectral Insight Mission Plan</h1>
<p class="subtitle">设备自动采集任务规划系统</p>
<n-space size="large">

View File

@ -5,9 +5,13 @@
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
返回
</n-button>
<n-button size="small" :type="drawingMode ? 'warning' : 'default'" @click="toggleDrawing">
<n-button size="small" :type="drawingMode && currentShape === 'Rect' ? 'warning' : 'default'" @click="startDraw('Rect')">
<template #icon><n-icon><SquareOutline /></n-icon></template>
{{ drawingMode ? '完成画框' : '画框' }}
画框
</n-button>
<n-button size="small" :type="drawingMode && currentShape === 'Circle' ? 'warning' : 'default'" @click="startDraw('Circle')">
<template #icon><n-icon><EllipseOutline /></n-icon></template>
画圆
</n-button>
<n-button size="small" @click="clearRects" :disabled="regions.length === 0">
<template #icon><n-icon><TrashOutline /></n-icon></template>
@ -31,6 +35,7 @@
:mode="scanMode"
:background-image="missionStore.mission.backgroundImage || undefined"
:drawing-mode="drawingMode"
:drawing-shape="currentShape"
@add-region="onAddRegion"
/>
</template>
@ -55,13 +60,13 @@
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMessage } from 'naive-ui';
import { ArrowBackOutline, SquareOutline, TrashOutline, MapOutline, SaveOutline } from '@vicons/ionicons5';
import { ArrowBackOutline, SquareOutline, EllipseOutline, TrashOutline, MapOutline, SaveOutline } from '@vicons/ionicons5';
import { invoke } from '@tauri-apps/api/core';
import ScanCanvas from '../components/planner/ScanCanvas.vue';
import ScanParamPanel from '../components/planner/ScanParamPanel.vue';
import { useMissionStore } from '../stores/mission';
import type { PathLineRecord, PathLineFile } from '../types/path-line';
import type { ScanRegion, CameraParams, ScanMode } from '../types/path-plan';
import type { ScanRegion, CameraParams, ScanMode, ScanShape } from '../types/path-plan';
import type { PlannerDefaults } from '../types/mission';
import { DEVICE_FOV } from '../utils/constants';
@ -74,6 +79,7 @@ const taskId = route.query.taskId ? Number(route.query.taskId) : null;
const subTaskId = route.query.subTaskId ? String(route.query.subTaskId) : null;
const drawingMode = ref(false);
const currentShape = ref<ScanShape>('Rect');
function getDeviceFov(): number {
if (taskId === null || subTaskId === null) return 30;
@ -104,11 +110,17 @@ invoke<PlannerDefaults>('load_planner_defaults').then(p => {
camera.value = { fovDegrees: camera.value.fovDegrees, heightCm: p.heightCm };
}).catch(() => {});
function toggleDrawing() {
drawingMode.value = !drawingMode.value;
function startDraw(shape: ScanShape) {
if (drawingMode.value && currentShape.value === shape) {
drawingMode.value = false;
} else {
drawingMode.value = true;
currentShape.value = shape;
}
}
function onAddRegion(region: ScanRegion) {
region.shape = currentShape.value;
regions.value.push(region);
message.success(`已添加框 #${regions.value.length}`);
}
@ -148,6 +160,7 @@ async function generatePath() {
try {
const file = await invoke<PathLineFile>('generate_scan_paths_multi', {
regions: regions.value,
shapes: regions.value.map(r => r.shape),
camera: camera.value,
coverageRate: coverageRate.value,
speedYCmS: speedY.value,
@ -158,6 +171,7 @@ async function generatePath() {
generatedRecords.value = file.records;
const estTime = await invoke<number>('estimate_scan_time_multi_minutes', {
regions: regions.value,
shapes: regions.value.map(r => r.shape),
camera: camera.value,
coverageRate: coverageRate.value,
speedYCmS: speedY.value,

View File

@ -1,5 +1,9 @@
# Spectral Insight Mission Plan - 更新日志
## v0.0.3 (2026-06-18)
- docs: add 说明书.md
## v0.0.2
- 路径查看弹窗改为全屏双栏布局(左侧表格 + 右侧 Canvas 画布)

View File

@ -166,13 +166,7 @@ Spectral Insight Mission Plan 是一款设备自动采集任务规划软件,
---
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| F12 | 打开/关闭开发者工具DevTools |
---
## 版本信息