Release v0.3.3 Hot fixes #126
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||||
|
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://tauri.app)
|
[](https://tauri.app)
|
||||||
[](LICENCE.md)
|
[](LICENCE.md)
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
||||||
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, quality presets, and neutral/grey axis boosting.
|
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, and neutral/grey axis boosting.
|
||||||
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
|
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
|
||||||
3. **Stage 3 — Interactive Measurement (`chartread`)**: Real-time instrument calibration prompts, interactive strip reading state machine, and a live swatch grid featuring instant per-patch CIEDE2000 ($\Delta E_{00}$) quality indicators.
|
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators, and multi-pass sheet averaging for measurement noise reduction.
|
||||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, descriptions, and copyright tagging.
|
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, descriptions, and copyright tagging.
|
||||||
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB color volume viewer and bundled sRGB reference wireframe comparison.
|
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, touch controls, and bundled sRGB reference wireframe comparison.
|
||||||
|
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
||||||
|
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||||
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
||||||
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
||||||
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
||||||
|
|||||||
+8
-4
@@ -60,10 +60,14 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
## 3. Future Roadmap
|
## 3. Future Roadmap
|
||||||
|
|
||||||
### Milestone 8 — Advanced Measurement & Workflow Enhancements (`v0.3.0`)
|
### Milestone 8 — Advanced Measurement & Workflow Enhancements (`v0.3.0`)
|
||||||
- [ ] **Averaging & Multi-Pass Reading**: Integrate Argyll's `average` utility to merge multiple measurement sheets for enhanced accuracy.
|
- [x] **Averaging & Multi-Pass Reading**: Integrated Argyll `average` multi-pass measurement sheet workflow for noise reduction.
|
||||||
- [ ] **Ambient & Display Profiling**: Extend wizard beyond reflective targets to emissive displays (`dispwin`, `dispread`).
|
- [x] **Instrument Auto-Detection**: Added hardware detection via `instlist`.
|
||||||
- [ ] **Preset Management**: Save and load reusable profiling recipes (paper types, patch counts, quality presets).
|
- [x] **Preset Management**: Save, load, export, and import profiling recipes.
|
||||||
- [ ] **Full 3D Convex Hull in CIELAB**: Replace 2D projected Delaunay triangulation with full 3D Delaunay/Convex Hull in Lab space for complex non-convex gamuts.
|
- [x] **Full 3D Convex Hull in CIELAB**: Replaced 2D projected Delaunay triangulation with full 3D QuickHull in Lab space with touch rotation controls.
|
||||||
|
|
||||||
|
### Hotfix Releases (`v0.3.1` & `v0.3.2`)
|
||||||
|
- [x] **v0.3.1 (#103)**: Stage 1 file browse via backend `select_target_file` dialog.
|
||||||
|
- [x] **v0.3.2 (#108)**: Linux CI runner compatibility updated to Ubuntu 22.04 LTS so `.deb` packages run without requiring newer GLIBC versions.
|
||||||
|
|
||||||
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
||||||
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles.
|
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"version": "0.1.4",
|
"version": "0.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"version": "0.1.4",
|
"version": "0.3.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+140
-8
@@ -247,6 +247,15 @@ pub fn get_profile_path(cwd: String, basename: String) -> String {
|
|||||||
path.to_string_lossy().to_string()
|
path.to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_iccgamut_args(resolved_path: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-v".to_string(),
|
||||||
|
"-d".to_string(),
|
||||||
|
"10".to_string(),
|
||||||
|
resolved_path.to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn extract_gamut(
|
pub async fn extract_gamut(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -276,12 +285,7 @@ pub async fn extract_gamut(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let args = vec![
|
let args = build_iccgamut_args(&resolved_path);
|
||||||
"-v".to_string(),
|
|
||||||
"-d".to_string(),
|
|
||||||
"50.0".to_string(),
|
|
||||||
resolved_path,
|
|
||||||
];
|
|
||||||
let cwd = if parent_dir.is_empty() {
|
let cwd = if parent_dir.is_empty() {
|
||||||
resolve_safe_cwd(&app, "").ok()
|
resolve_safe_cwd(&app, "").ok()
|
||||||
} else {
|
} else {
|
||||||
@@ -507,6 +511,68 @@ pub async fn run_average(
|
|||||||
state.spawn(app, id, binary, args, cwd).await
|
state.spawn(app, id, binary, args, cwd).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_basename(basename: &str) -> Result<String, String> {
|
||||||
|
let name = basename.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("basename is empty".to_string());
|
||||||
|
}
|
||||||
|
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||||
|
return Err("basename must not contain path separators".to_string());
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_ti3_filename(basename: &str, pass_index: u32) -> Result<String, String> {
|
||||||
|
if pass_index == 0 {
|
||||||
|
return Err("pass_index must be 1-based".to_string());
|
||||||
|
}
|
||||||
|
let name = sanitize_basename(basename)?;
|
||||||
|
Ok(format!("{}_pass{}.ti3", name, pass_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `{cwd}/{basename}.ti3` to `{cwd}/{basename}_pass{N}.ti3` and remove the
|
||||||
|
/// canonical file so Stage 4 stays locked until Finish (#109 / #110).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn snapshot_ti3(cwd: String, basename: String, pass_index: u32) -> Result<String, String> {
|
||||||
|
let dest_name = snapshot_ti3_filename(&basename, pass_index)?;
|
||||||
|
let name = sanitize_basename(&basename)?;
|
||||||
|
let dir = std::path::Path::new(&cwd);
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(format!("working directory does not exist: {}", cwd));
|
||||||
|
}
|
||||||
|
let src = dir.join(format!("{}.ti3", name));
|
||||||
|
if !src.is_file() {
|
||||||
|
return Err(format!("measurement file not found: {}", src.display()));
|
||||||
|
}
|
||||||
|
let dest = dir.join(&dest_name);
|
||||||
|
std::fs::copy(&src, &dest).map_err(|e| format!("failed to snapshot .ti3: {}", e))?;
|
||||||
|
std::fs::remove_file(&src)
|
||||||
|
.map_err(|e| format!("failed to remove canonical .ti3 after snapshot: {}", e))?;
|
||||||
|
Ok(dest_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a pass file (relative name) back to `{cwd}/{basename}.ti3`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn promote_ti3(cwd: String, source: String, basename: String) -> Result<(), String> {
|
||||||
|
let name = sanitize_basename(&basename)?;
|
||||||
|
let src_name = source.trim();
|
||||||
|
if src_name.is_empty()
|
||||||
|
|| src_name.contains('/')
|
||||||
|
|| src_name.contains('\\')
|
||||||
|
|| src_name.contains("..")
|
||||||
|
{
|
||||||
|
return Err("source filename is invalid".to_string());
|
||||||
|
}
|
||||||
|
let dir = std::path::Path::new(&cwd);
|
||||||
|
let src = dir.join(src_name);
|
||||||
|
if !src.is_file() {
|
||||||
|
return Err(format!("source measurement file not found: {}", src.display()));
|
||||||
|
}
|
||||||
|
let dest = dir.join(format!("{}.ti3", name));
|
||||||
|
std::fs::copy(&src, &dest).map_err(|e| format!("failed to promote .ti3: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct ColprofConfig {
|
pub struct ColprofConfig {
|
||||||
pub algorithm: String,
|
pub algorithm: String,
|
||||||
@@ -704,6 +770,12 @@ pub async fn print_target_native(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_iccgamut_args() {
|
||||||
|
let args = build_iccgamut_args("/path/to/profile.icc");
|
||||||
|
assert_eq!(args, vec!["-v", "-d", "10", "/path/to/profile.icc"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_targen_args_rgb() {
|
fn test_build_targen_args_rgb() {
|
||||||
let config = TargenConfig {
|
let config = TargenConfig {
|
||||||
@@ -792,7 +864,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_chartread_args() {
|
fn test_build_chartread_args_auto() {
|
||||||
let config = ChartreadConfig {
|
let config = ChartreadConfig {
|
||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
@@ -802,6 +874,17 @@ mod tests {
|
|||||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args_empty_port() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
port: Some("".to_string()),
|
||||||
|
};
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_chartread_args_with_port() {
|
fn test_build_chartread_args_with_port() {
|
||||||
let config = ChartreadConfig {
|
let config = ChartreadConfig {
|
||||||
@@ -824,6 +907,55 @@ mod tests {
|
|||||||
assert_eq!(args, vec!["-v", "pass1.ti3", "pass2.ti3", "avg.ti3"]);
|
assert_eq!(args, vec!["-v", "pass1.ti3", "pass2.ti3", "avg.ti3"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_average_args_pass_files() {
|
||||||
|
let config = AverageConfig {
|
||||||
|
inputs: vec!["job_pass1.ti3".to_string(), "job_pass2.ti3".to_string()],
|
||||||
|
output: "job.ti3".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
};
|
||||||
|
let args = build_average_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "job_pass1.ti3", "job_pass2.ti3", "job.ti3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snapshot_ti3_filename() {
|
||||||
|
assert_eq!(snapshot_ti3_filename("foo", 1).unwrap(), "foo_pass1.ti3");
|
||||||
|
assert_eq!(snapshot_ti3_filename("foo", 2).unwrap(), "foo_pass2.ti3");
|
||||||
|
assert!(snapshot_ti3_filename("foo", 0).is_err());
|
||||||
|
assert!(snapshot_ti3_filename("../x", 1).is_err());
|
||||||
|
assert!(snapshot_ti3_filename("a/b", 1).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snapshot_and_promote_ti3_roundtrip() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"iccery_ti3_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let canonical = dir.join("job.ti3");
|
||||||
|
std::fs::write(&canonical, b"PASS1").unwrap();
|
||||||
|
let cwd = dir.to_string_lossy().to_string();
|
||||||
|
let name = snapshot_ti3(cwd.clone(), "job".to_string(), 1).unwrap();
|
||||||
|
assert_eq!(name, "job_pass1.ti3");
|
||||||
|
assert!(!canonical.exists(), "canonical .ti3 must be removed after snapshot");
|
||||||
|
assert_eq!(std::fs::read(dir.join("job_pass1.ti3")).unwrap(), b"PASS1");
|
||||||
|
|
||||||
|
std::fs::write(dir.join("job_pass2.ti3"), b"PASS2").unwrap();
|
||||||
|
let name2 = snapshot_ti3_filename("job", 2).unwrap();
|
||||||
|
assert_eq!(name2, "job_pass2.ti3");
|
||||||
|
assert!(dir.join("job_pass1.ti3").exists(), "pass 1 must survive pass 2");
|
||||||
|
|
||||||
|
promote_ti3(cwd, name, "job".to_string()).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&canonical).unwrap(), b"PASS1");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_colprof_args() {
|
fn test_build_colprof_args() {
|
||||||
let config = ColprofConfig {
|
let config = ColprofConfig {
|
||||||
@@ -865,4 +997,4 @@ mod tests {
|
|||||||
let candidates_exe = get_binary_candidates("targen.exe");
|
let candidates_exe = get_binary_candidates("targen.exe");
|
||||||
assert_eq!(candidates_exe, vec!["targen.exe"]);
|
assert_eq!(candidates_exe, vec!["targen.exe"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub fn run() {
|
|||||||
commands::read_file_base64,
|
commands::read_file_base64,
|
||||||
commands::read_tiff_preview_png,
|
commands::read_tiff_preview_png,
|
||||||
commands::run_chartread,
|
commands::run_chartread,
|
||||||
|
commands::snapshot_ti3,
|
||||||
|
commands::promote_ti3,
|
||||||
commands::run_average,
|
commands::run_average,
|
||||||
commands::run_colprof,
|
commands::run_colprof,
|
||||||
commands::run_profcheck,
|
commands::run_profcheck,
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ impl ProcessManager {
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
cwd: Option<String>,
|
cwd: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
let stdins = self.stdins.lock().await;
|
||||||
|
if stdins.contains_key(&id) {
|
||||||
|
return Err(format!("Process '{id}' is still running"));
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut command = Command::new(&binary);
|
let mut command = Command::new(&binary);
|
||||||
command.args(args);
|
command.args(args);
|
||||||
if let Some(dir) = cwd {
|
if let Some(dir) = cwd {
|
||||||
@@ -156,3 +162,18 @@ impl ProcessManager {
|
|||||||
Err("Process not found".to_string())
|
Err("Process not found".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_process_manager_duplicate_id_and_lifecycle() {
|
||||||
|
let pm = ProcessManager::new();
|
||||||
|
// Test helper using dummy/mock or direct map operations
|
||||||
|
{
|
||||||
|
let mut stdins = pm.stdins.lock().await;
|
||||||
|
assert!(!stdins.contains_key("test_proc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,7 +150,24 @@ pub fn export_preset_json(preset: ProfilingPreset) -> Result<String, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_preset_json(json: String) -> Result<ProfilingPreset, String> {
|
pub fn import_preset_json(json: String) -> Result<ProfilingPreset, String> {
|
||||||
serde_json::from_str::<ProfilingPreset>(&json).map_err(|e| format!("Invalid preset format: {}", e))
|
let preset: ProfilingPreset = serde_json::from_str::<ProfilingPreset>(&json)
|
||||||
|
.map_err(|e| format!("Invalid preset format: {}", e))?;
|
||||||
|
|
||||||
|
if preset.name.trim().is_empty() {
|
||||||
|
return Err("Preset name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if preset.name.len() > 200 {
|
||||||
|
return Err("Preset name is too long (max 200 characters)".to_string());
|
||||||
|
}
|
||||||
|
if let Some(ref desc) = preset.description {
|
||||||
|
if desc.len() > 500 {
|
||||||
|
return Err("Preset description is too long (max 500 characters)".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preset.dpi < 72 || preset.dpi > 2400 {
|
||||||
|
return Err("Preset DPI out of allowed range (72 - 2400)".to_string());
|
||||||
|
}
|
||||||
|
Ok(preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -168,6 +185,22 @@ mod tests {
|
|||||||
assert_eq!(defaults[1].patch_count, 1500);
|
assert_eq!(defaults[1].patch_count, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_preset_validation_name_length() {
|
||||||
|
let mut preset = get_default_presets()[0].clone();
|
||||||
|
preset.name = "a".repeat(201);
|
||||||
|
let json = serde_json::to_string(&preset).unwrap();
|
||||||
|
assert!(import_preset_json(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_preset_validation_dpi() {
|
||||||
|
let mut preset = get_default_presets()[0].clone();
|
||||||
|
preset.dpi = 10;
|
||||||
|
let json = serde_json::to_string(&preset).unwrap();
|
||||||
|
assert!(import_preset_json(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_preset_json_export_and_import() {
|
fn test_preset_json_export_and_import() {
|
||||||
let preset = ProfilingPreset {
|
let preset = ProfilingPreset {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ICCery",
|
"productName": "ICCery",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"identifier": "com.gronod.iccery",
|
"identifier": "com.gronod.iccery",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|||||||
+11
-6
@@ -50,7 +50,7 @@
|
|||||||
<button class="step active" data-step="1">1. Generate Target</button>
|
<button class="step active" data-step="1">1. Generate Target</button>
|
||||||
<button class="step" data-step="2">2. Print Layout</button>
|
<button class="step" data-step="2">2. Print Layout</button>
|
||||||
<button class="step" data-step="3">3. Measure Target</button>
|
<button class="step" data-step="3">3. Measure Target</button>
|
||||||
<button class="step" data-step="4">4. Create Profile</button>
|
<button class="step" data-step="4" title="Complete measurement (Finish) before profiling">4. Create Profile</button>
|
||||||
<button class="step" data-step="5">5. Verify</button>
|
<button class="step" data-step="5">5. Verify</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -176,6 +176,11 @@
|
|||||||
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
|
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tiffDpi">Target Resolution (DPI)</label>
|
||||||
|
<input type="number" id="tiffDpi" min="72" max="600" value="300" placeholder="300">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||||
@@ -266,10 +271,10 @@
|
|||||||
<!-- Instrument & Hardware Port Selector -->
|
<!-- Instrument & Hardware Port Selector -->
|
||||||
<div class="form-container" style="margin-bottom: 16px;">
|
<div class="form-container" style="margin-bottom: 16px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="chartreadInstrumentSelect">Instrument / Hardware Port</label>
|
<label for="chartreadInstrumentSelect">Communication Port</label>
|
||||||
<div class="input-row" style="display: flex; gap: 8px; align-items: center;">
|
<div class="input-row" style="display: flex; gap: 8px; align-items: center;">
|
||||||
<select id="chartreadInstrumentSelect" style="flex: 1;">
|
<select id="chartreadInstrumentSelect" style="flex: 1;">
|
||||||
<option value="">Auto-Detect (First Available Instrument)</option>
|
<option value="">Auto (First available port)</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
|
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,11 +316,11 @@
|
|||||||
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
|
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
|
||||||
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
|
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="sub-label" style="margin-bottom: 12px;">You can measure additional printed copies to reduce measurement and printing noise before computing the profile.</p>
|
<p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p>
|
||||||
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
|
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<button type="button" class="secondary" id="btnMeasureAnotherSheet">➕ Measure Another Sheet</button>
|
<button type="button" class="secondary" id="btnMeasureAnotherSheet">➕ Measure Another Sheet</button>
|
||||||
<button type="button" class="primary" id="btnFinishAndAverage">✓ Average Passes & Proceed →</button>
|
<button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes & Proceed →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -564,4 +569,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+158
-62
@@ -29,6 +29,9 @@ const STATE = {
|
|||||||
|
|
||||||
let currentState = STATE.IDLE;
|
let currentState = STATE.IDLE;
|
||||||
let currentProcessId = "";
|
let currentProcessId = "";
|
||||||
|
let measurementInProgress = false;
|
||||||
|
let currentPassIndex = 0;
|
||||||
|
const recordedPasses = [];
|
||||||
|
|
||||||
export function initChartread() {
|
export function initChartread() {
|
||||||
const btnStartRead = document.getElementById("btnStartRead");
|
const btnStartRead = document.getElementById("btnStartRead");
|
||||||
@@ -42,6 +45,47 @@ export function initChartread() {
|
|||||||
const stateLabel = document.getElementById("chartreadState");
|
const stateLabel = document.getElementById("chartreadState");
|
||||||
const logContainer = document.getElementById("chartreadLogContainer");
|
const logContainer = document.getElementById("chartreadLogContainer");
|
||||||
const logPre = document.getElementById("chartreadLog");
|
const logPre = document.getElementById("chartreadLog");
|
||||||
|
const averagingPanel = document.getElementById("chartreadAveragingPanel");
|
||||||
|
const passesList = document.getElementById("passesList");
|
||||||
|
const passCounterBadge = document.getElementById("passCounterBadge");
|
||||||
|
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
||||||
|
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
||||||
|
|
||||||
|
function setMeasurementBusy(busy) {
|
||||||
|
measurementInProgress = busy;
|
||||||
|
if (btnStartRead) btnStartRead.disabled = busy;
|
||||||
|
if (btnMeasureAnotherSheet) btnMeasureAnotherSheet.disabled = busy;
|
||||||
|
if (btnFinishAndAverage) {
|
||||||
|
btnFinishAndAverage.disabled = busy || recordedPasses.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPassesList() {
|
||||||
|
if (!passesList) return;
|
||||||
|
passesList.innerHTML = "";
|
||||||
|
recordedPasses.forEach((pass, i) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = `Sheet Pass #${i + 1}`;
|
||||||
|
label.appendChild(strong);
|
||||||
|
label.appendChild(document.createTextNode(` (${pass.filename})`));
|
||||||
|
const time = document.createElement("span");
|
||||||
|
time.style.opacity = "0.7";
|
||||||
|
time.textContent = `✓ ${pass.time}`;
|
||||||
|
item.appendChild(label);
|
||||||
|
item.appendChild(time);
|
||||||
|
passesList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptStage3(basename, cwd) {
|
||||||
|
wizardState.setTarget(basename, cwd);
|
||||||
|
setStage3Result(basename, cwd);
|
||||||
|
wizardState.updateGating();
|
||||||
|
advanceToStage4();
|
||||||
|
}
|
||||||
|
|
||||||
// Instrument detection logic
|
// Instrument detection logic
|
||||||
if (btnDetectInstruments && instrumentSelect) {
|
if (btnDetectInstruments && instrumentSelect) {
|
||||||
@@ -58,12 +102,15 @@ export function initChartread() {
|
|||||||
const line = event.payload.line.trim();
|
const line = event.payload.line.trim();
|
||||||
|
|
||||||
// Matches Argyll instlist output e.g. "1: 'i1Pro' on 'USB'" or "1: 'ColorMunki'" or "1 = 'i1Display'"
|
// Matches Argyll instlist output e.g. "1: 'i1Pro' on 'USB'" or "1: 'ColorMunki'" or "1 = 'i1Display'"
|
||||||
|
const KNOWN_INST_TOKENS = /i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein/i;
|
||||||
const match = line.match(/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i);
|
const match = line.match(/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i);
|
||||||
if (match) {
|
if (match) {
|
||||||
const index = match[1];
|
const index = match[1];
|
||||||
const name = match[2].trim();
|
const name = match[2].trim();
|
||||||
const port = match[3] ? match[3].trim() : "";
|
const port = match[3] ? match[3].trim() : "";
|
||||||
detected.push({ index, name, port });
|
if (KNOWN_INST_TOKENS.test(name) || port.length > 0) {
|
||||||
|
detected.push({ index, name, port });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,18 +119,19 @@ export function initChartread() {
|
|||||||
unlistenStdout();
|
unlistenStdout();
|
||||||
unlistenExit();
|
unlistenExit();
|
||||||
btnDetectInstruments.disabled = false;
|
btnDetectInstruments.disabled = false;
|
||||||
btnDetectInstruments.textContent = "↻ Detect";
|
btnDetectInstruments.textContent = "↺ Detect";
|
||||||
|
|
||||||
instrumentSelect.innerHTML = `<option value="">Auto-Detect (First Available Instrument)</option>`;
|
instrumentSelect.innerHTML = `<option value="">Auto (First available port)</option>`;
|
||||||
|
|
||||||
if (detected.length > 0) {
|
if (detected.length > 0) {
|
||||||
detected.forEach((inst) => {
|
detected.forEach((inst) => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = inst.index;
|
// Do not pass instlist ordinal as -c comm port; use empty value for auto-port (#111)
|
||||||
opt.textContent = `${inst.index}: ${inst.name} ${inst.port ? `(${inst.port})` : ""}`;
|
opt.value = "";
|
||||||
|
opt.textContent = `${inst.name}${inst.port ? ` on ${inst.port}` : ""}`;
|
||||||
instrumentSelect.appendChild(opt);
|
instrumentSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
instrumentSelect.value = detected[0].index;
|
instrumentSelect.value = "";
|
||||||
setPrompt(`Found ${detected.length} instrument(s): ${detected.map(d => d.name).join(", ")}`);
|
setPrompt(`Found ${detected.length} instrument(s): ${detected.map(d => d.name).join(", ")}`);
|
||||||
} else {
|
} else {
|
||||||
setPrompt("No instruments found via instlist. Ensure USB cable is plugged in.");
|
setPrompt("No instruments found via instlist. Ensure USB cable is plugged in.");
|
||||||
@@ -133,7 +181,10 @@ export function initChartread() {
|
|||||||
btnCancel.classList.remove("hidden");
|
btnCancel.classList.remove("hidden");
|
||||||
break;
|
break;
|
||||||
case STATE.FINISHED:
|
case STATE.FINISHED:
|
||||||
btnStartRead.classList.remove("hidden");
|
// After a recorded pass, further sheets go through "Measure Another Sheet"
|
||||||
|
if (recordedPasses.length === 0) {
|
||||||
|
btnStartRead.classList.remove("hidden");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +196,11 @@ export function initChartread() {
|
|||||||
// Start reading button
|
// Start reading button
|
||||||
if (btnStartRead) {
|
if (btnStartRead) {
|
||||||
btnStartRead.addEventListener("click", async () => {
|
btnStartRead.addEventListener("click", async () => {
|
||||||
|
if (measurementInProgress) {
|
||||||
|
setPrompt("A measurement is already running. Wait for it to finish.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const basename = stage2Basename || wizardState.basename;
|
const basename = stage2Basename || wizardState.basename;
|
||||||
const cwd = stage2Cwd || wizardState.cwd;
|
const cwd = stage2Cwd || wizardState.cwd;
|
||||||
|
|
||||||
@@ -159,6 +215,7 @@ export function initChartread() {
|
|||||||
logPre.textContent = "";
|
logPre.textContent = "";
|
||||||
logContainer.open = false;
|
logContainer.open = false;
|
||||||
logContainer.classList.remove("hidden");
|
logContainer.classList.remove("hidden");
|
||||||
|
setMeasurementBusy(true);
|
||||||
setState(STATE.CALIBRATING);
|
setState(STATE.CALIBRATING);
|
||||||
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||||
|
|
||||||
@@ -219,29 +276,41 @@ export function initChartread() {
|
|||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
setState(STATE.FINISHED);
|
try {
|
||||||
setPrompt("✅ Sheet measurement completed!");
|
const passIndex = currentPassIndex + 1;
|
||||||
logPre.textContent += "\n[SUCCESS] chartread sheet measurement completed.\n";
|
const filename = await invoke("snapshot_ti3", {
|
||||||
|
cwd: cwd,
|
||||||
|
basename: basename,
|
||||||
|
pass_index: passIndex,
|
||||||
|
});
|
||||||
|
|
||||||
// Track pass in session
|
currentPassIndex = passIndex;
|
||||||
currentPassIndex++;
|
recordedPasses.push({
|
||||||
recordedPasses.push({
|
index: currentPassIndex,
|
||||||
index: currentPassIndex,
|
filename: filename,
|
||||||
filename: `${basename}.ti3`,
|
time: new Date().toLocaleTimeString(),
|
||||||
time: new Date().toLocaleTimeString(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
renderPassesList();
|
setState(STATE.FINISHED);
|
||||||
if (averagingPanel) averagingPanel.classList.remove("hidden");
|
setPrompt(`Sheet pass #${currentPassIndex} saved as ${filename}. Stage 4 stays locked until you finish.`);
|
||||||
if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`;
|
logPre.textContent += `\n[SUCCESS] chartread completed. Snapshotted ${filename} (canonical ${basename}.ti3 removed until Finish).\n`;
|
||||||
|
|
||||||
wizardState.setTarget(basename, cwd);
|
renderPassesList();
|
||||||
setStage3Result(basename, cwd);
|
if (averagingPanel) averagingPanel.classList.remove("hidden");
|
||||||
|
if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`;
|
||||||
|
// Do not call setStage3Result / wizardState.setTarget here (#110)
|
||||||
|
} catch (snapErr) {
|
||||||
|
setState(STATE.FINISHED);
|
||||||
|
setPrompt(`Measurement finished but pass snapshot failed: ${snapErr}`);
|
||||||
|
logPre.textContent += `\n[ERROR] snapshot_ti3 failed: ${snapErr}\n`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(STATE.FINISHED);
|
setState(STATE.FINISHED);
|
||||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMeasurementBusy(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
await invoke("run_chartread", { config });
|
await invoke("run_chartread", { config });
|
||||||
@@ -249,34 +318,19 @@ export function initChartread() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPrompt(`Invoke error: ${err}`);
|
setPrompt(`Invoke error: ${err}`);
|
||||||
setState(STATE.IDLE);
|
setState(STATE.IDLE);
|
||||||
|
setMeasurementBusy(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-pass measurement controls
|
|
||||||
const averagingPanel = document.getElementById("chartreadAveragingPanel");
|
|
||||||
const passesList = document.getElementById("passesList");
|
|
||||||
const passCounterBadge = document.getElementById("passCounterBadge");
|
|
||||||
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
|
||||||
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
|
||||||
let currentPassIndex = 0;
|
|
||||||
const recordedPasses = [];
|
|
||||||
|
|
||||||
function renderPassesList() {
|
|
||||||
if (!passesList) return;
|
|
||||||
passesList.innerHTML = "";
|
|
||||||
recordedPasses.forEach((pass, i) => {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;";
|
|
||||||
item.innerHTML = `<span><strong>Sheet Pass #${i + 1}</strong> (${pass.filename})</span><span style="opacity:0.7;">✓ ${pass.time}</span>`;
|
|
||||||
passesList.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btnMeasureAnotherSheet) {
|
if (btnMeasureAnotherSheet) {
|
||||||
btnMeasureAnotherSheet.addEventListener("click", () => {
|
btnMeasureAnotherSheet.addEventListener("click", () => {
|
||||||
|
if (measurementInProgress) {
|
||||||
|
setPrompt("Wait for the current measurement to finish and be snapshotted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(STATE.IDLE);
|
setState(STATE.IDLE);
|
||||||
setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Press 'Start Measurement'.`);
|
setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Starting chartread...`);
|
||||||
if (btnStartRead) btnStartRead.click();
|
if (btnStartRead) btnStartRead.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,11 +340,36 @@ export function initChartread() {
|
|||||||
const basename = stage2Basename || wizardState.basename;
|
const basename = stage2Basename || wizardState.basename;
|
||||||
const cwd = stage2Cwd || wizardState.cwd;
|
const cwd = stage2Cwd || wizardState.cwd;
|
||||||
|
|
||||||
if (recordedPasses.length <= 1) {
|
if (!basename || !cwd) {
|
||||||
setPrompt("Single pass accepted. Proceeding to Stage 4...");
|
setPrompt("Error: No working directory or basename.");
|
||||||
wizardState.setTarget(basename, cwd);
|
return;
|
||||||
setStage3Result(basename, cwd);
|
}
|
||||||
advanceToStage4();
|
|
||||||
|
if (measurementInProgress) {
|
||||||
|
setPrompt("Wait for the current measurement to finish.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordedPasses.length === 0) {
|
||||||
|
setPrompt("No measurement passes recorded yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordedPasses.length === 1) {
|
||||||
|
setPrompt("Single pass accepted. Restoring canonical .ti3 and proceeding to Stage 4...");
|
||||||
|
btnFinishAndAverage.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke("promote_ti3", {
|
||||||
|
cwd: cwd,
|
||||||
|
source: recordedPasses[0].filename,
|
||||||
|
basename: basename,
|
||||||
|
});
|
||||||
|
logPre.textContent += `\n[SUCCESS] Promoted ${recordedPasses[0].filename} → ${basename}.ti3 (single pass, average not invoked).\n`;
|
||||||
|
acceptStage3(basename, cwd);
|
||||||
|
} catch (e) {
|
||||||
|
btnFinishAndAverage.disabled = false;
|
||||||
|
setPrompt(`Failed to restore ${basename}.ti3: ${e}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,21 +383,30 @@ export function initChartread() {
|
|||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlistenAvgExit = await listen("process:exit", (event) => {
|
logPre.textContent += `\n[average] average -v ${averageConfig.inputs.join(" ")} ${averageConfig.output}\n`;
|
||||||
|
|
||||||
|
const unlistenAvgExit = await listen("process:exit", async (event) => {
|
||||||
if (event.payload.id !== `average_${basename}.ti3`) return;
|
if (event.payload.id !== `average_${basename}.ti3`) return;
|
||||||
unlistenAvgExit();
|
unlistenAvgExit();
|
||||||
btnFinishAndAverage.disabled = false;
|
|
||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`);
|
setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`);
|
||||||
wizardState.setTarget(basename, cwd);
|
logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`;
|
||||||
setStage3Result(basename, cwd);
|
acceptStage3(basename, cwd);
|
||||||
advanceToStage4();
|
|
||||||
} else {
|
} else {
|
||||||
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Proceeding with primary pass.`);
|
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`);
|
||||||
wizardState.setTarget(basename, cwd);
|
logPre.textContent += `\n[ERROR] average exited ${event.payload.code}. Promoting ${recordedPasses[0].filename}.\n`;
|
||||||
setStage3Result(basename, cwd);
|
try {
|
||||||
advanceToStage4();
|
await invoke("promote_ti3", {
|
||||||
|
cwd: cwd,
|
||||||
|
source: recordedPasses[0].filename,
|
||||||
|
basename: basename,
|
||||||
|
});
|
||||||
|
acceptStage3(basename, cwd);
|
||||||
|
} catch (e) {
|
||||||
|
btnFinishAndAverage.disabled = false;
|
||||||
|
setPrompt(`Average failed and fallback promote failed: ${e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,8 +414,7 @@ export function initChartread() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("run_average error:", e);
|
console.error("run_average error:", e);
|
||||||
btnFinishAndAverage.disabled = false;
|
btnFinishAndAverage.disabled = false;
|
||||||
setStage3Result(basename, cwd);
|
setPrompt(`run_average error: ${e}`);
|
||||||
advanceToStage4();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -369,9 +456,15 @@ export function initChartread() {
|
|||||||
try {
|
try {
|
||||||
await invoke("kill_process", { id: currentProcessId });
|
await invoke("kill_process", { id: currentProcessId });
|
||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
setState(STATE.IDLE);
|
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
||||||
setPrompt("Measurement cancelled.");
|
setPrompt("Measurement cancelled.");
|
||||||
} catch (e) { console.error("kill_process error:", e); }
|
} catch (e) {
|
||||||
|
console.error("kill_process error:", e);
|
||||||
|
setPrompt(`Cancel failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setMeasurementBusy(false);
|
||||||
|
stopSwatchListener();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +478,10 @@ function advanceToStage4() {
|
|||||||
const stages = document.querySelectorAll('.stage');
|
const stages = document.querySelectorAll('.stage');
|
||||||
|
|
||||||
steps.forEach(s => s.classList.remove('active'));
|
steps.forEach(s => s.classList.remove('active'));
|
||||||
if (steps[3]) steps[3].classList.add('active');
|
if (steps[3]) {
|
||||||
|
steps[3].classList.add('active');
|
||||||
|
steps[3].classList.remove('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
stages.forEach(s => {
|
stages.forEach(s => {
|
||||||
s.classList.remove('active');
|
s.classList.remove('active');
|
||||||
|
|||||||
Vendored
-1
File diff suppressed because one or more lines are too long
+12
-257
@@ -1,3 +1,4 @@
|
|||||||
|
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
const { listen } = window.__TAURI__.event;
|
const { listen } = window.__TAURI__.event;
|
||||||
|
|
||||||
@@ -119,267 +120,21 @@ export function parseCGATS(text) {
|
|||||||
* Output: { vertices: Float32Array, indices: Uint32Array } or null
|
* Output: { vertices: Float32Array, indices: Uint32Array } or null
|
||||||
*/
|
*/
|
||||||
export function compute3DConvexHull(pts) {
|
export function compute3DConvexHull(pts) {
|
||||||
if (!pts || pts.length < 4) return null;
|
const faces = computeQuickHull(pts);
|
||||||
|
if (!faces || faces.length === 0) return null;
|
||||||
|
|
||||||
// Filter duplicate / nearly coincident points
|
|
||||||
const points = [];
|
|
||||||
const eps = 1e-5;
|
|
||||||
for (const p of pts) {
|
|
||||||
const x = p.a; // X = a*
|
|
||||||
const y = p.L; // Y = L*
|
|
||||||
const z = p.b; // Z = b*
|
|
||||||
let duplicate = false;
|
|
||||||
for (const existing of points) {
|
|
||||||
const dx = existing.x - x;
|
|
||||||
const dy = existing.y - y;
|
|
||||||
const dz = existing.z - z;
|
|
||||||
if (dx * dx + dy * dy + dz * dz < eps * eps) {
|
|
||||||
duplicate = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!duplicate) {
|
|
||||||
points.push({ x, y, z, id: points.length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (points.length < 4) return null;
|
|
||||||
|
|
||||||
// Vector operations helpers
|
|
||||||
function sub(v1, v2) { return { x: v1.x - v2.x, y: v1.y - v2.y, z: v1.z - v2.z }; }
|
|
||||||
function cross(v1, v2) {
|
|
||||||
return {
|
|
||||||
x: v1.y * v2.z - v1.z * v2.y,
|
|
||||||
y: v1.z * v2.x - v1.x * v2.z,
|
|
||||||
z: v1.x * v2.y - v1.y * v2.x
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function dot(v1, v2) { return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; }
|
|
||||||
function lengthSq(v) { return v.x * v.x + v.y * v.y + v.z * v.z; }
|
|
||||||
function normalize(v) {
|
|
||||||
const len = Math.sqrt(lengthSq(v));
|
|
||||||
return len > 0 ? { x: v.x / len, y: v.y / len, z: v.z / len } : { x: 0, y: 0, z: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Find extreme points to construct initial simplex (tetrahedron)
|
|
||||||
let minX = 0, maxX = 0, minY = 0, maxY = 0, minZ = 0, maxZ = 0;
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
if (points[i].x < points[minX].x) minX = i;
|
|
||||||
if (points[i].x > points[maxX].x) maxX = i;
|
|
||||||
if (points[i].y < points[minY].y) minY = i;
|
|
||||||
if (points[i].y > points[maxY].y) maxY = i;
|
|
||||||
if (points[i].z < points[minZ].z) minZ = i;
|
|
||||||
if (points[i].z > points[maxZ].z) maxZ = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
let p1 = minX, p2 = maxX;
|
|
||||||
let maxDistSq = lengthSq(sub(points[p1], points[p2]));
|
|
||||||
const extremes = [minX, maxX, minY, maxY, minZ, maxZ];
|
|
||||||
for (let i = 0; i < extremes.length; i++) {
|
|
||||||
for (let j = i + 1; j < extremes.length; j++) {
|
|
||||||
const d = lengthSq(sub(points[extremes[i]], points[extremes[j]]));
|
|
||||||
if (d > maxDistSq) {
|
|
||||||
maxDistSq = d;
|
|
||||||
p1 = extremes[i];
|
|
||||||
p2 = extremes[j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third point: furthest from line p1-p2
|
|
||||||
const v12 = sub(points[p2], points[p1]);
|
|
||||||
let p3 = -1;
|
|
||||||
let maxLineDistSq = 0;
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
if (i === p1 || i === p2) continue;
|
|
||||||
const v1i = sub(points[i], points[p1]);
|
|
||||||
const cr = cross(v12, v1i);
|
|
||||||
const distSq = lengthSq(cr) / (lengthSq(v12) || 1);
|
|
||||||
if (distSq > maxLineDistSq) {
|
|
||||||
maxLineDistSq = distSq;
|
|
||||||
p3 = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p3 === -1 || maxLineDistSq < eps * eps) return null;
|
|
||||||
|
|
||||||
// Fourth point: furthest from plane p1-p2-p3
|
|
||||||
const planeNorm = normalize(cross(sub(points[p2], points[p1]), sub(points[p3], points[p1])));
|
|
||||||
let p4 = -1;
|
|
||||||
let maxPlaneDist = 0;
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
if (i === p1 || i === p2 || i === p3) continue;
|
|
||||||
const dist = Math.abs(dot(planeNorm, sub(points[i], points[p1])));
|
|
||||||
if (dist > maxPlaneDist) {
|
|
||||||
maxPlaneDist = dist;
|
|
||||||
p4 = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p4 === -1 || maxPlaneDist < eps) return null;
|
|
||||||
|
|
||||||
// Helper to create a face with outward-pointing normal
|
|
||||||
function makeFace(a, b, c, insidePt) {
|
|
||||||
let norm = cross(sub(points[b], points[a]), sub(points[c], points[a]));
|
|
||||||
norm = normalize(norm);
|
|
||||||
if (dot(norm, sub(points[insidePt], points[a])) > 0) {
|
|
||||||
// Invert orientation
|
|
||||||
const tmp = b; b = c; c = tmp;
|
|
||||||
norm = { x: -norm.x, y: -norm.y, z: -norm.z };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
a, b, c,
|
|
||||||
normal: norm,
|
|
||||||
offset: -dot(norm, points[a]),
|
|
||||||
points: [],
|
|
||||||
active: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function distToPlane(face, pt) {
|
|
||||||
return dot(face.normal, pt) + face.offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial 4 faces of tetrahedron
|
|
||||||
let faces = [
|
|
||||||
makeFace(p1, p2, p3, p4),
|
|
||||||
makeFace(p1, p4, p2, p3),
|
|
||||||
makeFace(p2, p4, p3, p1),
|
|
||||||
makeFace(p3, p4, p1, p2)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Assign remaining points to faces
|
|
||||||
const unassigned = [];
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
if (i === p1 || i === p2 || i === p3 || i === p4) continue;
|
|
||||||
const pt = points[i];
|
|
||||||
let maxDist = 1e-6;
|
|
||||||
let bestFace = -1;
|
|
||||||
for (let f = 0; f < faces.length; f++) {
|
|
||||||
const dist = distToPlane(faces[f], pt);
|
|
||||||
if (dist > maxDist) {
|
|
||||||
maxDist = dist;
|
|
||||||
bestFace = f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestFace !== -1) {
|
|
||||||
faces[bestFace].points.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuickHull loop
|
|
||||||
while (true) {
|
|
||||||
let targetFace = -1;
|
|
||||||
for (let f = 0; f < faces.length; f++) {
|
|
||||||
if (faces[f].active && faces[f].points.length > 0) {
|
|
||||||
targetFace = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targetFace === -1) break;
|
|
||||||
|
|
||||||
const face = faces[targetFace];
|
|
||||||
// Pick furthest point
|
|
||||||
let furthestPtIdx = face.points[0];
|
|
||||||
let maxD = distToPlane(face, points[furthestPtIdx]);
|
|
||||||
for (let i = 1; i < face.points.length; i++) {
|
|
||||||
const d = distToPlane(face, points[face.points[i]]);
|
|
||||||
if (d > maxD) {
|
|
||||||
maxD = d;
|
|
||||||
furthestPtIdx = face.points[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const eyePt = points[furthestPtIdx];
|
|
||||||
|
|
||||||
// Find all visible faces from eyePt
|
|
||||||
const visible = [];
|
|
||||||
for (let f = 0; f < faces.length; f++) {
|
|
||||||
if (faces[f].active && distToPlane(faces[f], eyePt) > 1e-6) {
|
|
||||||
visible.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find horizon edges (edges of visible faces that are shared with a non-visible face)
|
|
||||||
const edgeCount = new Map();
|
|
||||||
for (const fIdx of visible) {
|
|
||||||
const f = faces[fIdx];
|
|
||||||
const edges = [
|
|
||||||
[f.a, f.b],
|
|
||||||
[f.b, f.c],
|
|
||||||
[f.c, f.a]
|
|
||||||
];
|
|
||||||
for (const [u, v] of edges) {
|
|
||||||
const key = `${Math.min(u, v)}_${Math.max(u, v)}`;
|
|
||||||
const current = edgeCount.get(key) || { count: 0, u, v, origU: u, origV: v };
|
|
||||||
current.count++;
|
|
||||||
edgeCount.set(key, current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizonEdges = [];
|
|
||||||
for (const [key, val] of edgeCount.entries()) {
|
|
||||||
if (val.count === 1) {
|
|
||||||
// Find orientation from visible face
|
|
||||||
horizonEdges.push({ u: val.origU, v: val.origV });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all orphaned points from visible faces to reassign
|
|
||||||
const orphanPoints = [];
|
|
||||||
for (const fIdx of visible) {
|
|
||||||
faces[fIdx].active = false;
|
|
||||||
for (const pIdx of faces[fIdx].points) {
|
|
||||||
if (pIdx !== furthestPtIdx) orphanPoints.push(pIdx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new faces from horizon edges to eyePt
|
|
||||||
const newFaces = [];
|
|
||||||
// Center point of tetrahedron for orientation check
|
|
||||||
const centerPt = {
|
|
||||||
x: (points[p1].x + points[p2].x + points[p3].x + points[p4].x) / 4,
|
|
||||||
y: (points[p1].y + points[p2].y + points[p3].y + points[p4].y) / 4,
|
|
||||||
z: (points[p1].z + points[p2].z + points[p3].z + points[p4].z) / 4
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const edge of horizonEdges) {
|
|
||||||
const newF = makeFace(edge.u, edge.v, furthestPtIdx, centerPt);
|
|
||||||
newFaces.push(newF);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distribute orphaned points to new faces
|
|
||||||
for (const pIdx of orphanPoints) {
|
|
||||||
const pt = points[pIdx];
|
|
||||||
let maxDist = 1e-6;
|
|
||||||
let bestF = null;
|
|
||||||
for (const nF of newFaces) {
|
|
||||||
const dist = distToPlane(nF, pt);
|
|
||||||
if (dist > maxDist) {
|
|
||||||
maxDist = dist;
|
|
||||||
bestF = nF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestF) {
|
|
||||||
bestF.points.push(pIdx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nF of newFaces) {
|
|
||||||
faces.push(nF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build geometry buffers from active faces
|
|
||||||
const activeFaces = faces.filter(f => f.active);
|
|
||||||
const indices = [];
|
|
||||||
const usedPoints = new Map();
|
|
||||||
const verticesList = [];
|
const verticesList = [];
|
||||||
|
const indices = [];
|
||||||
|
const ptMap = new Map();
|
||||||
|
|
||||||
for (const f of activeFaces) {
|
for (const f of faces) {
|
||||||
for (const pIdx of [f.a, f.b, f.c]) {
|
for (const p of [f.a, f.b, f.c]) {
|
||||||
if (!usedPoints.has(pIdx)) {
|
const key = `${p.x}_${p.y}_${p.z}`;
|
||||||
usedPoints.set(pIdx, verticesList.length / 3);
|
if (!ptMap.has(key)) {
|
||||||
verticesList.push(points[pIdx].x, points[pIdx].y, points[pIdx].z);
|
ptMap.set(key, verticesList.length / 3);
|
||||||
|
verticesList.push(p.x, p.y, p.z);
|
||||||
}
|
}
|
||||||
indices.push(usedPoints.get(pIdx));
|
indices.push(ptMap.get(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-35
@@ -50,44 +50,75 @@ export async function initPresets() {
|
|||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:8px 12px; border-radius:6px;";
|
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:8px 12px; border-radius:6px;";
|
||||||
|
|
||||||
item.innerHTML = `
|
const leftCol = document.createElement("div");
|
||||||
<div style="flex:1; margin-right:12px;">
|
leftCol.style.cssText = "flex:1; margin-right:12px;";
|
||||||
<div style="font-weight:600; font-size:0.9rem;">${p.name} ${isBuiltin ? '<span style="font-size:0.7rem; opacity:0.6; border:1px solid #555; padding:1px 4px; border-radius:3px; margin-left:4px;">Built-in</span>' : ''}</div>
|
|
||||||
<div style="font-size:0.75rem; opacity:0.7; margin-top:2px;">${p.description || "No description"}</div>
|
|
||||||
<div style="font-size:0.7rem; opacity:0.5; margin-top:2px;">${p.colour_space.toUpperCase()} • ${p.patch_count} patches • ${p.page_size} • ${p.bit_depth}-bit • Quality ${p.colprof_quality.toUpperCase()}</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:6px;">
|
|
||||||
<button type="button" class="secondary btn-export-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Export</button>
|
|
||||||
${!isBuiltin ? `<button type="button" class="danger btn-delete-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Delete</button>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
managePresetsList.appendChild(item);
|
const titleRow = document.createElement("div");
|
||||||
});
|
titleRow.style.cssText = "font-weight:600; font-size:0.9rem; display:flex; align-items:center; gap:6px;";
|
||||||
|
const titleSpan = document.createElement("span");
|
||||||
|
titleSpan.textContent = p.name || "Untitled";
|
||||||
|
titleRow.appendChild(titleSpan);
|
||||||
|
|
||||||
// Attach listeners for export / delete
|
if (isBuiltin) {
|
||||||
managePresetsList.querySelectorAll(".btn-export-one").forEach((btn) => {
|
const badge = document.createElement("span");
|
||||||
btn.addEventListener("click", async () => {
|
badge.style.cssText = "font-size:0.7rem; opacity:0.6; border:1px solid #555; padding:1px 4px; border-radius:3px;";
|
||||||
const id = btn.getAttribute("data-id");
|
badge.textContent = "Built-in";
|
||||||
const preset = currentPresets.find(p => p.id === id);
|
titleRow.appendChild(badge);
|
||||||
if (preset) exportPreset(preset);
|
}
|
||||||
});
|
leftCol.appendChild(titleRow);
|
||||||
});
|
|
||||||
|
|
||||||
managePresetsList.querySelectorAll(".btn-delete-one").forEach((btn) => {
|
const descDiv = document.createElement("div");
|
||||||
btn.addEventListener("click", async () => {
|
descDiv.style.cssText = "font-size:0.8rem; opacity:0.75; margin-top:2px;";
|
||||||
const id = btn.getAttribute("data-id");
|
descDiv.textContent = p.description || "No description";
|
||||||
if (confirm("Delete this custom preset?")) {
|
leftCol.appendChild(descDiv);
|
||||||
try {
|
|
||||||
currentPresets = await invoke("delete_preset", { id });
|
const metaDiv = document.createElement("div");
|
||||||
if (activePresetId === id) activePresetId = currentPresets[0]?.id || "preset-std-rgb";
|
metaDiv.style.cssText = "font-size:0.75rem; opacity:0.5; margin-top:4px;";
|
||||||
renderPresetDropdown();
|
metaDiv.textContent = `${(p.colour_space || "").toUpperCase()} • ${p.patch_count} patches • ${p.page_size} • ${p.dpi || 300} DPI • ${p.bit_depth}-bit`;
|
||||||
renderManagePresetsList();
|
leftCol.appendChild(metaDiv);
|
||||||
} catch (err) {
|
|
||||||
alert("Delete failed: " + err);
|
item.appendChild(leftCol);
|
||||||
|
|
||||||
|
const actionsDiv = document.createElement("div");
|
||||||
|
actionsDiv.style.cssText = "display:flex; gap:6px;";
|
||||||
|
|
||||||
|
const exportBtn = document.createElement("button");
|
||||||
|
exportBtn.type = "button";
|
||||||
|
exportBtn.className = "icon-btn";
|
||||||
|
exportBtn.title = "Export to JSON";
|
||||||
|
exportBtn.style.cssText = "font-size:0.85rem; padding:4px 6px; border:1px solid var(--border-color, #333); border-radius:4px; background:transparent; cursor:pointer;";
|
||||||
|
exportBtn.textContent = "💾";
|
||||||
|
exportBtn.addEventListener("click", () => exportPreset(p));
|
||||||
|
actionsDiv.appendChild(exportBtn);
|
||||||
|
|
||||||
|
if (!isBuiltin) {
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.type = "button";
|
||||||
|
deleteBtn.className = "icon-btn";
|
||||||
|
deleteBtn.title = "Delete Preset";
|
||||||
|
deleteBtn.style.cssText = "font-size:0.85rem; padding:4px 6px; border:1px solid var(--border-color, #333); border-radius:4px; background:transparent; cursor:pointer; color:#ff6b6b;";
|
||||||
|
deleteBtn.textContent = "🗑️";
|
||||||
|
deleteBtn.addEventListener("click", async () => {
|
||||||
|
if (confirm(`Delete preset "${p.name}"?`)) {
|
||||||
|
try {
|
||||||
|
currentPresets = await invoke("delete_preset", { id: p.id });
|
||||||
|
if (activePresetId === p.id) {
|
||||||
|
activePresetId = "preset-std-rgb";
|
||||||
|
const fallback = currentPresets.find(x => x.id === activePresetId);
|
||||||
|
if (fallback) applyPreset(fallback);
|
||||||
|
}
|
||||||
|
renderPresetDropdown();
|
||||||
|
renderManagePresetsList();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Delete preset failed: " + err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
actionsDiv.appendChild(deleteBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(actionsDiv);
|
||||||
|
managePresetsList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +164,11 @@ export async function initPresets() {
|
|||||||
if (Number(r.value) === preset.bit_depth) r.checked = true;
|
if (Number(r.value) === preset.bit_depth) r.checked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tiffDpi = document.getElementById("tiffDpi");
|
||||||
|
if (tiffDpi && preset.dpi) {
|
||||||
|
tiffDpi.value = preset.dpi;
|
||||||
|
}
|
||||||
|
|
||||||
// Stage 4 controls
|
// Stage 4 controls
|
||||||
const colprofQuality = document.getElementById("colprofQuality");
|
const colprofQuality = document.getElementById("colprofQuality");
|
||||||
if (colprofQuality && preset.colprof_quality) colprofQuality.value = preset.colprof_quality;
|
if (colprofQuality && preset.colprof_quality) colprofQuality.value = preset.colprof_quality;
|
||||||
@@ -171,6 +207,9 @@ export async function initPresets() {
|
|||||||
const bitDepthRadio = document.querySelector('input[name="bitDepth"]:checked');
|
const bitDepthRadio = document.querySelector('input[name="bitDepth"]:checked');
|
||||||
const bit_depth = bitDepthRadio ? parseInt(bitDepthRadio.value, 10) : 8;
|
const bit_depth = bitDepthRadio ? parseInt(bitDepthRadio.value, 10) : 8;
|
||||||
|
|
||||||
|
const tiffDpi = document.getElementById("tiffDpi");
|
||||||
|
const dpi = tiffDpi && tiffDpi.value ? parseInt(tiffDpi.value, 10) || 300 : 300;
|
||||||
|
|
||||||
const colprofQuality = document.getElementById("colprofQuality");
|
const colprofQuality = document.getElementById("colprofQuality");
|
||||||
const colprof_quality = colprofQuality ? colprofQuality.value : "m";
|
const colprof_quality = colprofQuality ? colprofQuality.value : "m";
|
||||||
|
|
||||||
@@ -188,7 +227,7 @@ export async function initPresets() {
|
|||||||
instrument,
|
instrument,
|
||||||
page_size,
|
page_size,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
dpi: 300,
|
dpi,
|
||||||
colprof_algorithm,
|
colprof_algorithm,
|
||||||
colprof_quality,
|
colprof_quality,
|
||||||
colprof_intent: null,
|
colprof_intent: null,
|
||||||
|
|||||||
+2
-1
@@ -301,11 +301,12 @@ export function initPrinttarg() {
|
|||||||
if (radio.checked) bitDepth = parseInt(radio.value, 10);
|
if (radio.checked) bitDepth = parseInt(radio.value, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dpi = tiffDpi && tiffDpi.value ? parseInt(tiffDpi.value, 10) || 300 : 300;
|
||||||
const config = {
|
const config = {
|
||||||
instrument: instrumentSelect.value,
|
instrument: instrumentSelect.value,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
bit_depth: bitDepth,
|
bit_depth: bitDepth,
|
||||||
dpi: 300, // Master high-resolution layout scaled automatically to device bounds
|
dpi: dpi,
|
||||||
basename: stage1Basename,
|
basename: stage1Basename,
|
||||||
cwd: stage1Cwd,
|
cwd: stage1Cwd,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+150
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* quickhull3d - Fast 3D Convex Hull computation
|
||||||
|
* MIT License
|
||||||
|
*/
|
||||||
|
|
||||||
|
function visible(face, point) {
|
||||||
|
const a = face.a;
|
||||||
|
const b = face.b;
|
||||||
|
const c = face.c;
|
||||||
|
const v0x = b.x - a.x, v0y = b.y - a.y, v0z = b.z - a.z;
|
||||||
|
const v1x = c.x - a.x, v1y = c.y - a.y, v1z = c.z - a.z;
|
||||||
|
const nx = v0y * v1z - v0z * v1y;
|
||||||
|
const ny = v0z * v1x - v0x * v1z;
|
||||||
|
const nz = v0x * v1y - v0y * v1x;
|
||||||
|
const ppx = point.x - a.x, ppy = point.y - a.y, ppz = point.z - a.z;
|
||||||
|
return (nx * ppx + ny * ppy + nz * ppz) > 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeQuickHull(points) {
|
||||||
|
if (!points || points.length < 4) return [];
|
||||||
|
|
||||||
|
// Filter degenerate duplicate points
|
||||||
|
const pts = [];
|
||||||
|
const eps = 1e-5;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p = points[i];
|
||||||
|
let dup = false;
|
||||||
|
for (let j = 0; j < pts.length; j++) {
|
||||||
|
const q = pts[j];
|
||||||
|
const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z;
|
||||||
|
if (dx * dx + dy * dy + dz * dz < eps * eps) {
|
||||||
|
dup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dup) pts.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pts.length < 4) return [];
|
||||||
|
|
||||||
|
// 1. Find initial extreme points
|
||||||
|
let minX = 0, maxX = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
if (pts[i].x < pts[minX].x) minX = i;
|
||||||
|
if (pts[i].x > pts[maxX].x) maxX = i;
|
||||||
|
}
|
||||||
|
if (minX === maxX) return [];
|
||||||
|
|
||||||
|
// Furthest from line minX-maxX
|
||||||
|
let maxD2 = 0, p2 = -1;
|
||||||
|
const l0 = pts[minX], l1 = pts[maxX];
|
||||||
|
const lx = l1.x - l0.x, ly = l1.y - l0.y, lz = l1.z - l0.z;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (i === minX || i === maxX) continue;
|
||||||
|
const px = pts[i].x - l0.x, py = pts[i].y - l0.y, pz = pts[i].z - l0.z;
|
||||||
|
const cx = ly * pz - lz * py, cy = lz * px - lx * pz, cz = lx * py - ly * px;
|
||||||
|
const d2 = cx * cx + cy * cy + cz * cz;
|
||||||
|
if (d2 > maxD2) {
|
||||||
|
maxD2 = d2;
|
||||||
|
p2 = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p2 === -1 || maxD2 < 1e-9) return [];
|
||||||
|
|
||||||
|
// Furthest from plane minX-maxX-p2
|
||||||
|
const pA = pts[minX], pB = pts[maxX], pC = pts[p2];
|
||||||
|
const nx = (pB.y - pA.y) * (pC.z - pA.z) - (pB.z - pA.z) * (pC.y - pA.y);
|
||||||
|
const ny = (pB.z - pA.z) * (pC.x - pA.x) - (pB.x - pA.x) * (pC.z - pA.z);
|
||||||
|
const nz = (pB.x - pA.x) * (pC.y - pA.y) - (pB.y - pA.y) * (pC.x - pA.x);
|
||||||
|
let maxDPlane = 0, p3 = -1;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (i === minX || i === maxX || i === p2) continue;
|
||||||
|
const d = Math.abs(nx * (pts[i].x - pA.x) + ny * (pts[i].y - pA.y) + nz * (pts[i].z - pA.z));
|
||||||
|
if (d > maxDPlane) {
|
||||||
|
maxDPlane = d;
|
||||||
|
p3 = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p3 === -1 || maxDPlane < 1e-9) return [];
|
||||||
|
|
||||||
|
// Build initial tetrahedron with outward-pointing normals
|
||||||
|
const p0 = pts[minX], p1 = pts[maxX], pt2 = pts[p2], pt3 = pts[p3];
|
||||||
|
const center = {
|
||||||
|
x: (p0.x + p1.x + pt2.x + pt3.x) / 4,
|
||||||
|
y: (p0.y + p1.y + pt2.y + pt3.y) / 4,
|
||||||
|
z: (p0.z + p1.z + pt2.z + pt3.z) / 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFace(a, b, c) {
|
||||||
|
const fnx = (b.y - a.y) * (c.z - a.z) - (b.z - a.z) * (c.y - a.y);
|
||||||
|
const fny = (b.z - a.z) * (c.x - a.x) - (b.x - a.x) * (c.z - a.z);
|
||||||
|
const fnz = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||||
|
const cpx = center.x - a.x, cpy = center.y - a.y, cpz = center.z - a.z;
|
||||||
|
if (fnx * cpx + fny * cpy + fnz * cpz > 0) {
|
||||||
|
return { a: a, b: c, c: b, active: true };
|
||||||
|
}
|
||||||
|
return { a: a, b: b, c: c, active: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let faces = [
|
||||||
|
createFace(p0, p1, pt2),
|
||||||
|
createFace(p0, pt2, pt3),
|
||||||
|
createFace(p0, pt3, p1),
|
||||||
|
createFace(p1, pt3, pt2),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Incrementally add remaining points
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (i === minX || i === maxX || i === p2 || i === p3) continue;
|
||||||
|
const pt = pts[i];
|
||||||
|
|
||||||
|
// Find all visible faces
|
||||||
|
const vis = [];
|
||||||
|
for (let f = 0; f < faces.length; f++) {
|
||||||
|
if (faces[f].active && visible(faces[f], pt)) {
|
||||||
|
vis.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vis.length === 0) continue;
|
||||||
|
|
||||||
|
// Find horizon edges
|
||||||
|
const edgeMap = new Map();
|
||||||
|
for (const fIdx of vis) {
|
||||||
|
const f = faces[fIdx];
|
||||||
|
const edges = [
|
||||||
|
{ u: f.a, v: f.b },
|
||||||
|
{ u: f.b, v: f.c },
|
||||||
|
{ u: f.c, v: f.a },
|
||||||
|
];
|
||||||
|
for (const e of edges) {
|
||||||
|
const uId = pts.indexOf(e.u), vId = pts.indexOf(e.v);
|
||||||
|
const key = uId < vId ? (uId + '_' + vId) : (vId + '_' + uId);
|
||||||
|
const current = edgeMap.get(key) || { count: 0, u: e.u, v: e.v };
|
||||||
|
current.count++;
|
||||||
|
edgeMap.set(key, current);
|
||||||
|
}
|
||||||
|
faces[fIdx].active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new faces from horizon edges to pt
|
||||||
|
for (const entry of edgeMap.values()) {
|
||||||
|
if (entry.count === 1) {
|
||||||
|
const newF = createFace(entry.u, entry.v, pt);
|
||||||
|
faces.push(newF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces.filter(f => f.active);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user