18 Commits
Author SHA1 Message Date
gronod 18a6d9394c ci(macos): compile tests with --no-run on cross-target Apple Silicon
Build Linux Packages / Build Linux (push) Successful in 7m9s
Build Windows Packages / Build Windows (push) Successful in 6m48s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 20m33s
Build macOS Packages / Build macOS (Intel) (push) Successful in 20m38s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Successful in 26m25s
2026-09-05 21:25:13 +01:00
gronod 9016932547 ci(macos): remove hanging rust-cache and run native cargo test for all platforms
Build Windows Packages / Build Windows (push) Successful in 7m13s
Build macOS Packages / Build macOS (Apple Silicon) (push) Failing after 7m15s
Build Linux Packages / Build Linux (push) Successful in 8m0s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 10m57s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 12m4s
2026-09-05 21:14:14 +01:00
gronod 2f7d8f3ca7 ci(macos): run cargo test without target for universal-apple-darwin
Build Windows Packages / Build Windows (push) Successful in 7m6s
Build Linux Packages / Build Linux (push) Successful in 7m43s
Build macOS Packages / Build macOS (Apple Silicon) (push) Failing after 9m16s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 43m44s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 43m44s
2026-09-05 20:34:17 +01:00
gronod 41efac68ed Merge pull request 'Release v0.8.1' (#206) from development into main
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Failing after 1m27s
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 6m4s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 6m4s
Build Windows Packages / Build Windows (push) Canceled after 6m1s
Build Linux Packages / Build Linux (push) Canceled after 6m12s
Merge pull request 'Release v0.8.1' (#206) from development into main
2026-09-05 20:27:52 +01:00
gronod 9aad7b083a Merge pull request 'feat(chartread): support -Y l switch for i1Pro 2 LED feedback (closes #204)' (#205) from feat/204-i1pro2-led-feedback into development
Merge pull request 'feat(chartread): support -Y l switch for i1Pro 2 LED feedback (closes #204)' (#205) from feat/204-i1pro2-led-feedback into development
2026-09-05 20:27:41 +01:00
gronod 5ee1b07263 feat(chartread): support -Y l switch for i1Pro 2 LED feedback (#204) 2026-09-05 20:26:44 +01:00
gronod 53ce298bc1 chore(release): bump version to 0.8.1 2026-09-05 20:24:46 +01:00
gronod 45ed7dbfe5 ci: uncomment Intel and Apple Silicon build targets in macos workflow matrix 2026-09-05 17:49:41 +01:00
gronod f553f10399 Update .gitea/workflows/build-macos.yml
made macos build on gitea universal only
2026-09-05 17:36:37 +01:00
gronod a9112445bb Update .gitea/workflows/build-macos.yml
Build Windows Packages / Build Windows (push) Canceled after 14s
Build Linux Packages / Build Linux (push) Canceled after 32s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 32m48s
commennted arm64 and universal builds from macos gitea workflow and updated node version to 24
2026-09-05 17:24:16 +01:00
gronod a584b9746f Update .gitea/workflows/build-macos.yml
Build Linux Packages / Build Linux (push) Canceled after 14s
Build Windows Packages / Build Windows (push) Canceled after 15s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 0s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 0s
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 2m2s
2026-09-05 17:18:31 +01:00
Gordon Bolton c2fdfafaa7 Merge development for v0.8.0 release
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 0s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 0s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 0s
Build Linux Packages / Build Linux (push) Canceled after 22s
Build Windows Packages / Build Windows (push) Canceled after 24s
Integrates Milestone 13 polish and closes #177, #178, #184, #176, #185, #179.

Generated with Devin
2026-09-04 01:31:08 +01:00
Gordon Bolton b60d0c3bce chore(release): bump version to 0.8.0
Generated with Devin
2026-09-04 01:28:00 +01:00
Gordon Bolton 8a0065274b Merge milestone 13 feature stack into development (#177-#185, #179)
Integrates the stacked enhancements and fixes from:
- enh/177-button-standardization
- enh/178-swatch-grid
- feat/184-configurable-delta-e
- enh/176-colprof-fwa
- enh/185-gamut-viewer
- fix/179-gamut-profcheck

Generated with Devin
2026-09-04 01:05:26 +01:00
Gordon Bolton ab2f6e562e fix(stage5): harden gamut .gam parsing and profcheck output parsing (#179)
- Make parseGamutFile support multiple BEGIN_DATA blocks, inline #
  comments, out-of-bounds vertex warnings, and user-visible parser
  warnings (returned in { vertices, faces, warnings }).
- Harden profcheck report parsing: prefer the last JSON object with
  avg_de/max_de/rms_de keys, then fall back to a wider set of regex
  patterns for legacy plain-text output.
- Add parser warnings to the process log when metrics cannot be found.
- Fix loadGamutMesh call signature and add try/catch around gamut load.
- Add manual gamut parser tests in src/js/gamut_viewer.test.js.
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 01:03:07 +01:00
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
17 changed files with 351 additions and 49 deletions
+11 -8
View File
@@ -13,17 +13,17 @@ jobs:
matrix:
platform:
- name: Intel
os: macos-15-intel
os: macos
target: x86_64-apple-darwin
binary_dir: macos-x86_64
arch: x86_64
- name: Apple Silicon
os: macos-15
os: macos
target: aarch64-apple-darwin
binary_dir: macos-aarch64
arch: arm64
- name: Universal (Intel + Apple Silicon)
os: macos-15
os: macos
target: universal-apple-darwin
binary_dir: macos-universal
arch: universal
@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
- name: Install Rust stable
@@ -44,9 +44,6 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Install Node dependencies
run: npm ci
@@ -69,7 +66,13 @@ jobs:
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
run: |
cd src-tauri
cargo test --target ${{ matrix.platform.target }}
if [ "${{ matrix.platform.target }}" = "universal-apple-darwin" ]; then
cargo test
elif [ "${{ matrix.platform.target }}" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then
cargo test --no-run --target ${{ matrix.platform.target }}
else
cargo test --target ${{ matrix.platform.target }}
fi
- name: Set Release Environment
id: set_env
+7
View File
@@ -1,5 +1,12 @@
# 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.
+1 -1
View File
@@ -18,7 +18,7 @@
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.
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, 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, 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.
- 🐧 **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.
+1 -1
View File
@@ -106,7 +106,7 @@ ICCery is a native, cross-platform desktop application built with:
- [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.
- [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)
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "iccery",
"private": true,
"version": "0.7.4",
"version": "0.8.1",
"type": "module",
"scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs",
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]]
name = "iccery"
version = "0.7.4"
version = "0.8.1"
dependencies = [
"base64 0.22.1",
"image",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.7.4"
version = "0.8.1"
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"]
edition = "2021"
+52 -2
View File
@@ -829,11 +829,13 @@ pub async fn read_tiff_preview_png(path: String) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()))
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChartreadConfig {
pub basename: String,
pub cwd: String,
pub port: Option<String>,
#[serde(default)]
pub enable_i1pro2_leds: Option<bool>,
}
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
@@ -849,6 +851,11 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
}
}
if config.enable_i1pro2_leds.unwrap_or(false) {
args.push("-Y".to_string());
args.push("l".to_string());
}
args.push(config.basename.clone());
args
}
@@ -857,8 +864,12 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
pub async fn run_chartread(
app: AppHandle,
state: State<'_, ProcessManager>,
config: ChartreadConfig,
mut config: ChartreadConfig,
) -> Result<(), String> {
if config.enable_i1pro2_leds.is_none() {
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
config.enable_i1pro2_leds = Some(settings.enable_i1pro2_leds);
}
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
let args = build_chartread_args(&config);
let id = format!("chartread_{}", config.basename);
@@ -1558,6 +1569,7 @@ mod tests {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
enable_i1pro2_leds: None,
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
@@ -1569,6 +1581,7 @@ mod tests {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: Some("".to_string()),
enable_i1pro2_leds: None,
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
@@ -1580,11 +1593,48 @@ mod tests {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: Some("1".to_string()),
enable_i1pro2_leds: None,
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
}
#[test]
fn test_build_chartread_args_leds_enabled() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
enable_i1pro2_leds: Some(true),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-Y", "l", "my_profile"]);
}
#[test]
fn test_build_chartread_args_with_port_and_leds() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: Some("1".to_string()),
enable_i1pro2_leds: Some(true),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-c", "1", "-Y", "l", "my_profile"]);
}
#[test]
fn test_build_chartread_args_leds_disabled() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
enable_i1pro2_leds: Some(false),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
}
#[test]
fn test_build_average_args() {
let config = AverageConfig {
+1 -1
View File
@@ -276,7 +276,7 @@ mod tests {
let pm = ProcessManager::new();
// Test helper using dummy/mock or direct map operations
{
let mut stdins = pm.stdins.lock().await;
let stdins = pm.stdins.lock().await;
assert!(!stdins.contains_key("test_proc"));
}
}
+27
View File
@@ -74,6 +74,8 @@ pub struct AppSettings {
pub delta_e_warning_max: f64,
#[serde(default)]
pub custom_presets: Vec<ProfilingPreset>,
#[serde(default)]
pub enable_i1pro2_leds: bool,
}
impl Default for AppSettings {
@@ -85,6 +87,7 @@ impl Default for AppSettings {
delta_e_good_max: default_delta_e_good_max(),
delta_e_warning_max: default_delta_e_warning_max(),
custom_presets: Vec::new(),
enable_i1pro2_leds: false,
}
}
}
@@ -466,4 +469,28 @@ mod tests {
assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info);
}
}
#[test]
fn test_default_enable_i1pro2_leds() {
let settings = AppSettings::default();
assert_eq!(settings.enable_i1pro2_leds, false);
}
#[test]
fn test_enable_i1pro2_leds_serialization() {
// Missing field defaults to false
let json_empty = "{}";
let settings_empty: AppSettings = serde_json::from_str(json_empty).unwrap();
assert_eq!(settings_empty.enable_i1pro2_leds, false);
// Explicit true
let json_true = r#"{"enable_i1pro2_leds": true}"#;
let settings_true: AppSettings = serde_json::from_str(json_true).unwrap();
assert_eq!(settings_true.enable_i1pro2_leds, true);
// Explicit false
let json_false = r#"{"enable_i1pro2_leds": false}"#;
let settings_false: AppSettings = serde_json::from_str(json_false).unwrap();
assert_eq!(settings_false.enable_i1pro2_leds, false);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.7.4",
"version": "0.8.1",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+14 -4
View File
@@ -883,9 +883,19 @@
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
</div>
<div class="form-group">
<label for="default_instrument">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1">
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Instrument &amp; Measurement Preferences</label>
<div style="margin-top: 8px;">
<label for="default_instrument" class="sub-label">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1">
</div>
<div style="margin-top: 12px;">
<label class="checkbox-label" for="enable_i1pro2_leds" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="enable_i1pro2_leds">
<span>Enable i1Pro 2 status LEDs (<code>-Y l</code>)</span>
</label>
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Provides visual status feedback via the instrument ring LEDs during patch reading. Requires patched ArgyllCMS build.</small>
</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>
@@ -940,7 +950,7 @@
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
</div>
<h2>About ICCery</h2>
<p><strong>Version:</strong> <span id="aboutVersion">v0.7.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.1</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p>
<h3>Licences & EULA</h3>
<div class="license-text-container">
+7 -2
View File
@@ -426,8 +426,11 @@ export function initChartread() {
}
});
let lastStderrLine = "";
const unlistenStderr = await listen("process:stderr", (event) => {
if (event.payload.id === currentProcessId && event.payload.line) {
lastStderrLine = event.payload.line;
logPre.textContent += "ERR: " + event.payload.line + "\n";
logPre.scrollTop = logPre.scrollHeight;
}
@@ -471,8 +474,10 @@ export function initChartread() {
}
} else {
setState(STATE.FINISHED);
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
if (logContainer) logContainer.open = true;
const errDetail = lastStderrLine ? ` (${lastStderrLine})` : "";
setPrompt(`❌ chartread exited with code ${event.payload.code}.${errDetail}`);
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.${errDetail}\n`;
}
setMeasurementBusy(false);
+65 -13
View File
@@ -206,32 +206,78 @@ function _line(from, to, material) {
// ─────────────────────────────────────────────────────────────────────────────
/**
* 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[][] }}
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
*/
// ─────────────────────────────────────────────────────────────────────────────
export function parseGamutFile(text) {
const lines = text.split('\n');
const vertices = [];
const faces = [];
const warnings = [];
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) {
const trimmed = line.trim();
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
if (trimmed === 'END_DATA') { dataStarted = false; continue; }
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const raw = lines[lineIdx];
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
if (trimmed === '') continue;
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
dataBlock++;
dataStarted = true;
continue;
}
if (trimmed.toUpperCase() === 'END_DATA') {
dataStarted = false;
continue;
}
if (!dataStarted) continue;
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) {
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
} else if (dataBlock === 2 && parts.length >= 3) {
faces.push([parts[0], parts[1], parts[2]]);
const allNumeric = parts.every(n => !Number.isNaN(n));
if (!allNumeric) {
warnings.push(`Skipping non-numeric data at line ${lineIdx + 1}`);
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -308,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);
if (!built) return null;
@@ -352,7 +401,10 @@ function _renderProfileGamut(text, previousMesh) {
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);
if (!built) return null;
+80
View File
@@ -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
View File
@@ -98,7 +98,14 @@ export function initProfcheck() {
// Ensure gamut mesh is loaded into 3D viewer
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 {
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) {
reportCard.classList.remove("hidden");
let avgDe = 0.0;
let maxDe = 0.0;
let rmsDe = 0.0;
let parserWarnings = [];
// Check if JSON output is present
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
if (jsonMatch) {
// Argyll's JSON output can appear either as a compact object on a single
// line or embedded inside larger text. Try to find and parse the LAST valid
// 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 {
const json = JSON.parse(jsonMatch[0]);
avgDe = json.avg_de || 0;
maxDe = json.max_de || json.peak_de || 0;
rmsDe = json.rms_de || 0;
const parsed = JSON.parse(m[0]);
if (typeof parsed === 'object' && parsed !== null && ('avg_de' in parsed || 'peak_de' in parsed || 'rms_de' in parsed)) {
jsonObjects.push(parsed);
}
} 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 {
// Regex fallbacks for standard profcheck output
const avgMatch = stdout.match(/avg\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/average\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
const maxMatch = stdout.match(/max\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/peak\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);
// Regex fallbacks for standard profcheck text output
const avgPatterns = [
/avg(?:\.?|erage)\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]);
else parserWarnings.push('Could not detect Average ΔE in profcheck output.');
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
else parserWarnings.push('Could not detect Peak ΔE in profcheck output.');
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);
+5
View File
@@ -21,6 +21,7 @@ export async function initSettings() {
const deltaEGoodMax = document.getElementById('deltaEGoodMax');
const deltaEWarningMax = document.getElementById('deltaEWarningMax');
const deltaEThresholdError = document.getElementById('deltaEThresholdError');
const enableI1Pro2Leds = document.getElementById('enable_i1pro2_leds');
if (!dialog || !openBtn) return;
@@ -57,6 +58,9 @@ export async function initSettings() {
const settings = await invoke('load_settings');
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
document.getElementById('default_instrument').value = settings.default_instrument || '';
if (enableI1Pro2Leds) {
enableI1Pro2Leds.checked = Boolean(settings.enable_i1pro2_leds);
}
if (logLevelSelect && settings.log_level) {
logLevelSelect.value = settings.log_level;
}
@@ -145,6 +149,7 @@ export async function initSettings() {
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),
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
};
await invoke('save_settings', { settings });
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');