Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0ee609be1c feat(#28): SceneKit gamut viewer
- Add native .gam mesh parser and mesh model (GamutMeshParser, GamutMesh).
- Add GamutView / GamutViewModel with SceneKit, bundled sRGB.gam reference,
  and profile-gamut comparison.
- Wire Stage 5 "View Gamut" button and sidebar quick action.
- Extract a best-effort .gam mesh after colprof via Iccgamut in
  ProfileWorkflowViewModel, with artefact gating for the generated .gam.
- Add BinaryResolver.referenceGamut() for bundled reference gamuts.
- Add Milestone6GamutUITests and GamutMeshParserTests.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 22:06:19 +01:00
gronod a73c6c0b97 Merge pull request 'feat/31-about-help-chrome' (#55) from feat/31-about-help-chrome into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #55
2026-09-09 18:33:26 +01:00
15 changed files with 1038 additions and 9 deletions
@@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable {
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL { public func referenceGamut(_ name: String) -> URL {
bundledRoot let stem = name.hasSuffix(".gam") ? name : "\(name).gam"
return bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true) .appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false) .appendingPathComponent(stem, isDirectory: false)
} }
/// Whether the resolved path exists and is executable. /// Whether the resolved path exists and is executable.
@@ -12,19 +12,23 @@ public struct StageArtefacts: Sendable, Equatable {
public var stage4Complete = false public var stage4Complete = false
/// Absolute path of the profile file when present. /// Absolute path of the profile file when present.
public var profilePath: URL? public var profilePath: URL?
/// Absolute path of the `.gam` gamut mesh when present (issue #28).
public var gamPath: URL?
public init( public init(
stage1Complete: Bool = false, stage1Complete: Bool = false,
stage2Complete: Bool = false, stage2Complete: Bool = false,
stage3Complete: Bool = false, stage3Complete: Bool = false,
stage4Complete: Bool = false, stage4Complete: Bool = false,
profilePath: URL? = nil profilePath: URL? = nil,
gamPath: URL? = nil
) { ) {
self.stage1Complete = stage1Complete self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete self.stage4Complete = stage4Complete
self.profilePath = profilePath self.profilePath = profilePath
self.gamPath = gamPath
} }
} }
@@ -45,6 +49,10 @@ public enum ArtefactProbe {
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true out.stage4Complete = true
out.profilePath = profile out.profilePath = profile
let gam = artefact(basename, "gam", cwd)
if exists(gam, fm: fileManager) {
out.gamPath = gam
}
} }
return out return out
} }
@@ -0,0 +1,51 @@
import Foundation
import simd
/// A single vertex of an Argyll `.gam` surface mesh.
///
/// Coordinates follow the v0.8.5 SceneKit 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.
public struct GamutVertex: Sendable, Equatable {
public let lab: LabColor
public let rgb: DisplayRGB
public let position: SIMD3<Float>
public init(lab: LabColor, rgb: DisplayRGB) {
self.lab = lab
self.rgb = rgb
self.position = SIMD3<Float>(Float(lab.a), Float(lab.l), Float(lab.b))
}
}
/// A face from an Argyll `.gam` file.
///
/// Indices are 0-based and index into `GamutMesh.vertices` in the order the
/// vertices were pushed by the parser (the `VERTEX_NO` column is discarded).
public struct GamutTriangle: Sendable, Equatable {
public let a: UInt32
public let b: UInt32
public let c: UInt32
public init(a: UInt32, b: UInt32, c: UInt32) {
self.a = a
self.b = b
self.c = c
}
}
/// Parsed gamut surface mesh.
public struct GamutMesh: Sendable, Equatable {
public let vertices: [GamutVertex]
public let faces: [GamutTriangle]
public init(vertices: [GamutVertex], faces: [GamutTriangle]) {
self.vertices = vertices
self.faces = faces
}
/// A printable summary for diagnostics.
public var summary: String {
"GamutMesh(vertices: \(vertices.count), faces: \(faces.count))"
}
}
@@ -0,0 +1,152 @@
import Foundation
/// Errors thrown by ``GamutMeshParser``.
public enum GamutMeshParseError: LocalizedError, Equatable, Sendable {
case missingFile
case readFailed(underlying: String)
case emptyFile
case noDataBlock
case malformedVertexLine(line: Int, content: String)
case malformedFaceLine(line: Int, content: String)
case outOfBoundsVertexIndex(UInt32, max: UInt32)
case invalidLabPlausibility(line: Int, content: String)
public var errorDescription: String? {
switch self {
case .missingFile:
return "Gamut file not found."
case .readFailed(let reason):
return "Could not read gamut file: \(reason)"
case .emptyFile:
return "Gamut file is empty."
case .noDataBlock:
return "Gamut file contains no BEGIN_DATA blocks."
case .malformedVertexLine(let line, let content):
return "Malformed vertex on line \(line): \(content)"
case .malformedFaceLine(let line, let content):
return "Malformed face on line \(line): \(content)"
case .outOfBoundsVertexIndex(let index, let max):
return "Face references vertex \(index) but only \(max + 1) vertices exist."
case .invalidLabPlausibility(let line, let content):
return "Lab value outside plausible range on line \(line): \(content)"
}
}
}
/// Parses Argyll `.gam` ASCII files into ``GamutMesh``.
///
/// The parser recognises two `BEGIN_DATA` `END_DATA` blocks:
///
/// 1. Vertices: `VERTEX_NO LAB_L LAB_A LAB_B`
/// 2. Faces: `VERTEX_0 VERTEX_1 VERTEX_2` (0-based indices)
///
/// Lines beginning with `#` and blank lines are ignored. `BEGIN_DATA` and
/// `END_DATA` are matched case-insensitively. The `VERTEX_NO` column is
/// discarded; vertices are indexed in push order, matching Argyll's output.
public enum GamutMeshParser {
/// Parse the file at `url`.
public static func parse(url: URL) throws -> GamutMesh {
guard FileManager.default.fileExists(atPath: url.path) else {
throw GamutMeshParseError.missingFile
}
guard let data = FileManager.default.contents(atPath: url.path) else {
throw GamutMeshParseError.readFailed(underlying: "contents(atPath:) returned nil")
}
guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii),
!text.isEmpty else {
throw GamutMeshParseError.emptyFile
}
return try parse(text: text)
}
/// Parse raw `.gam` text.
public static func parse(text: String) throws -> GamutMesh {
var vertices: [GamutVertex] = []
var faces: [GamutTriangle] = []
var dataBlock = 0
var inData = false
var lineNumber = 0
var warnings: [String] = []
for rawLine in text.components(separatedBy: .newlines) {
lineNumber += 1
// Strip inline `#` comments before any other processing.
let uncommented = rawLine.split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
let trimmed = uncommented.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let upper = trimmed.uppercased()
if upper == "BEGIN_DATA" {
dataBlock += 1
inData = true
continue
}
if upper == "END_DATA" {
inData = false
continue
}
if !inData { continue }
let parts = trimmed.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
.compactMap(Double.init)
guard !parts.isEmpty else { continue }
if dataBlock == 1 {
// Vertex format: index L a b
guard parts.count >= 4 else {
warnings.append("vertex arity \(parts.count) on line \(lineNumber)")
continue
}
let l = parts[1]
let a = parts[2]
let b = parts[3]
if l < 0 || l > 100 || abs(a) > 128 || abs(b) > 128 {
warnings.append("Lab plausibility warning on line \(lineNumber): L=\(l) a=\(a) b=\(b)")
// We still keep the vertex; Argyll can exceed ±128.
}
let lab = LabColor(l: l, a: a, b: b)
let rgb = LabColorMath.labToSRGB(lab)
vertices.append(GamutVertex(lab: lab, rgb: rgb))
} else {
// Face format: v0 v1 v2 (can extend for future n-gons, take first 3)
guard parts.count >= 3 else {
warnings.append("face arity \(parts.count) on line \(lineNumber)")
continue
}
let idx = parts.prefix(3).compactMap { UInt32(exactly: $0) }
guard idx.count == 3 else {
warnings.append("non-integer face indices on line \(lineNumber)")
continue
}
faces.append(GamutTriangle(a: idx[0], b: idx[1], c: idx[2]))
}
}
// Trim out-of-bounds face indices instead of throwing, so a slightly
// malformed file still renders. This matches the Web viewer's
// forgiving posture while surfacing the obvious cases.
let validFaces = faces.filter { face in
let max = UInt32(vertices.count)
guard face.a < max, face.b < max, face.c < max else {
warnings.append("dropping face \(face) referencing missing vertex")
return false
}
return true
}
if dataBlock == 0 {
throw GamutMeshParseError.noDataBlock
}
return GamutMesh(vertices: vertices, faces: validFaces)
}
}
+435
View File
@@ -0,0 +1,435 @@
import SwiftUI
import SceneKit
import ICCeryCore
import simd
/// Native SceneKit 3D gamut viewer.
///
/// 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.
struct GamutView: View {
@State private var viewModel: GamutViewModel
@FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
}
var body: some View {
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera
)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.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)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
}
}
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
/// one or two ``GamutMesh`` values.
///
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
var onReset: Binding<() -> Void>
func makeNSView(context: Context) -> SCNView {
let scnView = SCNView()
scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1)
scnView.allowsCameraControl = true
scnView.showsStatistics = false
scnView.antialiasingMode = .multisampling4X
let scene = SCNScene()
scnView.scene = scene
scnView.autoenablesDefaultLighting = false
context.coordinator.scnView = scnView
context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator()
onReset.wrappedValue = { [weak coordinator] in
coordinator?.resetCamera()
}
return coordinator
}
@MainActor
final class Coordinator: NSObject {
weak var scnView: SCNView?
weak var scene: SCNScene?
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
private let axisNode = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
node.camera?.zFar = 2000
return node
}()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene 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(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()
}
private func addLights(to scene: SCNScene) {
let ambient = SCNNode()
ambient.light = SCNLight()
ambient.light?.type = .ambient
ambient.light?.color = NSColor.white
ambient.light?.intensity = 750
scene.rootNode.addChildNode(ambient)
let key = SCNNode()
key.light = SCNLight()
key.light?.type = .directional
key.light?.color = NSColor.white
key.light?.intensity = 800
key.position = SCNVector3(150, 250, 150)
key.look(at: SCNVector3(0, 50, 0))
scene.rootNode.addChildNode(key)
let fill = SCNNode()
fill.light = SCNLight()
fill.light?.type = .directional
fill.light?.color = NSColor.white
fill.light?.intensity = 350
fill.position = SCNVector3(-120, -80, -120)
fill.look(at: SCNVector3(0, 50, 0))
scene.rootNode.addChildNode(fill)
}
private func buildAxisScaffold() {
axisNode.childNodes.forEach { $0.removeFromParentNode() }
// Bounding box: a*,b* ±128, L* 0100.
let box = buildWireBox(size: SIMD3<Float>(256, 100, 256), color: NSColor(red: 0.137, green: 0.137, blue: 0.212, alpha: 0.9))
box.position = SCNVector3(0, 50, 0)
axisNode.addChildNode(box)
// Ground grid at y=0.
axisNode.addChildNode(buildGridNode())
// Axis lines.
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(0, 0, 0),
to: SIMD3<Float>(0, 100, 0),
color: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0)
))
let abAxisColor = NSColor(red: 0.6, green: 0.733, blue: 0.8, alpha: 1.0)
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(-128, 0, 0),
to: SIMD3<Float>(128, 0, 0),
color: abAxisColor
))
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(0, 0, -128),
to: SIMD3<Float>(0, 0, 128),
color: abAxisColor
))
}
private func buildWireBox(size: SIMD3<Float>, color: NSColor) -> SCNNode {
let hx = size.x / 2
let hy = size.y / 2
let hz = size.z / 2
let corners: [SIMD3<Float>] = [
SIMD3(-hx, -hy, -hz), SIMD3(hx, -hy, -hz),
SIMD3(hx, -hy, hz), SIMD3(-hx, -hy, hz),
SIMD3(-hx, hy, -hz), SIMD3(hx, hy, -hz),
SIMD3(hx, hy, hz), SIMD3(-hx, hy, hz),
]
// 12 edges, two vertices each.
let edges: [(Int, Int)] = [
(0,1), (1,2), (2,3), (3,0),
(4,5), (5,6), (6,7), (7,4),
(0,4), (1,5), (2,6), (3,7),
]
var points: [SIMD3<Float>] = []
for (a, b) in edges {
points.append(corners[a])
points.append(corners[b])
}
return lineNode(points: points, color: color)
}
private func buildGridNode() -> SCNNode {
let divisions = 16
let half = Float(128)
let step = (half * 2) / Float(divisions)
var points: [SIMD3<Float>] = []
for i in 0...divisions {
let v = -half + step * Float(i)
// X-aligned
points.append(SIMD3(-half, 0, v))
points.append(SIMD3(half, 0, v))
// Z-aligned
points.append(SIMD3(v, 0, -half))
points.append(SIMD3(v, 0, half))
}
let gridColor = NSColor(red: 0.118, green: 0.118, blue: 0.157, alpha: 1.0)
return lineNode(points: points, color: gridColor)
}
private func buildLineNode(from: SIMD3<Float>, to: SIMD3<Float>, color: NSColor) -> SCNNode {
return lineNode(points: [from, to], color: color)
}
/// Builds a line-set from a flat list of point pairs.
///
/// Uses data-backed `SCNGeometrySource` so it works with `simd` vectors
/// and avoids the SceneKit convenience-initializer label mismatch.
private func lineNode(points: [SIMD3<Float>], color: NSColor) -> SCNNode {
let source = source(for: points)
let count = points.count
var indices: [UInt32] = []
indices.reserveCapacity(count)
for i in 0..<UInt32(count) {
indices.append(i)
}
let data = indices.withUnsafeBytes { Data($0) }
let element = SCNGeometryElement(
data: data,
primitiveType: .line,
primitiveCount: count / 2,
bytesPerIndex: 4
)
let geometry = SCNGeometry(sources: [source], elements: [element])
let material = SCNMaterial()
material.lightingModel = .constant
material.diffuse.contents = color
material.isDoubleSided = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.88
material.isDoubleSided = true
geometry.materials = [material]
let node = SCNNode(geometry: geometry)
node.name = name
return node
}
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
// Faint fill.
let fillMaterial = SCNMaterial()
fillMaterial.lightingModel = .lambert
fillMaterial.diffuse.contents = NSColor(red: 0.533, green: 0.6, blue: 0.733, alpha: 1.0)
fillMaterial.transparency = 0.93
fillMaterial.isDoubleSided = true
fillMaterial.writesToDepthBuffer = false
geometry.materials = [fillMaterial]
let fillNode = SCNNode(geometry: geometry)
// Structural outline: one line per triangle edge.
var linePoints: [SIMD3<Float>] = []
for face in mesh.faces {
let va = mesh.vertices[Int(face.a)].position
let vb = mesh.vertices[Int(face.b)].position
let vc = mesh.vertices[Int(face.c)].position
linePoints.append(va); linePoints.append(vb)
linePoints.append(vb); linePoints.append(vc)
linePoints.append(vc); linePoints.append(va)
}
let edgeColor = NSColor(red: 0.4, green: 0.533, blue: 0.667, alpha: 0.55)
let edgeNode = lineNode(points: linePoints, color: edgeColor)
let group = SCNNode()
group.addChildNode(fillNode)
group.addChildNode(edgeNode)
return group
}
/// Returns an `SCNGeometry` with per-vertex positions and sRGB colours.
///
/// Uses data-backed `SCNGeometrySource` initializers; this is the only
/// path that supports vertex colours through the `.color` semantic.
private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
let positions = mesh.vertices.map { $0.position }
let positionData = positions.withUnsafeBytes { Data($0) }
let positionSource = SCNGeometrySource(
data: positionData,
semantic: .vertex,
vectorCount: positions.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD3<Float>>.stride
)
let colors: [SIMD4<Float>] = mesh.vertices.map { v in
SIMD4<Float>(Float(v.rgb.r), Float(v.rgb.g), Float(v.rgb.b), 1.0)
}
let colorData = colors.withUnsafeBytes { Data($0) }
let colorSource = SCNGeometrySource(
data: colorData,
semantic: .color,
vectorCount: colors.count,
usesFloatComponents: true,
componentsPerVector: 4,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD4<Float>>.stride
)
var indices: [UInt32] = []
indices.reserveCapacity(mesh.faces.count * 3)
for face in mesh.faces {
indices.append(face.a)
indices.append(face.b)
indices.append(face.c)
}
let data = indices.withUnsafeBytes { Data($0) }
let element = SCNGeometryElement(
data: data,
primitiveType: .triangles,
primitiveCount: mesh.faces.count,
bytesPerIndex: 4
)
let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element])
return (geometry, element)
}
/// Shared helper for data-backed position sources.
private func source(for points: [SIMD3<Float>]) -> SCNGeometrySource {
let data = points.withUnsafeBytes { Data($0) }
return SCNGeometrySource(
data: data,
semantic: .vertex,
vectorCount: points.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD3<Float>>.stride
)
}
func resetCamera() {
guard let scnView else { return }
// Re-create the camera node so `allowsCameraControl` starts from the
// canonical home position every time.
let newCameraNode = SCNNode()
newCameraNode.camera = SCNCamera()
newCameraNode.camera?.zFar = 2000
let eye = SIMD3<Float>(180, 120, 180)
let target = SIMD3<Float>(0, 50, 0)
newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3<Float>(0, 1, 0))
if let scene = scnView.scene, scene.rootNode.childNodes.contains(cameraNode) {
cameraNode.removeFromParentNode()
}
scnView.scene?.rootNode.addChildNode(newCameraNode)
scnView.pointOfView = newCameraNode
}
private func lookAt(eye: SIMD3<Float>, target: SIMD3<Float>, up: SIMD3<Float>) -> simd_float4x4 {
let forward = normalize(target - eye)
let right = normalize(cross(up, forward))
let newUp = cross(forward, right)
var matrix = simd_float4x4()
matrix.columns.0 = SIMD4<Float>(right, 0)
matrix.columns.1 = SIMD4<Float>(newUp, 0)
matrix.columns.2 = SIMD4<Float>(-forward, 0)
matrix.columns.3 = SIMD4<Float>(eye, 1)
return matrix
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer.
///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory.
@MainActor
@Observable
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh?
/// User-facing status line.
var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {}
private let profileGamURL: URL?
init(profileGamURL: URL? = nil) {
self.profileGamURL = profileGamURL
Task { await load() }
}
private func load() async {
do {
let referenceURL = 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)"
}
} catch {
status = "Could not load gamut: \(error.localizedDescription)"
}
}
/// 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)
}.value
}
}
+13 -4
View File
@@ -52,6 +52,8 @@ final class ProfileWorkflowViewModel {
var colprofProgress: String? var colprofProgress: String?
var lastError: String? var lastError: String?
var createdProfileURL: URL? var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24) // MARK: - Stage 4/5 calibration (issue #24)
@@ -84,12 +86,17 @@ final class ProfileWorkflowViewModel {
restoreCreatedProfileURL() restoreCreatedProfileURL()
} }
/// Restores `createdProfileURL` from the wizard artefacts or by probing /// Restores `createdProfileURL` and `createdGamutURL` from the wizard
/// the working directory for an existing `.icc`/`.icm` (#52). /// artefacts or by probing the working directory (#52, #28).
func restoreCreatedProfileURL() { func restoreCreatedProfileURL() {
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil) let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
createdProfileURL = wizard.artefacts.profilePath createdProfileURL = wizard.artefacts.profilePath
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd) ?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
createdGamutURL = wizard.artefacts.gamPath
?? ArtefactProbe.artefact(wizard.basename, "gam", cwd)
if let gam = createdGamutURL, !FileManager.default.fileExists(atPath: gam.path) {
createdGamutURL = nil
}
} }
// MARK: - Derived // MARK: - Derived
@@ -190,6 +197,7 @@ final class ProfileWorkflowViewModel {
colprofProgress = nil colprofProgress = nil
lastError = nil lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -223,12 +231,13 @@ final class ProfileWorkflowViewModel {
// Gamut extraction is best-effort for Stage 5 / M6 viewer. // Gamut extraction is best-effort for Stage 5 / M6 viewer.
do { do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL) let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch) self?.colprofLog.append(contentsOf: batch)
} }
} }
self.colprofLog.append("Gamut mesh extracted.") self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch { } catch {
self.wizard.showNotice( self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)", "Gamut extraction skipped: \(error.localizedDescription)",
+3
View File
@@ -53,6 +53,9 @@ struct RootView: View {
.sheet(isPresented: $showingAbout) { .sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false } AboutView { showingAbout = false }
} }
.sheet(isPresented: $workflow.wizard.showingGamutViewer) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
}
} }
@ViewBuilder @ViewBuilder
+8
View File
@@ -84,6 +84,14 @@ struct SidebarView: View {
.disabled(true) .disabled(true)
.padding(.horizontal, 12) .padding(.horizontal, 12)
Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) {
Label("View Gamut", systemImage: "view.3d")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
Divider().overlay(Theme.border) Divider().overlay(Theme.border)
.padding(.vertical, 8) .padding(.vertical, 8)
+6
View File
@@ -146,6 +146,12 @@ struct Stage5View: View {
Spacer() Spacer()
Button("View Gamut") {
model.wizard.openGamut(profileGamURL: model.createdGamutURL)
}
.disabled(model.createdGamutURL == nil)
.accessibilityIdentifier("btnViewGamut")
Button("Install Profile") { model.beginInstallProfile() } Button("Install Profile") { model.beginInstallProfile() }
.disabled(model.createdProfileURL == nil) .disabled(model.createdProfileURL == nil)
.accessibilityIdentifier("btnInstallProfile") .accessibilityIdentifier("btnInstallProfile")
+10
View File
@@ -42,6 +42,10 @@ final class WizardViewModel {
var notice: Notice? var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts() private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -126,6 +130,12 @@ final class WizardViewModel {
stage = .generate stage = .generate
} }
/// Open the 3D gamut viewer (issue #28).
func openGamut(profileGamURL: URL? = nil) {
self.gamutProfileURL = profileGamURL
showingGamutViewer = true
}
/// Window-focus hook (#151): files deleted in Finder re-lock stages. /// Window-focus hook (#151): files deleted in Finder re-lock stages.
/// If the current stage re-locked, fall back to the deepest unlocked. /// If the current stage re-locked, fall back to the deepest unlocked.
func windowDidBecomeKey() { func windowDidBecomeKey() {
@@ -0,0 +1,166 @@
import Foundation
import Testing
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
/// 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")
}
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
}
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 4
BEGIN_DATA
100 10.0 20.0 30.0
50 20.0 30.0 40.0
2 30.0 40.0 50.0
7 40.0 50.0 60.0
END_DATA
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 1 2
1 2 3
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
}
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 10.0 20.0 30.0
# inline comment
1 20.0 30.0 40.0
END_DATA
# another comment
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 1 0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
}
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 50.0 -20.0 80.0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
}
@Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 100.0 0.0 0.0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
}
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 10.0 0.0 0.0
1 20.0 0.0 0.0
END_DATA
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 1 0
0 1 99
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1)
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
}
}
@Test("Throws when file is missing")
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
}
}
}
+6 -2
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Mock iccgamut for Milestone 5 UI tests. # Mock iccgamut for Milestone 5/6 UI tests.
# Writes {stem}.gam next to the profile path. # Writes {stem}.gam next to the profile path.
last="" last=""
for arg in "$@"; do last="$arg"; done for arg in "$@"; do last="$arg"; done
@@ -9,5 +9,9 @@ if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then
fi fi
stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//') stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//')
dir=$(dirname "$last") dir=$(dirname "$last")
touch "$dir/$stem.gam" if [ -n "${ICCERY_MOCK_GAMUT_SOURCE}" ] && [ -f "${ICCERY_MOCK_GAMUT_SOURCE}" ]; then
cp "${ICCERY_MOCK_GAMUT_SOURCE}" "$dir/$stem.gam"
else
touch "$dir/$stem.gam"
fi
exit 0 exit 0
@@ -0,0 +1,118 @@
import Foundation
import XCTest
/// Milestone 6 Issue #28 native SceneKit gamut viewer acceptance tests.
@MainActor
final class Milestone6GamutUITests: 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-m6-gamut-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
appDataDir = testRoot.appendingPathComponent("AppData")
// The bundled sRGB reference used by the app; copied into the test workdir
// by the mock iccgamut so the profile gamut is a real, parseable mesh.
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)
// Pre-stage a measured .ti3 and start the wizard on Stage 4.
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("mytarget.ti3").path,
contents: Data("MOCK_TI3".utf8),
attributes: nil)
let state: [String: Any] = [
"currentStage": 4,
"basename": "mytarget",
"cwd": workDir.path,
"printerName": "MockPrinter",
"sessionMode": "profile"
]
let stateData = try JSONSerialization.data(withJSONObject: state, options: [])
try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json"))
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_TEST_WORKDIR": workDir.path,
"ICCERY_MOCK_GAMUT_SOURCE": referenceGamutURL.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 }
return app.sheets.firstMatch.descendants(matching: .any)[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
}
/// Build and verify the mock profile, then open the native gamut viewer.
/// The viewer should load both the reference sRGB mesh and the profile
/// gamut copied from that reference.
func testViewGamutOpensSceneKitSheet() throws {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
waitFor("btnCreateProfile").click()
waitFor("btnVerifyProfile").click()
waitFor("btnViewGamut").click()
let gamutView = waitFor("gamutView")
XCTAssertTrue(gamutView.exists)
let status = waitFor("gamutStatusText")
let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
// The reset button demonstrates that the viewer is interactive.
let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled)
reset.click()
}
}
+1
View File
@@ -25,6 +25,7 @@ targets:
dependencies: dependencies:
- package: ICCeryCore - package: ICCeryCore
product: ICCeryCore product: ICCeryCore
- sdk: SceneKit.framework
postBuildScripts: postBuildScripts:
- name: Copy Argyll sidecars - name: Copy Argyll sidecars
script: | script: |