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
+61
View File
@@ -4,6 +4,7 @@
#include "param_slider.h"
#include <algorithm>
#include <cmath>
namespace reasampler::vst {
@@ -78,10 +79,70 @@ double valueAtPoint(const Rect& control, int x) {
return static_cast<double>(x - track.left) / static_cast<double>(span);
}
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
namespace {
constexpr double kPi = 3.14159265358979323846;
// Normalize an angle in degrees to [0, 360).
double normDeg(double deg) {
deg = std::fmod(deg, 360.0);
if (deg < 0.0) deg += 360.0;
return deg;
}
double clamp01(double v) { return (std::min)(1.0, (std::max)(0.0, v)); }
} // namespace
KnobGeometry computeKnob(const Rect& cell) {
if (cell.width() <= 0 || cell.height() <= 0) return KnobGeometry{};
KnobGeometry g;
g.centerX = (cell.left + cell.right) / 2.0;
g.centerY = (cell.top + cell.bottom) / 2.0;
g.radius = (std::min)(cell.width(), cell.height()) / 2.0;
return g;
}
bool knobHitTest(const KnobGeometry& knob, int x, int y) {
if (knob.radius <= 0.0) return false;
const double dx = x - knob.centerX;
const double dy = y - knob.centerY;
return dx * dx + dy * dy <= knob.radius * knob.radius;
}
double knobSweepDeg(const KnobArc& arc) {
const double sweep = normDeg(arc.endDeg) - normDeg(arc.startDeg);
// An end at-or-behind the start wraps clockwise past 12 o'clock; equal angles mean a
// full circle.
return sweep <= 0.0 ? sweep + 360.0 : sweep;
}
double knobValueAngleDeg(const KnobArc& arc, double value) {
return normDeg(normDeg(arc.startDeg) + knobSweepDeg(arc) * clamp01(value));
}
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value) {
// Clock angle -> screen direction: 0° points up (-y), 90° points right (+x).
const double rad = knobValueAngleDeg(arc, value) * kPi / 180.0;
return KnobPoint{knob.centerX + knob.radius * std::sin(rad),
knob.centerY - knob.radius * std::cos(rad)};
}
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
const double start = clamp01(startValue);
if (dragRangePixels <= 0) return start;
// Screen y grows downward: an upward drag (negative dy) increases the value.
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
for (const ControlRow& r : rows) {
if (r.kind == ControlKind::Toggle) {
if (contains(r.control, x, y)) return r.id;
} else if (r.kind == ControlKind::Knob) {
if (knobHitTest(computeKnob(r.control), x, y)) return r.id;
} else { // Slider — the interactive area is the track
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
}
+85 -13
View File
@@ -10,12 +10,13 @@
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a
// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out
// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell
// converts each control's engine value (frames, seconds, a fraction, a signed semitone
// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it
// tests without the audio core).
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
// engine-free so it tests without the audio core).
//
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
@@ -34,9 +35,11 @@ inline constexpr int kControlLabelWidth = 92; // the label column at the row's
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
// A control is one of two shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value.
enum class ControlKind { Toggle, Slider };
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
@@ -94,11 +97,80 @@ Rect sliderHandleRect(const Rect& control, double value);
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
double valueAtPoint(const Rect& control, int x);
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
//
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
//
// The DEFAULT arc encodes Daniel's "6 to 4 o'clock" spec: min at 6 o'clock (180°) sweeping
// clockwise 300° around to max at 4 o'clock (120°), leaving a minimal 60° dead arc at the
// bottom-right. The angles are PARAMETERS, not hardcoded — the shell sets the final sweep
// when the parallel layout spec lands.
inline constexpr double kKnobArcStartDeg = 180.0; // value 0 — 6 o'clock
inline constexpr double kKnobArcEndDeg = 120.0; // value 1 — 4 o'clock (clockwise wrap)
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 6->4 o'clock reading above.
struct KnobArc {
double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg;
};
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry {
double centerX = 0.0;
double centerY = 0.0;
double radius = 0.0;
};
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint {
double x = 0.0;
double y = 0.0;
};
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
// degenerate cell yields radius 0. The shell passes whatever cell it wants the knob in
// (the whole control column, or a square sub-cell from its own layout). Pure.
KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls inside the knob circle (boundary inclusive). A degenerate knob
// (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure.
double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
// Pure — the inverse map for the knob's drag interaction.
double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the
// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching
// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry
// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits.
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::vst