feat: Stage 3 - Interactive chartread Subprocess State Machine & Swatch Grid #13
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mock script for chartread -u
|
||||||
|
# This script simulates the behaviour of chartread for testing purposes.
|
||||||
|
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
|
||||||
|
# We don't really wait for input, just wait 1 second
|
||||||
|
sleep 1
|
||||||
|
echo "Calibration successful."
|
||||||
|
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip A..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip B..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Ready to read... done."
|
||||||
|
exit 0
|
||||||
@@ -171,6 +171,39 @@ pub async fn read_file_base64(path: String) -> Result<String, String> {
|
|||||||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ChartreadConfig {
|
||||||
|
pub basename: String,
|
||||||
|
pub cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-v".to_string(),
|
||||||
|
"-u".to_string(),
|
||||||
|
config.basename.clone(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_chartread(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ProcessManager>,
|
||||||
|
config: ChartreadConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
let id = format!("chartread_{}", config.basename);
|
||||||
|
|
||||||
|
let cwd = if config.cwd.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(config.cwd.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
state.spawn(app, id, binary, args, cwd).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -244,4 +277,14 @@ mod tests {
|
|||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]);
|
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
};
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) {
|
|||||||
error: Some(error),
|
error: Some(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct JsonRowPayload {
|
||||||
|
pub id: String,
|
||||||
|
pub json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_json_row(app: &AppHandle, id: &str, json: String) {
|
||||||
|
let _ = app.emit("process:json_row", JsonRowPayload {
|
||||||
|
id: id.to_string(),
|
||||||
|
json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub fn run() {
|
|||||||
commands::run_targen,
|
commands::run_targen,
|
||||||
commands::run_printtarg,
|
commands::run_printtarg,
|
||||||
commands::read_file_base64,
|
commands::read_file_base64,
|
||||||
|
commands::run_chartread,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ impl ProcessManager {
|
|||||||
let id_clone = id.clone();
|
let id_clone = id.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
|
||||||
let mut reader = BufReader::new(stdout).lines();
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
while let Ok(Some(line)) = reader.next_line().await {
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
|
if line.starts_with(JSON_ROW_PREFIX) {
|
||||||
|
let json_str = line[JSON_ROW_PREFIX.len()..].to_string();
|
||||||
|
crate::events::emit_json_row(&app_clone, &id_clone, json_str);
|
||||||
|
} else {
|
||||||
emit_stdout(&app_clone, &id_clone, line);
|
emit_stdout(&app_clone, &id_clone, line);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let id_clone2 = id.clone();
|
let id_clone2 = id.clone();
|
||||||
|
|||||||
+35
-2
@@ -177,9 +177,42 @@
|
|||||||
<!-- Stage 3: chartread -->
|
<!-- Stage 3: chartread -->
|
||||||
<section id="stage-3" class="stage hidden">
|
<section id="stage-3" class="stage hidden">
|
||||||
<h2>Measure Target</h2>
|
<h2>Measure Target</h2>
|
||||||
<p>Read the printed patches with your instrument.</p>
|
<p>Read the printed target patches with your spectrophotometer.</p>
|
||||||
|
|
||||||
|
<!-- State & Prompt Area -->
|
||||||
|
<div class="chartread-status">
|
||||||
|
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||||
|
<div class="status-prompt" id="chartreadPrompt">
|
||||||
|
Press 'Start Measurement' to begin reading the printed target.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="chartread-actions">
|
||||||
|
<button class="primary" id="btnStartRead">Start Measurement</button>
|
||||||
|
<button class="primary hidden" id="btnCalibrate">✓ Calibrate</button>
|
||||||
|
<button class="secondary hidden" id="btnRetry">↻ Retry Strip</button>
|
||||||
|
<button class="secondary hidden" id="btnSkip">⏭ Skip Strip</button>
|
||||||
|
<button class="danger hidden" id="btnCancel">✕ Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="read-progress-container hidden" id="readProgressContainer">
|
||||||
|
<div class="read-progress-bar" id="readProgress" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="read-progress-text" id="readProgressText"></div>
|
||||||
|
|
||||||
|
<!-- Live Stats -->
|
||||||
|
<div class="read-stats" id="readStats"></div>
|
||||||
|
|
||||||
|
<!-- Swatch Grid -->
|
||||||
<div class="swatch-grid" id="swatchGrid"></div>
|
<div class="swatch-grid" id="swatchGrid"></div>
|
||||||
<button class="primary" id="btnRead">Start Reading (.ti3)</button>
|
|
||||||
|
<!-- Process Log -->
|
||||||
|
<div class="log-container hidden" id="chartreadLogContainer">
|
||||||
|
<h4>Process Output</h4>
|
||||||
|
<pre id="chartreadLog"></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Stage 4: colprof -->
|
<!-- Stage 4: colprof -->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initTargen } from './targen.js';
|
import { initTargen } from './targen.js';
|
||||||
import { initPrinttarg } from './printtarg.js';
|
import { initPrinttarg } from './printtarg.js';
|
||||||
|
import { initChartread } from './chartread.js';
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
@@ -31,4 +32,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Initialize Stage 2
|
// Initialize Stage 2
|
||||||
initPrinttarg();
|
initPrinttarg();
|
||||||
|
|
||||||
|
// Initialize Stage 3
|
||||||
|
initChartread();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
import { startSwatchListener, stopSwatchListener } from './swatch_grid.js';
|
||||||
|
|
||||||
|
// Module-level state: set by Stage 2 when it completes
|
||||||
|
let stage2Basename = "";
|
||||||
|
let stage2Cwd = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by printtarg.js after Stage 2 completes.
|
||||||
|
*/
|
||||||
|
export function setStage2Result(basename, cwd) {
|
||||||
|
stage2Basename = basename;
|
||||||
|
stage2Cwd = cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State machine states
|
||||||
|
const STATE = {
|
||||||
|
IDLE: "IDLE",
|
||||||
|
CALIBRATING: "CALIBRATING",
|
||||||
|
AWAITING_STRIP: "AWAITING_STRIP",
|
||||||
|
READING: "READING",
|
||||||
|
ERROR: "ERROR",
|
||||||
|
FINISHED: "FINISHED",
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentState = STATE.IDLE;
|
||||||
|
let currentProcessId = "";
|
||||||
|
|
||||||
|
export function initChartread() {
|
||||||
|
const btnStartRead = document.getElementById("btnStartRead");
|
||||||
|
const btnCalibrate = document.getElementById("btnCalibrate");
|
||||||
|
const btnRetry = document.getElementById("btnRetry");
|
||||||
|
const btnSkip = document.getElementById("btnSkip");
|
||||||
|
const btnCancel = document.getElementById("btnCancel");
|
||||||
|
const promptText = document.getElementById("chartreadPrompt");
|
||||||
|
const stateLabel = document.getElementById("chartreadState");
|
||||||
|
const logContainer = document.getElementById("chartreadLogContainer");
|
||||||
|
const logPre = document.getElementById("chartreadLog");
|
||||||
|
|
||||||
|
function setState(newState) {
|
||||||
|
currentState = newState;
|
||||||
|
if (stateLabel) stateLabel.textContent = newState;
|
||||||
|
|
||||||
|
// Show/hide buttons based on state
|
||||||
|
btnCalibrate.classList.add("hidden");
|
||||||
|
btnRetry.classList.add("hidden");
|
||||||
|
btnSkip.classList.add("hidden");
|
||||||
|
btnCancel.classList.add("hidden");
|
||||||
|
btnStartRead.classList.add("hidden");
|
||||||
|
|
||||||
|
switch (newState) {
|
||||||
|
case STATE.IDLE:
|
||||||
|
btnStartRead.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.CALIBRATING:
|
||||||
|
btnCalibrate.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.AWAITING_STRIP:
|
||||||
|
btnRetry.classList.remove("hidden");
|
||||||
|
btnSkip.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.READING:
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.ERROR:
|
||||||
|
btnRetry.classList.remove("hidden");
|
||||||
|
btnSkip.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.FINISHED:
|
||||||
|
btnStartRead.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrompt(text) {
|
||||||
|
if (promptText) promptText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start reading button
|
||||||
|
if (btnStartRead) {
|
||||||
|
btnStartRead.addEventListener("click", async () => {
|
||||||
|
if (!stage2Basename) {
|
||||||
|
setPrompt("Error: No .ti2 file available. Complete Stage 2 first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logPre.textContent = "";
|
||||||
|
logContainer.classList.remove("hidden");
|
||||||
|
setState(STATE.CALIBRATING);
|
||||||
|
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
basename: stage2Basename,
|
||||||
|
cwd: stage2Cwd,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentProcessId = `chartread_${stage2Basename}`;
|
||||||
|
|
||||||
|
// Start swatch grid listener
|
||||||
|
await startSwatchListener(currentProcessId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||||
|
if (event.payload.id !== currentProcessId || !event.payload.line) return;
|
||||||
|
const line = event.payload.line;
|
||||||
|
|
||||||
|
logPre.textContent += line + "\n";
|
||||||
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
|
|
||||||
|
// Parse prompts for state transitions
|
||||||
|
const lineLower = line.toLowerCase();
|
||||||
|
|
||||||
|
if (lineLower.includes("calibrat") && lineLower.includes("place")) {
|
||||||
|
setState(STATE.CALIBRATING);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) {
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("ready to read")) {
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
||||||
|
setState(STATE.READING);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread")) {
|
||||||
|
setState(STATE.ERROR);
|
||||||
|
setPrompt("⚠️ " + line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||||
|
if (event.payload.id === currentProcessId && event.payload.line) {
|
||||||
|
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||||
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenExit = await listen("process:exit", (event) => {
|
||||||
|
if (event.payload.id !== currentProcessId) return;
|
||||||
|
unlistenStdout();
|
||||||
|
unlistenStderr();
|
||||||
|
unlistenExit();
|
||||||
|
stopSwatchListener();
|
||||||
|
|
||||||
|
if (event.payload.code === 0) {
|
||||||
|
setState(STATE.FINISHED);
|
||||||
|
setPrompt("✅ Measurement complete! .ti3 file has been saved.");
|
||||||
|
logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n";
|
||||||
|
advanceToStage4();
|
||||||
|
} else {
|
||||||
|
setState(STATE.FINISHED);
|
||||||
|
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||||
|
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("run_chartread", { config });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setPrompt(`Invoke error: ${err}`);
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calibrate / confirm button — sends space + newline
|
||||||
|
if (btnCalibrate) {
|
||||||
|
btnCalibrate.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry button — sends space + newline (same as confirm)
|
||||||
|
if (btnRetry) {
|
||||||
|
btnRetry.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||||
|
setState(STATE.READING);
|
||||||
|
setPrompt("Retrying strip read...");
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip button — sends "s\n"
|
||||||
|
if (btnSkip) {
|
||||||
|
btnSkip.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: "s\n" });
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt("Skipped current strip. Awaiting next...");
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button — kills the process
|
||||||
|
if (btnCancel) {
|
||||||
|
btnCancel.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("kill_process", { id: currentProcessId });
|
||||||
|
stopSwatchListener();
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
setPrompt("Measurement cancelled.");
|
||||||
|
} catch (e) { console.error("kill_process error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start in IDLE state
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
setPrompt("Press 'Start Measurement' to begin reading the printed target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceToStage4() {
|
||||||
|
const steps = document.querySelectorAll('.step');
|
||||||
|
const stages = document.querySelectorAll('.stage');
|
||||||
|
|
||||||
|
steps.forEach(s => s.classList.remove('active'));
|
||||||
|
if (steps[3]) steps[3].classList.add('active');
|
||||||
|
|
||||||
|
stages.forEach(s => {
|
||||||
|
s.classList.remove('active');
|
||||||
|
s.classList.add('hidden');
|
||||||
|
});
|
||||||
|
if (stages[3]) {
|
||||||
|
stages[3].classList.remove('hidden');
|
||||||
|
stages[3].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Convert CIE L*a*b* to sRGB [0–255] clamped values.
|
||||||
|
* Uses D50 illuminant reference white (standard for ICC profiles).
|
||||||
|
* @param {number} L - Lightness [0, 100]
|
||||||
|
* @param {number} a - Green-red axis [-128, 127]
|
||||||
|
* @param {number} b - Blue-yellow axis [-128, 127]
|
||||||
|
* @returns {number[]} [r, g, b] each in [0, 255]
|
||||||
|
*/
|
||||||
|
export function labToSrgb(L, a, b) {
|
||||||
|
// D50 reference white
|
||||||
|
const Xn = 0.9642;
|
||||||
|
const Yn = 1.0;
|
||||||
|
const Zn = 0.8249;
|
||||||
|
|
||||||
|
// Lab → XYZ
|
||||||
|
const fy = (L + 16) / 116;
|
||||||
|
const fx = a / 500 + fy;
|
||||||
|
const fz = fy - b / 200;
|
||||||
|
|
||||||
|
const delta = 6 / 29;
|
||||||
|
const delta3 = delta * delta * delta;
|
||||||
|
|
||||||
|
const X = Xn * (fx > delta ? fx * fx * fx : (fx - 16 / 116) * 3 * delta * delta);
|
||||||
|
const Y = Yn * (fy > delta ? fy * fy * fy : (fy - 16 / 116) * 3 * delta * delta);
|
||||||
|
const Z = Zn * (fz > delta ? fz * fz * fz : (fz - 16 / 116) * 3 * delta * delta);
|
||||||
|
|
||||||
|
// XYZ (D50) → linear sRGB via Bradford-adapted D50→D65 matrix
|
||||||
|
// Combined D50-adapted XYZ to sRGB matrix
|
||||||
|
const lr = 3.1338561 * X - 1.6168667 * Y - 0.4906146 * Z;
|
||||||
|
const lg = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
|
||||||
|
const lb = 0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
|
||||||
|
|
||||||
|
// Linear sRGB → gamma-corrected sRGB
|
||||||
|
function gammaCorrect(c) {
|
||||||
|
return c <= 0.0031308
|
||||||
|
? 12.92 * c
|
||||||
|
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.round(Math.max(0, Math.min(255, gammaCorrect(lr) * 255)));
|
||||||
|
const g = Math.round(Math.max(0, Math.min(255, gammaCorrect(lg) * 255)));
|
||||||
|
const bVal = Math.round(Math.max(0, Math.min(255, gammaCorrect(lb) * 255)));
|
||||||
|
|
||||||
|
return [r, g, bVal];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert device RGB percentages [0–100] to CSS rgb string.
|
||||||
|
* @param {number[]} device - [R%, G%, B%] each in [0, 100]
|
||||||
|
* @returns {string} CSS rgb() string
|
||||||
|
*/
|
||||||
|
export function deviceRgbToCss(device) {
|
||||||
|
const r = Math.round((device[0] / 100) * 255);
|
||||||
|
const g = Math.round((device[1] / 100) * 255);
|
||||||
|
const b = Math.round((device[2] / 100) * 255);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Lab triplet to CSS rgb string.
|
||||||
|
* @param {number[]} lab - [L, a, b]
|
||||||
|
* @returns {string} CSS rgb() string
|
||||||
|
*/
|
||||||
|
export function labToCss(lab) {
|
||||||
|
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Compute CIEDE2000 colour difference (ΔE₀₀) between two L*a*b* values.
|
||||||
|
* Standard parametric factors: kL=1, kC=1, kH=1.
|
||||||
|
*
|
||||||
|
* Reference: Sharma, Wu, Dalal (2005) "The CIEDE2000 Color-Difference Formula"
|
||||||
|
*
|
||||||
|
* @param {number[]} lab1 - [L1, a1, b1]
|
||||||
|
* @param {number[]} lab2 - [L2, a2, b2]
|
||||||
|
* @returns {number} ΔE₀₀ value
|
||||||
|
*/
|
||||||
|
export function computeDeltaE00(lab1, lab2) {
|
||||||
|
const [L1, a1, b1] = lab1;
|
||||||
|
const [L2, a2, b2] = lab2;
|
||||||
|
|
||||||
|
const kL = 1, kC = 1, kH = 1;
|
||||||
|
|
||||||
|
const C1ab = Math.sqrt(a1 * a1 + b1 * b1);
|
||||||
|
const C2ab = Math.sqrt(a2 * a2 + b2 * b2);
|
||||||
|
const Cab_avg = (C1ab + C2ab) / 2;
|
||||||
|
|
||||||
|
const Cab_avg7 = Math.pow(Cab_avg, 7);
|
||||||
|
const G = 0.5 * (1 - Math.sqrt(Cab_avg7 / (Cab_avg7 + Math.pow(25, 7))));
|
||||||
|
|
||||||
|
const a1p = a1 * (1 + G);
|
||||||
|
const a2p = a2 * (1 + G);
|
||||||
|
|
||||||
|
const C1p = Math.sqrt(a1p * a1p + b1 * b1);
|
||||||
|
const C2p = Math.sqrt(a2p * a2p + b2 * b2);
|
||||||
|
|
||||||
|
let h1p = Math.atan2(b1, a1p) * (180 / Math.PI);
|
||||||
|
if (h1p < 0) h1p += 360;
|
||||||
|
let h2p = Math.atan2(b2, a2p) * (180 / Math.PI);
|
||||||
|
if (h2p < 0) h2p += 360;
|
||||||
|
|
||||||
|
const dLp = L2 - L1;
|
||||||
|
const dCp = C2p - C1p;
|
||||||
|
|
||||||
|
let dhp;
|
||||||
|
if (C1p * C2p === 0) {
|
||||||
|
dhp = 0;
|
||||||
|
} else if (Math.abs(h2p - h1p) <= 180) {
|
||||||
|
dhp = h2p - h1p;
|
||||||
|
} else if (h2p - h1p > 180) {
|
||||||
|
dhp = h2p - h1p - 360;
|
||||||
|
} else {
|
||||||
|
dhp = h2p - h1p + 360;
|
||||||
|
}
|
||||||
|
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2);
|
||||||
|
|
||||||
|
const Lp_avg = (L1 + L2) / 2;
|
||||||
|
const Cp_avg = (C1p + C2p) / 2;
|
||||||
|
|
||||||
|
let hp_avg;
|
||||||
|
if (C1p * C2p === 0) {
|
||||||
|
hp_avg = h1p + h2p;
|
||||||
|
} else if (Math.abs(h1p - h2p) <= 180) {
|
||||||
|
hp_avg = (h1p + h2p) / 2;
|
||||||
|
} else if (h1p + h2p < 360) {
|
||||||
|
hp_avg = (h1p + h2p + 360) / 2;
|
||||||
|
} else {
|
||||||
|
hp_avg = (h1p + h2p - 360) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = 1
|
||||||
|
- 0.17 * Math.cos((hp_avg - 30) * Math.PI / 180)
|
||||||
|
+ 0.24 * Math.cos(2 * hp_avg * Math.PI / 180)
|
||||||
|
+ 0.32 * Math.cos((3 * hp_avg + 6) * Math.PI / 180)
|
||||||
|
- 0.20 * Math.cos((4 * hp_avg - 63) * Math.PI / 180);
|
||||||
|
|
||||||
|
const SL = 1 + (0.015 * Math.pow(Lp_avg - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_avg - 50, 2));
|
||||||
|
const SC = 1 + 0.045 * Cp_avg;
|
||||||
|
const SH = 1 + 0.015 * Cp_avg * T;
|
||||||
|
|
||||||
|
const Cp_avg7 = Math.pow(Cp_avg, 7);
|
||||||
|
const RT_term = -2 * Math.sqrt(Cp_avg7 / (Cp_avg7 + Math.pow(25, 7)))
|
||||||
|
* Math.sin(60 * Math.exp(-Math.pow((hp_avg - 275) / 25, 2)) * Math.PI / 180);
|
||||||
|
|
||||||
|
const dE = Math.sqrt(
|
||||||
|
Math.pow(dLp / (kL * SL), 2) +
|
||||||
|
Math.pow(dCp / (kC * SC), 2) +
|
||||||
|
Math.pow(dHp / (kH * SH), 2) +
|
||||||
|
RT_term * (dCp / (kC * SC)) * (dHp / (kH * SH))
|
||||||
|
);
|
||||||
|
|
||||||
|
return dE;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
const { listen } = window.__TAURI__.event;
|
const { listen } = window.__TAURI__.event;
|
||||||
|
import { setStage2Result } from './chartread.js';
|
||||||
|
|
||||||
// Module-level state: set by Stage 1 when it completes
|
// Module-level state: set by Stage 1 when it completes
|
||||||
let stage1Basename = "";
|
let stage1Basename = "";
|
||||||
@@ -118,6 +119,7 @@ export function initPrinttarg() {
|
|||||||
renderTiffGallery(manifest, config.cwd);
|
renderTiffGallery(manifest, config.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStage2Result(stage1Basename, stage1Cwd);
|
||||||
advanceToStage3();
|
advanceToStage3();
|
||||||
} else {
|
} else {
|
||||||
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
|
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { computeDeltaE00 } from './delta_e.js';
|
||||||
|
import { labToCss, deviceRgbToCss } from './color_convert.js';
|
||||||
|
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
|
||||||
|
let unlistenJsonRow = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for row events from a chartread process.
|
||||||
|
* @param {string} processId - The process ID (e.g. "chartread_my_profile")
|
||||||
|
*/
|
||||||
|
export async function startSwatchListener(processId) {
|
||||||
|
const grid = document.getElementById("swatchGrid");
|
||||||
|
const progressBar = document.getElementById("readProgress");
|
||||||
|
const progressText = document.getElementById("readProgressText");
|
||||||
|
const statsPanel = document.getElementById("readStats");
|
||||||
|
|
||||||
|
// Clear previous state
|
||||||
|
grid.innerHTML = "";
|
||||||
|
let totalPatches = 0;
|
||||||
|
let totalDeltaE = 0;
|
||||||
|
let patchCount = 0;
|
||||||
|
let maxDeltaE = 0;
|
||||||
|
|
||||||
|
unlistenJsonRow = await listen("process:json_row", (event) => {
|
||||||
|
if (event.payload.id !== processId) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.payload.json);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse json_row:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event !== "row_complete") return;
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const progress = ((data.row_index + 1) / data.total_rows) * 100;
|
||||||
|
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||||
|
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
|
||||||
|
|
||||||
|
// Create row container
|
||||||
|
const rowEl = document.createElement("div");
|
||||||
|
rowEl.className = "swatch-row";
|
||||||
|
|
||||||
|
const rowLabel = document.createElement("div");
|
||||||
|
rowLabel.className = "swatch-row-label";
|
||||||
|
rowLabel.textContent = data.row_id;
|
||||||
|
rowEl.appendChild(rowLabel);
|
||||||
|
|
||||||
|
const rowPatches = document.createElement("div");
|
||||||
|
rowPatches.className = "swatch-row-patches";
|
||||||
|
|
||||||
|
for (const patch of data.patches) {
|
||||||
|
if (patch.is_pad) continue; // Skip spacer patches
|
||||||
|
|
||||||
|
const patchEl = document.createElement("div");
|
||||||
|
patchEl.className = "swatch-patch";
|
||||||
|
|
||||||
|
// Determine the display colour
|
||||||
|
let bgColor;
|
||||||
|
if (patch.measured && patch.measured.Lab) {
|
||||||
|
bgColor = labToCss(patch.measured.Lab);
|
||||||
|
} else if (patch.device && patch.device.length === 3) {
|
||||||
|
bgColor = deviceRgbToCss(patch.device);
|
||||||
|
} else {
|
||||||
|
bgColor = "#888";
|
||||||
|
}
|
||||||
|
|
||||||
|
const swatch = document.createElement("div");
|
||||||
|
swatch.className = "swatch-color";
|
||||||
|
swatch.style.backgroundColor = bgColor;
|
||||||
|
patchEl.appendChild(swatch);
|
||||||
|
|
||||||
|
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
||||||
|
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
||||||
|
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
||||||
|
|
||||||
|
const deLabel = document.createElement("div");
|
||||||
|
deLabel.className = "swatch-de";
|
||||||
|
deLabel.textContent = deltaE.toFixed(1);
|
||||||
|
|
||||||
|
// Traffic light classification
|
||||||
|
if (deltaE < 2) {
|
||||||
|
patchEl.classList.add("de-good"); // Green
|
||||||
|
} else if (deltaE < 5) {
|
||||||
|
patchEl.classList.add("de-warning"); // Amber
|
||||||
|
} else {
|
||||||
|
patchEl.classList.add("de-bad"); // Red
|
||||||
|
}
|
||||||
|
|
||||||
|
patchEl.appendChild(deLabel);
|
||||||
|
|
||||||
|
// Accumulate stats
|
||||||
|
totalDeltaE += deltaE;
|
||||||
|
patchCount++;
|
||||||
|
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
|
||||||
|
}
|
||||||
|
|
||||||
|
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
|
||||||
|
rowPatches.appendChild(patchEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowEl.appendChild(rowPatches);
|
||||||
|
grid.appendChild(rowEl);
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
grid.scrollTop = grid.scrollHeight;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
if (statsPanel && patchCount > 0) {
|
||||||
|
const avgDe = (totalDeltaE / patchCount).toFixed(2);
|
||||||
|
statsPanel.textContent = `Avg ΔE₀₀: ${avgDe} · Max ΔE₀₀: ${maxDeltaE.toFixed(2)} · Patches: ${patchCount}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop listening for row events.
|
||||||
|
*/
|
||||||
|
export function stopSwatchListener() {
|
||||||
|
if (unlistenJsonRow) {
|
||||||
|
unlistenJsonRow();
|
||||||
|
unlistenJsonRow = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,3 +341,161 @@ button:disabled {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 3: chartread State Machine UI
|
||||||
|
======================================== */
|
||||||
|
.chartread-status {
|
||||||
|
background: var(--panel-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label span {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-prompt {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 2.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartread-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background-color: #c62828;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background-color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.read-progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-color), #4fc3f7);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-progress-text {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-stats {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 3: Swatch Grid
|
||||||
|
======================================== */
|
||||||
|
.swatch-grid {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-color);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row-label {
|
||||||
|
min-width: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row-patches {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch {
|
||||||
|
width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-color {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-de {
|
||||||
|
font-size: 0.65em;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic light ΔE indicators */
|
||||||
|
.swatch-patch.de-good {
|
||||||
|
border-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch.de-warning {
|
||||||
|
border-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch.de-bad {
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process log for chartread */
|
||||||
|
#chartreadLog {
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #ccc;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user