feat(param_slider): radial KNOB primitive — parameterized 6->4 o clock arc, needle endpoint, vertical-drag value map, circular hit-test (FA4)

This commit is contained in:
2026-07-27 18:25:07 -04:00
parent e2bd4f4351
commit 6c0bb4c204
3 changed files with 253 additions and 14 deletions
+107 -1
View File
@@ -9,7 +9,11 @@
// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and
// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse
// of the handle position; controlAtPoint routing a point to the right control id (toggle whole
// area vs slider track) and MISSING in the label column, a row gap, and off-panel.
// area vs slider track vs knob circle) and MISSING in the label column, a row gap, and
// off-panel. FA4 adds the radial KNOB: computeKnob inscribing the circle in its cell, the
// circular hit-test, the arc angle<->value mapping (min at startDeg, max at endDeg, linear
// midpoint; default = the 6->4 o'clock 300-degree sweep), the needle endpoint on the circle,
// and the vertical-drag delta->value map (up = increase) with clamping at 0/1.
#include "../src/vst/param_slider.h"
@@ -152,6 +156,95 @@ static void testValueAtPointDegenerateTrack() {
CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0));
}
// --- knob (FA4) -----------------------------------------------------------------
static bool near(double a, double b, double tol) { return (a - b) < tol && (b - a) < tol; }
static void testKnobGeometryInscribesCell() {
// A 44x44 cell at (100,0): center (122,22), radius 22.
const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44});
CHECK(approx(g.centerX, 122.0));
CHECK(approx(g.centerY, 22.0));
CHECK(approx(g.radius, 22.0));
// A wide cell inscribes on the smaller (vertical) dimension.
const KnobGeometry w = computeKnob(Rect{0, 0, 200, 22});
CHECK(approx(w.radius, 11.0));
CHECK(approx(w.centerX, 100.0));
// Degenerate cells yield radius 0.
CHECK(computeKnob(Rect{0, 0, 0, 22}).radius == 0.0);
CHECK(computeKnob(Rect{0, 0, 22, 0}).radius == 0.0);
}
static void testKnobHitTestCircle() {
const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22
CHECK(knobHitTest(g, 122, 22)); // center
CHECK(knobHitTest(g, 122 + 22, 22)); // on the circle boundary (inclusive)
CHECK(!knobHitTest(g, 122 + 22, 44)); // cell corner: inside the rect, outside the circle
CHECK(!knobHitTest(g, 122, 45)); // just below the circle
CHECK(!knobHitTest(KnobGeometry{}, 0, 0)); // degenerate knob hits nothing
}
static void testKnobDefaultArcIsSixToFourOClock() {
const KnobArc arc; // default: 180 (6 o'clock) clockwise to 120 (4 o'clock)
CHECK(approx(knobSweepDeg(arc), 300.0));
CHECK(approx(knobValueAngleDeg(arc, 0.0), kKnobArcStartDeg)); // min at 6 o'clock
CHECK(approx(knobValueAngleDeg(arc, 1.0), kKnobArcEndDeg)); // max at 4 o'clock
// Midpoint: halfway around the clockwise sweep -> 180 + 150 = 330.
CHECK(approx(knobValueAngleDeg(arc, 0.5), 330.0));
// Out-of-range values clamp to the arc ends.
CHECK(approx(knobValueAngleDeg(arc, -0.5), kKnobArcStartDeg));
CHECK(approx(knobValueAngleDeg(arc, 1.5), kKnobArcEndDeg));
}
static void testKnobArcIsParameterized() {
// A custom non-wrapping arc: 3 o'clock down to 9 o'clock through 6.
const KnobArc arc{90.0, 270.0};
CHECK(approx(knobSweepDeg(arc), 180.0));
CHECK(approx(knobValueAngleDeg(arc, 0.0), 90.0));
CHECK(approx(knobValueAngleDeg(arc, 0.5), 180.0));
CHECK(approx(knobValueAngleDeg(arc, 1.0), 270.0));
// Equal start/end means a full-circle sweep (end at-or-behind start wraps +360).
CHECK(approx(knobSweepDeg(KnobArc{0.0, 0.0}), 360.0));
}
static void testKnobNeedlePointOnCircle() {
const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22
// Default arc, value 0 -> 6 o'clock -> straight DOWN from the center (screen +y).
const KnobPoint p6 = knobNeedlePoint(g, KnobArc{}, 0.0);
CHECK(near(p6.x, 122.0, 1e-6) && near(p6.y, 44.0, 1e-6));
// A 12 o'clock needle points straight UP; 3 o'clock points RIGHT.
const KnobPoint p12 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.0);
CHECK(near(p12.x, 122.0, 1e-6) && near(p12.y, 0.0, 1e-6));
const KnobPoint p3 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.5);
CHECK(near(p3.x, 144.0, 1e-6) && near(p3.y, 22.0, 1e-6));
// Every needle endpoint sits ON the circle.
for (double v : {0.0, 0.25, 0.5, 0.75, 1.0}) {
const KnobPoint p = knobNeedlePoint(g, KnobArc{}, v);
const double dx = p.x - g.centerX, dy = p.y - g.centerY;
CHECK(near(dx * dx + dy * dy, g.radius * g.radius, 1e-6));
}
}
static void testKnobDragUpIncreases() {
// Up (negative dy) increases, down decreases, scaled by the drag range.
CHECK(approx(knobDragValue(0.5, -32, 128), 0.75));
CHECK(approx(knobDragValue(0.5, +32, 128), 0.25));
// A full-range upward drag from 0 lands exactly at 1.
CHECK(approx(knobDragValue(0.0, -128, 128), 1.0));
// Default sensitivity applies when the range is omitted.
CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0));
}
static void testKnobDragClamps() {
CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1
CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0
// The start value itself is clamped before the delta applies.
CHECK(approx(knobDragValue(1.5, 0, 128), 1.0));
CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0));
// A degenerate drag range yields the clamped start value.
CHECK(approx(knobDragValue(0.7, -50, 0), 0.7));
}
// --- controlAtPoint routing ---------------------------------------------------
static void testControlAtPointRoutes() {
@@ -160,6 +253,7 @@ static void testControlAtPointRoutes() {
{10, ControlKind::Toggle},
{20, ControlKind::Slider},
};
ctl.push_back({30, ControlKind::Knob});
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
// A point in the toggle's control area routes to the toggle id.
const Rect tctl = rows[0].control;
@@ -168,6 +262,11 @@ static void testControlAtPointRoutes() {
const Rect strack = sliderTrackRect(rows[1].control);
CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2,
(strack.top + strack.bottom) / 2) == 20);
// A point at the knob's center routes to the knob id; the control-rect corner (outside
// the circle) is a miss.
const KnobGeometry kg = computeKnob(rows[2].control);
CHECK(controlAtPoint(rows, static_cast<int>(kg.centerX), static_cast<int>(kg.centerY)) == 30);
CHECK(controlAtPoint(rows, rows[2].control.left + 1, rows[2].control.top + 1) == -1);
}
static void testControlAtPointMisses() {
@@ -196,6 +295,13 @@ int main() {
testValueAtPointEndpointsSaturate();
testValueAtPointIsHandleInverse();
testValueAtPointDegenerateTrack();
testKnobGeometryInscribesCell();
testKnobHitTestCircle();
testKnobDefaultArcIsSixToFourOClock();
testKnobArcIsParameterized();
testKnobNeedlePointOnCircle();
testKnobDragUpIncreases();
testKnobDragClamps();
testControlAtPointRoutes();
testControlAtPointMisses();