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 } }