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