From 7b8563d02be6a6c3b63136586d39bc8dcbde7c7b Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:05:53 +0000 Subject: [PATCH] =?UTF-8?q?panel:=20Wave=20control=20map=20(balls/rings/kn?= =?UTF-8?q?obs/buttons=20->=20CDL=20params=20w/=20sensitivity),=20grade=20?= =?UTF-8?q?state=20+=20displays,=20framed=20wire=20protocol=20w/=20partial?= =?UTF-8?q?-frame=20buffering=20=E2=80=94=2012=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgePanel/ControlMap.swift | 176 +++++++++++++++++++ Sources/ForgePanel/ForgePanel.swift | 1 - Sources/ForgePanel/TangentWire.swift | 71 ++++++++ Tests/ForgePanelTests/PanelTests.swift | 140 +++++++++++++++ Tests/ForgePanelTests/PlaceholderTests.swift | 5 - 5 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 Sources/ForgePanel/ControlMap.swift delete mode 100644 Sources/ForgePanel/ForgePanel.swift create mode 100644 Sources/ForgePanel/TangentWire.swift create mode 100644 Tests/ForgePanelTests/PanelTests.swift delete mode 100644 Tests/ForgePanelTests/PlaceholderTests.swift diff --git a/Sources/ForgePanel/ControlMap.swift b/Sources/ForgePanel/ControlMap.swift new file mode 100644 index 0000000..7f195e6 --- /dev/null +++ b/Sources/ForgePanel/ControlMap.swift @@ -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))" + } + } +} diff --git a/Sources/ForgePanel/ForgePanel.swift b/Sources/ForgePanel/ForgePanel.swift deleted file mode 100644 index b510258..0000000 --- a/Sources/ForgePanel/ForgePanel.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgePanel diff --git a/Sources/ForgePanel/TangentWire.swift b/Sources/ForgePanel/TangentWire.swift new file mode 100644 index 0000000..34e7675 --- /dev/null +++ b/Sources/ForgePanel/TangentWire.swift @@ -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.. 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)) + } +} diff --git a/Tests/ForgePanelTests/PlaceholderTests.swift b/Tests/ForgePanelTests/PlaceholderTests.swift deleted file mode 100644 index 5688b13..0000000 --- a/Tests/ForgePanelTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgePanelPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}