324 lines
14 KiB
Swift
324 lines
14 KiB
Swift
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°, H−120°, 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 0…100.
|
||
public var saturation: Double
|
||
|
||
/// Percent, clamped 0…100.
|
||
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 0…1. 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
|
||
}
|
||
}
|