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.
This commit is contained in:
2026-08-01 13:18:21 -04:00
parent ae9019465e
commit 2e09776342
16 changed files with 1001 additions and 72 deletions
+23 -6
View File
@@ -25,18 +25,30 @@ double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
if (h <= 1) return 0.0;
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
}
int velToX(const VelocityCurve::Box& box, double velocity) {
// The integer maps are these rounded — ONE mapping, so a sub-pixel trace and an integer hit-test
// cannot drift. Rounding the OFFSET (not the absolute coordinate) keeps the int results identical
// to what they were before the sub-pixel form existed: the offset is non-negative, so truncation
// is floor regardless of where the box sits.
double velToXf(const VelocityCurve::Box& box, double velocity) {
const int w = std::max(0, box.width);
if (w <= 0) return box.left;
if (w <= 0) return static_cast<double>(box.left);
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
return static_cast<double>(box.left) + frac * static_cast<double>(w);
}
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
double valueToYf(const VelocityCurve::Box& box, double value, CurveDomain d) {
const int h = std::max(0, box.height);
if (h <= 1) return box.top;
if (h <= 1) return static_cast<double>(box.top);
const double lo = curveYMin(d);
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
return static_cast<double>(box.top) + (1.0 - frac) * static_cast<double>(h - 1);
}
int velToX(const VelocityCurve::Box& box, double velocity) {
return box.left +
static_cast<int>(velToXf(box, velocity) - static_cast<double>(box.left) + 0.5);
}
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
return box.top +
static_cast<int>(valueToYf(box, value, d) - static_cast<double>(box.top) + 0.5);
}
} // namespace
@@ -187,6 +199,11 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
}
VelocityCurve::CurvePixelF VelocityCurve::subpixelFromPoint(const Box& box,
const VelocityPoint& p) const {
return CurvePixelF{velToXf(box, p.velocity), valueToYf(box, p.value, domain_)};
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const {
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
VelocityPoint p;
@@ -182,6 +182,16 @@ public:
};
CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const;
// The SAME mapping before rounding: pixelFromPoint IS this, rounded, so a sub-pixel trace and
// an integer hit-test cannot drift. An antialiased stroke needs the fraction — quantizing y to
// a whole pixel forces the slope into alternating 1/2-px steps, and that beat-frequency
// staircase is what read as a dotted line where a contour steepened.
struct CurvePixelF {
double x = 0.0;
double y = 0.0;
};
CurvePixelF subpixelFromPoint(const Box& box, const VelocityPoint& p) const;
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space
// click lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1
// reads the domain's max (the top row is what a collapsed box draws).