442 lines
20 KiB
Swift
442 lines
20 KiB
Swift
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
|
||
}
|
||
|
||
/// **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..<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)
|
||
}
|
||
}
|