import CoreGraphics import Foundation import ImageIO import UniformTypeIdentifiers // MARK: - FacetsGenerator /// **The faceted background, rendered** — the Swift port of `board-backgrounds-faceted.html`'s /// `genFacets` (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, reviewed 2026-08-07). /// /// A jittered point scatter over a frame slightly larger than the picture, Delaunay-triangulated, /// every triangle filled with its own colour off the recipe's narrow lightness band. /// /// ### Pure, and deliberately nothing else /// /// No SwiftUI, no `NSColor`, no actor, no filesystem: recipe in, pixels out. That is what lets the /// picker render a dozen previews off the main actor and the write path render one at 3072 px in the /// same call, from the same code, with no risk that the preview and the file are different pictures. /// /// ### Normalized space, so one seed means one composition at every size /// /// The gallery works in a 480×300 SVG viewBox; this works in a 16:10 **unit** frame — x 0…1, /// y 0…0.625 — with every constant divided by 480. A seed therefore names a composition rather than /// a composition-at-a-size: the 240 px preview in the picker and the 3072 px file written to the /// board folder are the same mesh, scaled. /// /// ### The scatter runs past the frame on purpose /// /// Points are laid over a region inset **outward** on all four sides by a margin the density /// chooses (`FacetsRecipe.Density.scatterMargin`, which carries the whole reasoning). Delaunay /// triangulation only covers the convex hull of its points, so a scatter that stopped at the frame /// edge would leave the ground colour showing in a ragged border. The margin is sized so that even /// the worst jitter draw puts every boundary-cell point at or beyond the frame edge — the picture is /// therefore entirely interior to the hull, and the ragged hull is cropped away. public enum FacetsGenerator: Sendable { /// **The name a generated background is written under** (`BoardStore.applyGeneratedBackground`). /// /// One fixed name rather than a minted one, because regenerating is the overwhelmingly common /// gesture — the user rerolls until they like it — and a fresh UUID per roll would leave a board /// folder full of abandoned PNGs the app never offers to clean up. The collision ladder handles /// the one case a fixed name cannot: somebody else's `facets.png` already sitting there. public static let fileName = "facets.png" /// The normalized frame: 1 wide, 10/16 tall. static let frameHeight = 0.625 /// **The fraction of a cell a jittered point can be pushed toward the cell's far corner** — the /// upper end of the 0.08…0.92 placement band, and therefore the number the boundary ring has to /// beat (`FacetsRecipe.Density.scatterMargin`). static let jitterReach = 0.92 /// **The anti-seam stroke**, 0.7 px at the gallery's 480-wide scale. Each triangle is stroked in /// its *own* fill colour, which is the whole trick: adjacent antialiased edges otherwise leave a /// hairline of the ground colour between every pair of faces, and a mesh full of those reads as a /// wireframe rather than a surface. static let strokeWidth = 0.7 / 480 // MARK: Rendering /// The pixel height that goes with `width` — the 16:10 frame, rounded. public static func pixelHeight(forWidth width: Int) -> Int { max(1, Int((Double(width) * frameHeight).rounded())) } /// This recipe's mesh, drawn at `pixelWidth` — opaque sRGB, no alpha to composite and none to /// store. /// /// `nil` only when CoreGraphics declines to make the bitmap at all (an allocation failure at an /// absurd size); every recipe renders. Callers treat it the way `BoardBackdrop.decode` is /// treated — no image, no banner, nothing written. public static func render(recipe: FacetsRecipe, pixelWidth: Int) -> CGImage? { let width = max(1, pixelWidth) let height = pixelHeight(forWidth: width) guard let space = CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue ) else { return nil } let mesh = facets(recipe: recipe) // The ground, painted in device pixels before the transform goes on: the frame's rounded // height and `frameHeight × width` differ by up to half a pixel, and a rect stated in // normalized units could leave that sliver unpainted along one edge. if let ground = mesh.ground.cgColor(in: space) { context.setFillColor(ground) context.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) } // **SVG's y-down frame, kept.** The mesh is a scatter, so a flip would cost nothing visually // — but keeping the axis the gallery drew in means a swatch reviewed there and a file written // here are the same picture rather than its mirror, which is the only claim the port makes. context.translateBy(x: 0, y: CGFloat(height)) context.scaleBy(x: CGFloat(width), y: -CGFloat(width)) context.setLineWidth(strokeWidth) for face in mesh.faces { guard let color = face.color.cgColor(in: space) else { continue } context.setFillColor(color) context.setStrokeColor(color) context.beginPath() context.move(to: face.a) context.addLine(to: face.b) context.addLine(to: face.c) context.closePath() context.drawPath(using: .fillStroke) } return context.makeImage() } /// The same render, encoded as PNG — the bytes `BoardWriter.writeBoardImage` puts in the board /// folder. /// /// **PNG rather than JPEG**, and not for the usual reason: a flat-shaded triangle mesh is exactly /// the content JPEG is worst at (hard edges become ringing, and the anti-seam stroke's whole /// point is that there is no gap at those edges), while it is exactly what PNG's filters /// compress well. public static func pngData(recipe: FacetsRecipe, pixelWidth: Int) -> Data? { guard let image = render(recipe: recipe, pixelWidth: pixelWidth) else { return nil } let data = NSMutableData() guard let destination = CGImageDestinationCreateWithData( data, UTType.png.identifier as CFString, 1, nil ) else { return nil } CGImageDestinationAddImage(destination, image, nil) guard CGImageDestinationFinalize(destination) else { return nil } return data as Data } // MARK: The mesh /// One triangle, in normalized coordinates, with the colour it is both filled and stroked in. struct Face: Sendable, Equatable { var a: CGPoint var b: CGPoint var c: CGPoint var color: FacetsColor /// The unsigned area of the triangle, in normalized units — what the coverage check adds up. var area: Double { let abx = Double(b.x - a.x), aby = Double(b.y - a.y) let acx = Double(c.x - a.x), acy = Double(c.y - a.y) return abs(abx * acy - acx * aby) / 2 } } /// A whole composition: the ground and the faces over it, in draw order. struct Mesh: Sendable, Equatable { var ground: FacetsColor var faces: [Face] } /// **The mesh, and the whole of the random consumption order.** /// /// Fixed and documented because it is the only thing keeping a seed meaningful across versions: /// **every point first** — column-major, x before y within a point, matching the HTML's /// `c`-outer/`r`-inner loops — **then four draws per triangle in triangle order** (the weighted /// hue pick, the hue jitter, the saturation factor, the lightness offset). Triangulation itself /// consumes nothing. Inserting a draw anywhere in that sequence re-rolls every board that ever /// stored this seed. /// /// **Nothing stores one yet**, which is why the grid could be resized under it (the full-bleed /// ring, `FacetsRecipe.Density`): a board carries the rendered PNG, never the recipe that made /// it, so re-rolling every composition costs exactly nothing today. The moment a seed is written /// to disk — a `background.recipe` subkey, a preset library — that stops being true and this /// sequence, the density grid and the scatter margins all become format. static func facets(recipe: FacetsRecipe) -> Mesh { var random = FacetsRandom(seed: recipe.seed) let points = scatter(recipe.density, using: &random) let indices = triangulate(points) let hues = recipe.hues let level = recipe.level var faces: [Face] = [] faces.reserveCapacity(indices.count) for index in indices { faces.append(Face( a: points[index.a], b: points[index.b], c: points[index.c], color: color(hues: hues, level: level, using: &random) )) } return Mesh(ground: recipe.primaryColor, faces: faces) } /// The grid-jittered scatter over the outset region. One point per cell, placed anywhere in the /// middle 84% of it — the 0.08…0.92 inset is what stops two points in neighbouring cells from /// landing on top of each other and producing a sliver triangle, and its upper end is what the /// density's margin is sized against (`FacetsRecipe.Density.scatterMargin`). static func scatter(_ density: FacetsRecipe.Density, using random: inout FacetsRandom) -> [CGPoint] { let margin = density.scatterMargin let x0 = -margin let x1 = 1 + margin let y0 = -margin let y1 = frameHeight + margin let columns = density.columns let rows = density.rows let cellWidth = (x1 - x0) / Double(columns) let cellHeight = (y1 - y0) / Double(rows) var points: [CGPoint] = [] points.reserveCapacity(columns * rows) for column in 0.. FacetsColor { let picked = pickHue(hues, using: &random) let hue = picked + random.uniform(-3, 3) let saturation = level.saturation * random.uniform(0.85, 1.15) let lightness = level.lightness + random.uniform(-FacetsRecipe.lightnessJitter, FacetsRecipe.lightnessJitter) return FacetsColor(hue: hue, saturation: saturation, lightness: lightness) } /// The weighted pick, off **one** draw: walk the list subtracting weights until the draw falls /// inside one. The trailing return is the floating-point safety net for a draw that survives every /// subtraction — the weights sum to 1, but they sum to 1 in binary. static func pickHue(_ hues: [FacetsRecipe.WeightedHue], using random: inout FacetsRandom) -> Double { var x = random.next() for entry in hues { if x < entry.weight { return entry.degrees } x -= entry.weight } return hues.last?.degrees ?? 0 } // MARK: Triangulation /// Three indices into the point array. struct IndexedTriangle: Sendable, Equatable { var a: Int var b: Int var c: Int } /// **Bowyer–Watson**, exactly as the gallery does it: a super-triangle enclosing everything, /// points inserted one at a time, the triangles whose circumcircle contains the new point /// removed, and the cavity they leave retriangulated against its own boundary — the edges that /// were not shared by two of the removed triangles. /// /// Triangles touching the super-triangle are dropped at the end, which is what leaves a /// triangulation of the input points alone. /// /// ### The one deliberate departure from the gallery /// /// The super-triangle is the HTML's shape at **100× its size**. Bowyer–Watson only produces a /// true triangulation when the scaffold is large enough that no real point's circumcircle can /// reach past it; the gallery's is about 11× the point cloud, which is not, and the cost is a /// handful of sliver triangles quietly missing near the hull — the count comes out 1–4 short of /// the 2n − 2 − h every triangulation must satisfy. /// /// It is a departure from the *scaffold*, not from the picture: the scaffold is deleted before /// anything is drawn, and the two versions were measured against each other over 36 meshes — total /// covered area differs by 0.2%, entirely in slivers outside the visible frame. What is bought is /// an invariant a test can hold the port to exactly, instead of a tolerance around a defect. static func triangulate(_ points: [CGPoint]) -> [IndexedTriangle] { let count = points.count guard count >= 3 else { return [] } var vertices = points vertices.append(CGPoint(x: -300_000.0 / 480, y: -300_000.0 / 480)) vertices.append(CGPoint(x: 350_000.0 / 480, y: -300_000.0 / 480)) vertices.append(CGPoint(x: 240.0 / 480, y: 360_000.0 / 480)) struct Working { var triangle: IndexedTriangle var circle: Circumcircle? } var working = [Working( triangle: IndexedTriangle(a: count, b: count + 1, c: count + 2), circle: circumcircle(vertices[count], vertices[count + 1], vertices[count + 2]) )] for index in 0.. Bool { let dx = Double(point.x) - x let dy = Double(point.y) - y return dx * dx + dy * dy < radiusSquared } } /// The circle through three points, or `nil` when they are collinear. /// /// The guard is `1e-12` **in normalized units**, which is the HTML's `1e-9` at 480 scale carried /// across with room to spare: the determinant is quadratic in the coordinates, so the same /// degeneracy reads about 2×10⁵ times smaller here. Three jittered grid points are never actually /// collinear; this exists so a hand-built or pathological point set degrades to a missing face /// instead of an infinity. static func circumcircle(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> Circumcircle? { let ax = Double(a.x), ay = Double(a.y) let bx = Double(b.x), by = Double(b.y) let cx = Double(c.x), cy = Double(c.y) let d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) guard abs(d) >= 1e-12 else { return nil } let a2 = ax * ax + ay * ay let b2 = bx * bx + by * by let c2 = cx * cx + cy * cy let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d let dx = ax - ux let dy = ay - uy return Circumcircle(x: ux, y: uy, radiusSquared: dx * dx + dy * dy) } } extension FacetsGenerator.IndexedTriangle { /// The three edges as **directed** pairs, in the HTML's own `e`/`(e+1)%3` order — see the walk in /// `triangulate` for why the direction is kept. var orderedEdges: [(from: Int, to: Int)] { [(a, b), (b, c), (c, a)] } } // MARK: - FacetsRandom /// **mulberry32**, ported bit-for-bit from the gallery's own PRNG. /// /// Not `SystemRandomNumberGenerator` and not `SeededRandomNumberGenerator`-of-the-week: the seed's /// entire job is to be a *stable name for a picture*, so the sequence has to be reproducible across /// machines, OS versions and Swift releases. A 32-bit LCG-ish mixer with wrapping arithmetic is /// reproducible by definition; the standard library's generators explicitly are not. /// /// Swift's `&*`/`&+` on `UInt32` are exactly JavaScript's `Math.imul` and its `| 0` truncation, so /// this produces the same doubles in the same order as the reviewed gallery does. struct FacetsRandom: Sendable { private var state: UInt32 /// The 64-bit seed folded to the generator's 32-bit state through **xmur3's finalizer** — the /// avalanche half of the gallery's string hash. Folding rather than truncating matters: seeds /// minted from a counter differ only in their low bits, and a raw truncation would hand /// neighbouring seeds neighbouring first draws. init(seed: UInt64) { var h = UInt32(truncatingIfNeeded: seed ^ (seed >> 32)) h ^= h >> 16 h = h &* 2_246_822_507 h ^= h >> 13 h = h &* 3_266_489_909 h ^= h >> 16 state = h } /// The next draw in 0..<1. mutating func next() -> Double { state = state &+ 0x6D2B_79F5 var t = state t = (t ^ (t >> 15)) &* (t | 1) t ^= t &+ ((t ^ (t >> 7)) &* (t | 61)) return Double(t ^ (t >> 14)) / 4_294_967_296 } /// A draw scaled into `lower.. Double { lower + next() * (upper - lower) } }