The Background tab fills in — facets rendered to order, eight hues in a carousel

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 16:08:29 -04:00
parent 56e37be158
commit fb96e30df0
19 changed files with 3031 additions and 38 deletions
@@ -0,0 +1,441 @@
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 01,
/// y 00.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.080.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.080.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)
}
}