Release v0.3.3 Hot fixes #126

Merged
gronod merged 17 commits from development into main 2026-08-28 19:04:32 +01:00
17 changed files with 623 additions and 384 deletions
+6 -4
View File
@@ -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.
[![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) [![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) [![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) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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"]);
} }
} }
+2
View File
@@ -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,
+21
View File
@@ -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"));
}
}
}
+34 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 &amp; Proceed →</button> <button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes &amp; Proceed →</button>
</div> </div>
</div> </div>
@@ -564,4 +569,4 @@
</div> </div>
</dialog> </dialog>
</body> </body>
</html> </html>
+158 -62
View File
@@ -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');
-1
View File
File diff suppressed because one or more lines are too long
+12 -257
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}; };
+150
View File
@@ -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);
}