07628a2059
Moves the NaN/Inf finiteness check to addSegment where the UB-causing cast actually happens, makes strokeBounds reject an interior non-finite point instead of swallowing it, corrects a LICE_GetPixel misattribution, and documents rasterRowOffset.
480 lines
22 KiB
C++
480 lines
22 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);
|
|
}
|
|
|
|
// --- non-finite input rejection ------------------------------------------------
|
|
|
|
static void testInteriorNonFiniteCoordinateRejectsTheWholeStroke() {
|
|
// Only pts[0] going non-finite used to propagate through strokeBounds's min/max reduction:
|
|
// std::min/max against NaN silently returns the OTHER, finite, operand, so a NaN anywhere
|
|
// else in the polyline vanished from the reduction instead of rejecting it. pts[1] here is
|
|
// the case that check missed.
|
|
const StrokePoint pts[3] = {{10.0f, 10.0f}, {NAN, 50.0f}, {90.0f, 10.0f}};
|
|
CHECK(strokeBounds(pts, 3, 1.5f, kBig).empty());
|
|
|
|
StrokeCanvas c;
|
|
strokePolyline(c, pts, 3, 1.5f, kBig);
|
|
CHECK(c.bounds().empty());
|
|
}
|
|
|
|
static void testAddSegmentRejectsNonFiniteInputsDirectly() {
|
|
// addSegment casts a pieces-count derived from len2 to int; a NaN/Inf endpoint must be
|
|
// caught before that cast, not one call layer down in addPiece where it's already too late.
|
|
StrokeCanvas c;
|
|
c.reset(kBig);
|
|
c.addSegment(10.0f, 10.0f, NAN, 50.0f, 1.5f);
|
|
c.addSegment(10.0f, 10.0f, INFINITY, 50.0f, 1.5f);
|
|
CHECK(peakCoverage(c) == 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 — matches the free function
|
|
// LICE_GetPixel's own row math, `(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();
|
|
testInteriorNonFiniteCoordinateRejectsTheWholeStroke();
|
|
testAddSegmentRejectsNonFiniteInputsDirectly();
|
|
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;
|
|
}
|