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
+87
View File
@@ -0,0 +1,87 @@
// stroke_aa.h — analytic antialiased thick-stroke coverage: distance-to-polyline, MAX-accumulated
// into a scratch mask that a shell blends ONCE. Pure geometry; no LICE, no host types.
//
// The single blend is the load-bearing part. Compositing a stroke segment-by-segment (or as a
// stack of 1px arcs) re-lays ink over the previous segment's antialiased fringe, which is what
// makes a stroke read as a soft glow that never reaches an opaque core.
#pragma once
#include <cstddef>
#include <vector>
#include "core/ui/rect.h"
namespace reasampler::ui {
struct StrokePoint {
float x = 0.0f;
float y = 0.0f;
};
// Chord-flatness bound for arc flattening: an order of magnitude under the AA fringe the arc is
// drawn with, so the polyline is indistinguishable from the true arc at any radius the deck uses.
inline constexpr float kArcFlatnessPx = 0.05f;
// Hard cap on flattening output, so a pathological radius/flatness pair terminates with a coarse
// arc rather than allocating without bound.
inline constexpr int kMaxArcSegments = 512;
// Long segments are split before rasterizing. This is EXACT, not an approximation: the minimum
// distance to a partition of a segment is the minimum distance to the whole segment. It exists
// because a piece is rasterized over its bounding box, and one long diagonal's box has area
// O(len^2) — subdivision is what keeps a stroke's cost linear in its length.
inline constexpr float kMaxPieceLen = 4.0f;
// A reusable coverage mask. Allocation is amortized across calls: `reset` grows the buffer but
// never clears it, because per-row valid extents make a clear unnecessary.
class StrokeCanvas {
public:
// Grows the buffer to fit `bounds` and marks every row empty. O(height), not O(area).
void reset(const Rect& bounds);
// MAX-accumulates one segment. Endpoints are round-capped, so a zero-length segment is a dot
// of radius `halfWidth` and a polyline's joints are round by construction.
void addSegment(float ax, float ay, float bx, float by, float halfWidth);
const Rect& bounds() const { return bounds_; }
// Only [rowLo, rowHi) of a row holds meaningful coverage; outside that span the buffer is
// deliberately uninitialized, which is what keeps cost proportional to ink, not to the box.
int rowLo(int y) const { return rowLo_[static_cast<std::size_t>(y - bounds_.y)]; }
int rowHi(int y) const { return rowHi_[static_cast<std::size_t>(y - bounds_.y)]; }
// Row base pointer; index it by (x - bounds().x) within [rowLo, rowHi).
const float* rowData(int y) const {
return coverage_.data() +
static_cast<std::size_t>(y - bounds_.y) * static_cast<std::size_t>(bounds_.width);
}
// Bounds-checked single read — 0 outside the valid span. For tests and cold callers; the
// blend loop walks rows directly.
float coverageAt(int x, int y) const;
private:
void addPiece(float ax, float ay, float bx, float by, float halfWidth);
void extendRow(int y, int x0, int x1);
Rect bounds_{};
std::vector<float> coverage_;
std::vector<int> rowLo_;
std::vector<int> rowHi_;
};
// The pixel box a polyline of this half-width can touch, intersected with `clip`.
Rect strokeBounds(const StrokePoint* pts, std::size_t count, float halfWidth, const Rect& clip);
// A whole stroke in one pass: bounds, reset, every segment MAX-accumulated. Blending the finished
// canvas exactly once is the caller's half of the contract.
void strokePolyline(StrokeCanvas& canvas, const StrokePoint* pts, std::size_t count,
float halfWidth, const Rect& clip);
// Appends a flattened arc (n+1 points for n chords) to `out`. Angles are radians in LICE's
// convention: 0 is 12 o'clock, increasing clockwise — x = cx + r*sin(a), y = cy - r*cos(a).
void appendArc(std::vector<StrokePoint>& out, float cx, float cy, float radius, float startRad,
float endRad, float flatnessPx = kArcFlatnessPx);
} // namespace reasampler::ui