Files
reasampler/tests/test_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

529 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD:
//
// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
// at -1, knots inside [0,1] evaluate identically in either domain (what the codec's v12
// re-tag rests on), and the pixel maps put value 0 on the box's centre line, not its floor.
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
// back to each domain's OWN neutral for a sub-2-point list.
#include "../src/core/instrument/engine/velocity_curve.h"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
using Box = VelocityCurve::Box;
// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks
// through a curve of the domain under test rather than a free function.
static const VelocityCurve& uni() {
static const VelocityCurve c = VelocityCurve::flat();
return c;
}
static const VelocityCurve& bip() {
static const VelocityCurve c = VelocityCurve::zero();
return c;
}
// --- eval ---------------------------------------------------------------------
static void testFlatIsUnityEverywhere() {
const VelocityCurve c = VelocityCurve::flat();
// R10-F1 Option A: every velocity plays at full level. Sweep the whole domain.
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 1.0));
// Two endpoints only.
CHECK(c.size() == 2);
}
static void testLinearRamp() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(0), 0.0));
CHECK(near(c.eval(127), 1.0));
// linear() is an EXACT straight line y = velocity/127: at any velocity the amp equals v/127.
CHECK(near(c.eval(63.5), 0.5)); // the exact midpoint
CHECK(near(c.eval(64.0), 64.0 / 127.0));
CHECK(near(c.eval(100.0), 100.0 / 127.0));
}
static void testEvalBoxClampsOutOfRangeVelocity() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(-10.0), 0.0)); // below 0 -> reads velocity-0 endpoint amp
CHECK(near(c.eval(200.0), 1.0)); // above 127 -> reads velocity-127 endpoint amp
}
static void testEvalMonotonicInX() {
// A curve that dips then rises must still be a well-defined FUNCTION (one amp per velocity) and
// monotonic WITHIN each segment. Build (0,1)->(64,0)->(127,1): eval sweeps must be single-valued
// and each half monotonic (down then up), never oscillating within a segment.
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 1.0);
c.addPoint(64.0, 0.0);
c.movePoint(2, 127, 1.0); // index 2 is the last endpoint after the insert
CHECK(c.size() == 3);
// Descending half [0,64]: non-increasing.
double prev = c.eval(0);
for (int v = 1; v <= 64; ++v) {
const double cur = c.eval(v);
CHECK(cur <= prev + 1e-9);
prev = cur;
}
// Ascending half [64,127]: non-decreasing.
prev = c.eval(64);
for (int v = 65; v <= 127; ++v) {
const double cur = c.eval(v);
CHECK(cur >= prev - 1e-9);
prev = cur;
}
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
}
// --- eval: monotone cubic Hermite spline (FritschCarlson) --------------------
static void testCollinearControlPointsReproduceExactLinearRamp() {
// The Option-B guarantee: for COLLINEAR knots the FC tangents reduce to the secant slope, so the
// spline IS the straight line y = velocity/127 — bit-exact, not merely close. Add an interior
// point that sits EXACTLY on the linear ramp so all three knots are collinear.
VelocityCurve c = VelocityCurve::linear(); // (0,0),(127,1)
c.addPoint(60.0, 60.0 / 127.0); // on the line -> still collinear
// Every velocity must equal velocity/127 to full double precision (bit-exact reproduction).
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), v / 127.0, 1e-12));
// And the untouched linear() with only its two endpoints, too.
const VelocityCurve line = VelocityCurve::linear();
for (int v = 0; v <= 127; ++v) CHECK(near(line.eval(v), v / 127.0, 1e-12));
}
static void testNoOvershootWithSharpInteriorDip() {
// A sharp interior dip is the classic overshoot trap: a NON-monotone interpolant (Catmull-Rom /
// natural cubic) would bulge the curve below 0 near the trough. FritschCarlson must keep every
// sampled amp inside [0,1] across the whole domain. Build (0,1)->(64,0)->(127,1).
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 1.0);
c.addPoint(64.0, 0.0);
c.movePoint(2, 127, 1.0);
for (int v = 0; v <= 127; ++v) {
const double y = c.eval(v);
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
}
// A dense sub-integer sweep too (the spline could overshoot between integer velocities).
for (int k = 0; k <= 1270; ++k) {
const double y = c.eval(k / 10.0);
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
}
CHECK(near(c.eval(64), 0.0)); // knot honored exactly
}
static void testSplineStaysSingleValuedMonotoneInEachSegment() {
// A rising staircase of knots: the spline must be non-decreasing across the whole domain (the FC
// limiter forbids overshoot, so a monotone-increasing knot set yields a monotone-increasing
// curve — no local wiggles that would make eval multi-valued in feel).
VelocityCurve c = VelocityCurve::linear();
c.addPoint(30.0, 0.1);
c.addPoint(60.0, 0.15); // a near-flat run then a steep rise: overshoot bait for a plain cubic
c.addPoint(90.0, 0.9);
double prev = c.eval(0);
for (int k = 1; k <= 1270; ++k) {
const double cur = c.eval(k / 10.0);
CHECK(cur >= prev - 1e-9); // non-decreasing everywhere -> single-valued, no wiggle
CHECK(cur >= 0.0 - 1e-12 && cur <= 1.0 + 1e-12);
prev = cur;
}
}
static void testSplinePinsEndpointsExactly() {
// The curve must pass THROUGH every control point, endpoints included, regardless of curvature.
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 0.2); // first endpoint amp 0.2
c.addPoint(40.0, 0.9);
c.addPoint(80.0, 0.1);
c.movePoint(3, 127, 0.7); // last endpoint amp 0.7
CHECK(near(c.eval(0), 0.2));
CHECK(near(c.eval(40), 0.9));
CHECK(near(c.eval(80), 0.1));
CHECK(near(c.eval(127), 0.7));
}
// --- editing: addPoint --------------------------------------------------------
static void testAddPointKeepsXOrderAndClamps() {
VelocityCurve c = VelocityCurve::linear(); // (0,0), (127,1)
const std::size_t i = c.addPoint(60.0, 0.3);
CHECK(i == 1); // inserted between the two endpoints
CHECK(c.size() == 3);
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3));
// Out-of-box add clamps into [0,127] x [0,1].
c.addPoint(500.0, 5.0);
const VelocityPoint& last = c.points().back();
CHECK(near(last.velocity, 127.0) && near(last.value, 1.0));
// Points remain X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
}
// --- editing: movePoint -------------------------------------------------------
static void testMoveInteriorClampsToNeighbours() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(40.0, 0.4); // idx 1
c.addPoint(80.0, 0.8); // idx 2
CHECK(c.size() == 4); // (0,0)(40,.4)(80,.8)(127,1)
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
CHECK(near(r.velocity, 80.0));
CHECK(near(r.value, 0.5)); // amp is free (box-clamped only)
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
CHECK(near(r2.velocity, 0.0));
}
static void testMoveEndpointsArePinnedInX() {
VelocityCurve c = VelocityCurve::linear();
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
CHECK(near(f.velocity, 0.0));
CHECK(near(f.value, 0.25));
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
CHECK(near(l.velocity, 127.0));
CHECK(near(l.value, 1.0));
}
static void testMoveOutOfRangeIndexIsNoOp() {
VelocityCurve c = VelocityCurve::linear();
c.movePoint(99, 50.0, 0.5);
CHECK(c.size() == 2);
CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0)); // unchanged
}
// --- editing: deletePoint -----------------------------------------------------
static void testDeleteRemovesInteriorRefusesEndpoints() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(60.0, 0.5); // idx 1
CHECK(c.size() == 3);
// Endpoints refuse deletion.
CHECK(!c.deletePoint(0));
CHECK(!c.deletePoint(2));
CHECK(c.size() == 3);
// Interior deletes.
CHECK(c.deletePoint(1));
CHECK(c.size() == 2);
// Out-of-range refuses.
CHECK(!c.deletePoint(9));
}
// --- hit-test + inverse map ---------------------------------------------------
// A 127px-wide, 101px-tall box at origin: velocity->x is 1px/unit, amp->y spans 100 rows (1 px per
// 0.01 amp), amp 1 at top (y=0), amp 0 at bottom (y=100).
static Box wideBox() { return Box{0, 0, 127, 101}; }
static void testPointAtPixelGrabsDrawnNode() {
VelocityCurve c = VelocityCurve::linear(); // (0,0) at (0,100); (127,1) at (127,0)
const Box b = wideBox();
// Grab near the first endpoint's drawn point (x=0, y=100).
CHECK(c.pointAtPixel(b, 0, 100) == 0);
// Grab near the last endpoint (x=127, y=0).
CHECK(c.pointAtPixel(b, 127, 0) == 1);
// A point far from any node misses.
CHECK(c.pointAtPixel(b, 63, 50) == -1);
}
static void testResolveDragMovesAndClamps() {
VelocityCurve grab = VelocityCurve::linear();
grab.addPoint(60.0, 0.5); // idx 1, drawn at x=60, y=50
const Box b = wideBox();
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
CHECK(near(moved.points()[1].value, 0.60, 1e-6));
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
CHECK(near(movedEnd.points()[0].velocity, 0.0));
CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
}
static void testResolveDragDegenerateBoxNoMotion() {
const VelocityCurve grab = VelocityCurve::linear();
const VelocityCurve r = VelocityCurve::resolvePointDrag(grab, 1, Box{0, 0, 0, 0}, 50, 50);
CHECK(r.equals(grab)); // zero-size box -> unchanged
}
// --- fromPoints (deserialization repair) --------------------------------------
static void testFromPointsSortsClampsAndForcesEndpoints() {
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
// X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
// Endpoints forced present at 0 and 127.
CHECK(near(c.points().front().velocity, 0.0));
CHECK(near(c.points().back().velocity, 127.0));
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
for (const VelocityPoint& p : c.points()) {
CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12);
}
}
static void testFromPointsSubTwoFallsBackToFlat() {
const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar);
CHECK(c0.equals(VelocityCurve::flat()));
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar);
CHECK(c1.equals(VelocityCurve::flat()));
// The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a
// corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully.
const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar);
CHECK(b0.equals(VelocityCurve::zero()));
const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar);
CHECK(b1.equals(VelocityCurve::zero()));
}
// --- the bipolar domain -------------------------------------------------------
static void testZeroIsExactlyZeroAtEveryVelocity() {
// The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or
// cutoff offset derived from it cannot nudge anything.
const VelocityCurve c = VelocityCurve::zero();
CHECK(c.domain() == CurveDomain::Bipolar);
for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0);
CHECK(c.size() == 2);
}
static void testBipolarEvalSpansTheNegativeHalf() {
// A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact
// straight line through zero — the whole point of the widened domain.
const VelocityCurve c =
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
CHECK(near(c.eval(0), -1.0));
CHECK(near(c.eval(127), 1.0));
CHECK(near(c.eval(63.5), 0.0, 1e-12));
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12));
}
static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
// The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain
// cannot be negative), bipolar keeps it.
const std::vector<VelocityPoint> raw = {{0.0, -0.5}, {127.0, 0.5}};
const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar);
CHECK(near(u.eval(0), 0.0));
CHECK(near(b.eval(0), -0.5));
// Out-of-domain magnitudes clamp to each domain's own floor.
const VelocityCurve b2 =
VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar);
CHECK(near(b2.eval(0), -1.0));
CHECK(near(b2.eval(127), 1.0));
}
static void testTheSameKnotsEvaluateIdenticallyInEitherDomain() {
// What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is
// read the same way in either domain — the domain governs the CLAMP, not the evaluation.
// Asserted against a CURVED (non-collinear) knot set, where the tangents are doing work,
// and with ==: the re-tag is bit-identical, not merely close.
const std::vector<VelocityPoint> knots = {
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v));
}
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
// The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar
// curve and at the bottom row for a unipolar one — the one mapping difference the shared
// popup code path has to get right.
const Box box{10, 20, 100, 101}; // 100 value rows: centre is 50 rows down
CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70);
CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120);
// Each domain's floor lands on the bottom row, its ceiling on the top.
CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120);
CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20);
// And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar.
CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12));
CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12));
}
static void testBipolarDragCoversTwiceTheValueRange() {
// A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box
// — the domain spans 2.0, not 1.0. Both still land inside their own domain.
const Box box{0, 0, 127, 101}; // 100 value rows
VelocityCurve u = VelocityCurve::flat();
u.movePoint(0, 0.0, 0.5);
VelocityCurve b = VelocityCurve::zero();
const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10);
const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10);
CHECK(near(uMoved.points()[0].value, 0.60, 1e-6));
CHECK(near(bMoved.points()[0].value, 0.20, 1e-6));
}
// --- 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 = uni().pixelFromPoint(box, {0.0, 1.0});
CHECK(tl.x == 10 && tl.y == 20);
const auto br = uni().pixelFromPoint(box, {127.0, 0.0});
CHECK(br.x == 110 && br.y == 70);
const auto mid = uni().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 = uni().pixelFromPoint(box, {200.0, 2.0});
CHECK(clamped.x == 110 && clamped.y == 20);
}
static void testSubpixelMapIsTheIntegerMapBeforeRounding() {
// The antialiased trace draws off subpixelFromPoint while the hit-test still resolves against
// pixelFromPoint. If the two were separate formulas the trace would drift off its own handles,
// so pin the relationship rather than the sub-pixel values: int == round(subpixel), everywhere.
const Box boxes[] = {{10, 20, 100, 51}, {0, 0, 127, 101}, {7, 3, 33, 17}};
for (const Box& box : boxes) {
for (const VelocityCurve& c : {uni(), bip()}) {
for (double v = 0.0; v <= 127.0; v += 1.0) {
for (double a = -1.0; a <= 1.0; a += 0.125) {
const auto ip = c.pixelFromPoint(box, {v, a});
const auto fp = c.subpixelFromPoint(box, {v, a});
CHECK(ip.x == box.left + static_cast<int>(fp.x - box.left + 0.5));
CHECK(ip.y == box.top + static_cast<int>(fp.y - box.top + 0.5));
}
}
}
}
}
static void testSubpixelMapResolvesSlopesTheIntegerMapFlattens() {
// The defect this exists for: adjacent columns of a gentle slope round to the SAME integer
// row, so an integer-only trace is a staircase. The sub-pixel map must separate them.
const Box box{0, 0, 400, 101};
const VelocityCurve c = VelocityCurve::linear();
int identicalIntRows = 0;
double maxSubpixelStep = 0.0, minSubpixelStep = 1e18;
for (int x = 1; x <= 200; ++x) {
const double v0 = c.pointFromPixel(box, x - 1, box.top).velocity;
const double v1 = c.pointFromPixel(box, x, box.top).velocity;
const int y0 = c.pixelFromPoint(box, {v0, c.eval(v0)}).y;
const int y1 = c.pixelFromPoint(box, {v1, c.eval(v1)}).y;
if (y0 == y1) ++identicalIntRows;
const double d = std::fabs(c.subpixelFromPoint(box, {v1, c.eval(v1)}).y -
c.subpixelFromPoint(box, {v0, c.eval(v0)}).y);
if (d > maxSubpixelStep) maxSubpixelStep = d;
if (d < minSubpixelStep) minSubpixelStep = d;
}
CHECK(identicalIntRows > 100); // the integer map really does flatten
CHECK(maxSubpixelStep - minSubpixelStep < 1e-9); // the sub-pixel one advances evenly
CHECK(maxSubpixelStep > 0.0);
}
static void testPointFromPixelInvertsAndClamps() {
const Box box{10, 20, 100, 51};
// Exact corners invert exactly.
const VelocityPoint tl = uni().pointFromPixel(box, 10, 20);
CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0));
const VelocityPoint br = uni().pointFromPixel(box, 110, 70);
CHECK(near(br.velocity, 127.0) && near(br.value, 0.0));
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
const VelocityPoint out = uni().pointFromPixel(box, -50, 500);
CHECK(near(out.velocity, 0.0) && near(out.value, 0.0));
const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50);
CHECK(near(out2.velocity, 127.0) && near(out2.value, 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 = uni().pixelFromPoint(box, p);
const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y);
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
CHECK(std::fabs(back.value - p.value) <= 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 = uni().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 = uni().pointFromPixel(flat, 50, 50);
CHECK(near(p.velocity, 0.0) && near(p.value, 1.0));
}
static void testFromPointsRoundTripsAValidCurve() {
VelocityCurve orig = VelocityCurve::linear();
orig.addPoint(40.0, 0.2);
orig.addPoint(90.0, 0.7);
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar);
CHECK(rebuilt.equals(orig));
}
int main() {
testFlatIsUnityEverywhere();
testLinearRamp();
testEvalBoxClampsOutOfRangeVelocity();
testEvalMonotonicInX();
testCollinearControlPointsReproduceExactLinearRamp();
testNoOvershootWithSharpInteriorDip();
testSplineStaysSingleValuedMonotoneInEachSegment();
testSplinePinsEndpointsExactly();
testAddPointKeepsXOrderAndClamps();
testMoveInteriorClampsToNeighbours();
testMoveEndpointsArePinnedInX();
testMoveOutOfRangeIndexIsNoOp();
testDeleteRemovesInteriorRefusesEndpoints();
testPointAtPixelGrabsDrawnNode();
testResolveDragMovesAndClamps();
testResolveDragDegenerateBoxNoMotion();
testFromPointsSortsClampsAndForcesEndpoints();
testFromPointsSubTwoFallsBackToFlat();
testZeroIsExactlyZeroAtEveryVelocity();
testBipolarEvalSpansTheNegativeHalf();
testUnipolarClampsAtZeroWhereBipolarDoesNot();
testTheSameKnotsEvaluateIdenticallyInEitherDomain();
testBipolarPixelMapPutsZeroOnTheCentreLine();
testBipolarDragCoversTwiceTheValueRange();
testPixelFromPointMapsCornersAndMidpoint();
testSubpixelMapIsTheIntegerMapBeforeRounding();
testSubpixelMapResolvesSlopesTheIntegerMapFlattens();
testPointFromPixelInvertsAndClamps();
testPixelMapsRoundTripWithinOnePixelQuantum();
testPixelFromPointAgreesWithPointAtPixel();
testPointFromPixelDegenerateBox();
testFromPointsRoundTripsAValidCurve();
if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
else std::printf("velocity_curve: %d FAILURES\n", g_fail);
return g_fail == 0 ? 0 : 1;
}