| 1 | #!/usr/bin/swift |
| 2 | |
| 3 | import AppKit |
| 4 | import Foundation |
| 5 | |
| 6 | private let bundleIdentifier = "ai.splitfire.klepon" |
| 7 | private let catalogName = "AppIconTV.brandassets" |
| 8 | private let outputDirectory = URL( |
| 9 | fileURLWithPath: CommandLine.arguments.contains("--output-dir") |
| 10 | ? CommandLine.arguments[ |
| 11 | CommandLine.arguments.firstIndex(of: "--output-dir")!.advanced(by: 1)] |
| 12 | : defaultOutputDirectoryPath() |
| 13 | ) |
| 14 | |
| 15 | private let backgroundColor = NSColor(calibratedRed: 0.98, green: 0.96, blue: 0.92, alpha: 1) |
| 16 | private let surfaceColor = NSColor(calibratedRed: 0.98, green: 0.95, blue: 0.89, alpha: 1) |
| 17 | private let surfaceSecondaryColor = NSColor(calibratedRed: 0.93, green: 0.90, blue: 0.84, alpha: 1) |
| 18 | private let accentColor = NSColor(calibratedRed: 0.17, green: 0.48, blue: 0.34, alpha: 1) |
| 19 | private let accentLightColor = NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 1) |
| 20 | private let accentDarkColor = NSColor(calibratedRed: 0.12, green: 0.34, blue: 0.23, alpha: 1) |
| 21 | private let warmColor = NSColor(calibratedRed: 0.56, green: 0.35, blue: 0.17, alpha: 1) |
| 22 | private let warmLightColor = NSColor(calibratedRed: 0.90, green: 0.60, blue: 0.25, alpha: 1) |
| 23 | private let textPrimaryColor = NSColor(calibratedRed: 0.17, green: 0.16, blue: 0.13, alpha: 1) |
| 24 | private let textSecondaryColor = NSColor(calibratedRed: 0.37, green: 0.35, blue: 0.31, alpha: 1) |
| 25 | private let dividerColor = NSColor(calibratedWhite: 0, alpha: 0.08) |
| 26 | private let coconutColor = NSColor(calibratedRed: 0.99, green: 0.98, blue: 0.94, alpha: 1) |
| 27 | private let coconutShadowColor = NSColor(calibratedRed: 0.88, green: 0.85, blue: 0.76, alpha: 0.72) |
| 28 | |
| 29 | private let coconutFlakes: [(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)] = [ |
| 30 | (0.16, 0.34, 0.050, 0.026, -18), |
| 31 | (0.19, 0.31, 0.042, 0.024, 12), |
| 32 | (0.22, 0.28, 0.052, 0.028, -8), |
| 33 | (0.25, 0.25, 0.044, 0.025, 9), |
| 34 | (0.28, 0.22, 0.055, 0.028, -12), |
| 35 | (0.32, 0.19, 0.048, 0.026, 14), |
| 36 | (0.36, 0.16, 0.056, 0.030, -10), |
| 37 | (0.41, 0.13, 0.046, 0.026, 6), |
| 38 | (0.46, 0.11, 0.054, 0.029, -14), |
| 39 | (0.51, 0.10, 0.043, 0.024, 11), |
| 40 | (0.56, 0.11, 0.051, 0.028, -9), |
| 41 | (0.61, 0.13, 0.046, 0.025, 13), |
| 42 | (0.66, 0.17, 0.056, 0.030, -11), |
| 43 | (0.70, 0.20, 0.050, 0.027, 8), |
| 44 | (0.74, 0.24, 0.047, 0.026, -7), |
| 45 | (0.77, 0.28, 0.055, 0.029, 12), |
| 46 | (0.80, 0.32, 0.048, 0.026, -9), |
| 47 | (0.24, 0.37, 0.032, 0.020, -6), |
| 48 | (0.29, 0.34, 0.034, 0.021, 13), |
| 49 | (0.35, 0.30, 0.033, 0.020, -10), |
| 50 | (0.40, 0.26, 0.035, 0.022, 5), |
| 51 | (0.47, 0.23, 0.032, 0.020, -15), |
| 52 | (0.54, 0.22, 0.034, 0.021, 7), |
| 53 | (0.60, 0.24, 0.033, 0.020, -8), |
| 54 | (0.67, 0.28, 0.035, 0.022, 14), |
| 55 | (0.72, 0.33, 0.032, 0.020, -11), |
| 56 | ] |
| 57 | |
| 58 | private let smallIconCanvas = CGSize(width: 400, height: 240) |
| 59 | private let appStoreCanvas = CGSize(width: 1280, height: 768) |
| 60 | private let topShelfCanvas = CGSize(width: 1920, height: 720) |
| 61 | private let topShelfWideCanvas = CGSize(width: 2320, height: 720) |
| 62 | |
| 63 | do { |
| 64 | try regenerateCatalog() |
| 65 | } catch { |
| 66 | fputs("tvOS brand asset generation failed: \(error)\n", stderr) |
| 67 | exit(1) |
| 68 | } |
| 69 | |
| 70 | private func regenerateCatalog() throws { |
| 71 | if FileManager.default.fileExists(atPath: outputDirectory.path) { |
| 72 | try FileManager.default.removeItem(at: outputDirectory) |
| 73 | } |
| 74 | |
| 75 | try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) |
| 76 | |
| 77 | try writeRootContents() |
| 78 | try writePrimaryIconStack( |
| 79 | name: "App Icon - tvOS", canvas: smallIconCanvas, scales: [1, 2], includeFrameSize: true) |
| 80 | try writePrimaryIconStack( |
| 81 | name: "App Icon - tvOS - App Store", canvas: appStoreCanvas, scales: [1], |
| 82 | includeFrameSize: false) |
| 83 | try writeTopShelfImageSet( |
| 84 | name: "Top Shelf Image", canvas: topShelfCanvas, baseFileName: "TopShelfImage") |
| 85 | try writeTopShelfImageSet( |
| 86 | name: "Top Shelf Image Wide", canvas: topShelfWideCanvas, baseFileName: "TopShelfImageWide") |
| 87 | } |
| 88 | |
| 89 | private func defaultOutputDirectoryPath() -> String { |
| 90 | let cwd = FileManager.default.currentDirectoryPath |
| 91 | return URL(fileURLWithPath: cwd) |
| 92 | .appendingPathComponent("Klepon/Assets.xcassets/\(catalogName)") |
| 93 | .path |
| 94 | } |
| 95 | |
| 96 | private func writeRootContents() throws { |
| 97 | try writeJSON( |
| 98 | [ |
| 99 | "assets": [ |
| 100 | [ |
| 101 | "idiom": "tv", |
| 102 | "filename": "App Icon - tvOS - App Store.imagestack", |
| 103 | "role": "primary-app-icon", |
| 104 | "size": "1280x768", |
| 105 | ], |
| 106 | [ |
| 107 | "idiom": "tv", |
| 108 | "filename": "App Icon - tvOS.imagestack", |
| 109 | "role": "primary-app-icon", |
| 110 | "size": "400x240", |
| 111 | ], |
| 112 | [ |
| 113 | "idiom": "tv", |
| 114 | "filename": "Top Shelf Image.imageset", |
| 115 | "role": "top-shelf-image", |
| 116 | "size": "1920x720", |
| 117 | ], |
| 118 | [ |
| 119 | "idiom": "tv", |
| 120 | "filename": "Top Shelf Image Wide.imageset", |
| 121 | "role": "top-shelf-image-wide", |
| 122 | "size": "2320x720", |
| 123 | ], |
| 124 | ], |
| 125 | "info": [ |
| 126 | "author": bundleIdentifier, |
| 127 | "version": 1, |
| 128 | ], |
| 129 | ], |
| 130 | to: outputDirectory.appendingPathComponent("Contents.json") |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | private func writePrimaryIconStack( |
| 135 | name: String, canvas: CGSize, scales: [Int], includeFrameSize: Bool |
| 136 | ) throws { |
| 137 | let stackFolder = outputDirectory.appendingPathComponent("\(name).imagestack") |
| 138 | try FileManager.default.createDirectory(at: stackFolder, withIntermediateDirectories: true) |
| 139 | |
| 140 | try writeJSON( |
| 141 | [ |
| 142 | "info": [ |
| 143 | "author": bundleIdentifier, |
| 144 | "version": 1, |
| 145 | ], |
| 146 | "layers": [ |
| 147 | ["filename": "Front.imagestacklayer"], |
| 148 | ["filename": "Back.imagestacklayer"], |
| 149 | ], |
| 150 | ], |
| 151 | to: stackFolder.appendingPathComponent("Contents.json") |
| 152 | ) |
| 153 | |
| 154 | try writeLayer( |
| 155 | folder: stackFolder.appendingPathComponent("Front.imagestacklayer"), |
| 156 | imageSetName: "Content", |
| 157 | imageBaseName: "Front", |
| 158 | canvas: canvas, |
| 159 | scales: scales, |
| 160 | includeFrameSize: includeFrameSize, |
| 161 | render: { try renderIconFront(canvas: $0) } |
| 162 | ) |
| 163 | |
| 164 | try writeLayer( |
| 165 | folder: stackFolder.appendingPathComponent("Back.imagestacklayer"), |
| 166 | imageSetName: "Content", |
| 167 | imageBaseName: "Back", |
| 168 | canvas: canvas, |
| 169 | scales: scales, |
| 170 | includeFrameSize: includeFrameSize, |
| 171 | render: { try renderIconBack(canvas: $0) } |
| 172 | ) |
| 173 | } |
| 174 | |
| 175 | private func writeLayer( |
| 176 | folder: URL, |
| 177 | imageSetName: String, |
| 178 | imageBaseName: String, |
| 179 | canvas: CGSize, |
| 180 | scales: [Int], |
| 181 | includeFrameSize: Bool, |
| 182 | render: (CGSize) throws -> Data |
| 183 | ) throws { |
| 184 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 185 | |
| 186 | var object: [String: Any] = [ |
| 187 | "info": [ |
| 188 | "author": bundleIdentifier, |
| 189 | "version": 1, |
| 190 | ] |
| 191 | ] |
| 192 | |
| 193 | if includeFrameSize { |
| 194 | object["properties"] = [ |
| 195 | "frame-size": [ |
| 196 | "width": Int(canvas.width), |
| 197 | "height": Int(canvas.height), |
| 198 | ] |
| 199 | ] |
| 200 | } |
| 201 | |
| 202 | try writeJSON(object, to: folder.appendingPathComponent("Contents.json")) |
| 203 | |
| 204 | try writeImageSet( |
| 205 | folder: folder.appendingPathComponent("\(imageSetName).imageset"), |
| 206 | baseName: imageBaseName, |
| 207 | canvas: canvas, |
| 208 | scales: scales, |
| 209 | render: render |
| 210 | ) |
| 211 | } |
| 212 | |
| 213 | private func writeTopShelfImageSet(name: String, canvas: CGSize, baseFileName: String) throws { |
| 214 | try writeImageSet( |
| 215 | folder: outputDirectory.appendingPathComponent("\(name).imageset"), |
| 216 | baseName: baseFileName, |
| 217 | canvas: canvas, |
| 218 | scales: [1, 2], |
| 219 | render: { try renderTopShelf(canvas: $0, isWide: name.contains("Wide")) } |
| 220 | ) |
| 221 | } |
| 222 | |
| 223 | private func writeImageSet( |
| 224 | folder: URL, |
| 225 | baseName: String, |
| 226 | canvas: CGSize, |
| 227 | scales: [Int], |
| 228 | render: (CGSize) throws -> Data |
| 229 | ) throws { |
| 230 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 231 | |
| 232 | var imageEntries: [[String: String]] = [] |
| 233 | |
| 234 | for scale in scales { |
| 235 | let scaledCanvas = CGSize( |
| 236 | width: canvas.width * CGFloat(scale), height: canvas.height * CGFloat(scale)) |
| 237 | let fileName = scale == 1 ? "\(baseName).png" : "\(baseName)@\(scale)x.png" |
| 238 | let png = try render(scaledCanvas) |
| 239 | try png.write(to: folder.appendingPathComponent(fileName), options: .atomic) |
| 240 | |
| 241 | var entry: [String: String] = [ |
| 242 | "filename": fileName, |
| 243 | "idiom": "tv", |
| 244 | ] |
| 245 | |
| 246 | if scales.count > 1 { |
| 247 | entry["scale"] = "\(scale)x" |
| 248 | } |
| 249 | |
| 250 | imageEntries.append(entry) |
| 251 | } |
| 252 | |
| 253 | try writeJSON( |
| 254 | [ |
| 255 | "images": imageEntries, |
| 256 | "info": [ |
| 257 | "author": bundleIdentifier, |
| 258 | "version": 1, |
| 259 | ], |
| 260 | ], |
| 261 | to: folder.appendingPathComponent("Contents.json") |
| 262 | ) |
| 263 | } |
| 264 | |
| 265 | private func renderIconBack(canvas: CGSize) throws -> Data { |
| 266 | try renderPNG(canvas: canvas, opaque: true) { rect in |
| 267 | drawWarmBackdrop(in: rect) |
| 268 | |
| 269 | let cardRect = CGRect( |
| 270 | x: rect.minX + rect.width * 0.08, |
| 271 | y: rect.minY + rect.height * 0.08, |
| 272 | width: rect.width * 0.84, |
| 273 | height: rect.height * 0.84 |
| 274 | ) |
| 275 | drawCard(in: cardRect, canvas: canvas) |
| 276 | |
| 277 | let haloRect = CGRect( |
| 278 | x: rect.midX - rect.height * 0.23, |
| 279 | y: rect.minY + rect.height * 0.24, |
| 280 | width: rect.height * 0.46, |
| 281 | height: rect.height * 0.46 |
| 282 | ) |
| 283 | let halo = NSBezierPath(ovalIn: haloRect) |
| 284 | NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.12).setFill() |
| 285 | halo.fill() |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | private func renderIconFront(canvas: CGSize) throws -> Data { |
| 290 | try renderPNG(canvas: canvas) { rect in |
| 291 | let orbSize = min(rect.height * 0.70, rect.width * 0.54) |
| 292 | let orbRect = CGRect( |
| 293 | x: rect.midX - orbSize / 2, |
| 294 | y: rect.minY + rect.height * 0.18, |
| 295 | width: orbSize, |
| 296 | height: orbSize |
| 297 | ) |
| 298 | drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.34) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | private func renderTopShelf(canvas: CGSize, isWide: Bool) throws -> Data { |
| 303 | try renderPNG(canvas: canvas, opaque: true) { rect in |
| 304 | drawWarmBackdrop(in: rect) |
| 305 | |
| 306 | let wave = NSBezierPath() |
| 307 | wave.move(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.14)) |
| 308 | wave.curve( |
| 309 | to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.22), |
| 310 | controlPoint1: CGPoint( |
| 311 | x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.04), |
| 312 | controlPoint2: CGPoint( |
| 313 | x: rect.minX + rect.width * 0.68, y: rect.minY + rect.height * 0.30) |
| 314 | ) |
| 315 | wave.line(to: CGPoint(x: rect.maxX, y: rect.minY)) |
| 316 | wave.line(to: CGPoint(x: rect.minX, y: rect.minY)) |
| 317 | wave.close() |
| 318 | NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 0.24).setFill() |
| 319 | wave.fill() |
| 320 | |
| 321 | let orbGlow = NSBezierPath( |
| 322 | ovalIn: CGRect( |
| 323 | x: rect.maxX - rect.height * 0.78, |
| 324 | y: rect.midY - rect.height * 0.34, |
| 325 | width: rect.height * 0.92, |
| 326 | height: rect.height * 0.92 |
| 327 | )) |
| 328 | NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.10).setFill() |
| 329 | orbGlow.fill() |
| 330 | |
| 331 | let titleWidth = isWide ? rect.width * 0.30 : rect.width * 0.34 |
| 332 | drawText( |
| 333 | "Taste of Indonesia", |
| 334 | in: CGRect( |
| 335 | x: rect.width * 0.10, y: rect.height * 0.62, width: titleWidth, |
| 336 | height: rect.height * 0.10), |
| 337 | font: font(name: "Avenir Next Demi Bold", size: rect.height * 0.09) |
| 338 | ?? NSFont.systemFont(ofSize: rect.height * 0.09, weight: .semibold), |
| 339 | color: textSecondaryColor |
| 340 | ) |
| 341 | |
| 342 | drawText( |
| 343 | "Klepon", |
| 344 | in: CGRect( |
| 345 | x: rect.width * 0.10, y: rect.height * 0.38, width: rect.width * 0.30, |
| 346 | height: rect.height * 0.18), |
| 347 | font: font(name: "Avenir Next Bold", size: rect.height * 0.20) |
| 348 | ?? NSFont.boldSystemFont(ofSize: rect.height * 0.20), |
| 349 | color: textPrimaryColor |
| 350 | ) |
| 351 | |
| 352 | drawText( |
| 353 | "Dishes Ingredients Traditions", |
| 354 | in: CGRect( |
| 355 | x: rect.width * 0.10, y: rect.height * 0.26, width: rect.width * 0.46, |
| 356 | height: rect.height * 0.08), |
| 357 | font: font(name: "Avenir Next Regular", size: rect.height * 0.075) |
| 358 | ?? NSFont.systemFont(ofSize: rect.height * 0.075), |
| 359 | color: textSecondaryColor |
| 360 | ) |
| 361 | |
| 362 | let cardWidth = isWide ? rect.width * 0.18 : rect.width * 0.23 |
| 363 | let cardRect = CGRect( |
| 364 | x: rect.maxX - cardWidth - rect.width * 0.16, |
| 365 | y: rect.midY - rect.height * 0.28, |
| 366 | width: cardWidth, |
| 367 | height: rect.height * 0.56 |
| 368 | ) |
| 369 | drawCard(in: cardRect, canvas: canvas) |
| 370 | |
| 371 | let secondaryCard = NSBezierPath( |
| 372 | roundedRect: CGRect( |
| 373 | x: cardRect.minX - cardRect.width * 0.18, |
| 374 | y: cardRect.minY + cardRect.height * 0.18, |
| 375 | width: cardRect.width * 0.34, |
| 376 | height: cardRect.height * 0.48 |
| 377 | ), xRadius: cardRect.width * 0.11, yRadius: cardRect.width * 0.11) |
| 378 | NSColor(calibratedWhite: 1, alpha: 0.28).setFill() |
| 379 | secondaryCard.fill() |
| 380 | |
| 381 | let tertiaryCard = NSBezierPath( |
| 382 | roundedRect: CGRect( |
| 383 | x: cardRect.maxX - cardRect.width * 0.02, |
| 384 | y: cardRect.minY + cardRect.height * 0.08, |
| 385 | width: cardRect.width * 0.26, |
| 386 | height: cardRect.height * 0.36 |
| 387 | ), xRadius: cardRect.width * 0.09, yRadius: cardRect.width * 0.09) |
| 388 | NSColor(calibratedWhite: 1, alpha: 0.18).setFill() |
| 389 | tertiaryCard.fill() |
| 390 | |
| 391 | let orbSize = min(cardRect.width * 0.68, cardRect.height * 0.68) |
| 392 | let orbRect = CGRect( |
| 393 | x: cardRect.midX - orbSize / 2, |
| 394 | y: cardRect.minY + cardRect.height * 0.24, |
| 395 | width: orbSize, |
| 396 | height: orbSize |
| 397 | ) |
| 398 | drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.28) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | private func drawWarmBackdrop(in rect: CGRect) { |
| 403 | let background = NSGradient(colors: [ |
| 404 | NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.94, alpha: 1), |
| 405 | backgroundColor, |
| 406 | NSColor(calibratedRed: 0.95, green: 0.91, blue: 0.84, alpha: 1), |
| 407 | ])! |
| 408 | background.draw(in: rect, angle: 0) |
| 409 | |
| 410 | let topGlow = NSBezierPath( |
| 411 | ovalIn: CGRect( |
| 412 | x: rect.minX - rect.width * 0.08, |
| 413 | y: rect.maxY - rect.height * 0.46, |
| 414 | width: rect.width * 0.52, |
| 415 | height: rect.height * 0.52 |
| 416 | )) |
| 417 | NSColor(calibratedWhite: 1, alpha: 0.14).setFill() |
| 418 | topGlow.fill() |
| 419 | |
| 420 | let rightGlow = NSBezierPath( |
| 421 | ovalIn: CGRect( |
| 422 | x: rect.maxX - rect.width * 0.26, |
| 423 | y: rect.minY + rect.height * 0.18, |
| 424 | width: rect.width * 0.24, |
| 425 | height: rect.height * 0.44 |
| 426 | )) |
| 427 | NSColor(calibratedRed: 0.90, green: 0.76, blue: 0.42, alpha: 0.10).setFill() |
| 428 | rightGlow.fill() |
| 429 | } |
| 430 | |
| 431 | private func drawCard(in rect: CGRect, canvas: CGSize) { |
| 432 | let radius = min(rect.width, rect.height) * 0.18 |
| 433 | let card = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) |
| 434 | let cardGradient = NSGradient(colors: [ |
| 435 | NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.93, alpha: 1), |
| 436 | surfaceColor, |
| 437 | surfaceSecondaryColor, |
| 438 | ])! |
| 439 | cardGradient.draw(in: card, angle: 90) |
| 440 | |
| 441 | dividerColor.setStroke() |
| 442 | card.lineWidth = max(1, canvas.width * 0.0016) |
| 443 | card.stroke() |
| 444 | |
| 445 | let innerGlow = NSBezierPath( |
| 446 | roundedRect: rect.insetBy(dx: rect.width * 0.02, dy: rect.height * 0.02), |
| 447 | xRadius: radius * 0.92, yRadius: radius * 0.92) |
| 448 | NSColor(calibratedWhite: 1, alpha: 0.22).setStroke() |
| 449 | innerGlow.lineWidth = max(1.5, canvas.width * 0.0022) |
| 450 | innerGlow.stroke() |
| 451 | |
| 452 | NSGraphicsContext.saveGraphicsState() |
| 453 | card.addClip() |
| 454 | let cornerGlow = NSGradient(colors: [ |
| 455 | NSColor(calibratedWhite: 1, alpha: 0.24), |
| 456 | NSColor(calibratedWhite: 1, alpha: 0), |
| 457 | ])! |
| 458 | cornerGlow.draw( |
| 459 | fromCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18), |
| 460 | radius: rect.width * 0.58, |
| 461 | toCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18), |
| 462 | radius: 0, |
| 463 | options: [] |
| 464 | ) |
| 465 | NSGraphicsContext.restoreGraphicsState() |
| 466 | } |
| 467 | |
| 468 | private func drawKlepon(in orbRect: CGRect, canvas: CGSize, shadowAlpha: CGFloat) { |
| 469 | let shadowRect = CGRect( |
| 470 | x: orbRect.minX + orbRect.width * 0.06, |
| 471 | y: orbRect.minY - orbRect.height * 0.12, |
| 472 | width: orbRect.width * 0.88, |
| 473 | height: orbRect.height * 0.26 |
| 474 | ) |
| 475 | let shadow = NSBezierPath(ovalIn: shadowRect) |
| 476 | NSColor(calibratedRed: 0.37, green: 0.24, blue: 0.10, alpha: shadowAlpha).setFill() |
| 477 | shadow.fill() |
| 478 | |
| 479 | let orb = NSBezierPath(ovalIn: orbRect) |
| 480 | let orbGradient = NSGradient(colors: [ |
| 481 | accentLightColor, accentColor, |
| 482 | NSColor(calibratedRed: 0.28, green: 0.58, blue: 0.40, alpha: 1), |
| 483 | ])! |
| 484 | orbGradient.draw(in: orb, angle: 18) |
| 485 | |
| 486 | let shade = NSBezierPath( |
| 487 | ovalIn: CGRect( |
| 488 | x: orbRect.minX + orbRect.width * 0.06, |
| 489 | y: orbRect.minY + orbRect.height * 0.10, |
| 490 | width: orbRect.width * 0.74, |
| 491 | height: orbRect.height * 0.70 |
| 492 | )) |
| 493 | let shadeGradient = NSGradient(colors: [ |
| 494 | NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0.26), |
| 495 | NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0), |
| 496 | ])! |
| 497 | shadeGradient.draw(in: shade, relativeCenterPosition: NSPoint(x: -0.3, y: -0.1)) |
| 498 | |
| 499 | let highlight = NSBezierPath( |
| 500 | ovalIn: CGRect( |
| 501 | x: orbRect.minX + orbRect.width * 0.16, |
| 502 | y: orbRect.minY + orbRect.height * 0.58, |
| 503 | width: orbRect.width * 0.30, |
| 504 | height: orbRect.height * 0.18 |
| 505 | )) |
| 506 | let highlightGradient = NSGradient(colors: [ |
| 507 | NSColor(calibratedWhite: 1, alpha: 0.28), |
| 508 | NSColor(calibratedWhite: 1, alpha: 0), |
| 509 | ])! |
| 510 | highlightGradient.draw(in: highlight, angle: 0) |
| 511 | |
| 512 | drawLeaves(around: orbRect, canvas: canvas) |
| 513 | drawPalmSugar(in: orbRect, canvas: canvas) |
| 514 | drawCoconutFlakes(in: orbRect, canvas: canvas) |
| 515 | } |
| 516 | |
| 517 | private func drawLeaves(around orbRect: CGRect, canvas: CGSize) { |
| 518 | let darkLeafRect = CGRect( |
| 519 | x: orbRect.maxX - orbRect.width * 0.28, |
| 520 | y: orbRect.maxY - orbRect.height * 0.18, |
| 521 | width: orbRect.width * 0.22, |
| 522 | height: orbRect.height * 0.22 |
| 523 | ) |
| 524 | let darkLeaf = NSBezierPath(ovalIn: darkLeafRect) |
| 525 | accentDarkColor.setFill() |
| 526 | darkLeaf.fill() |
| 527 | |
| 528 | let lightLeafRect = CGRect( |
| 529 | x: orbRect.maxX - orbRect.width * 0.34, |
| 530 | y: orbRect.maxY - orbRect.height * 0.23, |
| 531 | width: orbRect.width * 0.18, |
| 532 | height: orbRect.height * 0.18 |
| 533 | ) |
| 534 | let lightLeaf = NSBezierPath(ovalIn: lightLeafRect) |
| 535 | NSColor(calibratedRed: 0.30, green: 0.62, blue: 0.39, alpha: 1).setFill() |
| 536 | lightLeaf.fill() |
| 537 | |
| 538 | let stem = NSBezierPath() |
| 539 | stem.move( |
| 540 | to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.08) |
| 541 | ) |
| 542 | stem.line( |
| 543 | to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.34) |
| 544 | ) |
| 545 | stem.lineCapStyle = .round |
| 546 | stem.lineWidth = max(2.2, canvas.width * 0.006) |
| 547 | NSColor(calibratedRed: 0.23, green: 0.56, blue: 0.33, alpha: 1).setStroke() |
| 548 | stem.stroke() |
| 549 | } |
| 550 | |
| 551 | private func drawPalmSugar(in orbRect: CGRect, canvas: CGSize) { |
| 552 | let sugarSize = orbRect.width * 0.24 |
| 553 | let sugarRect = CGRect( |
| 554 | x: orbRect.midX - sugarSize / 2, |
| 555 | y: orbRect.midY - sugarSize / 2 + orbRect.height * 0.02, |
| 556 | width: sugarSize, |
| 557 | height: sugarSize |
| 558 | ) |
| 559 | let sugarGlow = NSBezierPath( |
| 560 | ovalIn: sugarRect.insetBy(dx: -sugarSize * 0.16, dy: -sugarSize * 0.16)) |
| 561 | NSColor(calibratedRed: 0.24, green: 0.14, blue: 0.06, alpha: 0.28).setFill() |
| 562 | sugarGlow.fill() |
| 563 | |
| 564 | let sugar = NSBezierPath(ovalIn: sugarRect) |
| 565 | let sugarGradient = NSGradient(colors: [ |
| 566 | warmLightColor, NSColor(calibratedRed: 0.83, green: 0.47, blue: 0.16, alpha: 1), warmColor, |
| 567 | ])! |
| 568 | sugarGradient.draw(in: sugar, relativeCenterPosition: NSPoint(x: 0.18, y: -0.18)) |
| 569 | |
| 570 | NSColor(calibratedWhite: 1, alpha: 0.16).setStroke() |
| 571 | sugar.lineWidth = max(1, canvas.width * 0.0022) |
| 572 | sugar.stroke() |
| 573 | } |
| 574 | |
| 575 | private func drawCoconutFlakes(in orbRect: CGRect, canvas: CGSize) { |
| 576 | for flake in coconutFlakes { |
| 577 | let flakeRect = CGRect( |
| 578 | x: orbRect.minX + orbRect.width * flake.0, |
| 579 | y: orbRect.minY + orbRect.height * flake.1, |
| 580 | width: orbRect.width * flake.2, |
| 581 | height: orbRect.height * flake.3 |
| 582 | ) |
| 583 | |
| 584 | let shadowRect = flakeRect.offsetBy(dx: orbRect.width * 0.008, dy: -orbRect.height * 0.008) |
| 585 | let shadow = NSBezierPath(ovalIn: shadowRect) |
| 586 | coconutShadowColor.setFill() |
| 587 | shadow.fill() |
| 588 | |
| 589 | NSGraphicsContext.saveGraphicsState() |
| 590 | if let cgContext = NSGraphicsContext.current?.cgContext { |
| 591 | cgContext.translateBy(x: flakeRect.midX, y: flakeRect.midY) |
| 592 | cgContext.rotate(by: flake.4 * .pi / 180) |
| 593 | cgContext.translateBy(x: -flakeRect.midX, y: -flakeRect.midY) |
| 594 | } |
| 595 | |
| 596 | let flakePath = NSBezierPath(ovalIn: flakeRect) |
| 597 | coconutColor.setFill() |
| 598 | flakePath.fill() |
| 599 | NSColor(calibratedWhite: 1, alpha: 0.26).setStroke() |
| 600 | flakePath.lineWidth = max(0.4, canvas.width * 0.0009) |
| 601 | flakePath.stroke() |
| 602 | NSGraphicsContext.restoreGraphicsState() |
| 603 | } |
| 604 | } |
| 605 | |
| 606 | private func renderPNG(canvas: CGSize, opaque: Bool = false, draw: (CGRect) throws -> Void) throws |
| 607 | -> Data |
| 608 | { |
| 609 | let pixelsWide = Int(canvas.width) |
| 610 | let pixelsHigh = Int(canvas.height) |
| 611 | |
| 612 | guard |
| 613 | let rep = NSBitmapImageRep( |
| 614 | bitmapDataPlanes: nil, |
| 615 | pixelsWide: pixelsWide, |
| 616 | pixelsHigh: pixelsHigh, |
| 617 | bitsPerSample: 8, |
| 618 | samplesPerPixel: 4, |
| 619 | hasAlpha: true, |
| 620 | isPlanar: false, |
| 621 | colorSpaceName: .deviceRGB, |
| 622 | bitmapFormat: [], |
| 623 | bytesPerRow: 0, |
| 624 | bitsPerPixel: 0 |
| 625 | ) |
| 626 | else { |
| 627 | throw NSError( |
| 628 | domain: "BrandAssetGenerator", |
| 629 | code: 1, |
| 630 | userInfo: [ |
| 631 | NSLocalizedDescriptionKey: |
| 632 | "Unable to allocate bitmap for \(pixelsWide)x\(pixelsHigh)" |
| 633 | ] |
| 634 | ) |
| 635 | } |
| 636 | |
| 637 | rep.size = canvas |
| 638 | |
| 639 | guard let context = NSGraphicsContext(bitmapImageRep: rep) else { |
| 640 | throw NSError( |
| 641 | domain: "BrandAssetGenerator", |
| 642 | code: 2, |
| 643 | userInfo: [NSLocalizedDescriptionKey: "Unable to create bitmap graphics context"] |
| 644 | ) |
| 645 | } |
| 646 | |
| 647 | NSGraphicsContext.saveGraphicsState() |
| 648 | NSGraphicsContext.current = context |
| 649 | context.shouldAntialias = true |
| 650 | context.imageInterpolation = .high |
| 651 | context.cgContext.setAllowsAntialiasing(true) |
| 652 | context.cgContext.setShouldAntialias(true) |
| 653 | |
| 654 | if opaque { |
| 655 | backgroundColor.setFill() |
| 656 | CGRect(origin: .zero, size: canvas).fill() |
| 657 | } |
| 658 | |
| 659 | try draw(CGRect(origin: .zero, size: canvas)) |
| 660 | context.flushGraphics() |
| 661 | NSGraphicsContext.restoreGraphicsState() |
| 662 | |
| 663 | guard let png = rep.representation(using: .png, properties: [:]) else { |
| 664 | throw NSError( |
| 665 | domain: "BrandAssetGenerator", |
| 666 | code: 3, |
| 667 | userInfo: [NSLocalizedDescriptionKey: "Failed to encode PNG"] |
| 668 | ) |
| 669 | } |
| 670 | |
| 671 | return png |
| 672 | } |
| 673 | |
| 674 | private func writeJSON(_ object: [String: Any], to url: URL) throws { |
| 675 | let data = try JSONSerialization.data( |
| 676 | withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) |
| 677 | try data.write(to: url, options: .atomic) |
| 678 | } |
| 679 | |
| 680 | private func drawText(_ string: String, in rect: CGRect, font: NSFont, color: NSColor) { |
| 681 | let paragraph = NSMutableParagraphStyle() |
| 682 | paragraph.alignment = .left |
| 683 | paragraph.lineBreakMode = .byClipping |
| 684 | |
| 685 | let attributed = NSAttributedString( |
| 686 | string: string, |
| 687 | attributes: [ |
| 688 | .font: font, |
| 689 | .foregroundColor: color, |
| 690 | .paragraphStyle: paragraph, |
| 691 | ] |
| 692 | ) |
| 693 | |
| 694 | attributed.draw(in: rect) |
| 695 | } |
| 696 | |
| 697 | private func font(name: String, size: CGFloat) -> NSFont? { |
| 698 | NSFont(name: name, size: size) |
| 699 | } |