diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 77507c3..4dbb8ab 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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(i)); + const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(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(map_.zones.size())) return; + const VelocityCurve::Box box = curveBoxFromRect(r); + if (box.width <= 0 || box.height <= 1) return; + PerformanceZone& z = map_.zones[static_cast(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(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(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(map_.zones.size())) { - paintControls(bmp, zonesControlPanel(content), - map_.zones[static_cast(selectedZone_)]); + const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { - const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; - const Rect panel = zonesControlPanel(content); - const std::vector descs = controlDescs(play); - const std::vector 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(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(selectedZone_)].play; + const Rect panel = zonesControlsArea(content); + const std::vector descs = controlDescs(play); + const std::vector 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 descs = controlDescs(zone.play); const std::vector 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& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(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(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(map_.zones.size())) return; + if (curvePointIndex_ < 0) return; + const int dy = y - dragStartY_; + map_.zones[static_cast(dragCurveZone_)].velocityCurve = + VelocityCurve::resolvePointDrag(dragStartCurve_, + static_cast(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(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(curveZone)].velocityCurve.deletePoint( + static_cast(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(); } } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 329986f..4b2dd78 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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 @@ -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. diff --git a/src/vst/velocity_curve.cpp b/src/vst/velocity_curve.cpp index c625ddf..248184d 100644 --- a/src/vst/velocity_curve.cpp +++ b/src/vst/velocity_curve.cpp @@ -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(x - box.left) / static_cast(w) * + (kVelMax - kVelMin)); + p.amp = (h <= 1) + ? kAmpMax + : clampAmp(kAmpMax - static_cast(y - box.top) / static_cast(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); diff --git a/src/vst/velocity_curve.h b/src/vst/velocity_curve.h index 19309b2..6c761e9 100644 --- a/src/vst/velocity_curve.h +++ b/src/vst/velocity_curve.h @@ -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 diff --git a/tests/test_velocity_curve.cpp b/tests/test_velocity_curve.cpp index d15d4b4..9baeaab 100644 --- a/tests/test_velocity_curve.cpp +++ b/tests/test_velocity_curve.cpp @@ -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(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");