feat(gamut): integrate 3D gamut visualization and platform profile extension handling (v0.1.14) #74
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.1.13"
|
||||
version = "0.1.14"
|
||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||
authors = ["Gordon"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -102,6 +102,32 @@ pub async fn detect_instruments(app: AppHandle, state: State<'_, ProcessManager>
|
||||
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]
|
||||
pub async fn extract_gamut(
|
||||
app: AppHandle,
|
||||
@@ -110,18 +136,41 @@ pub async fn extract_gamut(
|
||||
) -> Result<(), String> {
|
||||
let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?;
|
||||
let path = std::path::Path::new(&icc_path);
|
||||
let basename = path.file_stem().unwrap().to_str().unwrap();
|
||||
let parent_dir = path.parent().map(|p| p.to_str().unwrap()).unwrap_or("");
|
||||
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_string_lossy().to_string()).unwrap_or_default();
|
||||
|
||||
let mut args = vec!["-w".to_string()];
|
||||
if !parent_dir.is_empty() {
|
||||
args.push("-d".to_string());
|
||||
args.push(parent_dir.to_string());
|
||||
// Auto-detect .icc vs .icm if the specified path does not exist directly
|
||||
let resolved_path = if path.exists() {
|
||||
icc_path.clone()
|
||||
} 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);
|
||||
state.spawn(app, id, binary, args, None).await
|
||||
state.spawn(app, id, binary, args, cwd).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -379,9 +428,24 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
|
||||
pub async fn run_profcheck(
|
||||
app: AppHandle,
|
||||
state: State<'_, ProcessManager>,
|
||||
config: ProfcheckConfig,
|
||||
mut config: ProfcheckConfig,
|
||||
) -> Result<(), String> {
|
||||
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 id = format!("profcheck_{}", config.ti3_path);
|
||||
let cwd = Some(resolve_safe_cwd(&app, &config.cwd)?);
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn run() {
|
||||
commands::kill_process,
|
||||
commands::resolve_binary,
|
||||
commands::detect_instruments,
|
||||
commands::get_profile_path,
|
||||
commands::extract_gamut,
|
||||
commands::run_targen,
|
||||
commands::run_printtarg,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ICCery",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
||||
+1379
File diff suppressed because it is too large
Load Diff
+36
-3
@@ -1,6 +1,7 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { setStage4Result } from './profcheck.js';
|
||||
import { loadGamutMesh } from './gamut_viewer.js';
|
||||
|
||||
let chartreadBasename = "";
|
||||
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) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
@@ -90,11 +91,23 @@ export function initColprof() {
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
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");
|
||||
|
||||
setStage4Result(basename, cwd);
|
||||
|
||||
// Automatically extract gamut mesh for 3D visualization
|
||||
triggerGamutExtraction(basename, cwd, profilePath);
|
||||
} else {
|
||||
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() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
+74
-15
@@ -2,40 +2,74 @@ const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let currentProfileMesh, sRgbMesh;
|
||||
let currentProfileMesh = null;
|
||||
let sRgbMesh = null;
|
||||
|
||||
export async function initGamutViewer() {
|
||||
try {
|
||||
const container = document.getElementById('gamutViewerContainer');
|
||||
if (!container || typeof THREE === 'undefined') return;
|
||||
|
||||
// Clear any existing contents if re-initialized
|
||||
container.innerHTML = "";
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x111116);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 1000);
|
||||
camera.position.set(150, 100, 150);
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
const height = container.clientHeight > 0 ? container.clientHeight : 360;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
|
||||
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);
|
||||
|
||||
if (typeof THREE.OrbitControls !== 'undefined') {
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Add grid helper at L*=0 plane
|
||||
const gridHelper = new THREE.GridHelper(200, 20, 0x444455, 0x222233);
|
||||
gridHelper.position.y = 0;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
||||
scene.add(ambientLight);
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight.position.set(100, 200, 100);
|
||||
scene.add(dirLight);
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight1.position.set(100, 200, 100);
|
||||
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();
|
||||
|
||||
// Load bundled sRGB reference gamut wireframe on startup
|
||||
loadSrgbReferenceGamut();
|
||||
} catch (err) {
|
||||
console.warn("Gamut viewer initialization notice:", err);
|
||||
}
|
||||
@@ -74,10 +108,15 @@ function parseCGATS(text) {
|
||||
return points;
|
||||
}
|
||||
|
||||
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
|
||||
try {
|
||||
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
|
||||
const text = atob(base64Data);
|
||||
function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
if (!scene) return null;
|
||||
|
||||
if (previousMesh) {
|
||||
scene.remove(previousMesh);
|
||||
if (previousMesh.geometry) previousMesh.geometry.dispose();
|
||||
if (previousMesh.material) previousMesh.material.dispose();
|
||||
}
|
||||
|
||||
const points = parseCGATS(text);
|
||||
if (points.length < 3) return null;
|
||||
|
||||
@@ -103,13 +142,33 @@ export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe =
|
||||
color: color,
|
||||
wireframe: isWireframe,
|
||||
transparent: true,
|
||||
opacity: isWireframe ? 0.4 : 0.75,
|
||||
opacity: isWireframe ? 0.35 : 0.75,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(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) {
|
||||
console.error("Failed to load gamut mesh:", e);
|
||||
return null;
|
||||
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { loadGamutMesh } from './gamut_viewer.js';
|
||||
|
||||
let profileBasename = "";
|
||||
let profileCwd = "";
|
||||
@@ -30,7 +31,14 @@ export function initProfcheck() {
|
||||
|
||||
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||
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 = "";
|
||||
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) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
@@ -73,6 +81,10 @@ export function initProfcheck() {
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||
parseAndRenderReport(stdoutAccumulator);
|
||||
|
||||
// Ensure gamut mesh is loaded into 3D viewer
|
||||
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
||||
loadGamutMesh(gamFilePath, 0x3b82f6);
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user