feat(gamut): integrate 3D gamut visualization and platform profile extension handling (v0.1.14) #74

Merged
gronod merged 1 commits from fix/issue-57-69-gamut-viewer-and-profile-extension into development 2026-08-25 10:33:56 +01:00
9 changed files with 1608 additions and 60 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "iccery", "name": "iccery",
"private": true, "private": true,
"version": "0.1.13", "version": "0.1.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tauri": "tauri" "tauri": "tauri"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iccery" name = "iccery"
version = "0.1.13" version = "0.1.14"
description = "Modern Printer Profiling UI frontend for ArgyllCMS" description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"] authors = ["Gordon"]
edition = "2021" edition = "2021"
+73 -9
View File
@@ -102,6 +102,32 @@ pub async fn detect_instruments(app: AppHandle, state: State<'_, ProcessManager>
state.spawn(app, "instlist".to_string(), binary, vec![], None).await state.spawn(app, "instlist".to_string(), binary, vec![], None).await
} }
pub fn resolve_profile_extension(cwd: &str, basename: &str) -> (String, std::path::PathBuf) {
let p_icc = std::path::Path::new(cwd).join(format!("{}.icc", basename));
let p_icm = std::path::Path::new(cwd).join(format!("{}.icm", basename));
if p_icm.exists() && !p_icc.exists() {
("icm".to_string(), p_icm)
} else if p_icc.exists() {
("icc".to_string(), p_icc)
} else {
#[cfg(windows)]
{
("icm".to_string(), p_icm)
}
#[cfg(not(windows))]
{
("icc".to_string(), p_icc)
}
}
}
#[tauri::command]
pub fn get_profile_path(cwd: String, basename: String) -> String {
let (_, path) = resolve_profile_extension(&cwd, &basename);
path.to_string_lossy().to_string()
}
#[tauri::command] #[tauri::command]
pub async fn extract_gamut( pub async fn extract_gamut(
app: AppHandle, app: AppHandle,
@@ -110,18 +136,41 @@ pub async fn extract_gamut(
) -> Result<(), String> { ) -> Result<(), String> {
let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?; let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?;
let path = std::path::Path::new(&icc_path); let path = std::path::Path::new(&icc_path);
let basename = path.file_stem().unwrap().to_str().unwrap(); let basename = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "profile".to_string());
let parent_dir = path.parent().map(|p| p.to_str().unwrap()).unwrap_or(""); let parent_dir = path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
let mut args = vec!["-w".to_string()]; // Auto-detect .icc vs .icm if the specified path does not exist directly
if !parent_dir.is_empty() { let resolved_path = if path.exists() {
args.push("-d".to_string()); icc_path.clone()
args.push(parent_dir.to_string()); } else {
let alt = if icc_path.ends_with(".icc") {
icc_path.replace(".icc", ".icm")
} else if icc_path.ends_with(".icm") {
icc_path.replace(".icm", ".icc")
} else {
icc_path.clone()
};
if std::path::Path::new(&alt).exists() {
alt
} else {
icc_path.clone()
} }
args.push(icc_path.clone()); };
let args = vec![
"-v".to_string(),
"-d".to_string(),
"50.0".to_string(),
resolved_path,
];
let cwd = if parent_dir.is_empty() {
resolve_safe_cwd(&app, "").ok()
} else {
Some(parent_dir)
};
let id = format!("iccgamut_{}", basename); let id = format!("iccgamut_{}", basename);
state.spawn(app, id, binary, args, None).await state.spawn(app, id, binary, args, cwd).await
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -379,9 +428,24 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
pub async fn run_profcheck( pub async fn run_profcheck(
app: AppHandle, app: AppHandle,
state: State<'_, ProcessManager>, state: State<'_, ProcessManager>,
config: ProfcheckConfig, mut config: ProfcheckConfig,
) -> Result<(), String> { ) -> Result<(), String> {
let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?; let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?;
// Auto-detect .icc vs .icm if the specified icc_path does not exist directly
if !std::path::Path::new(&config.icc_path).exists() {
let alt = if config.icc_path.ends_with(".icc") {
config.icc_path.replace(".icc", ".icm")
} else if config.icc_path.ends_with(".icm") {
config.icc_path.replace(".icm", ".icc")
} else {
config.icc_path.clone()
};
if std::path::Path::new(&alt).exists() {
config.icc_path = alt;
}
}
let args = build_profcheck_args(&config); let args = build_profcheck_args(&config);
let id = format!("profcheck_{}", config.ti3_path); let id = format!("profcheck_{}", config.ti3_path);
let cwd = Some(resolve_safe_cwd(&app, &config.cwd)?); let cwd = Some(resolve_safe_cwd(&app, &config.cwd)?);
+1
View File
@@ -18,6 +18,7 @@ pub fn run() {
commands::kill_process, commands::kill_process,
commands::resolve_binary, commands::resolve_binary,
commands::detect_instruments, commands::detect_instruments,
commands::get_profile_path,
commands::extract_gamut, commands::extract_gamut,
commands::run_targen, commands::run_targen,
commands::run_printtarg, commands::run_printtarg,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.1.13", "version": "0.1.14",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"
+1379
View File
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1,6 +1,7 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
import { setStage4Result } from './profcheck.js'; import { setStage4Result } from './profcheck.js';
import { loadGamutMesh } from './gamut_viewer.js';
let chartreadBasename = ""; let chartreadBasename = "";
let chartreadCwd = ""; let chartreadCwd = "";
@@ -80,7 +81,7 @@ export function initColprof() {
} }
}); });
const unlistenExit = await listen("process:exit", (event) => { const unlistenExit = await listen("process:exit", async (event) => {
if (event.payload.id === processId) { if (event.payload.id === processId) {
unlistenStdout(); unlistenStdout();
unlistenStderr(); unlistenStderr();
@@ -90,11 +91,23 @@ export function initColprof() {
if (event.payload.code === 0) { if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n"; logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n";
const iccFilename = `${basename}.icc`;
successInfo.textContent = `Profile: ${iccFilename} (${description})`; // Resolve real profile path (.icm on Windows, .icc on Unix)
let profilePath = cwd ? `${cwd}/${basename}.icc` : `${basename}.icc`;
try {
profilePath = await invoke("get_profile_path", { cwd, basename });
} catch (e) {
console.warn("Could not query platform profile path:", e);
}
const displayFilename = profilePath.split(/[\\/]/).pop() || `${basename}.icc`;
successInfo.textContent = `Profile: ${displayFilename} (${description})`;
successCard.classList.remove("hidden"); successCard.classList.remove("hidden");
setStage4Result(basename, cwd); setStage4Result(basename, cwd);
// Automatically extract gamut mesh for 3D visualization
triggerGamutExtraction(basename, cwd, profilePath);
} else { } else {
logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`; logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`;
} }
@@ -116,6 +129,26 @@ export function initColprof() {
} }
} }
async function triggerGamutExtraction(basename, cwd, profilePath) {
try {
const processId = `iccgamut_${basename}`;
const unlistenExit = await listen("process:exit", (event) => {
if (event.payload.id === processId) {
unlistenExit();
if (event.payload.code === 0) {
const sep = cwd.includes('\\') ? '\\' : '/';
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
loadGamutMesh(gamFilePath, 0x3b82f6);
}
}
});
await invoke("extract_gamut", { iccPath: profilePath });
} catch (err) {
console.warn("Automated gamut extraction notice:", err);
}
}
function advanceToStage5() { function advanceToStage5() {
const steps = document.querySelectorAll('.step'); const steps = document.querySelectorAll('.step');
const stages = document.querySelectorAll('.stage'); const stages = document.querySelectorAll('.stage');
+74 -15
View File
@@ -2,40 +2,74 @@ const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
let scene, camera, renderer, controls; let scene, camera, renderer, controls;
let currentProfileMesh, sRgbMesh; let currentProfileMesh = null;
let sRgbMesh = null;
export async function initGamutViewer() { export async function initGamutViewer() {
try { try {
const container = document.getElementById('gamutViewerContainer'); const container = document.getElementById('gamutViewerContainer');
if (!container || typeof THREE === 'undefined') return; if (!container || typeof THREE === 'undefined') return;
// Clear any existing contents if re-initialized
container.innerHTML = "";
scene = new THREE.Scene(); scene = new THREE.Scene();
scene.background = new THREE.Color(0x111116); scene.background = new THREE.Color(0x111116);
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 1000); const width = container.clientWidth > 0 ? container.clientWidth : 500;
camera.position.set(150, 100, 150); const height = container.clientHeight > 0 ? container.clientHeight : 360;
renderer = new THREE.WebGLRenderer({ antialias: true }); camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
renderer.setSize(container.clientWidth, container.clientHeight); camera.position.set(150, 110, 150);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
if (typeof THREE.OrbitControls !== 'undefined') { if (typeof THREE.OrbitControls !== 'undefined') {
controls = new THREE.OrbitControls(camera, renderer.domElement); controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.05;
} }
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*) // Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
const axesHelper = new THREE.AxesHelper(80); const axesHelper = new THREE.AxesHelper(100);
scene.add(axesHelper); scene.add(axesHelper);
// Add grid helper at L*=0 plane
const gridHelper = new THREE.GridHelper(200, 20, 0x444455, 0x222233);
gridHelper.position.y = 0;
scene.add(gridHelper);
// Lights // Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
scene.add(ambientLight); scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(100, 200, 100); dirLight1.position.set(100, 200, 100);
scene.add(dirLight); scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-100, -100, -100);
scene.add(dirLight2);
// Handle viewport resize dynamically (e.g., when switching to Stage 5 tab)
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const w = entry.contentRect.width;
const h = entry.contentRect.height;
if (w > 0 && h > 0 && renderer && camera) {
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
}
});
resizeObserver.observe(container);
animate(); animate();
// Load bundled sRGB reference gamut wireframe on startup
loadSrgbReferenceGamut();
} catch (err) { } catch (err) {
console.warn("Gamut viewer initialization notice:", err); console.warn("Gamut viewer initialization notice:", err);
} }
@@ -74,10 +108,15 @@ function parseCGATS(text) {
return points; return points;
} }
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) { function renderGamutFromText(text, color, isWireframe, previousMesh) {
try { if (!scene) return null;
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
const text = atob(base64Data); if (previousMesh) {
scene.remove(previousMesh);
if (previousMesh.geometry) previousMesh.geometry.dispose();
if (previousMesh.material) previousMesh.material.dispose();
}
const points = parseCGATS(text); const points = parseCGATS(text);
if (points.length < 3) return null; if (points.length < 3) return null;
@@ -103,13 +142,33 @@ export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe =
color: color, color: color,
wireframe: isWireframe, wireframe: isWireframe,
transparent: true, transparent: true,
opacity: isWireframe ? 0.4 : 0.75, opacity: isWireframe ? 0.35 : 0.75,
side: THREE.DoubleSide side: THREE.DoubleSide
}); });
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh); scene.add(mesh);
return mesh; return mesh;
}
export async function loadSrgbReferenceGamut() {
try {
const response = await fetch('assets/sRGB.gam');
if (response.ok) {
const text = await response.text();
sRgbMesh = renderGamutFromText(text, 0x94a3b8, true, sRgbMesh);
}
} catch (e) {
console.warn("Could not load sRGB reference gamut:", e);
}
}
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
try {
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
const text = atob(base64Data);
currentProfileMesh = renderGamutFromText(text, color, isWireframe, currentProfileMesh);
return currentProfileMesh;
} catch (e) { } catch (e) {
console.error("Failed to load gamut mesh:", e); console.error("Failed to load gamut mesh:", e);
return null; return null;
+14 -2
View File
@@ -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 { loadGamutMesh } from './gamut_viewer.js';
let profileBasename = ""; let profileBasename = "";
let profileCwd = ""; let profileCwd = "";
@@ -30,7 +31,14 @@ export function initProfcheck() {
const sep = cwd.includes('\\') ? '\\' : '/'; const sep = cwd.includes('\\') ? '\\' : '/';
const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`; const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`;
const iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`;
// Query platform-aware profile path (.icm on Windows, .icc on Unix)
let iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`;
try {
iccPath = await invoke("get_profile_path", { cwd, basename });
} catch (e) {
console.warn("Could not query platform profile path:", e);
}
logPre.textContent = ""; logPre.textContent = "";
logContainer.open = false; logContainer.open = false;
@@ -63,7 +71,7 @@ export function initProfcheck() {
} }
}); });
const unlistenExit = await listen("process:exit", (event) => { const unlistenExit = await listen("process:exit", async (event) => {
if (event.payload.id === processId) { if (event.payload.id === processId) {
unlistenStdout(); unlistenStdout();
unlistenStderr(); unlistenStderr();
@@ -73,6 +81,10 @@ export function initProfcheck() {
if (event.payload.code === 0) { if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n"; logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
parseAndRenderReport(stdoutAccumulator); parseAndRenderReport(stdoutAccumulator);
// Ensure gamut mesh is loaded into 3D viewer
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
loadGamutMesh(gamFilePath, 0x3b82f6);
} else { } else {
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`; logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
} }