// 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 #include #include 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(x) + 0.5f - cx) * dx + (static_cast(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(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 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 hops; for (int i = 0; i <= 180; ++i) { const float t = static_cast(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 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 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 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(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(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(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 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(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(s) / kSteps; const int px = static_cast(std::floor(cx + ux * r)); const int py = static_cast(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; }