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.
-[](https://git.i3omb.com/gronod/ICCery)
+[](https://git.i3omb.com/gronod/ICCery)
[](https://git.i3omb.com/gronod/ICCery)
[](https://tauri.app)
[](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)
-
+
+
+
+
+
+
+
+
+
Layers
+
+
+
+ Profile Gamut
+
+
+
+
+
+ sRGB Reference
+
+
+
+
+
+ CIELAB Axes
+
+
+
+
@@ -837,7 +877,7 @@
About ICCery
- Version: v0.3.2 • Build Date: August 2026
+ Version: v0.7.1 • Build 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;
+}
+
+
+