Compare commits
18
Commits
3026e7081c
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a6d9394c | ||
|
|
9016932547 | ||
|
|
2f7d8f3ca7 | ||
|
|
41efac68ed | ||
|
|
9aad7b083a | ||
|
|
5ee1b07263 | ||
|
|
53ce298bc1 | ||
|
|
45ed7dbfe5 | ||
|
|
f553f10399 | ||
|
|
a9112445bb | ||
|
|
a584b9746f | ||
|
|
c2fdfafaa7 | ||
|
|
b60d0c3bce | ||
|
|
8a0065274b | ||
|
|
ab2f6e562e | ||
|
|
d50c3c4f3c | ||
|
|
9a365acd2b | ||
|
|
47a5373a8e |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.7.4"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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 & 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> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.1</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||
<h3>Licences & EULA</h3>
|
||||
<div class="license-text-container">
|
||||
|
||||
+7
-2
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user