Gamut compare: second profile, reference spaces, click-inspect #147

Closed
opened 2026-09-12 18:34:18 +01:00 by gronod · 2 comments
Owner

Summary

GamutViewModel on develop loads bundled sRGB.gam plus at most one profile .gam next to the ICC (Sources/ICCery/GamutViewModel.swift). Make the viewer a comparison tool: second profile or extra reference mesh, volume / containment readout, click a vertex or drop a Lab / TIFF sample and see in/out of which hulls.

Spec refs

  • docs/18-gamut-viewer.md (axes X=a* ±128, Y=L* 0–100, Z=b* ±128, camera home (180,120,180) lookAt (0,50,0), R resets on a focusable container, native faces > hull)
  • docs/04-argyll-binaries.md §10 iccgamut -v -d 10 (density, not a directory)
  • BinaryResolver.referenceGamut(_:)
  • GamutMesh / GamutMeshParser
  • Issues #28, #24 (iccgamut failure is info, not fatal)
  • #225 class: lazy SceneKit, pause on disappear — already in GamutView

Scope

In:

  • N meshes (1 reference + up to 2 profiles) with independent visibility
  • Bundled references beyond sRGB only when a real .gam is shipped (never an 8-cusp stub)
  • Convex-hull / mesh point-in-volume test in ICCeryCore (no new CMM)
  • Click-inspect + optional Lab / TIFF sample
  • Volume estimate (sum of tetrahedra from mesh centroid)

Out:

  • WebGL / Three.js / Metal rewrite
  • Linking lcms or ColorSync as a CMM to proof images (that is a later cctiff ticket)
  • Generating AdobeRGB.gam at runtime — ship the file or omit the preset

Implementation notes

Core

public struct NamedGamut: Sendable, Equatable, Identifiable {
  var id: String
  var displayName: String
  var role: Role // reference | profileA | profileB
  var mesh: GamutMesh
  var sourceURL: URL
}
public enum GamutContainment { case inside, outside, unknown }

Point-in-mesh uses the same Lab→SIMD3 mapping as GamutVertex.position (x=a*, y=L*, z=b*). Use the parser’s existing hull fallback when faces are missing. Do not invent Delaunay.

Volume: sum signed tetrahedra from centroid to each face; expose Lab-cubic units in the status line only. A “percent of sRGB” ratio is allowed only when both volumes are finite and > 0.

Runner

