Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeyRendererApp.swift

v0.1.0-build.85 Latest Commit ede38f5

Size
36 KB
Lines
581
Language
swift
SHA-256
a6633b5413c9…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1import AppKit
2import ImageIO
3import SwiftUI
4import UniformTypeIdentifiers
5import WebKit
6
7@main
8struct CKeyRendererApp: App {
9 var body: some Scene {
10 WindowGroup("CKey Renderer") {
11 RendererView()
12 .frame(minWidth: 980, minHeight: 700)
13 }
14 }
15}
16
17private enum RenderProfile: String, CaseIterable, Identifiable {
18 case scalable = "Profile 0 · Scalable"
19 case locator = "Profile 1 · Locator"
20 case text = "Profile 2 · Text"
21 case extended = "Profile 3 · Extended"
22 var id: String { rawValue }
23}
24
25private final class RendererModel: ObservableObject {
26 @Published var profile: RenderProfile = .scalable { didSet { scheduleRegenerate() } }
27 /// Profile 0: 0 = smallest version that fits; otherwise 1...31.
28 @Published var scalableVersion = 0 { didSet { scheduleRegenerate() } }
29 /// Profile 0 correction level: 0 L, 1 M, 2 Q, 3 H.
30 @Published var scalableLevel = 1 { didSet { scheduleRegenerate() } }
31 /// Profile 0 layout: 0 standard, 1 print-robust (P0.1).
32 @Published var scalableLayout = 0 { didSet { scheduleRegenerate() } }
33 /// Print export width (inches) and printer resolution (dpi).
34 @Published var printWidthInches = 1.0
35 @Published var printDPI = 600.0
36 /// The most recent Profile 0 symbol, kept for print export.
37 private let lastSymbol = UnsafeMutablePointer<ckey_scalable_symbol>.allocate(capacity: 1)
38 private var hasLastSymbol = false
39 /// Module pitch of the current symbol in canonical units (29-unit field).
40 @Published private(set) var modulePitch = 1.0
41 // Profile 0 design system (ckey_style). Foreground/Background below are
42 // the ink and background-centre colours.
43 @Published var moduleShape = 0 { didSet { scheduleRegenerate() } } // rounded, square, circle, diamond
44 @Published var ringStyle = 0 { didSet { scheduleRegenerate() } } // joined arcs, dots
45 @Published var rayCap = 0 { didSet { scheduleRegenerate() } } // round, flat
46 @Published var sheen = false { didSet { scheduleRegenerate() } }
47 @Published var inkShade = Color(red: 0.784, green: 0.714, blue: 0.604) { didSet { scheduleRegenerate() } }
48 @Published var vignette = false { didSet { scheduleRegenerate() } }
49 @Published var backgroundEdge = Color(red: 0.027, green: 0.031, blue: 0.047) { didSet { scheduleRegenerate() } }
50 @Published var accentsOn = false { didSet { scheduleRegenerate() } }
51 @Published var accent1 = Color(red: 0.663, green: 0.722, blue: 0.588) { didSet { scheduleRegenerate() } }
52 @Published var accent2 = Color(red: 0.620, green: 0.631, blue: 0.651) { didSet { scheduleRegenerate() } }
53 @Published var accentPercent = 18.0 { didSet { scheduleRegenerate() } }
54 @Published var bevel = 0.0 { didSet { scheduleRegenerate() } }
55 @Published var depth = 0.0 { didSet { scheduleRegenerate() } }
56 @Published var grain = 0.0 { didSet { scheduleRegenerate() } }
57 @Published private(set) var logoDataURI: String? = nil { didSet { scheduleRegenerate() } }
58 @Published private(set) var logoName = ""
59 /// 0 = the empty centre; 1 = the largest radius the version allows.
60 @Published var logoSize = 0.0 { didSet { scheduleRegenerate() } }
61 @Published var logoPlate = true { didSet { scheduleRegenerate() } }
62 @Published private(set) var styleStatus = ""
63 @Published var payload = "CKeyDemoPayload!" { didSet { scheduleRegenerate() } }
64 @Published var extendedTier = 0 { didSet { scheduleRegenerate() } }
65 @Published var foreground = Color.black { didSet { scheduleRegenerate() } }
66 @Published var background = Color.white { didSet { scheduleRegenerate() } }
67 @Published var blur = 0.0
68 @Published var damage = 0.0
69 @Published var noise = 0.0
70 @Published var rotation = 0.0
71 @Published var resolution = 1.0
72 @Published private(set) var svg = ""
73 @Published private(set) var status = "Preparing renderer…"
74 @Published private(set) var previewReadStatus = "Synthetic preview: waiting for a symbol."
75
76 private var regenerationWorkItem: DispatchWorkItem?
77
78 init() { scheduleRegenerate() }
79 deinit { lastSymbol.deallocate() }
80
81 private func scheduleRegenerate() {
82 regenerationWorkItem?.cancel()
83 let work = DispatchWorkItem { [weak self] in self?.regenerate() }
84 regenerationWorkItem = work
85 DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work)
86 }
87
88 func regenerate() {
89 let input = Array(payload.utf8)
90 var diag = ckey_diagnostics()
91 let rendered: String?
92 var detail = ""
93 switch profile {
94 case .scalable:
95 let symbol = lastSymbol
96 let level = ckey_ecc_level(rawValue: UInt32(scalableLevel))
97 let layout = ckey_scalable_layout(rawValue: UInt32(scalableLayout))
98 hasLastSymbol = false
99 guard ckey_encode_scalable(input, input.count, UInt32(scalableVersion), level, layout, symbol, &diag) == CKEY_OK else { svg = ""; return fail(diag) }
100 hasLastSymbol = true
101 let version = symbol.pointee.version
102 var style = ckey_style()
103 ckey_style_default(&style)
104 style.background = rgb(background)
105 style.background_edge = vignette ? rgb(backgroundEdge) : style.background
106 style.ink = rgb(foreground)
107 style.ink_shade = sheen ? rgb(inkShade) : style.ink
108 if accentsOn {
109 style.accents.0 = rgb(accent1); style.accents.1 = rgb(accent2)
110 style.accent_count = 2; style.accent_percent = UInt32(accentPercent.rounded())
111 }
112 style.modules = ckey_module_shape(rawValue: UInt32(moduleShape))
113 style.rings = ckey_ring_style(rawValue: UInt32(ringStyle))
114 style.rays = ckey_ray_cap(rawValue: UInt32(rayCap))
115 style.bevel = bevel; style.shadow = depth; style.texture = grain
116 style.logo_plate = logoPlate ? 1 : 0
117 let maxRadius = ckey_scalable_logo_max_radius(version, layout)
118 style.logo_radius = logoSize <= 0 ? 0 : 3.0 + (maxRadius - 3.0) * logoSize
119 var checkDiag = ckey_diagnostics()
120 var checked = CKEY_OK
121 rendered = (logoDataURI ?? "").withCString { href -> String? in
122 style.logo_href = logoDataURI == nil ? nil : href
123 checked = ckey_check_scalable_style(symbol, &style, &checkDiag)
124 return checked == CKEY_OK ? renderScalable(symbol, style: &style) : nil
125 }
126 styleStatus = diagnosticString(checkDiag)
127 guard checked == CKEY_OK else { svg = ""; status = "Design refused: \(styleStatus)"; return }
128 let modules = ckey_scalable_modules_across(version)
129 modulePitch = 29.0 / Double(max(modules, 1))
130 detail = "\(scalableLayout == 1 ? "P0.1 print · " : "")\(scalableVersion == 0 ? "auto" : "selected") v\(version) · \(modules) modules · level \(["L", "M", "Q", "H"][scalableLevel]) · capacity \(ckey_scalable_capacity(version, level, layout)) B"
131 case .locator:
132 guard input.count == Int(CKEY_LOCATOR_PAYLOAD_BYTES) else {
133 svg = ""; status = "Profile 1 requires exactly 16 UTF-8 bytes; entered \(input.count)."; return
134 }
135 let bytes = input
136 var symbol = ckey_symbol()
137 guard ckey_encode_locator(bytes, bytes.count, &symbol, &diag) == CKEY_OK else { svg = ""; return fail(diag) }
138 rendered = renderLocator(&symbol)
139 detail = "RS(77,26), exactly 16 bytes"
140 case .text:
141 guard !input.isEmpty, input.count <= Int(CKEY_TEXT_PAYLOAD_MAX_BYTES) else { svg = ""; status = "Profile 2 accepts 1–200 UTF-8 bytes."; return }
142 var symbol = ckey_text_symbol()
143 guard ckey_encode_text(input, input.count, &symbol, &diag) == CKEY_OK else { svg = ""; return fail(diag) }
144 rendered = renderText(&symbol)
145 detail = "RS(255,210), 200-byte maximum"
146 case .extended:
147 guard !input.isEmpty, input.count <= Int(CKEY_EXTENDED_PAYLOAD_MAX_BYTES) else { svg = ""; status = "Profile 3 accepts 1–2,048 UTF-8 bytes."; return }
148 var symbol = ckey_extended_symbol()
149 let encodeStatus: ckey_status
150 switch extendedTier {
151 case 256: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_256, &symbol, &diag)
152 case 384: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_384, &symbol, &diag)
153 case 512: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_512, &symbol, &diag)
154 case 768: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_768, &symbol, &diag)
155 case 1024: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_1024, &symbol, &diag)
156 case 1536: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_1536, &symbol, &diag)
157 case 2048: encodeStatus = ckey_encode_extended_tiered(input, input.count, CKEY_EXTENDED_TIER_2048, &symbol, &diag)
158 default: encodeStatus = ckey_encode_extended(input, input.count, &symbol, &diag)
159 }
160 guard encodeStatus == CKEY_OK else { svg = ""; return fail(diag) }
161 rendered = renderExtended(&symbol)
162 let tier = extendedTier == 0 ? ([256, 384, 512, 768, 1024, 1536, 2048].first { input.count <= $0 } ?? 2048) : extendedTier
163 let dataBytes = (11 + tier + 9) / 10
164 detail = "\(extendedTier == 0 ? "auto" : "selected") tier \(tier) B · RS(255,\(dataBytes)) × 10"
165 }
166 guard var next = rendered else { svg = ""; status = "The C++ renderer did not produce SVG."; return }
167 if profile != .scalable { // Profile 0 colours come from its ckey_style
168 // Default output is black (#000000) on white (#ffffff); swap through
169 // placeholders so a black background cannot be re-coloured as ink.
170 next = next.replacingOccurrences(of: "#ffffff", with: "@CKEY-BG@").replacingOccurrences(of: "#000000", with: "@CKEY-FG@")
171 next = next.replacingOccurrences(of: "@CKEY-BG@", with: background.hex).replacingOccurrences(of: "@CKEY-FG@", with: foreground.hex)
172 }
173 svg = next
174 let version = ckey_get_software_version().map { String(cString: $0) } ?? "development"
175 status = "\(profile.rawValue) · \(input.count) payload byte(s) · \(detail) · \(version)"
176 }
177
178 func saveSVG() {
179 let panel = NSSavePanel(); panel.nameFieldStringValue = "ckey-symbol.svg"; panel.allowedContentTypes = [.svg]
180 guard panel.runModal() == .OK, let url = panel.url else { return }
181 do { try svg.write(to: url, atomically: true, encoding: .utf8); status = "Saved \(url.lastPathComponent)" }
182 catch { status = "Could not save SVG: \(error.localizedDescription)" }
183 }
184
185 func recordPreviewRead(_ result: String) {
186 if previewReadStatus != result { previewReadStatus = result }
187 }
188
189 private func fail(_ diag: ckey_diagnostics) {
190 // C fixed-size arrays import to Swift as tuples, not C string pointers.
191 // Reinterpret only the message field's storage when constructing String.
192 status = withUnsafeBytes(of: diag.message) { bytes in
193 guard let baseAddress = bytes.baseAddress else { return "CKey operation failed." }
194 return String(cString: baseAddress.assumingMemoryBound(to: CChar.self))
195 }
196 }
197 func chooseLogo() {
198 let panel = NSOpenPanel()
199 panel.allowedContentTypes = [.png, .jpeg, .svg]
200 panel.allowsMultipleSelection = false
201 guard panel.runModal() == .OK, let url = panel.url, let data = try? Data(contentsOf: url) else { return }
202 guard data.count <= 2_000_000 else { styleStatus = "Logo files must be 2 MB or smaller."; return }
203 let ext = url.pathExtension.lowercased()
204 let type = ext == "svg" ? "image/svg+xml" : ext == "png" ? "image/png" : "image/jpeg"
205 logoName = url.lastPathComponent
206 logoDataURI = "data:\(type);base64,\(data.base64EncodedString())"
207 }
208
209 func removeLogo() { logoDataURI = nil; logoName = "" }
210
211 /// Exports the current Profile 0 symbol as a 1-bit-style print PNG: black
212 /// ink on paper on the printer's dot grid (no grey to halftone), tagged
213 /// with its resolution so it prints at the chosen physical width.
214 func exportPrintPNG() {
215 guard profile == .scalable, hasLastSymbol else { status = "Print export is for Profile 0 symbols."; return }
216 var side = 0
217 guard ckey_render_scalable_print(lastSymbol, nil, printWidthInches, printDPI, nil, 0, &side) == CKEY_INSUFFICIENT_BUFFER, side > 0 else {
218 status = "Print export: width × dpi is out of range."; return
219 }
220 var pixels = [UInt8](repeating: 255, count: side * side)
221 let rendered = pixels.withUnsafeMutableBufferPointer { buffer in
222 ckey_render_scalable_print(lastSymbol, nil, printWidthInches, printDPI, buffer.baseAddress, buffer.count, &side)
223 }
224 guard rendered == CKEY_OK else { status = "Print export failed."; return }
225 let panel = NSSavePanel(); panel.nameFieldStringValue = "ckey-print.png"; panel.allowedContentTypes = [.png]
226 guard panel.runModal() == .OK, let url = panel.url else { return }
227 guard let provider = CGDataProvider(data: Data(pixels) as CFData),
228 let image = CGImage(width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 8, bytesPerRow: side,
229 space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
230 provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
231 let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
232 status = "Print export: could not create the PNG."; return
233 }
234 let properties = [kCGImagePropertyDPIWidth as String: printDPI, kCGImagePropertyDPIHeight as String: printDPI] as CFDictionary
235 CGImageDestinationAddImage(destination, image, properties)
236 status = CGImageDestinationFinalize(destination)
237 ? "Saved \(url.lastPathComponent): \(String(format: "%.2f", printWidthInches)) in at \(Int(printDPI)) dpi, black on paper. Print at 100% / actual size."
238 : "Print export: could not write the PNG."
239 }
240
241 /// The original concept art's look: champagne metal, navy vignette,
242 /// sage and grey accents, embossed depth, and fine grain.
243 func applyConceptPreset() {
244 background = Color(red: 0.078, green: 0.086, blue: 0.118); backgroundEdge = Color(red: 0.027, green: 0.031, blue: 0.047)
245 foreground = Color(red: 0.965, green: 0.933, blue: 0.878); inkShade = Color(red: 0.784, green: 0.714, blue: 0.604)
246 sheen = true; vignette = true; accentsOn = true; accentPercent = 18
247 bevel = 0.6; depth = 0.5; grain = 0.4; moduleShape = 0; ringStyle = 0; rayCap = 0
248 }
249
250 private func rgb(_ colour: Color) -> ckey_rgb {
251 let c = NSColor(colour).usingColorSpace(.deviceRGB) ?? .white
252 let byte = { (v: CGFloat) in UInt8(max(0, min(255, (v * 255).rounded()))) }
253 return ckey_rgb(r: byte(c.redComponent), g: byte(c.greenComponent), b: byte(c.blueComponent))
254 }
255
256 private func diagnosticString(_ diag: ckey_diagnostics) -> String {
257 withUnsafeBytes(of: diag.message) { bytes in
258 guard let base = bytes.baseAddress else { return "" }
259 return String(cString: base.assumingMemoryBound(to: CChar.self))
260 }
261 }
262
263 private func renderScalable(_ symbol: UnsafeMutablePointer<ckey_scalable_symbol>, style: inout ckey_style) -> String? {
264 var needed = 0
265 guard ckey_render_scalable_styled_svg(symbol, &style, 24, nil, 0, &needed) == CKEY_INSUFFICIENT_BUFFER else { return nil }
266 var chars = [CChar](repeating: 0, count: needed)
267 let result = chars.withUnsafeMutableBufferPointer { buffer in
268 ckey_render_scalable_styled_svg(symbol, &style, 24, buffer.baseAddress, buffer.count, &needed)
269 }
270 guard result == CKEY_OK else { return nil }
271 return chars.withUnsafeBufferPointer { buffer in
272 guard let baseAddress = buffer.baseAddress else { return nil }
273 return String(cString: baseAddress)
274 }
275 }
276 private func renderLocator(_ symbol: inout ckey_symbol) -> String? {
277 var needed = 0
278 guard ckey_render_svg(&symbol, 24, nil, 0, &needed) == CKEY_INSUFFICIENT_BUFFER else { return nil }
279 var chars = [CChar](repeating: 0, count: needed)
280 let result = chars.withUnsafeMutableBufferPointer { buffer in
281 ckey_render_svg(&symbol, 24, buffer.baseAddress, buffer.count, &needed)
282 }
283 guard result == CKEY_OK else { return nil }
284 return chars.withUnsafeBufferPointer { buffer in
285 guard let baseAddress = buffer.baseAddress else { return nil }
286 return String(cString: baseAddress)
287 }
288 }
289 private func renderText(_ symbol: inout ckey_text_symbol) -> String? {
290 var needed = 0
291 guard ckey_render_text_svg(&symbol, 24, nil, 0, &needed) == CKEY_INSUFFICIENT_BUFFER else { return nil }
292 var chars = [CChar](repeating: 0, count: needed)
293 let result = chars.withUnsafeMutableBufferPointer { buffer in
294 ckey_render_text_svg(&symbol, 24, buffer.baseAddress, buffer.count, &needed)
295 }
296 guard result == CKEY_OK else { return nil }
297 return chars.withUnsafeBufferPointer { buffer in
298 guard let baseAddress = buffer.baseAddress else { return nil }
299 return String(cString: baseAddress)
300 }
301 }
302 private func renderExtended(_ symbol: inout ckey_extended_symbol) -> String? {
303 var needed = 0
304 guard ckey_render_extended_svg(&symbol, 24, nil, 0, &needed) == CKEY_INSUFFICIENT_BUFFER else { return nil }
305 var chars = [CChar](repeating: 0, count: needed)
306 let result = chars.withUnsafeMutableBufferPointer { buffer in
307 ckey_render_extended_svg(&symbol, 24, buffer.baseAddress, buffer.count, &needed)
308 }
309 guard result == CKEY_OK else { return nil }
310 return chars.withUnsafeBufferPointer { buffer in
311 guard let baseAddress = buffer.baseAddress else { return nil }
312 return String(cString: baseAddress)
313 }
314 }
315}
316
317private struct RendererView: View {
318 @StateObject private var model = RendererModel()
319 var body: some View {
320 HSplitView {
321 ScrollView { VStack(alignment: .leading, spacing: 16) {
322 Text("CKey Renderer").font(.largeTitle.bold())
323 Text("Native C++ encoder · non-normative visual stress laboratory").foregroundStyle(.secondary)
324 Picker("Profile", selection: $model.profile) { ForEach(RenderProfile.allCases) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented)
325 if model.profile == .scalable {
326 Picker("Version", selection: $model.scalableVersion) {
327 Text("Auto · smallest that fits").tag(0)
328 ForEach(1...31, id: \.self) { version in
329 Text("v\(version) · \(ckey_scalable_modules_across(UInt32(version))) modules").tag(version)
330 }
331 }
332 Picker("Correction", selection: $model.scalableLevel) {
333 Text("L · ~7%").tag(0); Text("M · ~14%").tag(1); Text("Q · ~22%").tag(2); Text("H · ~30%").tag(3)
334 }.pickerStyle(.segmented)
335 Picker("Layout", selection: $model.scalableLayout) {
336 Text("Standard").tag(0); Text("Print · P0.1").tag(1)
337 }.pickerStyle(.segmented)
338 DisclosureGroup("Print") {
339 VStack(alignment: .leading, spacing: 10) {
340 LabSlider(title: "Width", value: $model.printWidthInches, range: 0.2...4.0, suffix: " in")
341 Picker("Printer", selection: $model.printDPI) {
342 Text("300 dpi").tag(300.0); Text("600 dpi").tag(600.0); Text("1200 dpi").tag(1200.0)
343 }.pickerStyle(.segmented)
344 Button("Export Print PNG…") { model.exportPrintPNG() }
345 Text("Black ink on paper on the printer's dot grid. For small prints use Print · P0.1 and print at 100% / actual size.")
346 .font(.caption).foregroundStyle(.secondary)
347 }.padding(.top, 6)
348 }
349 DisclosureGroup("Design") {
350 VStack(alignment: .leading, spacing: 10) {
351 Picker("Modules", selection: $model.moduleShape) {
352 Text("Rounded").tag(0); Text("Square").tag(1); Text("Circle").tag(2); Text("Diamond").tag(3)
353 }.pickerStyle(.segmented)
354 Picker("Rings", selection: $model.ringStyle) { Text("Joined arcs").tag(0); Text("Dots").tag(1) }.pickerStyle(.segmented)
355 Picker("Rays", selection: $model.rayCap) { Text("Round").tag(0); Text("Flat").tag(1) }.pickerStyle(.segmented)
356 HStack { Toggle("Metal sheen", isOn: $model.sheen); ColorPicker("Shade", selection: $model.inkShade).disabled(!model.sheen) }
357 HStack { Toggle("Vignette", isOn: $model.vignette); ColorPicker("Edge", selection: $model.backgroundEdge).disabled(!model.vignette) }
358 HStack {
359 Toggle("Accents", isOn: $model.accentsOn)
360 ColorPicker("", selection: $model.accent1).disabled(!model.accentsOn)
361 ColorPicker("", selection: $model.accent2).disabled(!model.accentsOn)
362 }
363 LabSlider(title: "Accent share", value: $model.accentPercent, range: 0...60, suffix: " %")
364 LabSlider(title: "Bevel", value: $model.bevel, range: 0...1, suffix: "")
365 LabSlider(title: "Depth", value: $model.depth, range: 0...1, suffix: "")
366 LabSlider(title: "Grain", value: $model.grain, range: 0...1, suffix: "")
367 HStack {
368 Button("Choose Logo…") { model.chooseLogo() }
369 Button("Remove") { model.removeLogo() }.disabled(model.logoName.isEmpty)
370 Text(model.logoName).font(.caption).lineLimit(1)
371 }
372 LabSlider(title: "Logo size", value: $model.logoSize, range: 0...1, suffix: "")
373 Toggle("Logo plate", isOn: $model.logoPlate)
374 Button("Concept preset") { model.applyConceptPreset() }
375 Text(model.styleStatus).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
376 }.padding(.top, 6)
377 }
378 }
379 if model.profile == .extended {
380 Picker("Capacity / ECC", selection: $model.extendedTier) {
381 Text("Auto · strongest that fits").tag(0)
382 ForEach([256, 384, 512, 768, 1024, 1536, 2048], id: \.self) { capacity in
383 Text("\(capacity) B · RS tier").tag(capacity)
384 }
385 }
386 }
387 TextEditor(text: $model.payload).font(.system(.body, design: .monospaced)).frame(minHeight: 125).overlay(RoundedRectangle(cornerRadius: 8).stroke(.secondary.opacity(0.3)))
388 HStack { ColorPicker("Foreground", selection: $model.foreground); ColorPicker("Background", selection: $model.background) }
389 LabSlider(title: "Blur", value: $model.blur, range: 0...2.5, suffix: " × module")
390 LabSlider(title: "Damage", value: $model.damage, range: 0...45, suffix: " %")
391 LabSlider(title: "Noise", value: $model.noise, range: 0...40, suffix: " %")
392 LabSlider(title: "Rotation", value: $model.rotation, range: -25...25, suffix: "°")
393 LabSlider(title: "Resolution", value: $model.resolution, range: 0.1...1.0, suffix: "×")
394 HStack { Button("Regenerate") { model.regenerate() }; Button("Save SVG…") { model.saveSVG() }.disabled(model.svg.isEmpty) }
395 Divider()
396 Text(model.status).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
397 Text(model.previewReadStatus).font(.caption.monospaced()).foregroundStyle(model.previewReadStatus.contains("PASS") ? .green : .secondary).textSelection(.enabled)
398 Text("The read check uses the displayed raster and the same native decoder as CKeyReader.").font(.caption).foregroundStyle(.secondary)
399 Spacer()
400 }.padding(22) }.frame(minWidth: 360, idealWidth: 410)
401 SymbolPreview(svg: model.svg, profile: model.profile, modulePitch: model.modulePitch, expectedPayload: Array(model.payload.utf8),
402 backgroundHex: model.background.hex, blur: model.blur, damage: model.damage, noise: model.noise,
403 rotation: model.rotation, resolution: model.resolution,
404 onRead: model.recordPreviewRead)
405 .background(Color(nsColor: .windowBackgroundColor))
406 }
407 }
408}
409
410private struct LabSlider: View {
411 let title: String; @Binding var value: Double; let range: ClosedRange<Double>; let suffix: String
412 var body: some View { VStack(alignment: .leading) { HStack { Text(title); Spacer(); Text("\(value, specifier: "%.1f")\(suffix)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) }; Slider(value: $value, in: range) } }
413}
414
415private struct SymbolPreview: NSViewRepresentable {
416 let svg: String
417 let profile: RenderProfile
418 /// Profile 0 module pitch in canonical units; unused by other profiles.
419 let modulePitch: Double
420 let expectedPayload: [UInt8]
421 let backgroundHex: String
422 let blur: Double; let damage: Double; let noise: Double; let rotation: Double
423 let resolution: Double
424 let onRead: (String) -> Void
425 final class Coordinator: NSObject, WKNavigationDelegate {
426 var lastSVG: String?
427 var readWorkItem: DispatchWorkItem?
428 var onNavigationFinished: (() -> Void)?
429 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
430 onNavigationFinished?()
431 }
432 }
433 func makeCoordinator() -> Coordinator { Coordinator() }
434 func makeNSView(context: Context) -> WKWebView {
435 let webView = WKWebView()
436 webView.navigationDelegate = context.coordinator
437 return webView
438 }
439 func updateNSView(_ webView: WKWebView, context: Context) {
440 let coordinator = context.coordinator
441 coordinator.onNavigationFinished = { [weak webView, weak coordinator] in
442 guard let webView, let coordinator else { return }
443 self.scheduleRead(webView, coordinator: coordinator, delay: 0.08)
444 }
445 if coordinator.lastSVG != svg {
446 coordinator.lastSVG = svg
447 let moduleSize = moduleSizeInCSSPixels(webView)
448 let html = """
449 <html><style>html,body{margin:0;width:100%;height:100%;display:grid;place-items:center;background:#161616;overflow:hidden}.symbol{position:relative;width:min(84vmin,780px);aspect-ratio:1}.symbol svg{width:100%;height:100%;display:block}.damage,.noise{position:absolute;inset:0;pointer-events:none;opacity:0}.noise{mix-blend-mode:overlay}</style><body><div class='symbol'>\(svg)<div class='damage' id='damage'></div><div class='noise' id='noise'></div></div><script>window.setEffects=function(b,d,n,r,m,bg){let s=document.querySelector('.symbol'),damage=document.getElementById('damage'),noise=document.getElementById('noise');s.style.filter='blur('+(b*m)+'px)';s.style.transform='rotate('+r+'deg)';damage.style.opacity=d;damage.style.backgroundImage='repeating-linear-gradient(117deg,transparent 0 '+(m*8)+'px,'+bg+' '+(m*8)+'px '+(m*10)+'px),repeating-linear-gradient(27deg,transparent 0 '+(m*17)+'px,'+bg+' '+(m*17)+'px '+(m*19)+'px)';noise.style.opacity=n;noise.style.backgroundImage='radial-gradient(#fff 0.7px,transparent 0.9px)';noise.style.backgroundSize=(m*1.6)+'px '+(m*1.6)+'px'};window.setEffects(\(blur),\(damage / 100),\(noise / 100),\(rotation),\(moduleSize),'\(backgroundHex)');</script></body></html>
450 """
451 webView.loadHTMLString(html, baseURL: nil)
452 } else {
453 let script = "window.setEffects(\(blur),\(damage / 100),\(noise / 100),\(rotation),\(moduleSizeInCSSPixels(webView)),'\(backgroundHex)')"
454 webView.evaluateJavaScript(script) { [weak webView] _, _ in
455 guard let webView else { return }
456 self.scheduleRead(webView, coordinator: coordinator, delay: 0.12)
457 }
458 }
459 }
460
461 private func moduleSizeInCSSPixels(_ webView: WKWebView) -> Double {
462 let side = min(min(Double(webView.bounds.width), Double(webView.bounds.height)) * 0.84, 780.0)
463 let viewBoxSide = profile == .extended ? 124.0 : 31.0
464 let markWidth = profile == .scalable ? 0.68 * modulePitch : profile == .locator ? 0.74 : 0.34
465 return side / viewBoxSide * markWidth
466 }
467
468 private func scheduleRead(_ webView: WKWebView, coordinator: Coordinator, delay: Double) {
469 coordinator.readWorkItem?.cancel()
470 let work = DispatchWorkItem { [weak webView] in
471 guard let webView else { return }
472 self.readDisplayedRaster(webView)
473 }
474 coordinator.readWorkItem = work
475 DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
476 }
477
478 private func readDisplayedRaster(_ webView: WKWebView) {
479 guard !svg.isEmpty, webView.bounds.width > 0, webView.bounds.height > 0 else {
480 onRead("Synthetic preview: no valid symbol to decode."); return
481 }
482 webView.takeSnapshot(with: nil) { image, error in
483 guard error == nil, let image,
484 let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
485 onRead("Synthetic preview: could not capture the displayed raster."); return
486 }
487 let width = max(2, Int((Double(cgImage.width) * resolution).rounded()))
488 let height = max(2, Int((Double(cgImage.height) * resolution).rounded()))
489 guard width > 1, height > 1 else { onRead("Synthetic preview: raster is too small."); return }
490 var gray = [UInt8](repeating: 0, count: width * height)
491 let rendered = gray.withUnsafeMutableBytes { bytes -> Bool in
492 guard let context = CGContext(data: bytes.baseAddress, width: width, height: height,
493 bitsPerComponent: 8, bytesPerRow: width,
494 space: CGColorSpaceCreateDeviceGray(),
495 bitmapInfo: CGImageAlphaInfo.none.rawValue) else { return false }
496 context.interpolationQuality = .high
497 context.translateBy(x: 0, y: CGFloat(height)); context.scaleBy(x: 1, y: -1)
498 context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
499 return true
500 }
501 guard rendered else { onRead("Synthetic preview: grayscale conversion failed."); return }
502
503 let side = min(min(Double(webView.bounds.width), Double(webView.bounds.height)) * 0.84, 780.0)
504 let left = (Double(webView.bounds.width) - side) / 2
505 let top = (Double(webView.bounds.height) - side) / 2
506 let viewBoxSide = profile == .extended ? 124.0 : 31.0
507 let fieldSide = profile == .extended ? 116.0 : 29.0
508 let inset = profile == .extended ? 4.0 : 1.0
509 let moduleScale = side / viewBoxSide
510 let sx = Double(width) / Double(webView.bounds.width)
511 let sy = Double(height) / Double(webView.bounds.height)
512 let cx = left + side / 2, cy = top + side / 2
513 let angle = rotation * Double.pi / 180
514 var quad: [Double] = []
515 for (u, v) in [(0.0, 0.0), (fieldSide, 0.0), (fieldSide, fieldSide), (0.0, fieldSide)] {
516 let x = left + (inset + u) * moduleScale
517 let y = top + (inset + v) * moduleScale
518 let dx = x - cx, dy = y - cy
519 quad.append((cx + dx * cos(angle) - dy * sin(angle)) * sx)
520 quad.append((cy + dx * sin(angle) + dy * cos(angle)) * sy)
521 }
522 var output = [UInt8](repeating: 0, count: 65535) // Profile 0 payloads exceed 2,048 bytes
523 var scalableVersion: UInt32 = 0
524 var scalableLayoutFound: UInt32 = 0
525 var outputLength = 0
526 var diagnostic = ckey_diagnostics()
527 let result: ckey_status = gray.withUnsafeBufferPointer { pixels in
528 quad.withUnsafeBufferPointer { q in
529 output.withUnsafeMutableBufferPointer { decoded in
530 switch profile {
531 case .scalable:
532 return ckey_decode_scalable_gray8(pixels.baseAddress, width, height, width, q.baseAddress,
533 decoded.baseAddress, decoded.count, &outputLength, &scalableVersion,
534 &scalableLayoutFound, &diagnostic)
535 case .locator:
536 return ckey_decode_gray8(pixels.baseAddress, width, height, width, q.baseAddress,
537 decoded.baseAddress, Int(CKEY_LOCATOR_PAYLOAD_BYTES), &diagnostic)
538 case .text:
539 return ckey_decode_text_gray8(pixels.baseAddress, width, height, width, q.baseAddress,
540 decoded.baseAddress, decoded.count, &outputLength, &diagnostic)
541 case .extended:
542 return ckey_decode_extended_gray8(pixels.baseAddress, width, height, width, q.baseAddress,
543 decoded.baseAddress, decoded.count, &outputLength, &diagnostic)
544 }
545 }
546 }
547 }
548 let decodedCount = profile == .locator ? Int(CKEY_LOCATOR_PAYLOAD_BYTES) : outputLength
549 // Raster pixels per symbol unit actually presented to the decoder.
550 // Clean-render floors are about 6 (P1), 10 (P2), and 11 (P3);
551 // below them a FAIL reflects the window/resolution, not the symbol.
552 let pixelsPerUnit = moduleScale * sx
553 let density: String
554 if profile == .scalable {
555 // Profile 0 needs about 6 px per module at every version.
556 let pixelsPerModule = pixelsPerUnit * modulePitch
557 density = (scalableLayoutFound == 1 ? "P0.1 " : "") + "v\(scalableVersion) · " + String(format: "%.1f px/module", pixelsPerModule) +
558 (pixelsPerModule < 6.0 ? " (below the ~6 px/module decode floor — enlarge the window or raise Resolution)" : "")
559 } else {
560 let decodeFloor = profile == .locator ? 6.0 : profile == .text ? 10.0 : 11.0
561 density = String(format: "%.1f px/unit", pixelsPerUnit) + (pixelsPerUnit < decodeFloor ? " (below the ~\(Int(decodeFloor)) px/unit decode floor — enlarge the window or raise Resolution)" : "")
562 }
563 let expected = expectedPayload
564 let matches = result == CKEY_OK && decodedCount == expected.count &&
565 Array(output.prefix(decodedCount)) == expected
566 if matches {
567 onRead("Synthetic preview decode: PASS · \(decodedCount) bytes · \(density)")
568 } else {
569 let message = withUnsafeBytes(of: diagnostic.message) { bytes -> String in
570 guard let address = bytes.baseAddress else { return "decode rejected" }
571 return String(cString: address.assumingMemoryBound(to: CChar.self))
572 }
573 onRead("Synthetic preview decode: FAIL · \(message) · \(density)")
574 }
575 }
576 }
577}
578
579private extension Color {
580 var hex: String { let c = NSColor(self).usingColorSpace(.deviceRGB) ?? .white; return String(format: "#%02X%02X%02X", Int(c.redComponent*255), Int(c.greenComponent*255), Int(c.blueComponent*255)) }
581}