S-VIEW-10: velocity-curve editor UI — draggable spline box beside the Sample hero + in the Zone panel, all math through the pure velocity_curve module
This commit is contained in:
+224
-13
@@ -693,11 +693,22 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
||||
namespace {
|
||||
constexpr int kPad = 8;
|
||||
|
||||
// The S-VIEW-10 velocity-curve editor box: a compact bordered box beside the hero waveform
|
||||
// (amp-over-time beside amp-over-velocity — the two share the drawn-curve-with-handles grammar).
|
||||
// Suppressed gracefully (empty rect) when the window is too narrow for both, mirroring
|
||||
// prune_button's suppression rule. The INSET keeps node handles + the pick radius inside the
|
||||
// border so an endpoint at amp 0/1 stays grabbable.
|
||||
constexpr int kVelCurveBoxW = 168; // the curve box width (incl. border)
|
||||
constexpr int kVelCurveMinHeroW = 240; // hero must keep at least this much width beside the box
|
||||
constexpr int kVelCurveInset = 6; // border -> mapping-box inset (>= node half-size + 3)
|
||||
constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete
|
||||
|
||||
struct SampleBands {
|
||||
Rect title; // top: name + Browse/Zone nav buttons
|
||||
Rect navBrowse; // the "Browse" title-band button
|
||||
Rect navZone; // the "Zone" title-band button
|
||||
Rect hero; // the hero waveform + S-VIEW-3 envelope overlay
|
||||
Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed)
|
||||
Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle
|
||||
Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack)
|
||||
};
|
||||
@@ -715,7 +726,17 @@ SampleBands computeSampleBands(int w, int h) {
|
||||
|
||||
int y = titleH;
|
||||
const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH));
|
||||
b.hero = Rect{kPad, y, w - kPad, y + heroH};
|
||||
const Rect fullHero{kPad, y, w - kPad, y + heroH};
|
||||
// Carve the velocity-curve editor from the hero band's right when there is room for both;
|
||||
// otherwise the hero keeps the full width and the curve editor is suppressed (empty rect).
|
||||
if (fullHero.width() >= kVelCurveBoxW + kPad + kVelCurveMinHeroW) {
|
||||
b.velCurve = Rect{fullHero.right - kVelCurveBoxW, fullHero.top, fullHero.right,
|
||||
fullHero.bottom};
|
||||
b.hero = Rect{fullHero.left, fullHero.top, b.velCurve.left - kPad, fullHero.bottom};
|
||||
} else {
|
||||
b.hero = fullHero;
|
||||
b.velCurve = Rect{};
|
||||
}
|
||||
y += heroH;
|
||||
const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y));
|
||||
b.cluster = Rect{0, y, w, y + clusterH};
|
||||
@@ -784,6 +805,36 @@ Rect zonesControlPanel(const Rect& content) {
|
||||
content.bottom - 4};
|
||||
}
|
||||
|
||||
// The S-VIEW-10 velocity-curve editor inside the Zone param panel: right-anchored, top-aligned,
|
||||
// with the control rows keeping the panel's left. Suppressed (empty rect) when the panel is too
|
||||
// narrow/short for both — the controls then keep the full panel (the pre-S-VIEW-10 layout).
|
||||
// Both draw + hit-test derive from these two formulas so they never drift.
|
||||
constexpr int kVelCurveBoxH = 140; // the Zone-panel curve box height (Sample uses the hero's)
|
||||
constexpr int kVelCurveMinPanelW = 220; // controls keep at least this much width beside the box
|
||||
Rect zonesCurveBox(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
if (panel.width() < kVelCurveBoxW + kPad + kVelCurveMinPanelW || panel.height() < 80) {
|
||||
return Rect{};
|
||||
}
|
||||
const int boxH = (std::min)(kVelCurveBoxH, panel.height());
|
||||
return Rect{panel.right - kVelCurveBoxW, panel.top, panel.right, panel.top + boxH};
|
||||
}
|
||||
Rect zonesControlsArea(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
const Rect curve = zonesCurveBox(content);
|
||||
if (curve.width() <= 0) return panel;
|
||||
return Rect{panel.left, panel.top, curve.left - kPad, panel.bottom};
|
||||
}
|
||||
|
||||
// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and
|
||||
// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the
|
||||
// Box through this ONE formula, so drawn nodes and grabs can never drift apart.
|
||||
VelocityCurve::Box curveBoxFromRect(const Rect& r) {
|
||||
return VelocityCurve::Box{r.left + kVelCurveInset, r.top + kVelCurveInset,
|
||||
(std::max)(0, r.width() - 2 * kVelCurveInset),
|
||||
(std::max)(0, r.height() - 2 * kVelCurveInset)};
|
||||
}
|
||||
|
||||
// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is
|
||||
// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored
|
||||
// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide,
|
||||
@@ -983,6 +1034,10 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// S-VIEW-10: the velocity->amp transfer-curve editor beside the hero (amp-over-time beside
|
||||
// amp-over-velocity). Independent of the decoded PCM — drawn even while (decoding...).
|
||||
paintVelocityCurve(bmp, bands.velCurve, zone);
|
||||
|
||||
// --- Root + preview cluster (fenced root strip, preview button, velocity knob, channel) ---
|
||||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||||
int root = effectiveRoot();
|
||||
@@ -1070,6 +1125,92 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width() <= 0 || r.height() <= 0) return; // suppressed (window too narrow)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role.
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
// A corner caption (decorative — the axes are velocity -> amp).
|
||||
kitText(bmp, Rect{r.left + 4, r.top + 1, r.right - 4, r.top + 12}, "Vel curve",
|
||||
Font::Micro, Role::TextDim);
|
||||
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
const VelocityCurve& curve = zone.velocityCurve;
|
||||
|
||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
|
||||
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
|
||||
// the hit-test all share one coordinate system.
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= box.width; ++px) {
|
||||
const int cx = box.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
}
|
||||
|
||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||
// to accent-hot when grabbed or hovered.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||
curvePointIndex_ == static_cast<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, hot ? handleHot : handle,
|
||||
1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
|
||||
int idx = z.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zoneIndex;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click: add a control point at the cursor via the pure inverse map, then grab
|
||||
// it — the click flows straight into a placing drag.
|
||||
if (idx < 0) {
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||||
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
|
||||
dragCurveRect_ = r;
|
||||
dragCurveZone_ = zoneIndex;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
selectedZone_ = zoneIndex;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||
@@ -1362,8 +1503,11 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
|
||||
// single capture is already a one-zone map — one storage site serves both).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
paintControls(bmp, zonesControlPanel(content),
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)]);
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintControls(bmp, zonesControlsArea(content), z);
|
||||
// S-VIEW-10: the same velocity-curve editor, right-anchored in the per-zone param panel
|
||||
// (one curve per zone). Suppressed (empty rect) when the panel is too narrow.
|
||||
paintVelocityCurve(bmp, zonesCurveBox(content), z);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1499,12 +1643,21 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
h = {HoverKind::kDeleteZone, -1};
|
||||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
const std::vector<ControlDesc> descs = controlDescs(play);
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, descs);
|
||||
const int id = controlAtPoint(rows, x, y);
|
||||
if (id >= 0) h = {HoverKind::kControl, id};
|
||||
const Rect cb = zonesCurveBox(content);
|
||||
if (cb.width() > 0 && contains(cb, x, y)) {
|
||||
// S-VIEW-10: a curve node under the pointer lights accent-hot.
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
const int idx = z.velocityCurve.pointAtPixel(curveBoxFromRect(cb), x, y);
|
||||
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
|
||||
} else {
|
||||
const ZonePlaySeconds& play =
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect panel = zonesControlsArea(content);
|
||||
const std::vector<ControlDesc> descs = controlDescs(play);
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, descs);
|
||||
const int id = controlAtPoint(rows, x, y);
|
||||
if (id >= 0) h = {HoverKind::kControl, id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home)
|
||||
const SampleBands bands = computeSampleBands(w, hgt);
|
||||
@@ -1519,7 +1672,13 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1};
|
||||
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
|
||||
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
|
||||
else {
|
||||
else if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) {
|
||||
// S-VIEW-10: a curve node under the pointer lights accent-hot.
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const int idx = zone.velocityCurve.pointAtPixel(
|
||||
curveBoxFromRect(bands.velCurve), x, y);
|
||||
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
|
||||
} else {
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<ControlDesc> descs = controlDescs(zone.play);
|
||||
const std::vector<ControlRow> rows = layoutControls(bands.control, descs);
|
||||
@@ -1669,6 +1828,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S-VIEW-10: the velocity-curve editor beside the hero. Every in-box click is an edit
|
||||
// (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the
|
||||
// mirror of the control strip's ensureSampleZone path).
|
||||
if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) {
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) handleCurveMouseDown(bands.velCurve, zi, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
@@ -1833,9 +2001,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
|
||||
// The param panel: a toggle segment flips at once (commit); a slider grab starts a live drag.
|
||||
// Only when a zone is selected (the Zone surface has no single-capture fallback — that lives
|
||||
// on the Sample face now).
|
||||
// on the Sample face now). The S-VIEW-10 curve box is checked first — it sits inside the
|
||||
// panel's right column (zonesControlsArea is the panel minus that column).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
handleControlClick(selectedZone_, zonesControlPanel(content), x, y);
|
||||
const Rect cb = zonesCurveBox(content);
|
||||
if (cb.width() > 0 && contains(cb, x, y)) {
|
||||
handleCurveMouseDown(cb, selectedZone_, x, y);
|
||||
return;
|
||||
}
|
||||
handleControlClick(selectedZone_, zonesControlsArea(content), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1941,6 +2115,22 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
|
||||
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
|
||||
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// commit lands on WM_LBUTTONUP.
|
||||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
const int dy = y - dragStartY_;
|
||||
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
|
||||
VelocityCurve::resolvePointDrag(dragStartCurve_,
|
||||
static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), dx, dy);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
|
||||
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
@@ -2048,7 +2238,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
@@ -2059,9 +2249,14 @@ void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const int curveZone = dragCurveZone_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
dragParamZone_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
dragCurveZone_ = -1;
|
||||
// A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a
|
||||
// processor-side per-instance setting already applied live — neither reloads the instrument.
|
||||
// Every other drag is a coherent map edit: publish the in-flight map + reload off-thread.
|
||||
@@ -2069,6 +2264,20 @@ void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the
|
||||
// dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain
|
||||
// move — its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||||
curveZone < static_cast<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.left - kCurveDragOffMargin ||
|
||||
x > curveRect.right + kCurveDragOffMargin ||
|
||||
y < curveRect.top - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
|
||||
static_cast<std::size_t>(curveIdx));
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
@@ -2237,6 +2446,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->dragParamId_ = -1;
|
||||
self->dragParamZone_ = -1;
|
||||
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
|
||||
self->dragCurveZone_ = -1;
|
||||
self->invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry)
|
||||
#include "peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -78,9 +79,10 @@ private:
|
||||
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
||||
// marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a
|
||||
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
|
||||
// envNode_).
|
||||
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
|
||||
// transfer-curve editor (which point in curvePointIndex_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kParamSlider, kEnvNode };
|
||||
kScrollThumb, kParamSlider, kEnvNode, kCurveNode };
|
||||
|
||||
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
|
||||
// The int value is the ControlDesc id the pure param_slider hit-test returns; the shell
|
||||
@@ -133,6 +135,7 @@ private:
|
||||
kAddZone, // the "+ Add Zone" button
|
||||
kDeleteZone, // the "Delete" zone button
|
||||
kControl, // a param-panel control row (index = ControlDesc id)
|
||||
kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10)
|
||||
};
|
||||
struct HoverTarget {
|
||||
HoverKind kind = HoverKind::kNone;
|
||||
@@ -152,6 +155,17 @@ private:
|
||||
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
|
||||
std::int64_t frames);
|
||||
// S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127,
|
||||
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
|
||||
// point. Shared by the Sample face (beside the hero) and the Zone param panel; all mapping /
|
||||
// hit-test / clamp math lives in the pure velocity_curve module. `r` empty -> draws nothing.
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
|
||||
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
|
||||
// an empty-space click ADDS a point at the cursor and grabs it for an immediate drag.
|
||||
// `zoneIndex` must be a valid index into map_.zones (callers materialize first).
|
||||
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
|
||||
|
||||
void onMouseDown(int x, int y);
|
||||
void onMouseMove(int x, int y);
|
||||
@@ -405,6 +419,15 @@ private:
|
||||
EnvNode envNode_ = EnvNode::Origin;
|
||||
AmpEnvelope dragStartEnv_{};
|
||||
|
||||
// S-VIEW-10 velocity-curve node drag: which point is grabbed, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the box rect the grab happened in (the Sample
|
||||
// and Zone views place the editor differently — the drag resolves against the grab-time box),
|
||||
// and which zone the edit lands on. Mirror of the envelope-node drag state.
|
||||
int curvePointIndex_ = -1;
|
||||
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
|
||||
Rect dragCurveRect_{};
|
||||
int dragCurveZone_ = -1;
|
||||
|
||||
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
||||
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
|
||||
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||
|
||||
@@ -216,6 +216,28 @@ bool VelocityCurve::deletePoint(std::size_t index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
|
||||
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
|
||||
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
|
||||
// ampToY pins to box.top (amp 1).
|
||||
VelocityPoint p;
|
||||
const int w = std::max(0, box.width);
|
||||
const int h = std::max(0, box.height);
|
||||
p.velocity = (w <= 0)
|
||||
? kVelMin
|
||||
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
|
||||
(kVelMax - kVelMin));
|
||||
p.amp = (h <= 1)
|
||||
? kAmpMax
|
||||
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
||||
(kAmpMax - kAmpMin));
|
||||
return p;
|
||||
}
|
||||
|
||||
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
const int px = velToX(box, points_[i].velocity);
|
||||
|
||||
@@ -133,6 +133,22 @@ public:
|
||||
// for determinism (mirror of nodeAtPoint).
|
||||
int pointAtPixel(const Box& box, int x, int y) const;
|
||||
|
||||
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
|
||||
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
|
||||
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
|
||||
struct CurvePixel {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||
|
||||
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
|
||||
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
|
||||
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
|
||||
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
|
||||
// pixelFromPoint's degenerate collapse.
|
||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||
|
||||
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
|
||||
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
|
||||
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
|
||||
|
||||
@@ -274,6 +274,70 @@ static void testFromPointsSubTwoFallsBackToFlat() {
|
||||
CHECK(c1.equals(VelocityCurve::flat()));
|
||||
}
|
||||
|
||||
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
|
||||
|
||||
static void testPixelFromPointMapsCornersAndMidpoint() {
|
||||
// Box 100 px wide, 51 px tall at (10, 20). velToX spans the WIDTH (frac * w); ampToY spans
|
||||
// (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
|
||||
// hit-test mapping puts them.
|
||||
const Box box{10, 20, 100, 51};
|
||||
const auto tl = VelocityCurve::pixelFromPoint(box, {0.0, 1.0});
|
||||
CHECK(tl.x == 10 && tl.y == 20);
|
||||
const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0});
|
||||
CHECK(br.x == 110 && br.y == 70);
|
||||
const auto mid = VelocityCurve::pixelFromPoint(box, {63.5, 0.5});
|
||||
CHECK(mid.x == 60 && mid.y == 45);
|
||||
// Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
|
||||
const auto clamped = VelocityCurve::pixelFromPoint(box, {200.0, 2.0});
|
||||
CHECK(clamped.x == 110 && clamped.y == 20);
|
||||
}
|
||||
|
||||
static void testPointFromPixelInvertsAndClamps() {
|
||||
const Box box{10, 20, 100, 51};
|
||||
// Exact corners invert exactly.
|
||||
const VelocityPoint tl = VelocityCurve::pointFromPixel(box, 10, 20);
|
||||
CHECK(near(tl.velocity, 0.0) && near(tl.amp, 1.0));
|
||||
const VelocityPoint br = VelocityCurve::pointFromPixel(box, 110, 70);
|
||||
CHECK(near(br.velocity, 127.0) && near(br.amp, 0.0));
|
||||
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
|
||||
const VelocityPoint out = VelocityCurve::pointFromPixel(box, -50, 500);
|
||||
CHECK(near(out.velocity, 0.0) && near(out.amp, 0.0));
|
||||
const VelocityPoint out2 = VelocityCurve::pointFromPixel(box, 500, -50);
|
||||
CHECK(near(out2.velocity, 127.0) && near(out2.amp, 1.0));
|
||||
}
|
||||
|
||||
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
|
||||
// Forward-then-inverse must agree within one pixel's worth of value (the rounding quantum) —
|
||||
// this is what keeps an added point under the cursor and a drawn node grabbable where drawn.
|
||||
const Box box{3, 7, 160, 120};
|
||||
const double velQuantum = 127.0 / 160.0;
|
||||
const double ampQuantum = 1.0 / 119.0;
|
||||
const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
|
||||
for (const VelocityPoint& p : pts) {
|
||||
const auto px = VelocityCurve::pixelFromPoint(box, p);
|
||||
const VelocityPoint back = VelocityCurve::pointFromPixel(box, px.x, px.y);
|
||||
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
|
||||
CHECK(std::fabs(back.amp - p.amp) <= ampQuantum);
|
||||
}
|
||||
}
|
||||
|
||||
static void testPixelFromPointAgreesWithPointAtPixel() {
|
||||
// A node drawn at pixelFromPoint's coordinates must hit-test back to that same node — the
|
||||
// draw/grab no-drift contract the two helpers exist to guarantee.
|
||||
VelocityCurve c = VelocityCurve::linear();
|
||||
const std::size_t idx = c.addPoint(70.0, 0.3);
|
||||
const Box box{0, 0, 200, 100};
|
||||
const auto px = VelocityCurve::pixelFromPoint(box, c.points()[idx]);
|
||||
CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
|
||||
}
|
||||
|
||||
static void testPointFromPixelDegenerateBox() {
|
||||
// Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
|
||||
const Box flat{5, 5, 0, 0};
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(flat, 50, 50);
|
||||
CHECK(near(p.velocity, 0.0) && near(p.amp, 1.0));
|
||||
}
|
||||
|
||||
static void testFromPointsRoundTripsAValidCurve() {
|
||||
VelocityCurve orig = VelocityCurve::linear();
|
||||
orig.addPoint(40.0, 0.2);
|
||||
@@ -302,6 +366,11 @@ int main() {
|
||||
testResolveDragDegenerateBoxNoMotion();
|
||||
testFromPointsSortsClampsAndForcesEndpoints();
|
||||
testFromPointsSubTwoFallsBackToFlat();
|
||||
testPixelFromPointMapsCornersAndMidpoint();
|
||||
testPointFromPixelInvertsAndClamps();
|
||||
testPixelMapsRoundTripWithinOnePixelQuantum();
|
||||
testPixelFromPointAgreesWithPointAtPixel();
|
||||
testPointFromPixelDegenerateBox();
|
||||
testFromPointsRoundTripsAValidCurve();
|
||||
|
||||
if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user