If the user picks an .icc/.icm that has no sibling .gam, call existing ArgyllRunner.runIccgamut(config: IccgamutConfig(profileURL:)) with -d 10, cwd = parent, id iccgamut_{stem}. Non-zero or missing .gam → info banner, mesh omitted, other meshes stay (parity with #24).

Add FileDialogService.selectGamutFile (open .gam) and reuse selectProfileFile (.icc/.icm/.mpp, not .ti*, #172). One picker per purpose — do not share selectDatasetFile.

View model

Replace the two-optional-mesh shape:

var layers: [NamedGamut]
var visibleIDs: Set<String>
var inspectLab: LabColor?
var inspectResult: [(id, GamutContainment, DisplayRGB)]

Load sRGB on init via BinaryResolver.referenceGamut("sRGB") as today. “Add profile…” appends profile B; adding a third profile replaces B, never sRGB. Parse off-main via Task.detached as today.

Click: convert SceneKit hit to Lab (x→a*, y→L*, z→b*). Show Lab* + per-layer inside/outside + vertex RGB already stored on GamutVertex.rgb.

Optional “Sample TIFF”: host-side TIFF→PNG already exists (readTiffPreviewPng, #58). Pick a pixel, convert sRGB 8-bit → Lab D50 with a documented approximate matrix helper in ICCeryCore labelled approximate, not ColorSync. Do not call a CMM.

SceneKit

Keep one SCNView. Distinct geometries per layer, vertex colours from GamutVertex.rgb, profile B wireframe or ~30% opacity so overlaps read. Pause render loop on disappear (already there). R still resets camera on the focusable container — do not port the v1 dead-key trap.

Intel / context-lost: if SCNView fails to create a context, set gamutViewerUnavailable and show the docs/18 fallback banner. Do not respawn the view in a loop.

UI

Stay inside the existing GamutView sheet. Do not add a second viewer, do not embed SceneKit in Stage 5 itself, and do not put layer chrome in the 270 pt sidebar. btnViewGamut already exists in both SidebarView and Stage5View and already opens RootView’s .sheet → GamutView(profileGamURL:). That entry path stays.

Existing identifiers that must keep working for Milestone6GamutUITests:

  • btnViewGamut
  • gamutView (containment on the whole sheet)
  • gamutStatusText
  • btnResetGamutCamera

Do not rename btnResetGamutCamera to btnGamutReset. The short list in the first draft of this ticket is wrong; use the ids already on develop.

Sheet chrome

GamutView is currently a ZStack (scene + floating Reset + floating status), min 500×400. Grow it:

  • .frame(minWidth: 720, minHeight: 520) so the layer row and inspect panel fit without covering the mesh
  • Theme.background around the chrome; keep the SceneKit clear colour NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1) (already in GamutSceneView)
  • Present still from RootView. Do not present from SidebarView or Stage5View (clips, double sheets)

Layout, top to bottom:

  1. Toolbar (new) — layer toggles + Add compare + Sample…
  2. Scene — existing GamutSceneView, still .focusable() so R works
  3. Status line — keep gamutStatusText, extend the string
  4. Inspect panel (new) — Lab readout + per-layer inside/outside

Reset stays as the existing top-trailing Button btnResetGamutCamera. Do not add a second Reset in the toolbar.

Toolbar — layer toggles

A leading HStack of Toggles, one per NamedGamut in layers, bound to visibleIDs.

Layer Id Default on Label
bundled sRGB gamutLayer-sRGB yes Text("sRGB")
current profile (A) gamutLayer-profile yes if mesh loaded, else disabled Text(profileDisplayName) or Text("Profile")
compare profile (B) gamutLayer-compare yes once loaded Text(compareDisplayName) or Text("Compare")
  • Toggles use .toggleStyle(.checkbox) so they read as the v1 layer list, not as switches.
  • Disabled + help “No profile .gam loaded” when A or B has no mesh. Unchecking hides the SCNNode; it does not unload the GamutMesh.
  • sRGB cannot be removed, only hidden. There is no “delete sRGB” control.
  • Profile names via Text only (#114). Never put the raw path in the toggle label; put the filename in .help.

Buttons on the trailing side of the same toolbar:

  • Add compare… btnGamutAddCompare — always enabled. Opens an .actionSheet / menu with two items:
    • Open .gam… btnGamutOpenGam → FileDialogService.selectGamutFile
    • Open profile… btnGamutOpenProfile → FileDialogService.selectProfileFile then runIccgamut if no sibling .gam
  • Remove compare btnGamutRemoveCompare — enabled only when layer B exists. Removes B, leaves A + sRGB.
  • Sample TIFF… btnGamutSampleTiff — optional, enabled whenever the sheet is open. See Inspect.

Adding a third profile replaces B. Do not grow the toolbar to four checkboxes. Show a one-line info notice inside the sheet (gamutNoticeText) “Compare slot holds one profile. The previous compare mesh was replaced.” — not the main NoticeBanner, which sits behind the sheet.

If iccgamut fails or the .gam parse fails: keep sRGB + A, set gamutNoticeText to the existing #24 info copy, leave gamutLayer-compare hidden/disabled. Do not close the sheet.

Status line

Keep the floating caption id gamutStatusText. Extend viewModel.status to one line, caption font, Theme.text:

sRGB 448v/892f · Profile 1024v/2048f · Compare 980v/1900f · vol 62% of sRGB

Rules:

  • Omit a clause whose layer is unloaded (not merely hidden)
  • Volume ratio only when sRGB volume and A volume are both finite and > 0; otherwise omit the vol clause rather than printing vol n/a
  • Do not put Lab inspect values in this string — that is the inspect panel
  • Milestone6GamutUITests already asserts this identifier exists and is non-empty. Keep it non-empty even with only sRGB (current behaviour)

Inspect panel

A bottom bar, id gamutInspectPanel, min height 56, Theme.panel background. Hidden is not allowed — show the idle copy so the sheet height is stable (helps UI tests and avoids SceneKit resize storms).

Idle: Text("Click the mesh, or enter Lab, to inspect.") id gamutInspectIdle

After a sample:

Field Id Content
L* gamutInspectL Text of one decimal
a* gamutInspectA Text of one decimal
b* gamutInspectB Text of one decimal
sRGB containment gamutInspect-sRGB Text("sRGB in") / Text("sRGB out") / Text("sRGB ?")
profile A gamutInspect-profile same pattern with the profile name
compare B gamutInspect-compare hidden when B unloaded
swatch gamutInspectSwatch 16×16 Color from GamutVertex.rgb when the hit was a vertex; otherwise the approximate sRGB of the typed Lab

Click path:

  • Hit-test the SCNView. If the hit node is a gamut mesh vertex or face, convert position with the existing mapping x→a*, y→L*, z→b* and publish inspectLab.
  • Clicks on the axis scaffold or empty background clear the numeric fields back to idle (do not leave a stale Lab).
  • Do not install a SwiftUI onTapGesture on the ZStack that eats SceneKit orbit drags.

Manual Lab path — compact fields in the same panel, not a separate dialog:

  • TextFields gamutLabEntryL / gamutLabEntryA / gamutLabEntryB, width ~56 each
  • Inspect button btnGamutInspectLab — disabled while any field is non-numeric
  • Range captions only; do not clamp on type. Out-of-axis values (L* outside 0…100, a*/b* outside ±128) still run containment and show ? if the point is outside every hull

TIFF path (btnGamutSampleTiff):

  • Reuse readTiffPreviewPng (#58). Show a small preview sheet gamutTiffPreview with the PNG and a click-to-sample cursor
  • Convert the clicked sRGB8 pixel through the documented approximate Lab D50 helper. Label the inspect panel Text("approx. Lab, not ColorSync") id gamutInspectApprox whenever the sample came from TIFF or from typed RGB-less Lab
  • Cancel btnCloseGamutTiffPreview. No CMM, no NSImage as the 3D source

Scene behaviour the UI must not break

  • One SCNView. Profile B is wireframe or ~30% opacity (pick one and keep it; do not toggle both). sRGB stays the current reference look. A stays solid vertex-coloured.
  • R on the focusable scene container still resets camera (onKeyPress(.init("R")) already in GamutView). Reset button stays btnResetGamutCamera.
  • .onDisappear { pause() } stays. Do not start the render loop in RootView.onAppear.
  • If SCNView cannot create a context: hide GamutSceneView, show Text from docs/18 fallback, id gamutViewerUnavailable. Do not rebuild the representable in a loop. Layer toggles stay visible but disabled.

Help overlays

Wire helpOverlay on btnViewGamut (already has a label), btnGamutAddCompare, btnGamutSampleTiff, and the inspect panel. Do not attach a help overlay to GamutSceneView — it steals first-responder from R.

Stage 5 / sidebar

No new sidebar controls in this ticket. #146 may already have added a Media block; this ticket must not insert anything between Media and Calibrate / View Gamut. btnViewGamut remains enabled even when no profile .gam exists (today it still opens sRGB-only). Keep that: compare is how the user loads a second mesh without finishing Stage 5.

Stage 5 may keep its own btnViewGamut. Do not give it a second identifier such as btnViewGamutStage5 — Milestone6GamutUITests uses waitFor("btnViewGamut").

UI tests

Keep Milestone6GamutUITests green. Add Milestone10GamutCompareUITests (no GPU assertion beyond “sheet opened”):

  • testLayerTogglesExistWithSRGB — gamutLayer-sRGB exists and is on; gamutLayer-compare exists and is disabled before a compare load
  • testAddCompareButtonExists — btnGamutAddCompare exists; cancel of the file dialog leaves sRGB status non-empty
  • testInspectPanelIdleStableHeight — gamutInspectPanel exists before any click; sheet still has gamutStatusText
  • testResetIdentifierUnchanged — btnResetGamutCamera still exists
  • Do not click into SceneKit in CI (no reliable hit-test on the runner). Containment unit tests cover Lab in/out; UI tests only prove chrome.

Identifiers (complete)

btnViewGamut                 // existing, do not rename
gamutView                    // existing
gamutStatusText              // existing
btnResetGamutCamera          // existing
gamutLayer-sRGB
gamutLayer-profile
gamutLayer-compare
btnGamutAddCompare
btnGamutOpenGam
btnGamutOpenProfile
btnGamutRemoveCompare
btnGamutSampleTiff
gamutNoticeText
gamutInspectPanel
gamutInspectIdle
gamutInspectL
gamutInspectA
gamutInspectB
gamutInspect-sRGB
gamutInspect-profile
gamutInspect-compare
gamutInspectSwatch
gamutInspectApprox
gamutLabEntryL
gamutLabEntryA
gamutLabEntryB
btnGamutInspectLab
gamutTiffPreview
btnCloseGamutTiffPreview
gamutViewerUnavailable

Rewrite invariants

iccgamut -d is density 10. Never colprof -u. Never feed TIFF to NSImage as the 3D source. User-supplied profile names via Text. AGPL: spawn iccgamut only.

Dependencies

Blocks-on: #28 viewer, #24 iccgamut, #6 dialogs, #3 reference asset.
Unblocks: none.

Test

  • CI/mock:
    • GamutMeshParser fixtures unchanged
    • containment: Lab (50,0,0) inside sRGB fixture; (50,80,80) outside
    • volume finite and positive on bundled sRGB.gam
    • missing compare .gam leaves sRGB layer
    • existing Milestone 6 gamut UI tests still pass
    • Milestone10GamutCompareUITests: layer toggles, Add compare, idle inspect panel, Reset id unchanged (no SceneKit click in CI)
  • Hardware: two real printer .gam overlays; click-inspect a vertex; no GPU hang on leave/reenter

Acceptance criteria

  • sRGB always loadable from BinaryResolver.referenceGamut("sRGB")
  • Second profile is optional; failure is info
  • Click or typed Lab shows Lab* + inside/outside per visible layer
  • Render loop pauses on disappear
  • No WebGL, no in-process CMM
  • Existing ids gamutView, gamutStatusText, btnResetGamutCamera, btnViewGamut unchanged
  • Compare chrome lives in the GamutView sheet, not the sidebar
  • Milestone6GamutUITests stay green
  • Branch: feat/<this>-gamut-compare → milestone/m10-studio → develop
## Summary `GamutViewModel` on `develop` loads bundled `sRGB.gam` plus at most one profile `.gam` next to the ICC (`Sources/ICCery/GamutViewModel.swift`). Make the viewer a comparison tool: second profile or extra reference mesh, volume / containment readout, click a vertex or drop a Lab / TIFF sample and see in/out of which hulls. ## Spec refs - `docs/18-gamut-viewer.md` (axes X=a* ±128, Y=L* 0–100, Z=b* ±128, camera home (180,120,180) lookAt (0,50,0), R resets on a focusable container, native faces > hull) - `docs/04-argyll-binaries.md` §10 `iccgamut -v -d 10` (density, not a directory) - `BinaryResolver.referenceGamut(_:)` - `GamutMesh` / `GamutMeshParser` - Issues #28, #24 (`iccgamut` failure is info, not fatal) - #225 class: lazy SceneKit, pause on disappear — already in `GamutView` ## Scope **In:** - N meshes (1 reference + up to 2 profiles) with independent visibility - Bundled references beyond sRGB *only when a real `.gam` is shipped* (never an 8-cusp stub) - Convex-hull / mesh point-in-volume test in `ICCeryCore` (no new CMM) - Click-inspect + optional Lab / TIFF sample - Volume estimate (sum of tetrahedra from mesh centroid) **Out:** - WebGL / Three.js / Metal rewrite - Linking lcms or ColorSync as a CMM to proof images (that is a later `cctiff` ticket) - Generating `AdobeRGB.gam` at runtime — ship the file or omit the preset ## Implementation notes ### Core ``` public struct NamedGamut: Sendable, Equatable, Identifiable { var id: String var displayName: String var role: Role // reference | profileA | profileB var mesh: GamutMesh var sourceURL: URL } public enum GamutContainment { case inside, outside, unknown } ``` Point-in-mesh uses the same Lab→SIMD3 mapping as `GamutVertex.position` (`x=a*`, `y=L*`, `z=b*`). Use the parser’s existing hull fallback when faces are missing. Do not invent Delaunay. Volume: sum signed tetrahedra from centroid to each face; expose Lab-cubic units in the status line only. A “percent of sRGB” ratio is allowed only when both volumes are finite and > 0. ### Runner If the user picks an `.icc`/`.icm` that has no sibling `.gam`, call existing `ArgyllRunner.runIccgamut(config: IccgamutConfig(profileURL:))` with `-d 10`, cwd = parent, id `iccgamut_{stem}`. Non-zero or missing `.gam` → info banner, mesh omitted, other meshes stay (parity with #24). Add `FileDialogService.selectGamutFile` (open `.gam`) and reuse `selectProfileFile` (`.icc/.icm/.mpp`, not `.ti*`, #172). One picker per purpose — do not share `selectDatasetFile`. ### View model Replace the two-optional-mesh shape: ``` var layers: [NamedGamut] var visibleIDs: Set<String> var inspectLab: LabColor? var inspectResult: [(id, GamutContainment, DisplayRGB)] ``` Load sRGB on init via `BinaryResolver.referenceGamut("sRGB")` as today. “Add profile…” appends profile B; adding a third profile replaces B, never sRGB. Parse off-main via `Task.detached` as today. Click: convert SceneKit hit to Lab (`x→a*`, `y→L*`, `z→b*`). Show L*a*b* + per-layer inside/outside + vertex RGB already stored on `GamutVertex.rgb`. Optional “Sample TIFF”: host-side TIFF→PNG already exists (`readTiffPreviewPng`, #58). Pick a pixel, convert sRGB 8-bit → Lab D50 with a documented approximate matrix helper in `ICCeryCore` labelled **approximate, not ColorSync**. Do not call a CMM. ### SceneKit Keep one `SCNView`. Distinct geometries per layer, vertex colours from `GamutVertex.rgb`, profile B wireframe or ~30% opacity so overlaps read. Pause render loop on disappear (already there). R still resets camera on the focusable container — do not port the v1 dead-key trap. Intel / context-lost: if `SCNView` fails to create a context, set `gamutViewerUnavailable` and show the `docs/18` fallback banner. Do not respawn the view in a loop. ### UI Stay inside the existing `GamutView` sheet. Do not add a second viewer, do not embed SceneKit in Stage 5 itself, and do not put layer chrome in the 270 pt sidebar. `btnViewGamut` already exists in both `SidebarView` and `Stage5View` and already opens `RootView`’s `.sheet` → `GamutView(profileGamURL:)`. That entry path stays. Existing identifiers that **must keep working** for `Milestone6GamutUITests`: - `btnViewGamut` - `gamutView` (containment on the whole sheet) - `gamutStatusText` - `btnResetGamutCamera` Do not rename `btnResetGamutCamera` to `btnGamutReset`. The short list in the first draft of this ticket is wrong; use the ids already on `develop`. #### Sheet chrome `GamutView` is currently a `ZStack` (scene + floating Reset + floating status), min 500×400. Grow it: - `.frame(minWidth: 720, minHeight: 520)` so the layer row and inspect panel fit without covering the mesh - `Theme.background` around the chrome; keep the SceneKit clear colour `NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1)` (already in `GamutSceneView`) - Present still from `RootView`. Do not present from `SidebarView` or `Stage5View` (clips, double sheets) Layout, top to bottom: 1. **Toolbar** (new) — layer toggles + Add compare + Sample… 2. **Scene** — existing `GamutSceneView`, still `.focusable()` so R works 3. **Status line** — keep `gamutStatusText`, extend the string 4. **Inspect panel** (new) — Lab readout + per-layer inside/outside Reset stays as the existing top-trailing `Button` `btnResetGamutCamera`. Do not add a second Reset in the toolbar. #### Toolbar — layer toggles A leading `HStack` of `Toggle`s, one per `NamedGamut` in `layers`, bound to `visibleIDs`. | Layer | Id | Default on | Label | |---|---|---|---| | bundled sRGB | `gamutLayer-sRGB` | yes | `Text("sRGB")` | | current profile (A) | `gamutLayer-profile` | yes if mesh loaded, else disabled | `Text(profileDisplayName)` or `Text("Profile")` | | compare profile (B) | `gamutLayer-compare` | yes once loaded | `Text(compareDisplayName)` or `Text("Compare")` | - Toggles use `.toggleStyle(.checkbox)` so they read as the v1 layer list, not as switches. - Disabled + help “No profile .gam loaded” when A or B has no mesh. Unchecking hides the `SCNNode`; it does not unload the `GamutMesh`. - sRGB cannot be removed, only hidden. There is no “delete sRGB” control. - Profile names via `Text` only (#114). Never put the raw path in the toggle label; put the filename in `.help`. Buttons on the trailing side of the same toolbar: - `Add compare…` `btnGamutAddCompare` — always enabled. Opens an `.actionSheet` / menu with two items: - `Open .gam…` `btnGamutOpenGam` → `FileDialogService.selectGamutFile` - `Open profile…` `btnGamutOpenProfile` → `FileDialogService.selectProfileFile` then `runIccgamut` if no sibling `.gam` - `Remove compare` `btnGamutRemoveCompare` — enabled only when layer B exists. Removes B, leaves A + sRGB. - `Sample TIFF…` `btnGamutSampleTiff` — optional, enabled whenever the sheet is open. See Inspect. Adding a third profile **replaces B**. Do not grow the toolbar to four checkboxes. Show a one-line info notice inside the sheet (`gamutNoticeText`) “Compare slot holds one profile. The previous compare mesh was replaced.” — not the main `NoticeBanner`, which sits behind the sheet. If `iccgamut` fails or the `.gam` parse fails: keep sRGB + A, set `gamutNoticeText` to the existing #24 info copy, leave `gamutLayer-compare` hidden/disabled. Do not close the sheet. #### Status line Keep the floating caption id `gamutStatusText`. Extend `viewModel.status` to one line, caption font, `Theme.text`: ``` sRGB 448v/892f · Profile 1024v/2048f · Compare 980v/1900f · vol 62% of sRGB ``` Rules: - Omit a clause whose layer is unloaded (not merely hidden) - Volume ratio only when sRGB volume and A volume are both finite and > 0; otherwise omit the `vol` clause rather than printing `vol n/a` - Do not put Lab inspect values in this string — that is the inspect panel - `Milestone6GamutUITests` already asserts this identifier exists and is non-empty. Keep it non-empty even with only sRGB (current behaviour) #### Inspect panel A bottom bar, id `gamutInspectPanel`, min height 56, `Theme.panel` background. Hidden is not allowed — show the idle copy so the sheet height is stable (helps UI tests and avoids SceneKit resize storms). Idle: `Text("Click the mesh, or enter Lab, to inspect.")` id `gamutInspectIdle` After a sample: | Field | Id | Content | |---|---|---| | L* | `gamutInspectL` | `Text` of one decimal | | a* | `gamutInspectA` | `Text` of one decimal | | b* | `gamutInspectB` | `Text` of one decimal | | sRGB containment | `gamutInspect-sRGB` | `Text("sRGB in")` / `Text("sRGB out")` / `Text("sRGB ?")` | | profile A | `gamutInspect-profile` | same pattern with the profile name | | compare B | `gamutInspect-compare` | hidden when B unloaded | | swatch | `gamutInspectSwatch` | 16×16 `Color` from `GamutVertex.rgb` when the hit was a vertex; otherwise the approximate sRGB of the typed Lab | Click path: - Hit-test the `SCNView`. If the hit node is a gamut mesh vertex or face, convert position with the existing mapping `x→a*`, `y→L*`, `z→b*` and publish `inspectLab`. - Clicks on the axis scaffold or empty background clear the numeric fields back to idle (do not leave a stale Lab). - Do not install a SwiftUI `onTapGesture` on the `ZStack` that eats SceneKit orbit drags. Manual Lab path — compact fields in the same panel, not a separate dialog: - `TextField`s `gamutLabEntryL` / `gamutLabEntryA` / `gamutLabEntryB`, width ~56 each - `Inspect` button `btnGamutInspectLab` — disabled while any field is non-numeric - Range captions only; do not clamp on type. Out-of-axis values (L* outside 0…100, a*/b* outside ±128) still run containment and show `?` if the point is outside every hull TIFF path (`btnGamutSampleTiff`): - Reuse `readTiffPreviewPng` (#58). Show a small preview sheet `gamutTiffPreview` with the PNG and a click-to-sample cursor - Convert the clicked sRGB8 pixel through the documented **approximate** Lab D50 helper. Label the inspect panel `Text("approx. Lab, not ColorSync")` id `gamutInspectApprox` whenever the sample came from TIFF or from typed RGB-less Lab - Cancel `btnCloseGamutTiffPreview`. No CMM, no `NSImage` as the 3D source #### Scene behaviour the UI must not break - One `SCNView`. Profile B is wireframe **or** ~30% opacity (pick one and keep it; do not toggle both). sRGB stays the current reference look. A stays solid vertex-coloured. - R on the focusable scene container still resets camera (`onKeyPress(.init("R"))` already in `GamutView`). Reset button stays `btnResetGamutCamera`. - `.onDisappear { pause() }` stays. Do not start the render loop in `RootView.onAppear`. - If `SCNView` cannot create a context: hide `GamutSceneView`, show `Text` from `docs/18` fallback, id `gamutViewerUnavailable`. Do not rebuild the representable in a loop. Layer toggles stay visible but disabled. #### Help overlays Wire `helpOverlay` on `btnViewGamut` (already has a label), `btnGamutAddCompare`, `btnGamutSampleTiff`, and the inspect panel. Do **not** attach a help overlay to `GamutSceneView` — it steals first-responder from R. #### Stage 5 / sidebar No new sidebar controls in this ticket. #146 may already have added a Media block; this ticket must not insert anything between Media and Calibrate / View Gamut. `btnViewGamut` remains enabled even when no profile `.gam` exists (today it still opens sRGB-only). Keep that: compare is how the user loads a second mesh without finishing Stage 5. Stage 5 may keep its own `btnViewGamut`. Do not give it a second identifier such as `btnViewGamutStage5` — `Milestone6GamutUITests` uses `waitFor("btnViewGamut")`. #### UI tests Keep `Milestone6GamutUITests` green. Add `Milestone10GamutCompareUITests` (no GPU assertion beyond “sheet opened”): - `testLayerTogglesExistWithSRGB` — `gamutLayer-sRGB` exists and is on; `gamutLayer-compare` exists and is disabled before a compare load - `testAddCompareButtonExists` — `btnGamutAddCompare` exists; cancel of the file dialog leaves sRGB status non-empty - `testInspectPanelIdleStableHeight` — `gamutInspectPanel` exists before any click; sheet still has `gamutStatusText` - `testResetIdentifierUnchanged` — `btnResetGamutCamera` still exists - Do not click into SceneKit in CI (no reliable hit-test on the runner). Containment unit tests cover Lab in/out; UI tests only prove chrome. #### Identifiers (complete) ``` btnViewGamut // existing, do not rename gamutView // existing gamutStatusText // existing btnResetGamutCamera // existing gamutLayer-sRGB gamutLayer-profile gamutLayer-compare btnGamutAddCompare btnGamutOpenGam btnGamutOpenProfile btnGamutRemoveCompare btnGamutSampleTiff gamutNoticeText gamutInspectPanel gamutInspectIdle gamutInspectL gamutInspectA gamutInspectB gamutInspect-sRGB gamutInspect-profile gamutInspect-compare gamutInspectSwatch gamutInspectApprox gamutLabEntryL gamutLabEntryA gamutLabEntryB btnGamutInspectLab gamutTiffPreview btnCloseGamutTiffPreview gamutViewerUnavailable ``` ### Rewrite invariants `iccgamut -d` is density 10. Never `colprof -u`. Never feed TIFF to `NSImage` as the 3D source. User-supplied profile names via `Text`. AGPL: spawn `iccgamut` only. ## Dependencies Blocks-on: #28 viewer, #24 iccgamut, #6 dialogs, #3 reference asset. Unblocks: none. ## Test - CI/mock: - `GamutMeshParser` fixtures unchanged - containment: Lab (50,0,0) inside sRGB fixture; (50,80,80) outside - volume finite and positive on bundled `sRGB.gam` - missing compare `.gam` leaves sRGB layer - existing Milestone 6 gamut UI tests still pass - `Milestone10GamutCompareUITests`: layer toggles, Add compare, idle inspect panel, Reset id unchanged (no SceneKit click in CI) - Hardware: two real printer `.gam` overlays; click-inspect a vertex; no GPU hang on leave/reenter ## Acceptance criteria - [ ] sRGB always loadable from `BinaryResolver.referenceGamut("sRGB")` - [ ] Second profile is optional; failure is info - [ ] Click or typed Lab shows L*a*b* + inside/outside per visible layer - [ ] Render loop pauses on disappear - [ ] No WebGL, no in-process CMM - [ ] Existing ids `gamutView`, `gamutStatusText`, `btnResetGamutCamera`, `btnViewGamut` unchanged - [ ] Compare chrome lives in the `GamutView` sheet, not the sidebar - [ ] `Milestone6GamutUITests` stay green - [ ] Branch: `feat/<this>-gamut-compare` → `milestone/m10-studio` → `develop`
gronod added this to the M10 — Studio workflow (media library, gamut compare, spot-read, projects) milestone 2026-09-12 18:34:18 +01:00
gronod self-assigned this 2026-09-12 18:34:18 +01:00
Author
Owner

M10 implementation order (see milestone): 3 of 4. May develop in parallel with #148; merge after #148 to keep sidebar/a11y diffs serial.

M10 implementation order (see milestone): **3 of 4**. May develop in parallel with #148; merge after #148 to keep sidebar/a11y diffs serial.
Author
Owner

Implementation landed in PR #153 at 398d9b4756 on milestone/m10-studio (merged as 99c0d7e).

Acceptance evidence:

  • sRGB always loadable from BinaryResolver.referenceGamut("sRGB") — GamutViewModel seeds the reference layer from it.
  • Second profile is optional — btnGamutAddCompare offers .gam / profile load; failure surfaces as info text (gamutNoticeText), never a crash.
  • Click or typed Lab shows Lab* + inside/outside per visible layer — SceneKit hit-test coordinator + gamutLabEntry{L,A,B} / btnGamutInspectLab write into gamutInspectPanel with per-layer containment (GamutContainment, Möller–Trumbore + signed-tetra volume).
  • Render loop pauses on disappear (SceneKit view isPlaying toggled in coordinator).
  • No WebGL, no in-process CMM — SceneKit + fixed-matrix ApproximateLab sRGB8→Lab D50.
  • Existing ids unchanged: gamutView, gamutStatusText, btnResetGamutCamera, btnViewGamut.
  • Compare chrome lives in the GamutView sheet toolbar (gamutLayer-* toggles, btnGamutAddCompare), not the sidebar.
  • Milestone6GamutUITests green (testViewGamutOpensSceneKitSheet passed 19.171 s); Milestone10GamutCompareUITests 7/7 green.

Verification at milestone/m10-studio c39afe0dfd (x86_64):

  • targeted gate (PR #153 body): passed
  • full host-arch ICCeryCoreTests: 439 passed, 0 failed
  • full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip)
  • command: xcodebuild test -scheme ICCery -destination 'platform=macOS' -derivedDataPath build/DerivedData-m10-final ARCHS=x86_64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
  • xcresult: build/DerivedData-m10-final/Logs/Test/Test-ICCery-2026.09.13_11-45-03-+0100.xcresult

Hardware item (two real .gam files, click-inspect, no GPU hang) remains a milestone gate (Phase 8), not this issue's merge gate.
Risk register rows proven at this SHA: R16 (SceneKit ids stable under compare chrome).
Closing manually only after code and tests are present on the milestone branch.

Implementation landed in PR #153 at 398d9b4756a5e361ebffbfbaa9987988f41b2806 on milestone/m10-studio (merged as 99c0d7e). Acceptance evidence: - [x] sRGB always loadable from `BinaryResolver.referenceGamut("sRGB")` — `GamutViewModel` seeds the reference layer from it. - [x] Second profile is optional — `btnGamutAddCompare` offers `.gam` / profile load; failure surfaces as info text (`gamutNoticeText`), never a crash. - [x] Click or typed Lab shows L*a*b* + inside/outside per visible layer — SceneKit hit-test coordinator + `gamutLabEntry{L,A,B}` / `btnGamutInspectLab` write into `gamutInspectPanel` with per-layer containment (`GamutContainment`, Möller–Trumbore + signed-tetra volume). - [x] Render loop pauses on disappear (SceneKit view `isPlaying` toggled in coordinator). - [x] No WebGL, no in-process CMM — SceneKit + fixed-matrix `ApproximateLab` sRGB8→Lab D50. - [x] Existing ids unchanged: `gamutView`, `gamutStatusText`, `btnResetGamutCamera`, `btnViewGamut`. - [x] Compare chrome lives in the `GamutView` sheet toolbar (`gamutLayer-*` toggles, `btnGamutAddCompare`), not the sidebar. - [x] `Milestone6GamutUITests` green (`testViewGamutOpensSceneKitSheet` passed 19.171 s); `Milestone10GamutCompareUITests` 7/7 green. Verification at milestone/m10-studio c39afe0dfd007863660bafff15c8da3ca11d3b5d (x86_64): - targeted gate (PR #153 body): passed - full host-arch ICCeryCoreTests: 439 passed, 0 failed - full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip) - command: `xcodebuild test -scheme ICCery -destination 'platform=macOS' -derivedDataPath build/DerivedData-m10-final ARCHS=x86_64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'` - xcresult: build/DerivedData-m10-final/Logs/Test/Test-ICCery-2026.09.13_11-45-03-+0100.xcresult Hardware item (two real `.gam` files, click-inspect, no GPU hang) remains a milestone gate (Phase 8), not this issue's merge gate. Risk register rows proven at this SHA: R16 (SceneKit ids stable under compare chrome). Closing manually only after code and tests are present on the milestone branch.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gronod/iccery-v2-mac#147