Files
reasampler/src/core/instrument/engine/velocity_curve.cpp
T
daniel 2e09776342 fix: stroke arcs and splines analytically — opaque core, angle-independent weight
LICE_Arc never reaches opacity and ThickFLine's width is minor-axis. One
distance-to-polyline coverage mask, blended once, replaces both.
2026-08-01 13:18:21 -04:00

265 lines
11 KiB
C++

// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
#include "core/instrument/engine/velocity_curve.h"
#include <algorithm>
#include <cmath>
#include <utility>
namespace reasampler::instrument::engine {
namespace {
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampValue(double a, CurveDomain d) { return std::clamp(a, curveYMin(d), kCurveYMax); }
// X spans the width for [0,127]; Y spans (height-1) rows for the domain's range with its max at
// the TOP (pixel y increases downward, so this axis is inverted relative to the value).
double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width);
if (w <= 0) return 0.0;
return (kVelMax - kVelMin) / static_cast<double>(w);
}
double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
const int h = std::max(0, box.height);
if (h <= 1) return 0.0;
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
}
// The integer maps are these rounded — ONE mapping, so a sub-pixel trace and an integer hit-test
// cannot drift. Rounding the OFFSET (not the absolute coordinate) keeps the int results identical
// to what they were before the sub-pixel form existed: the offset is non-negative, so truncation
// is floor regardless of where the box sits.
double velToXf(const VelocityCurve::Box& box, double velocity) {
const int w = std::max(0, box.width);
if (w <= 0) return static_cast<double>(box.left);
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
return static_cast<double>(box.left) + frac * static_cast<double>(w);
}
double valueToYf(const VelocityCurve::Box& box, double value, CurveDomain d) {
const int h = std::max(0, box.height);
if (h <= 1) return static_cast<double>(box.top);
const double lo = curveYMin(d);
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
return static_cast<double>(box.top) + (1.0 - frac) * static_cast<double>(h - 1);
}
int velToX(const VelocityCurve::Box& box, double velocity) {
return box.left +
static_cast<int>(velToXf(box, velocity) - static_cast<double>(box.left) + 0.5);
}
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
return box.top +
static_cast<int>(valueToYf(box, value, d) - static_cast<double>(box.top) + 0.5);
}
} // namespace
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
const double n = curveNeutral(CurveDomain::Unipolar);
c.points_ = {{kVelMin, n}, {kVelMax, n}};
return c;
}
VelocityCurve VelocityCurve::linear() {
VelocityCurve c;
c.points_ = {{kVelMin, 0.0}, {kVelMax, kCurveYMax}};
return c;
}
VelocityCurve VelocityCurve::zero() {
VelocityCurve c;
c.domain_ = CurveDomain::Bipolar;
const double n = curveNeutral(CurveDomain::Bipolar);
c.points_ = {{kVelMin, n}, {kVelMax, n}};
return c;
}
VelocityCurve VelocityCurve::rampDown() {
VelocityCurve c;
c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots).
// Trim before the endpoint synthesis below can add up to two more, then again after, so a
// corrupt over-long blob lands at exactly the ceiling with its two endpoints intact.
if (pts.size() > kMaxCurvePoints) pts.resize(kMaxCurvePoints);
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.value = clampValue(p.value, domain);
}
std::stable_sort(pts.begin(), pts.end(),
[](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity;
});
if (pts.size() < 2) {
return domain == CurveDomain::Bipolar ? zero() : flat();
}
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value, pts.front().hard});
} else {
pts.front().velocity = kVelMin;
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().value, pts.back().hard});
} else {
pts.back().velocity = kVelMax;
}
if (pts.size() > kMaxCurvePoints) {
// Drop the interior points nearest the end, never an endpoint.
pts.erase(pts.begin() + static_cast<std::ptrdiff_t>(kMaxCurvePoints) - 1,
pts.end() - 1);
}
VelocityCurve c;
c.domain_ = domain;
c.points_ = std::move(pts);
return c;
}
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return curveNeutral(domain_);
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
const double v = clampVelocity(velocity);
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
// Linear walk: this overload is the COLD one (a note-on, a paint column). The per-sample
// reader is SplineCursor, which shares the same tangent + Hermite functions.
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
if (span <= 0.0) return clampValue(b.value, domain_);
const double d = (b.value - a.value) / span;
const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span);
const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB,
(v - a.velocity) / span);
return clampValue(y, domain_);
}
}
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
}
int VelocityCurve::addPoint(double velocity, double value) {
// At the ceiling the add is REFUSED outright rather than trading a point away — the existing
// contour must come through an over-add bit-identical.
if (points_.size() >= kMaxCurvePoints) return -1;
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_), false};
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
return static_cast<int>(i);
}
bool VelocityCurve::toggleHard(std::size_t index) {
if (index >= points_.size()) return false;
points_[index].hard = !points_[index].hard;
return true;
}
bool VelocityCurve::setHard(std::size_t index, bool hard) {
if (index >= points_.size()) return false;
points_[index].hard = hard;
return true;
}
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
const bool isFirst = (index == 0);
const bool isLast = (index + 1 == points_.size());
double newValue = clampValue(value, domain_);
double newVel;
if (isFirst) {
newVel = kVelMin;
} else if (isLast) {
newVel = kVelMax;
} else {
const double lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi);
}
points_[index] = VelocityPoint{newVel, newValue, points_[index].hard};
return points_[index];
}
bool VelocityCurve::deletePoint(std::size_t index) {
if (index >= points_.size()) return false;
if (index == 0 || index + 1 == points_.size()) return false; // endpoints are not deletable
points_.erase(points_.begin() + static_cast<std::ptrdiff_t>(index));
return true;
}
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
const VelocityPoint& p) const {
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
}
VelocityCurve::CurvePixelF VelocityCurve::subpixelFromPoint(const Box& box,
const VelocityPoint& p) const {
return CurvePixelF{velToXf(box, p.velocity), valueToYf(box, p.value, domain_)};
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const {
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
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));
const double lo = curveYMin(domain_);
p.value = (h <= 1)
? kCurveYMax
: clampValue(kCurveYMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
(kCurveYMax - lo),
domain_);
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);
const int py = valueToY(box, points_[i].value, domain_);
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
return static_cast<int>(i);
}
}
return -1;
}
VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels) {
VelocityCurve out = grabCurve;
if (index >= out.points_.size()) return out; // out of range -> no motion
const double velPerPx = velPerPixel(box);
const double valPerPx = valuePerPixel(box, grabCurve.domain_);
if (velPerPx <= 0.0 || valPerPx <= 0.0) return out; // degenerate box -> no motion
const VelocityPoint& grab = grabCurve.points_[index];
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
// Y increases downward but the value increases upward, so a downward drag (positive dy)
// LOWERS the value.
const double newValue = grab.value - static_cast<double>(dyPixels) * valPerPx;
out.movePoint(index, newVel, newValue); // applies box + neighbour-X + endpoint-pin clamps
return out;
}
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
if (domain_ != other.domain_) return false;
if (points_.size() != other.points_.size()) return false;
for (std::size_t i = 0; i < points_.size(); ++i) {
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
if (points_[i].hard != other.points_[i].hard) return false;
}
return true;
}
} // namespace reasampler::instrument::engine