diff --git a/README.md b/README.md index ff6fc9a..93eb2e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.2.1-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.3.3-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) @@ -14,11 +14,13 @@ ## Key Features - 🪄 **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`). - 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. - 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. - 🌐 **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. diff --git a/ROADMAP.md b/ROADMAP.md index 1f35140..11472fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,10 +60,14 @@ ICCery is a native, cross-platform desktop application built with: ## 3. Future Roadmap ### 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. -- [ ] **Ambient & Display Profiling**: Extend wizard beyond reflective targets to emissive displays (`dispwin`, `dispread`). -- [ ] **Preset Management**: Save and load reusable profiling recipes (paper types, patch counts, quality presets). -- [ ] **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] **Averaging & Multi-Pass Reading**: Integrated Argyll `average` multi-pass measurement sheet workflow for noise reduction. +- [x] **Instrument Auto-Detection**: Added hardware detection via `instlist`. +- [x] **Preset Management**: Save, load, export, and import profiling recipes. +- [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`) - [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles. diff --git a/package-lock.json b/package-lock.json index ab10594..3dbfe09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iccery", - "version": "0.1.4", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iccery", - "version": "0.1.4", + "version": "0.3.3", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/package.json b/package.json index 660c706..453f1a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "tauri": "tauri" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7254304..a9be0fe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.3.2" +version = "0.3.3" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6d8be3c..03b2ede 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -247,6 +247,15 @@ pub fn get_profile_path(cwd: String, basename: String) -> String { path.to_string_lossy().to_string() } +pub fn build_iccgamut_args(resolved_path: &str) -> Vec { + vec![ + "-v".to_string(), + "-d".to_string(), + "10".to_string(), + resolved_path.to_string(), + ] +} + #[tauri::command] pub async fn extract_gamut( app: AppHandle, @@ -276,12 +285,7 @@ pub async fn extract_gamut( } }; - let args = vec![ - "-v".to_string(), - "-d".to_string(), - "50.0".to_string(), - resolved_path, - ]; + let args = build_iccgamut_args(&resolved_path); let cwd = if parent_dir.is_empty() { resolve_safe_cwd(&app, "").ok() } else { @@ -507,6 +511,68 @@ pub async fn run_average( state.spawn(app, id, binary, args, cwd).await } +fn sanitize_basename(basename: &str) -> Result { + 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 { + 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 { + 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)] pub struct ColprofConfig { pub algorithm: String, @@ -704,6 +770,12 @@ pub async fn print_target_native( mod tests { 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] fn test_build_targen_args_rgb() { let config = TargenConfig { @@ -792,7 +864,7 @@ mod tests { } #[test] - fn test_build_chartread_args() { + fn test_build_chartread_args_auto() { let config = ChartreadConfig { basename: "my_profile".to_string(), cwd: "/home/user".to_string(), @@ -802,6 +874,17 @@ mod tests { 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] fn test_build_chartread_args_with_port() { let config = ChartreadConfig { @@ -824,6 +907,55 @@ mod tests { 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] fn test_build_colprof_args() { let config = ColprofConfig { @@ -865,4 +997,4 @@ mod tests { let candidates_exe = get_binary_candidates("targen.exe"); assert_eq!(candidates_exe, vec!["targen.exe"]); } -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ebf33af..42e5884 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ pub fn run() { commands::read_file_base64, commands::read_tiff_preview_png, commands::run_chartread, + commands::snapshot_ti3, + commands::promote_ti3, commands::run_average, commands::run_colprof, commands::run_profcheck, diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index e3b8ad7..648fe56 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -29,6 +29,12 @@ impl ProcessManager { args: Vec, cwd: Option, ) -> 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); command.args(args); if let Some(dir) = cwd { @@ -156,3 +162,18 @@ impl ProcessManager { 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")); + } + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c444aec..c45a2a9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -150,7 +150,24 @@ pub fn export_preset_json(preset: ProfilingPreset) -> Result { #[tauri::command] pub fn import_preset_json(json: String) -> Result { - serde_json::from_str::(&json).map_err(|e| format!("Invalid preset format: {}", e)) + let preset: ProfilingPreset = serde_json::from_str::(&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)] @@ -168,6 +185,22 @@ mod tests { 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] fn test_preset_json_export_and_import() { let preset = ProfilingPreset { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ae910c7..f33028f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.3.2", + "version": "0.3.3", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 31eb400..207bd9f 100644 --- a/src/index.html +++ b/src/index.html @@ -50,7 +50,7 @@ - + @@ -176,6 +176,11 @@ + +
+ + +
@@ -266,10 +271,10 @@
- +
@@ -311,11 +316,11 @@

