diff --git a/Sources/ForgeColor/Lut3D.swift b/Sources/ForgeColor/Lut3D.swift new file mode 100644 index 0000000..515b97f --- /dev/null +++ b/Sources/ForgeColor/Lut3D.swift @@ -0,0 +1,132 @@ +import Foundation + +/// 3D LUT lattice. Table layout matches .cube: red index fastest, then green, then blue. +/// Entry (r,g,b) at index b*size² + g*size + r, three floats per entry. +public struct Lut3D: Sendable { + public let size: Int + /// size³ * 3 floats. + public let table: [Float] + + public init(size: Int, table: [Float]) { + precondition(size >= 2, "LUT size must be >= 2") + precondition(table.count == size * size * size * 3, "table count mismatch") + self.size = size + self.table = table + } + + public static func identity(size: Int) -> Lut3D { + build(size: size) { $0 } + } + + /// Build lattice by evaluating a function at each grid point. + public static func build(size: Int, function: (SIMD3) -> SIMD3) -> Lut3D { + var table = [Float]() + table.reserveCapacity(size * size * size * 3) + let denom = Float(size - 1) + for b in 0.. SIMD3 { + let i = ((b * size + g) * size + r) * 3 + return SIMD3(table[i], table[i + 1], table[i + 2]) + } + + /// Trilinear interpolation; input clamped to [0,1]. + public func sample(_ rgb: SIMD3) -> SIMD3 { + let maxIdx = Float(size - 1) + let clamped = SIMD3( + min(max(rgb.x, 0), 1), + min(max(rgb.y, 0), 1), + min(max(rgb.z, 0), 1)) + let pos = clamped * maxIdx + let i0 = SIMD3(Int(pos.x), Int(pos.y), Int(pos.z)) + let i1 = SIMD3( + min(i0.x + 1, size - 1), + min(i0.y + 1, size - 1), + min(i0.z + 1, size - 1)) + let f = pos - SIMD3(Float(i0.x), Float(i0.y), Float(i0.z)) + + // 8 corners; lerp r, then g, then b. + let c000 = entry(i0.x, i0.y, i0.z), c100 = entry(i1.x, i0.y, i0.z) + let c010 = entry(i0.x, i1.y, i0.z), c110 = entry(i1.x, i1.y, i0.z) + let c001 = entry(i0.x, i0.y, i1.z), c101 = entry(i1.x, i0.y, i1.z) + let c011 = entry(i0.x, i1.y, i1.z), c111 = entry(i1.x, i1.y, i1.z) + + let c00 = c000 + (c100 - c000) * f.x + let c10 = c010 + (c110 - c010) * f.x + let c01 = c001 + (c101 - c001) * f.x + let c11 = c011 + (c111 - c011) * f.x + let c0 = c00 + (c10 - c00) * f.y + let c1 = c01 + (c11 - c01) * f.y + return c0 + (c1 - c0) * f.z + } +} + +// MARK: - .cube I/O + +public enum CubeError: Error, Equatable { + case missingSize + case invalidFloat(line: Int) + case entryCountMismatch(expected: Int, got: Int) + case invalidSize +} + +public enum CubeFile { + /// Serialize to Resolve/IRIDAS .cube text. + public static func write(_ lut: Lut3D, title: String = "Forge") -> String { + var out = "TITLE \"\(title)\"\nLUT_3D_SIZE \(lut.size)\n" + out.reserveCapacity(lut.table.count * 10) + var i = 0 + while i < lut.table.count { + out += String(format: "%.6f %.6f %.6f\n", lut.table[i], lut.table[i + 1], lut.table[i + 2]) + i += 3 + } + return out + } + + /// Parse .cube text. Ignores TITLE/DOMAIN_MIN/DOMAIN_MAX/comments/blank lines. + public static func parse(_ text: String) throws -> Lut3D { + var size: Int? + var values = [Float]() + var lineNo = 0 + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + lineNo += 1 + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + if line.hasPrefix("TITLE") || line.hasPrefix("DOMAIN_MIN") || line.hasPrefix("DOMAIN_MAX") + || line.hasPrefix("LUT_1D_SIZE") { + continue + } + if line.hasPrefix("LUT_3D_SIZE") { + let parts = line.split(separator: " ") + guard parts.count == 2, let s = Int(parts[1]), s >= 2 else { throw CubeError.invalidSize } + size = s + continue + } + // Data row: three floats. + let comps = line.split(separator: " ", omittingEmptySubsequences: true) + guard comps.count == 3 else { throw CubeError.invalidFloat(line: lineNo) } + for c in comps { + guard let v = Float(c) else { throw CubeError.invalidFloat(line: lineNo) } + values.append(v) + } + } + guard let s = size else { throw CubeError.missingSize } + let expected = s * s * s * 3 + guard values.count == expected else { + throw CubeError.entryCountMismatch(expected: expected, got: values.count) + } + return Lut3D(size: s, table: values) + } +} diff --git a/Tests/ForgeColorTests/Lut3DTests.swift b/Tests/ForgeColorTests/Lut3DTests.swift new file mode 100644 index 0000000..7a2ebb7 --- /dev/null +++ b/Tests/ForgeColorTests/Lut3DTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import ForgeColor + +final class Lut3DTests: XCTestCase { + + // Identity lattice is a no-op within interp tolerance. + func testIdentityNoOp() { + let lut = Lut3D.identity(size: 17) + for px in [SIMD3(0, 0, 0), SIMD3(1, 1, 1), SIMD3(0.5, 0.25, 0.75), SIMD3(0.18, 0.18, 0.18)] { + let out = lut.sample(px) + XCTAssertEqual(out.x, px.x, accuracy: 1e-5) + XCTAssertEqual(out.y, px.y, accuracy: 1e-5) + XCTAssertEqual(out.z, px.z, accuracy: 1e-5) + } + } + + // Lattice built from an analytic function: interp matches direct eval on off-lattice points. + func testTrilinearMatchesDirectEval() { + // f = gain 0.5 (linear function -> trilinear should be near-exact) + let f: (SIMD3) -> SIMD3 = { $0 * 0.5 } + let lut = Lut3D.build(size: 9, function: f) + for px in [SIMD3(0.13, 0.57, 0.91), SIMD3(0.9, 0.05, 0.5)] { + let out = lut.sample(px) + let direct = f(px) + XCTAssertEqual(out.x, direct.x, accuracy: 1e-4) + XCTAssertEqual(out.y, direct.y, accuracy: 1e-4) + XCTAssertEqual(out.z, direct.z, accuracy: 1e-4) + } + } + + // Nonlinear function: 33^3 approx within 1/1024 where trilinear error bound + // (max|f''|·h²/8, h=1/32) actually permits it. pow(x, 0.6) has f''→∞ at 0; + // at x=0.15, |f''|≈3.4 → bound ≈4e-4. Real flattening runs in log space where + // curvature is gentle everywhere. Samples in [0.15, 1]. + func testNonlinearApproxWithinTolerance() { + let f: (SIMD3) -> SIMD3 = { + SIMD3(pow($0.x, 2.2), pow($0.y, 1.8), pow($0.z, 0.6)) + } + let lut = Lut3D.build(size: 33, function: f) + var rng = SystemRandomNumberGenerator() + for _ in 0..<200 { + let px = SIMD3( + Float.random(in: 0.15...1, using: &rng), + Float.random(in: 0.15...1, using: &rng), + Float.random(in: 0.15...1, using: &rng)) + let out = lut.sample(px) + let direct = f(px) + XCTAssertEqual(out.x, direct.x, accuracy: 1.0 / 1024) + XCTAssertEqual(out.y, direct.y, accuracy: 1.0 / 1024) + XCTAssertEqual(out.z, direct.z, accuracy: 1.0 / 1024) + } + } + + // Out-of-range input clamps to lattice bounds. + func testInputClamped() { + let lut = Lut3D.identity(size: 5) + let out = lut.sample(SIMD3(-0.5, 1.5, 0.5)) + XCTAssertEqual(out.x, 0, accuracy: 1e-5) + XCTAssertEqual(out.y, 1, accuracy: 1e-5) + XCTAssertEqual(out.z, 0.5, accuracy: 1e-5) + } + + // MARK: CUBE I/O + + func testCubeRoundTrip() throws { + let lut = Lut3D.build(size: 5) { $0 * 0.8 + SIMD3(0.05, 0, 0.1) } + let text = CubeFile.write(lut, title: "Forge Test") + let parsed = try CubeFile.parse(text) + XCTAssertEqual(parsed.size, 5) + for i in 0..