From 0ee609be1c6e1f894f92ffeb4d687fca40883c54 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 22:06:19 +0100 Subject: [PATCH] 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> --- .../ICCeryCore/Argyll/BinaryResolver.swift | 5 +- .../ICCeryCore/Files/ArtefactProbe.swift | 10 +- .../Sources/ICCeryCore/Gamut/GamutMesh.swift | 51 ++ .../ICCeryCore/Gamut/GamutMeshParser.swift | 152 ++++++ Sources/ICCery/GamutView.swift | 435 ++++++++++++++++++ Sources/ICCery/GamutViewModel.swift | 57 +++ Sources/ICCery/ProfileWorkflowViewModel.swift | 17 +- Sources/ICCery/RootView.swift | 3 + Sources/ICCery/SidebarView.swift | 8 + Sources/ICCery/Stage5View.swift | 6 + Sources/ICCery/WizardViewModel.swift | 10 + .../GamutMeshParserTests.swift | 166 +++++++ Tests/ICCeryUITests/Fixtures/bin/iccgamut | 8 +- .../Milestone6GamutUITests.swift | 118 +++++ project.yml | 1 + 15 files changed, 1038 insertions(+), 9 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift create mode 100644 Sources/ICCery/GamutView.swift create mode 100644 Sources/ICCery/GamutViewModel.swift create mode 100644 Tests/ICCeryCoreTests/GamutMeshParserTests.swift create mode 100644 Tests/ICCeryUITests/Milestone6GamutUITests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift index d7c07f6..1c1d1c5 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift @@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable { /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). public func referenceGamut(_ name: String) -> URL { - bundledRoot + let stem = name.hasSuffix(".gam") ? name : "\(name).gam" + return bundledRoot .appendingPathComponent("reference_gamuts", isDirectory: true) - .appendingPathComponent(name, isDirectory: false) + .appendingPathComponent(stem, isDirectory: false) } /// Whether the resolved path exists and is executable. diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift index 94f62b0..2affd02 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -12,19 +12,23 @@ public struct StageArtefacts: Sendable, Equatable { public var stage4Complete = false /// Absolute path of the profile file when present. public var profilePath: URL? + /// Absolute path of the `.gam` gamut mesh when present (issue #28). + public var gamPath: URL? public init( stage1Complete: Bool = false, stage2Complete: Bool = false, stage3Complete: Bool = false, stage4Complete: Bool = false, - profilePath: URL? = nil + profilePath: URL? = nil, + gamPath: URL? = nil ) { self.stage1Complete = stage1Complete self.stage2Complete = stage2Complete self.stage3Complete = stage3Complete self.stage4Complete = stage4Complete self.profilePath = profilePath + self.gamPath = gamPath } } @@ -45,6 +49,10 @@ public enum ArtefactProbe { if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { out.stage4Complete = true out.profilePath = profile + let gam = artefact(basename, "gam", cwd) + if exists(gam, fm: fileManager) { + out.gamPath = gam + } } return out } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift new file mode 100644 index 0000000..117cbee --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift @@ -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 + + public init(lab: LabColor, rgb: DisplayRGB) { + self.lab = lab + self.rgb = rgb + self.position = SIMD3(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))" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift new file mode 100644 index 0000000..56a04d1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift @@ -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) + } +} diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift new file mode 100644 index 0000000..1cdc82a --- /dev/null +++ b/Sources/ICCery/GamutView.swift @@ -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* 0–100. + let box = buildWireBox(size: SIMD3(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(0, 0, 0), + to: SIMD3(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(-128, 0, 0), + to: SIMD3(128, 0, 0), + color: abAxisColor + )) + axisNode.addChildNode(buildLineNode( + from: SIMD3(0, 0, -128), + to: SIMD3(0, 0, 128), + color: abAxisColor + )) + } + + private func buildWireBox(size: SIMD3, color: NSColor) -> SCNNode { + let hx = size.x / 2 + let hy = size.y / 2 + let hz = size.z / 2 + + let corners: [SIMD3] = [ + 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] = [] + 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] = [] + 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, to: SIMD3, 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], color: NSColor) -> SCNNode { + let source = source(for: points) + + let count = points.count + var indices: [UInt32] = [] + indices.reserveCapacity(count) + for i in 0.. 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] = [] + 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.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + + let colors: [SIMD4] = mesh.vertices.map { v in + SIMD4(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.size, + dataOffset: 0, + dataStride: MemoryLayout>.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]) -> SCNGeometrySource { + let data = points.withUnsafeBytes { Data($0) } + return SCNGeometrySource( + data: data, + semantic: .vertex, + vectorCount: points.count, + usesFloatComponents: true, + componentsPerVector: 3, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.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(180, 120, 180) + let target = SIMD3(0, 50, 0) + newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3(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, target: SIMD3, up: SIMD3) -> 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(right, 0) + matrix.columns.1 = SIMD4(newUp, 0) + matrix.columns.2 = SIMD4(-forward, 0) + matrix.columns.3 = SIMD4(eye, 1) + return matrix + } + } +} diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift new file mode 100644 index 0000000..27dda7f --- /dev/null +++ b/Sources/ICCery/GamutViewModel.swift @@ -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 + } +} diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 320c774..8083ead 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -52,6 +52,8 @@ final class ProfileWorkflowViewModel { var colprofProgress: String? var lastError: String? var createdProfileURL: URL? + /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). + var createdGamutURL: URL? // MARK: - Stage 4/5 calibration (issue #24) @@ -84,12 +86,17 @@ final class ProfileWorkflowViewModel { restoreCreatedProfileURL() } - /// Restores `createdProfileURL` from the wizard artefacts or by probing - /// the working directory for an existing `.icc`/`.icm` (#52). + /// Restores `createdProfileURL` and `createdGamutURL` from the wizard + /// artefacts or by probing the working directory (#52, #28). func restoreCreatedProfileURL() { let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil) createdProfileURL = wizard.artefacts.profilePath ?? 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 @@ -190,6 +197,7 @@ final class ProfileWorkflowViewModel { colprofProgress = nil lastError = nil createdProfileURL = nil + createdGamutURL = nil let runner = environment.runner Task { @MainActor [weak self] in @@ -223,12 +231,13 @@ final class ProfileWorkflowViewModel { // Gamut extraction is best-effort for Stage 5 / M6 viewer. do { 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 self?.colprofLog.append(contentsOf: batch) } } - self.colprofLog.append("Gamut mesh extracted.") + self.createdGamutURL = gamURL + self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)") } catch { self.wizard.showNotice( "Gamut extraction skipped: \(error.localizedDescription)", diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 501f8f1..13c99b1 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -53,6 +53,9 @@ struct RootView: View { .sheet(isPresented: $showingAbout) { AboutView { showingAbout = false } } + .sheet(isPresented: $workflow.wizard.showingGamutViewer) { + GamutView(profileGamURL: workflow.wizard.gamutProfileURL) + } } @ViewBuilder diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 54268fc..0573885 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -84,6 +84,14 @@ struct SidebarView: View { .disabled(true) .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) .padding(.vertical, 8) diff --git a/Sources/ICCery/Stage5View.swift b/Sources/ICCery/Stage5View.swift index a6c18bd..9ef62c3 100644 --- a/Sources/ICCery/Stage5View.swift +++ b/Sources/ICCery/Stage5View.swift @@ -146,6 +146,12 @@ struct Stage5View: View { Spacer() + Button("View Gamut") { + model.wizard.openGamut(profileGamURL: model.createdGamutURL) + } + .disabled(model.createdGamutURL == nil) + .accessibilityIdentifier("btnViewGamut") + Button("Install Profile") { model.beginInstallProfile() } .disabled(model.createdProfileURL == nil) .accessibilityIdentifier("btnInstallProfile") diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 2b0b9dc..7019e5e 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -42,6 +42,10 @@ final class WizardViewModel { var notice: Notice? /// Current artefact probe result; recomputed on `refreshGating()`. 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 var noticeDismissTask: Task? @@ -126,6 +130,12 @@ final class WizardViewModel { 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. /// If the current stage re-locked, fall back to the deepest unlocked. func windowDidBecomeKey() { diff --git a/Tests/ICCeryCoreTests/GamutMeshParserTests.swift b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift new file mode 100644 index 0000000..c3643a2 --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift @@ -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(-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) + } + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/iccgamut b/Tests/ICCeryUITests/Fixtures/bin/iccgamut index 9c0b74e..dd21229 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/iccgamut +++ b/Tests/ICCeryUITests/Fixtures/bin/iccgamut @@ -1,5 +1,5 @@ #!/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. last="" for arg in "$@"; do last="$arg"; done @@ -9,5 +9,9 @@ if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then fi stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//') 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 diff --git a/Tests/ICCeryUITests/Milestone6GamutUITests.swift b/Tests/ICCeryUITests/Milestone6GamutUITests.swift new file mode 100644 index 0000000..8c8068d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6GamutUITests.swift @@ -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() + } +} diff --git a/project.yml b/project.yml index d5014f6..e172855 100644 --- a/project.yml +++ b/project.yml @@ -25,6 +25,7 @@ targets: dependencies: - package: ICCeryCore product: ICCeryCore + - sdk: SceneKit.framework postBuildScripts: - name: Copy Argyll sidecars script: | -- 2.39.5