Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398d9b4756 | ||
|
|
538ef1e05f |
@@ -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") }
|
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||||
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
|
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
|
||||||
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
|
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)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ final class FileDialogService {
|
|||||||
message: "Choose a calibration file (.cal)")
|
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.
|
/// `btnImportPreset` — open a `.json` preset file.
|
||||||
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
open(extensions: ["json"], startingAt: start,
|
open(extensions: ["json"], startingAt: start,
|
||||||
@@ -102,14 +115,15 @@ final class FileDialogService {
|
|||||||
private func open(
|
private func open(
|
||||||
extensions: [String],
|
extensions: [String],
|
||||||
startingAt start: URL?,
|
startingAt start: URL?,
|
||||||
message: String?
|
message: String?,
|
||||||
|
allowsOtherFileTypes: Bool = true
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.allowedContentTypes = utTypes(extensions)
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
panel.allowsOtherFileTypes = true
|
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
||||||
panel.directoryURL = start
|
panel.directoryURL = start
|
||||||
if let message { panel.message = message }
|
if let message { panel.message = message }
|
||||||
return run(panel)
|
return run(panel)
|
||||||
|
|||||||
+474
-72
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SceneKit
|
import SceneKit
|
||||||
|
import Metal
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import simd
|
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
|
/// Displays the bundled `sRGB.gam` reference plus up to two profile
|
||||||
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
|
/// meshes with independent visibility toggles, a status line, and an
|
||||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
|
||||||
/// (blue-yellow) is depth.
|
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
|
||||||
|
/// `z = b*`.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@StateObject private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(
|
||||||
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
environment: AppEnvironment,
|
||||||
|
profileGamURL: URL? = nil,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
_viewModel = StateObject(wrappedValue: GamutViewModel(
|
||||||
|
environment: environment, profileGamURL: profileGamURL))
|
||||||
|
_showingAllHelp = showingAllHelp
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
VStack(spacing: 0) {
|
||||||
GamutSceneView(
|
toolbar
|
||||||
profileMesh: viewModel.profileMesh,
|
Divider().overlay(Theme.border)
|
||||||
referenceMesh: viewModel.sRGBMesh,
|
sceneArea
|
||||||
onReset: $viewModel.resetCamera,
|
Divider().overlay(Theme.border)
|
||||||
onPause: $pause
|
statusLine
|
||||||
)
|
inspectPanel
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, minHeight: 400)
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
|
.background(Theme.background)
|
||||||
.onDisappear { pause() }
|
.onDisappear { pause() }
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("gamutView")
|
.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
|
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
|
||||||
/// one or two ``GamutMesh`` values.
|
/// per ``NamedGamut`` layer.
|
||||||
///
|
///
|
||||||
/// Scene construction and camera reset are coordinated through a typed callback
|
/// Layer toggles hide/show `SCNNode`s — the scene is built once and the
|
||||||
/// binding owned by the view model.
|
/// camera is only reset through the explicit reset path, never on a
|
||||||
|
/// mesh or visibility update.
|
||||||
private struct GamutSceneView: NSViewRepresentable {
|
private struct GamutSceneView: NSViewRepresentable {
|
||||||
var profileMesh: GamutMesh?
|
var layers: [NamedGamut]
|
||||||
var referenceMesh: GamutMesh?
|
var visibleIDs: Set<String>
|
||||||
var onReset: Binding<() -> Void>
|
var onReset: Binding<() -> Void>
|
||||||
var onPause: Binding<() -> Void>
|
var onPause: Binding<() -> Void>
|
||||||
|
var onUnavailable: () -> Void
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> SCNView {
|
func makeNSView(context: Context) -> SCNView {
|
||||||
let scnView = SCNView()
|
let scnView = SCNView()
|
||||||
@@ -142,14 +371,23 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
context.coordinator.scnView = scnView
|
context.coordinator.scnView = scnView
|
||||||
context.coordinator.scene = scene
|
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.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
|
return scnView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: SCNView, context: Context) {
|
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 {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -165,6 +403,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
coordinator.removeKeyMonitor()
|
coordinator.removeKeyMonitor()
|
||||||
|
if let click = coordinator.clickGesture {
|
||||||
|
nsView.removeGestureRecognizer(click)
|
||||||
|
}
|
||||||
nsView.isPlaying = false
|
nsView.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +413,14 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
weak var scene: SCNScene?
|
weak var scene: SCNScene?
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
|
||||||
|
private(set) var clickGesture: NSClickGestureRecognizer?
|
||||||
private var keyMonitor: Any?
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
private let profileNode = SCNNode()
|
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
|
||||||
private let referenceGroup = SCNNode()
|
private var layerNodes: [String: SCNNode] = [:]
|
||||||
private let axisNode = SCNNode()
|
private let axisNode = SCNNode()
|
||||||
|
private let layerGroup = SCNNode()
|
||||||
private let cameraNode: SCNNode = {
|
private let cameraNode: SCNNode = {
|
||||||
let node = SCNNode()
|
let node = SCNNode()
|
||||||
node.camera = SCNCamera()
|
node.camera = SCNCamera()
|
||||||
@@ -184,33 +428,97 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return node
|
return node
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
|
/// Builds the static scene furniture exactly once — axis
|
||||||
guard let scene else { return }
|
/// 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(axisNode)
|
||||||
scene.rootNode.addChildNode(profileNode)
|
scene.rootNode.addChildNode(layerGroup)
|
||||||
scene.rootNode.addChildNode(referenceGroup)
|
|
||||||
scene.rootNode.addChildNode(cameraNode)
|
scene.rootNode.addChildNode(cameraNode)
|
||||||
|
|
||||||
buildAxisScaffold()
|
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)
|
addLights(to: scene)
|
||||||
resetCamera()
|
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) {
|
private func addLights(to scene: SCNScene) {
|
||||||
let ambient = SCNNode()
|
let ambient = SCNNode()
|
||||||
ambient.light = SCNLight()
|
ambient.light = SCNLight()
|
||||||
@@ -351,7 +659,8 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return SCNNode(geometry: geometry)
|
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 (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
let material = SCNMaterial()
|
let material = SCNMaterial()
|
||||||
@@ -361,11 +670,26 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
material.isDoubleSided = true
|
material.isDoubleSided = true
|
||||||
geometry.materials = [material]
|
geometry.materials = [material]
|
||||||
|
|
||||||
let node = SCNNode(geometry: geometry)
|
return SCNNode(geometry: geometry)
|
||||||
node.name = name
|
|
||||||
return node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
let (geometry, _) = scnGeometry(for: mesh)
|
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 Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
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
|
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||||
/// printer/profile `.gam` from the current working directory.
|
/// 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
|
@MainActor
|
||||||
final class GamutViewModel: ObservableObject {
|
final class GamutViewModel: ObservableObject {
|
||||||
|
|
||||||
/// Parsed reference sRGB gamut mesh.
|
/// Stable layer ids — also the `gamutLayer-<id>` a11y suffixes.
|
||||||
@Published var sRGBMesh: GamutMesh?
|
static let srgbLayerID = "sRGB"
|
||||||
|
static let profileLayerID = "profile"
|
||||||
|
static let compareLayerID = "compare"
|
||||||
|
|
||||||
/// Parsed printer/profile gamut mesh.
|
/// Loaded meshes: bundled sRGB plus up to two profiles.
|
||||||
@Published var profileMesh: GamutMesh?
|
@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…"
|
@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.
|
/// Closure injected into the SceneKit view to request a camera reset.
|
||||||
@Published var resetCamera: () -> Void = {}
|
@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
|
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 {
|
private func load() async {
|
||||||
do {
|
do {
|
||||||
let referenceURL = BinaryResolver().referenceGamut("sRGB")
|
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
|
||||||
let reference = try await parse(url: referenceURL)
|
let reference = try await parse(url: referenceURL)
|
||||||
sRGBMesh = reference
|
layers.append(NamedGamut(
|
||||||
|
id: Self.srgbLayerID,
|
||||||
if let profileGamURL {
|
displayName: "sRGB",
|
||||||
let profile = try await parse(url: profileGamURL)
|
role: .reference,
|
||||||
profileMesh = profile
|
mesh: reference,
|
||||||
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
|
sourceURL: referenceURL))
|
||||||
} else {
|
visibleIDs.insert(Self.srgbLayerID)
|
||||||
status = "sRGB reference gamut (\(reference.faces.count) faces)"
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
status = "Could not load gamut: \(error.localizedDescription)"
|
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
|
/// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot.
|
||||||
/// the UI.
|
/// 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 {
|
private func parse(url: URL) async throws -> GamutMesh {
|
||||||
try await Task.detached {
|
try await Task.detached {
|
||||||
try GamutMeshParser.parse(url: url)
|
try GamutMeshParser.parse(url: url)
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ struct RootView: View {
|
|||||||
get: { workflow.wizard.showingGamutViewer },
|
get: { workflow.wizard.showingGamutViewer },
|
||||||
set: { workflow.wizard.showingGamutViewer = $0 }
|
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)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
.helpOverlay(
|
||||||
|
"View the profile gamut in 3D against sRGB.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
.accessibilityIdentifier("btnViewGamut")
|
.accessibilityIdentifier("btnViewGamut")
|
||||||
.padding(.horizontal, 12)
|
.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). |
|
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
|
||||||
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
|
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
|
||||||
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
|
| #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