diff --git a/Media/hero.jpg b/Media/hero.jpg deleted file mode 100644 index d3f5014..0000000 Binary files a/Media/hero.jpg and /dev/null differ diff --git a/Media/hero.png b/Media/hero.png new file mode 100644 index 0000000..d059d38 Binary files /dev/null and b/Media/hero.png differ diff --git a/README.md b/README.md index 3e1f348..61cf4db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aither -![Aither — Living particle orbs for SwiftUI](Media/hero.jpg) +![Aither — Living particle orbs for SwiftUI](Media/hero.png) Aither is a dependency-free package for expressive, continuously looping particle orbs. Its primary API is native SwiftUI, backed by an efficient diff --git a/Scripts/render-hero.swift b/Scripts/render-hero.swift new file mode 100644 index 0000000..1bf6a97 --- /dev/null +++ b/Scripts/render-hero.swift @@ -0,0 +1,146 @@ +#!/usr/bin/env swift + +import AppKit + +struct Particle { + var x: CGFloat + var y: CGFloat + var z: CGFloat + var size: CGFloat + var opacity: CGFloat +} + +private let canvasWidth = 3_200 +private let canvasHeight = 1_280 +private let outputURL = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "Media/hero.png") + +guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: canvasWidth, + pixelsHigh: canvasHeight, + bitsPerSample: 8, + samplesPerPixel: 2, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceWhite, + bytesPerRow: 0, + bitsPerPixel: 0 +) else { + fatalError("Unable to allocate hero bitmap") +} + +NSGraphicsContext.saveGraphicsState() +guard let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap) else { + fatalError("Unable to create graphics context") +} +NSGraphicsContext.current = graphicsContext + +let context = graphicsContext.cgContext +let canvas = CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight) +context.setAllowsAntialiasing(true) +context.setShouldAntialias(true) +context.setFillColor(gray: 0, alpha: 1) +context.fill(canvas) + +// A high-density, fixed-phase interpretation of Aither's Zetesis particle shell. +// It uses the same latitude sampling, 3D rotation, depth sort and perspective +// projection as AitherView, scaled for a lossless 3200 × 1280 README asset. +let latitudeRows = 37 +let phase: CGFloat = 0.17 +let loop = phase * .pi * 2 +let scan = loop * 2 +var particles: [Particle] = [] + +for row in 0.. Particle { + let x1 = point.x + let y1 = point.y * cos(rx) - point.z * sin(rx) + let z1 = point.y * sin(rx) + point.z * cos(rx) + let x2 = x1 * cos(ry) + z1 * sin(ry) + let y2 = y1 + let z2 = -x1 * sin(ry) + z1 * cos(ry) + return Particle( + x: x2 * cos(rz) - y2 * sin(rz), + y: x2 * sin(rz) + y2 * cos(rz), + z: z2, + size: point.size, + opacity: point.opacity + ) +} + +let center = CGPoint(x: 2_680, y: 640) +let radius: CGFloat = 1_085 +let baseDot: CGFloat = 5.2 + +let rendered = particles + .map { rotated($0, x: -0.16, y: loop * 0.12, z: 0.018) } + .sorted { $0.z < $1.z } + +for particle in rendered { + let depth = max(0, min((particle.z + 1.15) / 2.3, 1)) + let perspective = 1 / max(0.72, 1 - particle.z * 0.2) + let point = CGPoint( + x: center.x + particle.x * radius * perspective, + y: center.y + particle.y * radius * perspective + ) + let dotRadius = baseDot * particle.size * (0.62 + depth * 0.58) * perspective + let alpha = max(0.08, min(particle.opacity * (0.12 + depth * 0.88), 1)) + + context.setFillColor(gray: 1, alpha: alpha) + context.fillEllipse(in: CGRect( + x: point.x - dotRadius, + y: point.y - dotRadius, + width: dotRadius * 2, + height: dotRadius * 2 + )) +} + +let title = "AITHER" +let titleFont = NSFont.monospacedSystemFont(ofSize: 25, weight: .medium) +let titleColor = NSColor(white: 1, alpha: 0.82) +let tracking: CGFloat = 18 +var titleX: CGFloat = 116 + +for character in title { + let glyph = String(character) + let attributes: [NSAttributedString.Key: Any] = [ + .font: titleFont, + .foregroundColor: titleColor + ] + let size = glyph.size(withAttributes: attributes) + glyph.draw(at: CGPoint(x: titleX, y: 105), withAttributes: attributes) + titleX += size.width + tracking +} + +graphicsContext.flushGraphics() +NSGraphicsContext.restoreGraphicsState() + +guard let png = bitmap.representation(using: .png, properties: [:]) else { + fatalError("Unable to encode hero PNG") +} + +try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true +) +try png.write(to: outputURL, options: .atomic) +print(outputURL.path)