Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added

- Process calibration shows a preview of the chart page it will print (before Start, from the
settings; after, this round's page through its curves) with its size on film.
- Process calibration can continue a kept run ("Continue a Previous Run"), start a new run from
an earlier run's latest curves or from .oqproc files ("Start from"), and go back to the settings
with "New Run…" to adjust a finished calibration further.
- The process run's print-settings note can be edited after Start; it is saved with the run and
recorded in later curves.
- Darkroom marks: a black guide line round the image and registration marks (a crosshair target
on each side, crop marks at the corners), each a checkbox. On negatives they go in the safe edge
in black ink, which shows under a red safelight (red and orange negatives all but vanish there)
Expand Down Expand Up @@ -55,6 +63,9 @@ versions follow [Semantic Versioning](https://semver.org/).
`openquad negative --outline 0.1`.

### Changed

- The process print-settings note no longer defaults to "600 x 600"; it starts empty, since the
quality and resolution are chosen in the print panel.
- Stacked process charts sit closer together: 0.1 in of film between them instead of 0.3 in.

## [0.1.0-beta.3] - 2026-10-27
Expand Down
30 changes: 30 additions & 0 deletions swift/OpenQuadApp/Sources/OpenQuadUI/DarkroomControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,33 @@ struct SheetSize: View {
}
}
}

