Files
iccery-v2-mac/Sources/ICCery/CalibrationViewModel.swift
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00

201 lines
6.7 KiB
Swift

import Combine
import Foundation
import SwiftUI
import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves.
@MainActor
final class CalibrationViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
let environment: AppEnvironment
// MARK: - Form state
@Published var colourSpace: ColourSpace = .cmyk
@Published var steps: Int = 21
@Published var whitePatches: Int = 4
@Published var includeNeutralEmphasis: Bool = false
@Published var inkLimit: String = "320"
@Published var applyToProfile: Bool = false
@Published var computedCalURL: URL?
@Published var calibrationLog: [String] = []
@Published var isGenerating = false
@Published var isComputing = false
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 identity: CalibrationIdentity {
CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
}
private var calBasename: String { identity.calibrationBasename }
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 }
// Snapshot the original (pre-CAL_) basename before changing the live one.
let identity = CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
wizard.calibrationOriginalBasename = identity.originalBasename
wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue,
basename: identity.originalBasename,
workingDirectory: cwd
)
Task { @MainActor in
do {
_ = try await ProcessRunSupport.runLogged(
setRunning: { self.isGenerating = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
)
self.wizard.restoreCalibration()
}
}
}
// 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) {
wizard.showNotice(
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return
}
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
outputURL: outputURL,
noInkLimit: false,
verify: false,
previousCalPath: nil,
totalInkLimit: inkLimitValue.map { Double($0) },
channelLimits: []
)
Task { @MainActor in
do {
let url = try await ProcessRunSupport.runLogged(
setRunning: { self.isComputing = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
self.computedCalURL = url
self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration()
} catch {
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() {
wizard.restoreCalibration()
wizard.go(to: .generate)
}
private var inkLimitValue: Int? {
guard colourSpace == .cmyk else { return nil }
return Int(inkLimit)
}
}