Multi-Pass Target Averaging

Pass 1 Complete
-

You can measure additional printed copies to reduce measurement and printing noise before computing the profile.

+

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.

- +
@@ -564,4 +569,4 @@ - \ No newline at end of file + diff --git a/src/js/chartread.js b/src/js/chartread.js index f5a42bf..d5c82bd 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -29,6 +29,9 @@ const STATE = { let currentState = STATE.IDLE; let currentProcessId = ""; +let measurementInProgress = false; +let currentPassIndex = 0; +const recordedPasses = []; export function initChartread() { const btnStartRead = document.getElementById("btnStartRead"); @@ -42,6 +45,47 @@ export function initChartread() { const stateLabel = document.getElementById("chartreadState"); const logContainer = document.getElementById("chartreadLogContainer"); 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 if (btnDetectInstruments && instrumentSelect) { @@ -58,12 +102,15 @@ export function initChartread() { const line = event.payload.line.trim(); // 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); if (match) { const index = match[1]; const name = match[2].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(); unlistenExit(); btnDetectInstruments.disabled = false; - btnDetectInstruments.textContent = "↻ Detect"; + btnDetectInstruments.textContent = "↺ Detect"; - instrumentSelect.innerHTML = ``; + instrumentSelect.innerHTML = ``; if (detected.length > 0) { detected.forEach((inst) => { const opt = document.createElement("option"); - opt.value = inst.index; - opt.textContent = `${inst.index}: ${inst.name} ${inst.port ? `(${inst.port})` : ""}`; + // Do not pass instlist ordinal as -c comm port; use empty value for auto-port (#111) + opt.value = ""; + opt.textContent = `${inst.name}${inst.port ? ` on ${inst.port}` : ""}`; instrumentSelect.appendChild(opt); }); - instrumentSelect.value = detected[0].index; + instrumentSelect.value = ""; setPrompt(`Found ${detected.length} instrument(s): ${detected.map(d => d.name).join(", ")}`); } else { setPrompt("No instruments found via instlist. Ensure USB cable is plugged in."); @@ -133,7 +181,10 @@ export function initChartread() { btnCancel.classList.remove("hidden"); break; 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; } } @@ -145,6 +196,11 @@ export function initChartread() { // Start reading button if (btnStartRead) { btnStartRead.addEventListener("click", async () => { + if (measurementInProgress) { + setPrompt("A measurement is already running. Wait for it to finish."); + return; + } + const basename = stage2Basename || wizardState.basename; const cwd = stage2Cwd || wizardState.cwd; @@ -159,6 +215,7 @@ export function initChartread() { logPre.textContent = ""; logContainer.open = false; logContainer.classList.remove("hidden"); + setMeasurementBusy(true); setState(STATE.CALIBRATING); setPrompt("Starting chartread... waiting for instrument calibration prompt."); @@ -219,29 +276,41 @@ export function initChartread() { stopSwatchListener(); if (event.payload.code === 0) { - setState(STATE.FINISHED); - setPrompt("✅ Sheet measurement completed!"); - logPre.textContent += "\n[SUCCESS] chartread sheet measurement completed.\n"; + try { + const passIndex = currentPassIndex + 1; + const filename = await invoke("snapshot_ti3", { + cwd: cwd, + basename: basename, + pass_index: passIndex, + }); - // Track pass in session - currentPassIndex++; - recordedPasses.push({ - index: currentPassIndex, - filename: `${basename}.ti3`, - time: new Date().toLocaleTimeString(), - }); + currentPassIndex = passIndex; + recordedPasses.push({ + index: currentPassIndex, + filename: filename, + time: new Date().toLocaleTimeString(), + }); - renderPassesList(); - if (averagingPanel) averagingPanel.classList.remove("hidden"); - if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`; + setState(STATE.FINISHED); + setPrompt(`Sheet pass #${currentPassIndex} saved as ${filename}. Stage 4 stays locked until you finish.`); + logPre.textContent += `\n[SUCCESS] chartread completed. Snapshotted ${filename} (canonical ${basename}.ti3 removed until Finish).\n`; - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); + renderPassesList(); + 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 { setState(STATE.FINISHED); setPrompt(`❌ chartread exited with code ${event.payload.code}.`); logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`; } + + setMeasurementBusy(false); }); await invoke("run_chartread", { config }); @@ -249,34 +318,19 @@ export function initChartread() { } catch (err) { setPrompt(`Invoke error: ${err}`); 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 = `Sheet Pass #${i + 1} (${pass.filename})✓ ${pass.time}`; - passesList.appendChild(item); - }); - } - if (btnMeasureAnotherSheet) { btnMeasureAnotherSheet.addEventListener("click", () => { + if (measurementInProgress) { + setPrompt("Wait for the current measurement to finish and be snapshotted."); + return; + } 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(); }); } @@ -286,11 +340,36 @@ export function initChartread() { const basename = stage2Basename || wizardState.basename; const cwd = stage2Cwd || wizardState.cwd; - if (recordedPasses.length <= 1) { - setPrompt("Single pass accepted. Proceeding to Stage 4..."); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + if (!basename || !cwd) { + setPrompt("Error: No working directory or basename."); + return; + } + + 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; } @@ -304,21 +383,30 @@ export function initChartread() { 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; unlistenAvgExit(); - btnFinishAndAverage.disabled = false; if (event.payload.code === 0) { setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`; + acceptStage3(basename, cwd); } else { - setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Proceeding with primary pass.`); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`); + logPre.textContent += `\n[ERROR] average exited ${event.payload.code}. Promoting ${recordedPasses[0].filename}.\n`; + try { + 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) { console.error("run_average error:", e); btnFinishAndAverage.disabled = false; - setStage3Result(basename, cwd); - advanceToStage4(); + setPrompt(`run_average error: ${e}`); } }); } @@ -369,9 +456,15 @@ export function initChartread() { try { await invoke("kill_process", { id: currentProcessId }); stopSwatchListener(); - setState(STATE.IDLE); + setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE); 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'); 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 => { s.classList.remove('active'); diff --git a/src/js/delaunator.min.js b/src/js/delaunator.min.js deleted file mode 100644 index 1f63da5..0000000 --- a/src/js/delaunator.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).Delaunator=i()}(this,(function(){"use strict";const t=134217729;function i(t,i,s,e,n){let h,r,l,o,a=i[0],f=e[0],c=0,u=0;f>a==f>-a?(h=a,a=i[++c]):(h=f,f=e[++u]);let _=0;if(ca==f>-a?(r=a+h,l=h-(r-a),a=i[++c]):(r=f+h,l=h-(r-f),f=e[++u]),h=r,0!==l&&(n[_++]=l);ca==f>-a?(r=h+a,o=r-h,l=h-(r-o)+(a-o),a=i[++c]):(r=h+f,o=r-h,l=h-(r-o)+(f-o),f=e[++u]),h=r,0!==l&&(n[_++]=l);for(;c0!=d>0)return g;const y=Math.abs(_+d);return Math.abs(g)>=33306690738754716e-32*y?g:-function(s,o,a,f,c,u,_){let d,g,y,w,b,A,k,M,p,x,S,T,z,U,m,K,L,v;const F=s-c,P=a-c,E=o-u,H=f-u;U=F*H,A=t*F,k=A-(A-F),M=F-k,A=t*H,p=A-(A-H),x=H-p,m=M*x-(U-k*p-M*p-k*x),K=E*P,A=t*E,k=A-(A-E),M=E-k,A=t*P,p=A-(A-P),x=P-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,e[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,e[1]=z-(S+b)+(b-K),v=T+S,b=v-T,e[2]=T-(v-b)+(S-b),e[3]=v;let I=function(t,i){let s=i[0];for(let e=1;e=N||-I>=N)return I;if(b=s-F,d=s-(F+b)+(b-c),b=a-P,y=a-(P+b)+(b-c),b=o-E,g=o-(E+b)+(b-u),b=f-H,w=f-(H+b)+(b-u),0===d&&0===g&&0===y&&0===w)return I;if(N=11093356479670487e-47*_+33306690738754706e-32*Math.abs(I),I+=F*w+H*d-(E*y+P*g),I>=N||-I>=N)return I;U=d*H,A=t*d,k=A-(A-d),M=d-k,A=t*H,p=A-(A-H),x=H-p,m=M*x-(U-k*p-M*p-k*x),K=g*P,A=t*g,k=A-(A-g),M=g-k,A=t*P,p=A-(A-P),x=P-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const j=i(4,e,4,l,n);U=F*w,A=t*F,k=A-(A-F),M=F-k,A=t*w,p=A-(A-w),x=w-p,m=M*x-(U-k*p-M*p-k*x),K=E*y,A=t*E,k=A-(A-E),M=E-k,A=t*y,p=A-(A-y),x=y-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const q=i(j,n,4,l,h);U=d*w,A=t*d,k=A-(A-d),M=d-k,A=t*w,p=A-(A-w),x=w-p,m=M*x-(U-k*p-M*p-k*x),K=g*y,A=t*g,k=A-(A-g),M=g-k,A=t*y,p=A-(A-y),x=y-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const D=i(q,h,4,l,r);return r[D-1]}(s,o,a,f,c,u,y)}const a=Math.pow(2,-52),f=new Uint32Array(512);class c{static from(t,i=w,s=b){const e=t.length,n=new Float64Array(2*e);for(let h=0;h>1;if(i>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const s=Math.max(2*i-5,0);this._triangles=new Uint32Array(3*s),this._halfedges=new Int32Array(3*s),this._hashSize=Math.ceil(Math.sqrt(i)),this._hullPrev=new Uint32Array(i),this._hullNext=new Uint32Array(i),this._hullTri=new Uint32Array(i),this._hullHash=new Int32Array(this._hashSize).fill(-1),this._ids=new Uint32Array(i),this._dists=new Float64Array(i),this.update()}update(){const{coords:t,_hullPrev:i,_hullNext:s,_hullTri:e,_hullHash:n}=this,h=t.length>>1;let r=1/0,l=1/0,f=-1/0,c=-1/0;for(let i=0;if&&(f=s),e>c&&(c=e),this._ids[i]=i}const _=(r+f)/2,y=(l+c)/2;let w,b,A,k=1/0;for(let i=0;i0&&(b=i,k=s)}let x=t[2*b],S=t[2*b+1],T=1/0;for(let i=0;ie&&(i[s++]=n,e=this._dists[n])}return this.hull=i.subarray(0,s),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(o(M,p,x,S,z,U)<0){const t=b,i=x,s=S;b=A,x=z,S=U,A=t,z=i,U=s}const m=function(t,i,s,e,n,h){const r=s-t,l=e-i,o=n-t,a=h-i,f=r*r+l*l,c=o*o+a*a,u=.5/(r*a-l*o);return{x:t+(a*f-l*c)*u,y:i+(r*c-o*f)*u}}(M,p,x,S,z,U);this._cx=m.x,this._cy=m.y;for(let i=0;i0&&Math.abs(c-h)<=a&&Math.abs(u-r)<=a)continue;if(h=c,r=u,f===w||f===b||f===A)continue;let _=0;for(let t=0,i=this._hashKey(c,u);t=0;)if(g=d,g===_){g=-1;break}if(-1===g)continue;let y=this._addTriangle(g,f,s[g],-1,-1,e[g]);e[f]=this._legalize(y+2),e[g]=y,K++;let k=s[g];for(;d=s[k],o(c,u,t[2*k],t[2*k+1],t[2*d],t[2*d+1])<0;)y=this._addTriangle(k,f,d,e[f],-1,e[k]),e[f]=this._legalize(y+2),s[k]=k,K--,k=d;if(g===_)for(;d=i[g],o(c,u,t[2*d],t[2*d+1],t[2*g],t[2*g+1])<0;)y=this._addTriangle(d,f,g,-1,e[g],e[d]),this._legalize(y+2),e[d]=y,s[g]=g,K--,g=d;this._hullStart=i[f]=g,s[g]=i[k]=f,s[f]=k,n[this._hashKey(c,u)]=f,n[this._hashKey(t[2*g],t[2*g+1])]=g}this.hull=new Uint32Array(K);for(let t=0,i=this._hullStart;t0?3-s:1+s)/4}(t-this._cx,i-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:i,_halfedges:s,coords:e}=this;let n=0,h=0;for(;;){const r=s[t],l=t-t%3;if(h=l+(t+2)%3,-1===r){if(0===n)break;t=f[--n];continue}const o=r-r%3,a=l+(t+1)%3,c=o+(r+2)%3,u=i[h],d=i[t],g=i[a],y=i[c];if(_(e[2*u],e[2*u+1],e[2*d],e[2*d+1],e[2*g],e[2*g+1],e[2*y],e[2*y+1])){i[t]=y,i[r]=u;const e=s[c];if(-1===e){let i=this._hullStart;do{if(this._hullTri[i]===c){this._hullTri[i]=t;break}i=this._hullPrev[i]}while(i!==this._hullStart)}this._link(t,e),this._link(r,s[h]),this._link(h,c);const l=o+(r+1)%3;n=s&&i[t[r]]>h;)t[r+1]=t[r--];t[r+1]=e}else{let n=s+1,h=e;y(t,s+e>>1,n),i[t[s]]>i[t[e]]&&y(t,s,e),i[t[n]]>i[t[e]]&&y(t,n,e),i[t[s]]>i[t[n]]&&y(t,s,n);const r=t[n],l=i[r];for(;;){do{n++}while(i[t[n]]l);if(h=h-s?(g(t,i,n,e),g(t,i,s,h-1)):(g(t,i,s,h-1),g(t,i,n,e))}}function y(t,i,s){const e=t[i];t[i]=t[s],t[s]=e}function w(t){return t[0]}function b(t){return t[1]}return c})); diff --git a/src/js/gamut_viewer.js b/src/js/gamut_viewer.js index bc2e0f5..ad45f5f 100644 --- a/src/js/gamut_viewer.js +++ b/src/js/gamut_viewer.js @@ -1,3 +1,4 @@ +import { computeQuickHull } from "./vendor/quickhull.js"; const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; @@ -119,267 +120,21 @@ export function parseCGATS(text) { * Output: { vertices: Float32Array, indices: Uint32Array } or null */ 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 indices = []; + const ptMap = new Map(); - for (const f of activeFaces) { - for (const pIdx of [f.a, f.b, f.c]) { - if (!usedPoints.has(pIdx)) { - usedPoints.set(pIdx, verticesList.length / 3); - verticesList.push(points[pIdx].x, points[pIdx].y, points[pIdx].z); + for (const f of faces) { + for (const p of [f.a, f.b, f.c]) { + const key = `${p.x}_${p.y}_${p.z}`; + if (!ptMap.has(key)) { + ptMap.set(key, verticesList.length / 3); + verticesList.push(p.x, p.y, p.z); } - indices.push(usedPoints.get(pIdx)); + indices.push(ptMap.get(key)); } } diff --git a/src/js/presets.js b/src/js/presets.js index fcb8068..0b5e9f8 100644 --- a/src/js/presets.js +++ b/src/js/presets.js @@ -50,44 +50,75 @@ export async function initPresets() { 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.innerHTML = ` -
-
${p.name} ${isBuiltin ? 'Built-in' : ''}
-
${p.description || "No description"}
-
${p.colour_space.toUpperCase()} • ${p.patch_count} patches • ${p.page_size} • ${p.bit_depth}-bit • Quality ${p.colprof_quality.toUpperCase()}
-
-
- - ${!isBuiltin ? `` : ''} -
- `; + const leftCol = document.createElement("div"); + leftCol.style.cssText = "flex:1; margin-right:12px;"; - 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 - managePresetsList.querySelectorAll(".btn-export-one").forEach((btn) => { - btn.addEventListener("click", async () => { - const id = btn.getAttribute("data-id"); - const preset = currentPresets.find(p => p.id === id); - if (preset) exportPreset(preset); - }); - }); + if (isBuiltin) { + const badge = document.createElement("span"); + badge.style.cssText = "font-size:0.7rem; opacity:0.6; border:1px solid #555; padding:1px 4px; border-radius:3px;"; + badge.textContent = "Built-in"; + titleRow.appendChild(badge); + } + leftCol.appendChild(titleRow); - managePresetsList.querySelectorAll(".btn-delete-one").forEach((btn) => { - btn.addEventListener("click", async () => { - const id = btn.getAttribute("data-id"); - if (confirm("Delete this custom preset?")) { - try { - currentPresets = await invoke("delete_preset", { id }); - if (activePresetId === id) activePresetId = currentPresets[0]?.id || "preset-std-rgb"; - renderPresetDropdown(); - renderManagePresetsList(); - } catch (err) { - alert("Delete failed: " + err); + const descDiv = document.createElement("div"); + descDiv.style.cssText = "font-size:0.8rem; opacity:0.75; margin-top:2px;"; + descDiv.textContent = p.description || "No description"; + leftCol.appendChild(descDiv); + + const metaDiv = document.createElement("div"); + metaDiv.style.cssText = "font-size:0.75rem; opacity:0.5; margin-top:4px;"; + metaDiv.textContent = `${(p.colour_space || "").toUpperCase()} • ${p.patch_count} patches • ${p.page_size} • ${p.dpi || 300} DPI • ${p.bit_depth}-bit`; + leftCol.appendChild(metaDiv); + + 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; }); + const tiffDpi = document.getElementById("tiffDpi"); + if (tiffDpi && preset.dpi) { + tiffDpi.value = preset.dpi; + } + // Stage 4 controls const colprofQuality = document.getElementById("colprofQuality"); 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 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 colprof_quality = colprofQuality ? colprofQuality.value : "m"; @@ -188,7 +227,7 @@ export async function initPresets() { instrument, page_size, bit_depth, - dpi: 300, + dpi, colprof_algorithm, colprof_quality, colprof_intent: null, diff --git a/src/js/printtarg.js b/src/js/printtarg.js index 3222048..61f6520 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -301,11 +301,12 @@ export function initPrinttarg() { if (radio.checked) bitDepth = parseInt(radio.value, 10); }); + const dpi = tiffDpi && tiffDpi.value ? parseInt(tiffDpi.value, 10) || 300 : 300; const config = { instrument: instrumentSelect.value, page_size: pageSize, bit_depth: bitDepth, - dpi: 300, // Master high-resolution layout scaled automatically to device bounds + dpi: dpi, basename: stage1Basename, cwd: stage1Cwd, }; diff --git a/src/js/vendor/quickhull.js b/src/js/vendor/quickhull.js new file mode 100644 index 0000000..bdd3cb2 --- /dev/null +++ b/src/js/vendor/quickhull.js @@ -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); +}