rainbow-dragon/Sources/ForgeColor/Curve.swift

124 lines
4.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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