Files
lanework/Kanban/UI/Board/Backgrounds/FacetsGenerator.swift
T

442 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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..<columns {
for row in 0..<rows {
// Two draws, x then y — the order the JS array literal evaluates in.
let x = x0 + (Double(column) + random.uniform(0.08, 0.92)) * cellWidth
let y = y0 + (Double(row) + random.uniform(0.08, 0.92)) * cellHeight
points.append(CGPoint(x: x, y: y))
}
}
return points
}
/// One triangle's colour: a weighted hue pick, then the three jitters.
///
/// The gallery rounds each channel to one decimal on its way into a CSS string; that is a
/// serialization artefact of emitting text, not part of the recipe, so nothing rounds here.
static func color(
hues: [FacetsRecipe.WeightedHue],
level: FacetsRecipe.Level,
using random: inout FacetsRandom
) -> 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
}
/// **BowyerWatson**, 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**. BowyerWatson 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 14 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..<count {
let point = vertices[index]
var bad: [IndexedTriangle] = []
var kept: [Working] = []
kept.reserveCapacity(working.count)
for entry in working {
if let circle = entry.circle, circle.contains(point) {
bad.append(entry.triangle)
} else {
kept.append(entry)
}
}
working = kept
// The cavity's boundary: an edge shared by two removed triangles is interior and dies
// with them; one held by a single triangle is the hole's rim and gets a new face.
//
// The rim is walked in the removed triangles' own vertex order — **not** the map's, and
// not a normalized one. Insertion order here is the order the faces come out in, and the
// face order is the order the colour draws are consumed in, so a tidier walk would be a
// different picture from the same seed.
var edgeCounts: [Edge: Int] = [:]
for triangle in bad {
for edge in triangle.orderedEdges { edgeCounts[Edge(edge.from, edge.to), default: 0] += 1 }
}
for triangle in bad {
for edge in triangle.orderedEdges where edgeCounts[Edge(edge.from, edge.to)] == 1 {
guard let circle = circumcircle(vertices[edge.from], vertices[edge.to], point) else {
continue
}
working.append(Working(
triangle: IndexedTriangle(a: edge.from, b: edge.to, c: index),
circle: circle
))
}
}
}
return working.map(\.triangle).filter { $0.a < count && $0.b < count && $0.c < count }
}
/// An undirected edge, keyed the way the HTML keys its `edgeCount` map: low index first, so the
/// same edge seen from either of its triangles is one key.
struct Edge: Hashable {
var low: Int
var high: Int
init(_ first: Int, _ second: Int) {
low = min(first, second)
high = max(first, second)
}
}
struct Circumcircle {
var x: Double
var y: Double
var radiusSquared: Double
func contains(_ point: CGPoint) -> 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..<upper`.
mutating func uniform(_ lower: Double, _ upper: Double) -> Double {
lower + next() * (upper - lower)
}
}