Files
iccery-v2-mac/docs/18-gamut-viewer.md
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4c2863e52c Add bootstrap docs: AGENTS.md, BUILD-PLAN.md, LICENCE.md, vendored spec (docs/), brand assets
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 17:36:50 +01:00

830 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 18 — 3D gamut viewer
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Exhaustive reading of the current implementation for a rewrite. All citations are
`path:start-end` against `/tmp/ICCery` unless noted. Issue numbers refer to
`/tmp/iccery-research/issues/`.
**Module map**
| File | Role |
|---|---|
| `src/js/gamut_viewer.js` (831 lines) | Scene, parser, mesh, lifecycle, controls |
| `src/js/gamut_viewer.test.js` | Parser + WebGL-heuristic unit tests (Node) |
| `src/js/CSS2DRenderer.js` | Vendored Three.js CSS2D addon (IIFE → `THREE.*`) |
| `src/js/OrbitControls.js` | Vendored Three r128 OrbitControls (IIFE → `THREE.OrbitControls`) |
| `src/js/vendor/quickhull.js` | Incremental 3D QuickHull fallback |
| `src/js/color_convert.js` | Lab → sRGB for per-vertex colour |
| `src/js/three.min.js` | Three.js **r128** (`const e="128"`) |
| `src/js/state.js` | `ensureGamutViewer` / `pauseGamutViewer` on stage change |
| `src/js/app.js` | GPU hints, **no** eager WebGL on `DOMContentLoaded` |
| `src/js/colprof.js` | Post-`colprof` `extract_gamut` + `loadGamutMesh` |
| `src/js/profcheck.js` | Post-verify `loadGamutMesh` |
| `src-tauri/src/commands.rs` | `build_iccgamut_args`, `extract_gamut`, `read_file_base64` |
| `src/assets/sRGB.gam` | Bundled full Argyll surface (448 verts / 892 faces) |
| `src-tauri/argyll/reference_gamuts/sRGB.gam` | Stub 8-cusp CGATS — **not** what the viewer loads |
---
## 1. Lifecycle (MUST NOT init WebGL on `DOMContentLoaded`)
This is the #225 contract. Eager `THREE.WebGLRenderer` + continuous rAF on a
hidden Stage 5 canvas respawns WKWebViews GPU helper on Monterey Intel: white
flash loop, then silent exit (Web Content process death, no `ICCery` crash
report).
### 1.1 What is forbidden
`src/js/app.js:130-137` explicitly does **not** call `initGamutViewer` in the
`DOMContentLoaded` `safeInit` batch:
```js
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
// respawns WKWebView on Monterey Intel).
safeInit('Stage 1 (Targen)', initTargen);
// ...
safeInit('Stage 5 (Profcheck)', initProfcheck); // profcheck UI only, not WebGL
```
`#stage-5` starts with `class="stage hidden"` (`src/index.html:901`). Hidden
containers report `clientWidth === 0`; the old code fell back to 500×400 and
still created a GPU context (#225 §7.1).
### 1.2 `ensureGamutViewer` — only when Stage 5 is shown
`src/js/state.js:90-94`:
```js
if (stageNumber === 5) {
ensureGamutViewer();
} else {
pauseGamutViewer();
}
```
This is the **only** production call site besides context-restore / fallback
reload. `resumeGamutViewer` is exported (`gamut_viewer.js:180-182`) but **never
imported**. Returning to Stage 5 re-enters `ensureGamutViewer`, which
`startAnimate()`s if already ready.
`ensureGamutViewer` (`gamut_viewer.js:149-174`):
1. Bail if `gamutViewerUnavailable` (WebGL feature-detect failed once — sticky).
2. If `gamutViewerReady`, just `startAnimate()` (no second renderer).
3. If `#stage-5` has class `hidden`, **return without creating WebGL**.
4. Defer actual `initGamutViewer()` by one `requestAnimationFrame` so layout has
a non-zero size after the `.hidden` class is removed.
5. Re-check hidden + ready flags inside the rAF callback.
`initGamutViewer` (`gamut_viewer.js:187-329`) is the real constructor:
- Re-entry guard: `gamutViewerReady || gamutViewerInitStarted`.
- Requires `#gamutViewerContainer` and global `THREE`.
- Feature-detects WebGL **before** `new THREE.WebGLRenderer` (`webglAvailable`,
lines 37-46): tries `webgl2`, then `webgl`, then `experimental-webgl`.
- On failure: sets `gamutViewerUnavailable = true`, logs
`WEBGL_UNAVAILABLE_MESSAGE`, paints fallback, returns. Never throws into
`safeInit`.
- Constrained-GPU path (`isLikelyConstrainedGpu`, lines 56-79 +
`setGpuHints` from `app.js:96-100`):
```js
renderer = new THREE.WebGLRenderer({
antialias: !lowPower,
alpha: false, // opaque; matches scene.background 0x0e0e14
powerPreference: lowPower ? 'low-power' : 'default',
failIfMajorPerformanceCaveat: false,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
```
Arch preference: backend `get_app_info.arch` beats UA. Safari reports
`navigator.platform === "MacIntel"` on Apple Silicon; treating that as Intel
would wrongly drop antialias on M-series. Heuristic: `x86_64`/`x86`/`ia32`
from backend → constrained; else UA `ARM|Apple Silicon|aarch64` → not;
else Intel Mac UA + `macosMajor < 13` (or unknown major) → constrained.
- Default size if container is still 0: **500×400** (`gamut_viewer.js:214-215`).
Should not trigger if the hidden-stage guard works.
### 1.3 Pause on leave
`pauseGamutViewer` → `stopAnimate()` (`animationRunning = false`). The rAF loop
(`gamut_viewer.js:334-343`) returns immediately if the flag is false, so a lost
or hidden context does not spin:
```js
function animate() {
if (!animationRunning) return;
requestAnimationFrame(animate);
if (contextLost || !renderer) return;
if (controls) controls.update();
if (renderer && scene && camera) {
renderer.render(scene, camera);
if (labelRenderer) labelRenderer.render(scene, camera);
}
}
```
**Does not dispose** the renderer on leave. Scene, GPU context, and meshes
survive. That is intentional: re-entering Stage 5 is cheap, and `loadGamutMesh`
from Stage 4 can populate the scene if the user already visited Stage 5.
### 1.4 Context-lost handler
`gamut_viewer.js:238-252`:
```js
renderer.domElement.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // allow restore
contextLost = true;
stopAnimate();
logger.error('WebGL context lost', 'GamutViewer');
showGamutFallback(container, '3D view lost its GPU context. Profiling stages still work.', {
reloadable: true,
});
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
logger.warn('WebGL context restored — rebuilding viewer', 'GamutViewer');
contextLost = false;
disposeViewer();
ensureGamutViewer();
});
```
`disposeViewer` (`gamut_viewer.js:115-143`) disconnects `ResizeObserver`,
`renderer.dispose()`, removes both canvases, nulls scene/camera/controls/meshes,
clears `gamutViewerReady` / `gamutViewerInitStarted` / `contextLost`.
**Not reset by dispose:** `gamutViewerUnavailable`, `togglesWired`, `gpuHints`.
`togglesWired` is OK (listeners live on HTML checkboxes, not the canvas).
`gamutViewerUnavailable` means a “Reload 3D view” button after a *feature-detect*
failure would no-op — but that path does not show the button (`reloadable`
defaults false). Init-throw and context-lost do show it.
### 1.5 Monterey WKWebView (#225) — adjacent host fixes (not in the JS viewer)
Fix 3 (this module) is the crash stop. Host-side (for rewrite awareness):
- Window `visible: false` until double-rAF + `show_main_window` (`app.js:143-150`).
- Dark `#1A1A22` window / WKWebView backing (`tauri.conf.json`, `macos_webview.rs`).
- `minimumSystemVersion` 12.0; Intel Monterey < 13 gets a one-shot notice
(`app.js:30-45`).
- `visibilitychange` / `pagehide` logged (`app.js:74-79`).
Rewrite **must** keep: no WebGL until Stage 5 visible; pause rAF on leave;
feature-detect; context-lost fallback; `alpha: false`; DPR cap on constrained
GPU; do not treat MacIntel UA as Intel when backend arch is `aarch64`.
---
## 2. Scene graph
Coordinate convention (commented at `gamut_viewer.js:346-347` and `_buildGeometry`):
```
X = a* (128 → +128) green ← → red
Y = L* (0 → 100) lightness, up
Z = b* (128 → +128) blue ← → yellow
```
### 2.1 Camera
- `PerspectiveCamera(45, aspect, 0.1, 2000)`
- Home position `(180, 120, 180)`
- `lookAt(0, 50, 0)` — centre of the L* axis, not the origin
- `OrbitControls.target = (0, 50, 0)`, `enableDamping = true`, `dampingFactor = 0.05`
- Touch override (`gamut_viewer.js:272-275`): `ONE: ROTATE`, `TWO: DOLLY_PAN`
(same as OrbitControls defaults; #117 says do not change this).
- `renderer.domElement.style.touchAction = 'none'` to stop the webview scrolling.
`resetCamera` (`gamut_viewer.js:676-682`) restores position + target. OrbitControls
also has its own `reset()` using `position0/target0`, but the app never calls it;
the custom function writes the same home pose.
### 2.2 Lights
No shadows, no environment map, no fog.
| Light | Colour | Intensity | Position |
|---|---|---|---|
| AmbientLight | `0xffffff` | 0.75 | — |
| DirectionalLight | `0xffffff` | 0.80 | `(150, 250, 150)` |
| DirectionalLight | `0xffffff` | 0.35 | `(-120, -80, -120)` |
Lambert materials + these lights are what make per-vertex Lab colour readable.
A rewrite that switches to MeshBasicMaterial would lose the slight modelling;
MeshStandardMaterial would need different lighting.
### 2.3 Axis scaffold (`buildAxisScaffold`, `gamut_viewer.js:349-423`)
Replaces `THREE.AxesHelper` (#185). One `THREE.Group` (`axisScaffoldGroup`):
1. **Bounding box** — `BoxGeometry(256, 100, 256)` → `EdgesGeometry` →
`LineSegments`, colour `0x232336`, opacity 0.9, **positioned at `(0, 50, 0)`**
so it covers L* 0100, a*/b* ±128.
2. **Ground grid** — `GridHelper(256, 16, 0x1e1e2e, 0x1a1a28)` at `y = 0`
(L*=0). #185 said “remove GridHelper”; the implementation kept a custom one.
3. **Axis lines** — L* grey `0xcccccc` `(0,0,0)→(0,100,0)`; a* and b* `0x99bbcc`
spanning ±128.
4. **Ticks** — L* at 25/50/75/100 (crosses ±4 on X and Z); a* and b* at
±128/±64 (vertical ticks ±3). **No tick at 0** on a*/b*.
5. **CSS2D labels** (only if `THREE.CSS2DObject` exists):
| Text | Position | Class |
|---|---|---|
| `L*` | `(0, 110, 0)` | `gamut-axis-primary` |
| `+a* (Red →)` | `(140, 0, 0)` | `gamut-axis-a-pos` |
| `a* (Green)` | `(-140, 0, 0)` | `gamut-axis-a-neg` |
| `+b* (Yellow →)` | `(0, 0, 140)` | `gamut-axis-b-pos` |
| `b* (Blue)` | `(0, 0, -140)` | `gamut-axis-b-neg` |
| `25` / `50` / `75` / `100` | `(10, v, 0)` | `gamut-tick-label` |
No numeric labels on a*/b* ticks. Colours are CSS (`main.css:1667-1691`):
a+ salmon, a green, b+ yellow, b blue.
Labels are **children of `axisScaffoldGroup`**. `toggleAxes` sets
`axisScaffoldGroup.visible`. WebGL descendants honour group visibility; the
CSS2DRenderer (`CSS2DRenderer.js:87-89`) only tests `object.visible` on the
label itself, **not ancestors**. Toggling axes off can leave CSS2D labels
on-screen. Rewrite should walk parents or hide the label DOM.
### 2.4 CSS2D overlay
`CSS2DRenderer.js` is Three.js `examples/jsm/renderers/CSS2DRenderer.js`
rewritten as an IIFE that assigns `THREE.CSS2DObject` / `THREE.CSS2DRenderer`
(`CSS2DRenderer.js:157-159`). Loaded as a classic script after `three.min.js`
(`index.html:9-11`).
Init (`gamut_viewer.js:255-263`): absolute, `top/left 0`, `pointer-events: none`
so orbit still hits the WebGL canvas. Rendered every frame after the WebGL pass.
### 2.5 Profile mesh (`currentProfileMesh`)
Solid `THREE.Mesh`, `MeshLambertMaterial({ vertexColors: true, transparent: true,
opacity: 0.88, side: THREE.DoubleSide })`. Native `.gam` faces when present;
QuickHull fallback otherwise. See §45.
### 2.6 sRGB reference (`sRgbGroup`)
A `THREE.Group` of two children sharing one `BufferGeometry` (#185 component 2):
1. Faint fill: `MeshLambertMaterial({ color: 0x8899bb, opacity: 0.07,
transparent, DoubleSide, depthWrite: false })`.
2. Structural outline: `EdgesGeometry(geometry, 15)` (15° coplanar threshold)
+ `LineBasicMaterial({ color: 0x6688aa, opacity: 0.55 })`.
Loaded from `fetch('assets/sRGB.gam')` at the end of `initGamutViewer`
(`loadSrgbReferenceGamut`, lines 742-752). **Adobe RGB was specified in #9 and
never shipped.**
### 2.7 Resize
`ResizeObserver` on `#gamutViewerContainer` updates camera aspect, renderer size,
and labelRenderer size. No `window.resize` listener.
### 2.8 Background
`scene.background = 0x0e0e14` matches `#gamutViewerContainer` CSS. `alpha: false`
on the renderer is required so WKWebView does not composite a white underlay.
---
## 3. `.gam` file parser
`parseGamutFile` (`gamut_viewer.js:447-509`) is the post-#179 parser. The
previous `parseCGATS` only read the first `BEGIN_DATA` (vertices), discarded
the triangle table, then handed `{L,a,b}` objects to QuickHull which indexes
`.x/.y/.z` → empty hull → blank scene.
### 3.1 Real Argyll format (`src/assets/sRGB.gam`)
Header (not parsed; ignored because `dataStarted` is false):
```
GAMUT
DESCRIPTOR "Argyll Gamut surface poligon data"
ORIGINATOR "Argyll CMS gamut library"
COLOR_REP "LAB"
GAMUT_CENTER "50.000000 0.000000 0.000000"
CUSP_RED / CUSP_YELLOW / … / CUSP_MAGENTA
# First come the triangle verticy location
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 448
BEGIN_DATA
0 53.23738 78.28787 62.14806
END_DATA
# And then come the triangles
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 892
BEGIN_DATA
13 1 46
END_DATA
```
Two `BEGIN_DATA`…`END_DATA` blocks. Block 1 = vertices (`index L a b`).
Block 2+ = 0-based triangle indices. Issue #179s printer sample
(`xp_55_koala_satin.gam`) is the same shape: 739 verts, 1474 faces.
### 3.2 Algorithm (quote)
```js
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
if (trimmed === '') continue;
if (trimmed.toUpperCase() === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
if (trimmed.toUpperCase() === 'END_DATA') { dataStarted = false; continue; }
if (!dataStarted) continue;
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1) {
// Vertex format: index L a b (index is usually ignored)
const [_, L, a, b] = parts; // VERTEX_NO discarded
vertices.push([L, a, b]);
} else {
faces.push([parts[0], parts[1], parts[2]]);
}
```
Rules:
- Hash comments stripped **even inside data blocks** (`# inline comment` test).
- `BEGIN_DATA` / `END_DATA` matched case-insensitively.
- Header keys (`COLOR_REP`, `NUMBER_OF_SETS`, `BEGIN_DATA_FORMAT`, …) skipped.
- Non-numeric data lines → warning, skip.
- Vertex arity < 4 or face arity < 3 → warning.
- Lab plausibility warning (does **not** drop the vertex):
`L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128`.
Bundled sRGB cusp blue is `b* = -126.4162` (inside); a printer gamut can
exceed ±128.
- Multiple face blocks are concatenated (`dataBlock >= 2`). Dual-table test
covers this.
- Vertices with no faces → warning *“will compute convex hull on the fly.”*
- Zero `BEGIN_DATA` → warning *“file may be empty or not a valid Argyll .gam”*.
`VERTEX_NO` is discarded. Faces are assumed to index **push order** (Argyll
writes 0..N-1 sequentially). A non-dense index column would silently mis-bind.
### 3.3 Lab vs XYZ
`COLOR_REP "LAB"` is **not read**. Columns are always treated as L, a, b.
`iccgamut` default is Lab. If a rewrite (or a user) ever passed `iccgamut -x`
(XYZ), the viewer would plot XYZ as if it were Lab and colour it through
`labToSrgb`. There is no XYZ path.
### 3.4 Stub vs real sRGB.gam
`src-tauri/argyll/reference_gamuts/sRGB.gam` is **not** an Argyll gamut surface:
```
CGATS.17
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
INDEX LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 8
BEGIN_DATA
0 0.0 0.0 0.0 # black
1 100.0 0.0 0.0 # white
2 53.2 80.1 67.2 # red cusp (approx)
END_DATA
```
8 points, **no face table**. The parser would emit 8 vertices + the hull-fallback
warning; QuickHull of 8 cusps is a coarse octahedron, not sRGB. The viewer
never loads this file. It `fetch`es `src/assets/sRGB.gam` (448/892, produced by
`iccgamut` on 2026-08-25). Rewrite: either delete the stub or generate it the
same way as the asset, and do not assume `reference_gamuts/` is the runtime
source.
---
## 4. Mesh generation: QuickHull vs ConvexGeometry
### 4.1 History
| Issue | What happened |
|---|---|
| #9 | Planned Delaunay on Lab point cloud + sRGB/AdobeRGB wireframes. |
| #57 | Viewer was a dead panel; v0.2 used 2D Delaunay on (a*, b*) flattening L*. |
| #89 | Replaced Delaunay with inlined 3D QuickHull (~270 lines in `gamut_viewer.js`); touch OrbitControls. `delaunator.min.js` deleted. |
| #117 | Proposed replacing the inline hull with Three r128 `ConvexGeometry` / `ConvexHull`, **or** vendoring `quickhull3d`. Optional. |
| #179 | Real `.gam` files already contain the surface triangulation. Parser was throwing it away and hulling `{L,a,b}` objects → empty mesh. |
**Current state:** native face indices are the primary path. QuickHull is only
the no-faces fallback. There is **no** `ConvexGeometry.js` / `ConvexHull.js` in
the tree (`src/js/vendor/` contains only `quickhull.js`). #117 was closed but
the code took the “vendor quickhull3d-like ESM” option, not Threes addon.
### 4.2 `_buildGeometry` (`gamut_viewer.js:515-568`)
```js
if (vertices.length < 4) return null;
if (faces.length > 0) {
// Native mesh: position [a, L, b] per vertex, Uint32 index from .gam
posArr[i*3] = a; // X
posArr[i*3+1] = L; // Y
posArr[i*3+2] = b; // Z
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
geometry.computeVertexNormals();
} else {
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // a, L, b
const hullFaces = computeQuickHull(pts);
// Dedup by `${x}_${y}_${z}` string key while expanding hull faces
// Then rewrite `vertices` from the position attribute as [L, a, b]
}
```
Critical #179 fix for the fallback: points **must** be `{x,y,z}` not `{L,a,b}`.
`quickhull.js:44` uses `pts[i].x`; undefined x made `minX === maxX` and returned `[]`.
### 4.3 `computeQuickHull` (`src/js/vendor/quickhull.js`)
Classic incremental convex hull (150 lines):
1. O(n²) duplicate filter, eps `1e-5` (#117 risk: density-50 clouds freeze here).
2. Extreme points: min/max X, then furthest from that line, then furthest from
that plane → initial tetrahedron.
3. Outward winding via tetrahedron **centroid** (`createFace` flips if the
centroid is in front). Horizon winding is recovered from this centroid, not
the growing hull (#117 risk for re-entrant sets — moot for a true convex hull).
4. For each remaining point: collect visible faces (`n·(p-a) > 1e-9`), build
horizon as edges that appear once (`uId+'_'+vId` via `pts.indexOf`, another
O(n)), deactivate visible faces, fan new faces from horizon to the point.
5. Return `faces.filter(f => f.active)` as `{a,b,c}` point references.
A convex hull of a printer gamut **fills concavities** (dark cyan/magenta
indentations that #89 wanted to preserve). That is why native `.gam` faces are
the correct primary path: `iccgamut` already computed the (possibly non-convex)
surface. Hull is only for vertex-only clouds (the 8-cusp stub, or a future
`.ti3` point cloud).
`EdgesGeometry(geometry, 15)` on the sRGB mesh strips coplanar internal
triangle edges. Threshold 15° is a magic number from #185.
---
## 5. Per-vertex colouring from Lab
`_renderProfileGamut` (`gamut_viewer.js:623-667`) — **profile only**, not sRGB
reference (reference stays steel-blue `0x8899bb` / `0x6688aa`).
```js
const posAttr = geometry.getAttribute('position'); // laid out [a, L, b]
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, 1));
material = new THREE.MeshLambertMaterial({ vertexColors: true, opacity: 0.88, … });
```
`labToSrgb` (`color_convert.js:9-45`):
- D50 white `Xn=0.9642, Yn=1, Zn=0.8249` (ICC PCS).
- CIE Lab → XYZ (standard `δ = 6/29` piecewise cube).
- XYZ D50 → linear sRGB via a **Bradford-adapted D50→D65** matrix (not a raw
D65 sRGB matrix — correct for ICC Lab).
- sRGB gamma, clamp 0255, `Math.round`.
Out-of-gamut Lab (printer vertices outside sRGB) clips to the sRGB cube. That
is the intended diagnostic: the blob is “true colour” where representable and
clips where the printer exceeds sRGB — which is exactly when it pokes out of
the sRGB wireframe.
`colprof.js:206` still calls `loadGamutMesh(gamFilePath, 0x3b82f6)`. The second
arg is **ignored** (flat-blue leftover from pre-#185). Rewrite: drop it.
---
## 6. Controls
### 6.1 HTML overlay (`index.html:980-1038`)
Glassmorphic panel, `position: absolute; top/right: 12px` over
`#gamutViewerWrap`. Three layers, each a toggle + opacity slider:
| Control | Id | Default | Handler |
|---|---|---|---|
| Profile visibility | `chkProfileGamut` | checked | `toggleProfileGamut` |
| Profile opacity | `rngProfileOpacity` | 0.88 | `setProfileOpacity` |
| sRGB visibility | `chkSrgbReference` | checked | `toggleSrgbReference` |
| sRGB opacity | `rngSrgbOpacity` | 0.55 | `setSrgbReferenceOpacity` |
| Axes visibility | `chkLabAxes` | checked | `toggleAxes` |
| Axes opacity | `rngAxisOpacity` | 0.80 | `setAxisOpacity` |
| Reset | `btnGamutResetCamera` | — | `resetCamera` |
Hint text: “Drag to rotate • Scroll to zoom • Right-drag to pan” /
“Press R to reset”.
Wired once in `_wireToggles` (`gamut_viewer.js:791-831`). `togglesWired` is
never cleared on dispose.
### 6.2 Opacity idiosyncrasy (`setSrgbReferenceOpacity`, lines 700-710)
```js
child.material.opacity = child.material.opacity >= 0.5
? Math.max(0.05, Math.min(1, opacity)) // treated as edges
: Math.max(0.02, Math.min(0.2, opacity * 0.2)); // treated as fill
```
Initial edges 0.55 ≥ 0.5, fill 0.07 < 0.5. **If the user drags the slider below
0.5, edges flip into the fill branch permanently** (next input sees 0.1 < 0.5).
Rewrite should tag the two materials, not infer role from current opacity.
`setProfileOpacity` also sets `transparent = opacity < 1`. `setAxisOpacity`
walks all children with a `.material` (lines, grid) but CSS2D labels have none.
### 6.3 Reset (R)
Two mechanisms:
1. Button `btnGamutResetCamera`.
2. `stage5.addEventListener('keydown', …)` when key is `r`/`R` and stage is not
hidden (`gamut_viewer.js:822-829`).
`_onKeyDown` (`gamut_viewer.js:730-734`) is **dead code** — never registered.
`#stage-5` has **no `tabindex`**, so it will not receive `keydown` unless a
focusable descendant (slider, checkbox, button, or a form control earlier in
Stage 5) is focused. The button is the reliable path. Rewrite: listen on
`document` while Stage 5 is active, ignore events from text inputs.
### 6.4 Touch
Vendored `OrbitControls.js` is Three r128 (IIFE, assigns `THREE.OrbitControls`
and `THREE.MapControls` at the bottom). Touch is fully implemented
(`onTouchStart/Move/End`, `passive: false`, `preventDefault` on start).
Mapping used by the viewer:
- 1 finger → rotate
- 2 fingers → dolly + pan
Mouse: left rotate, wheel dolly, right pan. `renderer.domElement.style.touchAction
= 'none'` is required on WKWebView / mobile.
`OrbitControls` is **not** an ES module; it depends on global `THREE` from
`three.min.js` r128. A rewrite that npm-installs a newer three must vendor a
matching OrbitControls (and CSS2DRenderer) — the current files are frozen to r128.
---
## 7. `iccgamut` CLI flags
### 7.1 Current argv (#112 fix)
`commands.rs:684-690`:
```rust
pub fn build_iccgamut_args(resolved_path: &str) -> Vec<String> {
vec![
"-v".to_string(),
"-d".to_string(),
"10".to_string(),
resolved_path.to_string(),
]
}
```
Locked by `test_build_iccgamut_args` (`commands.rs:1475-1478`):
`["-v", "-d", "10", "/path/to/profile.icc"]`.
Meaning:
| Flag | Meaning |
|---|---|
| `-v` | Verbose (stdout into the process log via ProcessManager). |
| `-d 10` | **Surface point density**, typical useful range ~110. Not a directory. |
| `{profile}` | Resolved `.icc`/`.icm` path. |
| *(cwd)* | Profile parent dir, so `{basename}.gam` lands next to the profile. Set via `ProcessManager::spawn(..., cwd)`, **not** via `-d`. |
| no `-w` | VRML not required; `.gam` is the default. |
`extract_gamut` (`commands.rs:694-731`) also swaps `.icc`↔`.icm` if the given
path does not exist, process id `iccgamut_{basename}`.
### 7.2 The `-d 50.0` bug (#112)
Pre-fix argv was `-v -d 50.0 {profile}`. Density 50 produces thousands of
vertices; QuickHulls O(n²) duplicate filter then froze the UI. There was
historical confusion that `-d` meant “directory”. Directory is `cwd`. Do **not**
pass the parent path as `-d`. Do **not** revert to 50.
#179s real printer file was generated with `-v -d 10` (739 verts / 1474 faces,
43.6 KB) — the density we want.
### 7.3 When it runs
1. **Stage 4 success** (`colprof.js:173-174, 197-215`): `triggerGamutExtraction`
listens for `process:exit` on `iccgamut_{basename}`, then
`loadGamutMesh(cwd/basename.gam)`. This often runs **while Stage 5 is still
hidden**, so `scene` is null and `_renderProfileGamut` returns null. The
`.gam` file is still written to disk.
2. **Stage 5 verify success** (`profcheck.js:626-635`): `loadGamutMesh` again.
This is the path that actually puts the printer mesh on screen, assuming the
user has opened Stage 5 (viewer inited) and then clicked Verify.
If the user opens Stage 5 *before* verifying, they see axes + sRGB only.
Navigating to Stage 5 does **not** auto-load an existing `{basename}.gam`.
Rewrite should load it in `ensureGamutViewer` once basename/cwd are known.
---
## 8. Fallback UI when WebGL is missing
`showGamutFallback` (`gamut_viewer.js:81-102`) wipes
`#gamutViewerContainer.innerHTML` and inserts:
```html
<div class="gamut-webgl-fallback" role="status">
<p>…message…</p>
<!-- optional --> <button class="secondary btn-md">Reload 3D view</button>
</div>
```
CSS (`main.css:1494-1511`): flex-centred, min-height 280px, same `#0e0e14`
background, muted text. Controls panel is a **sibling** of the container
(`#gamutViewerWrap` > container + panel), so the legend remains visible over
an empty/fallback view — another rewrite nicety: hide or disable it.
Messages:
| Trigger | Copy | Reload? |
|---|---|---|
| `webglAvailable() === false` | `WEBGL_UNAVAILABLE_MESSAGE` = “3D gamut viewer requires WebGL; the rest of ICCery still works.” | no |
| `initGamutViewer` throw | same `WEBGL_UNAVAILABLE_MESSAGE` | yes |
| `webglcontextlost` | “3D view lost its GPU context. Profiling stages still work.” | yes |
Reload: `disposeViewer(); ensureGamutViewer();`. Feature-detect failure sets
`gamutViewerUnavailable`, so a hypothetical reload would no-op until a full
page reload.
Intel Monterey < 13 also gets a session-once wizard banner
(`app.js:30-45`): “On this Mac the 3D gamut view may be unavailable…”
Init catch path removes a half-attached `renderer.domElement` before showing
fallback (`gamut_viewer.js:321-327`).
---
## 9. Tests
`src/js/gamut_viewer.test.js` — Node-runnable ESM (`node src/js/gamut_viewer.test.js`).
#212: previously crashed with `window is not defined` because `gamut_viewer.js`
top-level-dereferenced `window.__TAURI__.core`. Now guarded
(`gamut_viewer.js:5`):
```js
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
? window.__TAURI__.core.invoke : null;
```
The test file polyfills `globalThis.window` / `document` **before** a dynamic
`await import('./gamut_viewer.js')` (static import is hoisted and would still
lose the race). Canvas `getContext` returns `null` so `webglAvailable()` is
false in Node.
Cases (`runAll`):
| Test | Asserts |
|---|---|
| `testParseGamutBasic` | 4 verts, 2 faces, 0 warnings |
| `testParseGamutDualTable` | two face `BEGIN_DATA` blocks concatenate to 2 faces |
| `testParseGamutWithComments` | `#` lines + `# inline comment` stripped |
| `testWebglAvailableFalseWithoutContext` | no context / no document → false |
| `testWebglAvailableTrueWithWebgl` | `getContext('webgl')` → true |
| `testConstrainedGpuPrefersBackendArch` | `aarch64` + MacIntel UA → **not** constrained; `x86_64` → constrained |
| `testConstrainedGpuIgnoresAppleSiliconUa` | ARM UA → not constrained |
| `testConstrainedGpuIntelMac` | Monterey Intel UA → constrained; x86_64 on Ventura still constrained |
| `testEnsureGamutViewerNoopsWithoutDom` | no Stage 5 / no THREE → `isGamutViewerReady() === false` |
| `testFallbackMessage` | copy contains “requires WebGL” |
**Not tested:** real `sRGB.gam` golden parse (448/892), Lab-bounds warnings,
empty file, XYZ, QuickHull tetrahedron/cube fixtures (#117 AC),
`labToSrgb` colours, `loadGamutMesh`, context-lost, opacity slider, R-key,
`build_iccgamut_args` is a **Rust** test not a JS one.
Auto-run:
```js
if (typeof process !== 'undefined' && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
runAll(); // throws if any fail → non-zero exit
}
```
---
## 10. Idiosyncrasies / rewrite traps
1. **Do not create WebGL on `DOMContentLoaded`.** Stage 5 starts `.hidden`.
`ensureGamutViewer` + one rAF + hidden-class re-check is the contract (#225).
2. **Pause rAF on leave; do not necessarily dispose.** Scene stays so Stage 4s
late `loadGamutMesh` can work *if* Stage 5 was visited first — which it
usually is not. Safer rewrite: queue the `.gam` path and load on first
`ensureGamutViewer`.
3. **Native faces > hull.** A convex hull of Lab points is the wrong surface
for a printer gamut. Only hull when the face table is missing.
4. **Position layout is `(a, L, b)` not `(L, a, b)`.** Colour, hull fallback,
and CSS2D all depend on this. Mixing them was the #179 blank-scene bug.
5. **`VERTEX_NO` is ignored; faces are 0-based push-order.** Fine for Argyll.
6. **`COLOR_REP` is ignored.** Lab only.
7. **Two sRGB.gam files, only one is real.** Runtime = `src/assets/sRGB.gam`
(448/892). `src-tauri/argyll/reference_gamuts/sRGB.gam` is an 8-point stub.
8. **No Adobe RGB overlay** despite #9.
9. **`loadGamutMesh(path, 0x3b82f6)`** — extra colour arg is dead.
10. **sRGB opacity slider infers fill vs edges from `opacity >= 0.5`.** Breaks
after the user goes below 0.5.
11. **CSS2D labels ignore parent `visible`.** Axis toggle is incomplete.
12. **R-key is bound to `#stage-5` without `tabindex`.** `_onKeyDown` is unused.
13. **Duplicate CSS** for `#gamutViewerContainer`: `main.css:835-842`
(`height: 400px; background: #111116`) and `main.css:1480-1486`
(`min-height: 420px; background: #0e0e14`). Later rules win for
background; `height: 400px` + `min-height: 420px` → 420px.
14. **Three r128 via `<script>` + IIFE addons.** Not ESM. `gamut_viewer.js` is
ESM and talks to `window.THREE`. A bundler rewrite must keep that seam or
import matching addons.
15. **`invoke` is null outside Tauri.** `loadGamutMesh` will throw if called
from tests / a browser without the polyfill.
16. **`togglesWired` / `gamutViewerUnavailable` survive `disposeViewer`.**
17. **`GridHelper` still present** after #185 said to remove it.
18. **QuickHull O(n²) duplicate filter** — keep density at 10, never 50 (#112).
19. **`atob(read_file_base64)`** assumes the `.gam` is ASCII. It is (CGATS).
Do not switch to a UTF-16 profile dump without a TextDecoder.
20. **DoubleSide Lambert** hides winding errors in Argylls triangle table.
If a rewrite uses FrontSide, audit winding.
21. **`failIfMajorPerformanceCaveat: false`** — accept software GL rather than
crash Monterey.
22. **No `resumeGamutViewer` call site.** `ensureGamutViewer` already restarts
the loop when `gamutViewerReady`.
23. **Feature-detect prefers webgl2** but r128 `WebGLRenderer` still opens a
WebGL1 context unless `r128` is given `{ capability: … }`. Detecting webgl2
does not mean Three uses it.
24. **Stage 5 verify is what actually shows the printer mesh**, not Stage 4
extraction and not merely opening the viewer.
25. **Cusp vertices in the header** (`CUSP_RED` etc.) are not used. They are
duplicated as the first few `BEGIN_DATA` rows. A rewrite could label cusps
but the current viewer does not.
---
## 11. Public API surface (for a rewrite to preserve)
```js
setGpuHints(hints) // { arch, os, macosMajor }
isGamutViewerReady()
webglAvailable(doc?)
isLikelyConstrainedGpu(hints?)
ensureGamutViewer() // Stage 5 entry
pauseGamutViewer() // Stage 5 leave
resumeGamutViewer() // unused
initGamutViewer() // internal-ish but exported
parseGamutFile(text) → { vertices, faces, warnings }
resetCamera()
setProfileOpacity(0..1)
setSrgbReferenceOpacity(0..1)
setAxisOpacity(0..1)
loadSrgbReferenceGamut()
loadGamutMesh(gamFilePath) → Mesh|null
toggleSrgbReference(visible)
toggleProfileGamut(visible)
toggleAxes(visible)
WEBGL_UNAVAILABLE_MESSAGE
```
HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`,
`chkLabAxes`, `btnGamutResetCamera`, `rngProfileOpacity`, `rngSrgbOpacity`,
`rngAxisOpacity`, `gamutViewerContainer`, `stage-5`.
---
## 12. Issue → code checklist
| Issue | Status in tree |
|---|---|
| #9 3D Lab viewer | Shipped; no Adobe RGB; Delaunay replaced. |
| #57 dead panel | `extract_gamut` + `loadGamutMesh` wired from colprof + profcheck. Stage 4 load still races init. |
| #89 3D hull + touch | Hull vendored; OrbitControls touch live; Delaunay gone. |
| #112 `-d 50` | Fixed to `-d 10`; Rust unit test. |
| #117 ConvexGeometry | Not done; `vendor/quickhull.js` instead. Native faces make this low-priority. |
| #179 blank mesh + 0.00 ΔE | `parseGamutFile` uses both tables; points passed as `{x,y,z}`. (Profcheck regex is out of scope here.) |
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |