rainbow-dragon/Sources/ForgeColor/Curve.swift

125 lines
4.2 KiB
Swift
Raw Normal View History

import Foundation
/// Monotone cubic (FritschCarlson) 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]
/// FritschCarlson 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
}
// FritschCarlson 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<Float>) -> SIMD3<Float> {
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))
}
}