color: Fritsch-Carlson monotone cubic curves + CurveSet (master+RGB) — 7 tests

This commit is contained in:
Forge Dev 2026-07-10 18:51:47 +00:00
parent 0b4808f67c
commit 8b2f77aa46
2 changed files with 200 additions and 0 deletions

View file

@ -0,0 +1,124 @@
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))
}
}

View file

@ -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<Float>(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<Float>(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)
}
}