diff --git a/Sources/ForgeColor/Curve.swift b/Sources/ForgeColor/Curve.swift new file mode 100644 index 0000000..7b2d3cb --- /dev/null +++ b/Sources/ForgeColor/Curve.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Monotone cubic (Fritsch–Carlson) tone curve. Monotonic control points +/// guarantee monotonic output — no spline overshoot, safe for grading. +public struct Curve: Equatable, Sendable, Codable { + public struct Point: Equatable, Sendable, Codable { + public var x: Float + public var y: Float + public init(x: Float, y: Float) { + self.x = x + self.y = y + } + } + + public private(set) var points: [Point] + /// Fritsch–Carlson tangents, one per point. + private var tangents: [Float] + + public init(points: [Point]) { + precondition(points.count >= 2, "curve needs >= 2 points") + let sorted = points.sorted { $0.x < $1.x } + self.points = sorted + self.tangents = Self.computeTangents(sorted) + } + + public static let identity = Curve(points: [.init(x: 0, y: 0), .init(x: 1, y: 1)]) + + public var isIdentity: Bool { self == .identity } + + private static func computeTangents(_ pts: [Point]) -> [Float] { + let n = pts.count + // Secant slopes. + var delta = [Float](repeating: 0, count: n - 1) + for i in 0..<(n - 1) { + let dx = pts[i + 1].x - pts[i].x + delta[i] = dx == 0 ? 0 : (pts[i + 1].y - pts[i].y) / dx + } + var m = [Float](repeating: 0, count: n) + m[0] = delta[0] + m[n - 1] = delta[n - 2] + for i in 1..<(n - 1) { + // Zero tangent at local extrema / flats prevents overshoot. + m[i] = (delta[i - 1] * delta[i] <= 0) ? 0 : (delta[i - 1] + delta[i]) / 2 + } + // Fritsch–Carlson limiter. + for i in 0..<(n - 1) { + if delta[i] == 0 { + m[i] = 0 + m[i + 1] = 0 + continue + } + let a = m[i] / delta[i] + let b = m[i + 1] / delta[i] + let s = a * a + b * b + if s > 9 { + let tau = 3 / sqrt(s) + m[i] = tau * a * delta[i] + m[i + 1] = tau * b * delta[i] + } + } + return m + } + + public func evaluate(_ x: Float) -> Float { + // Endpoint pinning. + if x <= points[0].x { return points[0].y } + if x >= points[points.count - 1].x { return points[points.count - 1].y } + // Find segment (linear scan; point counts are tiny). + var i = 0 + while i < points.count - 2 && x > points[i + 1].x { i += 1 } + let p0 = points[i], p1 = points[i + 1] + let h = p1.x - p0.x + if h == 0 { return p0.y } + let t = (x - p0.x) / h + let t2 = t * t + let t3 = t2 * t + // Cubic Hermite basis. + let h00 = 2 * t3 - 3 * t2 + 1 + let h10 = t3 - 2 * t2 + t + let h01 = -2 * t3 + 3 * t2 + let h11 = t3 - t2 + return h00 * p0.y + h10 * h * tangents[i] + h01 * p1.y + h11 * h * tangents[i + 1] + } + + // Codable: encode points only, rebuild tangents on decode. + enum CodingKeys: String, CodingKey { case points } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let pts = try c.decode([Point].self, forKey: .points) + self.init(points: pts) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(points, forKey: .points) + } + + public static func == (l: Curve, r: Curve) -> Bool { l.points == r.points } +} + +/// Master + per-channel curves. Master first, then channel curve. +public struct CurveSet: Equatable, Sendable, Codable { + public var master: Curve + public var red: Curve + public var green: Curve + public var blue: Curve + + public init(master: Curve = .identity, red: Curve = .identity, green: Curve = .identity, blue: Curve = .identity) { + self.master = master + self.red = red + self.green = green + self.blue = blue + } + + public static let identity = CurveSet() + + public var isIdentity: Bool { self == .identity } + + public func apply(_ rgb: SIMD3) -> SIMD3 { + let m = SIMD3(master.evaluate(rgb.x), master.evaluate(rgb.y), master.evaluate(rgb.z)) + return SIMD3(red.evaluate(m.x), green.evaluate(m.y), blue.evaluate(m.z)) + } +} diff --git a/Tests/ForgeColorTests/CurveTests.swift b/Tests/ForgeColorTests/CurveTests.swift new file mode 100644 index 0000000..3ee53b0 --- /dev/null +++ b/Tests/ForgeColorTests/CurveTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import ForgeColor + +final class CurveTests: XCTestCase { + + // Default 2-point identity curve is a no-op. + func testIdentityNoOp() { + let c = Curve.identity + for x: Float in [0, 0.1, 0.18, 0.5, 0.9, 1] { + XCTAssertEqual(c.evaluate(x), x, accuracy: 1e-6) + } + } + + // Curve passes exactly through its control points. + func testInterpolatesControlPoints() { + let c = Curve(points: [ + .init(x: 0, y: 0), + .init(x: 0.25, y: 0.4), + .init(x: 0.75, y: 0.7), + .init(x: 1, y: 1), + ]) + XCTAssertEqual(c.evaluate(0.25), 0.4, accuracy: 1e-5) + XCTAssertEqual(c.evaluate(0.75), 0.7, accuracy: 1e-5) + XCTAssertEqual(c.evaluate(0), 0, accuracy: 1e-5) + XCTAssertEqual(c.evaluate(1), 1, accuracy: 1e-5) + } + + // Monotonic control points -> monotonic output (no spline overshoot ringing). + func testMonotonicPreserved() { + let c = Curve(points: [ + .init(x: 0, y: 0), + .init(x: 0.1, y: 0.5), // steep segment then flat — classic overshoot trap + .init(x: 0.2, y: 0.55), + .init(x: 1, y: 1), + ]) + var prev: Float = -1 + for i in 0...1000 { + let y = c.evaluate(Float(i) / 1000) + XCTAssertGreaterThanOrEqual(y, prev - 1e-6, "non-monotonic at x=\(Float(i) / 1000)") + prev = y + } + } + + // Input outside domain pins to endpoint values. + func testEndpointPinning() { + let c = Curve(points: [.init(x: 0.2, y: 0.3), .init(x: 0.8, y: 0.9)]) + XCTAssertEqual(c.evaluate(0), 0.3, accuracy: 1e-6) + XCTAssertEqual(c.evaluate(-5), 0.3, accuracy: 1e-6) + XCTAssertEqual(c.evaluate(1), 0.9, accuracy: 1e-6) + XCTAssertEqual(c.evaluate(99), 0.9, accuracy: 1e-6) + } + + // Points auto-sorted by x. + func testUnsortedPointsSorted() { + let c = Curve(points: [.init(x: 1, y: 1), .init(x: 0, y: 0), .init(x: 0.5, y: 0.6)]) + XCTAssertEqual(c.evaluate(0.5), 0.6, accuracy: 1e-5) + } + + // CurveSet: master applies to all channels, then per-channel. + func testCurveSetMasterThenChannel() { + var set = CurveSet.identity + set.master = Curve(points: [.init(x: 0, y: 0), .init(x: 1, y: 0.5)]) // halve + set.red = Curve(points: [.init(x: 0, y: 0.1), .init(x: 1, y: 1.1)]) // +0.1 + let out = set.apply(SIMD3(1, 1, 1)) + XCTAssertEqual(out.x, 0.6, accuracy: 1e-4) // master 1->0.5, red 0.5->0.6 + XCTAssertEqual(out.y, 0.5, accuracy: 1e-4) + XCTAssertEqual(out.z, 0.5, accuracy: 1e-4) + } + + func testCurveSetIdentityNoOp() { + let out = CurveSet.identity.apply(SIMD3(0.3, 0.6, 0.9)) + XCTAssertEqual(out.x, 0.3, accuracy: 1e-6) + XCTAssertEqual(out.y, 0.6, accuracy: 1e-6) + XCTAssertEqual(out.z, 0.9, accuracy: 1e-6) + } +}