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,323 @@
import CoreGraphics
import Foundation
// MARK: - FacetsRecipe
/// **What a generated board background is made of** the axis set the faceted gallery swept and the
/// review settled (DESIGN/explorations/board-backgrounds.md Faceted gallery, 2026-08-07).
///
/// The recipe plus its `seed` is the whole of the picture: `FacetsGenerator` is pure, so the same
/// pair renders the same mesh at any output size, on any machine, forever. That is what makes the
/// recipe worth being a value a board can carry one, a picker can offer one, and neither has to
/// hold a bitmap to mean something.
///
/// ### The colour model is HSL, not HSB
///
/// The gallery is a web page and its swatches are CSS `hsl()`, so the numbers below saturations of
/// 20/42/68, lightnesses of 90/88/85 are HSL numbers and only mean what the reviewer saw when they
/// are read as HSL. `FacetsColor` converts; nothing here reaches for `NSColor`, whose `saturation`
/// and `brightness` are the other model's and would land somewhere else entirely.
///
/// ### The generator source is authoritative
///
/// Every constant here restates one in `board-backgrounds-faceted.html`'s `genFacets`/`triangleColor`
/// pair. Where the two ever disagree the HTML is the reviewed artefact and this is the port.
public struct FacetsRecipe: Sendable, Equatable, Hashable {
/// The base hue the one every strategy below builds its list from.
public var hue: Hue
/// How many hues the mesh draws from, and in what proportion.
public var strategy: Strategy
/// How finely the frame is diced.
public var density: Density
/// Which end of the lightness range the whole swatch sits at.
public var tone: Tone
/// How much colour there is at that lightness.
public var saturation: Saturation
/// The composition's identity. Two renders of the same recipe under the same seed are the same
/// picture; changing it alone is the gallery's Reroll button.
public var seed: UInt64
public init(
hue: Hue,
strategy: Strategy,
density: Density,
tone: Tone,
saturation: Saturation,
seed: UInt64
) {
self.hue = hue
self.strategy = strategy
self.density = density
self.tone = tone
self.saturation = saturation
self.seed = seed
}
// MARK: Axes
/// The eight-hue wheel sweep 1 established and the finals return to (the faceted round narrowed
/// the *gallery* to four representatives to keep 216 swatches reviewable it never narrowed the
/// wheel).
public enum Hue: Sendable, Equatable, Hashable, CaseIterable {
case clay
case amber
case olive
case forest
case teal
case sky
case iris
case rose
/// Degrees on the colour wheel.
public var degrees: Double {
switch self {
case .clay: 8
case .amber: 38
case .olive: 80
case .forest: 140
case .teal: 175
case .sky: 215
case .iris: 262
case .rose: 335
}
}
}
/// How many hues a swatch draws from. The weights are the reviewed ones and they sum to 1 by
/// construction, which is what `FacetsGenerator`'s single-draw weighted pick assumes.
public enum Strategy: Sendable, Equatable, Hashable, CaseIterable {
/// One hue; only the lightness jitter draws the mesh.
case mono
/// The base plus its complement, 65/35 a dominant field with contrasting inclusions.
case duo
/// The contrasting triad H, H+120°, H120°, weighted 50/30/20.
case trio
}
/// Vertex count, as a grid of jittered cells laid over a region **larger than the picture**
/// and the size of that overhang, which is per-density for the reason below.
///
/// ### The cell size is the gallery's; the ring is new
///
/// What a viewer reads as "coarse" or "fine" is the size of a facet, not the number of points,
/// so the numbers preserved from the reviewed gallery are the **cell dimensions** 0.23 for
/// coarse, 0.128 for medium, 0.0827 for fine (its 1.15/5, 1.15/9, 1.15/14 in unit terms). The
/// grid then simply has however many cells it takes to cover the frame *plus* a boundary ring,
/// which is where the extra columns and rows come from: 5×3 7×5, 9×6 10×7, 14×9 15×10.
///
/// ### The ring is the full-bleed guarantee
///
/// The gallery used one margin for all three densities (0.075 in unit terms) and got away with
/// it: a mesh only covers its points' convex hull, and at medium and fine that margin left the
/// frame covered often enough that nobody looking at swatches would notice. It is not a
/// guarantee, though a boundary point is placed anywhere in the middle 84% of its cell, so the
/// worst draw puts it 0.92 of a cell *inward* of the region's edge, and against a margin of only
/// 0.075 every density could land inside the picture: coarse by 0.163, medium by 0.044, fine by
/// 0.0042. Each of those is a notch of flat ground colour on the frame edge, and coarse's a
/// sixth of the frame's height is one anybody would see.
///
/// So the margin is sized against the cell instead of fixed: **margin 0.92 × cell** on both
/// axes, which is exactly the statement "even the worst jitter draw leaves every boundary-cell
/// point at or beyond the frame edge". The whole frame is then interior to the hull and the mesh
/// is full-bleed by construction rather than by luck. `FacetsGeneratorTests` holds the inequality.
///
/// The ring's own triangles are drawn and then cropped away, which is what an oversized canvas
/// costs: a third of coarse's faces are never seen. That is the trade the gallery was already
/// making, made big enough to be a promise.
public enum Density: Sendable, Equatable, Hashable, CaseIterable {
case coarse
case medium
case fine
public var columns: Int {
switch self {
case .coarse: 7
case .medium: 10
case .fine: 15
}
}
public var rows: Int {
switch self {
case .coarse: 5
case .medium: 7
case .fine: 10
}
}
/// How far the scatter runs past the frame on every side, in width units the sacrificial
/// ring. Symmetric on both axes because the frame is, and the cells are very nearly square.
public var scatterMargin: Double {
switch self {
case .coarse: 0.305
case .medium: 0.14
case .fine: 0.12
}
}
}
/// Which end of the lightness range the swatch sits at. **Not a light/dark *pair*** a board
/// carries one background image and the app has no appearance-conditional backdrop, so this is a
/// choice the author makes once, like choosing a photograph.
public enum Tone: Sendable, Equatable, Hashable, CaseIterable {
case light
case dark
}
/// The saturation band. Rich rows get a little lightness headroom so the saturation actually
/// shows which is why the level below carries both numbers rather than a saturation alone.
public enum Saturation: Sendable, Equatable, Hashable, CaseIterable {
case soft
case mid
case rich
}
// MARK: The derived numbers
/// One tone × saturation cell: the base saturation and lightness every triangle jitters around.
public struct Level: Sendable, Equatable, Hashable {
public var saturation: Double
public var lightness: Double
}
/// **The per-triangle lightness jitter, ±4.5** the same for both tones, because the narrow
/// band *is* the recipe: "brightness stays a narrow per-triangle jitter around the tone base".
/// Widening it on either end would stop the mesh reading as one surface catching light.
public static let lightnessJitter: Double = 4.5
/// This recipe's saturation/lightness cell.
public var level: Level {
switch (tone, saturation) {
case (.light, .soft): Level(saturation: 20, lightness: 90)
case (.light, .mid): Level(saturation: 42, lightness: 88)
case (.light, .rich): Level(saturation: 68, lightness: 85)
case (.dark, .soft): Level(saturation: 16, lightness: 17)
case (.dark, .mid): Level(saturation: 34, lightness: 19)
case (.dark, .rich): Level(saturation: 52, lightness: 21)
}
}
/// The hues a triangle is picked from, with the weights that pick it. First entry is always the
/// base hue, which is also the ground the mesh is painted over.
public var hues: [WeightedHue] {
let base = hue.degrees
switch strategy {
case .mono:
return [WeightedHue(degrees: base, weight: 1)]
case .duo:
return [
WeightedHue(degrees: base, weight: 0.65),
WeightedHue(degrees: base + 180, weight: 0.35),
]
case .trio:
return [
WeightedHue(degrees: base, weight: 0.5),
WeightedHue(degrees: base + 120, weight: 0.3),
WeightedHue(degrees: base - 120, weight: 0.2),
]
}
}
/// One entry of the weighted hue list.
public struct WeightedHue: Sendable, Equatable, Hashable {
public var degrees: Double
public var weight: Double
}
/// **The ground the mesh is painted over** the base hue at the level's own saturation and
/// lightness, un-jittered. The rect the generator fills before the first triangle lands.
public var primaryColor: FacetsColor {
FacetsColor(hue: hue.degrees, saturation: level.saturation, lightness: level.lightness)
}
/// The same colour as `#RRGGBB`, uppercase **what the board's `background.color` gets set to**
/// when a generated image is applied (`BoardStore.applyGeneratedBackground`).
///
/// It is the honest fallback rather than a decoration: the colour underlay is what shows while
/// the backdrop decodes, what shows if the file is later deleted from the folder by hand, and
/// what a board copied without its image degrades to. Picking the mesh's own ground means all
/// three land on the picture's average rather than on white.
public var primaryColorHex: String { primaryColor.hexString }
}
// MARK: - FacetsColor
/// **One colour in the gallery's own model** HSL, in degrees and percent, converted to sRGB on
/// demand (see `FacetsRecipe`'s note on why this is not HSB).
///
/// Stored as it was computed rather than as components, so a colour can be compared, hashed and
/// printed in the numbers the recipe is written in.
public struct FacetsColor: Sendable, Equatable, Hashable {
/// Degrees, wrapped into 0..<360 the `mod360` the generator applies before every emission.
public var hue: Double
/// Percent, clamped 0100.
public var saturation: Double
/// Percent, clamped 0100.
public var lightness: Double
public init(hue: Double, saturation: Double, lightness: Double) {
self.hue = Self.wrapped(hue)
self.saturation = min(max(saturation, 0), 100)
self.lightness = min(max(lightness, 0), 100)
}
/// CSS's own `hsl()` sRGB, component-wise in 01. The chroma/secondary/match-lightness form,
/// which is the one the specification is written in and the one every browser implements.
public var components: (red: Double, green: Double, blue: Double) {
let saturation = saturation / 100
let lightness = lightness / 100
let chroma = (1 - abs(2 * lightness - 1)) * saturation
let sextant = hue / 60
let secondary = chroma * (1 - abs(sextant.truncatingRemainder(dividingBy: 2) - 1))
let match = lightness - chroma / 2
let (red, green, blue): (Double, Double, Double) = switch sextant {
case ..<1: (chroma, secondary, 0)
case ..<2: (secondary, chroma, 0)
case ..<3: (0, chroma, secondary)
case ..<4: (0, secondary, chroma)
case ..<5: (secondary, 0, chroma)
default: (chroma, 0, secondary)
}
return (red + match, green + match, blue + match)
}
/// `#RRGGBB`, uppercase the spelling `Palette`'s hex reader and the colour panel's round trip
/// both already speak (`NSColor.paletteHexString`), so a generated colour is indistinguishable
/// from a hand-written one on disk.
public var hexString: String {
let (red, green, blue) = components
return String(
format: "#%02X%02X%02X",
Self.byte(red), Self.byte(green), Self.byte(blue)
)
}
/// The colour as CoreGraphics wants it, in the space the digits name. **`space` is passed in
/// rather than made here** so a render creates one sRGB space for a whole mesh instead of one
/// per triangle.
func cgColor(in space: CGColorSpace) -> CGColor? {
let (red, green, blue) = components
return CGColor(colorSpace: space, components: [CGFloat(red), CGFloat(green), CGFloat(blue), 1])
}
private static func byte(_ component: Double) -> Int {
min(max(Int((component * 255).rounded()), 0), 255)
}
/// Degrees into 0..<360, negatives included `trio`'s third hue is `H 120`, which is negative
/// for every hue below clay's 8°.
private static func wrapped(_ degrees: Double) -> Double {
let wrapped = degrees.truncatingRemainder(dividingBy: 360)
return wrapped < 0 ? wrapped + 360 : wrapped
}
}