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
This commit is contained in:
xin
2026-06-17 17:17:39 +08:00
commit d732580c3e
80 changed files with 10842 additions and 0 deletions

View 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())
}
}

View 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();
}
}

View 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()
}

View 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;

View 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(&params);
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(&params)
}
#[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(
&regions, &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(
&regions, &camera, coverage_rate,
speed_y_cm_s, speed_x_start_cm_s, speed_x_scan_cm_s, &mode,
)
}

View 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)
}

View 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)
}