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
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | import AppKit |
| 2 | import ImageIO |
| 3 | import SwiftUI |
| 4 | import UniformTypeIdentifiers |
| 5 | import WebKit |
| 6 | |
| 7 | @main |
| 8 | struct CKeyRendererApp: App { |
| 9 | var body: some Scene { |
| 10 | WindowGroup("CKey Renderer") { |
| 11 | RendererView() |
| 12 | .frame(minWidth: 980, minHeight: 700) |
| 13 | } |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | private 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 | |
| 25 | private 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 | |
| 317 | private 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 | |
| 410 | private 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 | |
| 415 | private 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 | |
| 579 | private 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 | } |