3 Commits
Author SHA1 Message Date
gronod d50c3c4f3c Merge pull request 'Release v0.7.4' (#197) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m5s
Build Linux Packages / Build Linux (push) Successful in 16m34s
Release v0.7.4
2026-09-03 17:24:54 +01:00
gronod 9a365acd2b Merge pull request 'fix(print): macOS Preferences crash and color-management hardening (closes #188)' (#195) from fix/188-12 into main
Build Windows Packages / Build Windows (push) Successful in 7m6s
Build Linux Packages / Build Linux (push) Successful in 10m34s
Merge fix/188-12 into main: macOS Preferences crash fix and color-management hardening (closes #188)
2026-09-03 13:31:17 +01:00
gronod 47a5373a8e chore: merge development into main (fix Windows build failure)
Build Windows Packages / Build Windows (push) Successful in 7m28s
Build Linux Packages / Build Linux (push) Successful in 10m39s
Merge pull request #193 from development into main
2026-09-02 19:07:58 +01:00
12 changed files with 106 additions and 653 deletions
-46
View File
@@ -1,51 +1,5 @@
# ICCery Agent Notes # ICCery Agent Notes
## 3D Gamut Viewer
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB.
- Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders.
- Click **Reset View** or press **R** to return the camera to its default position.
- Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`.
## Stage 4 colprof Options
- `colprof` options are exposed in the Stage 4 UI with contextual tooltips:
- **FWA / OBA Compensation** (`-f`): choose D50, None, D65, or a custom `.sp` spectrum file.
- **Standard Illuminant** (`-i`) and **Observer** (`-o`): override default D50 / 1931 2° for CIE colourimetric calculations.
- **Viewing Conditions** (`-c` input, `-d` output): set CIE viewing-condition transforms for the profile table and output intent.
- The backend `build_colprof_args` in `commands.rs` maps these UI values to `colprof` CLI flags. Unit tests cover all combinations.
## Stage 3 Swatch Grid
- Swatch patches render a 135° diagonal split: top-left is the intended/expected colour, bottom-right is the measured colour.
- Argyll `chartread` emits `is_pad` for boundary/spacer patches. White reference patches (e.g. `-e` white steps) may also carry `is_pad`, but they have valid `expected.Lab` or non-zero `device` data. The guard skips only pads with no measurement *and* all-zero device values.
- Row/patch order from `chartread` (rows A→Z, patches 1→N within each row) is rendered left-to-right / top-to-bottom to match the `printtarg` output.
- Tooltip shows intended Lab (or device %), measured Lab, and ΔE₀₀ with a Good/Warning/Bad classification.
## UI Button Conventions
The frontend uses a tiered button sizing system defined in `src/styles/main.css`. Prefer these utility classes over inline `style` attributes.
| Class | Size | Use for |
|-------|------|---------|
| `.btn-sm` | 28px | Toolbar actions, header icons, minor toggles |
| `.btn-md` (default for `button.secondary`/`button.danger`) | 36px | Standard dialog/form actions and browse buttons |
| `.btn-lg` | 40px | Primary stage actions (Generate, Create Layout, Create Profile, Verify, etc.) |
| `.btn-icon-sq` | 36×36px | Square icon-only buttons (refresh, settings, etc.) |
| `.icon-btn` | 28×28px | Small header icon-only buttons (settings, about, save preset, manage presets) |
| `.btn-properties` | 36px | Printer driver preferences button |
- Primary action buttons use `button.primary` plus `.btn-lg` for major stage actions.
- Danger actions use `button.danger` (36px).
- All action rows use one of: `.stage-actions`, `.modal-actions`, `.chartread-actions`, `.print-actions-row`, `.btn-row`, `.btn-row-sm`, `.btn-row-end`, or `.input-row-sm`.
- Avoid inline `style` on `<button>` elements or their immediate parent rows.
## Settings & Preferences
- Settings are persisted to `settings.json` in the app data directory and include the Stage 3 ΔE₀₀ traffic-light thresholds.
- Valid threshold values must be non-negative and `delta_e_good_max < delta_e_warning_max`; both the frontend and backend enforce this.
- Saving settings dispatches a `settings-saved` custom event so live components (e.g. the swatch grid) can re-classify on the fly.
## Build Commands ## Build Commands
- **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required) - **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
+3 -4
View File
@@ -16,14 +16,13 @@
- 🪄 **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, profiling 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`) & 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 user-configurable traffic-light thresholds, diagonally split intended-vs-measured colour swatches, white reference patch preservation, and multi-pass sheet averaging for measurement noise reduction. 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, OBA/FWA compensation, illuminant/observer selection, viewing-condition transforms, custom ambient spectrum support, 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 convex hull color volume viewer, per-vertex true-colour rendering, layer opacity controls, camera reset, keyboard shortcut, touch controls, 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. - 📋 **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. - 🐧 **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.
- 🎛️ **Consistent UI Controls**: Tiered button sizing (`btn-sm`/`btn-md`/`btn-lg`/`btn-icon-sq`) and standardised action row classes provide a uniform, polished interface across all wizard stages and dialogs.
- ⚖️ **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.
--- ---
-9
View File
@@ -99,15 +99,6 @@ ICCery is a native, cross-platform desktop application built with:
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass. - [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
- [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred). - [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred).
### Milestone 13 — UI/UX & Workflow Polish
- [x] **Global Button Standardization (#177)**: Enforce `.btn-sm`/`.btn-md`/`.btn-lg`/`.btn-icon-sq` tiers across all stages, remove inline button styles, and add CSS custom properties for button metrics.
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
- [x] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
- [x] **Stage 4 OBA/FWA Tooltips & Tests (#176)**: Contextual help for OBA/FWA, illuminant, observer, and viewing-condition controls; additional `colprof` arg builder tests.
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
- [ ] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
### Milestone 12 — Future Workflow & Advanced Analytics (Deferred) ### Milestone 12 — Future Workflow & Advanced Analytics (Deferred)
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile. - [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese). - [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese).
-27
View File
@@ -1783,33 +1783,6 @@ mod tests {
); );
} }
#[test]
fn test_build_colprof_args_combined_fwa_illuminant_observer_viewing() {
let config = ColprofConfig {
algorithm: "x".to_string(),
quality: "u".to_string(),
intent: Some("a".to_string()),
copyright: Some("C".to_string()),
description: Some("D".to_string()),
basename: "profile".to_string(),
cwd: "".to_string(),
fwa: Some("D65".to_string()),
illuminant: Some("D65".to_string()),
observer: Some("2015_2".to_string()),
input_viewing_cond: Some("pc".to_string()),
output_viewing_cond: Some("md".to_string()),
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec![
"-v", "-a", "x", "-q", "u", "-t", "a",
"-f", "D65", "-i", "D65", "-o", "2015_2",
"-c", "pc", "-d", "md", "-D", "D", "-C", "C", "profile"
]
);
}
#[test] #[test]
fn test_build_profcheck_args() { fn test_build_profcheck_args() {
let config = ProfcheckConfig { let config = ProfcheckConfig {
+34 -20
View File
@@ -253,17 +253,14 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati
Some(("CNIJIntent2", "4")) Some(("CNIJIntent2", "4"))
} else if output.contains("CNIJIntent") { } else if output.contains("CNIJIntent") {
Some(("CNIJIntent", "4")) Some(("CNIJIntent", "4"))
} else if output.contains("EPIJ_CCor") {
// Epson "Color Settings" option. The value 0 disables driver-side color correction.
Some(("EPIJ_CCor", "0"))
} else if output.contains("EPIJ_CMat") { } else if output.contains("EPIJ_CMat") {
// Epson "Color Settings" option. The value 3 is "Off (No Color // Epson "Color Settings" option. The value 3 is "Off (No Color
// Adjustment)" which disables driver-side color management. // Adjustment)" which disables driver-side color management.
Some(("EPIJ_CMat", "3")) Some(("EPIJ_CMat", "3"))
} else if output.contains("StpColorCorrection") {
Some(("StpColorCorrection", "Uncorrected"))
} else if output.contains("ColorCorrection") { } else if output.contains("ColorCorrection") {
Some(("ColorCorrection", "Uncorrected")) Some(("ColorCorrection", "Uncorrected"))
} else if output.contains("StpColorCorrection") {
Some(("StpColorCorrection", "Uncorrected"))
} else if output.contains("EpsonColorMode") { } else if output.contains("EpsonColorMode") {
Some(("EpsonColorMode", "Off")) Some(("EpsonColorMode", "Off"))
} else { } else {
@@ -518,21 +515,38 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\
let merged = merge_printer_info(&destinations, &statuses, default_dest); let merged = merge_printer_info(&destinations, &statuses, default_dest);
assert_eq!(merged.len(), 4); assert_eq!(merged.len(), 4);
assert_eq!(merged[0].name, "Epson-Stylus-SX420W".to_string()); assert_eq!(
assert_eq!(merged[0].status, "Idle".to_string()); merged[0],
assert!(merged[0].is_default); Printer {
name: "Epson-Stylus-SX420W".to_string(),
assert_eq!(merged[1].name, "HP_LaserJet".to_string()); status: "Idle".to_string(),
assert_eq!(merged[1].status, "Printing".to_string()); is_default: true,
assert!(!merged[1].is_default); }
);
assert_eq!(merged[2].name, "Virtual_PDF".to_string()); assert_eq!(
assert_eq!(merged[2].status, "Idle".to_string()); merged[1],
assert!(!merged[2].is_default); Printer {
name: "HP_LaserJet".to_string(),
assert_eq!(merged[3].name, "Zebra_Label".to_string()); status: "Printing".to_string(),
assert_eq!(merged[3].status, "Stopped".to_string()); is_default: false,
assert!(!merged[3].is_default); }
);
assert_eq!(
merged[2],
Printer {
name: "Virtual_PDF".to_string(),
status: "Idle".to_string(),
is_default: false,
}
);
assert_eq!(
merged[3],
Printer {
name: "Zebra_Label".to_string(),
status: "Stopped".to_string(),
is_default: false,
}
);
} }
#[test] #[test]
+1 -68
View File
@@ -60,48 +60,15 @@ pub struct ProfilingPreset {
pub no_randomize: Option<bool>, pub no_randomize: Option<bool>,
} }
fn default_delta_e_good_max() -> f64 { 2.0 } #[derive(Debug, Deserialize, Serialize, Default, Clone)]
fn default_delta_e_warning_max() -> f64 { 5.0 }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppSettings { pub struct AppSettings {
pub argyll_binary_dir: Option<String>, pub argyll_binary_dir: Option<String>,
pub default_instrument: Option<String>, pub default_instrument: Option<String>,
pub log_level: Option<String>, pub log_level: Option<String>,
#[serde(default = "default_delta_e_good_max")]
pub delta_e_good_max: f64,
#[serde(default = "default_delta_e_warning_max")]
pub delta_e_warning_max: f64,
#[serde(default)] #[serde(default)]
pub custom_presets: Vec<ProfilingPreset>, pub custom_presets: Vec<ProfilingPreset>,
} }
impl Default for AppSettings {
fn default() -> Self {
Self {
argyll_binary_dir: None,
default_instrument: None,
log_level: None,
delta_e_good_max: default_delta_e_good_max(),
delta_e_warning_max: default_delta_e_warning_max(),
custom_presets: Vec::new(),
}
}
}
impl AppSettings {
/// Validate thresholds and other cross-field invariants.
pub fn validate(&self) -> Result<(), String> {
if self.delta_e_good_max < 0.0 || self.delta_e_warning_max < 0.0 {
return Err("ΔE thresholds cannot be negative.".to_string());
}
if self.delta_e_good_max >= self.delta_e_warning_max {
return Err("Good ΔE threshold must be strictly less than the warning threshold.".to_string());
}
Ok(())
}
}
pub fn get_default_presets() -> Vec<ProfilingPreset> { pub fn get_default_presets() -> Vec<ProfilingPreset> {
vec![ vec![
ProfilingPreset { ProfilingPreset {
@@ -272,7 +239,6 @@ pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
#[tauri::command] #[tauri::command]
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
settings.validate()?;
let path = app.path().app_data_dir().unwrap(); let path = app.path().app_data_dir().unwrap();
fs::create_dir_all(&path).map_err(|e| e.to_string())?; fs::create_dir_all(&path).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&settings).unwrap(); let json = serde_json::to_string_pretty(&settings).unwrap();
@@ -414,39 +380,6 @@ mod tests {
assert_eq!(preset, imported); assert_eq!(preset, imported);
} }
#[test]
fn test_default_delta_e_thresholds() {
let settings = AppSettings::default();
assert_eq!(settings.delta_e_good_max, 2.0);
assert_eq!(settings.delta_e_warning_max, 5.0);
assert!(settings.delta_e_good_max < settings.delta_e_warning_max);
assert!(settings.validate().is_ok());
}
#[test]
fn test_delta_e_threshold_serialization_defaults() {
let json = "{}";
let settings: AppSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.delta_e_good_max, 2.0);
assert_eq!(settings.delta_e_warning_max, 5.0);
}
#[test]
fn test_delta_e_threshold_validation() {
let mut settings = AppSettings::default();
settings.delta_e_good_max = 5.0;
settings.delta_e_warning_max = 2.0;
assert!(settings.validate().is_err());
settings.delta_e_good_max = -1.0;
settings.delta_e_warning_max = 2.0;
assert!(settings.validate().is_err());
settings.delta_e_good_max = 1.0;
settings.delta_e_warning_max = 3.0;
assert!(settings.validate().is_ok());
}
#[test] #[test]
fn test_parse_log_level_filter() { fn test_parse_log_level_filter() {
assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error); assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error);
+28 -84
View File
@@ -35,9 +35,9 @@
<!-- Profiling Preset Selector --> <!-- Profiling Preset Selector -->
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);"> <div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
<div class="form-group-header"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label for="presetSelect" class="form-group-label-small">Profiling Preset</label> <label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
<div class="btn-row-sm"> <div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset"> <button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</button> </button>
@@ -131,10 +131,10 @@
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group has-tooltip">
<div class="form-group-header"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label>Project Directory &amp; Filename</label> <label style="margin: 0;">Project Directory &amp; Filename</label>
<div class="btn-row-sm"> <div style="display:flex; justify-content:flex-end;">
<button type="button" id="btn-import-dataset" class="secondary btn-sm">Import Dataset...</button> <button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button>
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button> <button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
</div> </div>
</div> </div>
@@ -407,8 +407,8 @@
<!-- Target Label & Metadata (printtarg -d) --> <!-- Target Label & Metadata (printtarg -d) -->
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);"> <div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<div class="form-group-header"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label class="form-group-title">Target Label &amp; Metadata (Optional)</label> <label style="margin: 0; font-weight: 600;">Target Label &amp; Metadata (Optional)</label>
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button> <button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
</div> </div>
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p> <p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
@@ -481,7 +481,7 @@
<select id="printerSelect"> <select id="printerSelect">
<option value="" disabled selected>Detecting printers...</option> <option value="" disabled selected>Detecting printers...</option>
</select> </select>
<button type="button" class="btn-icon-sq" id="btnRefreshPrinters" title="Refresh printer list">↻</button> <button type="button" class="btn-icon" id="btnRefreshPrinters" title="Refresh printer list">↻</button>
<button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button> <button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button>
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span> <span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
</div> </div>
@@ -521,7 +521,7 @@
</div> </div>
<div class="print-actions-row"> <div class="print-actions-row">
<button class="primary" id="btnPrintAll"> <button class="primary btn-print" id="btnPrintAll">
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span> <span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
<span class="btn-spinner hidden"></span> <span class="btn-spinner hidden"></span>
</button> </button>
@@ -550,8 +550,8 @@
<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">Communication Port</label> <label for="chartreadInstrumentSelect">Communication Port</label>
<div class="input-row-sm"> <div class="input-row" style="display: flex; gap: 8px; align-items: center;">
<select id="chartreadInstrumentSelect"> <select id="chartreadInstrumentSelect" style="flex: 1;">
<option value="">Auto (First available port)</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>
@@ -593,13 +593,13 @@
<!-- Multi-Pass Session & Averaging Controls --> <!-- Multi-Pass Session & Averaging Controls -->
<div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);"> <div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);">
<div class="form-group-header"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h3 class="form-group-title">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;">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> <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 class="btn-row"> <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" title="Complete measurement (Finish) before profiling">✓ 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>
@@ -648,7 +648,7 @@
</select> </select>
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group">
<label>OBA / FWA Compensation</label> <label>OBA / FWA Compensation</label>
<select id="colprofFwa"> <select id="colprofFwa">
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option> <option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
@@ -656,24 +656,14 @@
<option value="D65">D65 FWA Compensation</option> <option value="D65">D65 FWA Compensation</option>
<option value="custom">Custom Ambient Spectrum (.sp)...</option> <option value="custom">Custom Ambient Spectrum (.sp)...</option>
</select> </select>
<div class="tooltip-text">
Select how colprof handles Optical Brightening Agent (OBA) / Fluorescent Whitening Agent (FWA) compensation.<br>
• <strong>D50</strong>: Simulates ISO 3664 M1 viewing (D50, UV-cut) — recommended for modern OBA papers.<br>
• <strong>None</strong>: Native M0; no FWA compensation. Can look yellow under D50 if the paper contains OBA.<br>
• <strong>D65</strong>: Simulates outdoor D65 daylight; useful for outdoor display / packaging proofing.<br>
• <strong>Custom (.sp)</strong>: Provide an Argyll spectrum file describing the viewing booth illuminant.
</div>
</div> </div>
<div class="form-group hidden has-tooltip" id="colprofCustomSpRow"> <div class="form-group hidden" id="colprofCustomSpRow">
<label>Custom Spectrum File (.sp)</label> <label>Custom Spectrum File (.sp)</label>
<div class="input-row-sm"> <div style="display: flex; gap: 8px;">
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly> <input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;">
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button> <button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
</div> </div>
<div class="tooltip-text">
Path to an Argyll-format spectrum file (.sp) representing the ambient illuminant. This is passed to colprof via the -f option and overrides the built-in D50/D65 compensation.
</div>
</div> </div>
<details class="advanced-section-details"> <details class="advanced-section-details">
@@ -683,7 +673,7 @@
</summary> </summary>
<div class="advanced-content"> <div class="advanced-content">
<div class="advanced-grid-2col"> <div class="advanced-grid-2col">
<div class="form-group has-tooltip"> <div class="form-group">
<label>Standard Illuminant</label> <label>Standard Illuminant</label>
<select id="colprofIlluminant"> <select id="colprofIlluminant">
<option value="" selected>Default (D50)</option> <option value="" selected>Default (D50)</option>
@@ -695,11 +685,8 @@
<option value="F8">F8</option> <option value="F8">F8</option>
<option value="F10">F10</option> <option value="F10">F10</option>
</select> </select>
<div class="tooltip-text">
Illuminant used for the CIE colourimetric calculations in colprof (option -i). Leave at Default (D50) for normal print profiling.
</div>
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group">
<label>Standard Observer</label> <label>Standard Observer</label>
<select id="colprofObserver"> <select id="colprofObserver">
<option value="" selected>Default (1931 2°)</option> <option value="" selected>Default (1931 2°)</option>
@@ -707,11 +694,8 @@
<option value="2015_2">2015 2°</option> <option value="2015_2">2015 2°</option>
<option value="2015_10">2015 10°</option> <option value="2015_10">2015 10°</option>
</select> </select>
<div class="tooltip-text">
CIE standard observer used for the colourimetric calculations (option -o). The 1931 2° observer is the default for graphic-arts profiling.
</div>
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group">
<label>Input Viewing Condition</label> <label>Input Viewing Condition</label>
<select id="colprofInputViewCond"> <select id="colprofInputViewCond">
<option value="none" selected>None</option> <option value="none" selected>None</option>
@@ -720,11 +704,8 @@
<option value="pe">Print evaluation (CIE 116-1995)</option> <option value="pe">Print evaluation (CIE 116-1995)</option>
<option value="pm">Print evaluation (partial mid-tone adaptation)</option> <option value="pm">Print evaluation (partial mid-tone adaptation)</option>
</select> </select>
<div class="tooltip-text">
Viewing condition under which the print target was measured (option -c). This affects the adapted white point used in the profile lookup table.
</div>
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group">
<label>Output Viewing Condition</label> <label>Output Viewing Condition</label>
<select id="colprofOutputViewCond"> <select id="colprofOutputViewCond">
<option value="none" selected>None</option> <option value="none" selected>None</option>
@@ -735,9 +716,6 @@
<option value="jd">Projector in dark environment</option> <option value="jd">Projector in dark environment</option>
<option value="tv">Television/Film Studio</option> <option value="tv">Television/Film Studio</option>
</select> </select>
<div class="tooltip-text">
Viewing condition under which the profile is intended to be used (option -d). Useful for creating display / output-referred profiles.
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -745,7 +723,7 @@
</div> </div>
<div class="stage-actions"> <div class="stage-actions">
<button class="primary btn-lg" id="btnCreateProfile">Calculate Profile (.icc)</button> <button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
</div> </div>
<!-- Spinner and Progress Stage --> <!-- Spinner and Progress Stage -->
@@ -758,7 +736,7 @@
<div class="success-card hidden" id="colprofSuccessCard"> <div class="success-card hidden" id="colprofSuccessCard">
<h3>🎉 Profile Calculation Complete</h3> <h3>🎉 Profile Calculation Complete</h3>
<p id="colprofSuccessInfo"></p> <p id="colprofSuccessInfo"></p>
<button class="primary btn-lg" id="btnGoToVerify">Proceed to Verification →</button> <button class="primary" id="btnGoToVerify">Proceed to Verification →</button>
</div> </div>
<!-- Process Log --> <!-- Process Log -->
@@ -774,7 +752,7 @@
<p>Check the numerical accuracy of your profile against the original measurement data.</p> <p>Check the numerical accuracy of your profile against the original measurement data.</p>
<div class="stage-actions"> <div class="stage-actions">
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button> <button class="primary" id="btnVerify">Verify Profile Accuracy</button>
</div> </div>
<!-- Report Card --> <!-- Report Card -->
@@ -827,10 +805,6 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngProfileOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngProfileOpacity" min="0.1" max="1" step="0.05" value="0.88">
</div>
<div class="gamut-legend-item"> <div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-srgb"></span> <span class="gamut-swatch gamut-swatch-srgb"></span>
@@ -840,10 +814,6 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngSrgbOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngSrgbOpacity" min="0.1" max="1" step="0.05" value="0.55">
</div>
<div class="gamut-legend-item"> <div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-axes"></span> <span class="gamut-swatch gamut-swatch-axes"></span>
@@ -853,15 +823,6 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngAxisOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngAxisOpacity" min="0.1" max="1" step="0.05" value="0.8">
</div>
<div class="gamut-controls-actions">
<button type="button" id="btnGamutResetCamera" class="btn-sm" title="Reset camera to default view (R)">⟲ Reset View</button>
<span class="gamut-keyboard-hint">Press R to reset</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -887,23 +848,6 @@
<label for="default_instrument">Default Instrument Override</label> <label for="default_instrument">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1"> <input type="text" id="default_instrument" placeholder="e.g. i1">
</div> </div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Stage 3 ΔE₀₀ Traffic-Light Thresholds</label>
<div class="input-row" style="margin-top: 8px; align-items: flex-start;">
<div>
<label for="deltaEGoodMax" class="sub-label">Good / Warning boundary</label>
<input type="number" id="deltaEGoodMax" min="0" step="0.1" value="2.0">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE below this value is shown as green (Good).</small>
</div>
<div>
<label for="deltaEWarningMax" class="sub-label">Warning / Bad boundary</label>
<input type="number" id="deltaEWarningMax" min="0" step="0.1" value="5.0">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE above this value is shown as red (Bad).</small>
</div>
</div>
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
</div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);"> <div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Diagnostics &amp; Logging</label> <label style="font-weight: 600;">Diagnostics &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;"> <div class="input-row" style="margin-top: 8px;">
@@ -918,7 +862,7 @@
</select> </select>
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small> <small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small>
</div> </div>
<div class="btn-row wrap"> <div style="display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap;">
<button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button> <button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button>
<button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button> <button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button>
<button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button> <button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button>
+4 -110
View File
@@ -204,11 +204,7 @@ function _line(from, to, material) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** // Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] }
* Parse an Argyll `.gam` text file into vertex and face arrays.
* @param {string} text - Raw contents of the .gam file.
* @returns {{ vertices: number[][], faces: number[][] }}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export function parseGamutFile(text) { export function parseGamutFile(text) {
const lines = text.split('\n'); const lines = text.split('\n');
@@ -387,77 +383,7 @@ function _renderProfileGamut(text, previousMesh) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Camera & view controls // Public: load bundled sRGB reference gamut
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reset the camera to its default home position and orientation.
* Also resets orbit controls target to the centre of the CIELAB volume.
*/
export function resetCamera() {
if (!camera || !controls) return;
camera.position.set(180, 120, 180);
camera.lookAt(0, 50, 0);
controls.target.set(0, 50, 0);
controls.update();
}
/**
* Set the opacity of the rendered profile gamut surface (0–1).
* @param {number} opacity
*/
export function setProfileOpacity(opacity) {
if (currentProfileMesh && currentProfileMesh.material) {
currentProfileMesh.material.opacity = Math.max(0, Math.min(1, opacity));
currentProfileMesh.material.transparent = opacity < 1;
currentProfileMesh.material.needsUpdate = true;
}
}
/**
* Set the opacity of the sRGB reference wireframe and fill (0–1).
* @param {number} opacity
*/
export function setSrgbReferenceOpacity(opacity) {
if (sRgbGroup) {
sRgbGroup.traverse((child) => {
if (child.material) {
child.material.opacity = child.material.opacity >= 0.5
? Math.max(0.05, Math.min(1, opacity))
: Math.max(0.02, Math.min(0.2, opacity * 0.2));
}
});
}
}
/**
* Set the opacity of the CIELAB axis scaffold (0–1).
* @param {number} opacity
*/
export function setAxisOpacity(opacity) {
if (axisScaffoldGroup) {
axisScaffoldGroup.traverse((child) => {
if (child.material) {
child.material.opacity = Math.max(0, Math.min(1, opacity));
}
});
}
}
/**
* Handle keyboard shortcuts for the gamut viewer.
* @param {KeyboardEvent} e
*/
function _onKeyDown(e) {
if (e.key === 'r' || e.key === 'R') {
resetCamera();
}
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* Load and render the bundled sRGB reference gamut.
* @returns {Promise<void>}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function loadSrgbReferenceGamut() { export async function loadSrgbReferenceGamut() {
try { try {
@@ -472,11 +398,7 @@ export async function loadSrgbReferenceGamut() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** // Public: load and render a profile gamut from a .gam file path
* Load and render a profile gamut from a `.gam` file path.
* @param {string} gamFilePath - Absolute path to the gamut file.
* @returns {Promise<THREE.Mesh|null>}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function loadGamutMesh(gamFilePath) { export async function loadGamutMesh(gamFilePath) {
try { try {
@@ -506,7 +428,7 @@ export function toggleAxes(visible) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Wire legend toggle checkboxes, opacity sliders, and reset button // Wire legend toggle checkboxes to the toggle functions
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function _wireToggles() { function _wireToggles() {
const bindings = [ const bindings = [
@@ -518,32 +440,4 @@ function _wireToggles() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener('change', (e) => fn(e.target.checked)); if (el) el.addEventListener('change', (e) => fn(e.target.checked));
} }
const resetBtn = document.getElementById('btnGamutResetCamera');
if (resetBtn) resetBtn.addEventListener('click', resetCamera);
const profileOpacity = document.getElementById('rngProfileOpacity');
if (profileOpacity) {
profileOpacity.addEventListener('input', (e) => setProfileOpacity(parseFloat(e.target.value)));
}
const srgbOpacity = document.getElementById('rngSrgbOpacity');
if (srgbOpacity) {
srgbOpacity.addEventListener('input', (e) => setSrgbReferenceOpacity(parseFloat(e.target.value)));
}
const axisOpacity = document.getElementById('rngAxisOpacity');
if (axisOpacity) {
axisOpacity.addEventListener('input', (e) => setAxisOpacity(parseFloat(e.target.value)));
}
// Only listen for 'R' reset when the viewer tab is active.
const stage5 = document.getElementById('stage-5');
if (stage5) {
stage5.addEventListener('keydown', (e) => {
if ((e.key === 'r' || e.key === 'R') && !stage5.classList.contains('hidden')) {
resetCamera();
}
});
}
} }
-43
View File
@@ -1,13 +1,6 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
import { logger } from './logger.js'; import { logger } from './logger.js';
function getInputValueAsFloat(id, fallback) {
const el = document.getElementById(id);
if (!el) return fallback;
const v = parseFloat(el.value);
return Number.isNaN(v) ? fallback : v;
}
export async function initSettings() { export async function initSettings() {
const dialog = document.getElementById('settingsDialog'); const dialog = document.getElementById('settingsDialog');
const openBtn = document.getElementById('openSettingsBtn'); const openBtn = document.getElementById('openSettingsBtn');
@@ -18,9 +11,6 @@ export async function initSettings() {
const btnCopyLogPath = document.getElementById('btnCopyLogPath'); const btnCopyLogPath = document.getElementById('btnCopyLogPath');
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt'); const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
const logPathDisplay = document.getElementById('logPathDisplay'); const logPathDisplay = document.getElementById('logPathDisplay');
const deltaEGoodMax = document.getElementById('deltaEGoodMax');
const deltaEWarningMax = document.getElementById('deltaEWarningMax');
const deltaEThresholdError = document.getElementById('deltaEThresholdError');
if (!dialog || !openBtn) return; if (!dialog || !openBtn) return;
@@ -34,24 +24,6 @@ export async function initSettings() {
} }
} }
function validateDeltaEThresholds() {
if (!deltaEGoodMax || !deltaEWarningMax) return true;
const good = parseFloat(deltaEGoodMax.value);
const warn = parseFloat(deltaEWarningMax.value);
const valid = !Number.isNaN(good) && !Number.isNaN(warn) && good < warn && good >= 0 && warn >= 0;
if (deltaEThresholdError) {
deltaEThresholdError.style.display = valid ? 'none' : 'block';
}
return valid;
}
if (deltaEGoodMax) {
deltaEGoodMax.addEventListener('input', validateDeltaEThresholds);
}
if (deltaEWarningMax) {
deltaEWarningMax.addEventListener('input', validateDeltaEThresholds);
}
openBtn.addEventListener('click', async () => { openBtn.addEventListener('click', async () => {
try { try {
const settings = await invoke('load_settings'); const settings = await invoke('load_settings');
@@ -60,13 +32,6 @@ export async function initSettings() {
if (logLevelSelect && settings.log_level) { if (logLevelSelect && settings.log_level) {
logLevelSelect.value = settings.log_level; logLevelSelect.value = settings.log_level;
} }
if (deltaEGoodMax) {
deltaEGoodMax.value = Number(settings.delta_e_good_max ?? 2.0).toFixed(1);
}
if (deltaEWarningMax) {
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
}
validateDeltaEThresholds();
await refreshLogPath(); await refreshLogPath();
dialog.showModal(); dialog.showModal();
} catch (e) { } catch (e) {
@@ -134,26 +99,18 @@ export async function initSettings() {
if (saveBtn) { if (saveBtn) {
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
try { try {
if (!validateDeltaEThresholds()) {
throw new Error('Invalid ΔE thresholds');
}
const currentSettings = await invoke('load_settings').catch(() => ({})); const currentSettings = await invoke('load_settings').catch(() => ({}));
const settings = { const settings = {
...currentSettings, ...currentSettings,
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null, argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
default_instrument: document.getElementById('default_instrument').value.trim() || null, default_instrument: document.getElementById('default_instrument').value.trim() || null,
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'), log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
}; };
await invoke('save_settings', { settings }); await invoke('save_settings', { settings });
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings'); logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
// Notify other UI modules (e.g. swatch grid) that settings changed.
window.dispatchEvent(new CustomEvent('settings-saved', { detail: settings }));
dialog.close(); dialog.close();
} catch (e) { } catch (e) {
logger.error(`Failed to save settings: ${e}`, 'Settings'); logger.error(`Failed to save settings: ${e}`, 'Settings');
alert(`Failed to save settings: ${e}`);
} }
}); });
} }
+14 -87
View File
@@ -1,76 +1,10 @@
import { computeDeltaE00 } from './delta_e.js'; import { computeDeltaE00 } from './delta_e.js';
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js'; import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
const { listen, emit } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
const { invoke } = window.__TAURI__.core;
let unlistenJsonRow = null; let unlistenJsonRow = null;
// Cached ΔE thresholds; refreshed from settings on load and when settings change.
let deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
/**
* Load the ΔE traffic-light thresholds from persisted app settings.
* Falls back to 2.0 / 5.0 if settings are missing or malformed.
*/
async function refreshDeltaEThresholds() {
try {
const s = await invoke('load_settings');
deltaEThresholds = {
goodMax: s.delta_e_good_max ?? 2.0,
warnMax: s.delta_e_warning_max ?? 5.0,
};
// Enforce the invariant at runtime in case a user hand-edited settings.json.
if (deltaEThresholds.goodMax > deltaEThresholds.warnMax) {
console.warn('ΔE thresholds invalid (good > warning); resetting to defaults.');
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
}
} catch (err) {
console.warn('Could not load ΔE thresholds, using defaults:', err);
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
}
}
/**
* Classify a CIEDE2000 value using the current thresholds.
* @param {number} deltaE
* @returns {{ label: string, cssClass: string }}
*/
function classifyDeltaE(deltaE) {
if (deltaE < deltaEThresholds.goodMax) {
return { label: "Good", cssClass: "de-good" };
} else if (deltaE < deltaEThresholds.warnMax) {
return { label: "Warning", cssClass: "de-warning" };
} else {
return { label: "Bad", cssClass: "de-bad" };
}
}
/**
* Re-classify all already-rendered swatches using the current thresholds.
* Call this after the user changes thresholds in Settings.
*/
function reclassifySwatches() {
document.querySelectorAll('.swatch-patch[data-delta-e]').forEach((patchEl) => {
const deltaE = parseFloat(patchEl.dataset.deltaE);
if (Number.isNaN(deltaE)) return;
patchEl.classList.remove('de-good', 'de-warning', 'de-bad');
const { cssClass, label } = classifyDeltaE(deltaE);
patchEl.classList.add(cssClass);
// Update the classification word in the title without re-parsing everything.
if (patchEl.title) {
patchEl.title = patchEl.title.replace(/\s*\([^)]*\)$/, '') + ` (${label})`;
}
});
}
// Listen for the custom event emitted by settings.js after a successful save.
window.addEventListener('settings-saved', () => {
refreshDeltaEThresholds().then(reclassifySwatches);
});
/** /**
* Start listening for row events from a chartread process. * Start listening for row events from a chartread process.
* @param {string} processId - The process ID (e.g. "chartread_my_profile") * @param {string} processId - The process ID (e.g. "chartread_my_profile")
@@ -82,9 +16,6 @@ export async function startSwatchListener(processId, onRowComplete) {
const progressText = document.getElementById("readProgressText"); const progressText = document.getElementById("readProgressText");
const statsPanel = document.getElementById("readStats"); const statsPanel = document.getElementById("readStats");
// Load thresholds at listener start
await refreshDeltaEThresholds();
// Clear previous state // Clear previous state
grid.innerHTML = ""; grid.innerHTML = "";
let totalPatches = 0; let totalPatches = 0;
@@ -120,8 +51,6 @@ export async function startSwatchListener(processId, onRowComplete) {
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`; if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
// Create row container // Create row container
// Row and patch order from chartread (A->Z, 1->N) is rendered
// left-to-right / top-to-bottom to match the printtarg output.
const rowEl = document.createElement("div"); const rowEl = document.createElement("div");
rowEl.className = "swatch-row"; rowEl.className = "swatch-row";
@@ -134,10 +63,6 @@ export async function startSwatchListener(processId, onRowComplete) {
rowPatches.className = "swatch-row-patches"; rowPatches.className = "swatch-row-patches";
for (const patch of data.patches) { for (const patch of data.patches) {
// Argyll marks spacer/boundary patches with is_pad. Some printtarg
// layouts also flag white reference patches (-e white steps) as pads,
// but those carry valid expected or measured device data. Only skip
// pads that have no measurement and no non-zero device coordinates.
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue; if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
const patchEl = document.createElement("div"); const patchEl = document.createElement("div");
@@ -159,21 +84,20 @@ export async function startSwatchListener(processId, onRowComplete) {
const swatch = document.createElement("div"); const swatch = document.createElement("div");
swatch.className = "swatch-color"; swatch.className = "swatch-color";
if (measuredCss) { if (measuredCss) {
swatch.style.background = `linear-gradient(135deg, ${intendedCss} 50%, ${measuredCss} 50%)`; swatch.style.background = `linear-gradient(135deg, ${intendedCss} 50%, ${measuredCss} 50%)`;
} else { } else {
swatch.style.backgroundColor = intendedCss; swatch.style.backgroundColor = intendedCss;
} }
patchEl.appendChild(swatch); patchEl.appendChild(swatch);
// Build a structured tooltip for the patch
let titleStr = `${patch.loc} (ID: ${patch.id})`; let titleStr = `${patch.loc} (ID: ${patch.id})`;
if (patch.expected && patch.expected.Lab) { if (patch.expected && patch.expected.Lab) {
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`; titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
} else if (patch.device) { } else if (patch.device) {
titleStr += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`; titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
} }
if (patch.measured && patch.measured.Lab) { if (patch.measured && patch.measured.Lab) {
@@ -183,19 +107,22 @@ export async function startSwatchListener(processId, onRowComplete) {
// Compute and display ΔE₀₀ if both expected and measured Lab are present // Compute and display ΔE₀₀ if both expected and measured Lab are present
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) { if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab); const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
const { label, cssClass } = classifyDeltaE(deltaE);
// Persist the raw ΔE on the element so re-classification works after
// the user changes thresholds in Settings.
patchEl.dataset.deltaE = deltaE.toFixed(4);
const deLabel = document.createElement("div"); const deLabel = document.createElement("div");
deLabel.className = "swatch-de"; deLabel.className = "swatch-de";
deLabel.textContent = deltaE.toFixed(1); deLabel.textContent = deltaE.toFixed(1);
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`;
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)} (${label})`; // Traffic light classification
if (deltaE < 2) {
patchEl.classList.add("de-good"); // Green
} else if (deltaE < 5) {
patchEl.classList.add("de-warning"); // Amber
} else {
patchEl.classList.add("de-bad"); // Red
}
patchEl.classList.add(cssClass);
patchEl.appendChild(deLabel); patchEl.appendChild(deLabel);
// Accumulate stats // Accumulate stats
-16
View File
@@ -1,16 +0,0 @@
// Manual smoke tests for swatch_grid.js
// Run in a browser/devtools console after the app has loaded:
// import('./swatch_grid.test.js').then(m => m.runAll())
export function runAll() {
console.group('swatch_grid smoke tests');
testClassifyDeltaE();
console.groupEnd();
}
function testClassifyDeltaE() {
// classifyDeltaE is currently internal to swatch_grid.js, but we can
// validate the visual classification by exercising the colour thresholds.
// This is a placeholder for a future unit-test harness.
console.log('testClassifyDeltaE: no direct export; validated by manual inspection');
}
+22 -139
View File
@@ -5,11 +5,6 @@
--text-color: #d4d4d4; --text-color: #d4d4d4;
--accent-color: #007acc; --accent-color: #007acc;
--border-color: #333333; --border-color: #333333;
--btn-height-sm: 28px;
--btn-height-md: 36px;
--btn-height-lg: 40px;
--btn-radius-sm: 4px;
--btn-radius-md: 6px;
} }
body { body {
@@ -132,27 +127,25 @@ button.primary {
background: linear-gradient(135deg, var(--accent-color), #2d73a8); background: linear-gradient(135deg, var(--accent-color), #2d73a8);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--btn-radius-md); border-radius: 6px;
padding: 0 18px; padding: 0 18px;
height: var(--btn-height-md); height: 36px;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s, transform 0.1s; transition: opacity 0.2s, transform 0.1s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
} }
button.primary:hover { button.primary:hover {
opacity: 0.9; opacity: 0.9;
} }
/* Button sizing system — use CSS variables for consistency across the UI */ /* Button sizing system */
.btn-sm { height: var(--btn-height-sm); padding: 0 10px; font-size: 0.78rem; border-radius: var(--btn-radius-sm); box-sizing: border-box; } .btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; }
.btn-md { height: var(--btn-height-md); padding: 0 16px; font-size: 0.875rem; border-radius: var(--btn-radius-md); box-sizing: border-box; } .btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; }
.btn-lg { height: var(--btn-height-lg); padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: var(--btn-radius-md); box-sizing: border-box; } .btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; }
button.primary.btn-lg { height: var(--btn-height-lg); } .btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon-sq { width: var(--btn-height-md); height: var(--btn-height-md); min-width: var(--btn-height-md); padding: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
/* Action containers */ /* Action containers */
.stage-actions { .stage-actions {
@@ -170,63 +163,10 @@ button.primary.btn-lg { height: var(--btn-height-lg); }
justify-content: flex-end; justify-content: flex-end;
} }
/* Utility for a horizontal row of buttons (replaces inline style="display:flex;..." on parent divs) */
.btn-row {
display: flex;
align-items: center;
gap: 8px;
}
.btn-row-end {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.btn-row-sm {
display: flex;
align-items: center;
gap: 6px;
}
.btn-row.wrap,
.btn-row-sm.wrap {
flex-wrap: wrap;
}
.form-group { .form-group {
margin: 14px 0; margin: 14px 0;
} }
.form-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.form-group-header label {
margin: 0;
}
.form-group-label-small {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
font-weight: 600;
}
.form-group-title {
font-weight: 600;
}
.form-group-header h3 {
margin: 0;
font-size: 1rem;
}
.form-group:first-child { .form-group:first-child {
margin-top: 0; margin-top: 0;
} }
@@ -314,17 +254,6 @@ select option:checked {
align-items: stretch; align-items: stretch;
} }
.input-row-sm {
display: flex;
gap: 8px;
align-items: center;
}
.input-row-sm > input,
.input-row-sm > select {
flex: 1;
}
.input-row input { .input-row input {
flex: 1; flex: 1;
} }
@@ -355,10 +284,9 @@ button.secondary {
color: white; color: white;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 0 16px; padding: 0 16px;
height: var(--btn-height-md); height: 36px;
border-radius: var(--btn-radius-sm); border-radius: 4px;
cursor: pointer; cursor: pointer;
box-sizing: border-box;
} }
button.secondary:hover { button.secondary:hover {
@@ -561,7 +489,6 @@ button:disabled {
.chartread-actions { .chartread-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -570,10 +497,9 @@ button.danger {
background-color: #c62828; background-color: #c62828;
color: white; color: white;
border: none; border: none;
padding: 0 16px; padding: 10px 20px;
height: var(--btn-height-md); font-size: 1rem;
font-size: 0.875rem; border-radius: 4px;
border-radius: var(--btn-radius-sm);
cursor: pointer; cursor: pointer;
} }
@@ -853,12 +779,12 @@ button.danger:hover {
font-size: 1.2rem; font-size: 1.2rem;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
border-radius: var(--btn-radius-sm); border-radius: 4px;
color: #d1d5db; color: #d1d5db;
transition: color 0.15s ease, background-color 0.15s ease; transition: color 0.15s ease, background-color 0.15s ease;
width: var(--btn-height-sm); width: 28px;
height: var(--btn-height-sm); height: 28px;
min-width: var(--btn-height-sm); min-width: 28px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1047,9 +973,8 @@ button.danger:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
padding: 0 12px; padding: 6px 12px;
height: var(--btn-height-md); border-radius: 4px;
border-radius: var(--btn-radius-sm);
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -1059,7 +984,6 @@ button.danger:hover {
gap: 6px; gap: 6px;
transition: all 0.2s ease; transition: all 0.2s ease;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.btn-properties:hover { .btn-properties:hover {
@@ -1118,24 +1042,20 @@ button.danger:hover {
font-weight: 600; font-weight: 600;
} }
/* Legacy alias: .btn-icon should not be used for new buttons; prefer .btn-icon-sq */
.btn-icon { .btn-icon {
width: var(--btn-height-md);
height: var(--btn-height-md);
min-width: var(--btn-height-md);
padding: 0;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
border-radius: var(--btn-radius-sm); width: 34px;
display: inline-flex; height: 34px;
border-radius: 4px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.btn-icon:hover { .btn-icon:hover {
@@ -1530,43 +1450,6 @@ button.danger:hover {
margin-bottom: 0; margin-bottom: 0;
} }
.gamut-legend-item.gamut-slider-item {
margin: -2px 0 8px 27px;
gap: 6px;
}
.gamut-slider-label {
font-size: 0.7rem;
opacity: 0.65;
min-width: 46px;
}
.gamut-slider-item input[type="range"] {
flex: 1;
margin: 0;
height: 4px;
accent-color: var(--accent-color, #3b82f6);
}
.gamut-controls-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
display: flex;
flex-direction: column;
gap: 6px;
}
.gamut-controls-actions button {
width: 100%;
}
.gamut-keyboard-hint {
font-size: 0.68rem;
opacity: 0.5;
text-align: center;
}
.gamut-legend-label { .gamut-legend-label {
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;