FA2: bounded-schematic Gate envelope (15% sustain reserve, in-bounds release) + zero-fade-out grabbable via nearest-node hit-test; all nodes clamp in-canvas
This commit is contained in:
@@ -23,6 +23,15 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// Seconds per pixel in the GATE timed region (FA2): the Gate schematic maps A/H/D/R onto
|
||||
// gateTimedWidth(area) px, not the full canvas, so a Gate time-node drag must use this scale for
|
||||
// the handle to track the cursor. Matches envelope_overlay::gatePolyline.
|
||||
double gateSecondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
const int w = gateTimedWidth(area);
|
||||
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -46,14 +55,23 @@ bool isDraggable(EnvNode n) {
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// First-match in draw order (deterministic tie-break), skipping non-draggable anchors.
|
||||
// NEAREST draggable node within the pick radius wins (Chebyshev distance — the square grab
|
||||
// box); ties break to the earlier draw-order node (FA2). Nearest-wins keeps every handle
|
||||
// grabbable when nodes sit close (e.g. a short hold), while the draw-order tie-break makes
|
||||
// exactly-coincident nodes deterministic: at zero fade-out, FadeOutStart overlays LengthEnd
|
||||
// and WINS the tie, so the fade-out handle is grabbable at the right edge and can be dragged
|
||||
// inward from zero.
|
||||
NodeHit best;
|
||||
int bestDist = kNodeGrabRadius + 1;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node)) continue;
|
||||
if (std::abs(x - v.x) <= kNodeGrabRadius && std::abs(y - v.y) <= kNodeGrabRadius) {
|
||||
return NodeHit{true, v.node};
|
||||
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 NodeHit{false, EnvNode::Origin};
|
||||
return best;
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
@@ -65,21 +83,28 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
const double secPerPx = secondsPerPixel(area, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
// Gate time nodes live in the TIMED region of the Gate schematic (FA2), which is narrower
|
||||
// than the canvas by the sustain-plateau reserve — their px->seconds scale differs 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<double>(dxPixels) * gateSecondsPerPixel(area, totalSeconds);
|
||||
|
||||
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 + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
out.attackSeconds =
|
||||
clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds = clamp(grabEnv.holdSeconds + dSec, 0.0, bounds.maxHoldSeconds);
|
||||
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 + dSec, 0.0, bounds.maxDecaySeconds);
|
||||
out.decaySeconds = clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(area);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
@@ -87,7 +112,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
}
|
||||
case EnvNode::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
clamp(grabEnv.releaseSeconds + dSec, 0.0, bounds.maxReleaseSeconds);
|
||||
clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
|
||||
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
|
||||
|
||||
Reference in New Issue
Block a user