/// A small picture of a sheet as it will print, with its size.
struct SheetPreview: View {
let image: RGBImage?
/// Pixels per inch of `image`.
let ppi: Double
let units: LengthUnit
let title: String

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.headline)
if let image, let picture = image.cgImage(eightBit: true) {
Image(decorative: picture, scale: 1)
.resizable()
.interpolation(.medium)
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 360, alignment: .topLeading)
.overlay(Rectangle().stroke(.separator))
Text("\(units.size(Double(image.width) / ppi, Double(image.height) / ppi)) on film")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Choose 1 to 5 colours to see the page.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
198 changes: 192 additions & 6 deletions swift/OpenQuadApp/Sources/OpenQuadUI/Wizards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,13 @@ public final class CalibrationWizardModel: WizardModel {
@MainActor
@Observable
public final class ProcessWizardModel: WizardModel {
public static let defaultPrinting = "Fine glossy paper, Extra detail, Diffusion, 600 x 600"
public var name = "carbon-office-film"
public var colours: Set<String> = ["green", "orange"]
public var mirror = false
public var film = "inkjet-coated office transparency"
public var printing = defaultPrinting
/// A note of how the film is printed (the print panel's paper type, quality and
/// resolution), kept with the run so every round is printed the same way.
public var printing = ""
public var useColour = "green"
/// Darkroom marks in black in the charts' safe edges.
public var guide = false
Expand All @@ -322,6 +323,10 @@ public final class ProcessWizardModel: WizardModel {
@ObservationIgnored var rememberSize: (((Double, Double)?) -> Void)?
/// Lengths shown in the window (the app's setting).
public var units = LengthUnit.inches
/// Curves the next run starts from, by colour (an earlier run's latest round or
/// .oqproc files), so a finished calibration can be adjusted further; empty for none.
public private(set) var startingCurves: [String: ProcessCurve] = [:]
public private(set) var startingName: String?
public private(set) var run: ProcessRun?
public private(set) var readings: [(String, ChartReading)] = []
public private(set) var result = ""
Expand All @@ -337,13 +342,101 @@ public final class ProcessWizardModel: WizardModel {
public var printed: Bool { run?.current.job != nil }
public var measured: Bool { run.map { $0.current.readings.count == $0.colours.count } ?? false }

/// Process runs kept in the folder, newest first.
public var savedRuns: [CalibrationWizardModel.SavedRun] {
let fm = FileManager.default
guard let folders = try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) else { return [] }
let format = DateFormatter()
format.dateFormat = "d MMM, HH:mm"
let runs = folders.compactMap { url -> CalibrationWizardModel.SavedRun? in
guard let run = try? ProcessRun.resume(url),
let modified = (try? url.appendingPathComponent("run.json").resourceValues(
forKeys: [.contentModificationDateKey]))?.contentModificationDate else { return nil }
let label = "\(run.name), \(run.colours.joined(separator: " over ")): round \(run.current.version) "
+ "(\(format.string(from: modified)))"
return CalibrationWizardModel.SavedRun(folder: url, label: label, modified: modified)
}
return Array(runs.sorted { $0.modified > $1.modified }.prefix(10))
}

/// Carry on with a kept run: its settings, its latest round and any readings.
public func resume(_ saved: URL) {
do {
let run = try ProcessRun.resume(saved)
self.run = run
name = run.name
colours = Set(run.colours)
mirror = run.mirror
film = run.film
printing = run.printing
guide = run.guide
registration = run.registration
let remember = rememberSize
rememberSize = nil // the run's own size, not a new preference
sheetSize = run.size
rememberSize = remember
if !run.scannerHost.isEmpty { scannerHost = run.scannerHost }
let read = (try? run.readings()) ?? [:]
readings = run.colours.compactMap { c in read[c].map { (c, $0) } }
useColour = run.bestColour() ?? run.colours[0]
result = ""
error = nil
say("Continuing \(run.name), round \(run.current.version).")
} catch {
self.error = "Couldn't continue that run: \(error)"
}
}

/// Start the next run from a kept run's latest curves (its colours chosen too).
public func startFrom(run saved: URL) {
do {
let run = try ProcessRun.resume(saved)
startingCurves = Dictionary(uniqueKeysWithValues: try run.colours.map { ($0, try run.curve($0)) })
startingName = "\(run.name) round \(run.current.version)"
colours = Set(run.colours)
if film.isEmpty { film = run.film }
say("Starting from \(startingName!): \(run.colours.joined(separator: ", ")).")
} catch {
self.error = "Couldn't read that run's curves: \(error)"
}
}

/// Start the next run from .oqproc process curves (one per colour; a later file wins).
public func startFrom(files: [URL]) {
do {
let curves = try files.map { try ProcessCurve.decode(Data(contentsOf: $0)) }
guard !curves.isEmpty else { return }
startingCurves = Dictionary(curves.map { ($0.colour, $0) }) { _, later in later }
startingName = files.count == 1 ? files[0].deletingPathExtension().lastPathComponent : "\(files.count) curve files"
colours.formUnion(startingCurves.keys)
say("Starting from \(startingName!): \(Negative.colourOrder.filter(startingCurves.keys.contains).joined(separator: ", ")).")
} catch {
self.error = "Couldn't read those curves: \(error)"
}
}

public func startFromScratch() {
startingCurves = [:]
startingName = nil
}

/// Back to the settings to start another run (this one stays kept, to continue).
public func closeRun() {
guard let run else { return }
self.run = nil
readings = []
result = ""
say("Put \(run.name) aside; continue it any time.")
}

public func start() {
let name = self.name.trimmingCharacters(in: .whitespaces)
do {
let run = try ProcessRun.start(folder: folder.appendingPathComponent("\(name)-\(Self.timestamp())"), name: name,
colours: orderedColours, scannerHost: scannerHost, mirror: mirror,
film: film.trimmingCharacters(in: .whitespaces),
printing: printing.trimmingCharacters(in: .whitespaces), size: sheetSize,
printing: printing.trimmingCharacters(in: .whitespaces),
starting: startingCurves.filter { orderedColours.contains($0.key) }, size: sheetSize,
guide: guide, registration: registration)
let page = try run.makePage()
self.run = run
Expand All @@ -364,6 +457,35 @@ public final class ProcessWizardModel: WizardModel {
"OpenQuad process chart: \(run.name) v\(run.current.version)")
}

/// Pixels per inch of `preview()`.
public static let previewPPI = 30.0

/// The chart page as it will print, small: this round's (through its curves) once
/// started, otherwise what Start would make from the settings. Nil with no colours.
public func preview() -> RGBImage? {
if let run {
guard let curves = try? run.colours.map({ try run.curve($0) }) else { return nil }
return Chart.page(colours: run.colours, processes: curves, mirror: run.mirror, dpi: Self.previewPPI,
scale: (try? run.scale()) ?? 1, guide: run.guide, registration: run.registration)
}
let colours = orderedColours
guard (1...5).contains(colours.count) else { return nil }
let scale = (try? ProcessRun.scale(count: colours.count, size: sheetSize)) ?? 1
return Chart.page(colours: colours, processes: colours.map { startingCurves[$0] }, mirror: mirror, dpi: Self.previewPPI, scale: scale, guide: guide,
registration: registration)
}

/// Keep an edited print-settings note with the run.
public func savePrinting() {
guard let run, run.printing != printing.trimmingCharacters(in: .whitespaces) else { return }
do {
try run.setPrinting(printing)
say("Print settings: \(run.printing.isEmpty ? "none noted" : run.printing)")
} catch {
self.error = "\(error)"
}
}

public func printed(_ note: String) {
guard let run else { return }
do {
Expand Down Expand Up @@ -811,7 +933,11 @@ public struct ProcessWizardView: View {
Text([model.run?.colours.joined(separator: " over ") ?? "", model.film, model.mirror ? "mirrored" : "not mirrored",
"scanner \(model.scannerHost.isEmpty ? "not set" : model.scannerHost)"].joined(separator: " · "))
.foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
Form { printSettings }
.formStyle(.grouped)
.frame(height: 110)
} else {
previousRuns
Form {
TextField("Name", text: $model.name)
LabeledContent("Colours") {
Expand All @@ -826,7 +952,24 @@ public struct ProcessWizardView: View {
LabeledContent("Scanner") { ScannerField(model: model) }
Toggle("Mirror the negatives", isOn: $model.mirror)
TextField("Film", text: $model.film)
TextField("Print settings", text: $model.printing)
LabeledContent("Start from") {
HStack {
Text(model.startingName.map { "\($0) (adjusted further)" } ?? "No correction")
.lineLimit(1).truncationMode(.middle)
Menu("Change") {
Button("No Correction", action: model.startFromScratch)
let saved = model.savedRuns
if !saved.isEmpty {
Section("A previous run's latest curves") {
ForEach(saved) { run in Button(run.label) { model.startFrom(run: run.folder) } }
}
}
Button("Process Curve Files…", action: chooseCurves)
}
.fixedSize()
}
}
printSettings
SheetSize(size: model.sheetSize, units: model.units) { model.sheetSize = $0 }
DarkroomMarks(guide: model.guide, registration: model.registration, negative: true, units: model.units,
setGuide: { model.guide = $0 }, setRegistration: { model.registration = $0 })
Expand All @@ -838,15 +981,21 @@ public struct ProcessWizardView: View {
Button("Start", action: model.start).disabled(model.started || model.colours.isEmpty || model.colours.count > 5)
Button("1. Print Chart Page…", action: printPage).disabled(!model.started || model.busy)
Button("…or I Printed It Elsewhere") { model.printed("printed elsewhere") }.disabled(!model.started || model.busy)
if model.started { Button("New Run…", action: model.closeRun).disabled(model.busy) }
if model.busy { ProgressView().controlSize(.small) }
}
HStack {
Button("2. Scan the Contact Print and Read It", action: scan).disabled(!model.printed || model.busy)
Button("…or Read a Scan File", action: readFile).disabled(!model.printed || model.busy)
Button("3. Refine the Curves", action: model.refine).disabled(!model.measured || model.busy)
}
ToneChartView(readings: model.readings)
.frame(minHeight: 260, maxHeight: .infinity)
HStack(alignment: .top, spacing: 16) {
SheetPreview(image: model.preview(), ppi: ProcessWizardModel.previewPPI, units: model.units,
title: model.started ? "This round's page" : "The page Start makes")
.frame(width: 220)
ToneChartView(readings: model.readings)
}
.frame(minHeight: 260, maxHeight: .infinity, alignment: .top)
if !model.result.isEmpty { Text(model.result).fixedSize(horizontal: false, vertical: true) }
if let error = model.error { Text(error).foregroundStyle(.red) }
HStack {
Expand All @@ -867,7 +1016,44 @@ public struct ProcessWizardView: View {
}
}

/// Kept runs to carry on with (another round, or adjust an earlier calibration further).
@ViewBuilder private var previousRuns: some View {
let saved = model.savedRuns
if !saved.isEmpty {
HStack {
Text("Or carry on with a kept run:").foregroundStyle(.secondary)
Menu("Continue a Previous Run") {
ForEach(saved) { run in Button(run.label) { model.resume(run.folder) } }
}
.fixedSize()
}
}
}

private func chooseCurves() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [UTType(filenameExtension: "oqproc") ?? .data]
panel.allowsMultipleSelection = true
panel.directoryURL = model.folder
panel.message = "Choose process curves to adjust further (one per colour)"
if panel.runModal() == .OK { model.startFrom(files: panel.urls) }
}

/// The print-settings note: editable before and after Start.
private var printSettings: some View {
VStack(alignment: .leading, spacing: 4) {
TextField("Print settings", text: $model.printing,
prompt: Text("e.g. Glossy film, Highest quality, 2400 x 1200"))
.onSubmit(model.savePrinting)
Text("A note of the paper type, quality and resolution you choose in the print panel (it opens at Print "
+ "Chart Page…), so every round prints the same way. OpenQuad doesn't set them; the page itself is 300 ppi.")
.font(.caption)
.foregroundStyle(.secondary)
}
}

private func printPage() {
model.savePrinting()
do {
guard let request = try model.pageForPrinting() else { return }
if printWithPanel(request) { model.printed("print panel") }
Expand Down
50 changes: 50 additions & 0 deletions swift/OpenQuadApp/Tests/OpenQuadUITests/MarksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,54 @@ struct MarksTests {
let resumed = try ProcessRun.resume(run.folder)
#expect(resumed.size.map { [$0.0, $0.1] } == [4, 6] && resumed.registration)
}

@Test func processWizardPreviewsThePageAndKeepsAnEditedPrintNote() throws {
let wizard = ProcessWizardModel(folder: folder.appendingPathComponent("processes"), scannerHost: "")
#expect(wizard.printing.isEmpty)
wizard.colours = ["green", "red"]
let before = try #require(wizard.preview())
wizard.sheetSize = (4, 6)
let small = try #require(wizard.preview())
#expect(small.width < before.width || small.height < before.height)
#expect(Double(max(small.width, small.height)) / ProcessWizardModel.previewPPI <= 6.1)
wizard.start()
let run = try #require(wizard.run)
let started = try #require(wizard.preview())
#expect(started.width == small.width && started.height == small.height)
wizard.printing = "Glossy film, Highest quality, 2400 x 1200 "
wizard.savePrinting()
#expect(run.printing == "Glossy film, Highest quality, 2400 x 1200")
#expect(try ProcessRun.resume(run.folder).printing == run.printing)
}

@Test func processRunsCanBeContinuedOrAdjustedFurther() throws {
let processes = folder.appendingPathComponent("processes")
let first = ProcessWizardModel(folder: processes, scannerHost: "")
first.colours = ["green", "red"]
first.printing = "2400 x 1200"
first.start()
let run = try #require(first.run)
var bent = ProcessCurve.identity(colour: "red")
bent.curve = bent.curve.map { $0 / 2 }
let file = folder.appendingPathComponent("red.oqproc")
try bent.encode().write(to: file)

let later = ProcessWizardModel(folder: processes, scannerHost: "")
#expect(later.savedRuns.map(\.folder.lastPathComponent) == [run.folder.lastPathComponent])
later.resume(run.folder)
#expect(later.started && later.colours == ["green", "red"] && later.printing == "2400 x 1200")
later.closeRun()
#expect(!later.started)

later.startFrom(files: [file])
#expect(later.startingCurves["red"]?.curve == bent.curve && later.colours.contains("red"))
later.name = "adjusted"
later.start()
#expect(try later.run?.curve("red").curve == bent.curve)
#expect(try later.run?.curve("green").curve == ProcessCurve.identity(colour: "green").curve)

let third = ProcessWizardModel(folder: processes, scannerHost: "")
third.startFrom(run: try #require(later.run).folder)
#expect(third.startingCurves["red"]?.curve == bent.curve && third.startingName?.hasPrefix("adjusted") == true)
}
}
Loading
Loading