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:
@@ -0,0 +1,468 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `FacetsGenerator` — the Swift port of the reviewed faceted gallery
|
||||
/// (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery; `board-backgrounds-faceted.html`).
|
||||
///
|
||||
/// What is worth pinning here is not "the picture looks nice", which no test can say, but the four
|
||||
/// properties the feature is built on:
|
||||
///
|
||||
/// - **A seed names a composition** — so the picker's preview and the file written from it are the
|
||||
/// same picture.
|
||||
/// - **The triangulation is a triangulation** — it tiles its points' convex hull exactly and every
|
||||
/// face is Delaunay, checked against the hull rather than against a remembered number.
|
||||
/// - **The mesh is full bleed** — proved twice over, once as arithmetic on the density constants
|
||||
/// (margin ≥ 0.92 × cell, which holds for every seed at once) and once on the meshes themselves
|
||||
/// (boundary points clear the frame, the frame's corners are covered). This is the property the
|
||||
/// reviewed gallery did *not* have; see `FacetsRecipe.Density`.
|
||||
/// - **The colour model is HSL** — the reason the swatches look like the ones that were reviewed.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func recipe(
|
||||
hue: FacetsRecipe.Hue = .sky,
|
||||
strategy: FacetsRecipe.Strategy = .duo,
|
||||
density: FacetsRecipe.Density = .medium,
|
||||
tone: FacetsRecipe.Tone = .light,
|
||||
saturation: FacetsRecipe.Saturation = .mid,
|
||||
seed: UInt64 = 0x5EED
|
||||
) -> FacetsRecipe {
|
||||
FacetsRecipe(
|
||||
hue: hue, strategy: strategy, density: density,
|
||||
tone: tone, saturation: saturation, seed: seed
|
||||
)
|
||||
}
|
||||
|
||||
/// The convex hull, by monotone chain — the region a Delaunay triangulation of these points must
|
||||
/// cover exactly, which is the sharpest thing that can be asked of the port.
|
||||
private func convexHull(_ points: [CGPoint]) -> [CGPoint] {
|
||||
let sorted = points.sorted { $0.x == $1.x ? $0.y < $1.y : $0.x < $1.x }
|
||||
guard sorted.count >= 3 else { return sorted }
|
||||
|
||||
func cross(_ o: CGPoint, _ a: CGPoint, _ b: CGPoint) -> Double {
|
||||
Double(a.x - o.x) * Double(b.y - o.y) - Double(a.y - o.y) * Double(b.x - o.x)
|
||||
}
|
||||
func chain(_ points: [CGPoint]) -> [CGPoint] {
|
||||
var hull: [CGPoint] = []
|
||||
for point in points {
|
||||
while hull.count >= 2, cross(hull[hull.count - 2], hull[hull.count - 1], point) <= 0 {
|
||||
hull.removeLast()
|
||||
}
|
||||
hull.append(point)
|
||||
}
|
||||
return hull
|
||||
}
|
||||
// Each half drops its own last point, which is the other half's first.
|
||||
return Array(chain(sorted).dropLast()) + Array(chain(sorted.reversed()).dropLast())
|
||||
}
|
||||
|
||||
/// The gallery's own cell dimensions: its 1.15 × 0.775 region (552 × 372 at the 480-wide scale, in
|
||||
/// unit terms) divided by its 5×3, 9×6 and 14×9 grids.
|
||||
private let reviewedCells: [(density: FacetsRecipe.Density, width: Double, height: Double)] = [
|
||||
(.coarse, 1.15 / 5, 0.775 / 3),
|
||||
(.medium, 1.15 / 9, 0.775 / 6),
|
||||
(.fine, 1.15 / 14, 0.775 / 9),
|
||||
]
|
||||
|
||||
/// Whether `point` is inside `face`, edges included — the three edge cross-products agreeing in
|
||||
/// sign. The tolerance admits a point exactly on an edge, which every frame corner shared by two
|
||||
/// faces is.
|
||||
private func contains(_ face: FacetsGenerator.Face, _ point: CGPoint) -> Bool {
|
||||
func side(_ a: CGPoint, _ b: CGPoint) -> Double {
|
||||
Double(b.x - a.x) * Double(point.y - a.y) - Double(b.y - a.y) * Double(point.x - a.x)
|
||||
}
|
||||
let first = side(face.a, face.b)
|
||||
let second = side(face.b, face.c)
|
||||
let third = side(face.c, face.a)
|
||||
let epsilon = 1e-12
|
||||
return (first >= -epsilon && second >= -epsilon && third >= -epsilon)
|
||||
|| (first <= epsilon && second <= epsilon && third <= epsilon)
|
||||
}
|
||||
|
||||
/// A simple polygon's area, by the shoelace formula.
|
||||
private func polygonArea(_ polygon: [CGPoint]) -> Double {
|
||||
guard polygon.count >= 3 else { return 0 }
|
||||
var total = 0.0
|
||||
for index in polygon.indices {
|
||||
let a = polygon[index]
|
||||
let b = polygon[(index + 1) % polygon.count]
|
||||
total += Double(a.x) * Double(b.y) - Double(b.x) * Double(a.y)
|
||||
}
|
||||
return abs(total) / 2
|
||||
}
|
||||
|
||||
/// The image a PNG payload decodes to — the only way to ask what was actually encoded rather than
|
||||
/// what was handed to the encoder.
|
||||
private func decoded(_ data: Data) -> CGImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
|
||||
return CGImageSourceCreateImageAtIndex(source, 0, nil)
|
||||
}
|
||||
|
||||
// MARK: - A seed names a composition
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ determinism")
|
||||
struct FacetsDeterminismTests {
|
||||
|
||||
/// The whole reason the generator is pure: the picker's preview, the file written to the board
|
||||
/// folder, and a re-render on another Mac next year are one picture.
|
||||
@Test("The same recipe and seed render identical bytes")
|
||||
func sameSeedSameBytes() throws {
|
||||
let first = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
|
||||
let second = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
/// Reroll's whole job.
|
||||
@Test("A different seed renders different bytes")
|
||||
func differentSeedDiffers() throws {
|
||||
let first = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 1), pixelWidth: 96))
|
||||
let second = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 2), pixelWidth: 96))
|
||||
#expect(first != second)
|
||||
}
|
||||
|
||||
/// **Normalized space, doing its job**: the composition is the same mesh at any output size, so
|
||||
/// the geometry a small preview shows is the geometry the 3072 px file has.
|
||||
@Test("Size changes the pixels, never the mesh")
|
||||
func sizeDoesNotChangeTheMesh() {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe())
|
||||
let again = FacetsGenerator.facets(recipe: recipe())
|
||||
#expect(mesh == again)
|
||||
|
||||
let small = FacetsGenerator.render(recipe: recipe(), pixelWidth: 64)
|
||||
let large = FacetsGenerator.render(recipe: recipe(), pixelWidth: 640)
|
||||
#expect(small?.width == 64)
|
||||
#expect(large?.width == 640)
|
||||
}
|
||||
|
||||
/// Every axis is part of the identity — a picker that changed one of them and got the same
|
||||
/// picture back would be a picker with a dead control.
|
||||
@Test("Each axis changes the picture")
|
||||
func everyAxisMatters() {
|
||||
let base = FacetsGenerator.facets(recipe: recipe())
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(hue: .rose)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(strategy: .trio)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(density: .fine)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(tone: .dark)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(saturation: .rich)) != base)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The mesh
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ the mesh")
|
||||
struct FacetsMeshTests {
|
||||
|
||||
/// **The triangulation tiles its points' convex hull exactly** — no hole, no overlap, nothing
|
||||
/// left over. Total face area against the hull's own area is the whole claim in one number, and
|
||||
/// it is the claim that matters here: a hole is a patch of flat ground colour in the middle of
|
||||
/// the picture, which is exactly the artefact the small super-triangle in the gallery's own
|
||||
/// generator produces and this port's larger one does not (`FacetsGenerator.triangulate`).
|
||||
///
|
||||
/// Checked across every density and a dozen seeds rather than one, because a triangulator's
|
||||
/// failures are input-shaped.
|
||||
///
|
||||
/// The tolerance is what a *hull* can honestly promise: when a point lands essentially on the
|
||||
/// line between its two neighbours, the sliver between them has no circumcircle to speak of and
|
||||
/// is not made. Swept over 600 meshes the largest such gap is 5.5 × 10⁻⁵ of a unit square; the
|
||||
/// smallest hole a *missing face* could leave is a fraction of a cell, and the smallest cell in
|
||||
/// the table is fine's at 7.2 × 10⁻³. The threshold sits between the two.
|
||||
@Test("The mesh tiles the hull exactly", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
|
||||
func meshTilesTheHull(density: FacetsRecipe.Density) {
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
#expect(points.count == density.columns * density.rows)
|
||||
|
||||
let hull = polygonArea(convexHull(points))
|
||||
let tiled = FacetsGenerator.triangulate(points).reduce(0.0) { total, triangle in
|
||||
total + FacetsGenerator.Face(
|
||||
a: points[triangle.a], b: points[triangle.b], c: points[triangle.c],
|
||||
color: FacetsColor(hue: 0, saturation: 0, lightness: 0)
|
||||
).area
|
||||
}
|
||||
#expect(abs(tiled - hull) < 3e-4, "density \(density), seed \(seed): \(tiled) vs hull \(hull)")
|
||||
}
|
||||
}
|
||||
|
||||
/// **The Delaunay property itself**: no point sits inside another triangle's circumcircle. A
|
||||
/// tiling alone could be any triangulation — this is the one the recipe names, and the one whose
|
||||
/// fat triangles make the mesh read as facets rather than as splinters.
|
||||
///
|
||||
/// The tolerance is relative and tiny; it exists because four points can be *nearly* cocircular,
|
||||
/// not because the predicate is soft.
|
||||
@Test("Every face is Delaunay", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
|
||||
func facesAreDelaunay(density: FacetsRecipe.Density) {
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
for triangle in FacetsGenerator.triangulate(points) {
|
||||
guard let circle = FacetsGenerator.circumcircle(
|
||||
points[triangle.a], points[triangle.b], points[triangle.c]
|
||||
) else {
|
||||
Issue.record("a face with no circumcircle survived")
|
||||
continue
|
||||
}
|
||||
for (index, point) in points.enumerated()
|
||||
where index != triangle.a && index != triangle.b && index != triangle.c {
|
||||
let dx = Double(point.x) - circle.x
|
||||
let dy = Double(point.y) - circle.y
|
||||
#expect(dx * dx + dy * dy >= circle.radiusSquared * (1 - 1e-9),
|
||||
"density \(density), seed \(seed): point \(index) is inside a face's circumcircle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The full-bleed inequality** — the one line the whole boundary ring exists to satisfy
|
||||
/// (`FacetsRecipe.Density.scatterMargin`).
|
||||
///
|
||||
/// A boundary cell's point is placed anywhere in the middle 84% of it, so the worst draw pushes
|
||||
/// it 0.92 of a cell *inward* from the outer edge. `margin ≥ 0.92 × cell` on both axes is
|
||||
/// therefore exactly "no draw can put a boundary point inside the picture", which is what makes
|
||||
/// the frame interior to the hull for **every** seed rather than for most of them.
|
||||
///
|
||||
/// It is arithmetic on constants, so it holds for all seeds at once — the sharpest form the
|
||||
/// claim has, and the one that would catch a future density added with a margin copied from its
|
||||
/// neighbour.
|
||||
@Test("Every density's margin outruns its worst jitter draw", arguments: FacetsRecipe.Density.allCases)
|
||||
func marginOutrunsTheJitter(density: FacetsRecipe.Density) {
|
||||
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
|
||||
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
|
||||
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellWidth,
|
||||
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellWidth)")
|
||||
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellHeight,
|
||||
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellHeight)")
|
||||
}
|
||||
|
||||
/// **The reviewed facet size, preserved** — the number a viewer actually reads as "coarse" or
|
||||
/// "fine" (`FacetsRecipe.Density`).
|
||||
///
|
||||
/// The grid grew when the boundary ring went in (5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10), and this
|
||||
/// is the guard that says it grew *outward*: the cell is still the gallery's 1.15/5, 1.15/9 and
|
||||
/// 1.15/14 in unit terms, so the same number of facets falls inside the picture as did in the
|
||||
/// swatches that were reviewed. Stretching the cells to reach the edges instead would have kept
|
||||
/// the point counts and changed every density's character.
|
||||
@Test("The cell size is the gallery's", arguments: reviewedCells)
|
||||
func cellSizeMatchesTheGallery(density: FacetsRecipe.Density, width: Double, height: Double) {
|
||||
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
|
||||
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
|
||||
// 5%, which is what round margins cost: medium and fine land within half a percent on both
|
||||
// axes, and coarse's cell comes out 4% shorter — its ring is 1.3 cells deep, so squaring the
|
||||
// grid up moved the height and left the width exactly where it was.
|
||||
#expect(abs(cellWidth - width) / width < 0.05, "\(density) width \(cellWidth) vs \(width)")
|
||||
#expect(abs(cellHeight - height) / height < 0.05, "\(density) height \(cellHeight) vs \(height)")
|
||||
}
|
||||
|
||||
/// The facet counts that follow from those cells: how many faces land **inside the picture**,
|
||||
/// which is the number the gallery's "≈20 · ≈97 · ≈230" was describing. The ring's own faces are
|
||||
/// cropped away and are not part of what anyone judged.
|
||||
@Test("The visible facet count matches the reviewed density")
|
||||
func visibleDensityMatchesTheGallery() {
|
||||
func visible(_ density: FacetsRecipe.Density, seed: UInt64) -> Int {
|
||||
FacetsGenerator.facets(recipe: recipe(density: density, seed: seed)).faces.count { face in
|
||||
let x = Double(face.a.x + face.b.x + face.c.x) / 3
|
||||
let y = Double(face.a.y + face.b.y + face.c.y) / 3
|
||||
return x >= 0 && x <= 1 && y >= 0 && y <= FacetsGenerator.frameHeight
|
||||
}
|
||||
}
|
||||
for seed in UInt64(1)...12 {
|
||||
#expect((14...30).contains(visible(.coarse, seed: seed)), "coarse: \(visible(.coarse, seed: seed))")
|
||||
#expect((62...84).contains(visible(.medium, seed: seed)), "medium: \(visible(.medium, seed: seed))")
|
||||
#expect((160...185).contains(visible(.fine, seed: seed)), "fine: \(visible(.fine, seed: seed))")
|
||||
}
|
||||
}
|
||||
|
||||
/// **Full bleed, checked on the points** — the inequality above, arrived at from the other end.
|
||||
///
|
||||
/// Every point in the first and last column sits at or beyond the left and right frame edges,
|
||||
/// and every point in the first and last row at or beyond the top and bottom. That is what makes
|
||||
/// the picture interior to the convex hull: each of its four sides has a wall of points past it.
|
||||
@Test("Every boundary point lands outside the frame on its own side",
|
||||
arguments: FacetsRecipe.Density.allCases)
|
||||
func boundaryPointsClearTheFrame(density: FacetsRecipe.Density) {
|
||||
let rows = density.rows
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
for (index, point) in points.enumerated() {
|
||||
// Column-major: index = column × rows + row (see `FacetsGenerator.scatter`).
|
||||
let column = index / rows
|
||||
let row = index % rows
|
||||
if column == 0 { #expect(Double(point.x) <= 0, "\(density)/\(seed): left \(point.x)") }
|
||||
if column == density.columns - 1 {
|
||||
#expect(Double(point.x) >= 1, "\(density)/\(seed): right \(point.x)")
|
||||
}
|
||||
if row == 0 { #expect(Double(point.y) <= 0, "\(density)/\(seed): top \(point.y)") }
|
||||
if row == rows - 1 {
|
||||
#expect(Double(point.y) >= FacetsGenerator.frameHeight,
|
||||
"\(density)/\(seed): bottom \(point.y)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **And full bleed, checked on the pixels that matter**: the four frame corners are each inside
|
||||
/// some face. A corner is where a triangulation's coverage fails first, and a corner showing flat
|
||||
/// ground colour is the artefact this whole ring was built to remove.
|
||||
///
|
||||
/// The total-area check rides along. Over 200 seeds a density the *worst* mesh still covers
|
||||
/// 2.15× the frame at coarse and 1.49× at medium and fine, so the arithmetic is never close —
|
||||
/// which is the point of a ring sized against the jitter rather than against a taste for how
|
||||
/// much overhang looks like enough.
|
||||
@Test("The frame's corners are covered", arguments: FacetsRecipe.Density.allCases)
|
||||
func frameCornersAreCovered(density: FacetsRecipe.Density) {
|
||||
let height = FacetsGenerator.frameHeight
|
||||
let corners = [
|
||||
CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0),
|
||||
CGPoint(x: 1, y: height), CGPoint(x: 0, y: height),
|
||||
]
|
||||
for seed in UInt64(1)...12 {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe(density: density, seed: seed))
|
||||
#expect(mesh.faces.reduce(0) { $0 + $1.area } >= height, "\(density)/\(seed): total area")
|
||||
for corner in corners {
|
||||
#expect(mesh.faces.contains { contains($0, corner) },
|
||||
"density \(density), seed \(seed): corner \(corner) shows ground colour")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No face may be degenerate: a zero-area triangle is a circumcircle the guard should have
|
||||
/// refused, and a stroked sliver is a visible scratch across the picture.
|
||||
@Test("No face is degenerate")
|
||||
func facesHaveArea() {
|
||||
for seed in UInt64(1)...12 {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe(density: .fine, seed: seed))
|
||||
#expect(mesh.faces.allSatisfy { $0.area > 0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Points land inside their own cell's middle band, which is what keeps neighbours from
|
||||
/// coinciding — and inside the outset region, which everything above rests on.
|
||||
@Test("The scatter stays inside the outset region", arguments: FacetsRecipe.Density.allCases)
|
||||
func scatterStaysInTheRegion(density: FacetsRecipe.Density) {
|
||||
var random = FacetsRandom(seed: 7)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
let margin = density.scatterMargin
|
||||
#expect(points.allSatisfy { Double($0.x) >= -margin && Double($0.x) <= 1 + margin })
|
||||
#expect(points.allSatisfy {
|
||||
Double($0.y) >= -margin && Double($0.y) <= FacetsGenerator.frameHeight + margin
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colour
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ colour is HSL")
|
||||
struct FacetsColorTests {
|
||||
|
||||
/// **Hand-computed CSS `hsl()`**, which is the whole claim: the gallery's swatches are `hsl()`
|
||||
/// strings, so a port that reached for `NSColor`'s hue/saturation/**brightness** would render a
|
||||
/// different set of colours from the ones that were reviewed.
|
||||
@Test("primaryColorHex is the recipe's level as CSS reads it", arguments: [
|
||||
(FacetsRecipe.Hue.sky, FacetsRecipe.Tone.light, FacetsRecipe.Saturation.soft, "#E0E5EB"),
|
||||
(.amber, .dark, .rich, "#513D1A"),
|
||||
(.forest, .light, .rich, "#BFF3D0"),
|
||||
(.rose, .dark, .soft, "#32242A"),
|
||||
(.clay, .light, .mid, "#EDD7D4"),
|
||||
(.iris, .dark, .mid, "#2C2041"),
|
||||
])
|
||||
func primaryColorMatchesHSL(
|
||||
hue: FacetsRecipe.Hue,
|
||||
tone: FacetsRecipe.Tone,
|
||||
saturation: FacetsRecipe.Saturation,
|
||||
expected: String
|
||||
) {
|
||||
let recipe = recipe(hue: hue, tone: tone, saturation: saturation)
|
||||
#expect(recipe.primaryColorHex == expected)
|
||||
}
|
||||
|
||||
/// The ground the generator paints under the mesh is the recipe's own primary — the value the
|
||||
/// board's `background.color` is set to, so the underlay and the picture agree.
|
||||
@Test("The ground is the primary colour")
|
||||
func groundIsThePrimary() {
|
||||
let recipe = recipe(hue: .teal, tone: .light, saturation: .soft)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe).ground == recipe.primaryColor)
|
||||
#expect(recipe.primaryColorHex == "#E0EBEA")
|
||||
}
|
||||
|
||||
/// The strategies' weighted lists, as the gallery states them — mono one hue, duo the complement
|
||||
/// at 65/35, trio the triad at 50/30/20 — each summing to 1, which is what the single-draw pick
|
||||
/// assumes.
|
||||
@Test("The hue lists are the reviewed ones")
|
||||
func hueListsMatchTheGallery() {
|
||||
#expect(recipe(hue: .sky, strategy: .mono).hues.map(\.degrees) == [215])
|
||||
#expect(recipe(hue: .sky, strategy: .duo).hues.map(\.degrees) == [215, 395])
|
||||
#expect(recipe(hue: .sky, strategy: .trio).hues.map(\.degrees) == [215, 335, 95])
|
||||
for strategy in FacetsRecipe.Strategy.allCases {
|
||||
let total = recipe(strategy: strategy).hues.reduce(0) { $0 + $1.weight }
|
||||
#expect(abs(total - 1) < 1e-12, "\(strategy) weights must sum to 1")
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightness stays in the narrow band the recipe names — "brightness stays a narrow per-triangle
|
||||
/// jitter around the tone base", which is the property that makes a whole swatch read as one
|
||||
/// surface. Hue and saturation jitter around theirs the same way.
|
||||
@Test("Every face jitters inside its recipe's bands")
|
||||
func facesStayInTheirBands() {
|
||||
let recipe = recipe(hue: .forest, strategy: .mono, density: .fine, tone: .dark, saturation: .rich)
|
||||
let level = recipe.level
|
||||
for face in FacetsGenerator.facets(recipe: recipe).faces {
|
||||
#expect(abs(face.color.lightness - level.lightness) <= FacetsRecipe.lightnessJitter)
|
||||
#expect(face.color.saturation >= level.saturation * 0.85)
|
||||
#expect(face.color.saturation <= level.saturation * 1.15)
|
||||
// Mono: one hue, ±3° — wrapped, so 140 ± 3 stays comfortably away from the seam.
|
||||
#expect(abs(face.color.hue - 140) <= 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrap that a triad needs: `H − 120` is negative for every hue below 120°, and a colour at
|
||||
/// −112° is a colour at 248°, not a colour at 0.
|
||||
@Test("A negative triad hue wraps rather than clamping")
|
||||
func negativeHuesWrap() {
|
||||
#expect(FacetsColor(hue: -112, saturation: 50, lightness: 50).hue == 248)
|
||||
#expect(FacetsColor(hue: 395, saturation: 50, lightness: 50).hue == 35)
|
||||
// Saturation and lightness clamp instead — they are percentages, not angles.
|
||||
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).saturation == 100)
|
||||
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).lightness == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The encoded file
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ the PNG")
|
||||
struct FacetsPNGTests {
|
||||
|
||||
/// 16:10, rounded — the aspect the board window is judged at and the one every swatch was
|
||||
/// reviewed in.
|
||||
@Test("The payload decodes at the requested width and a 16:10 height", arguments: [64, 480, 1024])
|
||||
func decodesAtTheRequestedSize(width: Int) throws {
|
||||
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: width))
|
||||
let image = try #require(decoded(data))
|
||||
#expect(image.width == width)
|
||||
#expect(image.height == FacetsGenerator.pixelHeight(forWidth: width))
|
||||
#expect(image.height == Int((Double(width) * 10 / 16).rounded()))
|
||||
}
|
||||
|
||||
/// It really is a PNG — the first eight bytes of the format's own signature — because the board
|
||||
/// frontmatter is about to name this file and `BoardBackdrop.decode` will be asked to read it.
|
||||
@Test("The payload is a PNG")
|
||||
func payloadIsPNG() throws {
|
||||
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 64))
|
||||
#expect(Array(data.prefix(8)) == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
}
|
||||
|
||||
/// Opaque, and stated: the backdrop sits under lane plates and card faces, and an image carrying
|
||||
/// alpha would let the window's own background through in a way no swatch was reviewed with.
|
||||
@Test("The render is opaque")
|
||||
func renderIsOpaque() throws {
|
||||
let image = try #require(FacetsGenerator.render(recipe: recipe(), pixelWidth: 64))
|
||||
#expect(image.alphaInfo == .noneSkipLast)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user