feat(gamut): second-profile compare and click-inspect (#147) #153
@@ -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 } }
|
||||
}
|
||||
}
|
||||
@@ -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<Double>(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<Double>.zero
|
||||
for vertex in mesh.vertices {
|
||||
centroid += SIMD3<Double>(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<Double>(mesh.vertices[Int(face.a)].position) - centroid
|
||||
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
|
||||
let c = SIMD3<Double>(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<Double>] = [
|
||||
SIMD3(1, 0, 0),
|
||||
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
|
||||
simd_normalize(SIMD3<Double>(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<Double>,
|
||||
direction dir: SIMD3<Double>,
|
||||
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<Double>(mesh.vertices[Int(face.a)].position)
|
||||
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
|
||||
let vc = SIMD3<Double>(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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+474
-72
@@ -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<Bool>
|
||||
) {
|
||||
_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<String>
|
||||
var onReset: Binding<() -> Void>
|
||||
var onPause: Binding<() -> Void>
|
||||
var onUnavailable: () -> Void
|
||||
var onInspect: (SIMD3<Float>, 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<Float>, 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<String>) {
|
||||
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>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-<id>` 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<String> = [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<Void, Never>?
|
||||
|
||||
/// 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<Float>, 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user