Merge Γ-W1-T3: staged contour traces draw the curve their exponent defines
This commit is contained in:
@@ -337,6 +337,7 @@ anything for a trigger shape.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `curve_tessellate` — the staged envelope's TRACE, split from `envelope_overlay` on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at `start + (end - start) * curveMap(phi)` — the composition `envelopes.h`'s four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -104,3 +104,12 @@ reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_la
|
||||
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
|
||||
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
|
||||
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
|
||||
|
||||
# The staged trace, split from envelope_overlay's vertex model. stroke_aa is the trace-point
|
||||
# vocabulary, filled in place so the shell's scratch buffer is reused rather than a fresh
|
||||
# vector returned per paint; curve_law arrives through envelope_overlay but is named here
|
||||
# because this module evaluates the law rather than merely carrying its exponents.
|
||||
reasampler_pure_library(curve_tessellate
|
||||
SOURCES curve_tessellate.cpp
|
||||
LINK PUBLIC envelope_overlay stroke_aa curve_law)
|
||||
reasampler_test(curve_tessellate LINK curve_tessellate)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// curve_tessellate.cpp — see curve_tessellate.h. Pure geometry; no host types.
|
||||
|
||||
#include "core/instrument/ui/curve_tessellate.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "core/util/curve_law.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using StrokePoint = reasampler::ui::StrokePoint;
|
||||
|
||||
namespace {
|
||||
|
||||
StrokePoint pt(double x, double y) {
|
||||
return StrokePoint{static_cast<float>(x), static_cast<float>(y)};
|
||||
}
|
||||
|
||||
// The samples strictly BETWEEN two nodes. The endpoints are the caller's, so a shared node is
|
||||
// emitted once and the polyline carries no zero-length joint.
|
||||
void appendInterior(int x0, int y0, int x1, int y1, double exponent,
|
||||
std::vector<StrokePoint>& out) {
|
||||
const int span = std::abs(x1 - x0);
|
||||
if (span < 2 || y1 == y0 || exponent == util::kCurveNeutral) return;
|
||||
const double dx = static_cast<double>(x1 - x0);
|
||||
const double dy = static_cast<double>(y1 - y0);
|
||||
for (int i = 1; i < span; ++i) {
|
||||
// phi is exact at every column, so x lands on the integer column and the last interior
|
||||
// sample is one column short of the end node.
|
||||
const double phi = static_cast<double>(i) / static_cast<double>(span);
|
||||
out.push_back(pt(static_cast<double>(x0) + dx * phi,
|
||||
static_cast<double>(y0) + dy * util::curveMap(phi, exponent)));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double segmentCurve(const StageEnvelope& env, EnvNode endNode) {
|
||||
switch (endNode) {
|
||||
case EnvNode::AttackEnd: return env.attackCurve;
|
||||
case EnvNode::DecayEnd: return env.decayCurve;
|
||||
case EnvNode::ReleaseEnd: return env.releaseCurve;
|
||||
default: return util::kCurveNeutral;
|
||||
}
|
||||
}
|
||||
|
||||
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
|
||||
int xHi, std::vector<StrokePoint>& out) {
|
||||
out.clear();
|
||||
bool started = false;
|
||||
int px = 0;
|
||||
int py = 0;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.knot) continue;
|
||||
const int x = std::max(xLo, std::min(xHi, v.x));
|
||||
if (started) appendInterior(px, py, x, v.y, segmentCurve(env, v.node), out);
|
||||
out.push_back(pt(x, v.y));
|
||||
started = true;
|
||||
px = x;
|
||||
py = v.y;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,34 @@
|
||||
// curve_tessellate.h — the staged envelope's TRACE: envelope_overlay's node vertices joined by
|
||||
// the curve each stage's exponent defines. Split from that module on the axis the two already
|
||||
// have — envelope_overlay decides where a node LANDS, this strokes the span BETWEEN two of
|
||||
// them over phi, so a re-scaled time axis changes nothing here.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/envelope_overlay.h"
|
||||
#include "core/ui/stroke_aa.h" // StrokePoint — the stroker's own vertex type
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The exponent governing the segment that ENDS at `node`: attack, decay and release are the
|
||||
// three sloped stages. Every other node ends a plateau, whose straightness comes from its own
|
||||
// zero level span rather than from an exponent, so neutral is returned and no caller needs a
|
||||
// second rule to recognize one.
|
||||
double segmentCurve(const StageEnvelope& env, EnvNode endNode);
|
||||
|
||||
// Replaces `out` with the polyline the shell strokes. Knot vertices are handles, not line
|
||||
// vertices, and are skipped; node vertices keep their exact INTEGER coordinates (clamped to
|
||||
// [xLo, xHi]) because those are the positions their draggable handles are drawn at. Only the
|
||||
// interior samples are sub-pixel, one per pixel column, which is what makes the density follow
|
||||
// the canvas width instead of a fixed count.
|
||||
//
|
||||
// A segment's level runs start + (end - start) * curveMap(phi, exponent) — the composition
|
||||
// envelopes.h's four evaluators use, so the trace cannot diverge from the sound. A neutral
|
||||
// exponent or a zero level span emits the two endpoints and nothing between: the straight
|
||||
// stroke, vertex for vertex.
|
||||
void buildEnvelopeTrace(const std::vector<EnvVertex>& poly, const StageEnvelope& env, int xLo,
|
||||
int xHi, std::vector<reasampler::ui::StrokePoint>& out);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -91,6 +91,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||
bake_hold
|
||||
file_bytes curve_law stroke_aa
|
||||
curve_tessellate
|
||||
bake_plan bake_render bake_reset bake_wire wav_codec)
|
||||
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
||||
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
||||
#include "core/instrument/ui/curve_tessellate.h" // buildEnvelopeTrace (the staged trace)
|
||||
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
|
||||
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||
@@ -212,17 +213,9 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
|
||||
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Clip x to the wave rect. Knots are handles, not line vertices. Vertices stay INTEGER here
|
||||
// — unlike the spline traces above — because they are the same positions the draggable
|
||||
// handles are drawn at, and a sub-pixel trace would sit off its own handles.
|
||||
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
|
||||
std::vector<ui::StrokePoint>& trace = scratchPoints();
|
||||
trace.clear();
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.knot) continue;
|
||||
const int vx = (std::max)(area.x, (std::min)(area.right() - 1, v.x));
|
||||
trace.push_back(ui::StrokePoint{static_cast<float>(vx), static_cast<float>(v.y)});
|
||||
}
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
// A degenerate envelope (every stage collapsed to zero span) can reduce this to ONE vertex.
|
||||
// strokePolylineAA's round-cap zero-length case then draws a dot at it, marking the sole
|
||||
// point rather than drawing nothing — kept deliberately as more legible than a blank trace.
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Standalone tests for reasampler::instrument::ui::curve_tessellate — no VST3, no REAPER, no
|
||||
// framework. Same fast assert loop as the sibling pure tests.
|
||||
//
|
||||
// Every assertion is expressed RELATIVE to the vertices buildEnvelopePolyline returns, never
|
||||
// against an absolute pixel literal, so a re-scaled overlay axis leaves this file untouched.
|
||||
// Covers: segmentCurve's node->exponent rule; the neutral exponent emitting today's straight
|
||||
// vertex list unchanged; knots excluded and node vertices preserved exactly; THE GATE — the
|
||||
// knot's centre within 1 px of the trace at every exponent, on all three sloped stages of both
|
||||
// layout policies; the mid-segment level matching curve_law's own curveMidLevel; curvature
|
||||
// direction; no overshoot past a segment's own endpoint levels; per-pixel-column density that
|
||||
// scales with the canvas; the x clamp; and the degenerate/empty cases.
|
||||
//
|
||||
// Both layout policies here ARE all three envelopes and both play modes: the editor's
|
||||
// packEnvelope collapses amp/filter/pitch x Gate/Trigger onto exactly these two EnvKinds, and
|
||||
// paintEnvelopeOverlay is the single paint path over them.
|
||||
|
||||
#include "../src/core/instrument/ui/curve_tessellate.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/util/curve_law.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using reasampler::ui::StrokePoint;
|
||||
|
||||
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 OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
|
||||
|
||||
// Offset so left/top != 0 (catches origin bugs). Tall enough that a 1 px tolerance is a small
|
||||
// fraction of the level span, which is what makes the level assertions discriminating.
|
||||
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 210); } // width 1000, height 200
|
||||
|
||||
static StageEnvelope ahdsr(double a, double h, double d, double sus, double r) {
|
||||
StageEnvelope e;
|
||||
e.kind = EnvKind::Ahdsr;
|
||||
e.attackSeconds = a;
|
||||
e.holdSeconds = h;
|
||||
e.decaySeconds = d;
|
||||
e.sustainLevel = sus;
|
||||
e.releaseSeconds = r;
|
||||
return e;
|
||||
}
|
||||
|
||||
static StageEnvelope ahd(double a, double d, double frac, double span) {
|
||||
StageEnvelope e;
|
||||
e.kind = EnvKind::Ahd;
|
||||
e.attackSeconds = a;
|
||||
e.decaySeconds = d;
|
||||
e.holdFraction = frac;
|
||||
e.originSeconds = 0.0;
|
||||
e.spanSeconds = span;
|
||||
return e;
|
||||
}
|
||||
|
||||
static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == node) { out = v; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The polyline's y where it crosses `x` — the trace as STROKED, not merely its samples, so a
|
||||
// knot between two samples is still measured against the line the user sees.
|
||||
static bool traceYAtX(const std::vector<StrokePoint>& t, double x, double& y) {
|
||||
for (std::size_t i = 1; i < t.size(); ++i) {
|
||||
const double x0 = t[i - 1].x, x1 = t[i].x;
|
||||
if (x1 == x0) {
|
||||
if (x == x0) { y = t[i].y; return true; }
|
||||
continue;
|
||||
}
|
||||
const double lo = x0 < x1 ? x0 : x1, hi = x0 < x1 ? x1 : x0;
|
||||
if (x < lo || x > hi) continue;
|
||||
const double u = (x - x0) / (x1 - x0);
|
||||
y = t[i - 1].y + (t[i].y - t[i - 1].y) * u;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The three sloped stages, each named by the knot that rides it and the two nodes it runs
|
||||
// between — the same pairing gatePolyline/ahdPolyline place the knot from.
|
||||
struct Stage {
|
||||
EnvNode knot;
|
||||
EnvNode from;
|
||||
EnvNode to;
|
||||
};
|
||||
static const Stage kStages[3] = {
|
||||
{EnvNode::AttackCurve, EnvNode::Origin, EnvNode::AttackEnd},
|
||||
{EnvNode::DecayCurve, EnvNode::HoldEnd, EnvNode::DecayEnd},
|
||||
{EnvNode::ReleaseCurve, EnvNode::ReleaseStart, EnvNode::ReleaseEnd},
|
||||
};
|
||||
|
||||
static const double kExponents[7] = {util::kCurveMin, 0.25, 0.5, util::kCurveNeutral,
|
||||
2.0, 4.0, util::kCurveMax};
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
static void testSegmentCurve() {
|
||||
StageEnvelope e = ahdsr(1.0, 0.0, 1.0, 0.5, 1.0);
|
||||
e.attackCurve = 0.3;
|
||||
e.decayCurve = 3.0;
|
||||
e.releaseCurve = 7.0;
|
||||
CHECK(segmentCurve(e, EnvNode::AttackEnd) == e.attackCurve);
|
||||
CHECK(segmentCurve(e, EnvNode::DecayEnd) == e.decayCurve);
|
||||
CHECK(segmentCurve(e, EnvNode::ReleaseEnd) == e.releaseCurve);
|
||||
// The plateau-ending nodes carry no exponent of their own.
|
||||
CHECK(segmentCurve(e, EnvNode::HoldEnd) == util::kCurveNeutral);
|
||||
CHECK(segmentCurve(e, EnvNode::ReleaseStart) == util::kCurveNeutral);
|
||||
CHECK(segmentCurve(e, EnvNode::Origin) == util::kCurveNeutral);
|
||||
}
|
||||
|
||||
// The regression guard: at the neutral exponent the trace IS the non-knot vertex list, one
|
||||
// point per vertex, at the same integer coordinates — the straight stroke drawn before curves.
|
||||
static void testNeutralIsTodaysStraightLine() {
|
||||
const Rect area = wideArea();
|
||||
const StageEnvelope envs[2] = {ahdsr(2.0, 0.0, 2.0, 0.4, 2.0), ahd(1.2, 1.6, 0.5, 4.0)};
|
||||
for (const StageEnvelope& env : envs) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
std::size_t nodes = 0;
|
||||
for (const EnvVertex& v : poly) if (!v.knot) ++nodes;
|
||||
CHECK(trace.size() == nodes);
|
||||
std::size_t i = 0;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.knot) continue;
|
||||
CHECK(trace[i].x == static_cast<float>(v.x));
|
||||
CHECK(trace[i].y == static_cast<float>(v.y));
|
||||
++i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Knots are handles, not line vertices. A knot sits mid-canvas but is appended AFTER the last
|
||||
// node, so admitting one would break the trace's x ordering — which is what this catches.
|
||||
static void testKnotsExcludedAndNodesPreserved() {
|
||||
const Rect area = wideArea();
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
env.attackCurve = 0.3;
|
||||
env.decayCurve = 4.0;
|
||||
env.releaseCurve = 0.4;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
std::size_t knots = 0;
|
||||
for (const EnvVertex& v : poly) if (v.knot) ++knots;
|
||||
CHECK(knots == 3);
|
||||
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
for (std::size_t i = 1; i < trace.size(); ++i) CHECK(trace[i].x >= trace[i - 1].x);
|
||||
|
||||
// Every node vertex still appears at its exact integer position: the handles are drawn
|
||||
// there, and a trace that missed one would sit off its own handle.
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.knot) continue;
|
||||
bool found = false;
|
||||
for (const StrokePoint& p : trace) {
|
||||
if (p.x == static_cast<float>(v.x) && p.y == static_cast<float>(v.y)) found = true;
|
||||
}
|
||||
CHECK(found);
|
||||
}
|
||||
}
|
||||
|
||||
// THE GATE (plan acceptance criterion 1): at every exponent the knot's centre lies on the
|
||||
// trace within 1 px. Swept over all three sloped stages of the AHDSR schematic.
|
||||
static void testKnotLiesOnTraceAhdsr() {
|
||||
const Rect area = wideArea();
|
||||
for (const Stage& st : kStages) {
|
||||
for (double pw : kExponents) {
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw;
|
||||
else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw;
|
||||
else env.releaseCurve = pw;
|
||||
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex knot;
|
||||
CHECK(findNode(poly, st.knot, knot));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
double y = 0.0;
|
||||
CHECK(traceYAtX(trace, static_cast<double>(knot.x), y));
|
||||
CHECK(std::fabs(y - static_cast<double>(knot.y)) <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same gate on the AHD policy — its two sloped stages, its own 1:1 time axis.
|
||||
static void testKnotLiesOnTraceAhd() {
|
||||
const Rect area = wideArea();
|
||||
for (int stage = 0; stage < 2; ++stage) {
|
||||
for (double pw : kExponents) {
|
||||
StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0);
|
||||
if (stage == 0) env.attackCurve = pw; else env.decayCurve = pw;
|
||||
const EnvNode knotNode = stage == 0 ? EnvNode::AttackCurve : EnvNode::DecayCurve;
|
||||
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex knot;
|
||||
CHECK(findNode(poly, knotNode, knot));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
double y = 0.0;
|
||||
CHECK(traceYAtX(trace, static_cast<double>(knot.x), y));
|
||||
CHECK(std::fabs(y - static_cast<double>(knot.y)) <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The trace is the SAME law the audio evaluates: at a segment's midpoint its level is
|
||||
// curve_law's curveMidLevel of that stage's exponent, composed onto the endpoints the trace
|
||||
// itself returned.
|
||||
static void testMidSegmentLevelMatchesTheLaw() {
|
||||
const Rect area = wideArea();
|
||||
for (const Stage& st : kStages) {
|
||||
for (double pw : kExponents) {
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw;
|
||||
else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw;
|
||||
else env.releaseCurve = pw;
|
||||
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex a, b;
|
||||
CHECK(findNode(poly, st.from, a));
|
||||
CHECK(findNode(poly, st.to, b));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
|
||||
const double xm = 0.5 * (static_cast<double>(a.x) + static_cast<double>(b.x));
|
||||
double y = 0.0;
|
||||
CHECK(traceYAtX(trace, xm, y));
|
||||
const double want = static_cast<double>(a.y) +
|
||||
(static_cast<double>(b.y) - static_cast<double>(a.y)) *
|
||||
util::curveMidLevel(pw);
|
||||
CHECK(std::fabs(y - want) <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A curve must bend, and in the direction the exponent names: phi^p with p > 1 holds the level
|
||||
// LOW for longer, p < 1 lifts it early. Stated against the chord between the two endpoints the
|
||||
// trace returned, so it holds whichever way the segment slopes.
|
||||
static void testCurvatureDirection() {
|
||||
const Rect area = wideArea();
|
||||
for (const Stage& st : kStages) {
|
||||
for (double pw : {0.25, 4.0}) {
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw;
|
||||
else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw;
|
||||
else env.releaseCurve = pw;
|
||||
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex a, b;
|
||||
CHECK(findNode(poly, st.from, a));
|
||||
CHECK(findNode(poly, st.to, b));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
|
||||
const double xm = 0.5 * (static_cast<double>(a.x) + static_cast<double>(b.x));
|
||||
double y = 0.0;
|
||||
CHECK(traceYAtX(trace, xm, y));
|
||||
const double chord = 0.5 * (static_cast<double>(a.y) + static_cast<double>(b.y));
|
||||
// Normalized level at the midpoint, 0 at the start node, 1 at the end node.
|
||||
const double dy = static_cast<double>(b.y) - static_cast<double>(a.y);
|
||||
const double u = (y - static_cast<double>(a.y)) / dy;
|
||||
CHECK(std::fabs(y - chord) > 1.0); // it actually left the straight line
|
||||
if (pw > util::kCurveNeutral) CHECK(u < 0.5);
|
||||
else CHECK(u > 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// curveMap maps 0->0 and 1->1 at every positive exponent, so no sample may pass either of its
|
||||
// own segment's endpoint levels.
|
||||
static void testNoOvershoot() {
|
||||
const Rect area = wideArea();
|
||||
for (const Stage& st : kStages) {
|
||||
for (double pw : kExponents) {
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw;
|
||||
else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw;
|
||||
else env.releaseCurve = pw;
|
||||
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex a, b;
|
||||
CHECK(findNode(poly, st.from, a));
|
||||
CHECK(findNode(poly, st.to, b));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
const double lo = a.y < b.y ? a.y : b.y;
|
||||
const double hi = a.y < b.y ? b.y : a.y;
|
||||
for (const StrokePoint& p : trace) {
|
||||
if (p.x < static_cast<float>(a.x) || p.x > static_cast<float>(b.x)) continue;
|
||||
CHECK(p.y >= static_cast<float>(lo) - 0.001f);
|
||||
CHECK(p.y <= static_cast<float>(hi) + 0.001f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Density follows the canvas: one sample per pixel column, so a wider canvas gets proportionally
|
||||
// more of them. A fixed count would fail the second half. Uses the AHD policy, whose x axis is
|
||||
// the waveform's own and so is independent of the AHDSR schematic's scale.
|
||||
static void testDensityFollowsWidth() {
|
||||
std::size_t counts[2] = {0, 0};
|
||||
const int widths[2] = {1000, 4000};
|
||||
for (int w = 0; w < 2; ++w) {
|
||||
const Rect area = Rect::ltrb(20, 10, 20 + widths[w], 210);
|
||||
StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0);
|
||||
env.attackCurve = 4.0;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
EnvVertex a, b;
|
||||
CHECK(findNode(poly, EnvNode::Origin, a));
|
||||
CHECK(findNode(poly, EnvNode::AttackEnd, b));
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
|
||||
std::size_t n = 0;
|
||||
float prevX = 0.0f;
|
||||
for (const StrokePoint& p : trace) {
|
||||
if (p.x < static_cast<float>(a.x) || p.x > static_cast<float>(b.x)) continue;
|
||||
if (n > 0) CHECK(p.x - prevX <= 1.0f); // no gap wider than one column
|
||||
prevX = p.x;
|
||||
++n;
|
||||
}
|
||||
CHECK(n > 1);
|
||||
counts[w] = n;
|
||||
}
|
||||
// 4x the canvas, ~4x the samples across the same stage.
|
||||
CHECK(counts[1] > 3 * counts[0]);
|
||||
}
|
||||
|
||||
// A plateau is straight because its two endpoints share a level, so a stray exponent on the
|
||||
// node that ends it must not curve it.
|
||||
static void testFlatSegmentEmitsNoInterior() {
|
||||
const Rect area = wideArea();
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 0.0, 1.0, 2.0); // sustain == 1: the decay span is flat
|
||||
env.attackCurve = util::kCurveNeutral;
|
||||
env.decayCurve = 5.0;
|
||||
env.releaseCurve = util::kCurveNeutral;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
std::vector<StrokePoint> trace;
|
||||
buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace);
|
||||
std::size_t nodes = 0;
|
||||
for (const EnvVertex& v : poly) if (!v.knot) ++nodes;
|
||||
CHECK(trace.size() == nodes);
|
||||
}
|
||||
|
||||
static void testClampAndDegenerate() {
|
||||
const Rect area = wideArea();
|
||||
StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0);
|
||||
env.attackCurve = 0.25;
|
||||
env.decayCurve = 4.0;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(area), 4.0);
|
||||
|
||||
// A narrower clamp than the vertices were built for: nothing escapes it.
|
||||
std::vector<StrokePoint> trace;
|
||||
const int lo = area.x + 100, hi = area.right() - 200;
|
||||
buildEnvelopeTrace(poly, env, lo, hi, trace);
|
||||
CHECK(!trace.empty());
|
||||
for (const StrokePoint& p : trace) {
|
||||
CHECK(p.x >= static_cast<float>(lo));
|
||||
CHECK(p.x <= static_cast<float>(hi));
|
||||
}
|
||||
|
||||
// The out vector is REPLACED, not appended to — a stale trace from the previous paint
|
||||
// would otherwise stroke a line across the canvas.
|
||||
buildEnvelopeTrace({}, env, area.x, area.right() - 1, trace);
|
||||
CHECK(trace.empty());
|
||||
|
||||
// A degenerate surface still yields the flat baseline the painter needs.
|
||||
const Rect flat = Rect::ltrb(20, 10, 20, 10);
|
||||
const std::vector<EnvVertex> degen = buildEnvelopePolyline(env, overlayOf(flat), 0.0);
|
||||
buildEnvelopeTrace(degen, env, flat.x, flat.x, trace);
|
||||
CHECK(trace.size() == degen.size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSegmentCurve();
|
||||
testNeutralIsTodaysStraightLine();
|
||||
testKnotsExcludedAndNodesPreserved();
|
||||
testKnotLiesOnTraceAhdsr();
|
||||
testKnotLiesOnTraceAhd();
|
||||
testMidSegmentLevelMatchesTheLaw();
|
||||
testCurvatureDirection();
|
||||
testNoOvershoot();
|
||||
testDensityFollowsWidth();
|
||||
testFlatSegmentEmitsNoInterior();
|
||||
testClampAndDegenerate();
|
||||
if (g_fail == 0) std::printf("curve_tessellate: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user