diff --git a/README.md b/README.md index 754700d..1504bab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.7.0-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.7.1-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) diff --git a/ROADMAP.md b/ROADMAP.md index 613a63a..205ee9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,7 +79,8 @@ ICCery is a native, cross-platform desktop application built with: - [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2. - [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation. -### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.0`) +### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.1`) +- [x] **3D Gamut Visualisation Rework (#185)** (`v0.7.1`): Purpose-built CIELAB axis scaffold with tick marks and crisp CSS2D HTML text labels, clean sRGB reference rendering using `THREE.EdgesGeometry` with faint transparent solid volume, per-vertex true-colour profile gamut shading via `labToSrgb()`, and glassmorphic legend overlay with independent layer visibility toggles. - [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck. - [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets. - [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings. diff --git a/package.json b/package.json index 838f32a..0d7739b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.7.0", + "version": "0.7.1", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 743efc3..1833ad1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.6.6" +version = "0.7.1" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d8831f..dee4814 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.7.0" +version = "0.7.1" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 40b6057..038dd4c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.7.0", + "version": "0.7.1", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 7c780f8..4adac1a 100644 --- a/src/index.html +++ b/src/index.html @@ -8,6 +8,7 @@ + @@ -776,8 +777,47 @@
-

3D Gamut Visualization (CIELAB)

-
+
+

3D Gamut Visualisation (CIELAB L* a* b*)

+

Drag to rotate • Scroll to zoom • Right-drag to pan

+
+ +
+ +
+ + +
+
Layers
+ +
+ + Profile Gamut + +
+ +
+ + sRGB Reference + +
+ +
+ + CIELAB Axes + +
+
+
@@ -837,7 +877,7 @@

About ICCery

-

Version: v0.3.2Build Date: August 2026

+

Version: v0.7.1Build Date: September 2026

Copyright © 2026 Gordon Bolton. All rights reserved.

Licences & EULA

diff --git a/src/js/CSS2DRenderer.js b/src/js/CSS2DRenderer.js new file mode 100644 index 0000000..83338b6 --- /dev/null +++ b/src/js/CSS2DRenderer.js @@ -0,0 +1,161 @@ +/** + * CSS2DRenderer - Three.js addon + * Adapted from three.js examples/jsm/renderers/CSS2DRenderer.js + * Modified to use global THREE object (IIFE pattern) for compatibility + * with projects using three.min.js as a script tag (not ES module). + * + * Source: https://github.com/mrdoob/three.js + * License: MIT + */ +( function () { + + const _vector = new THREE.Vector3(); + const _viewMatrix = new THREE.Matrix4(); + const _viewProjectionMatrix = new THREE.Matrix4(); + const _a = new THREE.Vector3(); + const _b = new THREE.Vector3(); + + class CSS2DObject extends THREE.Object3D { + + constructor( element = document.createElement( 'div' ) ) { + super(); + this.isCSS2DObject = true; + this.element = element; + this.element.style.position = 'absolute'; + this.element.style.userSelect = 'none'; + this.element.setAttribute( 'draggable', false ); + this.center = new THREE.Vector2( 0.5, 0.5 ); + this.rotation2D = 0; + + this.addEventListener( 'removed', function () { + this.traverse( function ( object ) { + if ( + object.element && + object.element instanceof object.element.ownerDocument.defaultView.Element && + object.element.parentNode !== null + ) { + object.element.remove(); + } + } ); + } ); + } + + copy( source, recursive ) { + super.copy( source, recursive ); + this.element = source.element.cloneNode( true ); + this.center = source.center; + this.rotation2D = source.rotation2D; + return this; + } + + } + + class CSS2DRenderer { + + constructor( parameters = {} ) { + const domElement = parameters.element !== undefined + ? parameters.element + : document.createElement( 'div' ); + + domElement.style.overflow = 'hidden'; + this.domElement = domElement; + + this._width = 0; + this._height = 0; + this._widthHalf = 0; + this._heightHalf = 0; + + const cache = { objects: new WeakMap() }; + + this.getSize = function () { + return { width: this._width, height: this._height }; + }; + + this.render = function ( scene, camera ) { + if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld(); + if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld(); + _viewMatrix.copy( camera.matrixWorldInverse ); + _viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix ); + this.renderObject( scene, scene, camera ); + this.zOrder( scene ); + }; + + this.renderObject = function ( object, scene, camera ) { + if ( object.isCSS2DObject ) { + _vector.setFromMatrixPosition( object.matrixWorld ); + _vector.applyMatrix4( _viewProjectionMatrix ); + const visible = ( object.visible === true ) && + ( _vector.z >= -1 && _vector.z <= 1 ) && + ( object.layers.test( camera.layers ) === true ); + const element = object.element; + element.style.display = ( visible === true ) ? '' : 'none'; + if ( visible === true ) { + object.onBeforeRender( this, scene, camera ); + const style = 'translate(' + + ( -100 * object.center.x ) + '%,' + + ( -100 * object.center.y ) + '%) ' + + 'translate(' + + ( _vector.x * this._widthHalf + this._widthHalf ) + 'px,' + + ( -_vector.y * this._heightHalf + this._heightHalf ) + 'px)'; + element.style.webkitTransform = style; + element.style.MozTransform = style; + element.style.oTransform = style; + element.style.transform = style; + if ( element.parentNode !== this.domElement ) { + this.domElement.appendChild( element ); + } + object.onAfterRender( this, scene, camera ); + } + cache.objects.set( object, { + distanceToCameraSquared: this.getDistanceToSquared( camera, object ) + } ); + } + for ( let i = 0, l = object.children.length; i < l; i++ ) { + this.renderObject( object.children[ i ], scene, camera ); + } + }; + + this.getDistanceToSquared = function ( object1, object2 ) { + _a.setFromMatrixPosition( object1.matrixWorld ); + _b.setFromMatrixPosition( object2.matrixWorld ); + return _a.distanceToSquared( _b ); + }; + + this.filterAndFlatten = function ( scene ) { + const result = []; + scene.traverse( function ( object ) { + if ( object.isCSS2DObject ) result.push( object ); + } ); + return result; + }; + + this.zOrder = function ( scene ) { + const sorted = this.filterAndFlatten( scene ).sort( ( a, b ) => { + if ( a.renderOrder !== b.renderOrder ) return b.renderOrder - a.renderOrder; + const dA = cache.objects.get( a ).distanceToCameraSquared; + const dB = cache.objects.get( b ).distanceToCameraSquared; + return dA - dB; + } ); + const zMax = sorted.length; + for ( let i = 0, l = sorted.length; i < l; i++ ) { + sorted[ i ].element.style.zIndex = ( zMax - i ).toString(); + } + }; + } + + setSize( width, height ) { + this._width = width; + this._height = height; + this._widthHalf = this._width / 2; + this._heightHalf = this._height / 2; + this.domElement.style.width = width + 'px'; + this.domElement.style.height = height + 'px'; + } + + } + + // Expose on global THREE object + THREE.CSS2DObject = CSS2DObject; + THREE.CSS2DRenderer = CSS2DRenderer; + +} )(); diff --git a/src/js/gamut_viewer.js b/src/js/gamut_viewer.js index 69b5373..03068ba 100644 --- a/src/js/gamut_viewer.js +++ b/src/js/gamut_viewer.js @@ -1,72 +1,89 @@ -import { computeQuickHull } from "./vendor/quickhull.js"; +import { computeQuickHull } from "./vendor/quickhull.js"; +import { labToSrgb } from "./color_convert.js"; + const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; -let scene, camera, renderer, controls; +let scene, camera, renderer, labelRenderer, controls; let currentProfileMesh = null; -let sRgbMesh = null; +let sRgbGroup = null; +let axisScaffoldGroup = null; +// ───────────────────────────────────────────────────────────────────────────── +// Public: initialise the gamut viewer +// ───────────────────────────────────────────────────────────────────────────── export async function initGamutViewer() { try { const container = document.getElementById('gamutViewerContainer'); if (!container || typeof THREE === 'undefined') return; - // Clear any existing contents if re-initialized + // Clear any existing contents if re-initialised container.innerHTML = ""; scene = new THREE.Scene(); - scene.background = new THREE.Color(0x111116); + scene.background = new THREE.Color(0x0e0e14); - const width = container.clientWidth > 0 ? container.clientWidth : 500; - const height = container.clientHeight > 0 ? container.clientHeight : 360; + const width = container.clientWidth > 0 ? container.clientWidth : 500; + const height = container.clientHeight > 0 ? container.clientHeight : 400; - camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000); - camera.position.set(150, 110, 150); + // ── WebGL renderer ──────────────────────────────────────────────────── + camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000); + camera.position.set(180, 120, 180); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.domElement.style.touchAction = 'none'; + renderer.domElement.style.display = 'block'; container.appendChild(renderer.domElement); + // ── CSS2D label renderer ────────────────────────────────────────────── + if (typeof THREE.CSS2DRenderer !== 'undefined') { + labelRenderer = new THREE.CSS2DRenderer(); + labelRenderer.setSize(width, height); + labelRenderer.domElement.style.position = 'absolute'; + labelRenderer.domElement.style.top = '0'; + labelRenderer.domElement.style.left = '0'; + labelRenderer.domElement.style.pointerEvents = 'none'; + container.appendChild(labelRenderer.domElement); + } + + // ── Orbit controls ──────────────────────────────────────────────────── if (typeof THREE.OrbitControls !== 'undefined') { controls = new THREE.OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.05; + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.target.set(0, 50, 0); // orbit around the centre of L*=50 + controls.update(); controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }; } - // Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*) - 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.7); + // ── Lighting ────────────────────────────────────────────────────────── + const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); scene.add(ambientLight); const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8); - dirLight1.position.set(100, 200, 100); + dirLight1.position.set(150, 250, 150); scene.add(dirLight1); - const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4); - dirLight2.position.set(-100, -100, -100); + const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.35); + dirLight2.position.set(-120, -80, -120); scene.add(dirLight2); - // Handle viewport resize dynamically (e.g., when switching to Stage 5 tab) + // ── CIELAB axis scaffold + labels ───────────────────────────────────── + buildAxisScaffold(); + + // ── Resize handling ─────────────────────────────────────────────────── const resizeObserver = new ResizeObserver((entries) => { - for (let entry of entries) { + for (const 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); + if (labelRenderer) labelRenderer.setSize(w, h); } } }); @@ -74,59 +91,255 @@ export async function initGamutViewer() { animate(); - // Load bundled sRGB reference gamut wireframe on startup + // ── Wire toggle controls ────────────────────────────────────────────── + _wireToggles(); + + // ── Load bundled sRGB reference gamut ───────────────────────────────── loadSrgbReferenceGamut(); + } catch (err) { - console.warn("Gamut viewer initialization notice:", err); + console.warn("Gamut viewer initialisation notice:", err); } } +// ───────────────────────────────────────────────────────────────────────────── +// Animation loop +// ───────────────────────────────────────────────────────────────────────────── function animate() { requestAnimationFrame(animate); if (controls) controls.update(); - if (renderer && scene && camera) renderer.render(scene, camera); + if (renderer && scene && camera) { + renderer.render(scene, camera); + if (labelRenderer) labelRenderer.render(scene, camera); + } } -export function parseGamutFile(text) { - const lines = text.split('\n'); - const vertices = []; // [[L, a, b], ...] - const faces = []; // [[v0, v1, v2], ...] - let dataStarted = false; - let dataBlock = 0; // 0 = not in data, 1 = vertices, 2 = faces - let fieldCount = 0; - - for (const line of lines) { - const trimmed = line.trim(); - - if (trimmed.startsWith('NUMBER_OF_FIELDS')) { - fieldCount = parseInt(trimmed.split(/\s+/)[1], 10); - } - if (trimmed === 'BEGIN_DATA') { - dataBlock++; - dataStarted = true; - continue; - } - if (trimmed === 'END_DATA') { - dataStarted = false; - continue; - } - - if (dataStarted) { - const parts = trimmed.split(/\s+/).map(Number); - if (dataBlock === 1 && parts.length >= 4) { - // Vertex: VERTEX_NO LAB_L LAB_A LAB_B - vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b] - } else if (dataBlock === 2 && parts.length >= 3) { - // Face: VERTEX_0 VERTEX_1 VERTEX_2 - faces.push([parts[0], parts[1], parts[2]]); - } +// ───────────────────────────────────────────────────────────────────────────── +// Build custom CIELAB axis scaffold (replaces THREE.AxesHelper) +// CIELAB mapping: X = a* (−128 → +128), Y = L* (0 → 100), Z = b* (−128 → +128) +// ───────────────────────────────────────────────────────────────────────────── +function buildAxisScaffold() { + axisScaffoldGroup = new THREE.Group(); + + const AXIS_MAT_L = new THREE.LineBasicMaterial({ color: 0xcccccc, transparent: true, opacity: 0.7 }); + const AXIS_MAT_A = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 }); + const AXIS_MAT_B = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 }); + const TICK_MAT = new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.8 }); + const BOX_MAT = new THREE.LineBasicMaterial({ color: 0x232336, transparent: true, opacity: 0.9 }); + const GRID_MAT = new THREE.LineBasicMaterial({ color: 0x1e1e2e }); + + // ── Bounding box (full CIELAB domain: a*/b* ±128, L* 0→100) ────────────── + const boxGeo = new THREE.BoxGeometry(256, 100, 256); + const edgesGeo = new THREE.EdgesGeometry(boxGeo); + const boxLines = new THREE.LineSegments(edgesGeo, BOX_MAT); + boxLines.position.set(0, 50, 0); // centre at a*=0, L*=50, b*=0 + axisScaffoldGroup.add(boxLines); + + // ── Ground plane grid at L*=0 (a*/b* plane) ────────────────────────────── + const gridHelper = new THREE.GridHelper(256, 16, 0x1e1e2e, 0x1a1a28); + gridHelper.position.y = 0; + axisScaffoldGroup.add(gridHelper); + + // ── L* axis line (vertical, Y): 0 → 100 ────────────────────────────────── + axisScaffoldGroup.add(_line([0, 0, 0], [0, 100, 0], AXIS_MAT_L)); + + // ── a* axis line (X): −128 → +128 ──────────────────────────────────────── + axisScaffoldGroup.add(_line([-128, 0, 0], [128, 0, 0], AXIS_MAT_A)); + + // ── b* axis line (Z): −128 → +128 ──────────────────────────────────────── + axisScaffoldGroup.add(_line([0, 0, -128], [0, 0, 128], AXIS_MAT_B)); + + // ── Tick marks — L* axis (y direction) ─────────────────────────────────── + for (const v of [25, 50, 75, 100]) { + axisScaffoldGroup.add(_line([-4, v, 0], [4, v, 0], TICK_MAT)); + axisScaffoldGroup.add(_line([0, v, -4], [0, v, 4], TICK_MAT)); + } + + // ── Tick marks — a* axis (x direction) ─────────────────────────────────── + for (const v of [-128, -64, 64, 128]) { + axisScaffoldGroup.add(_line([v, -3, 0], [v, 3, 0], TICK_MAT)); + } + + // ── Tick marks — b* axis (z direction) ─────────────────────────────────── + for (const v of [-128, -64, 64, 128]) { + axisScaffoldGroup.add(_line([0, -3, v], [0, 3, v], TICK_MAT)); + } + + scene.add(axisScaffoldGroup); + + // ── CSS2D axis labels ───────────────────────────────────────────────────── + if (typeof THREE.CSS2DObject !== 'undefined') { + const labels = [ + // Primary axis name labels + { text: 'L*', pos: [0, 110, 0 ], cls: 'gamut-axis-label gamut-axis-primary' }, + { text: '+a* (Red →)', pos: [140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-pos' }, + { text: '← −a* (Green)', pos: [-140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-neg' }, + { text: '+b* (Yellow →)', pos: [0, 0, 140], cls: 'gamut-axis-label gamut-axis-b-pos' }, + { text: '← −b* (Blue)', pos: [0, 0, -140], cls: 'gamut-axis-label gamut-axis-b-neg' }, + // Tick value labels on L* axis + { text: '25', pos: [10, 25, 0], cls: 'gamut-axis-label gamut-tick-label' }, + { text: '50', pos: [10, 50, 0], cls: 'gamut-axis-label gamut-tick-label' }, + { text: '75', pos: [10, 75, 0], cls: 'gamut-axis-label gamut-tick-label' }, + { text: '100', pos: [10, 100, 0], cls: 'gamut-axis-label gamut-tick-label' }, + ]; + + for (const { text, pos, cls } of labels) { + const div = document.createElement('div'); + div.textContent = text; + div.className = cls; + const label = new THREE.CSS2DObject(div); + label.position.set(...pos); + axisScaffoldGroup.add(label); } } - +} + +// Helper: create a THREE.Line between two points +function _line(from, to, material) { + const geo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(...from), + new THREE.Vector3(...to) + ]); + return new THREE.Line(geo, material); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] } +// ───────────────────────────────────────────────────────────────────────────── +export function parseGamutFile(text) { + const lines = text.split('\n'); + const vertices = []; + const faces = []; + let dataStarted = false; + let dataBlock = 0; // 1 = vertices section, 2 = faces section + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; } + if (trimmed === 'END_DATA') { dataStarted = false; continue; } + if (!dataStarted) continue; + + const parts = trimmed.split(/\s+/).map(Number); + if (dataBlock === 1 && parts.length >= 4) { + vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b] + } else if (dataBlock === 2 && parts.length >= 3) { + faces.push([parts[0], parts[1], parts[2]]); + } + } + return { vertices, faces }; } -export function renderGamutFromText(text, color, isWireframe, previousMesh) { +// ───────────────────────────────────────────────────────────────────────────── +// Build a BufferGeometry from parsed gamut data +// Returns null if insufficient data. +// ───────────────────────────────────────────────────────────────────────────── +function _buildGeometry(vertices, faces) { + if (vertices.length < 4) return null; + + const geometry = new THREE.BufferGeometry(); + + if (faces.length > 0) { + // Pre-triangulated mesh from .gam file + const posArr = new Float32Array(vertices.length * 3); + for (let i = 0; i < vertices.length; i++) { + const [L, a, b] = vertices[i]; + posArr[i * 3] = a; // X = a* + posArr[i * 3 + 1] = L; // Y = L* + posArr[i * 3 + 2] = b; // Z = b* + } + const idxArr = new Uint32Array(faces.length * 3); + for (let i = 0; i < faces.length; i++) { + idxArr[i * 3] = faces[i][0]; + idxArr[i * 3 + 1] = faces[i][1]; + idxArr[i * 3 + 2] = faces[i][2]; + } + geometry.setAttribute('position', new THREE.BufferAttribute(posArr, 3)); + geometry.setIndex(new THREE.BufferAttribute(idxArr, 1)); + geometry.computeVertexNormals(); + } else { + // Fallback: convex hull from point cloud + const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); + const hullFaces = computeQuickHull(pts); + if (!hullFaces || hullFaces.length === 0) return null; + + const vList = []; + const idxList = []; + const ptMap = new Map(); + for (const f of hullFaces) { + for (const p of [f.a, f.b, f.c]) { + const key = `${p.x}_${p.y}_${p.z}`; + if (!ptMap.has(key)) { ptMap.set(key, vList.length / 3); vList.push(p.x, p.y, p.z); } + idxList.push(ptMap.get(key)); + } + } + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vList), 3)); + geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(idxList), 1)); + geometry.computeVertexNormals(); + + // Re-extract vertices for colour calculation below + const posAttr = geometry.getAttribute('position'); + vertices = []; + for (let i = 0; i < posAttr.count; i++) { + // Note: stored as (a, L, b) — convert back to [L, a, b] + vertices.push([posAttr.getY(i), posAttr.getX(i), posAttr.getZ(i)]); + } + } + + return { geometry, vertices }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Render sRGB reference gamut as EdgesGeometry outline + faint solid fill +// Returned as a THREE.Group stored in sRgbGroup +// ───────────────────────────────────────────────────────────────────────────── +function _renderSrgbReference(text, previousGroup) { + if (!scene) return null; + + if (previousGroup) { + scene.remove(previousGroup); + previousGroup.traverse(child => { + if (child.geometry) child.geometry.dispose(); + if (child.material) child.material.dispose(); + }); + } + + const { vertices, faces } = parseGamutFile(text); + const built = _buildGeometry(vertices, faces); + if (!built) return null; + + const { geometry } = built; + const group = new THREE.Group(); + + // ── 1. Faint transparent solid fill ────────────────────────────────────── + const solidMat = new THREE.MeshLambertMaterial({ + color: 0x8899bb, + transparent: true, + opacity: 0.07, + side: THREE.DoubleSide, + depthWrite: false + }); + group.add(new THREE.Mesh(geometry, solidMat)); + + // ── 2. Clean structural outline via EdgesGeometry (15° threshold) ───────── + // This removes all co-planar internal triangle edges, leaving only the + // structural contours of the gamut surface hull. + const edgesGeo = new THREE.EdgesGeometry(geometry, 15); + const edgesMat = new THREE.LineBasicMaterial({ + color: 0x6688aa, + transparent: true, + opacity: 0.55 + }); + group.add(new THREE.LineSegments(edgesGeo, edgesMat)); + + scene.add(group); + return group; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Render profile gamut with per-vertex true Lab→sRGB colour +// ───────────────────────────────────────────────────────────────────────────── +function _renderProfileGamut(text, previousMesh) { if (!scene) return null; if (previousMesh) { @@ -136,61 +349,31 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) { } const { vertices, faces } = parseGamutFile(text); - if (vertices.length < 4) return null; + const built = _buildGeometry(vertices, faces); + if (!built) return null; - let geometry = new THREE.BufferGeometry(); + const { geometry } = built; + let verts = built.vertices; - if (faces.length > 0) { - // We have a pre-triangulated mesh - const positionArray = new Float32Array(vertices.length * 3); - for (let i = 0; i < vertices.length; i++) { - const [L, a, b] = vertices[i]; - positionArray[i * 3] = a; // x = a* - positionArray[i * 3 + 1] = L; // y = L* - positionArray[i * 3 + 2] = b; // z = b* - } - - const indexArray = new Uint32Array(faces.length * 3); - for (let i = 0; i < faces.length; i++) { - indexArray[i * 3] = faces[i][0]; - indexArray[i * 3 + 1] = faces[i][1]; - indexArray[i * 3 + 2] = faces[i][2]; - } - - geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3)); - geometry.setIndex(new THREE.BufferAttribute(indexArray, 1)); - geometry.computeVertexNormals(); - } else { - // Fallback to point cloud hull if no faces are present - const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // { x: a, y: L, z: b } - const hullFaces = computeQuickHull(pts); - if (!hullFaces || hullFaces.length === 0) return null; - - const verticesList = []; - const indices = []; - const ptMap = new Map(); - - for (const f of hullFaces) { - for (const p of [f.a, f.b, f.c]) { - const key = `${p.x}_${p.y}_${p.z}`; - if (!ptMap.has(key)) { - ptMap.set(key, verticesList.length / 3); - verticesList.push(p.x, p.y, p.z); - } - indices.push(ptMap.get(key)); - } - } - - geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verticesList), 3)); - geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1)); - geometry.computeVertexNormals(); + // ── Per-vertex colour: Lab → sRGB ───────────────────────────────────────── + // geometry 'position' is laid out as [a, L, b] per vertex + const posAttr = geometry.getAttribute('position'); + const colorArr = new Float32Array(posAttr.count * 3); + for (let i = 0; i < posAttr.count; i++) { + const a_star = posAttr.getX(i); + const L_star = posAttr.getY(i); + const b_star = posAttr.getZ(i); + const [r, g, b] = labToSrgb(L_star, a_star, b_star); + colorArr[i * 3] = r / 255; + colorArr[i * 3 + 1] = g / 255; + colorArr[i * 3 + 2] = b / 255; } + geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 3)); const material = new THREE.MeshLambertMaterial({ - color: color, - wireframe: isWireframe, + vertexColors: true, transparent: true, - opacity: isWireframe ? 0.35 : 0.75, + opacity: 0.88, side: THREE.DoubleSide }); @@ -199,26 +382,62 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) { return mesh; } +// ───────────────────────────────────────────────────────────────────────────── +// Public: load bundled sRGB reference gamut +// ───────────────────────────────────────────────────────────────────────────── 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); + sRgbGroup = _renderSrgbReference(text, sRgbGroup); } } catch (e) { console.warn("Could not load sRGB reference gamut:", e); } } -export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) { +// ───────────────────────────────────────────────────────────────────────────── +// Public: load and render a profile gamut from a .gam file path +// ───────────────────────────────────────────────────────────────────────────── +export async function loadGamutMesh(gamFilePath) { try { const base64Data = await invoke('read_file_base64', { path: gamFilePath }); const text = atob(base64Data); - currentProfileMesh = renderGamutFromText(text, color, isWireframe, currentProfileMesh); + currentProfileMesh = _renderProfileGamut(text, currentProfileMesh); return currentProfileMesh; } catch (e) { console.error("Failed to load gamut mesh:", e); return null; } } + +// ───────────────────────────────────────────────────────────────────────────── +// Toggle functions (exported for external use; also wired internally) +// ───────────────────────────────────────────────────────────────────────────── +export function toggleSrgbReference(visible) { + if (sRgbGroup) sRgbGroup.visible = visible; +} + +export function toggleProfileGamut(visible) { + if (currentProfileMesh) currentProfileMesh.visible = visible; +} + +export function toggleAxes(visible) { + if (axisScaffoldGroup) axisScaffoldGroup.visible = visible; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Wire legend toggle checkboxes to the toggle functions +// ───────────────────────────────────────────────────────────────────────────── +function _wireToggles() { + const bindings = [ + ['chkProfileGamut', toggleProfileGamut ], + ['chkSrgbReference', toggleSrgbReference ], + ['chkLabAxes', toggleAxes ], + ]; + for (const [id, fn] of bindings) { + const el = document.getElementById(id); + if (el) el.addEventListener('change', (e) => fn(e.target.checked)); + } +} diff --git a/src/styles/main.css b/src/styles/main.css index 3e3dcd0..5b653ea 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1349,3 +1349,210 @@ button.danger:hover { } +/* ═══════════════════════════════════════════════════════════════════════════ + 3D Gamut Viewer — Stage 5 + ═══════════════════════════════════════════════════════════════════════════ */ + +.gamut-viewer-section { + margin-top: 24px; + border-top: 1px solid var(--border-color, #333); + padding-top: 16px; +} + +.gamut-viewer-header { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.gamut-viewer-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-color, #d4d4d4); +} + +.gamut-viewer-subtitle { + font-weight: 400; + opacity: 0.55; + font-size: 0.85rem; + margin-left: 4px; +} + +.gamut-viewer-hint { + margin: 0; + font-size: 0.72rem; + opacity: 0.45; + letter-spacing: 0.02em; +} + +/* Wrapper gives the overlay panel a positioning context */ +.gamut-viewer-wrap { + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.07); +} + +#gamutViewerContainer { + width: 100%; + min-height: 420px; + background: #0e0e14; + display: block; + position: relative; +} + +/* Canvas fills the container */ +#gamutViewerContainer canvas { + display: block; + width: 100% !important; +} + +/* ── Legend / controls panel ─────────────────────────────────────────────── */ + +.gamut-controls-panel { + position: absolute; + top: 12px; + right: 12px; + z-index: 20; + min-width: 154px; + background: rgba(14, 14, 22, 0.82); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 10px; + padding: 10px 13px 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} + +.gamut-legend-title { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.45; + font-weight: 600; + margin-bottom: 9px; +} + +.gamut-legend-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 7px; + font-size: 0.8rem; + color: var(--text-color, #d4d4d4); +} + +.gamut-legend-item:last-child { + margin-bottom: 0; +} + +.gamut-legend-label { + flex: 1; + white-space: nowrap; + opacity: 0.85; +} + +/* Colour swatches */ +.gamut-swatch { + width: 11px; + height: 11px; + border-radius: 3px; + flex-shrink: 0; + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.gamut-swatch-profile { + /* Gradient hint at the three primary hue quadrants */ + background: linear-gradient(135deg, #3b82f6 0%, #ef4444 50%, #22c55e 100%); +} + +.gamut-swatch-srgb { + background: #6688aa; +} + +.gamut-swatch-axes { + background: #3a3a5a; +} + +/* ── Mini iOS-style toggle switch ─────────────────────────────────────────── */ + +.gamut-toggle { + position: relative; + display: inline-block; + width: 30px; + height: 16px; + flex-shrink: 0; + cursor: pointer; +} + +.gamut-toggle input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.gamut-toggle-slider { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.15); + border-radius: 16px; + transition: background 0.2s; +} + +.gamut-toggle-slider::before { + content: ''; + position: absolute; + width: 11px; + height: 11px; + left: 2.5px; + top: 2.5px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.7); + transition: transform 0.2s, background 0.2s; +} + +.gamut-toggle input:checked + .gamut-toggle-slider { + background: var(--accent-color, #007acc); +} + +.gamut-toggle input:checked + .gamut-toggle-slider::before { + transform: translateX(14px); + background: #fff; +} + +/* ── CSS2D axis label styles (injected into overlay DOM by CSS2DRenderer) ── */ + +.gamut-axis-label { + font-family: system-ui, -apple-system, monospace; + font-size: 11px; + color: rgba(200, 210, 230, 0.85); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 8px rgba(0, 0, 0, 0.7); + pointer-events: none; + user-select: none; + white-space: nowrap; +} + +.gamut-axis-primary { + font-size: 13px; + font-weight: 600; + color: rgba(220, 230, 255, 0.95); +} + +.gamut-axis-a-pos { color: rgba(255, 160, 150, 0.85); } +.gamut-axis-a-neg { color: rgba(140, 220, 160, 0.85); } +.gamut-axis-b-pos { color: rgba(255, 230, 130, 0.85); } +.gamut-axis-b-neg { color: rgba(140, 170, 255, 0.85); } + +.gamut-tick-label { + font-size: 10px; + opacity: 0.55; +} + + +