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

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View 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
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

28
src-tauri/src/error.rs Normal file
View 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())
}
}

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,3 @@
pub mod scheduler;
pub mod validator;
pub mod path_generator;

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

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

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