176 lines
7 KiB
Swift
176 lines
7 KiB
Swift
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))"
|
|
}
|
|
}
|
|
}
|