chore: release v0.7.1 #187

Merged
gronod merged 2 commits from development into main 2026-09-02 10:55:21 +01:00
10 changed files with 754 additions and 126 deletions
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]]
name = "iccery"
version = "0.6.6"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"image",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+43 -3
View File
@@ -8,6 +8,7 @@
<link rel="stylesheet" href="./styles/main.css" />
<script src="./js/three.min.js"></script>
<script src="./js/OrbitControls.js"></script>
<script src="./js/CSS2DRenderer.js"></script>
<script type="module" src="./js/app.js" defer></script>
</head>
<body>
@@ -776,8 +777,47 @@
<!-- 3D Gamut Viewer -->
<div class="gamut-viewer-section">
<h3>3D Gamut Visualization (CIELAB)</h3>
<div id="gamutViewerContainer"></div>
<div class="gamut-viewer-header">
<h3>3D Gamut Visualisation <span class="gamut-viewer-subtitle">(CIELAB L* a* b*)</span></h3>
<p class="gamut-viewer-hint">Drag to rotate &bull; Scroll to zoom &bull; Right-drag to pan</p>
</div>
<div class="gamut-viewer-wrap" id="gamutViewerWrap">
<!-- Three.js canvas is appended here by gamut_viewer.js -->
<div id="gamutViewerContainer"></div>
<!-- Legend & layer controls overlay -->
<div class="gamut-controls-panel" id="gamutControlsPanel">
<div class="gamut-legend-title">Layers</div>
<div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-profile"></span>
<span class="gamut-legend-label">Profile Gamut</span>
<label class="gamut-toggle" title="Show / hide profile gamut">
<input type="checkbox" id="chkProfileGamut" checked>
<span class="gamut-toggle-slider"></span>
</label>
</div>
<div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-srgb"></span>
<span class="gamut-legend-label">sRGB Reference</span>
<label class="gamut-toggle" title="Show / hide sRGB reference">
<input type="checkbox" id="chkSrgbReference" checked>
<span class="gamut-toggle-slider"></span>
</label>
</div>
<div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-axes"></span>
<span class="gamut-legend-label">CIELAB Axes</span>
<label class="gamut-toggle" title="Show / hide CIELAB axis scaffold">
<input type="checkbox" id="chkLabAxes" checked>
<span class="gamut-toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Process Log -->
@@ -837,7 +877,7 @@
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
</div>
<h2>About ICCery</h2>
<p><strong>Version:</strong> <span id="aboutVersion">v0.3.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">August 2026</span></p>
<p><strong>Version:</strong> <span id="aboutVersion">v0.7.1</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p>
<h3>Licences & EULA</h3>
<div class="license-text-container">
+161
View File
@@ -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;
} )();
+336 -117
View File
@@ -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));
}
}
+207
View File
@@ -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;
}