release(v0.3.0): Milestone 8 - Advanced Measurement & Workflow Enhancements #102

Merged
gronod merged 25 commits from development into main 2026-08-25 18:43:46 +01:00
10 changed files with 1125 additions and 34 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.2.1"
version = "0.3.0"
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"]
edition = "2021"
+108 -7
View File
@@ -30,14 +30,27 @@ pub async fn kill_process(
state.kill(&id).await
}
pub fn get_binary_candidates(binary_name: &str) -> Vec<String> {
if cfg!(windows) && !binary_name.to_lowercase().ends_with(".exe") {
vec![format!("{}.exe", binary_name), binary_name.to_string()]
} else {
vec![binary_name.to_string()]
}
}
#[tauri::command]
pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<String, String> {
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
let candidates = get_binary_candidates(&binary_name);
if let Some(dir) = settings.argyll_binary_dir {
if !dir.trim().is_empty() {
let custom_path = std::path::Path::new(&dir).join(&binary_name);
if custom_path.exists() {
return Ok(custom_path.to_string_lossy().to_string());
let base_dir = std::path::Path::new(&dir);
for name in &candidates {
let custom_path = base_dir.join(name);
if custom_path.exists() {
return Ok(custom_path.to_string_lossy().to_string());
}
}
}
}
@@ -50,10 +63,22 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<Strin
_ => "linux-x86_64",
};
for name in &candidates {
if let Ok(resource_path) = app.path().resolve(
format!("argyll/{}/{}", platform, name),
tauri::path::BaseDirectory::Resource,
) {
if resource_path.exists() {
return Ok(resource_path.to_string_lossy().to_string());
}
}
}
let primary_name = &candidates[0];
let resource_path = app
.path()
.resolve(
format!("argyll/{}/{}", platform, binary_name),
format!("argyll/{}/{}", platform, primary_name),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| e.to_string())?;
@@ -361,14 +386,24 @@ pub async fn read_tiff_preview_png(path: String) -> Result<String, String> {
pub struct ChartreadConfig {
pub basename: String,
pub cwd: String,
pub port: Option<String>,
}
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
vec![
let mut args = vec![
"-v".to_string(),
"-u".to_string(),
config.basename.clone(),
]
];
if let Some(ref port) = config.port {
if !port.trim().is_empty() {
args.push("-c".to_string());
args.push(port.trim().to_string());
}
}
args.push(config.basename.clone());
args
}
#[tauri::command]
@@ -385,6 +420,36 @@ pub async fn run_chartread(
state.spawn(app, id, binary, args, cwd).await
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AverageConfig {
pub inputs: Vec<String>,
pub output: String,
pub cwd: String,
}
pub fn build_average_args(config: &AverageConfig) -> Vec<String> {
let mut args = vec!["-v".to_string()];
for input in &config.inputs {
args.push(input.clone());
}
args.push(config.output.clone());
args
}
#[tauri::command]
pub async fn run_average(
app: AppHandle,
state: State<'_, ProcessManager>,
config: AverageConfig,
) -> Result<(), String> {
let binary = resolve_binary(app.clone(), "average".to_string()).await?;
let args = build_average_args(&config);
let id = format!("average_{}", config.output);
let cwd = Some(resolve_safe_cwd(&app, &config.cwd)?);
state.spawn(app, id, binary, args, cwd).await
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ColprofConfig {
pub algorithm: String,
@@ -674,11 +739,34 @@ mod tests {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
}
#[test]
fn test_build_chartread_args_with_port() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: Some("1".to_string()),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
}
#[test]
fn test_build_average_args() {
let config = AverageConfig {
inputs: vec!["pass1.ti3".to_string(), "pass2.ti3".to_string()],
output: "avg.ti3".to_string(),
cwd: "/home/user".to_string(),
};
let args = build_average_args(&config);
assert_eq!(args, vec!["-v", "pass1.ti3", "pass2.ti3", "avg.ti3"]);
}
#[test]
fn test_build_colprof_args() {
let config = ColprofConfig {
@@ -707,4 +795,17 @@ mod tests {
let args = build_profcheck_args(&config);
assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]);
}
#[test]
fn test_get_binary_candidates() {
let candidates = get_binary_candidates("targen");
if cfg!(windows) {
assert_eq!(candidates, vec!["targen.exe", "targen"]);
} else {
assert_eq!(candidates, vec!["targen"]);
}
let candidates_exe = get_binary_candidates("targen.exe");
assert_eq!(candidates_exe, vec!["targen.exe"]);
}
}
+6
View File
@@ -26,6 +26,7 @@ pub fn run() {
commands::read_file_base64,
commands::read_tiff_preview_png,
commands::run_chartread,
commands::run_average,
commands::run_colprof,
commands::run_profcheck,
commands::get_printers,
@@ -34,6 +35,11 @@ pub fn run() {
commands::print_target_native,
settings::load_settings,
settings::save_settings,
settings::get_all_presets,
settings::save_preset,
settings::delete_preset,
settings::export_preset_json,
settings::import_preset_json,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+167
View File
@@ -2,10 +2,99 @@ use serde::{Deserialize, Serialize};
use std::fs;
use tauri::{AppHandle, Manager};
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct ProfilingPreset {
pub id: String,
pub name: String,
pub description: Option<String>,
pub colour_space: String,
pub patch_count: u32,
pub white_patches: Option<u32>,
pub black_patches: Option<u32>,
pub instrument: String,
pub page_size: String,
pub bit_depth: u8,
pub dpi: u32,
pub colprof_algorithm: String,
pub colprof_quality: String,
pub colprof_intent: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppSettings {
pub argyll_binary_dir: Option<String>,
pub default_instrument: Option<String>,
#[serde(default)]
pub custom_presets: Vec<ProfilingPreset>,
}
pub fn get_default_presets() -> Vec<ProfilingPreset> {
vec![
ProfilingPreset {
id: "preset-std-rgb".to_string(),
name: "Standard RGB Photo (800 patches)".to_string(),
description: Some("Recommended for standard fine-art and photographic RGB printing on inkjet printers.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 800,
white_patches: Some(4),
black_patches: None,
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "m".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-hq-cmyk".to_string(),
name: "High-Gamut CMYK Proofing (1500 patches)".to_string(),
description: Some("High-precision CMYK press and RIP proofing profiling with deep shadow neutral boost.".to_string()),
colour_space: "cmyk".to_string(),
patch_count: 1500,
white_patches: None,
black_patches: Some(8),
instrument: "i1".to_string(),
page_size: "A3".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "h".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-draft-rgb".to_string(),
name: "Fast RGB Draft (400 patches)".to_string(),
description: Some("Quick turn-around draft profiling for testing new media.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 400,
white_patches: None,
black_patches: None,
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 150,
colprof_algorithm: "l".to_string(),
colprof_quality: "l".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-ultra-rgb".to_string(),
name: "Ultra Precision RGB (2500 patches)".to_string(),
description: Some("Maximum precision lookup table profile for exhibition and master printmaking.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 2500,
white_patches: Some(6),
black_patches: Some(6),
instrument: "i1".to_string(),
page_size: "A3".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "u".to_string(),
colprof_intent: None,
},
]
}
#[tauri::command]
@@ -25,3 +114,81 @@ pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String
let json = serde_json::to_string_pretty(&settings).unwrap();
fs::write(path.join("settings.json"), json).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_all_presets(app: AppHandle) -> Vec<ProfilingPreset> {
let mut presets = get_default_presets();
let settings = load_settings(app).unwrap_or_default();
presets.extend(settings.custom_presets);
presets
}
#[tauri::command]
pub fn save_preset(app: AppHandle, preset: ProfilingPreset) -> Result<Vec<ProfilingPreset>, String> {
let mut settings = load_settings(app.clone()).unwrap_or_default();
if let Some(pos) = settings.custom_presets.iter().position(|p| p.id == preset.id) {
settings.custom_presets[pos] = preset;
} else {
settings.custom_presets.push(preset);
}
save_settings(app.clone(), settings)?;
Ok(get_all_presets(app))
}
#[tauri::command]
pub fn delete_preset(app: AppHandle, id: String) -> Result<Vec<ProfilingPreset>, String> {
let mut settings = load_settings(app.clone()).unwrap_or_default();
settings.custom_presets.retain(|p| p.id != id);
save_settings(app.clone(), settings)?;
Ok(get_all_presets(app))
}
#[tauri::command]
pub fn export_preset_json(preset: ProfilingPreset) -> Result<String, String> {
serde_json::to_string_pretty(&preset).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_preset_json(json: String) -> Result<ProfilingPreset, String> {
serde_json::from_str::<ProfilingPreset>(&json).map_err(|e| format!("Invalid preset format: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_default_presets() {
let defaults = get_default_presets();
assert_eq!(defaults.len(), 4);
assert_eq!(defaults[0].id, "preset-std-rgb");
assert_eq!(defaults[0].colour_space, "rgb");
assert_eq!(defaults[0].patch_count, 800);
assert_eq!(defaults[1].colour_space, "cmyk");
assert_eq!(defaults[1].patch_count, 1500);
}
#[test]
fn test_preset_json_export_and_import() {
let preset = ProfilingPreset {
id: "custom-test".to_string(),
name: "Custom Test Preset".to_string(),
description: Some("Test description".to_string()),
colour_space: "rgb".to_string(),
patch_count: 1200,
white_patches: Some(2),
black_patches: Some(4),
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "h".to_string(),
colprof_intent: None,
};
let json = export_preset_json(preset.clone()).expect("Export failed");
let imported = import_preset_json(json).expect("Import failed");
assert_eq!(preset, imported);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.2.1",
"version": "0.3.0",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+81 -2
View File
@@ -8,7 +8,6 @@
<link rel="stylesheet" href="./styles/main.css" />
<script src="./js/three.min.js"></script>
<script src="./js/OrbitControls.js"></script>
<script src="./js/delaunator.min.js"></script>
<script type="module" src="./js/app.js" defer></script>
</head>
<body>
@@ -32,6 +31,21 @@
</button>
</div>
</div>
<!-- Profiling Preset Selector -->
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
<div style="display: flex; gap: 4px;">
<button type="button" id="btnSavePresetModal" class="icon-btn" title="Save current settings as Preset" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">💾</button>
<button type="button" id="btnOpenPresetsDialog" class="icon-btn" title="Manage Presets (Import/Export/Delete)" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">⚙️</button>
</div>
</div>
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
<option value="" disabled selected>Loading presets...</option>
</select>
</div>
<nav class="stepper">
<button class="step active" data-step="1">1. Generate Target</button>
<button class="step" data-step="2">2. Print Layout</button>
@@ -249,6 +263,19 @@
<h2>Measure Target</h2>
<p>Read the printed target patches with your spectrophotometer.</p>
<!-- Instrument & Hardware Port Selector -->
<div class="form-container" style="margin-bottom: 16px;">
<div class="form-group">
<label for="chartreadInstrumentSelect">Instrument / Hardware Port</label>
<div class="input-row" style="display: flex; gap: 8px; align-items: center;">
<select id="chartreadInstrumentSelect" style="flex: 1;">
<option value="">Auto-Detect (First Available Instrument)</option>
</select>
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
</div>
</div>
</div>
<!-- State & Prompt Area -->
<div class="chartread-status">
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
@@ -278,6 +305,20 @@
<!-- Swatch Grid -->
<div class="swatch-grid" id="swatchGrid"></div>
<!-- Multi-Pass Session & Averaging Controls -->
<div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
</div>
<p class="sub-label" style="margin-bottom: 12px;">You can measure additional printed copies to reduce measurement and printing noise before computing the profile.</p>
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
<div style="display: flex; gap: 8px;">
<button type="button" class="secondary" id="btnMeasureAnotherSheet"> Measure Another Sheet</button>
<button type="button" class="primary" id="btnFinishAndAverage">✓ Average Passes &amp; Proceed →</button>
</div>
</div>
<!-- Process Log -->
<details class="log-container hidden" id="chartreadLogContainer">
<summary>Process Output</summary>
@@ -469,7 +510,7 @@
<h5>Three.js</h5>
<p>Copyright (c) 2010-2023 three.js authors</p>
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
<h5>Delaunator</h5>
<p>Copyright (c) 2017, Mapbox</p>
@@ -484,5 +525,43 @@
</div>
</div>
</dialog>
<!-- Save Preset Dialog -->
<dialog id="savePresetDialog" class="settings-modal">
<div class="modal-content">
<h2>Save Profiling Preset</h2>
<p style="font-size: 0.9rem; opacity: 0.8; margin-bottom: 16px;">Save the current settings from Stage 1, 2, and 4 as a reusable profiling preset.</p>
<div class="form-group">
<label for="savePresetName">Preset Name</label>
<input type="text" id="savePresetName" placeholder="e.g. My Custom FineArt Preset">
</div>
<div class="form-group">
<label for="savePresetDesc">Description (Optional)</label>
<input type="text" id="savePresetDesc" placeholder="e.g. Optimized for 300gsm Cotton Rag with 1500 patches">
</div>
<div class="modal-actions">
<button id="btnConfirmSavePreset" class="primary">Save Preset</button>
<button id="btnCloseSavePresetDialog" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<!-- Manage Presets Dialog -->
<dialog id="managePresetsDialog" class="settings-modal" style="max-width: 600px;">
<div class="modal-content">
<h2>Manage Profiling Presets</h2>
<p style="font-size: 0.9rem; opacity: 0.8; margin-bottom: 14px;">View built-in and custom presets, or import and export preset configurations in JSON format.</p>
<div id="managePresetsList" style="display: flex; flex-direction: column; gap: 8px; max-height: 240px; overflow-y: auto; margin-bottom: 16px; padding: 4px;"></div>
<div style="display: flex; gap: 8px; justify-content: space-between; border-top: 1px solid var(--border-color, #333); padding-top: 14px;">
<div style="display: flex; gap: 8px;">
<button type="button" id="btnExportActivePreset" class="secondary" title="Export currently selected preset to JSON file">📤 Export Active</button>
<button type="button" id="btnImportPreset" class="secondary" title="Import preset from JSON file">📥 Import Preset</button>
</div>
<button id="btnCloseManagePresetsDialog" class="primary">Done</button>
</div>
</div>
</dialog>
</body>
</html>
+2
View File
@@ -5,6 +5,7 @@ import { initColprof } from './colprof.js';
import { initProfcheck } from './profcheck.js';
import { initSettings } from './settings.js';
import { initGamutViewer } from './gamut_viewer.js';
import { initPresets } from './presets.js';
import { wizardState } from './state.js';
const { invoke } = window.__TAURI__.core;
@@ -89,4 +90,5 @@ document.addEventListener('DOMContentLoaded', () => {
safeInit('Stage 5 (Profcheck)', initProfcheck);
safeInit('Settings', initSettings);
safeInit('Gamut Viewer', initGamutViewer);
safeInit('Presets', initPresets);
});
+157 -4
View File
@@ -36,11 +36,70 @@ export function initChartread() {
const btnRetry = document.getElementById("btnRetry");
const btnSkip = document.getElementById("btnSkip");
const btnCancel = document.getElementById("btnCancel");
const btnDetectInstruments = document.getElementById("btnDetectInstruments");
const instrumentSelect = document.getElementById("chartreadInstrumentSelect");
const promptText = document.getElementById("chartreadPrompt");
const stateLabel = document.getElementById("chartreadState");
const logContainer = document.getElementById("chartreadLogContainer");
const logPre = document.getElementById("chartreadLog");
// Instrument detection logic
if (btnDetectInstruments && instrumentSelect) {
btnDetectInstruments.addEventListener("click", async () => {
btnDetectInstruments.disabled = true;
btnDetectInstruments.textContent = "Detecting...";
setPrompt("Querying connected spectrophotometers and colorimeters via instlist...");
const detected = [];
try {
const unlistenStdout = await listen("process:stdout", (event) => {
if (event.payload.id !== "instlist" || !event.payload.line) return;
const line = event.payload.line.trim();
// Matches Argyll instlist output e.g. "1: 'i1Pro' on 'USB'" or "1: 'ColorMunki'" or "1 = 'i1Display'"
const match = line.match(/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i);
if (match) {
const index = match[1];
const name = match[2].trim();
const port = match[3] ? match[3].trim() : "";
detected.push({ index, name, port });
}
});
const unlistenExit = await listen("process:exit", (event) => {
if (event.payload.id !== "instlist") return;
unlistenStdout();
unlistenExit();
btnDetectInstruments.disabled = false;
btnDetectInstruments.textContent = "↻ Detect";
instrumentSelect.innerHTML = `<option value="">Auto-Detect (First Available Instrument)</option>`;
if (detected.length > 0) {
detected.forEach((inst) => {
const opt = document.createElement("option");
opt.value = inst.index;
opt.textContent = `${inst.index}: ${inst.name} ${inst.port ? `(${inst.port})` : ""}`;
instrumentSelect.appendChild(opt);
});
instrumentSelect.value = detected[0].index;
setPrompt(`Found ${detected.length} instrument(s): ${detected.map(d => d.name).join(", ")}`);
} else {
setPrompt("No instruments found via instlist. Ensure USB cable is plugged in.");
}
});
await invoke("detect_instruments");
} catch (err) {
console.error("detect_instruments error:", err);
btnDetectInstruments.disabled = false;
btnDetectInstruments.textContent = "↻ Detect";
setPrompt(`Instrument detection error: ${err}`);
}
});
}
function setState(newState) {
currentState = newState;
if (stateLabel) stateLabel.textContent = newState;
@@ -103,9 +162,12 @@ export function initChartread() {
setState(STATE.CALIBRATING);
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
const config = {
basename: basename,
cwd: cwd,
port: selectedPort,
};
currentProcessId = `chartread_${basename}`;
@@ -149,7 +211,7 @@ export function initChartread() {
}
});
const unlistenExit = await listen("process:exit", (event) => {
const unlistenExit = await listen("process:exit", async (event) => {
if (event.payload.id !== currentProcessId) return;
unlistenStdout();
unlistenStderr();
@@ -158,11 +220,23 @@ export function initChartread() {
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";
setPrompt("✅ Sheet measurement completed!");
logPre.textContent += "\n[SUCCESS] chartread sheet measurement completed.\n";
// Track pass in session
currentPassIndex++;
recordedPasses.push({
index: currentPassIndex,
filename: `${basename}.ti3`,
time: new Date().toLocaleTimeString(),
});
renderPassesList();
if (averagingPanel) averagingPanel.classList.remove("hidden");
if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`;
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
} else {
setState(STATE.FINISHED);
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
@@ -179,6 +253,85 @@ export function initChartread() {
});
}
// Multi-pass measurement controls
const averagingPanel = document.getElementById("chartreadAveragingPanel");
const passesList = document.getElementById("passesList");
const passCounterBadge = document.getElementById("passCounterBadge");
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
let currentPassIndex = 0;
const recordedPasses = [];
function renderPassesList() {
if (!passesList) return;
passesList.innerHTML = "";
recordedPasses.forEach((pass, i) => {
const item = document.createElement("div");
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;";
item.innerHTML = `<span><strong>Sheet Pass #${i + 1}</strong> (${pass.filename})</span><span style="opacity:0.7;">✓ ${pass.time}</span>`;
passesList.appendChild(item);
});
}
if (btnMeasureAnotherSheet) {
btnMeasureAnotherSheet.addEventListener("click", () => {
setState(STATE.IDLE);
setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Press 'Start Measurement'.`);
if (btnStartRead) btnStartRead.click();
});
}
if (btnFinishAndAverage) {
btnFinishAndAverage.addEventListener("click", async () => {
const basename = stage2Basename || wizardState.basename;
const cwd = stage2Cwd || wizardState.cwd;
if (recordedPasses.length <= 1) {
setPrompt("Single pass accepted. Proceeding to Stage 4...");
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
return;
}
setPrompt("Averaging measurement passes with Argyll average utility...");
btnFinishAndAverage.disabled = true;
try {
const averageConfig = {
inputs: recordedPasses.map(p => p.filename),
output: `${basename}.ti3`,
cwd: cwd,
};
const unlistenAvgExit = await listen("process:exit", (event) => {
if (event.payload.id !== `average_${basename}.ti3`) return;
unlistenAvgExit();
btnFinishAndAverage.disabled = false;
if (event.payload.code === 0) {
setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`);
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
} else {
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Proceeding with primary pass.`);
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
}
});
await invoke("run_average", { config: averageConfig });
} catch (e) {
console.error("run_average error:", e);
btnFinishAndAverage.disabled = false;
setStage3Result(basename, cwd);
advanceToStage4();
}
});
}
// Calibrate / confirm button — sends space + newline
if (btnCalibrate) {
btnCalibrate.addEventListener("click", async () => {
+288 -17
View File
@@ -25,12 +25,17 @@ export async function initGamutViewer() {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.domElement.style.touchAction = 'none';
container.appendChild(renderer.domElement);
if (typeof THREE.OrbitControls !== 'undefined') {
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
};
}
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
@@ -81,7 +86,7 @@ function animate() {
if (renderer && scene && camera) renderer.render(scene, camera);
}
function parseCGATS(text) {
export function parseCGATS(text) {
const lines = text.split('\n');
let dataStarted = false;
const points = [];
@@ -108,7 +113,283 @@ function parseCGATS(text) {
return points;
}
function renderGamutFromText(text, color, isWireframe, previousMesh) {
/**
* 3D Convex Hull (QuickHull in 3D) for CIELAB point clouds.
* Input: points array of { L, a, b }
* Output: { vertices: Float32Array, indices: Uint32Array } or null
*/
export function compute3DConvexHull(pts) {
if (!pts || pts.length < 4) return null;
// Filter duplicate / nearly coincident points
const points = [];
const eps = 1e-5;
for (const p of pts) {
const x = p.a; // X = a*
const y = p.L; // Y = L*
const z = p.b; // Z = b*
let duplicate = false;
for (const existing of points) {
const dx = existing.x - x;
const dy = existing.y - y;
const dz = existing.z - z;
if (dx * dx + dy * dy + dz * dz < eps * eps) {
duplicate = true;
break;
}
}
if (!duplicate) {
points.push({ x, y, z, id: points.length });
}
}
if (points.length < 4) return null;
// Vector operations helpers
function sub(v1, v2) { return { x: v1.x - v2.x, y: v1.y - v2.y, z: v1.z - v2.z }; }
function cross(v1, v2) {
return {
x: v1.y * v2.z - v1.z * v2.y,
y: v1.z * v2.x - v1.x * v2.z,
z: v1.x * v2.y - v1.y * v2.x
};
}
function dot(v1, v2) { return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; }
function lengthSq(v) { return v.x * v.x + v.y * v.y + v.z * v.z; }
function normalize(v) {
const len = Math.sqrt(lengthSq(v));
return len > 0 ? { x: v.x / len, y: v.y / len, z: v.z / len } : { x: 0, y: 0, z: 0 };
}
// Step 1: Find extreme points to construct initial simplex (tetrahedron)
let minX = 0, maxX = 0, minY = 0, maxY = 0, minZ = 0, maxZ = 0;
for (let i = 1; i < points.length; i++) {
if (points[i].x < points[minX].x) minX = i;
if (points[i].x > points[maxX].x) maxX = i;
if (points[i].y < points[minY].y) minY = i;
if (points[i].y > points[maxY].y) maxY = i;
if (points[i].z < points[minZ].z) minZ = i;
if (points[i].z > points[maxZ].z) maxZ = i;
}
let p1 = minX, p2 = maxX;
let maxDistSq = lengthSq(sub(points[p1], points[p2]));
const extremes = [minX, maxX, minY, maxY, minZ, maxZ];
for (let i = 0; i < extremes.length; i++) {
for (let j = i + 1; j < extremes.length; j++) {
const d = lengthSq(sub(points[extremes[i]], points[extremes[j]]));
if (d > maxDistSq) {
maxDistSq = d;
p1 = extremes[i];
p2 = extremes[j];
}
}
}
// Third point: furthest from line p1-p2
const v12 = sub(points[p2], points[p1]);
let p3 = -1;
let maxLineDistSq = 0;
for (let i = 0; i < points.length; i++) {
if (i === p1 || i === p2) continue;
const v1i = sub(points[i], points[p1]);
const cr = cross(v12, v1i);
const distSq = lengthSq(cr) / (lengthSq(v12) || 1);
if (distSq > maxLineDistSq) {
maxLineDistSq = distSq;
p3 = i;
}
}
if (p3 === -1 || maxLineDistSq < eps * eps) return null;
// Fourth point: furthest from plane p1-p2-p3
const planeNorm = normalize(cross(sub(points[p2], points[p1]), sub(points[p3], points[p1])));
let p4 = -1;
let maxPlaneDist = 0;
for (let i = 0; i < points.length; i++) {
if (i === p1 || i === p2 || i === p3) continue;
const dist = Math.abs(dot(planeNorm, sub(points[i], points[p1])));
if (dist > maxPlaneDist) {
maxPlaneDist = dist;
p4 = i;
}
}
if (p4 === -1 || maxPlaneDist < eps) return null;
// Helper to create a face with outward-pointing normal
function makeFace(a, b, c, insidePt) {
let norm = cross(sub(points[b], points[a]), sub(points[c], points[a]));
norm = normalize(norm);
if (dot(norm, sub(points[insidePt], points[a])) > 0) {
// Invert orientation
const tmp = b; b = c; c = tmp;
norm = { x: -norm.x, y: -norm.y, z: -norm.z };
}
return {
a, b, c,
normal: norm,
offset: -dot(norm, points[a]),
points: [],
active: true
};
}
function distToPlane(face, pt) {
return dot(face.normal, pt) + face.offset;
}
// Initial 4 faces of tetrahedron
let faces = [
makeFace(p1, p2, p3, p4),
makeFace(p1, p4, p2, p3),
makeFace(p2, p4, p3, p1),
makeFace(p3, p4, p1, p2)
];
// Assign remaining points to faces
const unassigned = [];
for (let i = 0; i < points.length; i++) {
if (i === p1 || i === p2 || i === p3 || i === p4) continue;
const pt = points[i];
let maxDist = 1e-6;
let bestFace = -1;
for (let f = 0; f < faces.length; f++) {
const dist = distToPlane(faces[f], pt);
if (dist > maxDist) {
maxDist = dist;
bestFace = f;
}
}
if (bestFace !== -1) {
faces[bestFace].points.push(i);
}
}
// QuickHull loop
while (true) {
let targetFace = -1;
for (let f = 0; f < faces.length; f++) {
if (faces[f].active && faces[f].points.length > 0) {
targetFace = f;
break;
}
}
if (targetFace === -1) break;
const face = faces[targetFace];
// Pick furthest point
let furthestPtIdx = face.points[0];
let maxD = distToPlane(face, points[furthestPtIdx]);
for (let i = 1; i < face.points.length; i++) {
const d = distToPlane(face, points[face.points[i]]);
if (d > maxD) {
maxD = d;
furthestPtIdx = face.points[i];
}
}
const eyePt = points[furthestPtIdx];
// Find all visible faces from eyePt
const visible = [];
for (let f = 0; f < faces.length; f++) {
if (faces[f].active && distToPlane(faces[f], eyePt) > 1e-6) {
visible.push(f);
}
}
// Find horizon edges (edges of visible faces that are shared with a non-visible face)
const edgeCount = new Map();
for (const fIdx of visible) {
const f = faces[fIdx];
const edges = [
[f.a, f.b],
[f.b, f.c],
[f.c, f.a]
];
for (const [u, v] of edges) {
const key = `${Math.min(u, v)}_${Math.max(u, v)}`;
const current = edgeCount.get(key) || { count: 0, u, v, origU: u, origV: v };
current.count++;
edgeCount.set(key, current);
}
}
const horizonEdges = [];
for (const [key, val] of edgeCount.entries()) {
if (val.count === 1) {
// Find orientation from visible face
horizonEdges.push({ u: val.origU, v: val.origV });
}
}
// Collect all orphaned points from visible faces to reassign
const orphanPoints = [];
for (const fIdx of visible) {
faces[fIdx].active = false;
for (const pIdx of faces[fIdx].points) {
if (pIdx !== furthestPtIdx) orphanPoints.push(pIdx);
}
}
// Create new faces from horizon edges to eyePt
const newFaces = [];
// Center point of tetrahedron for orientation check
const centerPt = {
x: (points[p1].x + points[p2].x + points[p3].x + points[p4].x) / 4,
y: (points[p1].y + points[p2].y + points[p3].y + points[p4].y) / 4,
z: (points[p1].z + points[p2].z + points[p3].z + points[p4].z) / 4
};
for (const edge of horizonEdges) {
const newF = makeFace(edge.u, edge.v, furthestPtIdx, centerPt);
newFaces.push(newF);
}
// Distribute orphaned points to new faces
for (const pIdx of orphanPoints) {
const pt = points[pIdx];
let maxDist = 1e-6;
let bestF = null;
for (const nF of newFaces) {
const dist = distToPlane(nF, pt);
if (dist > maxDist) {
maxDist = dist;
bestF = nF;
}
}
if (bestF) {
bestF.points.push(pIdx);
}
}
for (const nF of newFaces) {
faces.push(nF);
}
}
// Build geometry buffers from active faces
const activeFaces = faces.filter(f => f.active);
const indices = [];
const usedPoints = new Map();
const verticesList = [];
for (const f of activeFaces) {
for (const pIdx of [f.a, f.b, f.c]) {
if (!usedPoints.has(pIdx)) {
usedPoints.set(pIdx, verticesList.length / 3);
verticesList.push(points[pIdx].x, points[pIdx].y, points[pIdx].z);
}
indices.push(usedPoints.get(pIdx));
}
}
return {
vertices: new Float32Array(verticesList),
indices: new Uint32Array(indices)
};
}
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
if (!scene) return null;
if (previousMesh) {
@@ -118,24 +399,14 @@ function renderGamutFromText(text, color, isWireframe, previousMesh) {
}
const points = parseCGATS(text);
if (points.length < 3) return null;
if (points.length < 4) return null;
const coords = [];
const vertices = new Float32Array(points.length * 3);
points.forEach((pt, i) => {
coords.push(pt.a, pt.b);
vertices[i * 3] = pt.a; // X = a*
vertices[i * 3 + 1] = pt.L; // Y = L*
vertices[i * 3 + 2] = pt.b; // Z = b*
});
if (typeof Delaunator === 'undefined') return null;
const delaunay = new Delaunator(coords);
const hull = compute3DConvexHull(points);
if (!hull) return null;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setIndex(Array.from(delaunay.triangles));
geometry.setAttribute('position', new THREE.BufferAttribute(hull.vertices, 3));
geometry.setIndex(Array.from(hull.indices));
geometry.computeVertexNormals();
const material = new THREE.MeshLambertMaterial({
+312
View File
@@ -0,0 +1,312 @@
const { invoke } = window.__TAURI__.core;
let currentPresets = [];
let activePresetId = "preset-std-rgb";
export async function initPresets() {
const presetSelect = document.getElementById("presetSelect");
const btnSavePresetModal = document.getElementById("btnSavePresetModal");
const btnOpenPresetsDialog = document.getElementById("btnOpenPresetsDialog");
const savePresetDialog = document.getElementById("savePresetDialog");
const btnCloseSavePresetDialog = document.getElementById("btnCloseSavePresetDialog");
const btnConfirmSavePreset = document.getElementById("btnConfirmSavePreset");
const savePresetName = document.getElementById("savePresetName");
const savePresetDesc = document.getElementById("savePresetDesc");
const managePresetsDialog = document.getElementById("managePresetsDialog");
const btnCloseManagePresetsDialog = document.getElementById("btnCloseManagePresetsDialog");
const managePresetsList = document.getElementById("managePresetsList");
const btnExportActivePreset = document.getElementById("btnExportActivePreset");
const btnImportPreset = document.getElementById("btnImportPreset");
async function loadPresets() {
try {
currentPresets = await invoke("get_all_presets");
renderPresetDropdown();
renderManagePresetsList();
} catch (err) {
console.error("Failed to load presets:", err);
}
}
function renderPresetDropdown() {
if (!presetSelect) return;
presetSelect.innerHTML = "";
currentPresets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
if (p.id === activePresetId) opt.selected = true;
presetSelect.appendChild(opt);
});
}
function renderManagePresetsList() {
if (!managePresetsList) return;
managePresetsList.innerHTML = "";
currentPresets.forEach((p) => {
const isBuiltin = p.id.startsWith("preset-");
const item = document.createElement("div");
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:8px 12px; border-radius:6px;";
item.innerHTML = `
<div style="flex:1; margin-right:12px;">
<div style="font-weight:600; font-size:0.9rem;">${p.name} ${isBuiltin ? '<span style="font-size:0.7rem; opacity:0.6; border:1px solid #555; padding:1px 4px; border-radius:3px; margin-left:4px;">Built-in</span>' : ''}</div>
<div style="font-size:0.75rem; opacity:0.7; margin-top:2px;">${p.description || "No description"}</div>
<div style="font-size:0.7rem; opacity:0.5; margin-top:2px;">${p.colour_space.toUpperCase()}${p.patch_count} patches • ${p.page_size}${p.bit_depth}-bit • Quality ${p.colprof_quality.toUpperCase()}</div>
</div>
<div style="display:flex; gap:6px;">
<button type="button" class="secondary btn-export-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Export</button>
${!isBuiltin ? `<button type="button" class="danger btn-delete-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Delete</button>` : ''}
</div>
`;
managePresetsList.appendChild(item);
});
// Attach listeners for export / delete
managePresetsList.querySelectorAll(".btn-export-one").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-id");
const preset = currentPresets.find(p => p.id === id);
if (preset) exportPreset(preset);
});
});
managePresetsList.querySelectorAll(".btn-delete-one").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-id");
if (confirm("Delete this custom preset?")) {
try {
currentPresets = await invoke("delete_preset", { id });
if (activePresetId === id) activePresetId = currentPresets[0]?.id || "preset-std-rgb";
renderPresetDropdown();
renderManagePresetsList();
} catch (err) {
alert("Delete failed: " + err);
}
}
});
});
}
function applyPreset(preset) {
if (!preset) return;
activePresetId = preset.id;
// Stage 1 controls
const csRadios = document.querySelectorAll('input[name="colourSpace"]');
csRadios.forEach((r) => {
if (r.value.toLowerCase() === preset.colour_space.toLowerCase()) r.checked = true;
});
const patchCountPreset = document.getElementById("patchCountPreset");
const patchCountCustom = document.getElementById("patchCountCustom");
if (patchCountPreset && patchCountCustom) {
const match = Array.from(patchCountPreset.options).find(o => o.value === String(preset.patch_count));
if (match) {
patchCountPreset.value = String(preset.patch_count);
patchCountCustom.classList.add("hidden");
} else {
patchCountPreset.value = "custom";
patchCountCustom.value = preset.patch_count;
patchCountCustom.classList.remove("hidden");
}
}
const whitePatches = document.getElementById("whitePatches");
if (whitePatches) whitePatches.value = preset.white_patches !== null && preset.white_patches !== undefined ? preset.white_patches : "";
const blackPatches = document.getElementById("blackPatches");
if (blackPatches) blackPatches.value = preset.black_patches !== null && preset.black_patches !== undefined ? preset.black_patches : "";
// Stage 2 controls
const instrumentSelect = document.getElementById("instrumentSelect");
if (instrumentSelect && preset.instrument) instrumentSelect.value = preset.instrument;
const pageSizeSelect = document.getElementById("pageSizeSelect");
if (pageSizeSelect && preset.page_size) pageSizeSelect.value = preset.page_size;
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
bitDepthRadios.forEach((r) => {
if (Number(r.value) === preset.bit_depth) r.checked = true;
});
// Stage 4 controls
const colprofQuality = document.getElementById("colprofQuality");
if (colprofQuality && preset.colprof_quality) colprofQuality.value = preset.colprof_quality;
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
if (colprofAlgorithm && preset.colprof_algorithm) colprofAlgorithm.value = preset.colprof_algorithm;
}
function collectCurrentSettingsAsPreset(name, description) {
const csRadio = document.querySelector('input[name="colourSpace"]:checked');
const colour_space = csRadio ? csRadio.value : "rgb";
const patchCountPreset = document.getElementById("patchCountPreset");
const patchCountCustom = document.getElementById("patchCountCustom");
let patch_count = 800;
if (patchCountPreset) {
if (patchCountPreset.value === "custom" && patchCountCustom) {
patch_count = parseInt(patchCountCustom.value, 10) || 800;
} else {
patch_count = parseInt(patchCountPreset.value, 10) || 800;
}
}
const whiteInput = document.getElementById("whitePatches");
const white_patches = whiteInput && whiteInput.value ? parseInt(whiteInput.value, 10) : null;
const blackInput = document.getElementById("blackPatches");
const black_patches = blackInput && blackInput.value ? parseInt(blackInput.value, 10) : null;
const instrumentSelect = document.getElementById("instrumentSelect");
const instrument = instrumentSelect ? instrumentSelect.value : "i1";
const pageSizeSelect = document.getElementById("pageSizeSelect");
const page_size = pageSizeSelect ? pageSizeSelect.value : "A4";
const bitDepthRadio = document.querySelector('input[name="bitDepth"]:checked');
const bit_depth = bitDepthRadio ? parseInt(bitDepthRadio.value, 10) : 8;
const colprofQuality = document.getElementById("colprofQuality");
const colprof_quality = colprofQuality ? colprofQuality.value : "m";
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
const colprof_algorithm = colprofAlgorithm ? colprofAlgorithm.value : "l";
return {
id: `custom-${Date.now()}`,
name: name || "Custom Preset",
description: description || null,
colour_space,
patch_count,
white_patches,
black_patches,
instrument,
page_size,
bit_depth,
dpi: 300,
colprof_algorithm,
colprof_quality,
colprof_intent: null,
};
}
function exportPreset(preset) {
try {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(preset, null, 2));
const downloadAnchor = document.createElement("a");
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `${preset.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")}.preset.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
} catch (err) {
alert("Export failed: " + err);
}
}
// Dropdown change listener
if (presetSelect) {
presetSelect.addEventListener("change", () => {
const selected = currentPresets.find(p => p.id === presetSelect.value);
if (selected) applyPreset(selected);
});
}
// Save preset modal triggers
if (btnSavePresetModal && savePresetDialog) {
btnSavePresetModal.addEventListener("click", () => {
if (savePresetName) savePresetName.value = "";
if (savePresetDesc) savePresetDesc.value = "";
savePresetDialog.showModal();
});
}
if (btnCloseSavePresetDialog && savePresetDialog) {
btnCloseSavePresetDialog.addEventListener("click", () => {
savePresetDialog.close();
});
}
if (btnConfirmSavePreset && savePresetDialog) {
btnConfirmSavePreset.addEventListener("click", async () => {
const name = savePresetName ? savePresetName.value.trim() : "";
if (!name) {
alert("Please enter a name for your preset.");
return;
}
const desc = savePresetDesc ? savePresetDesc.value.trim() : "";
const preset = collectCurrentSettingsAsPreset(name, desc);
try {
currentPresets = await invoke("save_preset", { preset });
activePresetId = preset.id;
renderPresetDropdown();
renderManagePresetsList();
savePresetDialog.close();
} catch (err) {
alert("Save preset failed: " + err);
}
});
}
// Manage presets dialog triggers
if (btnOpenPresetsDialog && managePresetsDialog) {
btnOpenPresetsDialog.addEventListener("click", () => {
renderManagePresetsList();
managePresetsDialog.showModal();
});
}
if (btnCloseManagePresetsDialog && managePresetsDialog) {
btnCloseManagePresetsDialog.addEventListener("click", () => {
managePresetsDialog.close();
});
}
if (btnExportActivePreset) {
btnExportActivePreset.addEventListener("click", () => {
const preset = currentPresets.find(p => p.id === activePresetId);
if (preset) exportPreset(preset);
});
}
if (btnImportPreset) {
btnImportPreset.addEventListener("click", () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (evt) => {
try {
const jsonText = evt.target.result;
const imported = await invoke("import_preset_json", { json: jsonText });
imported.id = `custom-${Date.now()}`;
currentPresets = await invoke("save_preset", { preset: imported });
activePresetId = imported.id;
applyPreset(imported);
renderPresetDropdown();
renderManagePresetsList();
alert(`Preset "${imported.name}" imported successfully!`);
} catch (err) {
alert("Import failed: " + err);
}
};
reader.readAsText(file);
};
fileInput.click();
});
}
// Initial load
await loadPresets();
const defaultP = currentPresets.find(p => p.id === "preset-std-rgb");
if (defaultP) applyPreset(defaultP);
}