Files
reasampler/tests/test_stroke_aa.cpp
T
daniel 3fb77027c6 fix: close Θ-W7-T1 review — scaling guard, opacity claims, two vacuous test fixes
Guards the stroke blend against LICE_EXT_GET_SCALING, tightens the analytic-stroker's boxes and NaN handling, corrects the opaque-core threshold and inner-dial rationale in the docs, and re-derives two review-flagged tautological tests so they actually fail against the bugs they claim to catch.
2026-08-01 13:45:43 -04:00

453 lines
20 KiB
C++

// Standalone tests for reasampler::ui::stroke_aa — no REAPER, no LICE, no framework.
// Same fast assert loop as the sibling pure tests.
//
// The three properties here are the ones the shipped LICE draws failed, so each is asserted as a
// NUMBER rather than eyeballed: an opaque core (LICE_Arc's AA circle splits one unit of ink across
// two pixels by the radius's fraction, so no pixel ever reached 255); perpendicular weight that
// does not vary with angle (LICE_ThickFLine lays its width along the minor axis, rippling 42%
// around a knob sweep); and MAX-into-scratch accumulation (per-segment blending re-lays ink over
// the previous segment's fringe, which is what made a stroke read as a glow).
#include "../src/core/ui/stroke_aa.h"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::ui;
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 const Rect kBig{0, 0, 240, 240};
static constexpr float kPi = 3.14159265358979323846f;
// --- helpers -----------------------------------------------------------------
static float peakCoverage(const StrokeCanvas& c) {
float peak = 0.0f;
const Rect& b = c.bounds();
for (int y = b.y; y < b.bottom(); ++y)
for (int x = b.x; x < b.right(); ++x)
if (c.coverageAt(x, y) > peak) peak = c.coverageAt(x, y);
return peak;
}
static float totalInk(const StrokeCanvas& c) {
float sum = 0.0f;
const Rect& b = c.bounds();
for (int y = b.y; y < b.bottom(); ++y)
for (int x = b.x; x < b.right(); ++x) sum += c.coverageAt(x, y);
return sum;
}
// Ink per unit length across a window in the MIDDLE of a straight stroke, binned by each pixel's
// projection onto the stroke direction. This is literally "perpendicular weight": for a stroke of
// half-width hw the answer is 2*hw at every angle, and it is the measurement that fails against a
// minor-axis-width primitive.
static float perpendicularWeight(float angleDeg, float halfWidth) {
const float a = angleDeg * kPi / 180.0f;
const float dx = std::cos(a), dy = std::sin(a);
const float cx = 120.0f, cy = 120.0f;
const float half = 90.0f; // stroke reaches well past the window on both sides
const float win = 40.0f; // window half-length, clear of both round caps
const StrokePoint pts[2] = {{cx - dx * half, cy - dy * half},
{cx + dx * half, cy + dy * half}};
StrokeCanvas c;
strokePolyline(c, pts, 2, halfWidth, kBig);
float sum = 0.0f;
const Rect& b = c.bounds();
for (int y = b.y; y < b.bottom(); ++y) {
for (int x = b.x; x < b.right(); ++x) {
const float s = (static_cast<float>(x) + 0.5f - cx) * dx +
(static_cast<float>(y) + 0.5f - cy) * dy;
if (s >= -win && s < win) sum += c.coverageAt(x, y);
}
}
return sum / (2.0f * win);
}
// --- coverage / distance math ------------------------------------------------
static void testStraightStrokeHasAnOpaqueCore() {
// The shipped defect stated numerically: peak alpha must reach full, not 137-192/255.
// 2 and 3 px only: below the >= 2 px opaque-core threshold (core/ui/CLAUDE.md), a stroke
// does NOT reliably reach full alpha — see testSubOpaqueCoreAtOnePixelWidth below.
for (float w : {2.0f, 3.0f}) {
for (float deg : {0.0f, 17.0f, 45.0f, 63.0f, 90.0f}) {
const float a = deg * kPi / 180.0f;
const StrokePoint pts[2] = {{120.0f - 80.0f * std::cos(a), 120.0f - 80.0f * std::sin(a)},
{120.0f + 80.0f * std::cos(a), 120.0f + 80.0f * std::sin(a)}};
StrokeCanvas c;
strokePolyline(c, pts, 2, w * 0.5f, kBig);
CHECK(peakCoverage(c) >= 0.999f);
}
}
}
static void testSubOpaqueCoreAtOnePixelWidth() {
// Below the >= 2 px opaque-core threshold: a 1 px stroke (halfWidth = 0.5) has zero slack
// against the 0.5 px worst-case pixel-centre distance (core/ui/CLAUDE.md), so peak alpha
// tracks the stroke's alignment to the pixel grid instead of reaching 255 everywhere. Pin
// both ends of that modulation — this is the knob track arc's and the mini curve-trace's
// actual behaviour, not a hypothetical.
const StrokePoint onRowCentre[2] = {{20.0f, 100.5f}, {220.0f, 100.5f}}; // centred on row 100
StrokeCanvas aligned;
strokePolyline(aligned, onRowCentre, 2, 0.5f, kBig);
CHECK(aligned.coverageAt(120, 100) >= 0.999f); // aligned to the grid: reaches opaque
const StrokePoint onRowBoundary[2] = {{20.0f, 100.0f}, {220.0f, 100.0f}}; // on the boundary
StrokeCanvas misaligned;
strokePolyline(misaligned, onRowBoundary, 2, 0.5f, kBig);
CHECK(std::fabs(misaligned.coverageAt(120, 99) - 0.5f) < 1e-4f); // split evenly...
CHECK(std::fabs(misaligned.coverageAt(120, 100) - 0.5f) < 1e-4f); // ...across both rows
CHECK(peakCoverage(misaligned) < 0.999f); // and never reaches the opaque core here
}
static void testPerpendicularWeightIsAngleIndependent() {
// The criterion that killed the ThickFLine option: it dips to wid*cos(theta) at every 45
// degrees. An axis-aligned-only sample would pass against it, so sample the diagonals.
for (float w : {2.0f, 3.0f}) {
float lo = 1e9f, hi = -1e9f;
for (float deg = 0.0f; deg <= 90.0f; deg += 7.5f) {
const float m = perpendicularWeight(deg, w * 0.5f);
if (m < lo) lo = m;
if (m > hi) hi = m;
CHECK(std::fabs(m - w) < 0.06f * w); // within 6% of nominal at every angle
}
CHECK((hi - lo) / w < 0.08f); // and the spread across angles is under 8%
}
}
static void testWeightHoldsAtTheExactDiagonal() {
// Pinned separately because 45 degrees is where the rejected primitive was worst (0.707x).
const float m = perpendicularWeight(45.0f, 1.0f);
CHECK(m > 1.88f && m < 2.12f);
}
static void testZeroLengthSegmentIsARoundDot() {
StrokeCanvas c;
c.reset(kBig);
c.addSegment(60.0f, 60.0f, 60.0f, 60.0f, 1.5f); // degenerate: start == end
CHECK(peakCoverage(c) >= 0.999f);
// Radially symmetric about the point, and zero well outside the reach.
CHECK(std::fabs(c.coverageAt(58, 60) - c.coverageAt(61, 60)) < 1e-5f);
CHECK(std::fabs(c.coverageAt(60, 58) - c.coverageAt(60, 61)) < 1e-5f);
CHECK(c.coverageAt(65, 60) == 0.0f);
CHECK(c.coverageAt(60, 65) == 0.0f);
}
static void testZeroLengthPolylineOfOnePointDraws() {
const StrokePoint one[1] = {{40.0f, 40.0f}};
StrokeCanvas c;
strokePolyline(c, one, 1, 1.5f, kBig);
CHECK(!c.bounds().empty());
CHECK(peakCoverage(c) >= 0.999f);
}
static void testVerticalSegmentIsContinuousAndFullWeight() {
// Infinite slope: dx == 0 exactly, the case an x-stepping rasterizer cannot express.
const StrokePoint pts[2] = {{100.0f, 20.0f}, {100.0f, 200.0f}};
StrokeCanvas c;
strokePolyline(c, pts, 2, 1.0f, kBig);
for (int y = 25; y < 195; ++y) {
float rowPeak = 0.0f, rowInk = 0.0f;
for (int x = 90; x < 110; ++x) {
rowPeak = c.coverageAt(x, y) > rowPeak ? c.coverageAt(x, y) : rowPeak;
rowInk += c.coverageAt(x, y);
}
CHECK(rowPeak >= 0.999f); // no gap, no weak row
CHECK(std::fabs(rowInk - 2.0f) < 0.02f); // and uniform weight down the whole run
}
}
static void testNearVerticalSegmentIsContinuous() {
// The slope that broke into dots on screen: steep but not exactly vertical, so the old
// integer-y loop quantized it into alternating 1/2-px steps.
const StrokePoint pts[2] = {{100.0f, 20.0f}, {103.0f, 200.0f}};
StrokeCanvas c;
strokePolyline(c, pts, 2, 1.0f, kBig);
for (int y = 25; y < 195; ++y) {
float rowPeak = 0.0f;
for (int x = 90; x < 115; ++x)
rowPeak = c.coverageAt(x, y) > rowPeak ? c.coverageAt(x, y) : rowPeak;
CHECK(rowPeak >= 0.999f);
}
}
static void testCoverageFallsOffOverExactlyOnePixel() {
// The AA fringe is one pixel wide by construction: cov = clamp(hw + 0.5 - d, 0, 1). With the
// centreline on an integer y, pixel centres sit at d = 0.5, 1.5, 2.5 — one saturated row, one
// exactly-half fringe row, then nothing.
const StrokePoint pts[2] = {{20.0f, 100.0f}, {220.0f, 100.0f}};
StrokeCanvas c;
strokePolyline(c, pts, 2, 1.5f, kBig);
CHECK(std::fabs(c.coverageAt(120, 99) - 1.0f) < 1e-4f); // d = 0.5 -> saturated
CHECK(std::fabs(c.coverageAt(120, 98) - 0.5f) < 1e-4f); // d = 1.5 -> half
CHECK(c.coverageAt(120, 97) == 0.0f); // d = 2.5 -> past the reach
}
// --- accumulation semantics --------------------------------------------------
static void testOverlappingSegmentsTakeTheMaxNotTheSum() {
// The whole point of the scratch mask: an overlap must not read brighter than one stroke, or
// the joints of a 500-segment contour build up into a glow.
StrokeCanvas one;
one.reset(kBig);
one.addSegment(40.0f, 100.3f, 160.0f, 100.3f, 1.0f);
// Locate a genuinely partial fringe pixel rather than assuming which row it lands on — the
// assertion below is only meaningful on a pixel that is neither empty nor already saturated.
int fy = -1;
for (int y = 95; y < 106; ++y) {
const float v = one.coverageAt(100, y);
if (v > 0.05f && v < 0.95f) { fy = y; break; }
}
CHECK(fy != -1);
if (fy == -1) return;
const float soloEdge = one.coverageAt(100, fy);
StrokeCanvas both;
both.reset(kBig);
both.addSegment(40.0f, 100.3f, 160.0f, 100.3f, 1.0f);
both.addSegment(40.0f, 100.3f, 160.0f, 100.3f, 1.0f); // exactly on top of the first
CHECK(std::fabs(both.coverageAt(100, fy) - soloEdge) < 1e-6f);
CHECK(totalInk(both) <= totalInk(one) + 1e-3f);
}
static void testNoPixelEverExceedsFullCoverage() {
// Many mutually overlapping segments through one point — the pile-up case.
StrokeCanvas c;
c.reset(kBig);
for (int i = 0; i < 12; ++i) {
const float a = static_cast<float>(i) * kPi / 12.0f;
c.addSegment(120.0f - 60.0f * std::cos(a), 120.0f - 60.0f * std::sin(a),
120.0f + 60.0f * std::cos(a), 120.0f + 60.0f * std::sin(a), 1.5f);
}
const Rect& b = c.bounds();
for (int y = b.y; y < b.bottom(); ++y)
for (int x = b.x; x < b.right(); ++x) CHECK(c.coverageAt(x, y) <= 1.0f);
}
static void testRevisitedRowGapReadsZeroNotGarbage() {
// A circle touches most rows on BOTH sides, leaving an untouched gap between the two spans.
// The row's valid extent grows over that gap, so the gap must be zero-filled, not left at
// whatever the reused scratch buffer held.
//
// The dirtying pass must land at the SAME bounds/stride the arc pass will reuse, or the two
// writes address disjoint buffer offsets and the "old" value the gap reads back is just the
// scratch buffer's original zero-init — the guard would then have nothing to prove itself
// against (verified: deleting extendRow's fill at stroke_aa.cpp:48-51 left this test passing
// when the dirtying pass used `kBig` while the arc pass reset to its own tighter bounds).
std::vector<StrokePoint> ring;
appendArc(ring, 120.0f, 120.0f, 60.0f, 0.0f, 2.0f * kPi);
const Rect arcBounds = strokeBounds(ring.data(), ring.size(), 1.5f, kBig);
StrokeCanvas c;
c.reset(arcBounds);
// A horizontal segment straight across the row/columns the assertion below checks, so the
// buffer genuinely holds nonzero ink there before the arc's own pass reuses the canvas.
c.addSegment(100.0f, 120.0f, 140.0f, 120.0f, 1.5f);
CHECK(c.coverageAt(120, 120) > 0.9f); // sanity: the dirtying pass actually landed here
strokePolyline(c, ring.data(), ring.size(), 1.5f, kBig);
for (int x = 100; x < 140; ++x) CHECK(c.coverageAt(x, 120) == 0.0f); // hollow middle
}
// --- bounds / clipping -------------------------------------------------------
static void testBoundsClipToTheClipRectAndCoverTheReach() {
const StrokePoint pts[2] = {{10.0f, 10.0f}, {50.0f, 50.0f}};
const Rect b = strokeBounds(pts, 2, 1.5f, kBig);
CHECK(b.x <= 8 && b.y <= 8);
CHECK(b.right() >= 52 && b.bottom() >= 52);
const Rect clipped = strokeBounds(pts, 2, 1.5f, Rect{20, 20, 10, 10});
CHECK(clipped.x == 20 && clipped.y == 20);
CHECK(clipped.right() == 30 && clipped.bottom() == 30);
const StrokePoint away[2] = {{500.0f, 500.0f}, {600.0f, 600.0f}};
CHECK(strokeBounds(away, 2, 1.5f, kBig).empty()); // wholly outside -> nothing to draw
}
static void testStrokeEntirelyOutsideTheClipDrawsNothing() {
const StrokePoint away[2] = {{500.0f, 500.0f}, {600.0f, 600.0f}};
StrokeCanvas c;
strokePolyline(c, away, 2, 1.5f, kBig);
CHECK(c.bounds().empty());
}
static void testCoverageOutsideTheValidSpanReadsZero() {
StrokeCanvas c;
c.reset(kBig);
c.addSegment(100.0f, 100.0f, 140.0f, 100.0f, 1.0f);
CHECK(c.coverageAt(10, 100) == 0.0f); // same row, outside the touched span
CHECK(c.coverageAt(120, 10) == 0.0f); // an untouched row entirely
CHECK(c.coverageAt(-5, 100) == 0.0f); // outside the canvas
CHECK(c.coverageAt(1000, 1000) == 0.0f);
}
// --- raster row addressing ----------------------------------------------------
static void testRasterRowOffsetMatchesUnflippedAndFlippedLayouts() {
// Unflipped: row y is just y*rowSpan (LICE's top-down layout).
CHECK(rasterRowOffset(0, 100, 240, false) == 0u);
CHECK(rasterRowOffset(5, 100, 240, false) == 5u * 240u);
CHECK(rasterRowOffset(99, 100, 240, false) == 99u * 240u);
// Flipped (bottom-up DIBs): row y is (height-1-y)*rowSpan — LICE_SysBitmap's own pixel
// accessor, `(h-1-y)*rowspan + x` (lice.cpp:2262).
CHECK(rasterRowOffset(0, 100, 240, true) == 99u * 240u);
CHECK(rasterRowOffset(99, 100, 240, true) == 0u);
CHECK(rasterRowOffset(40, 100, 240, true) == 59u * 240u);
}
// --- long-segment subdivision ------------------------------------------------
static void testSubdivisionDoesNotChangeTheRenderedStroke() {
// Pieces exist to keep each bounding box tight; min-distance to a partition IS min-distance
// to the whole, so the output must be identical to the same span drawn in short hops.
const StrokePoint whole[2] = {{20.0f, 20.0f}, {200.0f, 140.0f}};
StrokeCanvas a;
strokePolyline(a, whole, 2, 1.0f, kBig);
std::vector<StrokePoint> hops;
for (int i = 0; i <= 180; ++i) {
const float t = static_cast<float>(i) / 180.0f;
hops.push_back(StrokePoint{20.0f + 180.0f * t, 20.0f + 120.0f * t});
}
StrokeCanvas b;
strokePolyline(b, hops.data(), hops.size(), 1.0f, kBig);
CHECK(a.bounds() == b.bounds());
float worst = 0.0f;
for (int y = kBig.y; y < kBig.bottom(); ++y)
for (int x = kBig.x; x < kBig.right(); ++x) {
const float d = std::fabs(a.coverageAt(x, y) - b.coverageAt(x, y));
if (d > worst) worst = d;
}
// Not exactly zero: the two paths split the line at different parameter values, so the
// projections differ in the last float bits. Measured worst case 1.6e-5 — a rounding
// difference, not a coverage one, and the identical bounds above rule out a clipping gap.
CHECK(worst < 1e-4f);
}
// --- arc flattening ----------------------------------------------------------
static void testArcPointsLieOnTheCircleAndRespectTheFlatness() {
std::vector<StrokePoint> pts;
appendArc(pts, 100.0f, 100.0f, 17.5f, -2.618f, 2.618f); // the knob's 300-degree sweep
CHECK(pts.size() >= 3);
for (const StrokePoint& p : pts) {
const float r = std::sqrt((p.x - 100.0f) * (p.x - 100.0f) +
(p.y - 100.0f) * (p.y - 100.0f));
CHECK(std::fabs(r - 17.5f) < 1e-2f);
}
// Chord sagitta stays inside the requested flatness, with a little numeric slack.
for (std::size_t i = 1; i < pts.size(); ++i) {
const float mx = 0.5f * (pts[i - 1].x + pts[i].x);
const float my = 0.5f * (pts[i - 1].y + pts[i].y);
const float rm = std::sqrt((mx - 100.0f) * (mx - 100.0f) + (my - 100.0f) * (my - 100.0f));
CHECK(17.5f - rm < kArcFlatnessPx * 1.5f);
}
}
static void testArcDensityGrowsWithRadius() {
std::vector<StrokePoint> small, large;
appendArc(small, 0.0f, 0.0f, 10.0f, 0.0f, kPi);
appendArc(large, 0.0f, 0.0f, 200.0f, 0.0f, kPi);
CHECK(large.size() > small.size());
}
static void testArcFlatteningTerminatesOnDegenerateInputs() {
std::vector<StrokePoint> pts;
appendArc(pts, 10.0f, 10.0f, 0.0f, 0.0f, kPi); // zero radius
CHECK(pts.size() == 1);
pts.clear();
appendArc(pts, 0.0f, 0.0f, 20.0f, 1.0f, 1.0f); // zero sweep
CHECK(pts.size() == 2 && std::fabs(pts[0].x - pts[1].x) < 1e-5f);
pts.clear();
appendArc(pts, 0.0f, 0.0f, 20.0f, 0.0f, kPi, 0.0f); // flatness 0 falls back
CHECK(!pts.empty() &&
pts.size() <= static_cast<std::size_t>(kMaxArcSegments) + 1);
pts.clear();
appendArc(pts, 0.0f, 0.0f, 20.0f, 0.0f, kPi, -3.0f); // negative flatness falls back
CHECK(!pts.empty() &&
pts.size() <= static_cast<std::size_t>(kMaxArcSegments) + 1);
pts.clear();
appendArc(pts, 0.0f, 0.0f, 1.0e9f, 0.0f, 2.0f * kPi, 1e-6f); // the cap is the guarantee
CHECK(pts.size() <= static_cast<std::size_t>(kMaxArcSegments) + 1);
pts.clear();
appendArc(pts, 0.0f, 0.0f, 4.0f, 0.0f, kPi, 50.0f); // flatness past the diameter
CHECK(pts.size() >= 2 && pts.size() <= 8);
}
// --- an arc as a rendered stroke ---------------------------------------------
static void testKnobArcIsOpaqueAndEvenAllTheWayRound() {
// The shipped defect, measured: 27 of 33 columns never reached full opacity and column ink
// ran 1.60-3.13 px against a nominal 3. Sweep radially instead of by column so the check is
// a true perpendicular cut at every angle, including the cardinals the old code was worst at.
const float cx = 120.0f, cy = 120.0f, radius = 16.0f, width = 3.0f;
std::vector<StrokePoint> pts;
appendArc(pts, cx, cy, radius, -2.618f, 2.618f);
StrokeCanvas c;
strokePolyline(c, pts.data(), pts.size(), width * 0.5f, kBig);
float lo = 1e9f, hi = -1e9f;
for (int i = 0; i < 72; ++i) {
const float a = -2.5f + (5.0f * static_cast<float>(i)) / 71.0f; // inside the sweep
const float ux = std::sin(a), uy = -std::cos(a);
float ink = 0.0f, peak = 0.0f;
// Integrate along the radial ray in fine steps, converting to a per-pixel weight.
constexpr int kSteps = 400;
constexpr float kSpan = 8.0f; // radial window centred on the arc
for (int s = 0; s < kSteps; ++s) {
const float r = radius - kSpan * 0.5f + kSpan * static_cast<float>(s) / kSteps;
const int px = static_cast<int>(std::floor(cx + ux * r));
const int py = static_cast<int>(std::floor(cy + uy * r));
const float v = c.coverageAt(px, py);
ink += v * (kSpan / kSteps);
if (v > peak) peak = v;
}
CHECK(peak >= 0.999f); // an opaque core at EVERY angle
if (ink < lo) lo = ink;
if (ink > hi) hi = ink;
}
CHECK(lo > width * 0.90f);
CHECK(hi < width * 1.10f);
CHECK((hi - lo) / width < 0.15f);
}
int main() {
testStraightStrokeHasAnOpaqueCore();
testSubOpaqueCoreAtOnePixelWidth();
testPerpendicularWeightIsAngleIndependent();
testWeightHoldsAtTheExactDiagonal();
testZeroLengthSegmentIsARoundDot();
testZeroLengthPolylineOfOnePointDraws();
testVerticalSegmentIsContinuousAndFullWeight();
testNearVerticalSegmentIsContinuous();
testCoverageFallsOffOverExactlyOnePixel();
testOverlappingSegmentsTakeTheMaxNotTheSum();
testNoPixelEverExceedsFullCoverage();
testRevisitedRowGapReadsZeroNotGarbage();
testBoundsClipToTheClipRectAndCoverTheReach();
testStrokeEntirelyOutsideTheClipDrawsNothing();
testCoverageOutsideTheValidSpanReadsZero();
testRasterRowOffsetMatchesUnflippedAndFlippedLayouts();
testSubdivisionDoesNotChangeTheRenderedStroke();
testArcPointsLieOnTheCircleAndRespectTheFlatness();
testArcDensityGrowsWithRadius();
testArcFlatteningTerminatesOnDegenerateInputs();
testKnobArcIsOpaqueAndEvenAllTheWayRound();
if (g_fail == 0) std::printf("test_stroke_aa: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}