Compare commits
9
Commits
c5fc89ec40
...
3e7706f689
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7706f689 | ||
|
|
8f43a93b64 | ||
|
|
f940ffd5a0 | ||
|
|
55ffe879ac | ||
|
|
120f713b46 | ||
|
|
f47b642c02 | ||
|
|
87e4f4fa8e | ||
|
|
b52720700f | ||
|
|
f027693fa9 |
@@ -21,6 +21,9 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
public var sessionMode: SessionMode
|
public var sessionMode: SessionMode
|
||||||
/// May differ from `basename` after a `.ti3` import (#94).
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
public var profileBasename: String?
|
public var profileBasename: String?
|
||||||
|
/// The pre-`CAL_` basename, persisted so a crash/relaunch can
|
||||||
|
/// restore the original (#29).
|
||||||
|
public var calibrationOriginalBasename: String = ""
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
currentStage: Int = WizardStage.generate.rawValue,
|
currentStage: Int = WizardStage.generate.rawValue,
|
||||||
@@ -28,7 +31,8 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
cwd: String = "",
|
cwd: String = "",
|
||||||
printerName: String? = nil,
|
printerName: String? = nil,
|
||||||
sessionMode: SessionMode = .profile,
|
sessionMode: SessionMode = .profile,
|
||||||
profileBasename: String? = nil
|
profileBasename: String? = nil,
|
||||||
|
calibrationOriginalBasename: String = ""
|
||||||
) {
|
) {
|
||||||
self.currentStage = currentStage
|
self.currentStage = currentStage
|
||||||
self.basename = basename
|
self.basename = basename
|
||||||
@@ -36,6 +40,7 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
self.printerName = printerName
|
self.printerName = printerName
|
||||||
self.sessionMode = sessionMode
|
self.sessionMode = sessionMode
|
||||||
self.profileBasename = profileBasename
|
self.profileBasename = profileBasename
|
||||||
|
self.calibrationOriginalBasename = calibrationOriginalBasename
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = WizardState()
|
public static let `default` = WizardState()
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ final class CalibrationViewModel {
|
|||||||
var isComputing = false
|
var isComputing = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
|
|
||||||
private var originalBasename: String = ""
|
|
||||||
|
|
||||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
@@ -53,7 +51,11 @@ final class CalibrationViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var calBasename: String {
|
private var calBasename: String {
|
||||||
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)"
|
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
|
||||||
|
let original = !wizard.calibrationOriginalBasename.isEmpty
|
||||||
|
? wizard.calibrationOriginalBasename
|
||||||
|
: wizard.basename
|
||||||
|
return "CAL_\(original)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var calOutputURL: URL? {
|
private var calOutputURL: URL? {
|
||||||
@@ -65,8 +67,14 @@ final class CalibrationViewModel {
|
|||||||
|
|
||||||
func generateTarget() {
|
func generateTarget() {
|
||||||
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||||
originalBasename = wizard.basename
|
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||||
wizard.basename = calBasename
|
if !wizard.basename.hasPrefix("CAL_") {
|
||||||
|
wizard.calibrationOriginalBasename = wizard.basename
|
||||||
|
} else if wizard.calibrationOriginalBasename.isEmpty {
|
||||||
|
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
|
||||||
|
}
|
||||||
|
let original = wizard.calibrationOriginalBasename
|
||||||
|
wizard.basename = "CAL_\(original)"
|
||||||
wizard.sessionMode = .calibration
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
isGenerating = true
|
isGenerating = true
|
||||||
@@ -79,7 +87,7 @@ final class CalibrationViewModel {
|
|||||||
whitePatches: whitePatches,
|
whitePatches: whitePatches,
|
||||||
includeNeutralEmphasis: includeNeutralEmphasis,
|
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||||
inkLimit: inkLimitValue,
|
inkLimit: inkLimitValue,
|
||||||
basename: originalBasename,
|
basename: original,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +110,7 @@ final class CalibrationViewModel {
|
|||||||
"Calibration target failed: \(error.localizedDescription)",
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
)
|
)
|
||||||
self.restoreProfileBasename()
|
self.wizard.restoreCalibration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +174,7 @@ final class CalibrationViewModel {
|
|||||||
self.profile.calibrationFile = url.path
|
self.profile.calibrationFile = url.path
|
||||||
self.profile.applyCalibration = self.applyToProfile
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
self.wizard.showNotice("Calibration curves computed.")
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
|
self.wizard.restoreCalibration()
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
@@ -190,18 +199,10 @@ final class CalibrationViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func returnToProfiling() {
|
func returnToProfiling() {
|
||||||
restoreProfileBasename()
|
wizard.restoreCalibration()
|
||||||
wizard.sessionMode = .profile
|
|
||||||
wizard.go(to: .generate)
|
wizard.go(to: .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreProfileBasename() {
|
|
||||||
if !originalBasename.isEmpty {
|
|
||||||
wizard.basename = originalBasename
|
|
||||||
originalBasename = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var inkLimitValue: Int? {
|
private var inkLimitValue: Int? {
|
||||||
guard colourSpace == .cmyk else { return nil }
|
guard colourSpace == .cmyk else { return nil }
|
||||||
return Int(inkLimit)
|
return Int(inkLimit)
|
||||||
|
|||||||
@@ -3,6 +3,65 @@ import SceneKit
|
|||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
|
/// Builds a SceneKit geometry from a `GamutMesh` while dropping faces whose
|
||||||
|
/// indices are not backed by a vertex in the source mesh.
|
||||||
|
@MainActor
|
||||||
|
internal struct GamutSceneGeometryBuilder {
|
||||||
|
static func geometry(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
|
||||||
|
)
|
||||||
|
|
||||||
|
let vcount = mesh.vertices.count
|
||||||
|
var indices: [UInt32] = []
|
||||||
|
var validFaces: [GamutTriangle] = []
|
||||||
|
indices.reserveCapacity(mesh.faces.count * 3)
|
||||||
|
for face in mesh.faces {
|
||||||
|
guard Int(face.a) < vcount, Int(face.b) < vcount, Int(face.c) < vcount else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indices.append(face.a)
|
||||||
|
indices.append(face.b)
|
||||||
|
indices.append(face.c)
|
||||||
|
validFaces.append(face)
|
||||||
|
}
|
||||||
|
let data = indices.withUnsafeBytes { Data($0) }
|
||||||
|
let element = SCNGeometryElement(
|
||||||
|
data: data,
|
||||||
|
primitiveType: .triangles,
|
||||||
|
primitiveCount: validFaces.count,
|
||||||
|
bytesPerIndex: 4
|
||||||
|
)
|
||||||
|
|
||||||
|
let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element])
|
||||||
|
return (geometry, element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Native SceneKit 3D gamut viewer.
|
/// Native SceneKit 3D gamut viewer.
|
||||||
///
|
///
|
||||||
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
|
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
|
||||||
@@ -11,6 +70,7 @@ import simd
|
|||||||
/// (blue-yellow) is depth.
|
/// (blue-yellow) is depth.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@State private var viewModel: GamutViewModel
|
@State private var viewModel: GamutViewModel
|
||||||
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(profileGamURL: URL? = nil) {
|
||||||
@@ -22,7 +82,8 @@ struct GamutView: View {
|
|||||||
GamutSceneView(
|
GamutSceneView(
|
||||||
profileMesh: viewModel.profileMesh,
|
profileMesh: viewModel.profileMesh,
|
||||||
referenceMesh: viewModel.sRGBMesh,
|
referenceMesh: viewModel.sRGBMesh,
|
||||||
onReset: $viewModel.resetCamera
|
onReset: $viewModel.resetCamera,
|
||||||
|
onPause: $pause
|
||||||
)
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
@@ -56,6 +117,7 @@ struct GamutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, minHeight: 400)
|
.frame(minWidth: 500, minHeight: 400)
|
||||||
|
.onDisappear { pause() }
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("gamutView")
|
.accessibilityIdentifier("gamutView")
|
||||||
}
|
}
|
||||||
@@ -70,6 +132,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
var profileMesh: GamutMesh?
|
var profileMesh: GamutMesh?
|
||||||
var referenceMesh: GamutMesh?
|
var referenceMesh: GamutMesh?
|
||||||
var onReset: Binding<() -> Void>
|
var onReset: Binding<() -> Void>
|
||||||
|
var onPause: Binding<() -> Void>
|
||||||
|
|
||||||
func makeNSView(context: Context) -> SCNView {
|
func makeNSView(context: Context) -> SCNView {
|
||||||
let scnView = SCNView()
|
let scnView = SCNView()
|
||||||
@@ -98,9 +161,16 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
onReset.wrappedValue = { [weak coordinator] in
|
onReset.wrappedValue = { [weak coordinator] in
|
||||||
coordinator?.resetCamera()
|
coordinator?.resetCamera()
|
||||||
}
|
}
|
||||||
|
onPause.wrappedValue = { [weak coordinator] in
|
||||||
|
coordinator?.pause()
|
||||||
|
}
|
||||||
return coordinator
|
return coordinator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
|
nsView.isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
@@ -314,7 +384,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
// Structural outline: one line per triangle edge.
|
// Structural outline: one line per triangle edge.
|
||||||
var linePoints: [SIMD3<Float>] = []
|
var linePoints: [SIMD3<Float>] = []
|
||||||
for face in mesh.faces {
|
let vcount = mesh.vertices.count
|
||||||
|
for face in mesh.faces
|
||||||
|
where Int(face.a) < vcount && Int(face.b) < vcount && Int(face.c) < vcount {
|
||||||
let va = mesh.vertices[Int(face.a)].position
|
let va = mesh.vertices[Int(face.a)].position
|
||||||
let vb = mesh.vertices[Int(face.b)].position
|
let vb = mesh.vertices[Int(face.b)].position
|
||||||
let vc = mesh.vertices[Int(face.c)].position
|
let vc = mesh.vertices[Int(face.c)].position
|
||||||
@@ -337,51 +409,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
/// Uses data-backed `SCNGeometrySource` initializers; this is the only
|
/// Uses data-backed `SCNGeometrySource` initializers; this is the only
|
||||||
/// path that supports vertex colours through the `.color` semantic.
|
/// path that supports vertex colours through the `.color` semantic.
|
||||||
private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
|
private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
|
||||||
let positions = mesh.vertices.map { $0.position }
|
GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||||
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.
|
/// Shared helper for data-backed position sources.
|
||||||
@@ -399,6 +427,10 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
scnView?.isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
func resetCamera() {
|
func resetCamera() {
|
||||||
guard let scnView else { return }
|
guard let scnView else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import ICCeryCore
|
|||||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
///
|
///
|
||||||
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
||||||
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
|
/// `printerName`, `sessionMode`, `profileBasename`,
|
||||||
|
/// `calibrationOriginalBasename`) are persisted to
|
||||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
/// navigation is disk, not buttons.
|
/// navigation is disk, not buttons.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -35,6 +36,10 @@ final class WizardViewModel {
|
|||||||
var profileBasename: String? {
|
var profileBasename: String? {
|
||||||
didSet { if profileBasename != oldValue { persist() } }
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
|
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||||
|
var calibrationOriginalBasename: String {
|
||||||
|
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
@@ -59,6 +64,15 @@ final class WizardViewModel {
|
|||||||
self.printerName = s.printerName
|
self.printerName = s.printerName
|
||||||
self.sessionMode = s.sessionMode
|
self.sessionMode = s.sessionMode
|
||||||
self.profileBasename = s.profileBasename
|
self.profileBasename = s.profileBasename
|
||||||
|
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
||||||
|
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
||||||
|
// the original before the UI can do anything with it (#29).
|
||||||
|
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
|
||||||
|
basename = calibrationOriginalBasename
|
||||||
|
calibrationOriginalBasename = ""
|
||||||
|
sessionMode = .profile
|
||||||
|
stage = .generate
|
||||||
|
}
|
||||||
refreshGating()
|
refreshGating()
|
||||||
// A restored stage may have been locked since (#151).
|
// A restored stage may have been locked since (#151).
|
||||||
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
||||||
@@ -108,8 +122,25 @@ final class WizardViewModel {
|
|||||||
|
|
||||||
/// `navigateToStage(n)` — refuses locked forward moves with a
|
/// `navigateToStage(n)` — refuses locked forward moves with a
|
||||||
/// warning banner; backward is always allowed (docs/06).
|
/// warning banner; backward is always allowed (docs/06).
|
||||||
|
///
|
||||||
|
/// If the live basename has a `CAL_` prefix, only `.calibrate`,
|
||||||
|
/// `.layOutPrint`, and `.measure` are allowed; any other target is
|
||||||
|
/// refused and the original basename is restored (#29).
|
||||||
func go(to target: WizardStage) {
|
func go(to target: WizardStage) {
|
||||||
guard target != .calibrate else { enterCalibration(); return }
|
guard target != .calibrate else { enterCalibration(); return }
|
||||||
|
if basename.hasPrefix("CAL_") {
|
||||||
|
guard !calibrationOriginalBasename.isEmpty else {
|
||||||
|
showNotice(
|
||||||
|
"Cannot leave calibration — the original target name is missing.",
|
||||||
|
kind: .warning
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == .generate || target == .buildProfile || target == .verifyInstall {
|
||||||
|
restoreCalibration()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
||||||
stage = target
|
stage = target
|
||||||
} else {
|
} else {
|
||||||
@@ -121,6 +152,9 @@ final class WizardViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func enterCalibration() {
|
func enterCalibration() {
|
||||||
|
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
|
||||||
|
calibrationOriginalBasename = basename
|
||||||
|
}
|
||||||
sessionMode = .calibration
|
sessionMode = .calibration
|
||||||
stage = .calibrate
|
stage = .calibrate
|
||||||
}
|
}
|
||||||
@@ -130,6 +164,15 @@ final class WizardViewModel {
|
|||||||
stage = .generate
|
stage = .generate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore the original profile basename and leave calibration mode.
|
||||||
|
func restoreCalibration() {
|
||||||
|
if !calibrationOriginalBasename.isEmpty {
|
||||||
|
basename = calibrationOriginalBasename
|
||||||
|
calibrationOriginalBasename = ""
|
||||||
|
}
|
||||||
|
sessionMode = .profile
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the 3D gamut viewer (issue #28).
|
/// Open the 3D gamut viewer (issue #28).
|
||||||
func openGamut(profileGamURL: URL? = nil) {
|
func openGamut(profileGamURL: URL? = nil) {
|
||||||
self.gamutProfileURL = profileGamURL
|
self.gamutProfileURL = profileGamURL
|
||||||
@@ -177,7 +220,8 @@ final class WizardViewModel {
|
|||||||
cwd: workingDirectory?.path ?? "",
|
cwd: workingDirectory?.path ?? "",
|
||||||
printerName: printerName,
|
printerName: printerName,
|
||||||
sessionMode: sessionMode,
|
sessionMode: sessionMode,
|
||||||
profileBasename: profileBasename
|
profileBasename: profileBasename,
|
||||||
|
calibrationOriginalBasename: calibrationOriginalBasename
|
||||||
)
|
)
|
||||||
try? stateStore.save(state)
|
try? stateStore.save(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Testing
|
||||||
|
import SceneKit
|
||||||
|
import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
||||||
|
@Suite("Gamut scene geometry builder")
|
||||||
|
@MainActor
|
||||||
|
struct GamutGeometryBuilderTests {
|
||||||
|
|
||||||
|
@Test("Drops out-of-bounds faces from the element without crashing")
|
||||||
|
func dropsOutOfBoundsFaces() {
|
||||||
|
let white = GamutVertex(
|
||||||
|
lab: LabColor(l: 100, a: 0, b: 0),
|
||||||
|
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
||||||
|
)
|
||||||
|
let black = GamutVertex(
|
||||||
|
lab: LabColor(l: 0, a: 0, b: 0),
|
||||||
|
rgb: DisplayRGB(r: 0, g: 0, b: 0)
|
||||||
|
)
|
||||||
|
let mesh = GamutMesh(
|
||||||
|
vertices: [white, black],
|
||||||
|
faces: [
|
||||||
|
GamutTriangle(a: 0, b: 1, c: 0),
|
||||||
|
GamutTriangle(a: 0, b: 1, c: 99)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||||
|
|
||||||
|
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
||||||
|
/// attempt to navigate to a non-calibration stage that would use it.
|
||||||
|
@Suite("WizardCalibrationSession")
|
||||||
|
@MainActor
|
||||||
|
struct WizardCalibrationSessionTests {
|
||||||
|
|
||||||
|
private func tempURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-cal-state-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent("wizard_state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tempDir() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-cal-dir-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
|
||||||
|
func relaunchRestoresOriginal() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
let store = WizardStateStore(fileURL: url)
|
||||||
|
var saved = WizardState(
|
||||||
|
currentStage: WizardStage.calibrate.rawValue,
|
||||||
|
basename: "CAL_DemoTarget",
|
||||||
|
cwd: "/tmp/charts",
|
||||||
|
sessionMode: .calibration,
|
||||||
|
calibrationOriginalBasename: "DemoTarget"
|
||||||
|
)
|
||||||
|
try store.save(saved)
|
||||||
|
|
||||||
|
let model = WizardViewModel(stateStore: store)
|
||||||
|
|
||||||
|
#expect(model.basename == "DemoTarget")
|
||||||
|
#expect(model.calibrationOriginalBasename == "")
|
||||||
|
#expect(model.sessionMode == .profile)
|
||||||
|
#expect(model.stage == .generate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
|
||||||
|
func goToBuildProfileRefusesAndRestores() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = tempURL()
|
||||||
|
let store = WizardStateStore(fileURL: url)
|
||||||
|
let model = WizardViewModel(stateStore: store)
|
||||||
|
|
||||||
|
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
|
||||||
|
model.calibrationOriginalBasename = "DemoTarget"
|
||||||
|
model.basename = "CAL_DemoTarget"
|
||||||
|
model.sessionMode = .calibration
|
||||||
|
model.stage = .calibrate
|
||||||
|
|
||||||
|
model.go(to: .buildProfile)
|
||||||
|
|
||||||
|
#expect(model.basename == "DemoTarget")
|
||||||
|
#expect(model.calibrationOriginalBasename == "")
|
||||||
|
#expect(model.sessionMode == .profile)
|
||||||
|
#expect(model.stage == .calibrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
|
||||||
|
func goToLayoutStaysCal() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = tempURL()
|
||||||
|
let store = WizardStateStore(fileURL: url)
|
||||||
|
let model = WizardViewModel(stateStore: store)
|
||||||
|
|
||||||
|
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
|
||||||
|
model.calibrationOriginalBasename = "DemoTarget"
|
||||||
|
model.basename = "CAL_DemoTarget"
|
||||||
|
model.sessionMode = .calibration
|
||||||
|
model.stage = .calibrate
|
||||||
|
|
||||||
|
model.go(to: .layOutPrint)
|
||||||
|
|
||||||
|
#expect(model.basename == "CAL_DemoTarget")
|
||||||
|
#expect(model.calibrationOriginalBasename == "DemoTarget")
|
||||||
|
#expect(model.sessionMode == .calibration)
|
||||||
|
#expect(model.stage == .layOutPrint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ struct WizardStateStoreTests {
|
|||||||
s.cwd = "/tmp/charts"
|
s.cwd = "/tmp/charts"
|
||||||
s.sessionMode = .calibration
|
s.sessionMode = .calibration
|
||||||
s.profileBasename = "imported"
|
s.profileBasename = "imported"
|
||||||
|
s.calibrationOriginalBasename = "pre-cal"
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
#expect(store.load() == s)
|
#expect(store.load() == s)
|
||||||
}
|
}
|
||||||
|
|||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
# Pre-UAT tester kit
|
||||||
|
|
||||||
|
This document is the canonical pre-UAT runbook. It is a **docs-only** slice; it does not change product code, `project.yml`, the build plan, or CI workflows.
|
||||||
|
|
||||||
|
## Builds that can be used for UAT
|
||||||
|
|
||||||
|
Only two artefacts are valid test builds:
|
||||||
|
|
||||||
|
1. **Debug built from this repository** (`xcodegen` → Debug → `xcodebuild test`/run).
|
||||||
|
2. **A notarized release DMG** produced by `scripts/package-release.sh`.
|
||||||
|
|
||||||
|
Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **not** test builds. Do not run them on a clean Mac or Gatekeeper will kill them (`Killed: 9`).
|
||||||
|
|
||||||
|
## Local Debug build & unit tests
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
||||||
|
xcodegen generate --project .
|
||||||
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
|
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
|
||||||
|
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `CODE_SIGNING_ALLOWED=YES` and `CODE_SIGN_IDENTITY='-'` (ad-hoc). `CODE_SIGNING_ALLOWED=NO` is wrong: the app and test bundles will not be signed and `xctest` cannot be injected into the test host.
|
||||||
|
|
||||||
|
After `fetch-argyll.sh`, verify that sidecars are signed:
|
||||||
|
|
||||||
|
```
|
||||||
|
codesign -dvv Vendor/Argyll/macos-universal/instlist
|
||||||
|
codesign -dvv Vendor/Argyll/macos-universal/chartread
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
|
||||||
|
|
||||||
|
## Release build / notarized DMG
|
||||||
|
|
||||||
|
`scripts/package-release.sh` reads these environment variables:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `CODESIGN_IDENTITY` | Developer ID Application identity name (omit or `-` for ad-hoc) |
|
||||||
|
| `DEVELOPMENT_TEAM` | Apple development team ID, passed through to `xcodebuild` |
|
||||||
|
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
|
||||||
|
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
|
||||||
|
| `APPLE_TEAM_ID` | Team ID for `notarytool` |
|
||||||
|
|
||||||
|
Invocation for a signed, notarized DMG:
|
||||||
|
|
||||||
|
```
|
||||||
|
export CODESIGN_IDENTITY="Developer ID Application: Gronod (TEAMID)"
|
||||||
|
export DEVELOPMENT_TEAM="TEAMID"
|
||||||
|
export NOTARIZE_APPLE_ID="apple-id@example.com"
|
||||||
|
export NOTARIZE_PASSWORD="abcd-efgh-ijkl-mnop"
|
||||||
|
export APPLE_TEAM_ID="TEAMID"
|
||||||
|
|
||||||
|
scripts/package-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will emit `ICCery-<version>-<build>.dmg`. After mounting, verify Gatekeeper acceptance:
|
||||||
|
|
||||||
|
```
|
||||||
|
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
And inside the mounted app bundle:
|
||||||
|
|
||||||
|
```
|
||||||
|
codesign -dvv --strict ICCery.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tester paths
|
||||||
|
|
||||||
|
### Week 1
|
||||||
|
|
||||||
|
Run these in order. Do not remap letters to “run unit tests”.
|
||||||
|
|
||||||
|
#### A — Stage 1 generate → Stage 2 print one page → cancel
|
||||||
|
|
||||||
|
1. Launch Debug build or notarized DMG.
|
||||||
|
2. Choose a target basename and working directory.
|
||||||
|
3. Stage 1: press **Generate target** (`targen`).
|
||||||
|
4. Stage 2: press **Print one page**.
|
||||||
|
5. Cancel the print panel.
|
||||||
|
|
||||||
|
**Pass:** the print panel appears and the app returns cleanly. The `printTask` is retained and not dropped.
|
||||||
|
|
||||||
|
#### B — Handheld chartread one sheet → Finish & Average
|
||||||
|
|
||||||
|
1. From Stage 2, press **Print chart pages** (or print the full target).
|
||||||
|
2. Stage 3: choose **Handheld read**.
|
||||||
|
3. Read one full sheet, then press **Finish & Average**.
|
||||||
|
|
||||||
|
**Pass:** a `.ti3` file is produced in the working directory.
|
||||||
|
|
||||||
|
#### C — Create profile, Apply Calibration off
|
||||||
|
|
||||||
|
1. Stage 4: turn **Apply Calibration** off.
|
||||||
|
2. Press **Create profile**.
|
||||||
|
3. If `iccgamut` fails or the `.gam` is missing, an info banner appears.
|
||||||
|
|
||||||
|
**Pass:** the app advances to Stage 5 regardless of the `iccgamut` outcome. This is issue #24: `.gam` failure is informational, not fatal.
|
||||||
|
|
||||||
|
#### D — Create profile, Apply Calibration on, missing/bad .cal
|
||||||
|
|
||||||
|
1. Stage 0: enter a printer calibration and let it complete (or hand-create a `CAL_<something>.cal`).
|
||||||
|
2. Stage 4: turn **Apply Calibration** on.
|
||||||
|
3. Rename or delete the `.cal` file so it is missing or unreadable.
|
||||||
|
4. Press **Create profile**.
|
||||||
|
|
||||||
|
**Pass:** the app **does not** advance to Stage 5; `applycal` failure blocks the profile creation.
|
||||||
|
|
||||||
|
#### E — Stage 5 profcheck + user vs system install / overwrite
|
||||||
|
|
||||||
|
1. Stage 5: press **Run profcheck**.
|
||||||
|
2. Press **Install profile** and choose **User install**.
|
||||||
|
3. Re-run **Install profile** and choose **System install** (or vice-versa).
|
||||||
|
4. When prompted, confirm overwrite.
|
||||||
|
|
||||||
|
**Pass:** `profcheck` completes, and the profile is installed/updated in the chosen scope.
|
||||||
|
|
||||||
|
### Week 2
|
||||||
|
|
||||||
|
#### F — Stage 0 full loop; return to profile basename; printtarg -K only on non-CAL_ name
|
||||||
|
|
||||||
|
1. Enter Stage 0 (printer calibration) and complete the full loop.
|
||||||
|
2. Verify that the live basename returns to the original profile basename (not `CAL_*`).
|
||||||
|
3. Stage 2 print options: `printtarg -K`/`-I` must **not** be present for any `CAL_*` basename.
|
||||||
|
|
||||||
|
**Pass:** `CAL_` never bleeds into `colprof`; `CAL_*.ti3` is not fed to `colprof`.
|
||||||
|
|
||||||
|
#### G — Import Dataset open panel
|
||||||
|
|
||||||
|
1. From any unlocked point where **Import dataset** is available, open the panel.
|
||||||
|
2. Select a file with one of: `.ti3`, `.txt`, `.cgats`, `.csv`.
|
||||||
|
|
||||||
|
**Pass:** the imported `.ti3`/dataset is accepted; the wizard skips the print and measure stages for the imported file. Do not use a `.ti1` file for “save” import here.
|
||||||
|
|
||||||
|
#### H — View Gamut + sRGB overlay + reset camera
|
||||||
|
|
||||||
|
1. Stage 5: press **View gamut**.
|
||||||
|
2. Verify the sRGB reference mesh is overlaid.
|
||||||
|
3. Click **Reset view** (or press `R`) to reset the camera.
|
||||||
|
4. Close the gamut sheet.
|
||||||
|
|
||||||
|
**Pass:** no GPU hang, the camera resets to the default home position, and the app remains responsive.
|
||||||
|
|
||||||
|
## Known behaviours / policies
|
||||||
|
|
||||||
|
### #24 — `.gam` failure is an info banner
|
||||||
|
|
||||||
|
- `iccgamut` missing or failing after `colprof` does **not** fail `Create profile`.
|
||||||
|
- The Stage 5 gamut pane falls back to the bundled `sRGB.gam` with no profile mesh.
|
||||||
|
- `applycal` failing **does** fail `Create profile` when **Apply Calibration** is on.
|
||||||
|
|
||||||
|
### `CAL_` vs profile basename; Cancel vs Force Quit
|
||||||
|
|
||||||
|
- Entering Stage 0 from a non-`CAL_` basename stores the original profile basename.
|
||||||
|
- **Cancel** in the calibration sheet returns to the original basename.
|
||||||
|
- A **Force Quit** mid-calibration leaves `CAL_*` behind; on relaunch the app restores the original profile basename.
|
||||||
|
- `CAL_` basenames cannot move to `generate`, `buildProfile`, or `verifyInstall`.
|
||||||
|
|
||||||
|
### Import skips print and measure
|
||||||
|
|
||||||
|
When a dataset is imported via the open panel, the wizard treats it as already measured. It must not re-print or re-measure the imported data.
|
||||||
|
|
||||||
|
## What is out of scope for pre-UAT
|
||||||
|
|
||||||
|
- No new product features (no #16 or M7 work).
|
||||||
|
- No `milestone/pre-uat` integration branch.
|
||||||
|
- No `project.yml` signing, sandbox, or deployment-target changes.
|
||||||
|
- No README or BUILD-PLAN rewrite.
|
||||||
|
- No CI workflow edits; this document is the single source of truth for the pre-UAT runbook.
|
||||||
Reference in New Issue
Block a user