color: Fritsch-Carlson monotone cubic curves + CurveSet (master+RGB) — 7 tests
This commit is contained in:
parent
0b4808f67c
commit
8b2f77aa46
2 changed files with 200 additions and 0 deletions
124
Sources/ForgeColor/Curve.swift
Normal file
124
Sources/ForgeColor/Curve.swift
Normal file
|
|
@ -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<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))
|
||||
}
|
||||
}
|
||||
76
Tests/ForgeColorTests/CurveTests.swift
Normal file
76
Tests/ForgeColorTests/CurveTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue