panel: Wave control map (balls/rings/knobs/buttons -> CDL params w/ sensitivity), grade state + displays, framed wire protocol w/ partial-frame buffering — 12 tests

This commit is contained in:
Forge Dev 2026-07-10 20:05:53 +00:00
parent 74d1287856
commit 7b8563d02b
5 changed files with 387 additions and 6 deletions

View file

@ -0,0 +1,176 @@
import Foundation
import ForgeColor
/// Tangent Wave control IDs mirror of the panel's control layout.
/// Real IDs assigned by Tangent Hub control map; these are our canonical set,
/// remapped at Hub-registration time in the macOS phase.
public enum TangentControls {
// Trackballs: X/Y per ball. Ball 1 = lift(offset), 2 = gamma(power), 3 = gain(slope).
public static let liftBallX: Int = 101
public static let liftBallY: Int = 102
public static let gammaBallX: Int = 111
public static let gammaBallY: Int = 112
public static let gainBallX: Int = 121
public static let gainBallY: Int = 122
// Rings (master per zone).
public static let liftRing: Int = 103
public static let gammaRing: Int = 113
public static let gainRing: Int = 123
// Knobs.
public static let satKnob: Int = 201
public static let contrastKnob: Int = 202
// Buttons.
public static let bypassButton: Int = 301
public static let grabStillButton: Int = 302
public static let nextSlotButton: Int = 303
public static let prevSlotButton: Int = 304
public static let resetButton: Int = 305
}
/// Panel input event.
public enum PanelInput: Equatable, Sendable {
case encoder(id: Int, delta: Int)
case button(id: Int, pressed: Bool)
}
/// CDL parameter targeted by a panel control.
public enum CDLParam: String, Equatable, Sendable {
case slopeRed, slopeGreen, slopeBlue, slopeMaster
case offsetRed, offsetGreen, offsetBlue, offsetMaster
case powerRed, powerGreen, powerBlue, powerMaster
case saturation
case contrast
}
/// Action produced by mapping a panel input.
public enum PanelAction: Equatable, Sendable {
case adjustCDL(param: CDLParam, amount: Float)
case toggleBypass
case grabStill
case nextSlot
case prevSlot
case resetGrade
}
/// Maps panel controls to actions with per-parameter sensitivity.
public struct ControlMap: Sendable {
public var encoderMap: [Int: CDLParam]
public var buttonMap: [Int: PanelAction]
private var sensitivities: [CDLParam: Float]
public func sensitivity(for param: CDLParam) -> Float {
sensitivities[param] ?? 0.001
}
public func action(for input: PanelInput) -> PanelAction? {
switch input {
case .encoder(let id, let delta):
guard let param = encoderMap[id] else { return nil }
return .adjustCDL(param: param, amount: Float(delta) * sensitivity(for: param))
case .button(let id, let pressed):
guard pressed else { return nil } // act on press only
return buttonMap[id]
}
}
/// Default Wave mapping: balls X->red-ish axis, Y->green/blue axis simplification
/// (full 2D chromatic ball math lands with real panel calibration).
public static let waveDefault = ControlMap(
encoderMap: [
TangentControls.liftBallX: .offsetRed,
TangentControls.liftBallY: .offsetBlue,
TangentControls.liftRing: .offsetMaster,
TangentControls.gammaBallX: .powerRed,
TangentControls.gammaBallY: .powerBlue,
TangentControls.gammaRing: .powerMaster,
TangentControls.gainBallX: .slopeRed,
TangentControls.gainBallY: .slopeBlue,
TangentControls.gainRing: .slopeMaster,
TangentControls.satKnob: .saturation,
TangentControls.contrastKnob: .contrast,
],
buttonMap: [
TangentControls.bypassButton: .toggleBypass,
TangentControls.grabStillButton: .grabStill,
TangentControls.nextSlotButton: .nextSlot,
TangentControls.prevSlotButton: .prevSlot,
TangentControls.resetButton: .resetGrade,
],
sensitivities: [
.slopeRed: 0.002, .slopeGreen: 0.002, .slopeBlue: 0.002, .slopeMaster: 0.002,
.offsetRed: 0.001, .offsetGreen: 0.001, .offsetBlue: 0.001, .offsetMaster: 0.001,
.powerRed: 0.002, .powerGreen: 0.002, .powerBlue: 0.002, .powerMaster: 0.002,
.saturation: 0.002, .contrast: 0.002,
])
public init(encoderMap: [Int: CDLParam], buttonMap: [Int: PanelAction], sensitivities: [CDLParam: Float]) {
self.encoderMap = encoderMap
self.buttonMap = buttonMap
self.sensitivities = sensitivities
}
}
/// Panel-editable grade state (CDL layer the panel drives).
public struct PanelGradeState: Sendable, Equatable {
public var cdl: CDL = .identity
public var bypassed = false
public init() {}
public mutating func apply(_ action: PanelAction) {
switch action {
case .adjustCDL(let param, let amount):
switch param {
case .slopeRed: cdl.slope.x += amount
case .slopeGreen: cdl.slope.y += amount
case .slopeBlue: cdl.slope.z += amount
case .slopeMaster:
cdl.slope += SIMD3(repeating: amount)
case .offsetRed: cdl.offset.x += amount
case .offsetGreen: cdl.offset.y += amount
case .offsetBlue: cdl.offset.z += amount
case .offsetMaster:
cdl.offset += SIMD3(repeating: amount)
case .powerRed: cdl.power.x += amount
case .powerGreen: cdl.power.y += amount
case .powerBlue: cdl.power.z += amount
case .powerMaster:
cdl.power += SIMD3(repeating: amount)
case .saturation:
cdl.saturation = max(0, cdl.saturation + amount)
case .contrast:
// Contrast pivot 0.435: slope up, offset compensates.
cdl.slope += SIMD3(repeating: amount)
cdl.offset -= SIMD3(repeating: amount * 0.435)
}
case .toggleBypass:
bypassed.toggle()
case .resetGrade:
cdl = .identity
bypassed = false
case .grabStill, .nextSlot, .prevSlot:
break // handled by app layer
}
}
/// Short readout string for panel displays.
public func displayText(for param: CDLParam) -> String {
func t(_ v: Float) -> String { String(format: "%.3f", v) }
switch param {
case .slopeRed: return "SlpR \(t(cdl.slope.x))"
case .slopeGreen: return "SlpG \(t(cdl.slope.y))"
case .slopeBlue: return "SlpB \(t(cdl.slope.z))"
case .slopeMaster: return "Slp \(t((cdl.slope.x + cdl.slope.y + cdl.slope.z) / 3))"
case .offsetRed: return "OffR \(t(cdl.offset.x))"
case .offsetGreen: return "OffG \(t(cdl.offset.y))"
case .offsetBlue: return "OffB \(t(cdl.offset.z))"
case .offsetMaster: return "Off \(t((cdl.offset.x + cdl.offset.y + cdl.offset.z) / 3))"
case .powerRed: return "PwrR \(t(cdl.power.x))"
case .powerGreen: return "PwrG \(t(cdl.power.y))"
case .powerBlue: return "PwrB \(t(cdl.power.z))"
case .powerMaster: return "Pwr \(t((cdl.power.x + cdl.power.y + cdl.power.z) / 3))"
case .saturation: return "Sat \(t(cdl.saturation))"
case .contrast: return "Con \(t(cdl.slope.x))"
}
}
}

View file

@ -1 +0,0 @@
// ForgePanel

View file

@ -0,0 +1,71 @@
import Foundation
/// Messages exchanged with the Tangent Hub bridge.
/// Wire framing: u32 little-endian length + JSON payload.
/// NOTE: real Tangent Hub TIPC is a binary protocol on TCP 64246; this JSON
/// framing is our internal bridge format the macOS TIPC shim translates.
/// Framing/parse logic (buffering, partial frames) is identical either way.
public enum TangentMessage: Equatable, Sendable {
case controlUpdate(PanelInput)
case displayUpdate(line: Int, text: String)
}
public enum TangentWireError: Error {
case badPayload
}
public enum TangentWire {
public static func encode(_ msg: TangentMessage) -> Data {
var obj: [String: Any] = [:]
switch msg {
case .controlUpdate(.encoder(let id, let delta)):
obj = ["type": "encoder", "id": id, "delta": delta]
case .controlUpdate(.button(let id, let pressed)):
obj = ["type": "button", "id": id, "pressed": pressed]
case .displayUpdate(let line, let text):
obj = ["type": "display", "line": line, "text": text]
}
let payload = (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
var out = Data()
var len = UInt32(payload.count).littleEndian
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
out.append(payload)
return out
}
/// Decode first complete frame from buffer, consuming it. nil if incomplete.
public static func decodeFirst(_ buffer: inout Data) throws -> TangentMessage? {
guard buffer.count >= 4 else { return nil }
// Byte-wise LE assembly (Data slices may be misaligned for u32 loads).
let b = [UInt8](buffer.prefix(4))
let length = UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
guard buffer.count >= 4 + Int(length) else { return nil }
let payload = buffer.subdata(in: buffer.startIndex + 4..<buffer.startIndex + 4 + Int(length))
buffer.removeFirst(4 + Int(length))
guard let obj = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let type = obj["type"] as? String else {
throw TangentWireError.badPayload
}
switch type {
case "encoder":
guard let id = obj["id"] as? Int, let delta = obj["delta"] as? Int else {
throw TangentWireError.badPayload
}
return .controlUpdate(.encoder(id: id, delta: delta))
case "button":
guard let id = obj["id"] as? Int, let pressed = obj["pressed"] as? Bool else {
throw TangentWireError.badPayload
}
return .controlUpdate(.button(id: id, pressed: pressed))
case "display":
guard let line = obj["line"] as? Int, let text = obj["text"] as? String else {
throw TangentWireError.badPayload
}
return .displayUpdate(line: line, text: text)
default:
throw TangentWireError.badPayload
}
}
}

View file

@ -0,0 +1,140 @@
import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgePanel
final class ControlMapTests: XCTestCase {
// Trackball delta maps to CDL offset per ball (lift/gamma/gain zones map to SOP).
func testTrackballToCDL() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
// Gain ball (ball 3) X delta -> slope red+.
let action = map.action(for: .encoder(id: TangentControls.gainBallX, delta: 10))
guard case .adjustCDL(let param, let amount) = action else { return XCTFail("wrong action") }
XCTAssertEqual(param, .slopeRed)
XCTAssertEqual(amount, 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-8)
grade.apply(action!)
XCTAssertEqual(grade.cdl.slope.x, 1 + 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-6)
}
// Master ring adjusts all three slope channels.
func testMasterRing() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
let action = map.action(for: .encoder(id: TangentControls.gainRing, delta: 5))
guard case .adjustCDL(let param, _) = action, param == .slopeMaster else {
return XCTFail("wrong mapping")
}
grade.apply(action!)
XCTAssertEqual(grade.cdl.slope.x, grade.cdl.slope.y, accuracy: 1e-7)
XCTAssertEqual(grade.cdl.slope.y, grade.cdl.slope.z, accuracy: 1e-7)
XCTAssertGreaterThan(grade.cdl.slope.x, 1)
}
// Sat knob.
func testSatKnob() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
let action = map.action(for: .encoder(id: TangentControls.satKnob, delta: -20))
grade.apply(action!)
XCTAssertLessThan(grade.cdl.saturation, 1)
}
// Buttons dispatch commands.
func testButtons() {
let map = ControlMap.waveDefault
XCTAssertEqual(map.action(for: .button(id: TangentControls.bypassButton, pressed: true)), .toggleBypass)
XCTAssertEqual(map.action(for: .button(id: TangentControls.grabStillButton, pressed: true)), .grabStill)
XCTAssertEqual(map.action(for: .button(id: TangentControls.nextSlotButton, pressed: true)), .nextSlot)
XCTAssertEqual(map.action(for: .button(id: TangentControls.resetButton, pressed: true)), .resetGrade)
// Release events ignored.
XCTAssertNil(map.action(for: .button(id: TangentControls.bypassButton, pressed: false)))
}
// Unmapped control -> nil.
func testUnmappedControl() {
XCTAssertNil(ControlMap.waveDefault.action(for: .encoder(id: 9999, delta: 1)))
}
// Reset action restores identity.
func testReset() {
var grade = PanelGradeState()
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.5))
grade.apply(.adjustCDL(param: .offsetBlue, amount: -0.1))
XCTAssertNotEqual(grade.cdl, CDL.identity)
grade.apply(.resetGrade)
XCTAssertEqual(grade.cdl, CDL.identity)
}
// Display text for current params (panel readout strings).
func testDisplayText() {
var grade = PanelGradeState()
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.234))
let text = grade.displayText(for: .slopeRed)
XCTAssertEqual(text, "SlpR 1.234")
}
}
final class TangentProtocolTests: XCTestCase {
// Frame encode: u32 LE length + JSON payload. Round trip.
func testFraming() throws {
let msg = TangentMessage.controlUpdate(.encoder(id: 42, delta: -3))
let framed = TangentWire.encode(msg)
var buf = framed
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .controlUpdate(.encoder(let id, let delta)) = decoded else {
return XCTFail("wrong decode")
}
XCTAssertEqual(id, 42)
XCTAssertEqual(delta, -3)
XCTAssertTrue(buf.isEmpty)
}
// Partial frames buffer until complete.
func testPartialFrames() throws {
let msg = TangentMessage.controlUpdate(.button(id: 7, pressed: true))
let framed = TangentWire.encode(msg)
var buf = Data(framed.prefix(3))
XCTAssertNil(try TangentWire.decodeFirst(&buf))
buf.append(framed.suffix(from: 3))
XCTAssertNotNil(try TangentWire.decodeFirst(&buf))
}
// Two frames in one read both decode.
func testTwoFramesOneBuffer() throws {
var buf = TangentWire.encode(.controlUpdate(.encoder(id: 1, delta: 1)))
buf.append(TangentWire.encode(.controlUpdate(.encoder(id: 2, delta: 2))))
let first = try XCTUnwrap(TangentWire.decodeFirst(&buf))
let second = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .controlUpdate(.encoder(let id1, _)) = first,
case .controlUpdate(.encoder(let id2, _)) = second else {
return XCTFail()
}
XCTAssertEqual(id1, 1)
XCTAssertEqual(id2, 2)
}
// Display update encodes.
func testDisplayUpdate() throws {
let msg = TangentMessage.displayUpdate(line: 0, text: "SlpR 1.234")
var buf = TangentWire.encode(msg)
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .displayUpdate(let line, let text) = decoded else { return XCTFail() }
XCTAssertEqual(line, 0)
XCTAssertEqual(text, "SlpR 1.234")
}
// Garbage payload throws.
func testGarbageRejected() {
var lenBytes = Data()
var len = UInt32(4).littleEndian
withUnsafeBytes(of: &len) { lenBytes.append(contentsOf: $0) }
var buf = lenBytes + Data("zzzz".utf8)
XCTAssertThrowsError(try TangentWire.decodeFirst(&buf))
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgePanelPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}