Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4491d0e9 | ||
|
|
ff883a43b2 | ||
|
|
34bef9a78e | ||
|
|
e929576c31 | ||
|
|
0ee609be1c | ||
|
|
a73c6c0b97 | ||
|
|
563f0e5d4a | ||
|
|
cd4665e7a9 | ||
|
|
f78da50a59 |
@@ -9,6 +9,7 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
case chartreadFailed(String)
|
||||
case averageFailed(String)
|
||||
case colprofFailed(String)
|
||||
case printcalFailed(String)
|
||||
case applycalFailed(String)
|
||||
case iccgamutFailed(String)
|
||||
case profcheckFailed(String)
|
||||
@@ -30,6 +31,8 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
return "Averaging failed: \(reason)"
|
||||
case .colprofFailed(let reason):
|
||||
return "Profile creation failed: \(reason)"
|
||||
case .printcalFailed(let reason):
|
||||
return "Calibration curve computation failed: \(reason)"
|
||||
case .applycalFailed(let reason):
|
||||
return "Apply calibration failed: \(reason)"
|
||||
case .iccgamutFailed(let reason):
|
||||
@@ -755,6 +758,82 @@ public struct ArgyllRunner: Sendable {
|
||||
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.
|
||||
|
||||
@@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable {
|
||||
|
||||
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||
public func referenceGamut(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
let stem = name.hasSuffix(".gam") ? name : "\(name).gam"
|
||||
return bundledRoot
|
||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.appendingPathComponent(stem, isDirectory: false)
|
||||
}
|
||||
|
||||
/// Whether the resolved path exists and is executable.
|
||||
|
||||
@@ -15,14 +15,26 @@ public enum ArtefactFiles {
|
||||
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(
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String) {
|
||||
) -> (version: String, build: String, buildDate: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
return (
|
||||
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
||||
info["CFBundleVersion"] as? String ?? "0"
|
||||
)
|
||||
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||
let build = 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
|
||||
/// Absolute path of the profile file when present.
|
||||
public var profilePath: URL?
|
||||
/// Absolute path of the `.gam` gamut mesh when present (issue #28).
|
||||
public var gamPath: URL?
|
||||
|
||||
public init(
|
||||
stage1Complete: Bool = false,
|
||||
stage2Complete: Bool = false,
|
||||
stage3Complete: Bool = false,
|
||||
stage4Complete: Bool = false,
|
||||
profilePath: URL? = nil
|
||||
profilePath: URL? = nil,
|
||||
gamPath: URL? = nil
|
||||
) {
|
||||
self.stage1Complete = stage1Complete
|
||||
self.stage2Complete = stage2Complete
|
||||
self.stage3Complete = stage3Complete
|
||||
self.stage4Complete = stage4Complete
|
||||
self.profilePath = profilePath
|
||||
self.gamPath = gamPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +49,10 @@ public enum ArtefactProbe {
|
||||
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||
out.stage4Complete = true
|
||||
out.profilePath = profile
|
||||
let gam = artefact(basename, "gam", cwd)
|
||||
if exists(gam, fm: fileManager) {
|
||||
out.gamPath = gam
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
///
|
||||
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
||||
/// against an isolated root and never touch the developer's state.
|
||||
/// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
|
||||
/// data so UI tests run against an isolated root and never touch the
|
||||
/// developer's state.
|
||||
public static var appDataDir: URL {
|
||||
#if DEBUG
|
||||
if let root = testRoot {
|
||||
@@ -42,10 +43,31 @@ public enum AppPaths {
|
||||
}
|
||||
|
||||
#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? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||
!raw.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||
!raw.isEmpty {
|
||||
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
|
||||
|
||||
|
||||
@@ -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 11–51, got: \(steps)"
|
||||
case .invalidInkLimit(let limit):
|
||||
return "Calibration ink limit must be 200–400, 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 0–100, 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import SwiftUI
|
||||
import SceneKit
|
||||
import ICCeryCore
|
||||
import simd
|
||||
|
||||
/// Native SceneKit 3D gamut viewer.
|
||||
///
|
||||
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
|
||||
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
|
||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
||||
/// (blue-yellow) is depth.
|
||||
struct GamutView: View {
|
||||
@State private var viewModel: GamutViewModel
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
init(profileGamURL: URL? = nil) {
|
||||
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
GamutSceneView(
|
||||
profileMesh: viewModel.profileMesh,
|
||||
referenceMesh: viewModel.sRGBMesh,
|
||||
onReset: $viewModel.resetCamera
|
||||
)
|
||||
.focusable()
|
||||
.focused($isFocused)
|
||||
.focusEffectDisabled()
|
||||
.onKeyPress(.init("R"), action: {
|
||||
viewModel.resetCamera()
|
||||
return .handled
|
||||
})
|
||||
.onAppear { isFocused = true }
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { viewModel.resetCamera() }) {
|
||||
Text("Reset view")
|
||||
}
|
||||
.accessibilityIdentifier("btnResetGamutCamera")
|
||||
.padding(8)
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Text(viewModel.status)
|
||||
.font(.caption)
|
||||
.padding(8)
|
||||
.background(.thinMaterial)
|
||||
.cornerRadius(6)
|
||||
.accessibilityIdentifier("gamutStatusText")
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("gamutView")
|
||||
}
|
||||
}
|
||||
|
||||
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
|
||||
/// one or two ``GamutMesh`` values.
|
||||
///
|
||||
/// Scene construction and camera reset are coordinated through a typed callback
|
||||
/// binding owned by the view model.
|
||||
private struct GamutSceneView: NSViewRepresentable {
|
||||
var profileMesh: GamutMesh?
|
||||
var referenceMesh: GamutMesh?
|
||||
var onReset: Binding<() -> Void>
|
||||
|
||||
func makeNSView(context: Context) -> SCNView {
|
||||
let scnView = SCNView()
|
||||
scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1)
|
||||
scnView.allowsCameraControl = true
|
||||
scnView.showsStatistics = false
|
||||
scnView.antialiasingMode = .multisampling4X
|
||||
|
||||
let scene = SCNScene()
|
||||
scnView.scene = scene
|
||||
scnView.autoenablesDefaultLighting = false
|
||||
|
||||
context.coordinator.scnView = scnView
|
||||
context.coordinator.scene = scene
|
||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
||||
|
||||
return scnView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SCNView, context: Context) {
|
||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
let coordinator = Coordinator()
|
||||
onReset.wrappedValue = { [weak coordinator] in
|
||||
coordinator?.resetCamera()
|
||||
}
|
||||
return coordinator
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject {
|
||||
weak var scnView: SCNView?
|
||||
weak var scene: SCNScene?
|
||||
|
||||
private let profileNode = SCNNode()
|
||||
private let referenceGroup = SCNNode()
|
||||
private let axisNode = SCNNode()
|
||||
private let cameraNode: SCNNode = {
|
||||
let node = SCNNode()
|
||||
node.camera = SCNCamera()
|
||||
node.camera?.zFar = 2000
|
||||
return node
|
||||
}()
|
||||
|
||||
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
|
||||
guard let scene else { return }
|
||||
|
||||
// Rebuild from scratch on every mesh change to avoid stale geometry.
|
||||
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
scene.rootNode.addChildNode(axisNode)
|
||||
scene.rootNode.addChildNode(profileNode)
|
||||
scene.rootNode.addChildNode(referenceGroup)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
buildAxisScaffold()
|
||||
|
||||
if let profile {
|
||||
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
|
||||
} else {
|
||||
profileNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
}
|
||||
|
||||
if let reference {
|
||||
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
|
||||
referenceGroup.addChildNode(referenceMeshNode(reference))
|
||||
}
|
||||
|
||||
addLights(to: scene)
|
||||
resetCamera()
|
||||
}
|
||||
|
||||
private func addLights(to scene: SCNScene) {
|
||||
let ambient = SCNNode()
|
||||
ambient.light = SCNLight()
|
||||
ambient.light?.type = .ambient
|
||||
ambient.light?.color = NSColor.white
|
||||
ambient.light?.intensity = 750
|
||||
scene.rootNode.addChildNode(ambient)
|
||||
|
||||
let key = SCNNode()
|
||||
key.light = SCNLight()
|
||||
key.light?.type = .directional
|
||||
key.light?.color = NSColor.white
|
||||
key.light?.intensity = 800
|
||||
key.position = SCNVector3(150, 250, 150)
|
||||
key.look(at: SCNVector3(0, 50, 0))
|
||||
scene.rootNode.addChildNode(key)
|
||||
|
||||
let fill = SCNNode()
|
||||
fill.light = SCNLight()
|
||||
fill.light?.type = .directional
|
||||
fill.light?.color = NSColor.white
|
||||
fill.light?.intensity = 350
|
||||
fill.position = SCNVector3(-120, -80, -120)
|
||||
fill.look(at: SCNVector3(0, 50, 0))
|
||||
scene.rootNode.addChildNode(fill)
|
||||
}
|
||||
|
||||
private func buildAxisScaffold() {
|
||||
axisNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Bounding box: a*,b* ±128, L* 0–100.
|
||||
let box = buildWireBox(size: SIMD3<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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ final class ProfileWorkflowViewModel {
|
||||
var colprofProgress: String?
|
||||
var lastError: String?
|
||||
var createdProfileURL: URL?
|
||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||
var createdGamutURL: URL?
|
||||
|
||||
// MARK: - Stage 4/5 calibration (issue #24)
|
||||
|
||||
@@ -84,12 +86,17 @@ final class ProfileWorkflowViewModel {
|
||||
restoreCreatedProfileURL()
|
||||
}
|
||||
|
||||
/// Restores `createdProfileURL` from the wizard artefacts or by probing
|
||||
/// the working directory for an existing `.icc`/`.icm` (#52).
|
||||
/// Restores `createdProfileURL` and `createdGamutURL` from the wizard
|
||||
/// artefacts or by probing the working directory (#52, #28).
|
||||
func restoreCreatedProfileURL() {
|
||||
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
|
||||
createdProfileURL = wizard.artefacts.profilePath
|
||||
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
|
||||
createdGamutURL = wizard.artefacts.gamPath
|
||||
?? ArtefactProbe.artefact(wizard.basename, "gam", cwd)
|
||||
if let gam = createdGamutURL, !FileManager.default.fileExists(atPath: gam.path) {
|
||||
createdGamutURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
@@ -190,6 +197,7 @@ final class ProfileWorkflowViewModel {
|
||||
colprofProgress = nil
|
||||
lastError = nil
|
||||
createdProfileURL = nil
|
||||
createdGamutURL = nil
|
||||
|
||||
let runner = environment.runner
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -223,12 +231,13 @@ final class ProfileWorkflowViewModel {
|
||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||
do {
|
||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
self.colprofLog.append("Gamut mesh extracted.")
|
||||
self.createdGamutURL = gamURL
|
||||
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
||||
} catch {
|
||||
self.wizard.showNotice(
|
||||
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||
|
||||
@@ -8,6 +8,7 @@ struct RootView: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@State private var showingSettings = false
|
||||
@State private var showingAbout = false
|
||||
@State private var showingAllHelp = false
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
|
||||
@@ -16,7 +17,8 @@ struct RootView: View {
|
||||
SidebarView(
|
||||
workflow: workflow,
|
||||
onOpenSettings: { showingSettings = true },
|
||||
onOpenAbout: { showingAbout = true }
|
||||
onOpenAbout: { showingAbout = true },
|
||||
showingAllHelp: $showingAllHelp
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
@@ -48,10 +50,14 @@ struct RootView: View {
|
||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||
ManagePresetsDialog(workflow: workflow)
|
||||
}
|
||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
|
||||
.sheet(isPresented: $showingAbout) {
|
||||
AboutView { showingAbout = false }
|
||||
}
|
||||
.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)
|
||||
case .verifyInstall:
|
||||
Stage5View(model: workflow.profile)
|
||||
default:
|
||||
case .calibrate:
|
||||
CalibrationView(model: workflow.calibration)
|
||||
@unknown default:
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ struct SidebarView: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
@Binding var showingAllHelp: Bool
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
|
||||
@@ -22,12 +23,20 @@ struct SidebarView: View {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Settings")
|
||||
.helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
|
||||
.accessibilityIdentifier("openSettingsBtn")
|
||||
Button(action: onOpenAbout) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.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)
|
||||
|
||||
@@ -65,14 +74,21 @@ struct SidebarView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
||||
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.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)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
@@ -85,6 +85,7 @@ struct Stage1View: View {
|
||||
Button("Browse…") { workflow.browseForTargetFile() }
|
||||
.accessibilityIdentifier("btnBrowse")
|
||||
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
||||
.accessibilityIdentifier("btnSelectWorkDir")
|
||||
Button("Open Existing…") { workflow.openExistingTarget() }
|
||||
.accessibilityIdentifier("btnOpenExisting")
|
||||
Button("Import Dataset…") { workflow.importMeasurementDataset() }
|
||||
|
||||
@@ -146,6 +146,12 @@ struct Stage5View: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("View Gamut") {
|
||||
model.wizard.openGamut(profileGamURL: model.createdGamutURL)
|
||||
}
|
||||
.disabled(model.createdGamutURL == nil)
|
||||
.accessibilityIdentifier("btnViewGamut")
|
||||
|
||||
Button("Install Profile") { model.beginInstallProfile() }
|
||||
.disabled(model.createdProfileURL == nil)
|
||||
.accessibilityIdentifier("btnInstallProfile")
|
||||
|
||||
@@ -123,8 +123,10 @@ final class TargetWorkflowViewModel {
|
||||
/// across stage switches and can observe settings changes.
|
||||
var measurement: MeasurementWorkflowViewModel
|
||||
/// 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
|
||||
/// Stage 0 calibration workflow.
|
||||
var calibration: CalibrationViewModel!
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
@@ -137,6 +139,12 @@ final class TargetWorkflowViewModel {
|
||||
wizard: wizard,
|
||||
environment: environment
|
||||
)
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
profile: self.profile,
|
||||
environment: environment
|
||||
)
|
||||
reloadPresets()
|
||||
}
|
||||
|
||||
@@ -339,6 +347,8 @@ final class TargetWorkflowViewModel {
|
||||
customLabel: labelIsCustom ? customLabel : nil,
|
||||
basename: wizard.basename,
|
||||
metadata: labelMetadata),
|
||||
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil,
|
||||
calibrationEmbedOnly: false,
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory
|
||||
)
|
||||
|
||||
@@ -42,6 +42,10 @@ final class WizardViewModel {
|
||||
var notice: Notice?
|
||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||
private(set) var artefacts = StageArtefacts()
|
||||
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
||||
var showingGamutViewer = false
|
||||
/// Optional `.gam` URL to show alongside the sRGB reference.
|
||||
var gamutProfileURL: URL?
|
||||
|
||||
private let stateStore: WizardStateStore
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
@@ -126,6 +130,12 @@ final class WizardViewModel {
|
||||
stage = .generate
|
||||
}
|
||||
|
||||
/// Open the 3D gamut viewer (issue #28).
|
||||
func openGamut(profileGamURL: URL? = nil) {
|
||||
self.gamutProfileURL = profileGamURL
|
||||
showingGamutViewer = true
|
||||
}
|
||||
|
||||
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
|
||||
/// If the current stage re-locked, fall back to the deepest unlocked.
|
||||
func windowDidBecomeKey() {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Mock iccgamut for Milestone 5 UI tests.
|
||||
# Mock iccgamut for Milestone 5/6 UI tests.
|
||||
# Writes {stem}.gam next to the profile path.
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
@@ -9,5 +9,9 @@ if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then
|
||||
fi
|
||||
stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//')
|
||||
dir=$(dirname "$last")
|
||||
touch "$dir/$stem.gam"
|
||||
if [ -n "${ICCERY_MOCK_GAMUT_SOURCE}" ] && [ -f "${ICCERY_MOCK_GAMUT_SOURCE}" ]; then
|
||||
cp "${ICCERY_MOCK_GAMUT_SOURCE}" "$dir/$stem.gam"
|
||||
else
|
||||
touch "$dir/$stem.gam"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
Executable
+25
@@ -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
|
||||
@@ -44,7 +44,10 @@ final class Milestone6CGATSUITests: XCTestCase {
|
||||
/// Must fail if import presents a save panel or a `.ti1` filter.
|
||||
func testImportUsesOpenPanelNotSaveTi1() throws {
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
// 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()
|
||||
@@ -54,8 +57,7 @@ final class Milestone6CGATSUITests: XCTestCase {
|
||||
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.
|
||||
_ = app.otherElements["stage-4"].waitForExistence(timeout: 10)
|
||||
XCTAssertTrue(app.otherElements["stage-4"].exists)
|
||||
XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10))
|
||||
|
||||
// The canonical .ti3 should be written next to the source file.
|
||||
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ targets:
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
- sdk: SceneKit.framework
|
||||
postBuildScripts:
|
||||
- name: Copy Argyll sidecars
|
||||
script: |
|
||||
|
||||
Reference in New Issue
Block a user