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", "name": "happa-mission-plan",
"private": true, "private": true,
"version": "0.1.0", "version": "0.0.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

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

View File

@ -27,9 +27,11 @@ pub fn estimate_scan_time_minutes(params: ScanParams) -> f64 {
services::path_generator::estimate_scan_time(&params) services::path_generator::estimate_scan_time(&params)
} }
#[command] #[command]
pub fn generate_scan_paths_multi( pub fn generate_scan_paths_multi(
regions: Vec<ScanRegion>, regions: Vec<ScanRegion>,
shapes: Vec<String>,
camera: CameraParams, camera: CameraParams,
coverage_rate: f64, coverage_rate: f64,
speed_y_cm_s: f64, speed_y_cm_s: f64,
@ -38,7 +40,7 @@ pub fn generate_scan_paths_multi(
mode: ScanMode, mode: ScanMode,
) -> Result<PathLineFile, AppError> { ) -> Result<PathLineFile, AppError> {
let records = services::path_generator::generate_paths_multi( 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, speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
); );
let count = records.len() as u64; let count = records.len() as u64;
@ -48,6 +50,7 @@ pub fn generate_scan_paths_multi(
#[command] #[command]
pub fn estimate_scan_time_multi_minutes( pub fn estimate_scan_time_multi_minutes(
regions: Vec<ScanRegion>, regions: Vec<ScanRegion>,
shapes: Vec<String>,
camera: CameraParams, camera: CameraParams,
coverage_rate: f64, coverage_rate: f64,
speed_y_cm_s: f64, speed_y_cm_s: f64,
@ -56,7 +59,7 @@ pub fn estimate_scan_time_multi_minutes(
mode: ScanMode, mode: ScanMode,
) -> f64 { ) -> f64 {
services::path_generator::estimate_scan_time_multi( 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, 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. /// Generate paths for multiple regions, concatenated into one record list.
pub fn generate_paths_multi( pub fn generate_paths_multi(
regions: &[ScanRegion], regions: &[ScanRegion],
shapes: &[String],
camera: &crate::models::CameraParams, camera: &crate::models::CameraParams,
coverage_rate: f64, coverage_rate: f64,
speed_y_cm_s: f64, speed_y_cm_s: f64,
@ -113,24 +115,54 @@ pub fn generate_paths_multi(
mode: &ScanMode, mode: &ScanMode,
) -> Vec<PathLineRecord> { ) -> Vec<PathLineRecord> {
let mut all_records = Vec::new(); let mut all_records = Vec::new();
for region in regions { for (i, region) in regions.iter().enumerate() {
let params = ScanParams { let is_circle = shapes.get(i).map(|s| s == "Circle").unwrap_or(false);
region: region.clone(), let step = calc_footprint(camera.height_cm, camera.fov_degrees)
camera: camera.clone(), * (1.0 - coverage_rate / 100.0);
coverage_rate, let line_count = if step <= 0.0 { 1 } else { ((region.y_max - region.y_min) / step).ceil() as u64 + 1 };
speed_y_cm_s,
speed_x_start_cm_s, for li in 0..line_count {
speed_x_scan_cm_s, let y = region.y_min + (li as f64) * step;
mode: mode.clone(), 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 all_records
} }
/// Estimate total time for multiple regions.
pub fn estimate_scan_time_multi( pub fn estimate_scan_time_multi(
regions: &[ScanRegion], regions: &[ScanRegion],
shapes: &[String],
camera: &crate::models::CameraParams, camera: &crate::models::CameraParams,
coverage_rate: f64, coverage_rate: f64,
speed_y_cm_s: f64, speed_y_cm_s: f64,
@ -138,18 +170,13 @@ pub fn estimate_scan_time_multi(
speed_x_scan_cm_s: f64, speed_x_scan_cm_s: f64,
mode: &ScanMode, mode: &ScanMode,
) -> f64 { ) -> f64 {
let mut total = 0.0; let records = generate_paths_multi(regions, shapes, camera, coverage_rate,
for region in regions { speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, mode);
let params = ScanParams { if records.is_empty() { return 0.0; }
region: region.clone(),
camera: camera.clone(), 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;
coverage_rate, let return_time = if matches!(mode, ScanMode::OneWay) {
speed_y_cm_s, (records.len() as f64 - 1.0) * speed_x_scan_cm_s / speed_x_start_cm_s.max(0.01)
speed_x_start_cm_s, } else { 0.0 };
speed_x_scan_cm_s, (total_x + return_time) / 60.0
mode: mode.clone(),
};
total += estimate_scan_time(&params);
}
total
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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