Initial commit: Happa Mission Plan v0.1.0
Task planning and path generation software for automated data collection equipment (hyperspectral cameras, DSLR, depth cameras). - Generate mission plans (JSON) with subtasks per device - Generate scan path binary files (.RecordLine3) - Multi-rectangle area planning with reference map support - Device-specific FOV defaults (Pika L 17.6°, Pika NIR 21.7°, etc.) - Timeline scheduling with constraint validation - Tauri 2.x + Vue 3 + Naive UI + Pinia
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
/target
|
||||||
7
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"tauri-apps.tauri-vscode",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Tauri + Vue + TypeScript
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tauri + Vue + Typescript App</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2130
package-lock.json
generated
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "happa-mission-plan",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"naive-ui": "^2.44.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
public/tauri.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
5235
src-tauri/Cargo.lock
generated
Normal file
25
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "happa-mission-plan"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "happa_mission_plan_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
thiserror = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
byteorder = "1"
|
||||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
12
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"dialog:default",
|
||||||
|
"fs:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
src-tauri/hooks.nsi
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
!macro preInit
|
||||||
|
; Auto-close if already installed (perMachine upgrade)
|
||||||
|
IfSilent 0 +2
|
||||||
|
SetSilent silent
|
||||||
|
!macroend
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
76
src-tauri/src/commands/defaults_commands.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use tauri::command;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::{MissionScanConfig, PlannerDefaults};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn defaults_dir() -> PathBuf {
|
||||||
|
// Save next to the executable
|
||||||
|
std::env::current_exe()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| std::path::Path::new("."))
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn save_default_scan_config(config: MissionScanConfig) -> Result<(), AppError> {
|
||||||
|
let path = defaults_dir().join("scan_defaults.json");
|
||||||
|
let content = serde_json::to_string_pretty(&config)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn load_default_scan_config() -> Result<MissionScanConfig, AppError> {
|
||||||
|
let path = defaults_dir().join("scan_defaults.json");
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let config: MissionScanConfig = serde_json::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(MissionScanConfig::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn save_default_background(path: String) -> Result<(), AppError> {
|
||||||
|
let file_path = defaults_dir().join("background_default.txt");
|
||||||
|
std::fs::write(file_path, &path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn load_default_background() -> Result<Option<String>, AppError> {
|
||||||
|
let file_path = defaults_dir().join("background_default.txt");
|
||||||
|
if file_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(file_path)?;
|
||||||
|
let trimmed = content.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(trimmed))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn save_planner_defaults(config: PlannerDefaults) -> Result<(), AppError> {
|
||||||
|
let path = defaults_dir().join("planner_defaults.json");
|
||||||
|
let content = serde_json::to_string_pretty(&config)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn load_planner_defaults() -> Result<PlannerDefaults, AppError> {
|
||||||
|
let path = defaults_dir().join("planner_defaults.json");
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let config: PlannerDefaults = serde_json::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(PlannerDefaults::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src-tauri/src/commands/devtools_commands.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
use tauri::{command, Webview};
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn toggle_devtools(webview: Webview) {
|
||||||
|
if webview.is_devtools_open() {
|
||||||
|
webview.close_devtools();
|
||||||
|
} else {
|
||||||
|
webview.open_devtools();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src-tauri/src/commands/file_commands.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use tauri::command;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::io;
|
||||||
|
use crate::models::MissionPlan;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn load_mission(path: String) -> Result<MissionPlan, AppError> {
|
||||||
|
io::json_reader::read_mission(Path::new(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn save_mission(path: String, mission: MissionPlan) -> Result<(), AppError> {
|
||||||
|
io::json_writer::write_mission(Path::new(&path), &mission)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn new_mission() -> MissionPlan {
|
||||||
|
MissionPlan::new()
|
||||||
|
}
|
||||||
6
src-tauri/src/commands/mod.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub mod file_commands;
|
||||||
|
pub mod task_commands;
|
||||||
|
pub mod validation_commands;
|
||||||
|
pub mod path_commands;
|
||||||
|
pub mod devtools_commands;
|
||||||
|
pub mod defaults_commands;
|
||||||
62
src-tauri/src/commands/path_commands.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use tauri::command;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::io;
|
||||||
|
use crate::models::{PathLineFile, ScanParams, ScanRegion, CameraParams, ScanMode};
|
||||||
|
use crate::services;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn load_path_line(path: String) -> Result<PathLineFile, AppError> {
|
||||||
|
io::binary_reader::read_path_line(Path::new(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn save_path_line(path: String, file: PathLineFile) -> Result<(), AppError> {
|
||||||
|
io::binary_writer::write_path_line(Path::new(&path), &file)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn generate_scan_path(params: ScanParams) -> Result<PathLineFile, AppError> {
|
||||||
|
let records = services::path_generator::generate_path(¶ms);
|
||||||
|
let count = records.len() as u64;
|
||||||
|
Ok(PathLineFile { count, records })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn estimate_scan_time_minutes(params: ScanParams) -> f64 {
|
||||||
|
services::path_generator::estimate_scan_time(¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn generate_scan_paths_multi(
|
||||||
|
regions: Vec<ScanRegion>,
|
||||||
|
camera: CameraParams,
|
||||||
|
coverage_rate: f64,
|
||||||
|
speed_y_cm_s: f64,
|
||||||
|
speed_x_start_cm_s: f64,
|
||||||
|
speed_x_scan_cm_s: f64,
|
||||||
|
mode: ScanMode,
|
||||||
|
) -> Result<PathLineFile, AppError> {
|
||||||
|
let records = services::path_generator::generate_paths_multi(
|
||||||
|
®ions, &camera, coverage_rate,
|
||||||
|
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
|
||||||
|
);
|
||||||
|
let count = records.len() as u64;
|
||||||
|
Ok(PathLineFile { count, records })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn estimate_scan_time_multi_minutes(
|
||||||
|
regions: Vec<ScanRegion>,
|
||||||
|
camera: CameraParams,
|
||||||
|
coverage_rate: f64,
|
||||||
|
speed_y_cm_s: f64,
|
||||||
|
speed_x_start_cm_s: f64,
|
||||||
|
speed_x_scan_cm_s: f64,
|
||||||
|
mode: ScanMode,
|
||||||
|
) -> f64 {
|
||||||
|
services::path_generator::estimate_scan_time_multi(
|
||||||
|
®ions, &camera, coverage_rate,
|
||||||
|
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src-tauri/src/commands/task_commands.rs
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
use tauri::command;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::{MissionPlan, SubTask, SubTaskType, Task};
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn add_task(mission: MissionPlan) -> MissionPlan {
|
||||||
|
let mut m = mission;
|
||||||
|
let new_id = m.next_task_id();
|
||||||
|
m.tasks.push(Task::new(new_id));
|
||||||
|
m.task_count = m.tasks.len() as u32;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn copy_task(mission: MissionPlan, task_id: u32) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let src = m.tasks.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?
|
||||||
|
.clone();
|
||||||
|
let new_id = m.next_task_id();
|
||||||
|
let mut new_task = src;
|
||||||
|
new_task.id = new_id;
|
||||||
|
new_task.scheduled_time = "2000-01-01T00:00:00".to_string();
|
||||||
|
new_task.start_time = None;
|
||||||
|
new_task.end_time = None;
|
||||||
|
new_task.duration_minutes = 0.0;
|
||||||
|
new_task.estimated_duration_minutes = 0.0;
|
||||||
|
new_task.status = "Waiting".to_string();
|
||||||
|
// Reset subtask IDs
|
||||||
|
for sub in &mut new_task.sub_tasks {
|
||||||
|
sub.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
sub.start_time = None;
|
||||||
|
sub.end_time = None;
|
||||||
|
sub.duration_minutes = 0.0;
|
||||||
|
sub.estimated_duration_minutes = 0.0;
|
||||||
|
sub.status = "Waiting".to_string();
|
||||||
|
}
|
||||||
|
m.tasks.push(new_task);
|
||||||
|
m.task_count = m.tasks.len() as u32;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn remove_task(mission: MissionPlan, task_id: u32) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let pos = m.tasks.iter().position(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
m.tasks.remove(pos);
|
||||||
|
m.task_count = m.tasks.len() as u32;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn add_sub_task(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task_id: u32,
|
||||||
|
sub_task_type: SubTaskType,
|
||||||
|
) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let task = m.tasks.iter_mut()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
|
// Check: same type already exists
|
||||||
|
if task.has_type(&sub_task_type) {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"Task #{} already has a {} sub-task",
|
||||||
|
task_id,
|
||||||
|
sub_task_type.label()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
task.sub_tasks.push(SubTask::new(sub_task_type));
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn remove_sub_task(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task_id: u32,
|
||||||
|
sub_task_id: String,
|
||||||
|
) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let task = m.tasks.iter_mut()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
|
let pos = task.sub_tasks.iter().position(|s| s.id == sub_task_id)
|
||||||
|
.ok_or(AppError::SubTaskNotFound(sub_task_id))?;
|
||||||
|
task.sub_tasks.remove(pos);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn update_sub_task(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task_id: u32,
|
||||||
|
sub_task: SubTask,
|
||||||
|
) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let task = m.tasks.iter_mut()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
|
let pos = task.sub_tasks.iter().position(|s| s.id == sub_task.id)
|
||||||
|
.ok_or(AppError::SubTaskNotFound(sub_task.id.clone()))?;
|
||||||
|
task.sub_tasks[pos] = sub_task;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn update_task(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task: Task,
|
||||||
|
) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let pos = m.tasks.iter().position(|t| t.id == task.id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task.id))?;
|
||||||
|
m.tasks[pos] = task;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn reorder_sub_tasks(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task_id: u32,
|
||||||
|
from_index: usize,
|
||||||
|
to_index: usize,
|
||||||
|
) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
let task = m.tasks.iter_mut()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
|
if from_index >= task.sub_tasks.len() || to_index >= task.sub_tasks.len() {
|
||||||
|
return Err(AppError::Validation("Index out of bounds".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub = task.sub_tasks.remove(from_index);
|
||||||
|
task.sub_tasks.insert(to_index, sub);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn validate_sub_task_order(
|
||||||
|
mission: MissionPlan,
|
||||||
|
task_id: u32,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let task = mission.tasks.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.ok_or(AppError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
|
let mut found_camera = false;
|
||||||
|
for sub in &task.sub_tasks {
|
||||||
|
if sub.sub_task_type.is_camera_type() {
|
||||||
|
found_camera = true;
|
||||||
|
} else if found_camera && sub.sub_task_type.is_hyperspectral() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
17
src-tauri/src/commands/validation_commands.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::MissionPlan;
|
||||||
|
use crate::services;
|
||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn calculate_schedule(mission: MissionPlan) -> Result<MissionPlan, AppError> {
|
||||||
|
let mut m = mission;
|
||||||
|
services::scheduler::calculate_schedule(&mut m)?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub fn validate_mission(mission: MissionPlan) -> Vec<services::validator::ValidationIssue> {
|
||||||
|
let validator = services::validator::Validator::new();
|
||||||
|
validator.validate(&mission)
|
||||||
|
}
|
||||||
28
src-tauri/src/error.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("JSON parse error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("Binary format error at byte {offset}: {detail}")]
|
||||||
|
BinaryFormat { offset: u64, detail: String },
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("Task {0} not found")]
|
||||||
|
TaskNotFound(u32),
|
||||||
|
#[error("SubTask {0} not found")]
|
||||||
|
SubTaskNotFound(String),
|
||||||
|
#[error("Chrono parse error: {0}")]
|
||||||
|
ChronoParse(#[from] chrono::ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for AppError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src-tauri/src/io/binary_reader.rs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::{PathLineFile, PathLineRecord};
|
||||||
|
|
||||||
|
/// Read a .RecordLine3 binary file.
|
||||||
|
///
|
||||||
|
/// Format:
|
||||||
|
/// [1 x f64 = count of remaining f64 values] + [count x f64]
|
||||||
|
/// Every 6 f64 values = one PathLineRecord
|
||||||
|
pub fn read_path_line(path: &Path) -> Result<PathLineFile, AppError> {
|
||||||
|
let data = std::fs::read(path)?;
|
||||||
|
|
||||||
|
if data.len() < 8 {
|
||||||
|
return Err(AppError::BinaryFormat {
|
||||||
|
offset: 0,
|
||||||
|
detail: "File too short: need at least 8 bytes for count header".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let count_bytes: [u8; 8] = data[..8].try_into().unwrap();
|
||||||
|
let total_elements = f64::from_le_bytes(count_bytes) as u64;
|
||||||
|
|
||||||
|
let expected_data_size = total_elements as usize * 8;
|
||||||
|
if data.len() != 8 + expected_data_size {
|
||||||
|
return Err(AppError::BinaryFormat {
|
||||||
|
offset: 8,
|
||||||
|
detail: format!(
|
||||||
|
"File size mismatch: header claims {} f64 values ({} bytes) but file has {} data bytes",
|
||||||
|
total_elements,
|
||||||
|
expected_data_size,
|
||||||
|
data.len() - 8
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_elements % 6 != 0 {
|
||||||
|
return Err(AppError::BinaryFormat {
|
||||||
|
offset: 0,
|
||||||
|
detail: format!(
|
||||||
|
"Total element count {} is not a multiple of 6",
|
||||||
|
total_elements
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let records_count = total_elements / 6;
|
||||||
|
let mut records = Vec::with_capacity(records_count as usize);
|
||||||
|
|
||||||
|
for i in 0..records_count {
|
||||||
|
let base = 8 + (i as usize * 6 * 8);
|
||||||
|
let record_bytes = &data[base..base + 48];
|
||||||
|
|
||||||
|
let mut iter = record_bytes.chunks_exact(8);
|
||||||
|
let read_f64 = |chunks: &mut std::slice::ChunksExact<u8>| -> f64 {
|
||||||
|
f64::from_le_bytes(chunks.next().unwrap().try_into().unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
records.push(PathLineRecord {
|
||||||
|
target_y_position: read_f64(&mut iter),
|
||||||
|
speed_target_y_position: read_f64(&mut iter),
|
||||||
|
target_x_min_position: read_f64(&mut iter),
|
||||||
|
speed_target_x_min_position: read_f64(&mut iter),
|
||||||
|
target_x_max_position: read_f64(&mut iter),
|
||||||
|
speed_target_x_max_position: read_f64(&mut iter),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PathLineFile {
|
||||||
|
count: records_count,
|
||||||
|
records,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
src-tauri/src/io/binary_writer.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::PathLineFile;
|
||||||
|
|
||||||
|
/// Write a .RecordLine3 binary file.
|
||||||
|
///
|
||||||
|
/// Writes total_elements = records.len() * 6 as the first f64,
|
||||||
|
/// then serializes each record's 6 f64 values in order.
|
||||||
|
pub fn write_path_line(path: &Path, file: &PathLineFile) -> Result<(), AppError> {
|
||||||
|
let total_elements = file.records.len() as u64 * 6;
|
||||||
|
let mut buf = Vec::with_capacity(8 + total_elements as usize * 8);
|
||||||
|
|
||||||
|
buf.extend_from_slice(&(total_elements as f64).to_le_bytes());
|
||||||
|
|
||||||
|
for record in &file.records {
|
||||||
|
buf.extend_from_slice(&record.target_y_position.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&record.speed_target_y_position.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&record.target_x_min_position.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&record.speed_target_x_min_position.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&record.target_x_max_position.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&record.speed_target_x_max_position.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(path, buf)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
9
src-tauri/src/io/json_reader.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::MissionPlan;
|
||||||
|
|
||||||
|
pub fn read_mission(path: &Path) -> Result<MissionPlan, AppError> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let mission: MissionPlan = serde_json::from_str(&content)?;
|
||||||
|
Ok(mission)
|
||||||
|
}
|
||||||
9
src-tauri/src/io/json_writer.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::MissionPlan;
|
||||||
|
|
||||||
|
pub fn write_mission(path: &Path, mission: &MissionPlan) -> Result<(), AppError> {
|
||||||
|
let content = serde_json::to_string_pretty(mission)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
src-tauri/src/io/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod json_reader;
|
||||||
|
pub mod json_writer;
|
||||||
|
pub mod binary_reader;
|
||||||
|
pub mod binary_writer;
|
||||||
53
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
mod io;
|
||||||
|
mod services;
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(move |app| {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
w.set_title(&format!("Happa Mission Plan v{}", version)).ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::file_commands::load_mission,
|
||||||
|
commands::file_commands::save_mission,
|
||||||
|
commands::file_commands::new_mission,
|
||||||
|
commands::task_commands::add_task,
|
||||||
|
commands::task_commands::copy_task,
|
||||||
|
commands::task_commands::remove_task,
|
||||||
|
commands::task_commands::add_sub_task,
|
||||||
|
commands::task_commands::remove_sub_task,
|
||||||
|
commands::task_commands::update_sub_task,
|
||||||
|
commands::task_commands::update_task,
|
||||||
|
commands::task_commands::reorder_sub_tasks,
|
||||||
|
commands::task_commands::validate_sub_task_order,
|
||||||
|
commands::validation_commands::calculate_schedule,
|
||||||
|
commands::validation_commands::validate_mission,
|
||||||
|
commands::path_commands::load_path_line,
|
||||||
|
commands::path_commands::save_path_line,
|
||||||
|
commands::path_commands::generate_scan_path,
|
||||||
|
commands::path_commands::estimate_scan_time_minutes,
|
||||||
|
commands::path_commands::generate_scan_paths_multi,
|
||||||
|
commands::path_commands::estimate_scan_time_multi_minutes,
|
||||||
|
commands::devtools_commands::toggle_devtools,
|
||||||
|
commands::defaults_commands::save_default_scan_config,
|
||||||
|
commands::defaults_commands::load_default_scan_config,
|
||||||
|
commands::defaults_commands::save_default_background,
|
||||||
|
commands::defaults_commands::load_default_background,
|
||||||
|
commands::defaults_commands::save_planner_defaults,
|
||||||
|
commands::defaults_commands::load_planner_defaults,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
happa_mission_plan_lib::run()
|
||||||
|
}
|
||||||
59
src-tauri/src/models/mission.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use super::task::Task;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MissionScanConfig {
|
||||||
|
#[serde(rename = "xMin")]
|
||||||
|
pub x_min: f64,
|
||||||
|
#[serde(rename = "xMax")]
|
||||||
|
pub x_max: f64,
|
||||||
|
#[serde(rename = "yMin")]
|
||||||
|
pub y_min: f64,
|
||||||
|
#[serde(rename = "yMax")]
|
||||||
|
pub y_max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MissionScanConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
x_min: 0.0,
|
||||||
|
x_max: 100.0,
|
||||||
|
y_min: 0.0,
|
||||||
|
y_max: 100.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MissionPlan {
|
||||||
|
pub version: String,
|
||||||
|
#[serde(rename = "taskCount")]
|
||||||
|
pub task_count: u32,
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
#[serde(rename = "scanConfig", default = "MissionScanConfig::default")]
|
||||||
|
pub scan_config: MissionScanConfig,
|
||||||
|
#[serde(rename = "backgroundImage", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub background_image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissionPlan {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
version: "1.0".to_string(),
|
||||||
|
task_count: 0,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
scan_config: MissionScanConfig::default(),
|
||||||
|
background_image: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_task_id(&self) -> u32 {
|
||||||
|
self.tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MissionPlan {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src-tauri/src/models/mod.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
pub mod mission;
|
||||||
|
pub mod task;
|
||||||
|
pub mod path_line;
|
||||||
|
pub mod path_plan;
|
||||||
|
|
||||||
|
pub use mission::{MissionPlan, MissionScanConfig};
|
||||||
|
pub use task::{SubTask, SubTaskType, Task};
|
||||||
|
pub use path_line::{PathLineFile, PathLineRecord};
|
||||||
|
pub use path_plan::{ScanMode, ScanParams, ScanRegion, CameraParams, PlannerDefaults};
|
||||||
23
src-tauri/src/models/path_line.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PathLineRecord {
|
||||||
|
#[serde(rename = "targetYPosition")]
|
||||||
|
pub target_y_position: f64,
|
||||||
|
#[serde(rename = "speedTargetYPosition")]
|
||||||
|
pub speed_target_y_position: f64,
|
||||||
|
#[serde(rename = "targetXMinPosition")]
|
||||||
|
pub target_x_min_position: f64,
|
||||||
|
#[serde(rename = "speedTargetXMinPosition")]
|
||||||
|
pub speed_target_x_min_position: f64,
|
||||||
|
#[serde(rename = "targetXMaxPosition")]
|
||||||
|
pub target_x_max_position: f64,
|
||||||
|
#[serde(rename = "speedTargetXMaxPosition")]
|
||||||
|
pub speed_target_x_max_position: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PathLineFile {
|
||||||
|
pub count: u64,
|
||||||
|
pub records: Vec<PathLineRecord>,
|
||||||
|
}
|
||||||
73
src-tauri/src/models/path_plan.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScanRegion {
|
||||||
|
#[serde(rename = "xMin")]
|
||||||
|
pub x_min: f64,
|
||||||
|
#[serde(rename = "xMax")]
|
||||||
|
pub x_max: f64,
|
||||||
|
#[serde(rename = "yMin")]
|
||||||
|
pub y_min: f64,
|
||||||
|
#[serde(rename = "yMax")]
|
||||||
|
pub y_max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CameraParams {
|
||||||
|
#[serde(rename = "fovDegrees")]
|
||||||
|
pub fov_degrees: f64,
|
||||||
|
#[serde(rename = "heightCm")]
|
||||||
|
pub height_cm: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ScanMode {
|
||||||
|
Zigzag,
|
||||||
|
OneWay,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScanParams {
|
||||||
|
pub region: ScanRegion,
|
||||||
|
pub camera: CameraParams,
|
||||||
|
#[serde(rename = "coverageRate")]
|
||||||
|
pub coverage_rate: f64,
|
||||||
|
#[serde(rename = "speedYCmS")]
|
||||||
|
pub speed_y_cm_s: f64,
|
||||||
|
#[serde(rename = "speedXStartCmS")]
|
||||||
|
pub speed_x_start_cm_s: f64,
|
||||||
|
#[serde(rename = "speedXScanCmS")]
|
||||||
|
pub speed_x_scan_cm_s: f64,
|
||||||
|
pub mode: ScanMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlannerDefaults {
|
||||||
|
#[serde(rename = "fovDegrees")]
|
||||||
|
pub fov_degrees: f64,
|
||||||
|
#[serde(rename = "heightCm")]
|
||||||
|
pub height_cm: f64,
|
||||||
|
#[serde(rename = "coverageRate")]
|
||||||
|
pub coverage_rate: f64,
|
||||||
|
#[serde(rename = "speedYCmS")]
|
||||||
|
pub speed_y_cm_s: f64,
|
||||||
|
#[serde(rename = "speedXStartCmS")]
|
||||||
|
pub speed_x_start_cm_s: f64,
|
||||||
|
#[serde(rename = "speedXScanCmS")]
|
||||||
|
pub speed_x_scan_cm_s: f64,
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlannerDefaults {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
fov_degrees: 30.0,
|
||||||
|
height_cm: 50.0,
|
||||||
|
coverage_rate: 30.0,
|
||||||
|
speed_y_cm_s: 5.0,
|
||||||
|
speed_x_start_cm_s: 20.0,
|
||||||
|
speed_x_scan_cm_s: 10.0,
|
||||||
|
mode: "Zigzag".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src-tauri/src/models/task.rs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SubTaskType {
|
||||||
|
#[serde(rename = "HyperSpectual400_1000nm")]
|
||||||
|
HyperSpectual400_1000nm,
|
||||||
|
#[serde(rename = "HyperSpectual1000_1700nm")]
|
||||||
|
HyperSpectual1000_1700nm,
|
||||||
|
#[serde(rename = "SingleLensReflex")]
|
||||||
|
SingleLensReflex,
|
||||||
|
#[serde(rename = "DepthCamera")]
|
||||||
|
DepthCamera,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubTaskType {
|
||||||
|
pub fn is_hyperspectral(&self) -> bool {
|
||||||
|
matches!(self, Self::HyperSpectual400_1000nm | Self::HyperSpectual1000_1700nm)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_camera_type(&self) -> bool {
|
||||||
|
matches!(self, Self::SingleLensReflex | Self::DepthCamera)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::HyperSpectual400_1000nm => "Pika L",
|
||||||
|
Self::HyperSpectual1000_1700nm => "Pika NIR",
|
||||||
|
Self::SingleLensReflex => "单反相机",
|
||||||
|
Self::DepthCamera => "深度相机",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SubTask {
|
||||||
|
#[serde(default = "default_subtask_id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub sub_task_type: SubTaskType,
|
||||||
|
#[serde(rename = "captureIntervalSeconds")]
|
||||||
|
pub capture_interval_seconds: Option<u32>,
|
||||||
|
#[serde(rename = "defaultRenderBand")]
|
||||||
|
pub default_render_band: Option<u32>,
|
||||||
|
#[serde(rename = "exposureTime")]
|
||||||
|
pub exposure_time: Option<u32>,
|
||||||
|
#[serde(rename = "frameRate")]
|
||||||
|
pub frame_rate: Option<u32>,
|
||||||
|
#[serde(rename = "pathLineFilePath")]
|
||||||
|
pub path_line_file_path: String,
|
||||||
|
#[serde(rename = "durationMinutes")]
|
||||||
|
pub duration_minutes: f64,
|
||||||
|
#[serde(rename = "estimatedDurationMinutes")]
|
||||||
|
pub estimated_duration_minutes: f64,
|
||||||
|
#[serde(rename = "endTime")]
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
#[serde(rename = "startTime")]
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_subtask_id() -> String {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubTask {
|
||||||
|
pub fn new(sub_task_type: SubTaskType) -> Self {
|
||||||
|
Self {
|
||||||
|
id: default_subtask_id(),
|
||||||
|
sub_task_type,
|
||||||
|
capture_interval_seconds: None,
|
||||||
|
default_render_band: Some(550),
|
||||||
|
exposure_time: None,
|
||||||
|
frame_rate: None,
|
||||||
|
path_line_file_path: String::new(),
|
||||||
|
duration_minutes: 0.0,
|
||||||
|
estimated_duration_minutes: 0.0,
|
||||||
|
end_time: None,
|
||||||
|
start_time: None,
|
||||||
|
status: "Waiting".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: u32,
|
||||||
|
#[serde(rename = "savePath")]
|
||||||
|
pub save_path: String,
|
||||||
|
#[serde(rename = "scheduledTime")]
|
||||||
|
pub scheduled_time: String,
|
||||||
|
#[serde(rename = "HalogenLampPreheatingTime_Minute")]
|
||||||
|
pub halogen_lamp_preheating_time_minute: f64,
|
||||||
|
#[serde(rename = "durationMinutes")]
|
||||||
|
pub duration_minutes: f64,
|
||||||
|
#[serde(rename = "estimatedDurationMinutes")]
|
||||||
|
pub estimated_duration_minutes: f64,
|
||||||
|
#[serde(rename = "endTime")]
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
#[serde(rename = "startTime")]
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(rename = "subTasks")]
|
||||||
|
pub sub_tasks: Vec<SubTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
save_path: String::new(),
|
||||||
|
scheduled_time: "2000-01-01T00:00:00".to_string(),
|
||||||
|
halogen_lamp_preheating_time_minute: 0.1,
|
||||||
|
duration_minutes: 0.0,
|
||||||
|
estimated_duration_minutes: 0.0,
|
||||||
|
end_time: None,
|
||||||
|
start_time: None,
|
||||||
|
status: "Waiting".to_string(),
|
||||||
|
sub_tasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_hyperspectral(&self) -> bool {
|
||||||
|
self.sub_tasks.iter().any(|s| s.sub_task_type.is_hyperspectral())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_type(&self, stype: &SubTaskType) -> bool {
|
||||||
|
self.sub_tasks.iter().any(|s| s.sub_task_type == *stype)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src-tauri/src/services/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod scheduler;
|
||||||
|
pub mod validator;
|
||||||
|
pub mod path_generator;
|
||||||
155
src-tauri/src/services/path_generator.rs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
use std::f64::consts::PI;
|
||||||
|
use crate::models::{PathLineRecord, ScanMode, ScanParams, ScanRegion};
|
||||||
|
|
||||||
|
const DEG_TO_RAD: f64 = PI / 180.0;
|
||||||
|
|
||||||
|
/// Calculate the camera footprint width at the target surface.
|
||||||
|
pub fn calc_footprint(height_cm: f64, fov_degrees: f64) -> f64 {
|
||||||
|
2.0 * height_cm * (fov_degrees / 2.0 * DEG_TO_RAD).tan()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate path line records from scan parameters.
|
||||||
|
pub fn generate_path(params: &ScanParams) -> Vec<PathLineRecord> {
|
||||||
|
let footprint = calc_footprint(params.camera.height_cm, params.camera.fov_degrees);
|
||||||
|
let step = footprint * (1.0 - params.coverage_rate / 100.0);
|
||||||
|
|
||||||
|
let x_min = params.region.x_min;
|
||||||
|
let x_max = params.region.x_max;
|
||||||
|
let y_min = params.region.y_min;
|
||||||
|
let y_max = params.region.y_max;
|
||||||
|
|
||||||
|
let line_count = if step <= 0.0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
((y_max - y_min) / step).ceil() as u64 + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut records = Vec::with_capacity(line_count as usize);
|
||||||
|
|
||||||
|
match params.mode {
|
||||||
|
ScanMode::Zigzag => {
|
||||||
|
for i in 0..line_count {
|
||||||
|
let y = y_min + (i as f64) * step;
|
||||||
|
if y > y_max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_forward = i % 2 == 0;
|
||||||
|
let (scan_start, scan_end) = if is_forward {
|
||||||
|
(x_min, x_max)
|
||||||
|
} else {
|
||||||
|
(x_max, x_min)
|
||||||
|
};
|
||||||
|
|
||||||
|
records.push(PathLineRecord {
|
||||||
|
target_y_position: y,
|
||||||
|
speed_target_y_position: params.speed_y_cm_s,
|
||||||
|
target_x_min_position: scan_start,
|
||||||
|
speed_target_x_min_position: params.speed_x_start_cm_s,
|
||||||
|
target_x_max_position: scan_end,
|
||||||
|
speed_target_x_max_position: params.speed_x_scan_cm_s,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanMode::OneWay => {
|
||||||
|
for i in 0..line_count {
|
||||||
|
let y = y_min + (i as f64) * step;
|
||||||
|
if y > y_max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(PathLineRecord {
|
||||||
|
target_y_position: y,
|
||||||
|
speed_target_y_position: params.speed_y_cm_s,
|
||||||
|
target_x_min_position: x_min,
|
||||||
|
speed_target_x_min_position: params.speed_x_start_cm_s,
|
||||||
|
target_x_max_position: x_max,
|
||||||
|
speed_target_x_max_position: params.speed_x_scan_cm_s,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate total time for a scan.
|
||||||
|
pub fn estimate_scan_time(params: &ScanParams) -> f64 {
|
||||||
|
let records = generate_path(params);
|
||||||
|
if records.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let y_range = (params.region.y_max - params.region.y_min).abs();
|
||||||
|
let x_range = (params.region.x_max - params.region.x_min).abs();
|
||||||
|
|
||||||
|
let y_time = if params.speed_y_cm_s > 0.0 {
|
||||||
|
y_range / params.speed_y_cm_s
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_x_time = records.len() as f64 * x_range / params.speed_x_scan_cm_s;
|
||||||
|
|
||||||
|
let total_return_time = if matches!(params.mode, ScanMode::OneWay) {
|
||||||
|
// In one-way mode, return to X start between lines
|
||||||
|
(records.len() as f64 - 1.0) * x_range / params.speed_x_start_cm_s.max(0.01)
|
||||||
|
} else {
|
||||||
|
// In zigzag mode, no return travel needed
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
(y_time + total_x_time + total_return_time) / 60.0 // convert to minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate paths for multiple regions, concatenated into one record list.
|
||||||
|
pub fn generate_paths_multi(
|
||||||
|
regions: &[ScanRegion],
|
||||||
|
camera: &crate::models::CameraParams,
|
||||||
|
coverage_rate: f64,
|
||||||
|
speed_y_cm_s: f64,
|
||||||
|
speed_x_start_cm_s: f64,
|
||||||
|
speed_x_scan_cm_s: f64,
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
all_records.extend(generate_path(¶ms));
|
||||||
|
}
|
||||||
|
all_records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate total time for multiple regions.
|
||||||
|
pub fn estimate_scan_time_multi(
|
||||||
|
regions: &[ScanRegion],
|
||||||
|
camera: &crate::models::CameraParams,
|
||||||
|
coverage_rate: f64,
|
||||||
|
speed_y_cm_s: f64,
|
||||||
|
speed_x_start_cm_s: f64,
|
||||||
|
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(¶ms);
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
95
src-tauri/src/services/scheduler.rs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::{MissionPlan, SubTaskType};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
pub fn calculate_schedule(mission: &mut MissionPlan) -> Result<(), AppError> {
|
||||||
|
let fmt = "%Y-%m-%dT%H:%M:%S";
|
||||||
|
|
||||||
|
for task in &mut mission.tasks {
|
||||||
|
if task.scheduled_time.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current_time = NaiveDateTime::parse_from_str(&task.scheduled_time, fmt)?;
|
||||||
|
|
||||||
|
// Phase 1: Halogen lamp preheating
|
||||||
|
if task.has_hyperspectral() && task.halogen_lamp_preheating_time_minute > 0.0 {
|
||||||
|
let preheat_minutes = task.halogen_lamp_preheating_time_minute;
|
||||||
|
current_time = current_time
|
||||||
|
.checked_add_signed(chrono::Duration::minutes(
|
||||||
|
(preheat_minutes * 60.0) as i64,
|
||||||
|
))
|
||||||
|
.unwrap_or(current_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Process subtasks using index to avoid borrow conflicts
|
||||||
|
let sub_types: Vec<SubTaskType> = task.sub_tasks.iter().map(|s| s.sub_task_type.clone()).collect();
|
||||||
|
|
||||||
|
for i in 0..task.sub_tasks.len() {
|
||||||
|
let sub = &mut task.sub_tasks[i];
|
||||||
|
sub.start_time = Some(current_time.format(fmt).to_string());
|
||||||
|
|
||||||
|
let sub_type = sub.sub_task_type.clone();
|
||||||
|
|
||||||
|
// Calculate estimated duration
|
||||||
|
let est_duration = match sub_type {
|
||||||
|
SubTaskType::HyperSpectual400_1000nm | SubTaskType::HyperSpectual1000_1700nm => {
|
||||||
|
sub.estimated_duration_minutes
|
||||||
|
}
|
||||||
|
SubTaskType::SingleLensReflex | SubTaskType::DepthCamera => {
|
||||||
|
if let Some(interval) = sub.capture_interval_seconds {
|
||||||
|
sub.estimated_duration_minutes
|
||||||
|
.max(interval as f64 * 10.0 / 60.0)
|
||||||
|
} else {
|
||||||
|
sub.estimated_duration_minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add DSLR wake-up delay if needed
|
||||||
|
if sub_type == SubTaskType::SingleLensReflex {
|
||||||
|
// Check if previous subtask exists and is not DSLR
|
||||||
|
let prev_is_dslr = if i > 0 {
|
||||||
|
sub_types[i - 1] == SubTaskType::SingleLensReflex
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !prev_is_dslr {
|
||||||
|
// Add 135s (2.25 min) wake-up delay
|
||||||
|
current_time = current_time
|
||||||
|
.checked_add_signed(chrono::Duration::seconds(135))
|
||||||
|
.unwrap_or(current_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time by estimated execution duration
|
||||||
|
let duration_secs = (est_duration * 60.0) as i64;
|
||||||
|
current_time = current_time
|
||||||
|
.checked_add_signed(chrono::Duration::seconds(duration_secs.max(1)))
|
||||||
|
.unwrap_or(current_time);
|
||||||
|
|
||||||
|
sub.end_time = Some(current_time.format(fmt).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set task-level timing
|
||||||
|
if let Some(first) = task.sub_tasks.first() {
|
||||||
|
task.start_time = first.start_time.clone();
|
||||||
|
}
|
||||||
|
if let Some(last) = task.sub_tasks.last() {
|
||||||
|
task.end_time = last.end_time.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(st), Some(et)) = (&task.start_time, &task.end_time) {
|
||||||
|
if let (Ok(s), Ok(e)) =
|
||||||
|
(NaiveDateTime::parse_from_str(st, fmt), NaiveDateTime::parse_from_str(et, fmt))
|
||||||
|
{
|
||||||
|
let dur_minutes = (e - s).num_seconds() as f64 / 60.0;
|
||||||
|
task.duration_minutes = dur_minutes;
|
||||||
|
task.estimated_duration_minutes = dur_minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
355
src-tauri/src/services/validator.rs
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
use crate::models::{MissionPlan, SubTaskType};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationIssue {
|
||||||
|
pub severity: ValidationSeverity,
|
||||||
|
pub rule: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(rename = "taskId")]
|
||||||
|
pub task_id: Option<u32>,
|
||||||
|
#[serde(rename = "subTaskId")]
|
||||||
|
pub sub_task_id: Option<String>,
|
||||||
|
#[serde(rename = "fieldPath")]
|
||||||
|
pub field_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ValidationSeverity {
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ValidationRule: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Validator {
|
||||||
|
rules: Vec<Box<dyn ValidationRule>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Validator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
rules: vec![
|
||||||
|
Box::new(SubTaskOrderRule),
|
||||||
|
Box::new(HalogenPreheatingRule),
|
||||||
|
Box::new(DSLRWakeupRule),
|
||||||
|
Box::new(RequiredFieldsRule),
|
||||||
|
Box::new(TimingConsistencyRule),
|
||||||
|
Box::new(HyperspectralRequiredRule),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
self.rules
|
||||||
|
.iter()
|
||||||
|
.flat_map(|rule| rule.check(mission))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Validator {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 1: Hyperspectral subtasks must come before DSLR/Depth
|
||||||
|
struct SubTaskOrderRule;
|
||||||
|
|
||||||
|
impl ValidationRule for SubTaskOrderRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SubTaskOrder"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for task in &mission.tasks {
|
||||||
|
let mut found_camera = false;
|
||||||
|
for sub in &task.sub_tasks {
|
||||||
|
if sub.sub_task_type.is_camera_type() {
|
||||||
|
found_camera = true;
|
||||||
|
} else if found_camera && sub.sub_task_type.is_hyperspectral() {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: 高光谱子任务 ({} 在位置 {}) 必须在单反/深度之后",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label(),
|
||||||
|
"unknown"
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: Some(format!("tasks[{}].subTasks[{}]", task.id, "order")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Halogen preheating required when hyperspectral tasks exist
|
||||||
|
struct HalogenPreheatingRule;
|
||||||
|
|
||||||
|
impl ValidationRule for HalogenPreheatingRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"HalogenPreheating"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for task in &mission.tasks {
|
||||||
|
if task.has_hyperspectral() && task.halogen_lamp_preheating_time_minute <= 0.0 {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Warning,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: 包含高光谱子任务但卤素灯预热时间为0",
|
||||||
|
task.id
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: None,
|
||||||
|
field_path: Some(format!("tasks[{}].HalogenLampPreheatingTime_Minute", task.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: DSLR wake-up timing check
|
||||||
|
struct DSLRWakeupRule;
|
||||||
|
|
||||||
|
impl ValidationRule for DSLRWakeupRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"DSLRWakeup"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for task in &mission.tasks {
|
||||||
|
let mut prev_was_dslr = false;
|
||||||
|
for sub in &task.sub_tasks {
|
||||||
|
if sub.sub_task_type == SubTaskType::SingleLensReflex {
|
||||||
|
if !prev_was_dslr {
|
||||||
|
// First DSLR in sequence - should have 135s wake-up delay
|
||||||
|
if let (Some(st), Some(prev_end)) =
|
||||||
|
(&sub.start_time, task.sub_tasks.iter().rev().find(|s| s.id != sub.id).and_then(|s| s.end_time.as_ref()))
|
||||||
|
{
|
||||||
|
if let (Ok(st_t), Ok(prev_t)) = (
|
||||||
|
chrono::NaiveDateTime::parse_from_str(st, "%Y-%m-%dT%H:%M:%S"),
|
||||||
|
chrono::NaiveDateTime::parse_from_str(prev_end, "%Y-%m-%dT%H:%M:%S"),
|
||||||
|
) {
|
||||||
|
let gap = (st_t - prev_t).num_seconds();
|
||||||
|
if gap < 135 {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Warning,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: DSLR 唤醒间隔 {}s 不足 135s",
|
||||||
|
task.id, gap
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_was_dslr = true;
|
||||||
|
} else {
|
||||||
|
prev_was_dslr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: Required fields check
|
||||||
|
struct RequiredFieldsRule;
|
||||||
|
|
||||||
|
impl ValidationRule for RequiredFieldsRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"RequiredFields"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
|
for (ti, task) in mission.tasks.iter().enumerate() {
|
||||||
|
if task.save_path.is_empty() {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!("Task #{}: savePath 不能为空", task.id),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: None,
|
||||||
|
field_path: Some(format!("tasks[{}].savePath", ti)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if task.scheduled_time.is_empty() {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!("Task #{}: scheduledTime 不能为空", task.id),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: None,
|
||||||
|
field_path: Some(format!("tasks[{}].scheduledTime", ti)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (si, sub) in task.sub_tasks.iter().enumerate() {
|
||||||
|
if sub.path_line_file_path.is_empty() {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Warning,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: {} 航线文件路径为空",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label()
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: Some(format!("tasks[{}].subTasks[{}].pathLineFilePath", ti, si)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match sub.sub_task_type {
|
||||||
|
SubTaskType::HyperSpectual400_1000nm | SubTaskType::HyperSpectual1000_1700nm => {
|
||||||
|
if sub.exposure_time.is_none() || sub.exposure_time == Some(0) {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: {} exposureTime 必填",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label()
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: Some(format!(
|
||||||
|
"tasks[{}].subTasks[{}].exposureTime",
|
||||||
|
ti, si
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if sub.frame_rate.is_none() || sub.frame_rate == Some(0) {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: {} frameRate 必填",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label()
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: Some(format!(
|
||||||
|
"tasks[{}].subTasks[{}].frameRate",
|
||||||
|
ti, si
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SubTaskType::SingleLensReflex | SubTaskType::DepthCamera => {
|
||||||
|
if sub.capture_interval_seconds.is_none() || sub.capture_interval_seconds == Some(0) {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: {} captureIntervalSeconds 必填",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label()
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: Some(format!(
|
||||||
|
"tasks[{}].subTasks[{}].captureIntervalSeconds",
|
||||||
|
ti, si
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 5: Timing consistency
|
||||||
|
struct TimingConsistencyRule;
|
||||||
|
|
||||||
|
impl ValidationRule for TimingConsistencyRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"TimingConsistency"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
let fmt = "%Y-%m-%dT%H:%M:%S";
|
||||||
|
|
||||||
|
for task in &mission.tasks {
|
||||||
|
for sub in &task.sub_tasks {
|
||||||
|
if let (Some(st), Some(et)) = (&sub.start_time, &sub.end_time) {
|
||||||
|
if let (Ok(s), Ok(e)) =
|
||||||
|
(chrono::NaiveDateTime::parse_from_str(st, fmt), chrono::NaiveDateTime::parse_from_str(et, fmt))
|
||||||
|
{
|
||||||
|
if e <= s {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Task #{}: {} endTime ({}) 早于或等于 startTime ({})",
|
||||||
|
task.id,
|
||||||
|
sub.sub_task_type.label(),
|
||||||
|
et,
|
||||||
|
st
|
||||||
|
),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: Some(sub.id.clone()),
|
||||||
|
field_path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 7: Each task must have at least one hyperspectral subtask
|
||||||
|
struct HyperspectralRequiredRule;
|
||||||
|
|
||||||
|
impl ValidationRule for HyperspectralRequiredRule {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"HyperspectralRequired"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, mission: &MissionPlan) -> Vec<ValidationIssue> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for task in &mission.tasks {
|
||||||
|
if !task.has_hyperspectral() {
|
||||||
|
issues.push(ValidationIssue {
|
||||||
|
severity: ValidationSeverity::Error,
|
||||||
|
rule: self.name().to_string(),
|
||||||
|
message: format!("Task #{}: 必须至少包含一个高光谱子任务", task.id),
|
||||||
|
task_id: Some(task.id),
|
||||||
|
sub_task_id: None,
|
||||||
|
field_path: Some(format!("tasks[{}].subTasks", task.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "happa-mission-plan",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"identifier": "com.xin.happa-mission-plan",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Happa Mission Plan",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ws: http://localhost:1420; img-src 'self' asset: http://asset.localhost https: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:;",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "nsis",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "perMachine",
|
||||||
|
"installerHooks": "./hooks.nsi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/App.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<n-message-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
|
<n-loading-bar-provider>
|
||||||
|
<n-notification-provider>
|
||||||
|
<router-view />
|
||||||
|
</n-notification-provider>
|
||||||
|
</n-loading-bar-provider>
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-message-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'F12') {
|
||||||
|
e.preventDefault();
|
||||||
|
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||||||
|
invoke('toggle_devtools').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
210
src/components/planner/ScanCanvas.vue
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="canvasContainer" class="scan-canvas">
|
||||||
|
<canvas
|
||||||
|
ref="canvas"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseup="onMouseUp"
|
||||||
|
@mouseleave="onMouseUp"
|
||||||
|
/>
|
||||||
|
<div class="canvas-info" v-if="regions.length > 0">
|
||||||
|
{{ regions.length }} 个框 | 点击「画框」开始绘制新框
|
||||||
|
</div>
|
||||||
|
<div class="canvas-info" v-else>
|
||||||
|
点击「画框」在画布上绘制扫描区域
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import type { ScanRegion, ScanMode } from '../../types/path-plan';
|
||||||
|
import type { PathLineRecord } from '../../types/path-line';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
regions: ScanRegion[];
|
||||||
|
records: PathLineRecord[];
|
||||||
|
mode: ScanMode;
|
||||||
|
backgroundImage?: string;
|
||||||
|
drawingMode: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'add-region': [region: ScanRegion];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const canvasContainer = ref<HTMLDivElement>();
|
||||||
|
const canvas = ref<HTMLCanvasElement>();
|
||||||
|
const isDrawing = ref(false);
|
||||||
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
const drawingRect = ref<ScanRegion | null>(null);
|
||||||
|
const bgImage = ref<HTMLImageElement | null>(null);
|
||||||
|
const padding = 40;
|
||||||
|
|
||||||
|
function getCanvasSize() {
|
||||||
|
const rect = canvasContainer.value?.getBoundingClientRect();
|
||||||
|
return { w: rect?.width || 600, h: rect?.height || 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCanvas(clientX: number, clientY: number) {
|
||||||
|
const rect = canvas.value!.getBoundingClientRect();
|
||||||
|
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorld(cx: number, cy: number) {
|
||||||
|
const { w, h } = getCanvasSize();
|
||||||
|
const drawW = w - padding * 2;
|
||||||
|
const drawH = h - padding * 2;
|
||||||
|
const worldX = (cx - padding) / drawW * 100;
|
||||||
|
const worldY = (cy - padding) / drawH * 100;
|
||||||
|
return { x: Math.max(0, Math.min(100, worldX)), y: Math.max(0, Math.min(100, 100 - worldY)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackgroundImage(path: string | undefined) {
|
||||||
|
if (!path) { bgImage.value = null; draw(); return; }
|
||||||
|
try {
|
||||||
|
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||||
|
const assetUrl = convertFileSrc(path);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { bgImage.value = img; draw(); };
|
||||||
|
img.onerror = () => { bgImage.value = null; draw(); };
|
||||||
|
img.src = assetUrl;
|
||||||
|
} catch { bgImage.value = null; draw(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToCanvas(worldX: number, worldY: number) {
|
||||||
|
const { w, h } = getCanvasSize();
|
||||||
|
const drawW = w - padding * 2;
|
||||||
|
const drawH = h - padding * 2;
|
||||||
|
const x = padding + (worldX / 100) * drawW;
|
||||||
|
const y = padding + ((100 - worldY) / 100) * drawH;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#2080f0', '#f0a020', '#18a058', '#d03050', '#a060e0', '#e06080'];
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const ctx = canvas.value?.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const { w, h } = getCanvasSize();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.value!.width = w * dpr;
|
||||||
|
canvas.value!.height = h * dpr;
|
||||||
|
canvas.value!.style.width = w + 'px';
|
||||||
|
canvas.value!.style.height = h + 'px';
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f8f8f8';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (bgImage.value) {
|
||||||
|
ctx.globalAlpha = 0.5;
|
||||||
|
ctx.drawImage(bgImage.value, 0, 0, w, h);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawW = w - padding * 2;
|
||||||
|
const drawH = h - padding * 2;
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
ctx.strokeStyle = '#e8e8e8';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
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]);
|
||||||
|
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw scan paths
|
||||||
|
if (props.records.length > 0) {
|
||||||
|
for (const record of props.records) {
|
||||||
|
const start = worldToCanvas(record.targetXMinPosition, record.targetYPosition);
|
||||||
|
const end = worldToCanvas(record.targetXMaxPosition, record.targetYPosition);
|
||||||
|
ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y);
|
||||||
|
ctx.strokeStyle = '#18a058'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||||
|
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(end.x, end.y);
|
||||||
|
ctx.lineTo(end.x - 8 * Math.cos(angle - 0.4), end.y - 8 * Math.sin(angle - 0.4));
|
||||||
|
ctx.lineTo(end.x - 8 * Math.cos(angle + 0.4), end.y - 8 * Math.sin(angle + 0.4));
|
||||||
|
ctx.closePath(); ctx.fillStyle = '#18a058'; ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if (!props.drawingMode) return;
|
||||||
|
isDrawing.value = true;
|
||||||
|
dragStart.value = toCanvas(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDrawing.value || !props.drawingMode) return;
|
||||||
|
const p = toCanvas(e.clientX, e.clientY);
|
||||||
|
const p1 = toWorld(dragStart.value.x, dragStart.value.y);
|
||||||
|
const p2 = toWorld(p.x, p.y);
|
||||||
|
drawingRect.value = {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (!isDrawing.value) return;
|
||||||
|
isDrawing.value = false;
|
||||||
|
if (drawingRect.value) {
|
||||||
|
const r = drawingRect.value;
|
||||||
|
if (r.xMax - r.xMin > 1 && r.yMax - r.yMin > 1) {
|
||||||
|
emit('add-region', r);
|
||||||
|
}
|
||||||
|
drawingRect.value = null;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBackgroundImage(props.backgroundImage);
|
||||||
|
window.addEventListener('resize', draw);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => [props.regions, props.records, props.mode, props.drawingMode], () => { draw(); });
|
||||||
|
watch(() => props.backgroundImage, (v) => { loadBackgroundImage(v); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scan-canvas { position: relative; width: 100%; height: 100%; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.canvas-info {
|
||||||
|
position: absolute; bottom: 8px; left: 8px;
|
||||||
|
background: rgba(0,0,0,0.6); color: white;
|
||||||
|
padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
src/components/planner/ScanParamPanel.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="param-panel">
|
||||||
|
<n-form size="small" label-placement="top" label-width="auto">
|
||||||
|
<n-card title="相机参数" size="small">
|
||||||
|
<n-grid :cols="2" :x-gap="8">
|
||||||
|
<n-gi><n-form-item label="FOV (°)"><n-input-number v-model:value="localCamera.fovDegrees" :min="1" :max="180" /></n-form-item></n-gi>
|
||||||
|
<n-gi><n-form-item label="高度 (cm)"><n-input-number v-model:value="localCamera.heightCm" :min="1" /></n-form-item></n-gi>
|
||||||
|
</n-grid>
|
||||||
|
<n-form-item label="覆盖率 (%)">
|
||||||
|
<n-slider v-model:value="localCoverageRate" :min="0" :max="100" :step="1" />
|
||||||
|
<n-text style="margin-left: 8px">{{ localCoverageRate }}%</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-card title="速度参数" size="small">
|
||||||
|
<n-form-item label="Y 轴定位速度 (cm/s)">
|
||||||
|
<n-input-number v-model:value="localSpeedY" :min="0.1" :step="0.5" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="X 扫描速度 (cm/s)">
|
||||||
|
<n-input-number v-model:value="localSpeedXScan" :min="0.1" :step="0.5" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="X 回起点速度 (cm/s)">
|
||||||
|
<n-input-number v-model:value="localSpeedXStart" :min="0.1" :step="0.5" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-card title="扫描模式" size="small">
|
||||||
|
<n-radio-group v-model:value="localMode">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-radio value="Zigzag">蛇形来回 (Zigzag)</n-radio>
|
||||||
|
<n-radio value="OneWay">单向回起点 (OneWay)</n-radio>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-card title="计算结果" size="small">
|
||||||
|
<n-descriptions label-placement="left" :column="1">
|
||||||
|
<n-descriptions-item label="扫描线数">{{ lineCount }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="预估耗时">{{ estTimeFormatted }}</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-card>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import type { CameraParams, ScanMode } from '../../types/path-plan';
|
||||||
|
import { formatDuration } from '../../utils/constants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
camera: CameraParams;
|
||||||
|
coverageRate: number;
|
||||||
|
speedY: number;
|
||||||
|
speedXScan: number;
|
||||||
|
speedXStart: number;
|
||||||
|
mode: ScanMode;
|
||||||
|
lineCount: number;
|
||||||
|
estTime: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [params: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localCamera = ref({ ...props.camera });
|
||||||
|
const localCoverageRate = ref(props.coverageRate);
|
||||||
|
const localSpeedY = ref(props.speedY);
|
||||||
|
const localSpeedXScan = ref(props.speedXScan);
|
||||||
|
const localSpeedXStart = ref(props.speedXStart);
|
||||||
|
const localMode = ref(props.mode);
|
||||||
|
|
||||||
|
const estTimeFormatted = computed(() => formatDuration(props.estTime));
|
||||||
|
|
||||||
|
watch([localCamera, localCoverageRate, localSpeedY, localSpeedXScan, localSpeedXStart, localMode], () => {
|
||||||
|
emit('update', {
|
||||||
|
camera: localCamera.value,
|
||||||
|
coverageRate: localCoverageRate.value,
|
||||||
|
speedY: localSpeedY.value,
|
||||||
|
speedXScan: localSpeedXScan.value,
|
||||||
|
speedXStart: localSpeedXStart.value,
|
||||||
|
mode: localMode.value,
|
||||||
|
});
|
||||||
|
}, { deep: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.param-panel {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
src/components/timeline/TimelineGantt.vue
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-gantt">
|
||||||
|
<n-empty v-if="!tasks.length" description="尚无任务,在左侧添加" style="margin-top: 60px" />
|
||||||
|
<div v-else class="gantt-container">
|
||||||
|
<div class="gantt-header">
|
||||||
|
<span v-for="tick in timeTicks" :key="tick" class="tick" :style="{ left: tickPosition(tick) + '%' }">
|
||||||
|
{{ formatTick(tick) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="gantt-body">
|
||||||
|
<div v-for="task in tasks" :key="task.id" class="task-row">
|
||||||
|
<div class="task-label">Task #{{ task.id }}</div>
|
||||||
|
<div class="task-bars">
|
||||||
|
<div
|
||||||
|
v-for="sub in task.subTasks"
|
||||||
|
:key="sub.id"
|
||||||
|
class="subtask-bar"
|
||||||
|
:style="barStyle(sub)"
|
||||||
|
:title="barTooltip(sub)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMissionStore } from '../../stores/mission';
|
||||||
|
import { SUBTASK_TYPE_COLORS, formatDuration } from '../../utils/constants';
|
||||||
|
|
||||||
|
const missionStore = useMissionStore();
|
||||||
|
const tasks = computed(() => missionStore.mission.tasks);
|
||||||
|
|
||||||
|
// Find global time range
|
||||||
|
const timeRange = computed(() => {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const task of tasks.value) {
|
||||||
|
for (const sub of task.subTasks) {
|
||||||
|
if (sub.startTime) {
|
||||||
|
const t = new Date(sub.startTime).getTime();
|
||||||
|
if (t < min) min = t;
|
||||||
|
}
|
||||||
|
if (sub.endTime) {
|
||||||
|
const t = new Date(sub.endTime).getTime();
|
||||||
|
if (t > max) max = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isFinite(min) || !isFinite(max)) return null;
|
||||||
|
return { min, max, duration: max - min };
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeTicks = computed(() => {
|
||||||
|
if (!timeRange.value || timeRange.value.duration <= 0) return [];
|
||||||
|
const count = 10;
|
||||||
|
return Array.from({ length: count + 1 }, (_, i) => i / count);
|
||||||
|
});
|
||||||
|
|
||||||
|
function tickPosition(fraction: number): number {
|
||||||
|
return fraction * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTick(fraction: number): string {
|
||||||
|
if (!timeRange.value) return '';
|
||||||
|
const time = timeRange.value.min + fraction * timeRange.value.duration;
|
||||||
|
const d = new Date(time);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function barStyle(sub: any) {
|
||||||
|
if (!sub.startTime || !sub.endTime || !timeRange.value || timeRange.value.duration <= 0) {
|
||||||
|
return { display: 'none' };
|
||||||
|
}
|
||||||
|
const start = new Date(sub.startTime).getTime();
|
||||||
|
const end = new Date(sub.endTime).getTime();
|
||||||
|
const left = ((start - timeRange.value.min) / timeRange.value.duration) * 100;
|
||||||
|
const width = ((end - start) / timeRange.value.duration) * 100;
|
||||||
|
const color = SUBTASK_TYPE_COLORS[sub.type] || '#888';
|
||||||
|
return {
|
||||||
|
left: left + '%',
|
||||||
|
width: Math.max(width, 0.5) + '%',
|
||||||
|
backgroundColor: color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function barTooltip(sub: any): string {
|
||||||
|
const times = sub.startTime && sub.endTime
|
||||||
|
? `${sub.startTime} → ${sub.endTime}`
|
||||||
|
: '未计算';
|
||||||
|
return `${sub.type}\n${times}\n${formatDuration(sub.estimatedDurationMinutes)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-gantt {
|
||||||
|
padding: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.gantt-header {
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.task-label {
|
||||||
|
width: 70px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-bars {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.subtask-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
.subtask-bar:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
src/components/validation/ValidationPanel.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="validation-panel">
|
||||||
|
<n-space style="padding: 8px" align="center" justify="space-between">
|
||||||
|
<n-text strong>校验结果</n-text>
|
||||||
|
<n-space>
|
||||||
|
<n-tag v-if="errors > 0" type="error" size="small">{{ errors }} 错误</n-tag>
|
||||||
|
<n-tag v-if="warnings > 0" type="warning" size="small">{{ warnings }} 警告</n-tag>
|
||||||
|
<n-tag v-if="!hasIssues" type="success" size="small">通过</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-empty v-if="!hasIssues" description="暂无问题" style="margin-top: 40px" />
|
||||||
|
|
||||||
|
<div v-for="(issue, i) in issues" :key="i" class="issue-item">
|
||||||
|
<n-alert
|
||||||
|
:type="issue.severity === 'Error' ? 'error' : 'warning'"
|
||||||
|
:title="issue.rule"
|
||||||
|
closable
|
||||||
|
style="margin: 4px 8px"
|
||||||
|
>
|
||||||
|
{{ issue.message }}
|
||||||
|
</n-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useValidationStore } from '../../stores/validation';
|
||||||
|
|
||||||
|
const validationStore = useValidationStore();
|
||||||
|
const issues = computed(() => validationStore.issues);
|
||||||
|
const errors = computed(() => validationStore.errorCount);
|
||||||
|
const warnings = computed(() => validationStore.warningCount);
|
||||||
|
const hasIssues = computed(() => validationStore.hasIssues);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.validation-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.issue-item {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import NaiveUI from 'naive-ui';
|
||||||
|
import router from './router';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(NaiveUI);
|
||||||
|
app.use(router);
|
||||||
|
app.mount('#app');
|
||||||
19
src/router/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import HomeView from '../views/HomeView.vue';
|
||||||
|
import MissionEditorView from '../views/MissionEditorView.vue';
|
||||||
|
import PathPlannerView from '../views/PathPlannerView.vue';
|
||||||
|
import PathLineViewer from '../views/PathLineViewer.vue';
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
|
{ path: '/editor', name: 'mission-editor', component: MissionEditorView },
|
||||||
|
{ path: '/planner', name: 'path-planner', component: PathPlannerView },
|
||||||
|
{ path: '/pathline', name: 'pathline-viewer', component: PathLineViewer },
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
161
src/stores/mission.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { MissionPlan, MissionScanConfig } from '../types/mission';
|
||||||
|
import { createEmptyMission } from '../types/mission';
|
||||||
|
import type { Task, SubTask, SubTaskType } from '../types/task';
|
||||||
|
import type { ValidationIssue } from '../types/validation';
|
||||||
|
|
||||||
|
export const useMissionStore = defineStore('mission', () => {
|
||||||
|
const mission = ref<MissionPlan>(createEmptyMission());
|
||||||
|
const currentFilePath = ref<string | null>(null);
|
||||||
|
const isDirty = ref(false);
|
||||||
|
|
||||||
|
const taskCount = computed(() => mission.value.tasks.length);
|
||||||
|
|
||||||
|
// Auto-persist scan config and background when they change
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
watch(
|
||||||
|
() => ({ sc: mission.value.scanConfig, bg: mission.value.backgroundImage }),
|
||||||
|
() => {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
invoke('save_default_scan_config', { config: mission.value.scanConfig }).catch(() => {});
|
||||||
|
invoke('save_default_background', { path: mission.value.backgroundImage || '' }).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createNew() {
|
||||||
|
mission.value = createEmptyMission();
|
||||||
|
// Load persisted defaults
|
||||||
|
try {
|
||||||
|
const defaults = await invoke<MissionScanConfig>('load_default_scan_config');
|
||||||
|
mission.value.scanConfig = defaults;
|
||||||
|
} catch {
|
||||||
|
// use defaults
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bg = await invoke<string | null>('load_default_background');
|
||||||
|
mission.value.backgroundImage = bg;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
currentFilePath.value = null;
|
||||||
|
isDirty.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMission(path: string) {
|
||||||
|
const result = await invoke<MissionPlan>('load_mission', { path });
|
||||||
|
mission.value = result;
|
||||||
|
currentFilePath.value = path;
|
||||||
|
isDirty.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMission(path?: string) {
|
||||||
|
const savePath = path || currentFilePath.value;
|
||||||
|
if (!savePath) throw new Error('No save path specified');
|
||||||
|
await invoke('save_mission', { path: savePath, mission: mission.value });
|
||||||
|
currentFilePath.value = savePath;
|
||||||
|
isDirty.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTask() {
|
||||||
|
const result = await invoke<MissionPlan>('add_task', { mission: mission.value });
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTask(taskId: number) {
|
||||||
|
const result = await invoke<MissionPlan>('copy_task', { mission: mission.value, taskId });
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTask(taskId: number) {
|
||||||
|
const result = await invoke<MissionPlan>('remove_task', { mission: mission.value, taskId });
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
|
||||||
|
const result = await invoke<MissionPlan>('add_sub_task', {
|
||||||
|
mission: mission.value,
|
||||||
|
taskId,
|
||||||
|
subTaskType,
|
||||||
|
});
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSubTask(taskId: number, subTaskId: string) {
|
||||||
|
const result = await invoke<MissionPlan>('remove_sub_task', {
|
||||||
|
mission: mission.value,
|
||||||
|
taskId,
|
||||||
|
subTaskId,
|
||||||
|
});
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSubTask(taskId: number, subTask: SubTask) {
|
||||||
|
const result = await invoke<MissionPlan>('update_sub_task', {
|
||||||
|
mission: mission.value,
|
||||||
|
taskId,
|
||||||
|
subTask,
|
||||||
|
});
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(task: Task) {
|
||||||
|
const result = await invoke<MissionPlan>('update_task', {
|
||||||
|
mission: mission.value,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calculateSchedule() {
|
||||||
|
const result = await invoke<MissionPlan>('calculate_schedule', { mission: mission.value });
|
||||||
|
mission.value = result;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runValidation(): Promise<ValidationIssue[]> {
|
||||||
|
return await invoke<ValidationIssue[]>('validate_mission', { mission: mission.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScanConfig(config: Partial<MissionScanConfig>) {
|
||||||
|
mission.value.scanConfig = { ...mission.value.scanConfig, ...config };
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackgroundImage(path: string | null) {
|
||||||
|
mission.value.backgroundImage = path;
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mission,
|
||||||
|
currentFilePath,
|
||||||
|
isDirty,
|
||||||
|
taskCount,
|
||||||
|
createNew,
|
||||||
|
loadMission,
|
||||||
|
saveMission,
|
||||||
|
addTask,
|
||||||
|
copyTask,
|
||||||
|
removeTask,
|
||||||
|
addSubTask,
|
||||||
|
removeSubTask,
|
||||||
|
updateSubTask,
|
||||||
|
updateTask,
|
||||||
|
calculateSchedule,
|
||||||
|
runValidation,
|
||||||
|
updateScanConfig,
|
||||||
|
setBackgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
46
src/stores/ui.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
export interface UiState {
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
showValidationPanel: boolean;
|
||||||
|
globalLoading: boolean;
|
||||||
|
globalError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
const showValidationPanel = ref(true);
|
||||||
|
const globalLoading = ref(false);
|
||||||
|
const globalError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const hasError = computed(() => globalError.value !== null);
|
||||||
|
|
||||||
|
function showError(msg: string) {
|
||||||
|
globalError.value = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissError() {
|
||||||
|
globalError.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleValidationPanel() {
|
||||||
|
showValidationPanel.value = !showValidationPanel.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarCollapsed,
|
||||||
|
showValidationPanel,
|
||||||
|
globalLoading,
|
||||||
|
globalError,
|
||||||
|
hasError,
|
||||||
|
showError,
|
||||||
|
dismissError,
|
||||||
|
toggleSidebar,
|
||||||
|
toggleValidationPanel,
|
||||||
|
};
|
||||||
|
});
|
||||||
28
src/stores/validation.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { ValidationIssue } from '../types/validation';
|
||||||
|
|
||||||
|
export const useValidationStore = defineStore('validation', () => {
|
||||||
|
const issues = ref<ValidationIssue[]>([]);
|
||||||
|
|
||||||
|
const errorCount = computed(() => issues.value.filter(i => i.severity === 'Error').length);
|
||||||
|
const warningCount = computed(() => issues.value.filter(i => i.severity === 'Warning').length);
|
||||||
|
const hasIssues = computed(() => issues.value.length > 0);
|
||||||
|
|
||||||
|
function setIssues(newIssues: ValidationIssue[]) {
|
||||||
|
issues.value = newIssues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
issues.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues,
|
||||||
|
errorCount,
|
||||||
|
warningCount,
|
||||||
|
hasIssues,
|
||||||
|
setIssues,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
});
|
||||||
49
src/types/mission.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { Task } from './task';
|
||||||
|
|
||||||
|
export interface MissionScanConfig {
|
||||||
|
xMin: number;
|
||||||
|
xMax: number;
|
||||||
|
yMin: number;
|
||||||
|
yMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannerDefaults {
|
||||||
|
fovDegrees: number;
|
||||||
|
heightCm: number;
|
||||||
|
coverageRate: number;
|
||||||
|
speedYCmS: number;
|
||||||
|
speedXStartCmS: number;
|
||||||
|
speedXScanCmS: number;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionPlan {
|
||||||
|
version: string;
|
||||||
|
taskCount: number;
|
||||||
|
tasks: Task[];
|
||||||
|
scanConfig: MissionScanConfig;
|
||||||
|
backgroundImage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultScanConfig(): MissionScanConfig {
|
||||||
|
return { xMin: 0, xMax: 100, yMin: 0, yMax: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultPlannerParams(): PlannerDefaults {
|
||||||
|
return {
|
||||||
|
fovDegrees: 30, heightCm: 50,
|
||||||
|
coverageRate: 30,
|
||||||
|
speedYCmS: 5, speedXStartCmS: 20, speedXScanCmS: 10,
|
||||||
|
mode: 'Zigzag',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyMission(): MissionPlan {
|
||||||
|
return {
|
||||||
|
version: '1.0',
|
||||||
|
taskCount: 0,
|
||||||
|
tasks: [],
|
||||||
|
scanConfig: createDefaultScanConfig(),
|
||||||
|
backgroundImage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/types/path-line.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface PathLineRecord {
|
||||||
|
targetYPosition: number;
|
||||||
|
speedTargetYPosition: number;
|
||||||
|
targetXMinPosition: number;
|
||||||
|
speedTargetXMinPosition: number;
|
||||||
|
targetXMaxPosition: number;
|
||||||
|
speedTargetXMaxPosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathLineFile {
|
||||||
|
count: number;
|
||||||
|
records: PathLineRecord[];
|
||||||
|
}
|
||||||
23
src/types/path-plan.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface ScanRegion {
|
||||||
|
xMin: number;
|
||||||
|
xMax: number;
|
||||||
|
yMin: number;
|
||||||
|
yMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraParams {
|
||||||
|
fovDegrees: number;
|
||||||
|
heightCm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanMode = 'Zigzag' | 'OneWay';
|
||||||
|
|
||||||
|
export interface ScanParams {
|
||||||
|
region: ScanRegion;
|
||||||
|
camera: CameraParams;
|
||||||
|
coverageRate: number;
|
||||||
|
speedYCmS: number;
|
||||||
|
speedXStartCmS: number;
|
||||||
|
speedXScanCmS: number;
|
||||||
|
mode: ScanMode;
|
||||||
|
}
|
||||||
42
src/types/task.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export enum SubTaskType {
|
||||||
|
HyperSpectual400_1000nm = 'HyperSpectual400_1000nm',
|
||||||
|
HyperSpectual1000_1700nm = 'HyperSpectual1000_1700nm',
|
||||||
|
SingleLensReflex = 'SingleLensReflex',
|
||||||
|
DepthCamera = 'DepthCamera',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHyperspectral(type: SubTaskType): boolean {
|
||||||
|
return type === SubTaskType.HyperSpectual400_1000nm || type === SubTaskType.HyperSpectual1000_1700nm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCameraType(type: SubTaskType): boolean {
|
||||||
|
return type === SubTaskType.SingleLensReflex || type === SubTaskType.DepthCamera;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubTask {
|
||||||
|
id: string;
|
||||||
|
type: SubTaskType;
|
||||||
|
captureIntervalSeconds: number | null;
|
||||||
|
defaultRenderBand: number | null;
|
||||||
|
exposureTime: number | null;
|
||||||
|
frameRate: number | null;
|
||||||
|
pathLineFilePath: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
estimatedDurationMinutes: number;
|
||||||
|
endTime: string | null;
|
||||||
|
startTime: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number;
|
||||||
|
savePath: string;
|
||||||
|
scheduledTime: string;
|
||||||
|
HalogenLampPreheatingTime_Minute: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
estimatedDurationMinutes: number;
|
||||||
|
endTime: string | null;
|
||||||
|
startTime: string | null;
|
||||||
|
status: string;
|
||||||
|
subTasks: SubTask[];
|
||||||
|
}
|
||||||
10
src/types/validation.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type ValidationSeverity = 'Error' | 'Warning';
|
||||||
|
|
||||||
|
export interface ValidationIssue {
|
||||||
|
severity: ValidationSeverity;
|
||||||
|
rule: string;
|
||||||
|
message: string;
|
||||||
|
taskId: number | null;
|
||||||
|
subTaskId: string | null;
|
||||||
|
fieldPath: string | null;
|
||||||
|
}
|
||||||
47
src/utils/constants.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export const SUBTASK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
HyperSpectual400_1000nm: 'Pika L',
|
||||||
|
HyperSpectual1000_1700nm: 'Pika NIR',
|
||||||
|
SingleLensReflex: '单反相机',
|
||||||
|
DepthCamera: '深度相机',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUBTASK_TYPE_COLORS: Record<string, string> = {
|
||||||
|
HyperSpectual400_1000nm: '#18a058',
|
||||||
|
HyperSpectual1000_1700nm: '#2080f0',
|
||||||
|
SingleLensReflex: '#f0a020',
|
||||||
|
DepthCamera: '#d03050',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEVICE_FOV: Record<string, number> = {
|
||||||
|
HyperSpectual400_1000nm: 17.6,
|
||||||
|
HyperSpectual1000_1700nm: 21.7,
|
||||||
|
SingleLensReflex: 74,
|
||||||
|
DepthCamera: 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_VALUES = {
|
||||||
|
halogenLampPreheatingTime_Minute: 0.1,
|
||||||
|
captureIntervalSeconds: 5,
|
||||||
|
exposureTime: 1,
|
||||||
|
frameRate: 30,
|
||||||
|
defaultRenderBand: 550,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 0) return '0min';
|
||||||
|
if (minutes < 1) return `${(minutes * 60).toFixed(0)}s`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = Math.round(minutes % 60);
|
||||||
|
if (h > 0) return `${h}h${m}min`;
|
||||||
|
return `${m}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(isoString: string | null): string {
|
||||||
|
if (!isoString) return '-';
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/views/HomeView.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<n-space vertical size="large" class="home-content">
|
||||||
|
<h1>Happa Mission Plan</h1>
|
||||||
|
<p class="subtitle">设备自动采集任务规划系统</p>
|
||||||
|
|
||||||
|
<n-space size="large">
|
||||||
|
<n-button type="primary" size="large" @click="createNew">
|
||||||
|
<template #icon><n-icon><AddOutline /></n-icon></template>
|
||||||
|
新建任务计划
|
||||||
|
</n-button>
|
||||||
|
<n-button size="large" @click="openFile">
|
||||||
|
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
||||||
|
打开已有文件
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-card v-if="lastFilePath" title="最近打开" size="small" style="margin-top: 16px">
|
||||||
|
<p>{{ lastFilePath }}</p>
|
||||||
|
<n-button size="small" @click="openLastFile">打开</n-button>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { AddOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
||||||
|
import { useMissionStore } from '../stores/mission';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const message = useMessage();
|
||||||
|
const missionStore = useMissionStore();
|
||||||
|
const lastFilePath = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function createNew() {
|
||||||
|
missionStore.createNew();
|
||||||
|
router.push('/editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFile() {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: 'Mission JSON', extensions: ['json'] }],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
await missionStore.loadMission(selected);
|
||||||
|
lastFilePath.value = selected;
|
||||||
|
router.push('/editor');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error('打开文件失败: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLastFile() {
|
||||||
|
if (lastFilePath.value) {
|
||||||
|
router.push('/editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.home-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
398
src/views/MissionEditorView.vue
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mission-editor">
|
||||||
|
<div class="main-panel">
|
||||||
|
<n-space vertical :size="8" style="padding: 12px">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<n-space align="center" justify="space-between">
|
||||||
|
<n-button size="small" @click="goBack">
|
||||||
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
|
返回
|
||||||
|
</n-button>
|
||||||
|
<n-space>
|
||||||
|
<n-button size="small" @click="handleSave">
|
||||||
|
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="handleCalculate">
|
||||||
|
<template #icon><n-icon><CalculatorOutline /></n-icon></template>
|
||||||
|
计算
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="handleValidate">
|
||||||
|
<template #icon><n-icon><CheckmarkCircleOutline /></n-icon></template>
|
||||||
|
校验
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<!-- Mission info -->
|
||||||
|
<n-card title="任务计划信息" size="small">
|
||||||
|
<n-descriptions label-placement="left" :column="2">
|
||||||
|
<n-descriptions-item label="任务数">
|
||||||
|
{{ missionStore.taskCount }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="文件">
|
||||||
|
{{ missionStore.currentFilePath || '未保存' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
<n-button block dashed style="margin-top: 8px" @click="handleAddTask">
|
||||||
|
添加任务
|
||||||
|
</n-button>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- Scan area + background (collapsible) -->
|
||||||
|
<n-collapse>
|
||||||
|
<n-collapse-item title="扫描区域配置" name="scanConfig">
|
||||||
|
<n-form size="small" label-placement="top">
|
||||||
|
<n-grid :cols="4" :x-gap="6">
|
||||||
|
<n-gi><n-form-item label="X min"><n-input-number v-model:value="missionStore.mission.scanConfig.xMin" :step="1" /></n-form-item></n-gi>
|
||||||
|
<n-gi><n-form-item label="X max"><n-input-number v-model:value="missionStore.mission.scanConfig.xMax" :step="1" /></n-form-item></n-gi>
|
||||||
|
<n-gi><n-form-item label="Y min"><n-input-number v-model:value="missionStore.mission.scanConfig.yMin" :step="1" /></n-form-item></n-gi>
|
||||||
|
<n-gi><n-form-item label="Y max"><n-input-number v-model:value="missionStore.mission.scanConfig.yMax" :step="1" /></n-form-item></n-gi>
|
||||||
|
</n-grid>
|
||||||
|
<n-form-item label="背景图">
|
||||||
|
<n-input v-model:value="missionStore.mission.backgroundImage" placeholder="背景图片路径" readonly size="small" />
|
||||||
|
<n-button size="tiny" style="margin-left: 4px" @click="browseBackground">浏览</n-button>
|
||||||
|
<n-button size="tiny" style="margin-left: 4px" @click="clearBackground" v-if="missionStore.mission.backgroundImage">清除</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
|
||||||
|
<!-- Task tree -->
|
||||||
|
<n-collapse v-if="missionStore.mission.tasks.length > 0" accordion>
|
||||||
|
<n-collapse-item
|
||||||
|
v-for="task in missionStore.mission.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:title="taskTitle(task)"
|
||||||
|
:name="String(task.id)"
|
||||||
|
>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button text size="tiny" @click.stop="handleCopyTask(task.id)" style="margin-right: 4px">
|
||||||
|
复制
|
||||||
|
</n-button>
|
||||||
|
<n-button text type="error" size="tiny" @click.stop="handleRemoveTask(task.id)">
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form size="small" label-placement="top">
|
||||||
|
<n-form-item label="数据保存路径">
|
||||||
|
<n-input v-model:value="task.savePath" placeholder="数据保存路径" readonly>
|
||||||
|
<template #suffix>
|
||||||
|
<n-button size="tiny" @click="browseSavePath(task)">浏览</n-button>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</n-form-item>
|
||||||
|
<n-grid :cols="2" :x-gap="8">
|
||||||
|
<n-gi>
|
||||||
|
<n-form-item label="计划时间">
|
||||||
|
<n-date-picker
|
||||||
|
v-model:formatted-value="task.scheduledTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="yyyy-MM-dd'T'HH:mm:ss"
|
||||||
|
placeholder="选择计划时间"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-form-item label="卤素灯预热 (分钟)">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="task.HalogenLampPreheatingTime_Minute"
|
||||||
|
:min="0"
|
||||||
|
:step="0.1"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<!-- Subtask children -->
|
||||||
|
<n-collapse>
|
||||||
|
<n-collapse-item
|
||||||
|
v-for="sub in task.subTasks"
|
||||||
|
:key="sub.id"
|
||||||
|
:title="getSubTaskLabel(sub.type)"
|
||||||
|
:name="sub.id"
|
||||||
|
>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button text type="error" size="tiny" @click.stop="handleRemoveSubTask(task.id, sub.id)">
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form size="small" label-placement="top">
|
||||||
|
<template v-if="isHyperspectral(sub.type)">
|
||||||
|
<n-grid :cols="2" :x-gap="8">
|
||||||
|
<n-gi>
|
||||||
|
<n-form-item label="曝光时间 (ms)">
|
||||||
|
<n-input-number v-model:value="sub.exposureTime" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-form-item label="帧率 (fps)">
|
||||||
|
<n-input-number v-model:value="sub.frameRate" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<n-form-item label="captureInterval (s)">
|
||||||
|
<n-input-number v-model:value="sub.captureIntervalSeconds" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
</template>
|
||||||
|
<n-form-item label="航线文件">
|
||||||
|
<n-input v-model:value="sub.pathLineFilePath" placeholder="航线文件路径" />
|
||||||
|
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">
|
||||||
|
浏览
|
||||||
|
</n-button>
|
||||||
|
<n-button size="tiny" style="margin-left: 4px" @click="goToPlanner(task.id, sub)">
|
||||||
|
生成航线
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<n-descriptions size="small" label-placement="left" :column="2">
|
||||||
|
<n-descriptions-item label="开始">{{ formatDateTime(sub.startTime) }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="结束">{{ formatDateTime(sub.endTime) }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="预计耗时">{{ formatDuration(sub.estimatedDurationMinutes) }}</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
|
||||||
|
<n-button block dashed size="small" style="margin-top: 4px" @click="showAddSubTask(task.id)">
|
||||||
|
添加子任务
|
||||||
|
</n-button>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
|
||||||
|
<n-empty v-if="missionStore.mission.tasks.length === 0" description="暂无任务,点击上方添加" />
|
||||||
|
|
||||||
|
<!-- Validation results (collapsible) -->
|
||||||
|
<n-collapse v-if="validationStore.issues.length > 0">
|
||||||
|
<n-collapse-item title="校验结果" name="validation">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space size="small">
|
||||||
|
<n-tag v-if="validationStore.errorCount > 0" type="error" size="small">
|
||||||
|
{{ validationStore.errorCount }} 错误
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-if="validationStore.warningCount > 0" type="warning" size="small">
|
||||||
|
{{ validationStore.warningCount }} 警告
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
<n-alert
|
||||||
|
v-for="(issue, i) in validationStore.issues"
|
||||||
|
:key="i"
|
||||||
|
:type="issue.severity === 'Error' ? 'error' : 'warning'"
|
||||||
|
:title="issue.rule"
|
||||||
|
closable
|
||||||
|
style="margin-bottom: 4px"
|
||||||
|
>
|
||||||
|
{{ issue.message }}
|
||||||
|
</n-alert>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SubTask type selector modal -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showTypeModal"
|
||||||
|
title="选择子任务类型"
|
||||||
|
:mask-closable="false"
|
||||||
|
preset="card"
|
||||||
|
style="width: 320px"
|
||||||
|
>
|
||||||
|
<n-space vertical>
|
||||||
|
<n-button
|
||||||
|
v-for="t in availableTypes"
|
||||||
|
:key="t.value"
|
||||||
|
:disabled="t.disabled"
|
||||||
|
block
|
||||||
|
@click="confirmAddSubTask(t.value)"
|
||||||
|
>
|
||||||
|
{{ t.label }}{{ t.disabled ? ' (已添加)' : '' }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-button block @click="showTypeModal = false">取消</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useMessage, useDialog } from 'naive-ui';
|
||||||
|
import {
|
||||||
|
ArrowBackOutline, SaveOutline, CalculatorOutline, CheckmarkCircleOutline,
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
|
import { useMissionStore } from '../stores/mission';
|
||||||
|
import { useValidationStore } from '../stores/validation';
|
||||||
|
import { SUBTASK_TYPE_LABELS } from '../utils/constants';
|
||||||
|
import { formatDuration, formatDateTime } from '../utils/constants';
|
||||||
|
import { isHyperspectral, SubTaskType } from '../types/task';
|
||||||
|
import type { SubTask } from '../types/task';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const missionStore = useMissionStore();
|
||||||
|
const validationStore = useValidationStore();
|
||||||
|
|
||||||
|
const showTypeModal = ref(false);
|
||||||
|
const typeModalTaskId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const ALL_TASK_TYPES: { value: SubTaskType; label: string }[] = [
|
||||||
|
{ value: SubTaskType.HyperSpectual400_1000nm, label: 'Pika L' },
|
||||||
|
{ value: SubTaskType.HyperSpectual1000_1700nm, label: 'Pika NIR' },
|
||||||
|
{ value: SubTaskType.SingleLensReflex, label: '单反相机' },
|
||||||
|
{ value: SubTaskType.DepthCamera, label: '深度相机' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableTypes = computed(() => {
|
||||||
|
const task = missionStore.mission.tasks.find(t => t.id === typeModalTaskId.value);
|
||||||
|
const existing: SubTaskType[] = task?.subTasks.map(s => s.type) || [];
|
||||||
|
return ALL_TASK_TYPES.map(t => ({
|
||||||
|
...t,
|
||||||
|
disabled: existing.includes(t.value),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSubTaskLabel(type: string): string {
|
||||||
|
return SUBTASK_TYPE_LABELS[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIME = '2000-01-01T00:00:00';
|
||||||
|
function taskTitle(task: { id: number; scheduledTime: string }): string {
|
||||||
|
if (!task.scheduledTime || task.scheduledTime === DEFAULT_TIME) {
|
||||||
|
return 'Task #' + task.id;
|
||||||
|
}
|
||||||
|
const t = task.scheduledTime;
|
||||||
|
const dateTime = t.length >= 16 ? t.slice(0, 10) + ' ' + t.slice(11, 16) : t;
|
||||||
|
return 'Task #' + task.id + ' ' + dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { router.push('/'); }
|
||||||
|
|
||||||
|
async function browseSavePath(task: any) {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({ directory: true, multiple: false, title: '选择数据保存目录' });
|
||||||
|
if (selected) task.savePath = selected;
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseBackground() {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'gif'] }],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selected) missionStore.setBackgroundImage(selected);
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBackground() { missionStore.setBackgroundImage(null); }
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (missionStore.currentFilePath) {
|
||||||
|
await missionStore.saveMission();
|
||||||
|
message.success('保存成功');
|
||||||
|
} else {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const path = await save({ filters: [{ name: 'Mission JSON', extensions: ['json'] }] });
|
||||||
|
if (path) { await missionStore.saveMission(path); message.success('保存成功'); }
|
||||||
|
}
|
||||||
|
} catch (e) { message.error('保存失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCalculate() {
|
||||||
|
try { await missionStore.calculateSchedule(); message.success('计算完成'); }
|
||||||
|
catch (e) { message.error('计算失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleValidate() {
|
||||||
|
try {
|
||||||
|
const issues = await missionStore.runValidation();
|
||||||
|
validationStore.setIssues(issues);
|
||||||
|
const errCount = issues.filter(i => i.severity === 'Error').length;
|
||||||
|
if (errCount > 0) message.warning(`发现 ${errCount} 个错误`);
|
||||||
|
else message.success('校验通过');
|
||||||
|
} catch (e) { message.error('校验失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddTask() {
|
||||||
|
try { await missionStore.addTask(); message.success('添加任务成功'); }
|
||||||
|
catch (e) { message.error('添加任务失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyTask(taskId: number) {
|
||||||
|
try { await missionStore.copyTask(taskId); message.success('复制成功'); }
|
||||||
|
catch (e) { message.error('复制失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveTask(taskId: number) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除 Task #${taskId}?此操作不可撤销`,
|
||||||
|
positiveText: '删除', negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try { await missionStore.removeTask(taskId); message.success('删除成功'); }
|
||||||
|
catch (e) { message.error('删除失败: ' + e); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveSubTask(taskId: number, subTaskId: string) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定删除该子任务?',
|
||||||
|
positiveText: '删除', negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try { await missionStore.removeSubTask(taskId, subTaskId); message.success('删除成功'); }
|
||||||
|
catch (e) { message.error('删除失败: ' + e); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddSubTask(taskId: number) {
|
||||||
|
typeModalTaskId.value = taskId;
|
||||||
|
showTypeModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAddSubTask(typeValue: SubTaskType) {
|
||||||
|
if (typeModalTaskId.value === null) return;
|
||||||
|
addSubTask(typeModalTaskId.value, typeValue);
|
||||||
|
showTypeModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
|
||||||
|
try { await missionStore.addSubTask(taskId, subTaskType); message.success('添加子任务成功'); }
|
||||||
|
catch (e) { message.error('添加子任务失败: ' + e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browsePathLine(sub: SubTask) {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selected) sub.pathLineFilePath = selected;
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPlanner(taskId: number, sub: SubTask) {
|
||||||
|
router.push({ path: '/planner', query: { taskId: String(taskId), subTaskId: sub.id } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mission-editor { height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.main-panel { flex: 1; overflow-y: auto; }
|
||||||
|
</style>
|
||||||
82
src/views/PathLineViewer.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pathline-viewer">
|
||||||
|
<n-space style="padding: 8px" align="center">
|
||||||
|
<n-button size="small" @click="goBack">
|
||||||
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
|
返回
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="openFile">
|
||||||
|
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
||||||
|
打开航线文件
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-empty v-if="!records.length" description="请打开一个 .RecordLine3 文件" style="margin-top: 60px" />
|
||||||
|
|
||||||
|
<div v-else style="padding: 8px">
|
||||||
|
<n-statistic label="记录条数" :value="records.length" />
|
||||||
|
<n-data-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="records"
|
||||||
|
:bordered="true"
|
||||||
|
:single-line="false"
|
||||||
|
size="small"
|
||||||
|
:max-height="600"
|
||||||
|
virtual-scroll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { ArrowBackOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { DataTableColumn } from 'naive-ui';
|
||||||
|
import type { PathLineRecord } from '../types/path-line';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const message = useMessage();
|
||||||
|
const records = ref<PathLineRecord[]>([]);
|
||||||
|
|
||||||
|
const columns: DataTableColumn<PathLineRecord>[] = [
|
||||||
|
{ title: 'Y 位置', key: 'targetYPosition', render: r => r.targetYPosition.toFixed(2) },
|
||||||
|
{ title: 'Y 速度', key: 'speedTargetYPosition', render: r => r.speedTargetYPosition.toFixed(2) },
|
||||||
|
{ title: 'X 起始', key: 'targetXMinPosition', render: r => r.targetXMinPosition.toFixed(2) },
|
||||||
|
{ title: 'X 起始速度', key: 'speedTargetXMinPosition', render: r => r.speedTargetXMinPosition.toFixed(2) },
|
||||||
|
{ title: 'X 结束', key: 'targetXMaxPosition', render: r => r.targetXMaxPosition.toFixed(2) },
|
||||||
|
{ title: 'X 扫描速度', key: 'speedTargetXMaxPosition', render: r => r.speedTargetXMaxPosition.toFixed(2) },
|
||||||
|
{ title: '#', key: 'index', render: (_, i) => i + 1, width: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function openFile() {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
const file = await invoke('load_path_line', { path: selected });
|
||||||
|
records.value = (file as any).records;
|
||||||
|
message.success(`加载成功: ${(file as any).records.length} 条记录`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error('打开文件失败: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/editor');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pathline-viewer {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
src/views/PathPlannerView.vue
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="path-planner">
|
||||||
|
<n-space style="padding: 8px" align="center">
|
||||||
|
<n-button size="small" @click="goBack">
|
||||||
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
|
返回
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" :type="drawingMode ? 'warning' : 'default'" @click="toggleDrawing">
|
||||||
|
<template #icon><n-icon><SquareOutline /></n-icon></template>
|
||||||
|
{{ drawingMode ? '完成画框' : '画框' }}
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="clearRects" :disabled="regions.length === 0">
|
||||||
|
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||||
|
清除
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" type="primary" @click="generatePath" :disabled="regions.length === 0">
|
||||||
|
<template #icon><n-icon><MapOutline /></n-icon></template>
|
||||||
|
生成航线
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="savePath" :disabled="!generatedRecords.length">
|
||||||
|
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||||
|
保存路径
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-split direction="horizontal" :default-size="0.6">
|
||||||
|
<template #1>
|
||||||
|
<ScanCanvas
|
||||||
|
:regions="regions"
|
||||||
|
:records="previewRecords"
|
||||||
|
:mode="scanMode"
|
||||||
|
:background-image="missionStore.mission.backgroundImage || undefined"
|
||||||
|
:drawing-mode="drawingMode"
|
||||||
|
@add-region="onAddRegion"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<ScanParamPanel
|
||||||
|
:camera="camera"
|
||||||
|
:coverage-rate="coverageRate"
|
||||||
|
:speed-y="speedY"
|
||||||
|
:speed-x-scan="speedXScan"
|
||||||
|
:speed-x-start="speedXStart"
|
||||||
|
:mode="scanMode"
|
||||||
|
:line-count="generatedRecords.length"
|
||||||
|
:est-time="estimatedTime"
|
||||||
|
@update="onParamChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 { 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 { PlannerDefaults } from '../types/mission';
|
||||||
|
import { DEVICE_FOV } from '../utils/constants';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const message = useMessage();
|
||||||
|
const missionStore = useMissionStore();
|
||||||
|
|
||||||
|
const taskId = route.query.taskId ? Number(route.query.taskId) : null;
|
||||||
|
const subTaskId = route.query.subTaskId ? String(route.query.subTaskId) : null;
|
||||||
|
|
||||||
|
const drawingMode = ref(false);
|
||||||
|
|
||||||
|
function getDeviceFov(): number {
|
||||||
|
if (taskId === null || subTaskId === null) return 30;
|
||||||
|
const task = missionStore.mission.tasks.find(t => t.id === taskId);
|
||||||
|
const sub = task?.subTasks.find(s => s.id === subTaskId);
|
||||||
|
return (sub && DEVICE_FOV[sub.type]) || 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = ref<ScanRegion[]>([]);
|
||||||
|
const camera = ref<CameraParams>({ fovDegrees: getDeviceFov(), heightCm: 50 });
|
||||||
|
const coverageRate = ref(30);
|
||||||
|
const speedY = ref(5);
|
||||||
|
const speedXScan = ref(10);
|
||||||
|
const speedXStart = ref(20);
|
||||||
|
const scanMode = ref<ScanMode>('Zigzag');
|
||||||
|
|
||||||
|
const generatedRecords = ref<PathLineRecord[]>([]);
|
||||||
|
const previewRecords = computed(() => generatedRecords.value);
|
||||||
|
const estimatedTime = ref(0);
|
||||||
|
|
||||||
|
// Load saved planner defaults
|
||||||
|
invoke<PlannerDefaults>('load_planner_defaults').then(p => {
|
||||||
|
coverageRate.value = p.coverageRate;
|
||||||
|
speedY.value = p.speedYCmS;
|
||||||
|
speedXScan.value = p.speedXScanCmS;
|
||||||
|
speedXStart.value = p.speedXStartCmS;
|
||||||
|
scanMode.value = p.mode as ScanMode;
|
||||||
|
camera.value = { fovDegrees: camera.value.fovDegrees, heightCm: p.heightCm };
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
function toggleDrawing() {
|
||||||
|
drawingMode.value = !drawingMode.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddRegion(region: ScanRegion) {
|
||||||
|
regions.value.push(region);
|
||||||
|
message.success(`已添加框 #${regions.value.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRects() {
|
||||||
|
regions.value = [];
|
||||||
|
generatedRecords.value = [];
|
||||||
|
estimatedTime.value = 0;
|
||||||
|
message.info('已清除所有框');
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlannerDefaults() {
|
||||||
|
const p: PlannerDefaults = {
|
||||||
|
fovDegrees: camera.value.fovDegrees,
|
||||||
|
heightCm: camera.value.heightCm,
|
||||||
|
coverageRate: coverageRate.value,
|
||||||
|
speedYCmS: speedY.value,
|
||||||
|
speedXStartCmS: speedXStart.value,
|
||||||
|
speedXScanCmS: speedXScan.value,
|
||||||
|
mode: scanMode.value,
|
||||||
|
};
|
||||||
|
invoke('save_planner_defaults', { config: p }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParamChange(params: any) {
|
||||||
|
if (params.camera) camera.value = params.camera;
|
||||||
|
if (params.coverageRate !== undefined) coverageRate.value = params.coverageRate;
|
||||||
|
if (params.speedY !== undefined) speedY.value = params.speedY;
|
||||||
|
if (params.speedXScan !== undefined) speedXScan.value = params.speedXScan;
|
||||||
|
if (params.speedXStart !== undefined) speedXStart.value = params.speedXStart;
|
||||||
|
if (params.mode) scanMode.value = params.mode;
|
||||||
|
savePlannerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePath() {
|
||||||
|
if (!regions.value.length) return;
|
||||||
|
try {
|
||||||
|
const file = await invoke<PathLineFile>('generate_scan_paths_multi', {
|
||||||
|
regions: regions.value,
|
||||||
|
camera: camera.value,
|
||||||
|
coverageRate: coverageRate.value,
|
||||||
|
speedYCmS: speedY.value,
|
||||||
|
speedXStartCmS: speedXStart.value,
|
||||||
|
speedXScanCmS: speedXScan.value,
|
||||||
|
mode: scanMode.value,
|
||||||
|
});
|
||||||
|
generatedRecords.value = file.records;
|
||||||
|
const estTime = await invoke<number>('estimate_scan_time_multi_minutes', {
|
||||||
|
regions: regions.value,
|
||||||
|
camera: camera.value,
|
||||||
|
coverageRate: coverageRate.value,
|
||||||
|
speedYCmS: speedY.value,
|
||||||
|
speedXStartCmS: speedXStart.value,
|
||||||
|
speedXScanCmS: speedXScan.value,
|
||||||
|
mode: scanMode.value,
|
||||||
|
});
|
||||||
|
estimatedTime.value = estTime;
|
||||||
|
message.success(`航线生成完成: ${file.count} 条记录`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error('航线生成失败: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePath() {
|
||||||
|
if (!generatedRecords.value.length) return;
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const path = await save({
|
||||||
|
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||||
|
});
|
||||||
|
if (path) {
|
||||||
|
const finalPath = path.endsWith('.RecordLine3') ? path : path + '.RecordLine3';
|
||||||
|
const file: PathLineFile = {
|
||||||
|
count: generatedRecords.value.length,
|
||||||
|
records: generatedRecords.value,
|
||||||
|
};
|
||||||
|
await invoke('save_path_line', { path: finalPath, file });
|
||||||
|
message.success('航线保存成功');
|
||||||
|
|
||||||
|
if (taskId !== null && subTaskId !== null) {
|
||||||
|
const task = missionStore.mission.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
const sub = task.subTasks.find(s => s.id === subTaskId);
|
||||||
|
if (sub) {
|
||||||
|
sub.pathLineFilePath = finalPath;
|
||||||
|
await missionStore.updateSubTask(taskId, { ...sub });
|
||||||
|
message.success('已自动填入子任务航线路径');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error('保存失败: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/editor');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.path-planner { height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
</style>
|
||||||
7
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
32
vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
// @ts-expect-error process is a nodejs global
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [vue()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||