// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types. #include "envelope_edit.h" #include #include // std::abs namespace reasampler::vst { namespace { double clamp(double v, double lo, double hi) { if (v < lo) return lo; if (v > hi) return hi; return v; } // Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the // area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX. double secondsPerPixel(const Rect& area, double totalSeconds) { const int w = std::max(0, area.width()); if (w <= 0 || totalSeconds <= 0.0) return 0.0; return totalSeconds / static_cast(w); } // Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's // param-domain gatePxPerSecond(area) scale — sample-length-free, matching // envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each // node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the // area is degenerate. double gateSecondsPerPixel(const Rect& area) { const double pps = gatePxPerSecond(area); return pps > 0.0 ? 1.0 / pps : 0.0; } // Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one // pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY. double levelPerPixel(const Rect& area) { const int h = std::max(0, area.height()); if (h <= 1) return 0.0; return 1.0 / static_cast(h - 1); } // True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors). bool isDraggable(EnvNode n) { switch (n) { case EnvNode::Origin: case EnvNode::ReleaseStart: return false; default: return true; } } // True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode // write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a // zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate // nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree. bool nodeInMode(EnvNode n, EnvMode m) { switch (n) { case EnvNode::AttackEnd: case EnvNode::HoldEnd: case EnvNode::DecayEnd: case EnvNode::ReleaseEnd: return m == EnvMode::Gate; case EnvNode::FadeInEnd: case EnvNode::FadeOutStart: case EnvNode::LengthEnd: return m == EnvMode::Trigger; case EnvNode::Origin: case EnvNode::ReleaseStart: return false; // never draggable in any mode (isDraggable filters these anyway) } return false; } } // namespace NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) { const std::vector poly = buildEnvelopePolyline(env, area, totalSeconds); // NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance — // the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never // coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only // matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the // tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate // baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode. NodeHit best; int bestDist = kNodeGrabRadius + 1; for (const EnvVertex& v : poly) { if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue; const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y)); if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties bestDist = dist; best = NodeHit{true, v.node}; } } return best; } AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area, double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels) { AmpEnvelope out = grabEnv; if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out; const double secPerPx = secondsPerPixel(area, totalSeconds); if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion const double dSec = static_cast(dxPixels) * secPerPx; // Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal // of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the // cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is // well-defined past the degenerate guard above. const double gateDSec = static_cast(dxPixels) * gateSecondsPerPixel(area); switch (node) { // --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative // durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour // because every segment stays >= 0), so the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: out.attackSeconds = clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: out.holdSeconds = clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { // Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // level, so subtract the level delta). out.decaySeconds = clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); const double lvlPerPx = levelPerPixel(area); const double dLevel = -static_cast(dyPixels) * lvlPerPx; out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); break; } case EnvNode::ReleaseEnd: out.releaseSeconds = clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); break; // --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED // span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the // two fade nodes never cross (each clamps against the other), and length in [0, max]. // // TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this): // fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span. // TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES // (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions: // pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate) // unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs) // See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula. case EnvNode::FadeInEnd: { if (dxPixels == 0) break; // zero-motion grab: no param change, no division const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeInFraction, 1.0 - std::max(0.0, grabEnv.fadeOutFraction)); out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::FadeOutStart: { if (dxPixels == 0) break; // zero-motion grab: no param change, no division // FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx) // lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta. const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeOutFraction, 1.0 - std::max(0.0, grabEnv.fadeInFraction)); out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::LengthEnd: { // LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it. const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0; out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction); break; } case EnvNode::Origin: case EnvNode::ReleaseStart: break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness } return out; } } // namespace reasampler::vst