Compare commits
11
Commits
0603faca59
..
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2fdfafaa7 | ||
|
|
b60d0c3bce | ||
|
|
8a0065274b | ||
|
|
ab2f6e562e | ||
|
|
3026e7081c | ||
|
|
84df0b7a7d | ||
|
|
9f8b2c8567 | ||
|
|
2c97e98dc3 | ||
|
|
d50c3c4f3c | ||
|
|
9a365acd2b | ||
|
|
47a5373a8e |
@@ -1,5 +1,34 @@
|
|||||||
# ICCery Agent Notes
|
# ICCery Agent Notes
|
||||||
|
|
||||||
|
## Stage 5 Verification / Profcheck
|
||||||
|
|
||||||
|
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
|
||||||
|
- If no delta-E values can be detected, the report cards show 0.00 and a warning is appended to the process log.
|
||||||
|
- The `.gam` file for the 3D viewer is parsed using `parseGamutFile`, which supports multiple `BEGIN_DATA` blocks (some Argyll files use a separate block per surface section), inline `#` comments, and out-of-bounds vertex warnings.
|
||||||
|
- Manual parser tests live in `src/js/gamut_viewer.test.js`.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
The frontend uses a tiered button sizing system defined in `src/styles/main.css`. Prefer these utility classes over inline `style` attributes.
|
||||||
@@ -18,6 +47,12 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css`
|
|||||||
- 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`.
|
- 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.
|
- 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)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
- 🪄 **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 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 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.
|
||||||
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, OBA/FWA compensation, illuminant/observer selection, viewing-condition transforms, custom ambient spectrum support, 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, 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, per-vertex true-colour rendering, layer opacity controls, camera reset, keyboard shortcut, touch controls, bundled sRGB reference wireframe comparison, and robust parsing of both JSON and legacy profcheck output formats.
|
||||||
- 📋 **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.
|
||||||
|
|||||||
+6
-6
@@ -101,12 +101,12 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
|
|
||||||
### Milestone 13 — UI/UX & Workflow Polish
|
### Milestone 13 — UI/UX & Workflow Polish
|
||||||
|
|
||||||
- [ ] **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] **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.
|
||||||
- [ ] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
|
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
|
||||||
- [ ] **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] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
|
||||||
- [ ] **Stage 4 OBA/FWA Tooltips & Tests (#176)**: Contextual help for OBA/FWA, illuminant, observer, and viewing-condition controls; additional `colprof` arg builder tests.
|
- [x] **Stage 4 OBA/FWA Tooltips & Tests (#176)**: Contextual help for OBA/FWA, illuminant, observer, and viewing-condition controls; additional `colprof` arg builder tests.
|
||||||
- [ ] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
- [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.
|
- [x] **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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1783,6 +1783,33 @@ 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 {
|
||||||
|
|||||||
@@ -60,15 +60,48 @@ pub struct ProfilingPreset {
|
|||||||
pub no_randomize: Option<bool>,
|
pub no_randomize: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
|
fn default_delta_e_good_max() -> f64 { 2.0 }
|
||||||
|
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 {
|
||||||
@@ -239,6 +272,7 @@ 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();
|
||||||
@@ -380,6 +414,39 @@ 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);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ICCery",
|
"productName": "ICCery",
|
||||||
"version": "0.7.4",
|
"version": "0.8.0",
|
||||||
"identifier": "com.gronod.iccery",
|
"identifier": "com.gronod.iccery",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|||||||
+62
-6
@@ -648,7 +648,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group has-tooltip">
|
||||||
<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,14 +656,24 @@
|
|||||||
<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" id="colprofCustomSpRow">
|
<div class="form-group hidden has-tooltip" id="colprofCustomSpRow">
|
||||||
<label>Custom Spectrum File (.sp)</label>
|
<label>Custom Spectrum File (.sp)</label>
|
||||||
<div class="input-row-sm">
|
<div class="input-row-sm">
|
||||||
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly>
|
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly>
|
||||||
<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">
|
||||||
@@ -673,7 +683,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">
|
<div class="form-group has-tooltip">
|
||||||
<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>
|
||||||
@@ -685,8 +695,11 @@
|
|||||||
<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">
|
<div class="form-group has-tooltip">
|
||||||
<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>
|
||||||
@@ -694,8 +707,11 @@
|
|||||||
<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">
|
<div class="form-group has-tooltip">
|
||||||
<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>
|
||||||
@@ -704,8 +720,11 @@
|
|||||||
<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">
|
<div class="form-group has-tooltip">
|
||||||
<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>
|
||||||
@@ -716,6 +735,9 @@
|
|||||||
<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>
|
||||||
@@ -805,6 +827,10 @@
|
|||||||
<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>
|
||||||
@@ -814,6 +840,10 @@
|
|||||||
<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>
|
||||||
@@ -823,6 +853,15 @@
|
|||||||
<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>
|
||||||
@@ -848,6 +887,23 @@
|
|||||||
<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 & Logging</label>
|
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||||
<div class="input-row" style="margin-top: 8px;">
|
<div class="input-row" style="margin-top: 8px;">
|
||||||
|
|||||||
+174
-16
@@ -204,30 +204,80 @@ 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.
|
||||||
|
*
|
||||||
|
* Argyll `.gam` files contain a header followed by one or more `BEGIN_DATA`
|
||||||
|
* ... `END_DATA` blocks. The first data block is a vertex list
|
||||||
|
* (index L a b); subsequent blocks contain triangle face indices (v0 v1 v2).
|
||||||
|
* Blank lines and hash `#` comments outside data blocks are ignored.
|
||||||
|
*
|
||||||
|
* @param {string} text - Raw contents of the .gam file.
|
||||||
|
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
|
||||||
|
*/
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export function parseGamutFile(text) {
|
export function parseGamutFile(text) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const vertices = [];
|
const vertices = [];
|
||||||
const faces = [];
|
const faces = [];
|
||||||
|
const warnings = [];
|
||||||
let dataStarted = false;
|
let dataStarted = false;
|
||||||
let dataBlock = 0; // 1 = vertices section, 2 = faces section
|
let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
|
||||||
|
|
||||||
for (const line of lines) {
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||||
const trimmed = line.trim();
|
const raw = lines[lineIdx];
|
||||||
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
|
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
|
||||||
if (trimmed === 'END_DATA') { dataStarted = false; continue; }
|
if (trimmed === '') continue;
|
||||||
|
|
||||||
|
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
|
||||||
|
dataBlock++;
|
||||||
|
dataStarted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.toUpperCase() === 'END_DATA') {
|
||||||
|
dataStarted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!dataStarted) continue;
|
if (!dataStarted) continue;
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/).map(Number);
|
const parts = trimmed.split(/\s+/).map(Number);
|
||||||
if (dataBlock === 1 && parts.length >= 4) {
|
const allNumeric = parts.every(n => !Number.isNaN(n));
|
||||||
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
|
if (!allNumeric) {
|
||||||
} else if (dataBlock === 2 && parts.length >= 3) {
|
warnings.push(`Skipping non-numeric data at line ${lineIdx + 1}`);
|
||||||
faces.push([parts[0], parts[1], parts[2]]);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataBlock === 1) {
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
// Vertex format: index L a b (index is usually ignored)
|
||||||
|
const [_, L, a, b] = parts;
|
||||||
|
if (L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128) {
|
||||||
|
warnings.push(`Vertex at line ${lineIdx + 1} is outside plausible CIELAB bounds: L=${L}, a=${a}, b=${b}`);
|
||||||
|
}
|
||||||
|
vertices.push([L, a, b]);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Vertex data at line ${lineIdx + 1} has only ${parts.length} values`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Face data can appear in multiple blocks (some .gam files use a
|
||||||
|
// separate DATA block per surface type or per convex-hull section).
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
faces.push([parts[0], parts[1], parts[2]]);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Face data at line ${lineIdx + 1} has only ${parts.length} values`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { vertices, faces };
|
if (vertices.length > 0 && faces.length === 0) {
|
||||||
|
warnings.push(`Parsed ${vertices.length} vertices but no faces; will compute convex hull on the fly.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataBlock === 0) {
|
||||||
|
warnings.push('No BEGIN_DATA blocks found; file may be empty or not a valid Argyll .gam file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vertices, faces, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -304,7 +354,10 @@ function _renderSrgbReference(text, previousGroup) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vertices, faces } = parseGamutFile(text);
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||||
|
}
|
||||||
const built = _buildGeometry(vertices, faces);
|
const built = _buildGeometry(vertices, faces);
|
||||||
if (!built) return null;
|
if (!built) return null;
|
||||||
|
|
||||||
@@ -348,7 +401,10 @@ function _renderProfileGamut(text, previousMesh) {
|
|||||||
if (previousMesh.material) previousMesh.material.dispose();
|
if (previousMesh.material) previousMesh.material.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vertices, faces } = parseGamutFile(text);
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||||
|
}
|
||||||
const built = _buildGeometry(vertices, faces);
|
const built = _buildGeometry(vertices, faces);
|
||||||
if (!built) return null;
|
if (!built) return null;
|
||||||
|
|
||||||
@@ -383,7 +439,77 @@ function _renderProfileGamut(text, previousMesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Public: load bundled sRGB reference gamut
|
// Camera & view controls
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
@@ -398,7 +524,11 @@ 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 {
|
||||||
@@ -428,7 +558,7 @@ export function toggleAxes(visible) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Wire legend toggle checkboxes to the toggle functions
|
// Wire legend toggle checkboxes, opacity sliders, and reset button
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
function _wireToggles() {
|
function _wireToggles() {
|
||||||
const bindings = [
|
const bindings = [
|
||||||
@@ -440,4 +570,32 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
|
||||||
|
// Run in a browser/devtools console after the app has loaded:
|
||||||
|
// import('./gamut_viewer.test.js').then(m => m.runAll())
|
||||||
|
|
||||||
|
import { parseGamutFile } from './gamut_viewer.js';
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('gamut/profcheck parser tests');
|
||||||
|
testParseGamutBasic();
|
||||||
|
testParseGamutDualTable();
|
||||||
|
testParseGamutWithComments();
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual, expected, message) {
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
console.log('PASS:', message);
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutBasic() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'basic gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'basic gamut face count');
|
||||||
|
assertEqual(warnings.length, 0, 'basic gamut no warnings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutDualTable() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'dual-table gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'dual-table gamut face count');
|
||||||
|
assertEqual(warnings.length, 0, 'dual-table gamut no warnings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutWithComments() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
# this is a comment
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
# inline comment
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'commented gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'commented gamut face count');
|
||||||
|
}
|
||||||
+76
-13
@@ -98,7 +98,14 @@ export function initProfcheck() {
|
|||||||
|
|
||||||
// Ensure gamut mesh is loaded into 3D viewer
|
// Ensure gamut mesh is loaded into 3D viewer
|
||||||
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
||||||
loadGamutMesh(gamFilePath, 0x3b82f6);
|
try {
|
||||||
|
const result = await loadGamutMesh(gamFilePath);
|
||||||
|
if (!result) {
|
||||||
|
logPre.textContent += `\n[WARN] Could not render 3D gamut mesh from ${gamFilePath}.\n`;
|
||||||
|
}
|
||||||
|
} catch (gamErr) {
|
||||||
|
logPre.textContent += `\n[WARN] 3D gamut render failed: ${gamErr}\n`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
||||||
}
|
}
|
||||||
@@ -113,33 +120,89 @@ export function initProfcheck() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse profcheck output for Average, Peak, and RMS delta-E values.
|
||||||
|
* Supports both Argyll's JSON-style summary and plain-text legacy output.
|
||||||
|
* @param {string} stdout - Full profcheck stdout.
|
||||||
|
*/
|
||||||
function parseAndRenderReport(stdout) {
|
function parseAndRenderReport(stdout) {
|
||||||
reportCard.classList.remove("hidden");
|
reportCard.classList.remove("hidden");
|
||||||
|
|
||||||
let avgDe = 0.0;
|
let avgDe = 0.0;
|
||||||
let maxDe = 0.0;
|
let maxDe = 0.0;
|
||||||
let rmsDe = 0.0;
|
let rmsDe = 0.0;
|
||||||
|
let parserWarnings = [];
|
||||||
|
|
||||||
// Check if JSON output is present
|
// Argyll's JSON output can appear either as a compact object on a single
|
||||||
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
|
// line or embedded inside larger text. Try to find and parse the LAST valid
|
||||||
if (jsonMatch) {
|
// JSON object in the output, which is most likely the summary.
|
||||||
|
const jsonObjects = [];
|
||||||
|
const re = /\{[\s\S]*?\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(stdout)) !== null) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(jsonMatch[0]);
|
const parsed = JSON.parse(m[0]);
|
||||||
avgDe = json.avg_de || 0;
|
if (typeof parsed === 'object' && parsed !== null && ('avg_de' in parsed || 'peak_de' in parsed || 'rms_de' in parsed)) {
|
||||||
maxDe = json.max_de || json.peak_de || 0;
|
jsonObjects.push(parsed);
|
||||||
rmsDe = json.rms_de || 0;
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("JSON parse error:", e);
|
// Not a valid JSON object, ignore.
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonObjects.length > 0) {
|
||||||
|
const json = jsonObjects[jsonObjects.length - 1];
|
||||||
|
avgDe = typeof json.avg_de === 'number' ? json.avg_de : 0;
|
||||||
|
maxDe = typeof json.max_de === 'number' ? json.max_de : (typeof json.peak_de === 'number' ? json.peak_de : 0);
|
||||||
|
rmsDe = typeof json.rms_de === 'number' ? json.rms_de : 0;
|
||||||
} else {
|
} else {
|
||||||
// Regex fallbacks for standard profcheck output
|
// Regex fallbacks for standard profcheck text output
|
||||||
const avgMatch = stdout.match(/avg\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/average\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
const avgPatterns = [
|
||||||
const maxMatch = stdout.match(/max\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
const rmsMatch = stdout.match(/RMS\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/rms\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+average[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const maxPatterns = [
|
||||||
|
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+max[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const rmsPatterns = [
|
||||||
|
/RMS\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/rms\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const find = (patterns) => {
|
||||||
|
for (const p of patterns) {
|
||||||
|
const match = stdout.match(p);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgMatch = find(avgPatterns);
|
||||||
|
const maxMatch = find(maxPatterns);
|
||||||
|
const rmsMatch = find(rmsPatterns);
|
||||||
|
|
||||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||||
|
else parserWarnings.push('Could not detect Average ΔE in profcheck output.');
|
||||||
|
|
||||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||||
|
else parserWarnings.push('Could not detect Peak ΔE in profcheck output.');
|
||||||
|
|
||||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||||
|
else parserWarnings.push('Could not detect RMS ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (!avgMatch && !maxMatch && !rmsMatch) {
|
||||||
|
parserWarnings.push('No delta-E values were found in profcheck output.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parserWarnings.length > 0) {
|
||||||
|
logPre.textContent += `\n[WARN] ${parserWarnings.join(' ')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
avgDeEl.textContent = avgDe.toFixed(2);
|
avgDeEl.textContent = avgDe.toFixed(2);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
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');
|
||||||
@@ -11,6 +18,9 @@ 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;
|
||||||
|
|
||||||
@@ -24,6 +34,24 @@ 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');
|
||||||
@@ -32,6 +60,13 @@ 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) {
|
||||||
@@ -99,18 +134,26 @@ 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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-14
@@ -1,10 +1,76 @@
|
|||||||
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 } = window.__TAURI__.event;
|
const { listen, emit } = 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")
|
||||||
@@ -16,6 +82,9 @@ 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;
|
||||||
@@ -51,6 +120,8 @@ 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";
|
||||||
|
|
||||||
@@ -63,6 +134,10 @@ 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");
|
||||||
@@ -84,20 +159,21 @@ 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 += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
titleStr += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.measured && patch.measured.Lab) {
|
if (patch.measured && patch.measured.Lab) {
|
||||||
@@ -107,22 +183,19 @@ 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)}`;
|
|
||||||
|
|
||||||
// Traffic light classification
|
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)} (${label})`;
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
@@ -1530,6 +1530,43 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user