// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only. // Usage: swift Design/generate_app_icon.swift // // The mark is the web favicon grown up: a flat gold SIM plate with three // ascending slots cut out of it, on a neutral graphite field. Two colours, one // light source from the upper left, no metal texture, so it still reads at the // 29pt settings size. The plate keeps the classic single SIM cut, top-left. // // The field is deliberately neutral (R = G = B): the moment it drifts cool the // whole icon reads as "blue icon" on a lit home screen. The gold is pushed warm // and deep enough that it stays metal rather than mustard. // // Every coordinate is in visual space (origin top-left, y grows down). import AppKit import CoreGraphics import Foundation let side: CGFloat = 1024 let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Design/AppIcon.png" func envDouble(_ name: String, _ fallback: Double) -> CGFloat { guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else { return CGFloat(fallback) } return CGFloat(value) } func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor { CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255) } guard let space = CGColorSpace(name: CGColorSpace.sRGB), let ctx = CGContext( data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!) exit(1) } ctx.translateBy(x: 0, y: side) ctx.scaleBy(x: 1, y: -1) typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int)) func makeGradient(_ stops: [Stop]) -> CGGradient? { var components: [CGFloat] = [] for stop in stops { components.append(CGFloat(stop.color.0) / 255) components.append(CGFloat(stop.color.1) / 255) components.append(CGFloat(stop.color.2) / 255) components.append(CGFloat(stop.color.3) / 255) } let locations = stops.map(\.location) return CGGradient( colorSpace: space, colorComponents: components, locations: locations, count: stops.count ) } func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) { guard let gradient = makeGradient(stops) else { return } ctx.saveGState() ctx.addPath(path) ctx.clip() ctx.drawLinearGradient( gradient, start: start, end: end, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] ) ctx.restoreGState() } func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) { guard let gradient = makeGradient(stops) else { return } ctx.saveGState() ctx.drawRadialGradient( gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: [.drawsAfterEndLocation] ) ctx.restoreGState() } func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) } func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath { CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } /// SIM plate outline: rounded square with the top-left corner cut at 45 degrees. func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath { let bevel = corner * 0.26 let vertices: [(point: CGPoint, radius: CGFloat)] = [ (CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel), (CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel), (CGPoint(x: rect.maxX, y: rect.minY), corner), (CGPoint(x: rect.maxX, y: rect.maxY), corner), (CGPoint(x: rect.minX, y: rect.maxY), corner), ] let path = CGMutablePath() path.move(to: vertices[0].point) for index in vertices.indices { path.addArc( tangent1End: vertices[index].point, tangent2End: vertices[(index + 1) % vertices.count].point, radius: vertices[index].radius ) } path.closeSubpath() return path } /// Bright along the upper-left edges, gone by the lower-right: an even outline /// reads like a sticker cut rather than a plate lit from above. func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) { ctx.saveGState() ctx.addPath(path) ctx.clip() ctx.addPath(path) ctx.setLineWidth(width) ctx.replacePathWithStrokedPath() ctx.clip() fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops, CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY)) ctx.restoreGState() } // MARK: - Field fillLinear( rectPath(CGRect(x: 0, y: 0, width: side, height: side)), [ // 石墨底严格等色。原先 B 比 R 高 1 级,缩到 60pt 后边缘像素仍会落在蓝色波段。 (0.00, (32, 32, 32, 255)), (0.55, (19, 19, 19, 255)), (1.00, (10, 10, 10, 255)), ] as [Stop], CGPoint(x: side * 0.06, y: 0), CGPoint(x: side * 0.94, y: side) ) fillRadial( CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86), [(0.0, (232, 226, 214, 24)), (0.60, (232, 226, 214, 6)), (1.0, (232, 226, 214, 0))] as [Stop] ) // MARK: - Plate let plateSide = envDouble("ICON_PLATE_SIDE", 592) let plateCenterY = envDouble("ICON_PLATE_Y", 500) let plateCorner = envDouble("ICON_PLATE_CORNER", 122) let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168) let plateRect = CGRect( x: (side - plateSide) / 2, y: plateCenterY - plateSide / 2, width: plateSide, height: plateSide ) let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer) ctx.saveGState() ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(0, 3, 2, 150)) ctx.addPath(plate) ctx.setFillColor(rgb(0, 0, 0)) ctx.fillPath() ctx.restoreGState() fillLinear( plate, [ // 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。 (0.00, (252, 229, 170, 255)), (0.46, (240, 199, 111, 255)), (1.00, (211, 155, 61, 255)), ] as [Stop], CGPoint(x: plateRect.minX, y: plateRect.minY), CGPoint(x: plateRect.maxX, y: plateRect.maxY) ) directionalRim( plate, plateRect, plateSide * 0.007, [ (0.00, (255, 246, 224, 140)), (0.36, (255, 240, 206, 32)), (0.68, (255, 236, 196, 0)), ] as [Stop] ) // MARK: - Slots let slotWidth = envDouble("ICON_SLOT_W", 92) let slotGap = envDouble("ICON_SLOT_GAP", 54) let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640) let slotHeights: [CGFloat] = [136, 232, 328] // 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265), // 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。 let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap let slotsStartX = (side - slotsWidth) / 2 for (index, height) in slotHeights.enumerated() { let rect = CGRect( x: slotsStartX + CGFloat(index) * (slotWidth + slotGap), y: slotBottom - height, width: slotWidth, height: height ) let radius = slotWidth * 0.30 let path = roundedPath(rect, radius) fillLinear( path, [ (0.00, (16, 16, 16, 255)), (1.00, (32, 32, 32, 255)), ] as [Stop], CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.minX, y: rect.maxY) ) directionalRim( path, rect, radius * 0.30, [ (0.00, (120, 82, 24, 96)), (0.52, (120, 82, 24, 16)), (1.00, (255, 240, 202, 64)), ] as [Stop] ) } guard let image = ctx.makeImage() else { FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!) exit(1) } let rep = NSBitmapImageRep(cgImage: image) guard let data = rep.representation(using: .png, properties: [:]) else { FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!) exit(1) } try! data.write(to: URL(fileURLWithPath: output)) print(output)