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
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||