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

324 lines
14 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
// 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 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
}
}