diff --git a/Tests/CoreRasterTests/ColorRangeTests.swift b/Tests/CoreRasterTests/ColorRangeTests.swift new file mode 100644 index 00000000..19fe70f9 --- /dev/null +++ b/Tests/CoreRasterTests/ColorRangeTests.swift @@ -0,0 +1,104 @@ +// ColorRangeTests.swift: fill-tools FT-4's acceptance, written first (FT-1); FT-4 +// removes the `.disabled` traits. Distances come from `FillCheck` (FloodFillTests). +import CoreRaster +import Testing + +@Suite struct ColorRangeTests { + /// The packet's falloff: `1 - smoothstep(0, f, d)`. + private func falloff(_ d: Double, fuzziness f: Double) -> Double { + let x = min(max(d / f, 0), 1) + return 1 - x * x * (3 - 2 * x) + } + + @Test(.disabled("waits on FT-4")) + func fuzzinessZeroCoversOnlyExactMatches() throws { + let scene = try FillScene() + let mask = try scene.buffer.colorRangeMask(around: FillScene.colorC, fuzziness: 0) + #expect(mask.width == scene.buffer.width && mask.height == scene.buffer.height) + for y in 0.. 0 && ring < 0.1, "ring coverage \(ring)") + for y in 0..= 0.2 { #expect(got == 0) } + } + } + // A row stepping from C to A in tenths: coverage never rises with distance. + var ramp = try PixelBuffer(width: 11, height: 1) + for k in 0...10 { + let f = Float(k) / 10, (a, c) = (FillScene.colorA, FillScene.colorC) + try ramp.setPixel(x: k, y: 0, RGBA(r: c.r + (a.r - c.r) * f, g: c.g + (a.g - c.g) * f, + b: c.b + (a.b - c.b) * f, a: 1)) + } + let rampMask = try ramp.colorRangeMask(around: FillScene.colorC, fuzziness: 0.2) + let values = try (0...10).map { try rampMask.coverage(x: $0, y: 0) } + #expect(values[0] == 1 && values[10] == 0) + #expect(zip(values, values.dropFirst()).allSatisfy { $0 >= $1 }, "\(values)") + #expect(values[2] > 0.5 && values[2] < 1, "\(values)") + } + + @Test(.disabled("waits on FT-4")) + func threeByThreeMean() throws { + let noise = try FillFixtures.noise() + for (x, y, r) in [(3, 3, 1), (1, 1, 1), (5, 2, 1), (3, 3, 2)] { + let got = try noise.sampledColor(at: (x: x, y: y), radius: r) + let want = try FillFixtures.boxMean(noise, x: x, y: y, radius: r) + for (g, w) in [(got.r, want.r), (got.g, want.g), (got.b, want.b), (got.a, want.a)] { + #expect(abs(g - w) < 1e-5, "(\(x), \(y)) radius \(r): \(got) vs \(want)") + } + } + #expect(try noise.sampledColor(at: (x: 3, y: 3), radius: 0) == noise.pixel(x: 3, y: 3)) + } + + @Test(.disabled("waits on FT-4")) + func badArgumentsThrow() throws { + let noise = try FillFixtures.noise() + for radius in [3, -1, Int.max] { + #expect(throws: RasterError.self) { try noise.sampledColor(at: (x: 3, y: 3), radius: radius) } + } + for p in [(x: 7, y: 0), (x: 0, y: 7), (x: -1, y: 3)] { + #expect(throws: RasterError.self) { try noise.sampledColor(at: p, radius: 0) } + } + for f: Float in [-0.01, 1.01, .nan] { + #expect(throws: RasterError.self) { try noise.colorRangeMask(around: FillScene.colorA, fuzziness: f) } + } + #expect(throws: RasterError.self) { try noise.colorRangeMask(around: RGBA(r: .nan, g: 0, b: 0, a: 1), fuzziness: 0.2) } + } + + /// Seeded colours and fuzziness, in and out of range: the mask throws + /// `RasterError` or has coverage in 0...1, and two runs are `==`. + @Test(.disabled("waits on FT-4")) + func fuzzedArgumentsThrowOrStayInRange() throws { + let noise = try FillFixtures.noise() + var rng = SplitMix64(seed: 0xF7_04) + for _ in 0..<100 { + let color = RGBA(r: .random(in: -0.5...1.5, using: &rng), g: .random(in: 0...1, using: &rng), + b: .random(in: 0...1, using: &rng), a: .random(in: 0...1, using: &rng)) + let f = Float.random(in: -0.2...1.2, using: &rng) + do { + let mask = try noise.colorRangeMask(around: color, fuzziness: f) + #expect(mask == (try noise.colorRangeMask(around: color, fuzziness: f)) && (0...49).contains(mask.area)) + } catch is RasterError { + continue + } + } + } +} diff --git a/Tests/CoreRasterTests/FloodFillTests.swift b/Tests/CoreRasterTests/FloodFillTests.swift new file mode 100644 index 00000000..9550a73e --- /dev/null +++ b/Tests/CoreRasterTests/FloodFillTests.swift @@ -0,0 +1,181 @@ +// FloodFillTests.swift: fill-tools FT-3's acceptance, written first (FT-1); FT-3 +// removes the `.disabled` traits. `FillCheck` is shared with ColorRangeTests. +import CoreColor +import CoreRaster +import Testing +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +enum FillCheck { + /// The packet's colour distance: Euclidean OKLab distance of the straight + /// colours, alpha difference as a fourth axis. Pixels are premultiplied linear. + static func distance(_ p: RGBA, _ q: RGBA) throws -> Double { + let (sp, sq) = (p.unpremultiplied(), q.unpremultiplied()) + func lab(_ s: RGBA) throws -> Color3 { try Color3(Double(s.r), Double(s.g), Double(s.b)).converted(from: .linearSRGB, to: .oklab) } + let (lp, lq) = (try lab(sp), try lab(sq)) + let (d0, d1, d2, da) = (lp.c0 - lq.c0, lp.c1 - lq.c1, lp.c2 - lq.c2, Double(sp.a - sq.a)) + return (d0 * d0 + d1 * d1 + d2 * d2 + da * da).squareRoot() + } + + /// Every pixel of `rect`, row by row. + static func pixels(_ rect: FillRect) -> [(x: Int, y: Int)] { + (rect.y..<(rect.y + rect.height)).flatMap { y in (rect.x..<(rect.x + rect.width)).map { (x: $0, y: y) } } + } + + /// The ring around `rect` without its four corners: the pixels that share an edge with it. + static func edgeRing(_ rect: FillRect) -> [(x: Int, y: Int)] { + let outer = FillRect(x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2) + let (columns, rows) = (rect.x..<(rect.x + rect.width), rect.y..<(rect.y + rect.height)) + return pixels(outer).filter { rect.ringContains(x: $0.x, y: $0.y) && (columns.contains($0.x) || rows.contains($0.y)) } + } +} + +@Suite struct FloodFillTests { + private func cover(_ mask: Mask, _ p: (x: Int, y: Int)) throws -> Float { try mask.coverage(x: p.x, y: p.y) } + + @Test(.disabled("waits on FT-3")) + func regionCoversExactlyItsInterior() throws { + let scene = try FillScene() + let mask = try scene.buffer.floodMask(from: scene.seedB, tolerance: .within(0.1)) + #expect(mask.width == scene.buffer.width && mask.height == scene.buffer.height) + #expect(mask == (try scene.buffer.floodMask(from: scene.seedB, tolerance: .within(0.1)))) // deterministic + for y in 0.. Int { + var usage = rusage() + #if canImport(Darwin) + guard getrusage(RUSAGE_SELF, &usage) == 0 else { return 0 } + return Int(usage.ru_maxrss) + #else + guard getrusage(__rusage_who_t(RUSAGE_SELF.rawValue), &usage) == 0 else { return 0 } + return Int(usage.ru_maxrss) * 1024 + #endif + } +} diff --git a/Tests/CoreRasterTests/RasterGradientTests.swift b/Tests/CoreRasterTests/RasterGradientTests.swift new file mode 100644 index 00000000..bec735c2 --- /dev/null +++ b/Tests/CoreRasterTests/RasterGradientTests.swift @@ -0,0 +1,115 @@ +// RasterGradientTests.swift: fill-tools FT-5's acceptance, written first (FT-1); +// FT-5 removes the `.disabled` traits. A black-to-white ramp in linear light, +// dither off, makes each pixel's red its `t` (FT-6 checks dither on its own). +import CoreColor +import CoreRaster +import Testing + +@Suite struct RasterGradientTests { + private static let black = RGBA(r: 0, g: 0, b: 0, a: 1) + private static let white = RGBA(r: 1, g: 1, b: 1, a: 1) + private static let ramp = [RasterGradientStop(offset: 0, color: black), RasterGradientStop(offset: 1, color: white)] + + /// A 32 x 24 ramp of `shape`, so each pixel's red is its `t`. + private func render(_ shape: RasterGradientShape, from a: Mask.Point = FillFixtures.gradientA, + to b: Mask.Point = FillFixtures.gradientB) throws -> PixelBuffer { + try PixelBuffer.gradient(width: 32, height: 24, shape: shape, from: a, to: b, stops: Self.ramp, + space: .linear, dither: false) + } + + private func t(_ image: PixelBuffer, _ x: Int, _ y: Int) throws -> Double { Double(try image.pixel(x: x, y: y).r) } + + @Test(.disabled("waits on FT-5")) + func shapesMatchAnalyticT() throws { + for c in FillFixtures.gradientCases { + let got = try t(try render(c.shape), c.x, c.y) + #expect(abs(got - c.t) < 1e-6, "\(c.shape) at (\(c.x), \(c.y)): \(got), want \(c.t)") + } + for shape in [RasterGradientShape.linear, .radial, .angle, .reflected, .diamond] { + let image = try render(shape), again = try render(shape) + #expect(image.width == 32 && image.height == 24 && image == again) // deterministic + for y in 0..<24 { + for x in 0..<32 { + let p = Mask.Point(x: Double(x) + 0.5, y: Double(y) + 0.5) + let want = FillFixtures.analyticT(shape, at: p, from: FillFixtures.gradientA, to: FillFixtures.gradientB) + #expect(abs(try t(image, x, y) - want) < 1e-5, "\(shape) at (\(x), \(y))") + } + } + } + } + + @Test(.disabled("waits on FT-5")) + func reflectedMirrors() throws { + let image = try render(.reflected) + for k in 1...9 { + #expect(try t(image, 10 - k, 10) == t(image, 10 + k, 10), "k = \(k)") + #expect(abs(try t(image, 10 - k, 10) - Double(k) / 10) < 1e-6, "k = \(k)") + } + #expect(try t(image, 10, 10) == 0) + } + + /// With `b` straight below `a`, angle 0 points down, not along +x. + @Test(.disabled("waits on FT-5")) + func angleStartsAtB() throws { + let a = FillFixtures.gradientA, b = Mask.Point(x: 10.5, y: 20.5) + let image = try render(.angle, from: a, to: b) + for (x, y, want) in [(10, 15, 0.0), (5, 10, 0.25), (10, 5, 0.5), (15, 10, 0.75), (5, 15, 0.125)] { + #expect(abs(try t(image, x, y) - want) < 1e-6, "(\(x), \(y))") + let p = Mask.Point(x: Double(x) + 0.5, y: Double(y) + 0.5) + #expect(abs(FillFixtures.analyticT(.angle, at: p, from: a, to: b) - want) < 1e-9) + } + } + + @Test(.disabled("waits on FT-5")) + func clampsPastEnd() throws { + let linear = try render(.linear) + for x in 20..<32 { #expect(try t(linear, x, 3) == 1, "x = \(x)") } + for x in 0...10 { #expect(try t(linear, x, 20) == 0, "x = \(x)") } + let radial = try render(.radial) + #expect(try t(radial, 31, 23) == 1) + #expect(try t(try render(.diamond), 0, 0) == 1) + } + + /// Red to green at `t` = 0.5 (pixel (15, 10)): linear light gives the + /// plain mean; perceptual gives the OKLab midpoint, which CoreColor states. + @Test(.disabled("waits on FT-5")) + func stopsInterpolateInOKLab() throws { + let red = RGBA(r: 1, g: 0, b: 0, a: 1), green = RGBA(r: 0, g: 1, b: 0, a: 1) + let stops = [RasterGradientStop(offset: 0, color: red), RasterGradientStop(offset: 1, color: green)] + func mid(_ space: GradientSpace) throws -> RGBA { + try PixelBuffer.gradient(width: 32, height: 24, shape: .linear, from: FillFixtures.gradientA, + to: FillFixtures.gradientB, stops: stops, space: space, dither: false).pixel(x: 15, y: 10) + } + let lr = try Color3(1, 0, 0).converted(from: .linearSRGB, to: .oklab) + let lg = try Color3(0, 1, 0).converted(from: .linearSRGB, to: .oklab) + let want = try Color3((lr.c0 + lg.c0) / 2, (lr.c1 + lg.c1) / 2, (lr.c2 + lg.c2) / 2) + .converted(from: .oklab, to: .linearSRGB) + let linear = try mid(.linear), perceptual = try mid(.perceptual) + #expect(abs(linear.r - 0.5) < 1e-6 && abs(linear.g - 0.5) < 1e-6 && abs(linear.b) < 1e-6) + for (got, w) in [(perceptual.r, want.c0), (perceptual.g, want.c1), (perceptual.b, want.c2)] { + #expect(abs(Double(got) - w) < 1e-4, "perceptual mid \(perceptual), want \(want)") + } + #expect(want.c0 - 0.5 > 0.1 && want.c1 - 0.5 < -0.1) // about +0.13 red, -0.11 green off the linear mid + } + + @Test(.disabled("waits on FT-5")) + func badStopsPointsOrSizeThrow() { + let (a, b) = (FillFixtures.gradientA, FillFixtures.gradientB) + let red = RGBA(r: 1, g: 0, b: 0, a: 1) + func stop(_ offset: Float, _ color: RGBA = red) -> RasterGradientStop { RasterGradientStop(offset: offset, color: color) } + let badStops: [[RasterGradientStop]] = [ + [], [stop(0)], [stop(0.8), stop(0.2)], [stop(-0.1), stop(1)], [stop(0), stop(1.1)], + [stop(0), stop(.nan)], [stop(0), stop(1, RGBA(r: .infinity, g: 0, b: 0, a: 1))], + (0...64).map { stop(Float($0) / 64) }, + ] + let nan = Mask.Point(x: .nan, y: 0), inf = Mask.Point(x: 1, y: .infinity) + let cases = badStops.map { (8, 8, a, b, $0) } + [(8, 8, a, a, Self.ramp), (8, 8, nan, b, Self.ramp), + (8, 8, a, inf, Self.ramp), (0, 8, a, b, Self.ramp), (8, 0, a, b, Self.ramp), (-1, 4, a, b, Self.ramp), + (Int.max, 2, a, b, Self.ramp)] + for (w, h, p, q, stops) in cases { + #expect(throws: RasterError.self) { + try PixelBuffer.gradient(width: w, height: h, shape: .radial, from: p, to: q, stops: stops) + } + } + } +}