From 398d9b4756a5e361ebffbfbaa9987988f41b2806 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 13 Sep 2026 10:23:25 +0100 Subject: [PATCH] feat(gamut): second-profile compare and click-inspect (#147) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Gamut/ApproximateLab.swift | 64 ++ .../ICCeryCore/Gamut/GamutContainment.swift | 115 ++++ .../Sources/ICCeryCore/Gamut/NamedGamut.swift | 39 ++ Sources/ICCery/AppEnvironment.swift | 6 + Sources/ICCery/FileDialogService.swift | 18 +- Sources/ICCery/GamutView.swift | 546 +++++++++++++++--- Sources/ICCery/GamutViewModel.swift | 296 +++++++++- Sources/ICCery/RootView.swift | 5 +- Sources/ICCery/SidebarView.swift | 3 + .../ICCeryCoreTests/ApproximateLabTests.swift | 39 ++ .../GamutContainmentTests.swift | 85 +++ .../ICCeryCoreTests/GamutViewModelTests.swift | 127 ++++ .../Milestone10GamutCompareUITests.swift | 186 ++++++ docs/18-gamut-viewer.md | 32 + docs/21-ui-reference.md | 5 +- 15 files changed, 1466 insertions(+), 100 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift create mode 100644 Tests/ICCeryCoreTests/ApproximateLabTests.swift create mode 100644 Tests/ICCeryCoreTests/GamutContainmentTests.swift create mode 100644 Tests/ICCeryCoreTests/GamutViewModelTests.swift create mode 100644 Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift new file mode 100644 index 0000000..9310082 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel +/// (issue #147). +/// +/// This is a fixed-matrix helper, **not** a colour-management module: it +/// never touches ICC profiles, ColorSync, or lcms. The UI labels its +/// output "approx. Lab, not ColorSync". +public enum ApproximateLab { + + /// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1. + private static let srgbToXYZ: [[Double]] = [ + [0.4124, 0.3576, 0.1805], + [0.2126, 0.7152, 0.0722], + [0.0193, 0.1192, 0.9505], + ] + + /// 8-bit sRGB triple → Lab D50 (approximate). + public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor { + srgbToLab(DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0)) + } + + /// 0–1 sRGB triple → Lab D50 (approximate). + public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor { + func linear(_ c: Double) -> Double { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)] + // 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in. + let xyz65 = XYZColor( + x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100, + y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100, + z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100) + return LabColorMath.xyzToLab(adaptD65ToD50(xyz65)) + } + + /// Bradford D65 → D50 chromatic adaptation — the mirror of + /// `LabColorMath.adaptD50ToD65`. + private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor { + let m = LabColorMath.bradford + let inv = LabColorMath.bradfordInv + let d65 = LabColorMath.d65White + let d50 = LabColorMath.d50White + let source = multiply(m, [xyz.x, xyz.y, xyz.z]) + let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z]) + let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z]) + let scaled = [ + source[0] * (dstWhite[0] / srcWhite[0]), + source[1] * (dstWhite[1] / srcWhite[1]), + source[2] * (dstWhite[2] / srcWhite[2]), + ] + return XYZColor( + x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2], + y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2], + z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2]) + } + + private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] { + m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift new file mode 100644 index 0000000..9202712 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift @@ -0,0 +1,115 @@ +import Foundation +import simd + +/// Result of a point-in-gamut test (issue #147). +public enum GamutContainment: String, Sendable, Equatable { + /// The point lies inside the mesh volume. + case inside + /// The point lies outside the mesh volume. + case outside + /// The mesh has no faces to test against. + case unknown +} + +/// Point-in-mesh containment and volume estimation for ``GamutMesh``. +/// +/// Both tests run on the scene-space positions stored on +/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same +/// mapping the SceneKit viewer uses. No colour management is involved. +public enum GamutGeometry { + + /// Whether `lab` is inside `mesh`. + /// + /// Ray-casts through the face list: an odd crossing count means the + /// point is inside a closed surface. A ray that grazes a vertex or + /// edge gives an ambiguous count, so the test retries with off-axis + /// directions before answering. Meshes without faces report + /// ``GamutContainment/unknown``. + public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment { + guard !mesh.faces.isEmpty else { return .unknown } + let origin = SIMD3(lab.a, lab.l, lab.b) + for direction in rayDirections { + if let inside = castRay(from: origin, direction: direction, mesh: mesh) { + return inside ? .inside : .outside + } + } + return .unknown + } + + /// Approximate mesh volume in Lab-cubic units. + /// + /// Sums signed tetrahedra from the vertex centroid to each face; for + /// a closed surface the magnitude equals the enclosed volume + /// regardless of face winding. Returns 0 for empty or face-less + /// meshes. + public static func volume(of mesh: GamutMesh) -> Double { + guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 } + var centroid = SIMD3.zero + for vertex in mesh.vertices { + centroid += SIMD3(vertex.position) + } + centroid /= Double(mesh.vertices.count) + + var sum = 0.0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let a = SIMD3(mesh.vertices[Int(face.a)].position) - centroid + let b = SIMD3(mesh.vertices[Int(face.b)].position) - centroid + let c = SIMD3(mesh.vertices[Int(face.c)].position) - centroid + sum += simd_dot(a, simd_cross(b, c)) / 6.0 + } + return abs(sum) + } + + // MARK: - Ray casting + + /// Primary +X ray, then off-axis retries for degenerate edge hits. + private static let rayDirections: [SIMD3] = [ + SIMD3(1, 0, 0), + simd_normalize(SIMD3(0.71, 1.0, 0.53)), + simd_normalize(SIMD3(0.53, 0.71, 1.0)), + ] + + /// Möller–Trumbore crossing count. Returns `nil` when a crossing + /// lands on a triangle edge or vertex (ambiguous parity) so the + /// caller can retry with a different direction. + private static func castRay( + from origin: SIMD3, + direction dir: SIMD3, + mesh: GamutMesh + ) -> Bool? { + let epsilon = 1e-9 + var crossings = 0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let va = SIMD3(mesh.vertices[Int(face.a)].position) + let vb = SIMD3(mesh.vertices[Int(face.b)].position) + let vc = SIMD3(mesh.vertices[Int(face.c)].position) + + let e1 = vb - va + let e2 = vc - va + let p = simd_cross(dir, e2) + let det = simd_dot(e1, p) + if abs(det) < 1e-12 { continue } // ray parallel to face + let inv = 1.0 / det + let tvec = origin - va + let u = simd_dot(tvec, p) * inv + let q = simd_cross(tvec, e1) + let v = simd_dot(dir, q) * inv + let t = simd_dot(e2, q) * inv + + guard t > epsilon else { continue } + if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue } + // Crossing on an edge or vertex — parity is ambiguous. + if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil } + crossings += 1 + } + return crossings % 2 == 1 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift new file mode 100644 index 0000000..57f3a79 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A parsed gamut mesh plus the display metadata the compare viewer +/// needs (issue #147). +/// +/// `displayName` is user-derived (a file name); views must render it +/// through `Text` only (#114). +public struct NamedGamut: Sendable, Equatable, Identifiable { + + /// What the layer is used for in the compare UI. + public enum Role: String, Sendable, Equatable { + /// Bundled reference space (sRGB). Cannot be removed, only hidden. + case reference + /// The workflow's own profile gamut. + case profileA + /// The user-added compare gamut. Replaced, never stacked. + case profileB + } + + public var id: String + public var displayName: String + public var role: Role + public var mesh: GamutMesh + public var sourceURL: URL + + public init( + id: String, + displayName: String, + role: Role, + mesh: GamutMesh, + sourceURL: URL + ) { + self.id = id + self.displayName = displayName + self.role = role + self.mesh = mesh + self.sourceURL = sourceURL + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 5dcfb55..27420b8 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -83,6 +83,12 @@ enum UITestHooks { static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } /// Spot-read CSV export destination (`selectCsvSavePath`, #148). static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") } + /// `.gam` compare picker result (gamut sheet, #147). Unset → cancel. + static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") } + /// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel. + static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") } + /// TIFF sample picker result (gamut sheet, #147). Unset → cancel. + static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") } // MARK: - Print panel / CUPS stubs (issue 13/17) diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index 5852e93..f7f8379 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -70,6 +70,19 @@ final class FileDialogService { message: "Choose a calibration file (.cal)") } + /// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147). + func selectGamutFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["gam"], startingAt: start, + message: "Choose a .gam surface mesh", + allowsOtherFileTypes: false) + } + + /// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147). + func selectTiffFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["tif", "tiff"], startingAt: start, + message: "Choose a target TIFF page") + } + /// `btnImportPreset` — open a `.json` preset file. func selectPresetFile(startingAt start: URL? = nil) -> URL? { open(extensions: ["json"], startingAt: start, @@ -102,14 +115,15 @@ final class FileDialogService { private func open( extensions: [String], startingAt start: URL?, - message: String? + message: String?, + allowsOtherFileTypes: Bool = true ) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = utTypes(extensions) - panel.allowsOtherFileTypes = true + panel.allowsOtherFileTypes = allowsOtherFileTypes panel.directoryURL = start if let message { panel.message = message } return run(panel) diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift index aa3debb..7ede277 100644 --- a/Sources/ICCery/GamutView.swift +++ b/Sources/ICCery/GamutView.swift @@ -1,5 +1,6 @@ import SwiftUI import SceneKit +import Metal import ICCeryCore import simd @@ -62,72 +63,300 @@ internal struct GamutSceneGeometryBuilder { } } -/// Native SceneKit 3D gamut viewer. +/// Native SceneKit 3D gamut viewer (issues #28, #147). /// -/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses -/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the -/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* -/// (blue-yellow) is depth. +/// Displays the bundled `sRGB.gam` reference plus up to two profile +/// meshes with independent visibility toggles, a status line, and an +/// inspect panel (click a mesh, type a Lab value, or sample a TIFF +/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`, +/// `z = b*`. struct GamutView: View { @StateObject private var viewModel: GamutViewModel @State private var pause: () -> Void = {} @FocusState private var isFocused: Bool + @Binding var showingAllHelp: Bool - init(profileGamURL: URL? = nil) { - _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) + init( + environment: AppEnvironment, + profileGamURL: URL? = nil, + showingAllHelp: Binding + ) { + _viewModel = StateObject(wrappedValue: GamutViewModel( + environment: environment, profileGamURL: profileGamURL)) + _showingAllHelp = showingAllHelp } var body: some View { - ZStack { - GamutSceneView( - profileMesh: viewModel.profileMesh, - referenceMesh: viewModel.sRGBMesh, - onReset: $viewModel.resetCamera, - onPause: $pause - ) - .focusable() - .focused($isFocused) - .onAppear { isFocused = true } - - VStack { - HStack { - Spacer() - Button(action: { viewModel.resetCamera() }) { - Text("Reset view") - } - .accessibilityIdentifier("btnResetGamutCamera") - .padding(8) - } - Spacer() - HStack { - Text(viewModel.status) - .font(.caption) - .padding(8) - .background(.thinMaterial) - .cornerRadius(6) - .accessibilityIdentifier("gamutStatusText") - Spacer() - } - .padding(8) - } + VStack(spacing: 0) { + toolbar + Divider().overlay(Theme.border) + sceneArea + Divider().overlay(Theme.border) + statusLine + inspectPanel } - .frame(minWidth: 500, minHeight: 400) + .frame(minWidth: 720, minHeight: 520) + .background(Theme.background) .onDisappear { pause() } .accessibilityElement(children: .contain) .accessibilityIdentifier("gamutView") + .sheet(isPresented: $viewModel.showingTiffPreview) { + tiffPreviewSheet + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB") + layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile") + layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare") + Spacer() + addCompareMenu + Button("Remove compare") { viewModel.removeCompare() } + .disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil) + .accessibilityIdentifier("btnGamutRemoveCompare") + Button("Sample TIFF…") { viewModel.openTiffSample() } + .accessibilityIdentifier("btnGamutSampleTiff") + .helpOverlay( + "Sample a colour from a target TIFF page.", + showing: $showingAllHelp) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func layerToggle(id: String, fallback: String) -> some View { + let layer = viewModel.layer(id: id) + return Toggle(isOn: Binding( + get: { layer != nil && viewModel.visibleIDs.contains(id) }, + set: { on in + if on { + viewModel.visibleIDs.insert(id) + } else { + viewModel.visibleIDs.remove(id) + } + } + )) { + Text(layer?.displayName ?? fallback) + } + .toggleStyle(.checkbox) + .disabled(layer == nil || viewModel.viewerUnavailable) + .help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded") + .accessibilityIdentifier("gamutLayer-\(id)") + } + + private var addCompareMenu: some View { + Menu("Add compare…") { + Button("Open .gam…") { viewModel.openCompareGam() } + .accessibilityIdentifier("btnGamutOpenGam") + Button("Open profile…") { viewModel.openCompareProfile() } + .accessibilityIdentifier("btnGamutOpenProfile") + } + .accessibilityIdentifier("btnGamutAddCompare") + .helpOverlay( + "Add a second profile or .gam mesh to compare against.", + showing: $showingAllHelp) + } + + // MARK: - Scene + + private var sceneArea: some View { + ZStack(alignment: .topTrailing) { + if viewModel.viewerUnavailable { + Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("gamutViewerUnavailable") + } else { + GamutSceneView( + layers: viewModel.layers, + visibleIDs: viewModel.visibleIDs, + onReset: $viewModel.resetCamera, + onPause: $pause, + onUnavailable: { viewModel.viewerUnavailable = true }, + onInspect: { point, layerID in + if let layerID { + viewModel.inspectSceneHit(world: point, layerID: layerID) + } else { + viewModel.clearInspect() + } + } + ) + .focusable() + .focused($isFocused) + .onAppear { isFocused = true } + } + Button(action: { viewModel.resetCamera() }) { + Text("Reset view") + } + .accessibilityIdentifier("btnResetGamutCamera") + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Status + + private var statusLine: some View { + HStack(spacing: 10) { + Text(viewModel.status) + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutStatusText") + if let notice = viewModel.noticeText { + Text(notice) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutNoticeText") + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + // MARK: - Inspect panel + + private var inspectPanel: some View { + HStack(spacing: 12) { + if let lab = viewModel.inspectLab { + inspectSwatch + labReadout(lab) + containmentColumn + if viewModel.inspectIsApproximate { + Text("approx. Lab, not ColorSync") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectApprox") + } + } else { + Text("Click the mesh, or enter Lab, to inspect.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectIdle") + } + Spacer() + labEntryFields + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 56) + .background(Theme.panel) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutInspectPanel") + .helpOverlay( + "Inspect a Lab point against each loaded gamut.", + showing: $showingAllHelp) + } + + @ViewBuilder + private var inspectSwatch: some View { + if let swatch = viewModel.inspectSwatch { + Color(red: swatch.r, green: swatch.g, blue: swatch.b) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border)) + .accessibilityIdentifier("gamutInspectSwatch") + } + } + + private func labReadout(_ lab: LabColor) -> some View { + HStack(spacing: 10) { + Text(String(format: "L %.1f", lab.l)) + .accessibilityIdentifier("gamutInspectL") + Text(String(format: "a %.1f", lab.a)) + .accessibilityIdentifier("gamutInspectA") + Text(String(format: "b %.1f", lab.b)) + .accessibilityIdentifier("gamutInspectB") + } + .font(.caption.monospacedDigit()) + .foregroundStyle(Theme.text) + } + + private var containmentColumn: some View { + HStack(spacing: 10) { + ForEach(viewModel.inspectResults, id: \.id) { result in + Text("\(result.name) \(containmentWord(result.containment))") + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutInspect-\(result.id)") + } + } + } + + private func containmentWord(_ containment: GamutContainment) -> String { + switch containment { + case .inside: return "in" + case .outside: return "out" + case .unknown: return "?" + } + } + + private var labEntryFields: some View { + HStack(spacing: 6) { + Text("Lab 0–100 · ±128") + .font(.caption2) + .foregroundStyle(.secondary) + TextField("L", text: $viewModel.labEntryL) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryL") + TextField("a", text: $viewModel.labEntryA) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryA") + TextField("b", text: $viewModel.labEntryB) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryB") + Button("Inspect") { viewModel.inspectEnteredLab() } + .disabled(!viewModel.canInspectLab) + .accessibilityIdentifier("btnGamutInspectLab") + } + } + + // MARK: - TIFF sample sheet + + private var tiffPreviewSheet: some View { + VStack(spacing: 12) { + Text("Click a pixel to sample its colour.") + .font(.headline) + .foregroundStyle(Theme.text) + if let png = viewModel.tiffPreviewPNG { + TiffSampleImageView(pngData: png) { r, g, b in + viewModel.sampleTiffPixel(r: r, g: g, b: b) + } + .frame(minWidth: 320, minHeight: 240) + } + HStack { + Spacer() + Button("Cancel") { viewModel.showingTiffPreview = false } + .accessibilityIdentifier("btnCloseGamutTiffPreview") + } + } + .padding(16) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutTiffPreview") } } -/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from -/// one or two ``GamutMesh`` values. +/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node +/// per ``NamedGamut`` layer. /// -/// Scene construction and camera reset are coordinated through a typed callback -/// binding owned by the view model. +/// Layer toggles hide/show `SCNNode`s — the scene is built once and the +/// camera is only reset through the explicit reset path, never on a +/// mesh or visibility update. private struct GamutSceneView: NSViewRepresentable { - var profileMesh: GamutMesh? - var referenceMesh: GamutMesh? + var layers: [NamedGamut] + var visibleIDs: Set var onReset: Binding<() -> Void> var onPause: Binding<() -> Void> + var onUnavailable: () -> Void + var onInspect: (SIMD3, String?) -> Void func makeNSView(context: Context) -> SCNView { let scnView = SCNView() @@ -142,14 +371,23 @@ private struct GamutSceneView: NSViewRepresentable { context.coordinator.scnView = scnView context.coordinator.scene = scene - context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.onInspect = onInspect + context.coordinator.buildSceneOnce() + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) context.coordinator.installKeyMonitor() + context.coordinator.installClickGesture() + + // No GPU → the docs/18 fallback; never respawn the view in a loop. + if MTLCreateSystemDefaultDevice() == nil { + DispatchQueue.main.async { onUnavailable() } + } return scnView } func updateNSView(_ nsView: SCNView, context: Context) { - context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.onInspect = onInspect + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) } func makeCoordinator() -> Coordinator { @@ -165,6 +403,9 @@ private struct GamutSceneView: NSViewRepresentable { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { coordinator.removeKeyMonitor() + if let click = coordinator.clickGesture { + nsView.removeGestureRecognizer(click) + } nsView.isPlaying = false } @@ -172,11 +413,14 @@ private struct GamutSceneView: NSViewRepresentable { final class Coordinator: NSObject { weak var scnView: SCNView? weak var scene: SCNScene? + var onInspect: (SIMD3, String?) -> Void = { _, _ in } + private(set) var clickGesture: NSClickGestureRecognizer? private var keyMonitor: Any? - private let profileNode = SCNNode() - private let referenceGroup = SCNNode() + /// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`. + private var layerNodes: [String: SCNNode] = [:] private let axisNode = SCNNode() + private let layerGroup = SCNNode() private let cameraNode: SCNNode = { let node = SCNNode() node.camera = SCNCamera() @@ -184,33 +428,97 @@ private struct GamutSceneView: NSViewRepresentable { return node }() - func buildScene(profile: GamutMesh?, reference: GamutMesh?) { - guard let scene else { return } + /// Builds the static scene furniture exactly once — axis + /// scaffold, lights, camera home. Layer content lives under + /// `layerGroup` and is managed by `syncLayers`. + func buildSceneOnce() { + guard let scene, scene.rootNode.childNodes.isEmpty else { return } - // Rebuild from scratch on every mesh change to avoid stale geometry. - scene.rootNode.childNodes.forEach { $0.removeFromParentNode() } scene.rootNode.addChildNode(axisNode) - scene.rootNode.addChildNode(profileNode) - scene.rootNode.addChildNode(referenceGroup) + scene.rootNode.addChildNode(layerGroup) scene.rootNode.addChildNode(cameraNode) buildAxisScaffold() - - if let profile { - profileNode.addChildNode(profileMeshNode(profile, name: "profile")) - } else { - profileNode.childNodes.forEach { $0.removeFromParentNode() } - } - - if let reference { - referenceGroup.childNodes.forEach { $0.removeFromParentNode() } - referenceGroup.addChildNode(referenceMeshNode(reference)) - } - addLights(to: scene) resetCamera() } + /// Reconciles the node set with `layers` and `visibleIDs`. + /// + /// New layers get a node; removed layers lose theirs; hidden + /// layers keep their mesh (`isHidden` only). Never rebuilds the + /// scene, so the camera is untouched by a checkbox toggle. + func syncLayers(_ layers: [NamedGamut], visibleIDs: Set) { + guard scene != nil else { return } + let wanted = Set(layers.map { $0.id }) + for (id, node) in layerNodes where !wanted.contains(id) { + node.removeFromParentNode() + layerNodes.removeValue(forKey: id) + } + for layer in layers { + if layerNodes[layer.id] == nil { + let node = makeLayerNode(for: layer) + layerNodes[layer.id] = node + layerGroup.addChildNode(node) + } + layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id) + } + } + + private func makeLayerNode(for layer: NamedGamut) -> SCNNode { + let node: SCNNode + switch layer.role { + case .reference: + node = referenceMeshNode(layer.mesh) + case .profileA: + node = profileMeshNode(layer.mesh) + case .profileB: + node = compareMeshNode(layer.mesh) + } + node.name = layer.id + return node + } + + // MARK: - Click inspect (#147) + + /// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer` + /// only fires on a press+release in place, so orbit drags are + /// untouched. + func installClickGesture() { + guard let scnView, clickGesture == nil else { return } + let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:))) + scnView.addGestureRecognizer(gesture) + clickGesture = gesture + } + + @objc private func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let scnView else { return } + let point = gesture.location(in: scnView) + for hit in scnView.hitTest(point, options: nil) { + if let layerID = layerID(for: hit.node) { + let world = hit.worldCoordinates + onInspect( + SIMD3(Float(world.x), Float(world.y), Float(world.z)), + layerID) + return + } + } + // Axis scaffold / empty background → back to idle. + onInspect(.zero, nil) + } + + /// Walks the hit node's ancestor chain looking for a layer node. + private func layerID(for node: SCNNode) -> String? { + var current: SCNNode? = node + while let node = current { + if let name = node.name, layerNodes[name] != nil { return name } + current = node.parent + } + return nil + } + + // MARK: - Scene furniture (unchanged from #28) + private func addLights(to scene: SCNScene) { let ambient = SCNNode() ambient.light = SCNLight() @@ -351,7 +659,8 @@ private struct GamutSceneView: NSViewRepresentable { return SCNNode(geometry: geometry) } - private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode { + /// Profile A: solid vertex-coloured surface. + private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode { let (geometry, _) = scnGeometry(for: mesh) let material = SCNMaterial() @@ -361,11 +670,26 @@ private struct GamutSceneView: NSViewRepresentable { material.isDoubleSided = true geometry.materials = [material] - let node = SCNNode(geometry: geometry) - node.name = name - return node + return SCNNode(geometry: geometry) } + /// Compare profile B: same vertex colours at ~30 % opacity so + /// overlaps with A and the sRGB reference stay readable. + private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.white + material.transparency = 0.30 + material.isDoubleSided = true + material.writesToDepthBuffer = false + geometry.materials = [material] + + return SCNNode(geometry: geometry) + } + + /// Bundled sRGB reference: faint fill + structural edge lines. private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode { let (geometry, _) = scnGeometry(for: mesh) @@ -488,3 +812,81 @@ private struct GamutSceneView: NSViewRepresentable { } } } + +/// Click-to-sample image view for the TIFF preview sheet (#147). +/// +/// The TIFF is already decoded to PNG on the host side (#58); the view +/// reports 8-bit sRGB pixel values at the clicked point — the Lab +/// conversion is the documented approximate matrix helper, not a CMM. +private struct TiffSampleImageView: NSViewRepresentable { + let pngData: Data + var onSample: (Int, Int, Int) -> Void + + func makeNSView(context: Context) -> TiffSampleNSView { + let view = TiffSampleNSView() + view.image = NSImage(data: pngData) + view.onSample = onSample + return view + } + + func updateNSView(_ nsView: TiffSampleNSView, context: Context) { + nsView.onSample = onSample + } +} + +private final class TiffSampleNSView: NSView { + var image: NSImage? { + didSet { + bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil) + .flatMap { NSBitmapImageRep(cgImage: $0) } + invalidateIntrinsicContentSize() + needsDisplay = true + } + } + var onSample: ((Int, Int, Int) -> Void)? + private var bitmapRep: NSBitmapImageRep? + + override var intrinsicContentSize: NSSize { + image?.size ?? NSSize(width: 320, height: 240) + } + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill() + dirtyRect.fill() + guard let image else { return } + image.draw(in: imageRect()) + } + + override func mouseUp(with event: NSEvent) { + guard let rep = bitmapRep else { return } + let rect = imageRect() + let location = convert(event.locationInWindow, from: nil) + guard rect.contains(location), rect.width > 0, rect.height > 0 else { return } + + let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide)) + // This view is not flipped: y grows up, bitmap rows grow down. + let y = rep.pixelsHigh - 1 + - Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh)) + guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return } + + guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return } + onSample?( + Int((color.redComponent * 255).rounded()), + Int((color.greenComponent * 255).rounded()), + Int((color.blueComponent * 255).rounded())) + } + + /// Aspect-fit rect of the image inside `bounds`. + private func imageRect() -> NSRect { + guard let image, image.size.width > 0, image.size.height > 0 else { return .zero } + let scale = min(bounds.width / image.size.width, bounds.height / image.size.height) + let size = NSSize(width: image.size.width * scale, height: image.size.height * scale) + return NSRect( + x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2, + width: size.width, + height: size.height) + } +} diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift index 6e99565..6225f70 100644 --- a/Sources/ICCery/GamutViewModel.swift +++ b/Sources/ICCery/GamutViewModel.swift @@ -1,53 +1,303 @@ import Combine import Foundation import ICCeryCore +import simd -/// View model for the native SceneKit gamut viewer. +/// View model for the native SceneKit gamut viewer (issues #28, #147). /// -/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a -/// printer/profile `.gam` from the current working directory. +/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own +/// profile `.gam` when one exists, and an optional compare mesh the user +/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info +/// notice, never fatal (#24). @MainActor final class GamutViewModel: ObservableObject { - /// Parsed reference sRGB gamut mesh. - @Published var sRGBMesh: GamutMesh? + /// Stable layer ids — also the `gamutLayer-` a11y suffixes. + static let srgbLayerID = "sRGB" + static let profileLayerID = "profile" + static let compareLayerID = "compare" - /// Parsed printer/profile gamut mesh. - @Published var profileMesh: GamutMesh? + /// Loaded meshes: bundled sRGB plus up to two profiles. + @Published var layers: [NamedGamut] = [] - /// User-facing status line. + /// Layer ids currently shown in the scene. Toggling never unloads + /// the mesh — the `SCNNode` is hidden only. + @Published var visibleIDs: Set = [srgbLayerID] + + /// User-facing status line (`gamutStatusText`). Always non-empty + /// once set — `Milestone6GamutUITests` asserts it. @Published var status = "Loading gamut…" + /// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner` + /// sits behind the sheet, so notices surface here instead. + @Published var noticeText: String? + + /// Set when `SCNView` cannot create a render context; the scene is + /// replaced by the docs/18 fallback text (`gamutViewerUnavailable`). + @Published var viewerUnavailable = false + /// Closure injected into the SceneKit view to request a camera reset. @Published var resetCamera: () -> Void = {} - private let profileGamURL: URL? + // MARK: - Inspect panel - init(profileGamURL: URL? = nil) { + /// Lab point currently inspected, or `nil` for the idle state. + @Published var inspectLab: LabColor? + + /// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of + /// the inspected Lab. + @Published var inspectSwatch: DisplayRGB? + + /// `true` when the swatch/Lab came from the approximate helper or a + /// typed value — drives the "approx. Lab, not ColorSync" caption. + @Published var inspectIsApproximate = false + + /// Per-layer containment for `inspectLab`, in layer order. + @Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = [] + + /// Manual Lab entry fields (`gamutLabEntry*`). + @Published var labEntryL = "" + @Published var labEntryA = "" + @Published var labEntryB = "" + + // MARK: - TIFF sampling + + /// PNG bytes for the preview sheet (`gamutTiffPreview`). + @Published var tiffPreviewPNG: Data? + @Published var showingTiffPreview = false + + private let environment: AppEnvironment + private let profileGamURL: URL? + private let fileDialogs = FileDialogService.shared + + init(environment: AppEnvironment, profileGamURL: URL? = nil) { + self.environment = environment self.profileGamURL = profileGamURL - Task { await load() } + loadTask = Task { await load() } } + private var loadTask: Task? + + /// Awaits the initial sRGB/profile load — used by tests. + func awaitInitialLoad() async { + await loadTask?.value + } + + func layer(id: String) -> NamedGamut? { + layers.first { $0.id == id } + } + + // MARK: - Initial load + private func load() async { do { - let referenceURL = BinaryResolver().referenceGamut("sRGB") + let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB") let reference = try await parse(url: referenceURL) - sRGBMesh = reference - - if let profileGamURL { - let profile = try await parse(url: profileGamURL) - profileMesh = profile - status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference" - } else { - status = "sRGB reference gamut (\(reference.faces.count) faces)" - } + layers.append(NamedGamut( + id: Self.srgbLayerID, + displayName: "sRGB", + role: .reference, + mesh: reference, + sourceURL: referenceURL)) + visibleIDs.insert(Self.srgbLayerID) } catch { status = "Could not load gamut: \(error.localizedDescription)" + return + } + + if let profileGamURL { + do { + let profile = try await parse(url: profileGamURL) + layers.append(NamedGamut( + id: Self.profileLayerID, + displayName: profileGamURL.deletingPathExtension().lastPathComponent, + role: .profileA, + mesh: profile, + sourceURL: profileGamURL)) + visibleIDs.insert(Self.profileLayerID) + } catch { + // #24 — a missing/unparseable profile mesh is info, not fatal. + noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)" + } + } + refreshStatus() + } + + // MARK: - Compare slot (profile B) + + /// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot. + func openCompareGam() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutFileURL + : fileDialogs.selectGamutFile() + guard let url else { return } + Task { await loadCompareGam(url: url) } + } + + /// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam` + /// when present, otherwise runs bundled `iccgamut -v -d 10` (#24). + func openCompareProfile() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutProfileURL + : fileDialogs.selectProfileFile() + guard let url else { return } + Task { await loadCompareProfile(url: url) } + } + + /// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A. + func removeCompare() { + layers.removeAll { $0.id == Self.compareLayerID } + visibleIDs.remove(Self.compareLayerID) + refreshStatus() + } + + /// Parses `url` into the compare slot. Internal for tests — the UI + /// reaches it through `openCompareGam` / `openCompareProfile`. + func loadCompareGam(url: URL) async { + do { + let mesh = try await parse(url: url) + installCompare(NamedGamut( + id: Self.compareLayerID, + displayName: url.deletingPathExtension().lastPathComponent, + role: .profileB, + mesh: mesh, + sourceURL: url)) + } catch { + noticeText = "Could not load compare gamut: \(error.localizedDescription)" } } - /// Parses a `.gam` file off the main actor so large meshes do not stall - /// the UI. + /// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot. + /// Internal for tests. + func loadCompareProfile(url: URL) async { + let gamURL = url.deletingPathExtension().appendingPathExtension("gam") + do { + if !FileManager.default.fileExists(atPath: gamURL.path) { + _ = try await environment.runner.runIccgamut( + config: IccgamutConfig(profileURL: url)) + } + await loadCompareGam(url: gamURL) + } catch { + noticeText = "Gamut extraction failed: \(error.localizedDescription)" + } + } + + /// A third profile replaces B — the compare slot never stacks and + /// sRGB is never touched. + private func installCompare(_ gamut: NamedGamut) { + if layers.contains(where: { $0.id == gamut.id }) { + noticeText = "Compare slot holds one profile. The previous compare mesh was replaced." + } + layers.removeAll { $0.id == gamut.id } + layers.append(gamut) + visibleIDs.insert(gamut.id) + refreshStatus() + } + + // MARK: - Status line + + /// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`. + /// Unloaded layers are omitted; the volume clause appears only when + /// both volumes are finite and positive. + private func refreshStatus() { + var clauses = layers.map { + "\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces" + } + if let srgb = layer(id: Self.srgbLayerID), + let profile = layer(id: Self.profileLayerID) { + let srgbVolume = GamutGeometry.volume(of: srgb.mesh) + let profileVolume = GamutGeometry.volume(of: profile.mesh) + if srgbVolume.isFinite, srgbVolume > 0, + profileVolume.isFinite, profileVolume > 0 { + clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB") + } + } + status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ") + } + + // MARK: - Inspect + + /// Whether every manual Lab field parses as a number; the Inspect + /// button is disabled while this is false. + var canInspectLab: Bool { + [labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil } + } + + /// Runs containment for a Lab point and publishes the inspect row. + func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) { + inspectLab = lab + inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab) + inspectIsApproximate = isApproximate + inspectResults = layers.map { + ($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh)) + } + } + + /// Click on the axis scaffold or empty background returns the panel + /// to idle. + func clearInspect() { + inspectLab = nil + inspectSwatch = nil + inspectIsApproximate = false + inspectResults = [] + } + + /// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`. + /// The swatch is the nearest vertex colour of the hit layer's mesh. + func inspectSceneHit(world: SIMD3, layerID: String) { + let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z)) + var swatch: DisplayRGB? + var approximate = true + if let mesh = layer(id: layerID)?.mesh, + let nearest = mesh.vertices.min(by: { + simd_distance($0.position, world) < simd_distance($1.position, world) + }) { + swatch = nearest.rgb + approximate = false + } + inspect(lab: lab, swatch: swatch, isApproximate: approximate) + } + + /// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis + /// values still run containment and report `?` outside every hull. + func inspectEnteredLab() { + guard let l = Double(labEntryL), + let a = Double(labEntryA), + let b = Double(labEntryB) else { return } + inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true) + } + + // MARK: - TIFF sampling + + /// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG + /// preview (#58), and open the click-to-sample sheet. + func openTiffSample() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutTiffURL + : fileDialogs.selectTiffFile() + guard let url else { return } + guard let png = TiffPreview.previewPNG(tiff: url) else { + noticeText = "Could not decode TIFF preview." + return + } + tiffPreviewPNG = png + showingTiffPreview = true + } + + /// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50. + func sampleTiffPixel(r: Int, g: Int, b: Int) { + inspect( + lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b), + swatch: DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0), + isApproximate: true) + showingTiffPreview = false + } + + /// Parses a `.gam` file off the main actor so large meshes do not + /// stall the UI. private func parse(url: URL) async throws -> GamutMesh { try await Task.detached { try GamutMeshParser.parse(url: url) diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 5322e1b..7d8c849 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -82,7 +82,10 @@ struct RootView: View { get: { workflow.wizard.showingGamutViewer }, set: { workflow.wizard.showingGamutViewer = $0 } )) { - GamutView(profileGamURL: workflow.wizard.gamutProfileURL) + GamutView( + environment: workflow.environment, + profileGamURL: workflow.wizard.gamutProfileURL, + showingAllHelp: $showingAllHelp) } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 459972b..4b97cda 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -160,6 +160,9 @@ struct SidebarView: View { .frame(maxWidth: .infinity) } .controlSize(.large) + .helpOverlay( + "View the profile gamut in 3D against sRGB.", + showing: $showingAllHelp) .accessibilityIdentifier("btnViewGamut") .padding(.horizontal, 12) diff --git a/Tests/ICCeryCoreTests/ApproximateLabTests.swift b/Tests/ICCeryCoreTests/ApproximateLabTests.swift new file mode 100644 index 0000000..e0e65c3 --- /dev/null +++ b/Tests/ICCeryCoreTests/ApproximateLabTests.swift @@ -0,0 +1,39 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``ApproximateLab`` sanity tests (issue #147). +/// +/// These are loose sanity checks on a fixed-matrix approximation — not +/// ColorSync goldens. +final class ApproximateLabTests: XCTestCase { + + func testWhiteMapsToHighLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255) + XCTAssertGreaterThan(lab.l, 95) + XCTAssertEqual(lab.a, 0, accuracy: 2) + XCTAssertEqual(lab.b, 0, accuracy: 2) + } + + func testBlackMapsToZeroLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0) + XCTAssertEqual(lab.l, 0, accuracy: 1) + } + + func testPureRedIsChromatic() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0) + // sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only. + XCTAssertGreaterThan(lab.l, 40) + XCTAssertLessThan(lab.l, 65) + XCTAssertGreaterThan(lab.a, 60) + XCTAssertGreaterThan(lab.b, 40) + } + + func testMidGreyIsNeutral() { + let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128) + XCTAssertGreaterThan(lab.l, 45) + XCTAssertLessThan(lab.l, 65) + XCTAssertEqual(lab.a, 0, accuracy: 1) + XCTAssertEqual(lab.b, 0, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/GamutContainmentTests.swift b/Tests/ICCeryCoreTests/GamutContainmentTests.swift new file mode 100644 index 0000000..c69cf8b --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutContainmentTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``GamutGeometry`` containment and volume goldens on the bundled +/// `sRGB.gam` reference mesh (issue #147). No GPU involved. +final class GamutContainmentTests: XCTestCase { + + /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. + private var bundledSRGBGamURL: URL { + let bundle = Bundle.main + let resource = bundle.resourceURL ?? bundle.bundleURL + return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + } + + private func loadSRGB() throws -> GamutMesh { + try GamutMeshParser.parse(url: bundledSRGBGamURL) + } + + func testNeutralMidGreyIsInsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + } + + func testSaturatedColourIsOutsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh), + .outside) + } + + func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws { + let mesh = try loadSRGB() + let volume = GamutGeometry.volume(of: mesh) + XCTAssertTrue(volume.isFinite) + XCTAssertGreaterThan(volume, 0) + } + + func testVertexOnlyMeshReportsUnknown() { + let mesh = GamutMesh( + vertices: [ + GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)), + ], + faces: []) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .unknown) + XCTAssertEqual(GamutGeometry.volume(of: mesh), 0) + } + + func testKnownCubeFixture() throws { + // Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60. + // Two triangles per face, outward winding. + let lab = { (l: Double, a: Double, b: Double) in + GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0)) + } + // Corners in (a, L, b) space. + let c = [ + lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom + lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top + ] + let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in + [GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)] + } + var faces: [GamutTriangle] = [] + faces += quad(0, 3, 2, 1) // bottom (y=40) + faces += quad(4, 5, 6, 7) // top (y=60) + faces += quad(0, 1, 5, 4) // z=-10 + faces += quad(3, 7, 6, 2) // z=+10 + faces += quad(1, 2, 6, 5) // x=+10 + faces += quad(0, 4, 7, 3) // x=-10 + let mesh = GamutMesh(vertices: c, faces: faces) + + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh), + .outside) + // 20 × 20 × 20 Lab-cube. + XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/GamutViewModelTests.swift b/Tests/ICCeryCoreTests/GamutViewModelTests.swift new file mode 100644 index 0000000..b284a7e --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutViewModelTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #147 — `GamutViewModel` compare-slot behaviour: failed or +/// missing compare meshes are info, never fatal (#24); sRGB always stays. +@MainActor +final class GamutViewModelTests: XCTestCase { + + /// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool + /// binaries, so `iccgamut` spawns deterministically fail. + private func bundledRootWithoutTools() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)") + let gamutDir = root.appendingPathComponent("reference_gamuts") + try FileManager.default.createDirectory( + at: gamutDir, withIntermediateDirectories: true) + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + try FileManager.default.copyItem( + at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"), + to: gamutDir.appendingPathComponent("sRGB.gam")) + return root + } + + private func makeViewModel(root: URL? = nil) throws -> GamutViewModel { + let env = try TestAppEnvironment.make(bundledArgyllRoot: root) + return GamutViewModel(environment: env.environment) + } + + func testInitialLoadHasSRGBAndFacesStatus() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)") + } + + func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + await vm.loadCompareGam( + url: URL(fileURLWithPath: "/nonexistent/compare.gam")) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws { + // bundledRootWithoutTools has no macos-universal/iccgamut. + let vm = try makeViewModel(root: bundledRootWithoutTools()) + await vm.awaitInitialLoad() + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + + let profile = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc") + try Data("MOCK_ICC".utf8).write(to: profile) + defer { try? FileManager.default.removeItem(at: profile) } + + await vm.loadCompareProfile(url: profile) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testThirdProfileReplacesCompareSlot() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + let first = dir.appendingPathComponent("first.gam") + let second = dir.appendingPathComponent("second.gam") + try FileManager.default.copyItem(at: srgb, to: first) + try FileManager.default.copyItem(at: srgb, to: second) + defer { try? FileManager.default.removeItem(at: dir) } + + await vm.loadCompareGam(url: first) + XCTAssertNil(vm.noticeText) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first") + + await vm.loadCompareGam(url: second) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second") + XCTAssertEqual( + vm.layers.filter { $0.role == .profileB }.count, 1, + "compare slot holds one profile") + XCTAssertNotNil(vm.noticeText) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testRemoveCompareLeavesSRGB() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + await vm.loadCompareGam( + url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")) + XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID)) + + vm.removeCompare() + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testInspectLabRunsContainmentPerLayer() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + vm.labEntryL = "50" + vm.labEntryA = "0" + vm.labEntryB = "0" + XCTAssertTrue(vm.canInspectLab) + vm.inspectEnteredLab() + + let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID } + XCTAssertEqual(srgb?.containment, .inside) + XCTAssertTrue(vm.inspectIsApproximate) + XCTAssertNotNil(vm.inspectSwatch) + } +} diff --git a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift new file mode 100644 index 0000000..da7f2f9 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift @@ -0,0 +1,186 @@ +import Foundation +import XCTest + +/// Milestone 10 — Issue #147 gamut compare chrome tests. +/// +/// Sheet-opened only: no GPU hit-test assertions (no reliable SceneKit +/// click on the runner). Containment itself is covered by +/// `GamutContainmentTests`. +@MainActor +final class Milestone10GamutCompareUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + private var referenceGamutURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m10-gamut-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + referenceGamutURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + let inSheet = app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + if inSheet.exists { return inSheet } + // Menu popup items live outside the window hierarchy. + return app.menuItems[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + private func openGamutSheet() { + launchApp() + waitFor("btnViewGamut").click() + _ = waitFor("gamutView") + } + + func testLayerTogglesExistWithSRGB() throws { + openGamutSheet() + + let srgb = waitFor("gamutLayer-sRGB") + XCTAssertTrue(srgb.exists) + XCTAssertTrue(srgb.isEnabled) + // NSButton checkbox value is 1 when checked. + XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on") + + let compare = waitFor("gamutLayer-compare") + XCTAssertTrue(compare.exists) + XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load") + } + + func testAddCompareButtonExists() throws { + openGamutSheet() + + waitFor("btnGamutAddCompare").click() + // No ICCERY_TEST_GAMUT_FILE set → the stubbed picker cancels; + // the sRGB status must stay non-empty. + waitFor("btnGamutOpenGam").click() + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)") + } + + func testCompareGamLoadEnablesToggle() throws { + let compareURL = workDir.appendingPathComponent("compare.gam") + try FileManager.default.copyItem(at: referenceGamutURL, to: compareURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_FILE"] = compareURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenGam").click() + + let compare = waitFor("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load") + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("compare"), "Status should list the compare layer, got: \(value)") + + let remove = waitFor("btnGamutRemoveCompare") + XCTAssertTrue(remove.isEnabled) + } + + func testOpenProfileRunsIccgamutForCompare() throws { + // A profile with no sibling .gam → the mock iccgamut writes one. + let profileURL = workDir.appendingPathComponent("myprinter.icc") + try Data("MOCK_ICC".utf8).write(to: profileURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_PROFILE"] = profileURL.path + app.launchEnvironment["ICCERY_MOCK_GAMUT_SOURCE"] = referenceGamutURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenProfile").click() + + let compare = waitFor("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") + } + + func testInspectPanelIdleStableHeight() throws { + openGamutSheet() + + let panel = waitFor("gamutInspectPanel") + XCTAssertTrue(panel.exists) + XCTAssertTrue(element("gamutInspectIdle").exists) + XCTAssertTrue(element("gamutStatusText").exists) + } + + func testManualLabInspectShowsContainment() throws { + openGamutSheet() + + waitFor("gamutLabEntryL").click() + element("gamutLabEntryL").typeText("50") + element("gamutLabEntryA").click() + element("gamutLabEntryA").typeText("0") + element("gamutLabEntryB").click() + element("gamutLabEntryB").typeText("0") + + waitFor("btnGamutInspectLab").click() + + let inside = waitFor("gamutInspect-sRGB") + let value = inside.value as? String ?? inside.label + XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)") + XCTAssertTrue(element("gamutInspectL").exists) + XCTAssertTrue(element("gamutInspectSwatch").exists) + } + + func testResetIdentifierUnchanged() throws { + openGamutSheet() + let reset = waitFor("btnResetGamutCamera") + XCTAssertTrue(reset.isEnabled) + } +} diff --git a/docs/18-gamut-viewer.md b/docs/18-gamut-viewer.md index ee2a15c..131546b 100644 --- a/docs/18-gamut-viewer.md +++ b/docs/18-gamut-viewer.md @@ -827,3 +827,35 @@ HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`, | #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. | +| #147 compare + inspect | macOS native: layer toggles (`gamutLayer-*`), compare slot (one extra profile or `.gam`), click/typed-Lab containment, TIFF pixel sample. | + +--- + +## 13. macOS rewrite notes (#28, #147) + +The native viewer (`Sources/ICCery/GamutView.swift` + `GamutViewModel`) keeps +the v1 contract points that matter — `(a*, L*, b*)` axes, camera home +(180,120,180) lookAt (0,50,0), R resets via a local `NSEvent` monitor, native +faces only — and adds the compare/inspect layer model: + +- **Layers.** `NamedGamut` (reference / profileA / profileB). sRGB cannot be + removed, only hidden. The compare slot holds exactly one profile; picking a + third replaces it and posts `gamutNoticeText`. +- **Toggles hide, not unload.** `visibleIDs` maps to `SCNNode.isHidden`; the + scene is built once and a checkbox never resets the camera. +- **Containment.** `GamutGeometry.containment` ray-casts the face table + (`GamutVertex.position` space) with off-axis retries on edge hits; no faces + → `.unknown`. `GamutGeometry.volume` sums signed tetrahedra from the vertex + centroid (Lab-cubic); the `vol % of sRGB` clause only prints when both + volumes are finite and > 0. +- **Inspect.** Click a mesh (hit-test in the `SCNView` coordinator on + mouse-up, never a ZStack tap gesture), type Lab, or sample a TIFF pixel. + Per-layer in/out/? + swatch; `gamutInspectApprox` marks samples that went + through `ApproximateLab` (fixed-matrix sRGB→Lab D50, **not** ColorSync, no + CMM). +- **Fallback.** No GPU → `gamutViewerUnavailable` text replaces the scene; + layer toggles stay visible but disabled; the representable is never + respawned in a loop. +- **iccgamut** still runs only as the bundled sidecar, `-v -d {density}` + (density 10 = surface density, not a directory). Failure is an in-sheet + info notice — sRGB + profile A stay loaded (#24). diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index f4675f9..ae9ced1 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -75,11 +75,12 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel | | Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` | | Spot read | `spotReadView` | `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `spotSidecarMissing`, `btnCloseSpotRead` | +| Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable` | ## Dialogs must go through host APIs Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (298) +## Complete `id=` roster (330) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`. -- 2.39.5