Compare commits

..
Author SHA1 Message Date
gronod 6f4491d0e9 Merge pull request 'feat(#29): Stage 0 printer calibration' (#64) from feat/29-stage0-calibration into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #64
2026-09-09 22:48:50 +01:00
gronod ff883a43b2 fix(#29): manual gamut sheet binding so RootView compiles with let wizard 2026-09-09 22:41:00 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 34bef9a78e fix(stage0): implement printer calibration workflow and stabilise M6 UI tests.
- Add CalibrationTargenArgs, PrintcalArgs, CalibrationStore (stage 0).
- Add ArgyllRunner.runCalibrationTargen and runPrintcal.
- Add CalibrationView + CalibrationViewModel to the app shell.
- Wire calibration into RootView, SidebarView, Stage1View, TargetWorkflowViewModel.
- Remove mutable _uiTestRoot from AppPaths; derive isolated test roots from
  ICCERY_TEST_WORKDIR / process id.
- Fix Milestone6CGATSUITests to assert stage4TargetBasename instead of a
  VStack accessibility identifier.
- Fix Milestone6CalibrationUITests fixture binary path.

Refs: #29

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 22:27:02 +01:00
gronod e929576c31 Merge pull request 'feat(#28): SceneKit gamut viewer' (#62) from feat/28-scenekit-gamut into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #62
2026-09-09 22:13:19 +01:00
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
gronod 563f0e5d4a Merge pull request 'Milestone/m6 gamut stage0 cgats release' (#57) from milestone/m6-gamut-stage0-cgats-release into feat/31-about-help-chrome
Reviewed-on: #57
2026-09-09 18:31:47 +01:00
gronod cd4665e7a9 Merge pull request 'feat/30-cgats-interop' (#56) from feat/30-cgats-interop into milestone/m6-gamut-stage0-cgats-release 2026-09-09 17:20:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 153b6194a3 Implement #30: CGATS parser, canonical .ti3 writer, and dataset import.
- Add CGATSParser, CGATSWriter, and CGATSSummary under ICCeryCore/CGATS.
- Parse CTI3, CGATS.17, ISO28178, and CSV; synthesize COLOR_REP/DEVICE_CLASS.
- Scale RGB/CMYK device coordinates from 0-255 to 0-100 when needed.
- Round-trip parse, write, reparse with tab-delimited canonical .ti3.
- Wire `Import Dataset…` in Stage 1 to open-panel import, write .ti3, and
  jump to Stage 4/5 while keeping Stages 1-2 locked without .ti1/.ti2.
- Add CGATSParserTests and Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:18:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f78da50a59 Implement #31: About dialog and global help overlays.
- Add ArtefactFiles.appInfo() build date from executable mtime.
- Add AboutView with version, build, build date, and close button.
- Add HelpOverlay view modifier for non-reflowing help badges.
- Wire About as a sheet and help toggle in SidebarView/RootView.
- Add AboutHelpUITests coverage.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:04:15 +01:00
gronod 629a1fce1d Merge pull request 'Milestone 5 — Profile generation, verification & installation plus M5 bugfixes (#23–#27, #50, #52)' (#54) from milestone/m5-profile into develop 2026-09-09 14:22:10 +01:00
gronod 460b0a1ffa Merge pull request 'M5 bugfixes — Stage 3 prompt stream, ProcessManager, profile/verification fixes (#50, #52)' (#53) from feat/52-m5-bugfixes into milestone/m5-profile 2026-09-09 14:21:42 +01:00
40 changed files with 3183 additions and 34 deletions
@@ -9,6 +9,7 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case chartreadFailed(String) case chartreadFailed(String)
case averageFailed(String) case averageFailed(String)
case colprofFailed(String) case colprofFailed(String)
case printcalFailed(String)
case applycalFailed(String) case applycalFailed(String)
case iccgamutFailed(String) case iccgamutFailed(String)
case profcheckFailed(String) case profcheckFailed(String)
@@ -30,6 +31,8 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
return "Averaging failed: \(reason)" return "Averaging failed: \(reason)"
case .colprofFailed(let reason): case .colprofFailed(let reason):
return "Profile creation failed: \(reason)" return "Profile creation failed: \(reason)"
case .printcalFailed(let reason):
return "Calibration curve computation failed: \(reason)"
case .applycalFailed(let reason): case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)" return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason): case .iccgamutFailed(let reason):
@@ -755,6 +758,82 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: processId) await processManager.kill(id: processId)
} }
} }
// MARK: - Stage 0 calibration
/// Generates a calibration wedge `.ti1`.
public func runCalibrationTargen(
config: CalibrationTargenConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
///
/// `printcal` is captured (not streamed) and is exempt from the `-u`
/// JSON policy.
public func runPrintcal(
config: PrintcalConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let args = try PrintcalArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let binaryURL = binaryResolver.resolve("printcal")
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId)
let result = try await processManager.runCaptured(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
if let onLogBatch = onLogBatch, !result.stdout.isEmpty {
onLogBatch(result.stdout.components(separatedBy: .newlines))
}
guard result.exitCode == 0 else {
throw ArgyllRunnerError.printcalFailed(
result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)"
: result.stderr
)
}
let calURL = config.outputURL
guard FileManager.default.fileExists(atPath: calURL.path) else {
throw ArgyllRunnerError.missingArtefact(calURL.path)
}
return calURL
}
} }
/// Events emitted by a running `chartread` session. /// Events emitted by a running `chartread` session.
@@ -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.
@@ -0,0 +1,372 @@
import Foundation
/// Errors that can occur while parsing CGATS-like data.
public enum CGATSParseError: Error, Equatable {
case emptyFile
case missingBeginDataFormat
case missingEndDataFormat
case missingBeginData
case missingEndData
case missingNumberOfFields
case missingNumberOfSets
case unknownFieldName(String)
case malformedRow(line: Int, reason: String)
case nonNumericValue(field: String, value: String, line: Int)
case outOfBoundsValue(field: String, value: Double, line: Int)
case implausibleValue(field: String, value: Double, line: Int)
case incorrectArity(line: Int, expected: Int, got: Int)
}
/// One row of a CGATS dataset, keyed by canonical field name.
public struct CGATSSample: Sendable, Equatable {
public var id: String
public var loc: String?
public var values: [String: String]
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
self.id = id
self.loc = loc
self.values = values
}
}
/// A parsed CGATS / CTI3 / CSV dataset.
public struct CGATSDataset: Sendable, Equatable {
public var format: CGATSFormat
public var keywords: [String: String]
public var fieldNames: [String]
public var samples: [CGATSSample]
public var colorRep: String?
public var deviceClass: String?
public var targetInstrument: String?
public init(
format: CGATSFormat,
keywords: [String: String] = [:],
fieldNames: [String] = [],
samples: [CGATSSample] = [],
colorRep: String? = nil,
deviceClass: String? = nil,
targetInstrument: String? = nil
) {
self.format = format
self.keywords = keywords
self.fieldNames = fieldNames
self.samples = samples
self.colorRep = colorRep
self.deviceClass = deviceClass
self.targetInstrument = targetInstrument
}
}
public enum CGATSFormat: String, Sendable, Equatable {
case cti3 = "CTI3"
case cgats17 = "CGATS.17"
case csv = "CSV"
}
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
public enum CGATSParser {
/// Parse the contents of a CGATS-like file.
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix("SAMPLE_ID,")
let (format, lines) = try preprocess(contents, isCSV: isCSV)
var formatStart: Int?
var formatEnd: Int?
var dataStart: Int?
var dataEnd: Int?
var keywords = [String: String]()
for (index, line) in lines.enumerated() {
switch Self.normalizedKeyword(line) {
case "BEGIN_DATA_FORMAT": formatStart = index
case "END_DATA_FORMAT": formatEnd = index
case "BEGIN_DATA": dataStart = index
case "END_DATA": dataEnd = index
default:
if let (key, value) = parseKeyword(line) {
keywords[key] = value
}
}
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
numberOfFields != fieldNames.count {
// Warn only; the data format line is the source of truth.
} else if keywords["NUMBER_OF_FIELDS"] == nil {
// Optional header; do not fail.
}
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
numberOfSets != dataEnd - dataStart - 1 {
// Warn only; the actual rows are the source of truth.
} else if keywords["NUMBER_OF_SETS"] == nil {
// Optional header; do not fail.
}
struct RawSample {
var id: String
var loc: String?
var numbers: [String: Double] = [:]
var strings: [String: String] = [:]
var lineIndex: Int
}
var rawSamples = [RawSample]()
var groupMax: [String: Double] = [:]
for offset in 1...(dataEnd - dataStart - 1) {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
for (i, name) in fieldNames.enumerated() {
let raw = stripInlineComment(rawRow[i])
if isNumericField(name) {
let cleaned = raw.trimmingCharacters(in: .whitespaces)
if let number = parseNumber(cleaned) {
sample.numbers[name] = number
if let group = deviceGroup(name) {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
}
}
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
sample.loc = sample.strings["SAMPLE_LOC"]
rawSamples.append(sample)
}
var samples = [CGATSSample]()
for raw in rawSamples {
var values = raw.strings
for (name, number) in raw.numbers {
var scaled = number
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
scaled = number / 2.55
}
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
}
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
// Keep lookups by canonical keys, but also preserve original aliases.
let rawRow = splitFields(lines[raw.lineIndex])
for (i, rawName) in rawFieldNames.enumerated() {
let canonical = canonicalFieldName(rawName)
if canonical != rawName {
sample.values[rawName] = rawRow[i]
}
}
samples.append(sample)
}
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
return CGATSDataset(
format: format,
keywords: keywords,
fieldNames: fieldNames,
samples: samples,
colorRep: colorRep,
deviceClass: deviceClass,
targetInstrument: keywords["TARGET_INSTRUMENT"]
)
}
/// Parse from a URL (throws as `Error` for public callers).
public static func parse(url: URL) throws -> CGATSDataset {
let contents = try String(contentsOf: url)
return try parse(contents, sourceURL: url)
}
// MARK: - Internals
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
var format: CGATSFormat?
for var line in allLines {
line = stripComment(line)
line = line.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty else { continue }
if format == nil {
if line.hasPrefix("CTI3") { format = .cti3 }
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
else if isCSV { format = .csv }
}
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
line == "BEGIN_DATA" || line == "END_DATA" ||
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
// These are exact keywords; keep them intact.
}
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
if let format, format == .csv,
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
let header = lines[0]
let data = lines.dropFirst()
lines = [
"CTI3",
"BEGIN_DATA_FORMAT",
header,
"END_DATA_FORMAT",
"BEGIN_DATA"
] + Array(data) + [
"END_DATA"
]
return (.csv, lines)
}
return (format ?? .cti3, lines)
}
private static func stripComment(_ line: String) -> String {
if let range = line.range(of: "#") {
return String(line[..<range.lowerBound])
}
return line
}
private static func stripInlineComment(_ token: String) -> String {
if let range = token.range(of: "#") {
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
}
return token
}
private static func splitFields(_ line: String) -> [String] {
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
if line.contains(",") {
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
}
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
// KEYWORD value or KEYWORD "value"
let tokens = splitFields(line)
guard let key = tokens.first else { return nil }
// Data-boundary keywords are not value keywords.
let boundaryKeys = Set([
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
"BEGIN_DATA", "END_DATA"
])
guard !boundaryKeys.contains(key) else { return nil }
let rawValue = tokens.dropFirst().joined(separator: " ")
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return (key, value)
}
private static func normalizedKeyword(_ line: String) -> String {
line.uppercased().trimmingCharacters(in: .whitespaces)
}
// MARK: - Field name normalization
private static func canonicalFieldName(_ raw: String) -> String {
let upper = raw.uppercased()
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "-", with: "_")
switch upper {
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
case "SAMPLE_NAME": return "SAMPLE_ID"
case "LAB_L", "L*", "L_AB": return "LAB_L"
case "LAB_A", "A*", "A_AB": return "LAB_A"
case "LAB_B", "B*", "B_AB": return "LAB_B"
case "XYZ_X", "X": return "XYZ_X"
case "XYZ_Y", "Y": return "XYZ_Y"
case "XYZ_Z", "Z": return "XYZ_Z"
default: return upper
}
}
private static func isNumericField(_ name: String) -> Bool {
let numericNames: Set = [
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
]
return !numericNames.contains(name)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
var number = value
// Plausibility checks for Lab and XYZ.
if field == "LAB_L" { number = max(0, min(160, number)) }
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
return String(format: "%.4f", number)
}
private static func deviceGroup(_ name: String) -> String? {
if name.hasPrefix("RGB_") { return "RGB" }
if name.hasPrefix("CMYK_") { return "CMYK" }
if name.hasPrefix("DEVICE_") { return "DEVICE" }
return nil
}
private static func inferColorRep(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
return nil
}
private static func inferDeviceClass(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
return "OUTPUT"
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Human-readable summary of an imported CGATS dataset.
public struct CGATSSummary: Sendable, Equatable {
public let patchCount: Int
public let colorSpace: String?
public let deviceClass: String?
public let hasSpectral: Bool
public let previewRows: [String]
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
self.patchCount = dataset.samples.count
self.colorSpace = dataset.colorRep
self.deviceClass = dataset.deviceClass
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
})
}
}
@@ -0,0 +1,82 @@
import Foundation
/// Errors from writing a canonical `.ti3` dataset.
public enum CGATSWriterError: Error, Equatable {
case noSamples
case missingRequiredField(String)
case invalidValue(field: String, value: String)
}
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
public enum CGATSWriter {
public static func write(_ dataset: CGATSDataset) throws -> String {
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
throw CGATSWriterError.noSamples
}
var lines = [String]()
// Header
lines.append(dataset.format.rawValue)
lines.append("")
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
if let colorRep = dataset.colorRep {
lines.append("COLOR_REP \"\(colorRep)\"")
}
if let deviceClass = dataset.deviceClass {
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
}
if let instrument = dataset.targetInstrument {
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
}
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
lines.append("")
lines.append("BEGIN_DATA_FORMAT")
lines.append(dataset.fieldNames.joined(separator: "\t"))
lines.append("END_DATA_FORMAT")
lines.append("")
lines.append("BEGIN_DATA")
for sample in dataset.samples {
let row = try dataset.fieldNames.map { field in
guard let raw = sample.values[field], !raw.isEmpty else {
throw CGATSWriterError.missingRequiredField(field)
}
// Normalize numeric fields to a compact decimal.
if isNumeric(field) {
return normalizedNumber(raw)
}
return raw
}
lines.append(row.joined(separator: "\t"))
}
lines.append("END_DATA")
return lines.joined(separator: "\n") + "\n"
}
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
let text = try write(dataset)
try text.write(to: url, atomically: true, encoding: .utf8)
}
// MARK: - Internals
private static func isNumeric(_ field: String) -> Bool {
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
return !nonNumeric.contains(field)
}
private static func normalizedNumber(_ raw: String) -> String {
guard let number = Double(raw) else { return raw }
if number == floor(number) {
return String(format: "%.0f", number)
}
return String(format: "%.4f", number)
}
}
@@ -15,14 +15,26 @@ public enum ArtefactFiles {
try Data(contentsOf: url).base64EncodedString() try Data(contentsOf: url).base64EncodedString()
} }
/// `get_app_info` version + build for the About dialog. /// `get_app_info` version, build, and build date for the About dialog.
public static func appInfo( public static func appInfo(
bundle: Bundle = .main bundle: Bundle = .main
) -> (version: String, build: String) { ) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:] let info = bundle.infoDictionary ?? [:]
return ( let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
info["CFBundleShortVersionString"] as? String ?? "0.0.0", let build = info["CFBundleVersion"] as? String ?? "0"
info["CFBundleVersion"] as? String ?? "0"
) let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
let date = values.contentModificationDate {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
buildDate = formatter.string(from: date)
} else {
buildDate = "Unknown"
}
return (version, build, buildDate)
} }
} }
@@ -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)
}
}
@@ -15,8 +15,9 @@ public enum AppPaths {
/// `~/Library/Application Support/com.gronod.iccery2` /// `~/Library/Application Support/com.gronod.iccery2`
/// ///
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run /// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
/// against an isolated root and never touch the developer's state. /// data so UI tests run against an isolated root and never touch the
/// developer's state.
public static var appDataDir: URL { public static var appDataDir: URL {
#if DEBUG #if DEBUG
if let root = testRoot { if let root = testRoot {
@@ -42,10 +43,31 @@ public enum AppPaths {
} }
#if DEBUG #if DEBUG
/// DEBUG-only root override. Order:
/// 1. `ICCERY_TEST_ROOT` for an explicit test root.
/// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to
/// the current UI test's working directory.
/// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test
/// that sets neither of the above still runs in isolation.
///
/// Computed from `ProcessInfo` each call no mutable static state.
private static var testRoot: URL? { private static var testRoot: URL? {
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
!raw.isEmpty else { return nil } !raw.isEmpty {
return URL(fileURLWithPath: raw, isDirectory: true) return URL(fileURLWithPath: raw, isDirectory: true)
}
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"],
!raw.isEmpty {
return URL(fileURLWithPath: raw, isDirectory: true)
}
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
return FileManager.default.temporaryDirectory
.appendingPathComponent(
"iccery-ui-\(ProcessInfo.processInfo.processIdentifier)",
isDirectory: true
)
}
return nil
} }
#endif #endif
@@ -0,0 +1,203 @@
import Foundation
/// A single channel's calibration curve.
public struct CalibrationCurve: Sendable, Equatable {
public let channel: Character
public let input: [Double]
public let output: [Double]
public init(channel: Character, input: [Double], output: [Double]) {
self.channel = channel
self.input = input
self.output = output
}
}
/// Parsed Argyll `.cal` curve data.
public struct CalibrationData: Sendable, Equatable {
public var colorRep: String
public var descriptor: String?
public var created: Date?
public var maxTac: Double?
public var inkLimits: [Character: Double]
public var curves: [CalibrationCurve]
public init(
colorRep: String = "",
descriptor: String? = nil,
created: Date? = nil,
maxTac: Double? = nil,
inkLimits: [Character: Double] = [:],
curves: [CalibrationCurve] = []
) {
self.colorRep = colorRep
self.descriptor = descriptor
self.created = created
self.maxTac = maxTac
self.inkLimits = inkLimits
self.curves = curves
}
}
/// Errors from loading and parsing a `.cal` file.
public enum CalibrationStoreError: Error, Equatable {
case unreadableFile
case missingColorRep
case missingCurveData
case unsupportedFormat
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .unreadableFile:
return "Could not read the calibration file."
case .missingColorRep:
return "The .cal file is missing its COLOR_REP header."
case .missingCurveData:
return "The .cal file contains no calibration curve data."
case .unsupportedFormat:
return "The .cal file format is not supported."
case .parseFailed(let reason):
return "Calibration parse failed: \(reason)"
}
}
}
/// Store for a calibration curve, its metadata, and staleness checks.
public actor CalibrationStore {
public private(set) var data: CalibrationData?
public private(set) var sourceURL: URL?
public private(set) var storedPrinterName: String?
/// Number of days after which a calibration is considered stale.
public var staleDays: Int
public init(staleDays: Int = 30) {
self.staleDays = staleDays
}
/// Load and parse a `.cal` file.
public func load(url: URL) async throws {
let dataset = try CGATSParser.parse(url: url)
guard let colorRep = dataset.colorRep, !colorRep.isEmpty else {
throw CalibrationStoreError.missingColorRep
}
var data = CalibrationData()
data.colorRep = colorRep
data.descriptor = dataset.keywords["DESCRIPTOR"]
if let createdString = dataset.keywords["CREATED"] {
let formatter = ISO8601DateFormatter()
data.created = formatter.date(from: createdString)
?? Date(timeIntervalSince1970: 0)
} else {
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
data.created = attrs?[.modificationDate] as? Date
}
let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"]
for key in limitKeys {
if let raw = dataset.keywords[key], let value = Double(raw) {
data.maxTac = value
break
}
}
for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") {
let suffix = key.dropFirst("INK_LIMIT_".count)
guard let channel = suffix.first, let value = Double(raw) else { continue }
data.inkLimits[channel] = value
}
data.curves = try Self.extractCurves(from: dataset)
guard !data.curves.isEmpty else {
throw CalibrationStoreError.missingCurveData
}
self.data = data
self.sourceURL = url
// Printer name may live in a sidecar JSON. For now, fall back to the
// descriptor so callers have something to compare.
self.storedPrinterName = data.descriptor
}
/// Store an explicit printer name (e.g. from a sidecar).
public func setPrinterName(_ name: String?) {
self.storedPrinterName = name
}
/// True if the loaded calibration is older than `staleDays` or the
/// printer name does not match.
public func isStale(comparedTo currentPrinter: String? = nil) -> Bool {
guard let data else { return true }
if let created = data.created,
let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created),
Date() > threshold {
return true
}
if let stored = storedPrinterName, !stored.isEmpty,
let current = currentPrinter, !current.isEmpty,
stored != current {
return true
}
return false
}
// MARK: - Internals
private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] {
// Argyll .cal files contain an INPUT_VALUE column and one or more
// per-channel output columns. Field names vary by COLOR_REP.
let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" }
guard !outputFields.isEmpty else {
// Older .cal files may only have one output column named OUTPUT_VALUE.
if dataset.fieldNames.contains("OUTPUT_VALUE") {
return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)]
}
throw CalibrationStoreError.missingCurveData
}
var curves = [CalibrationCurve]()
for field in outputFields {
let channel = field.first ?? "?"
let curve = try buildCurve(channel: channel, field: field, dataset: dataset)
curves.append(curve)
}
return curves
}
private static func buildCurve(
channel: Character,
field: String,
dataset: CGATSDataset
) throws -> CalibrationCurve {
var input = [Double]()
var output = [Double]()
for sample in dataset.samples {
guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field],
let inVal = parseNumber(inRaw),
let outRaw = sample.values[field],
let outVal = parseNumber(outRaw) else {
throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)")
}
input.append(inVal)
output.append(outVal)
}
return CalibrationCurve(channel: channel, input: input, output: output)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
}
@@ -0,0 +1,93 @@
import Foundation
/// Errors during calibration `targen` argv construction.
public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidSteps(Int)
case invalidInkLimit(Int)
case invalidWhitePatches(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid calibration basename: \(name)"
case .invalidSteps(let steps):
return "Calibration steps must be 1151, got: \(steps)"
case .invalidInkLimit(let limit):
return "Calibration ink limit must be 200400, got: \(limit)"
case .invalidWhitePatches(let count):
return "Calibration white patches cannot be negative, got: \(count)"
}
}
}
/// Configuration for a calibration wedge `targen` run.
public struct CalibrationTargenConfig: Sendable, Equatable {
public var colourSpace: ColourSpace
public var steps: Int
public var whitePatches: Int
public var includeNeutralEmphasis: Bool
public var inkLimit: Int?
public var basename: String
public var workingDirectory: URL?
public init(
colourSpace: ColourSpace = .rgb,
steps: Int = 21,
whitePatches: Int = 4,
includeNeutralEmphasis: Bool = false,
inkLimit: Int? = nil,
basename: String = "",
workingDirectory: URL? = nil
) {
self.colourSpace = colourSpace
self.steps = steps
self.whitePatches = whitePatches
self.includeNeutralEmphasis = includeNeutralEmphasis
self.inkLimit = inkLimit
self.basename = basename
self.workingDirectory = workingDirectory
}
}
/// Pure argv builder for the Stage 0 calibration `targen` chart.
///
/// Produces a per-channel wedge with `-f 0` (no full-spread patches).
public enum CalibrationTargenArgs {
/// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`.
public static func build(config: CalibrationTargenConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard (11...51).contains(config.steps) else {
throw CalibrationTargenArgError.invalidSteps(config.steps)
}
guard config.whitePatches >= 0 else {
throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches)
}
var args: [String] = [
"-v",
"-d", config.colourSpace.dFlagValue,
"-s", "\(config.steps)",
"-g", "\(config.steps)",
"-e", "\(config.whitePatches)",
"-f", "0"
]
if config.includeNeutralEmphasis {
args.append(contentsOf: ["-n", "\(config.steps)"])
}
if config.colourSpace == .cmyk, let inkLimit = config.inkLimit {
guard (200...400).contains(inkLimit) else {
throw CalibrationTargenArgError.invalidInkLimit(inkLimit)
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
}
@@ -0,0 +1,114 @@
import Foundation
/// Errors during `printcal` argv construction.
public enum PrintcalArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidTotalInkLimit(Double)
case invalidPerChannelLimit(Character, Double)
case invalidOutputPath
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid calibration basename: \(name)"
case .invalidTotalInkLimit(let limit):
return "Total ink limit must be positive, got: \(limit)"
case .invalidPerChannelLimit(let channel, let limit):
return "\(channel) channel limit must be 0100, got: \(limit)"
case .invalidOutputPath:
return "Invalid .cal output path"
}
}
}
/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`.
public struct PrintcalChannelLimit: Sendable, Equatable {
public let channel: Character
public let percent: Double
public init(channel: Character, percent: Double) {
self.channel = channel
self.percent = percent
}
}
/// Configuration for an Argyll `printcal` run.
public struct PrintcalConfig: Sendable, Equatable {
public var ti3Basename: String
public var workingDirectory: URL?
public var outputURL: URL
public var noInkLimit: Bool
public var verify: Bool
public var previousCalPath: String?
public var totalInkLimit: Double?
public var channelLimits: [PrintcalChannelLimit]
public init(
ti3Basename: String,
workingDirectory: URL? = nil,
outputURL: URL,
noInkLimit: Bool = false,
verify: Bool = false,
previousCalPath: String? = nil,
totalInkLimit: Double? = nil,
channelLimits: [PrintcalChannelLimit] = []
) {
self.ti3Basename = ti3Basename
self.workingDirectory = workingDirectory
self.outputURL = outputURL
self.noInkLimit = noInkLimit
self.verify = verify
self.previousCalPath = previousCalPath
self.totalInkLimit = totalInkLimit
self.channelLimits = channelLimits
}
}
/// Pure argv builder for Argyll's `printcal` tool.
///
/// `printcal` is captured, not streamed. JS never sends `-u` (unapply).
public enum PrintcalArgs {
/// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC]
/// [-xC pct]... -o out.cal CAL_basename`.
public static func build(config: PrintcalConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename)
guard !cleanBasename.isEmpty else {
throw PrintcalArgError.invalidBasename(config.ti3Basename)
}
var args: [String] = ["-v", "-e"]
if config.noInkLimit {
args.append("-I")
}
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit {
throw PrintcalArgError.invalidTotalInkLimit(tac)
}
for limit in config.channelLimits {
guard (0...100).contains(limit.percent) else {
throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent)
}
args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)])
}
guard !config.outputURL.path.isEmpty else {
throw PrintcalArgError.invalidOutputPath
}
args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
}
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
import ICCeryCore
/// About dialog for ICCery (issue #31, docs/21 §Modals).
struct AboutView: View {
let onClose: () -> Void
private let info = ArtefactFiles.appInfo()
var body: some View {
VStack(spacing: 20) {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 64)
Text("ICCery")
.font(.title)
.foregroundStyle(Theme.text)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Version:")
.foregroundStyle(.secondary)
Text(info.version)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutVersion")
}
HStack {
Text("Build:")
.foregroundStyle(.secondary)
Text(info.build)
.foregroundStyle(Theme.text)
}
HStack {
Text("Build date:")
.foregroundStyle(.secondary)
Text(info.buildDate)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutBuildDate")
}
}
.font(.callout)
Text("Native macOS printer profiling workstation.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
onClose()
}
.controlSize(.large)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeAboutBtn")
}
.padding(32)
.frame(width: 360)
.background(Theme.panel)
.accessibilityIdentifier("aboutDialog")
}
}
+2
View File
@@ -68,6 +68,8 @@ enum UITestHooks {
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse). /// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
/// Preset import file. /// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination. /// Preset export destination.
+111
View File
@@ -0,0 +1,111 @@
import SwiftUI
import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@Bindable var model: CalibrationViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Calibrate Printer")
.font(.title2.bold())
.padding(.horizontal, 16)
.padding(.top, 16)
Form {
Section("Wedge Settings") {
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
HStack {
Text("Steps per channel")
Spacer()
TextField("", value: $model.steps, format: .number)
.frame(width: 60)
.accessibilityIdentifier("calSteps")
}
HStack {
Text("White patches")
Spacer()
TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60)
}
if model.colourSpace == .cmyk {
HStack {
Text("Ink-limit exploration")
Spacer()
TextField("", text: $model.inkLimit)
.frame(width: 60)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
}
Section("Workflow") {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(!model.canGenerate)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(!model.canGenerate)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.calibrationLog.isEmpty {
Section("Log") {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.calibrationLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
}
}
}
.frame(minHeight: 80, maxHeight: 120)
}
}
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Return to Profiling") { model.returnToProfiling() }
.accessibilityIdentifier("btnCalReturn")
}
.padding(16)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves.
@MainActor
@Observable
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
let environment: AppEnvironment
// MARK: - Form state
var colourSpace: ColourSpace = .cmyk
var steps: Int = 21
var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320"
var applyToProfile: Bool = false
var computedCalURL: URL?
var calibrationLog: [String] = []
var isGenerating = false
var isComputing = false
var lastError: String?
private var originalBasename: String = ""
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.profile = profile
self.environment = environment
}
private var wizard: WizardViewModel { workflow.wizard }
// MARK: - Derived
var canGenerate: Bool {
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating
}
var canCompute: Bool {
calibrationTi3URL != nil && !isComputing
}
var calibrationTi3URL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).ti3")
}
private var calBasename: String {
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)"
}
private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal")
}
// MARK: - Generate calibration target
func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
originalBasename = wizard.basename
wizard.basename = calBasename
wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue,
basename: originalBasename,
workingDirectory: cwd
)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isGenerating = false }
do {
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
)
self.restoreProfileBasename()
}
}
}
// MARK: - Layout, print, measure
/// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename.
/// After measurement, the user returns and presses Compute Curves.
func createLayout() {
wizard.sessionMode = .calibration
wizard.go(to: .layOutPrint)
}
func measureChart() {
wizard.sessionMode = .calibration
wizard.go(to: .measure)
}
// MARK: - Compute curves
func computeCurves() {
guard canCompute,
let cwd = wizard.effectiveWorkingDirectory,
let outputURL = calOutputURL else { return }
// Collision check: the Argyll `printcal` exit error contains
// "already exists" when the user declines overwrite. We do not
// silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
wizard.showNotice(lastError!, kind: .error)
return
}
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
outputURL: outputURL,
noInkLimit: false,
verify: false,
previousCalPath: nil,
totalInkLimit: inkLimitValue.map { Double($0) },
channelLimits: []
)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isComputing = false }
do {
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.computedCalURL = url
self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.")
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - Apply toggle
func updateApplyToProfile() {
profile.applyCalibration = applyToProfile
if applyToProfile, let url = computedCalURL {
profile.calibrationFile = url.path
} else if applyToProfile {
// User toggled on before computing; keep the path if already set.
} else {
profile.applyCalibration = false
}
}
func returnToProfiling() {
restoreProfileBasename()
wizard.sessionMode = .profile
wizard.go(to: .generate)
}
private func restoreProfileBasename() {
if !originalBasename.isEmpty {
wizard.basename = originalBasename
originalBasename = ""
}
}
private var inkLimitValue: Int? {
guard colourSpace == .cmyk else { return nil }
return Int(inkLimit)
}
}
+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
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
/// Reusable help overlay badge that does not reflow layout (#171).
///
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
/// available on hover, so the overlay is purely a visual cue in help mode.
struct HelpOverlay: ViewModifier {
let text: String
@Binding var showing: Bool
func body(content: Content) -> some View {
content
.help(text)
.overlay(alignment: .topTrailing) {
if showing {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.accent)
.offset(x: 8, y: -8)
}
}
}
}
extension View {
/// Adds a non-reflowing help overlay to the view.
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
modifier(HelpOverlay(text: text, showing: showing))
}
}
+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)",
+14 -6
View File
@@ -8,6 +8,7 @@ struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard } private var model: WizardViewModel { workflow.wizard }
@@ -16,7 +17,8 @@ struct RootView: View {
SidebarView( SidebarView(
workflow: workflow, workflow: workflow,
onOpenSettings: { showingSettings = true }, onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true } onOpenAbout: { showingAbout = true },
showingAllHelp: $showingAllHelp
) )
Rectangle() Rectangle()
@@ -48,10 +50,14 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) { .sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow) ManagePresetsDialog(workflow: workflow)
} }
.alert("ICCery 2.0.0", isPresented: $showingAbout) { .sheet(isPresented: $showingAbout) {
Button("OK") {} AboutView { showingAbout = false }
} message: { }
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") .sheet(isPresented: Binding(
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
} }
} }
@@ -68,7 +74,9 @@ struct RootView: View {
Stage4View(model: workflow.profile) Stage4View(model: workflow.profile)
case .verifyInstall: case .verifyInstall:
Stage5View(model: workflow.profile) Stage5View(model: workflow.profile)
default: case .calibrate:
CalibrationView(model: workflow.calibration)
@unknown default:
StagePlaceholderView(stage: model.stage) StagePlaceholderView(stage: model.stage)
} }
} }
+21 -5
View File
@@ -7,6 +7,7 @@ struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard } private var model: WizardViewModel { workflow.wizard }
@@ -22,12 +23,20 @@ struct SidebarView: View {
Image(systemName: "gearshape") Image(systemName: "gearshape")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Settings") .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openSettingsBtn")
Button(action: onOpenAbout) { Button(action: onOpenAbout) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("About ICCery") .helpOverlay("Open the About dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openAboutBtn")
Button(action: { showingAllHelp.toggle() }) {
Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle")
}
.buttonStyle(.plain)
.help("Toggle help overlays")
.accessibilityIdentifier("btnToggleAllHelp")
} }
.padding(12) .padding(12)
@@ -65,14 +74,21 @@ struct SidebarView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 8) .padding(.bottom, 8)
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until // Calibrate Printer (`#btnCalibratePrinter`).
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
Button(action: { model.enterCalibration() }) { Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3") Label("Calibrate Printer", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.controlSize(.large) .controlSize(.large)
.disabled(true) .accessibilityIdentifier("btnCalibratePrinter")
.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) .padding(.horizontal, 12)
Divider().overlay(Theme.border) Divider().overlay(Theme.border)
+2 -2
View File
@@ -85,11 +85,11 @@ struct Stage1View: View {
Button("Browse…") { workflow.browseForTargetFile() } Button("Browse…") { workflow.browseForTargetFile() }
.accessibilityIdentifier("btnBrowse") .accessibilityIdentifier("btnBrowse")
Button("Working Dir…") { workflow.browseForWorkingDirectory() } Button("Working Dir…") { workflow.browseForWorkingDirectory() }
.accessibilityIdentifier("btnSelectWorkDir")
Button("Open Existing…") { workflow.openExistingTarget() } Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting") .accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { /* CGATS import #94, later */ } Button("Import Dataset…") { workflow.importMeasurementDataset() }
.accessibilityIdentifier("btn-import-dataset") .accessibilityIdentifier("btn-import-dataset")
.disabled(true)
} }
Text(workflow.targetDirectory?.path ?? "No working directory selected") Text(workflow.targetDirectory?.path ?? "No working directory selected")
.font(.caption) .font(.caption)
+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")
+48 -1
View File
@@ -123,8 +123,10 @@ final class TargetWorkflowViewModel {
/// across stage switches and can observe settings changes. /// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists /// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can apply preset values. /// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow.
var calibration: CalibrationViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
@@ -137,6 +139,12 @@ final class TargetWorkflowViewModel {
wizard: wizard, wizard: wizard,
environment: environment environment: environment
) )
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
profile: self.profile,
environment: environment
)
reloadPresets() reloadPresets()
} }
@@ -243,6 +251,43 @@ final class TargetWorkflowViewModel {
// MARK: - Issue 8: resume an existing target // MARK: - Issue 8: resume an existing target
/// `#btn-import-dataset` open a measured dataset, write a canonical
/// `.ti3` to the working directory, and set the target (issue #30).
func importMeasurementDataset() {
let url = UITestHooks.isEnabled
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
guard let url else { return }
do {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
wizard.showNotice("Choose a working directory before importing.", kind: .warning)
return
}
let stem = url.deletingPathExtension().lastPathComponent
let output = directory.appendingPathComponent("\(stem).ti3")
try CGATSWriter.write(dataset, to: output)
wizard.setTarget(basename: stem, workingDirectory: directory)
wizard.refreshGating()
wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)")
if wizard.isUnlocked(.verifyInstall) {
wizard.go(to: .verifyInstall)
} else if wizard.isUnlocked(.buildProfile) {
wizard.go(to: .buildProfile)
} else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
}
} catch let error as CGATSParseError {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
}
}
/// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103). /// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but /// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds. /// only when the sibling `.ti1` exists so the artefact gate holds.
@@ -302,6 +347,8 @@ final class TargetWorkflowViewModel {
customLabel: labelIsCustom ? customLabel : nil, customLabel: labelIsCustom ? customLabel : nil,
basename: wizard.basename, basename: wizard.basename,
metadata: labelMetadata), metadata: labelMetadata),
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil,
calibrationEmbedOnly: false,
basename: wizard.basename, basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory workingDirectory: wizard.effectiveWorkingDirectory
) )
+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,81 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
private func makeRunner() -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
}
private func makeTestDir() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("calibration-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
basename: "demo",
workingDirectory: testRoot
)
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
workingDirectory: testRoot,
outputURL: output
)
let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
workingDirectory: testRoot,
outputURL: output
)
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config)
}
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -0,0 +1,134 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
private static let canonicalCTI3 = """
CTI3
DESCRIPTOR "Sample target"
COLOR_REP "RGB"
DEVICE_CLASS "DISPLAY"
NUMBER_OF_FIELDS 11
NUMBER_OF_SETS 2
BEGIN_DATA_FORMAT
SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B
END_DATA_FORMAT
BEGIN_DATA
1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0
2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y
END_DATA_FORMAT
BEGIN_DATA
1 255 128 0 50 25
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L
END_DATA_FORMAT
BEGIN_DATA
1 50 50 50 50 50
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
}
@Test("Rejects malformed arity")
func rejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
BEGIN_DATA
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
}
}
@@ -0,0 +1,69 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CalibrationStore")
struct CalibrationStoreTests {
private static let sampleCal = """
CTI3
DESCRIPTOR "Test printer"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
MAX_TAC "300"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 30)
try await store.load(url: url)
let data = await store.data
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255])
}
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true)
}
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 9999)
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true)
}
}
@@ -0,0 +1,62 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
@Test("RGB baseline")
func rgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
whitePatches: 4,
basename: "demo",
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
}
@Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
whitePatches: 4,
includeNeutralEmphasis: true,
inkLimit: 320,
basename: "printer",
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
}
@Test("Rejects out-of-range steps")
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test")
}
}
@@ -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)
}
}
}
@@ -0,0 +1,59 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("PrintcalArgs")
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv")
func defaults() throws {
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
outputURL: tmp
)
let args = try PrintcalArgs.build(config: config)
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
@Test("All options and channel limits")
func allOptions() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
noInkLimit: true,
verify: true,
previousCalPath: "/tmp/old.cal",
totalInkLimit: 280,
channelLimits: [
PrintcalChannelLimit(channel: "C", percent: 95),
PrintcalChannelLimit(channel: "M", percent: 90)
]
)
let args = try PrintcalArgs.build(config: config)
#expect(args == [
"-v", "-e",
"-I", "-z",
"-a", "/tmp/old.cal",
"-m", "280.0",
"-xC", "95.0",
"-xM", "90.0",
"-o", "/tmp/out.cal",
"CAL_demo"
])
}
@Test("Rejects invalid per-channel limit")
func rejectsBadChannelLimit() {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
)
#expect(throws: (any Error).self) {
_ = try PrintcalArgs.build(config: config)
}
}
}
@@ -0,0 +1,72 @@
import XCTest
/// About and help chrome UI tests (issue #31).
@MainActor
final class AboutHelpUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() async throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
}
override func tearDown() async throws {
app?.terminate()
app = nil
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> 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
}
func testAboutDialogShowsVersionAndBuildDate() throws {
app.launch()
app.activate()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
_ = waitFor("aboutDialog", timeout: 10)
XCTAssertTrue(element("aboutVersion").exists)
XCTAssertTrue(element("aboutBuildDate").exists)
let close = app.buttons["closeAboutBtn"]
XCTAssertTrue(close.exists)
close.click()
XCTAssertFalse(element("aboutDialog").exists)
}
func testHelpOverlaysDoNotChangeSidebarHeight() throws {
app.launch()
app.activate()
let toggle = app.buttons["btnToggleAllHelp"]
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
let before = sidebar.frame
toggle.click()
let after = sidebar.frame
XCTAssertEqual(before.size.height, after.size.height,
"Toggling global help must not reflow the sidebar height.")
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
}
}
+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
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Mock printcal for Stage 0 calibration tests. Creates the .cal named by
# the -o argument in the process working directory. Exit code overridable
# via ICCERY_MOCK_PRINTCAL_EXIT.
output=""
basename=""
prev=""
for arg in "$@"; do
if [ "$prev" = "-o" ]; then
output="$arg"
fi
prev="$arg"
done
# If no -o, derive from the last positional argument.
if [ -z "$output" ]; then
for arg in "$@"; do basename="$arg"; done
output="$basename.cal"
fi
if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then
echo "mock printcal failure" >&2
exit "$ICCERY_MOCK_PRINTCAL_EXIT"
fi
echo "ideal power 1.0, device power 0.8"
touch "$output"
exit 0
@@ -0,0 +1,66 @@
import Foundation
import XCTest
/// Milestone 6 CGATS import UI tests (issue #30).
@MainActor
final class Milestone6CGATSUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var datasetURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
datasetURL = testRoot.appendingPathComponent("imported.csv")
try csv.write(to: datasetURL, atomically: true, encoding: .utf8)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_WORKDIR": testRoot.path,
"ICCERY_TEST_DATASET_IMPORT": datasetURL.path
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
}
/// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1`
/// Must fail if import presents a save panel or a `.ti1` filter.
func testImportUsesOpenPanelNotSaveTi1() throws {
app.launch()
// CGATS import needs a working directory; the env provides one.
XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5))
app.buttons["btnSelectWorkDir"].tap()
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
app.buttons["btn-import-dataset"].click()
// No save panel should appear; the open panel is stubbed under UI testing.
let savePanel = app.sheets.firstMatch
XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.")
// The dataset should be accepted and the user should advance to Stage 4.
XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10))
// The canonical .ti3 should be written next to the source file.
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path))
}
}
@@ -0,0 +1,80 @@
import Foundation
import XCTest
/// Milestone 6 Stage 0 printer calibration UI acceptance.
///
/// Uses the mock Argyll fixtures and UI-test environment flags so no real
/// instrument, printer, or modal file panel is required.
@MainActor
final class Milestone6CalibrationUITests: XCTestCase {
private var app: XCUIApplication!
private var testWorkDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testWorkDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cal-ui-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: testWorkDir,
withIntermediateDirectories: true
)
let binaryDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_ARGYLL_BINARY_DIR": binaryDir.path,
"ICCERY_TEST_WORKDIR": testWorkDir.path
]
app.launch()
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testWorkDir {
try? FileManager.default.removeItem(at: testWorkDir)
}
}
func testCalibrationDashboardOpensAndCanGenerate() throws {
// Set up a target and working directory on Stage 1.
let basename = app.textFields["targetBasename"]
XCTAssertTrue(basename.waitForExistence(timeout: 5))
basename.tap()
basename.typeText("DemoTarget")
let workDir = app.buttons["btnSelectWorkDir"]
XCTAssertTrue(workDir.waitForExistence(timeout: 5))
workDir.tap()
let generate = app.buttons["btnGenerate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5))
generate.tap()
// Open the calibration dashboard once Stage 2 is reached.
let advance = app.buttons["btnAdvanceToStage3"]
XCTAssertTrue(advance.waitForExistence(timeout: 10))
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
// Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1.
let calGenerate = app.buttons["btnCalGenerate"]
XCTAssertTrue(calGenerate.waitForExistence(timeout: 5))
calGenerate.tap()
// After generation the wizard should advance to Stage 2 (layout) because
// a CAL_ .ti1 now exists and the session is in calibration mode.
let layout = app.buttons["btnCreateLayout"]
XCTAssertTrue(layout.waitForExistence(timeout: 10))
}
}
@@ -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: |