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:
@@ -101,6 +101,7 @@ L7 sub-pass, 2026-07-27):
|
||||
- `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect.
|
||||
- `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace.
|
||||
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped.
|
||||
- `stroke_aa` — analytic antialiased thick-stroke COVERAGE (the shell blends it): `StrokeCanvas`, a reusable mask holding distance-to-polyline coverage MAX-accumulated across segments, plus `strokePolyline` / `strokeBounds` / `appendArc`. An arc is just a flattened polyline, so ONE path serves the knob arcs, the inner dial, the envelope polyline and both spline traces. Coverage is `clamp(halfWidth + 0.5 - distance, 0, 1)`, which makes perpendicular weight exactly `2·halfWidth` at every angle and gives an opaque core for any width above 1 px. Long segments are subdivided before rasterizing — EXACT, not an approximation (min-distance to a partition of a segment is min-distance to the whole), purely to keep each piece's bounding box tight, since one long diagonal's box has area O(len²).
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -122,3 +123,17 @@ L7 sub-pass, 2026-07-27):
|
||||
- `rect`'s prior role names survive only as `using` aliases at their old call
|
||||
sites — changing `rect.h` itself ripples across every directory that aliases
|
||||
it (e.g. `editor_geometry::Rect`); check all alias sites, not just this one.
|
||||
- **`stroke_aa`'s mask is deliberately NOT cleared on `reset`.** Only
|
||||
`[rowLo, rowHi)` of each row holds meaningful coverage; everything else is
|
||||
whatever the reused buffer last held. That is what keeps a stroke's cost
|
||||
proportional to its ink rather than to its bounding box — but it means any new
|
||||
reader must respect the row extents, and any new writer must grow them through
|
||||
`extendRow`, which zero-fills the newly-valid cells INCLUDING the gap when a
|
||||
stroke revisits a row far from where it left it (a circle touches most rows on
|
||||
both sides). Reading the raw buffer outside the extents returns garbage by
|
||||
design, not zero.
|
||||
- **Neither `LICE_Arc` nor `LICE_ThickFLine` can draw these strokes** — the first
|
||||
never reaches an opaque core, the second's width is along the minor axis so its
|
||||
perpendicular weight falls off as `cos θ`. The evidence and the measurements
|
||||
live in `docs/product/visual-design-language.md` §8; do not "simplify" a stroke
|
||||
site back onto either primitive.
|
||||
|
||||
@@ -38,3 +38,6 @@ reasampler_test(card_meta LINK card_meta)
|
||||
|
||||
reasampler_pure_library(card_drag SOURCES card_drag.cpp LINK PUBLIC drag_out bank_grid)
|
||||
reasampler_test(card_drag LINK card_drag)
|
||||
|
||||
reasampler_pure_library(stroke_aa SOURCES stroke_aa.cpp)
|
||||
reasampler_test(stroke_aa LINK stroke_aa)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#include "core/ui/stroke_aa.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::ui {
|
||||
namespace {
|
||||
|
||||
inline float clamp01(float v) { return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
void StrokeCanvas::reset(const Rect& bounds) {
|
||||
bounds_ = bounds;
|
||||
if (bounds_.empty()) {
|
||||
bounds_ = Rect{};
|
||||
return;
|
||||
}
|
||||
const std::size_t area =
|
||||
static_cast<std::size_t>(bounds_.width) * static_cast<std::size_t>(bounds_.height);
|
||||
if (coverage_.size() < area) coverage_.resize(area);
|
||||
rowLo_.assign(static_cast<std::size_t>(bounds_.height), bounds_.width);
|
||||
rowHi_.assign(static_cast<std::size_t>(bounds_.height), 0);
|
||||
}
|
||||
|
||||
float StrokeCanvas::coverageAt(int x, int y) const {
|
||||
if (!contains(bounds_, x, y)) return 0.0f;
|
||||
const int rel = x - bounds_.x;
|
||||
if (rel < rowLo(y) || rel >= rowHi(y)) return 0.0f;
|
||||
return rowData(y)[rel];
|
||||
}
|
||||
|
||||
// Grows a row's valid span to cover [x0, x1) (canvas-relative), zero-filling only the cells that
|
||||
// become valid. Cost is bounded by the growth, so a whole stroke stays O(ink).
|
||||
void StrokeCanvas::extendRow(int y, int x0, int x1) {
|
||||
const std::size_t r = static_cast<std::size_t>(y - bounds_.y);
|
||||
float* row = coverage_.data() + r * static_cast<std::size_t>(bounds_.width);
|
||||
int lo = rowLo_[r];
|
||||
int hi = rowHi_[r];
|
||||
if (hi <= lo) {
|
||||
std::fill(row + x0, row + x1, 0.0f);
|
||||
rowLo_[r] = x0;
|
||||
rowHi_[r] = x1;
|
||||
return;
|
||||
}
|
||||
// A stroke can revisit a row far from where it left it (an arc touches most rows on both
|
||||
// sides of the circle), so the gap between the old span and the new one must be zeroed too.
|
||||
if (x0 < lo) {
|
||||
std::fill(row + x0, row + lo, 0.0f);
|
||||
rowLo_[r] = x0;
|
||||
}
|
||||
if (x1 > hi) {
|
||||
std::fill(row + hi, row + x1, 0.0f);
|
||||
rowHi_[r] = x1;
|
||||
}
|
||||
}
|
||||
|
||||
void StrokeCanvas::addPiece(float ax, float ay, float bx, float by, float halfWidth) {
|
||||
const float reach = halfWidth + 0.5f; // beyond this the coverage is 0
|
||||
const float dx = bx - ax;
|
||||
const float dy = by - ay;
|
||||
const float len2 = dx * dx + dy * dy;
|
||||
const float invLen2 = len2 > 0.0f ? 1.0f / len2 : 0.0f;
|
||||
|
||||
int x0 = static_cast<int>(std::floor((std::min)(ax, bx) - reach));
|
||||
int x1 = static_cast<int>(std::ceil((std::max)(ax, bx) + reach)) + 1;
|
||||
int y0 = static_cast<int>(std::floor((std::min)(ay, by) - reach));
|
||||
int y1 = static_cast<int>(std::ceil((std::max)(ay, by) + reach)) + 1;
|
||||
x0 = (std::max)(x0, bounds_.x);
|
||||
y0 = (std::max)(y0, bounds_.y);
|
||||
x1 = (std::min)(x1, bounds_.right());
|
||||
y1 = (std::min)(y1, bounds_.bottom());
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
const int relX0 = x0 - bounds_.x;
|
||||
const int relX1 = x1 - bounds_.x;
|
||||
for (int y = y0; y < y1; ++y) {
|
||||
extendRow(y, relX0, relX1);
|
||||
float* row = coverage_.data() + static_cast<std::size_t>(y - bounds_.y) *
|
||||
static_cast<std::size_t>(bounds_.width);
|
||||
const float pyc = static_cast<float>(y) + 0.5f;
|
||||
const float qy = pyc - ay;
|
||||
for (int x = x0; x < x1; ++x) {
|
||||
const float qx = static_cast<float>(x) + 0.5f - ax;
|
||||
const float t = clamp01((qx * dx + qy * dy) * invLen2);
|
||||
const float ex = qx - dx * t;
|
||||
const float ey = qy - dy * t;
|
||||
const float cov = clamp01(reach - std::sqrt(ex * ex + ey * ey));
|
||||
float& dst = row[x - bounds_.x];
|
||||
if (cov > dst) dst = cov;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void StrokeCanvas::addSegment(float ax, float ay, float bx, float by, float halfWidth) {
|
||||
if (bounds_.empty() || halfWidth <= 0.0f) return;
|
||||
const float dx = bx - ax;
|
||||
const float dy = by - ay;
|
||||
const float len2 = dx * dx + dy * dy;
|
||||
if (len2 <= kMaxPieceLen * kMaxPieceLen) {
|
||||
addPiece(ax, ay, bx, by, halfWidth);
|
||||
return;
|
||||
}
|
||||
const int pieces = static_cast<int>(std::sqrt(len2) / kMaxPieceLen) + 1;
|
||||
float px = ax;
|
||||
float py = ay;
|
||||
for (int i = 1; i <= pieces; ++i) {
|
||||
const float t = static_cast<float>(i) / static_cast<float>(pieces);
|
||||
const float qx = ax + dx * t;
|
||||
const float qy = ay + dy * t;
|
||||
addPiece(px, py, qx, qy, halfWidth);
|
||||
px = qx;
|
||||
py = qy;
|
||||
}
|
||||
}
|
||||
|
||||
Rect strokeBounds(const StrokePoint* pts, std::size_t count, float halfWidth, const Rect& clip) {
|
||||
if (pts == nullptr || count == 0 || halfWidth <= 0.0f || clip.empty()) return Rect{};
|
||||
float minX = pts[0].x, maxX = pts[0].x, minY = pts[0].y, maxY = pts[0].y;
|
||||
for (std::size_t i = 1; i < count; ++i) {
|
||||
minX = (std::min)(minX, pts[i].x);
|
||||
maxX = (std::max)(maxX, pts[i].x);
|
||||
minY = (std::min)(minY, pts[i].y);
|
||||
maxY = (std::max)(maxY, pts[i].y);
|
||||
}
|
||||
const float reach = halfWidth + 0.5f;
|
||||
const int x0 = (std::max)(clip.x, static_cast<int>(std::floor(minX - reach)));
|
||||
const int y0 = (std::max)(clip.y, static_cast<int>(std::floor(minY - reach)));
|
||||
const int x1 = (std::min)(clip.right(), static_cast<int>(std::ceil(maxX + reach)) + 1);
|
||||
const int y1 = (std::min)(clip.bottom(), static_cast<int>(std::ceil(maxY + reach)) + 1);
|
||||
if (x0 >= x1 || y0 >= y1) return Rect{};
|
||||
return Rect::ltrb(x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
void strokePolyline(StrokeCanvas& canvas, const StrokePoint* pts, std::size_t count,
|
||||
float halfWidth, const Rect& clip) {
|
||||
canvas.reset(strokeBounds(pts, count, halfWidth, clip));
|
||||
if (canvas.bounds().empty()) return;
|
||||
if (count == 1) {
|
||||
canvas.addSegment(pts[0].x, pts[0].y, pts[0].x, pts[0].y, halfWidth);
|
||||
return;
|
||||
}
|
||||
for (std::size_t i = 1; i < count; ++i) {
|
||||
canvas.addSegment(pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y, halfWidth);
|
||||
}
|
||||
}
|
||||
|
||||
void appendArc(std::vector<StrokePoint>& out, float cx, float cy, float radius, float startRad,
|
||||
float endRad, float flatnessPx) {
|
||||
if (radius <= 0.0f) {
|
||||
out.push_back(StrokePoint{cx, cy});
|
||||
return;
|
||||
}
|
||||
if (!(flatnessPx > 0.0f)) flatnessPx = kArcFlatnessPx;
|
||||
// Chord sagitta: r*(1 - cos(step/2)) <= flatness. A flatness at or past the diameter admits
|
||||
// the whole sweep in one chord, which is what keeps `maxStep` strictly positive.
|
||||
const float cosHalf = (std::max)(-1.0f, 1.0f - flatnessPx / radius);
|
||||
const float maxStep = 2.0f * std::acos(cosHalf);
|
||||
const float sweep = endRad - startRad;
|
||||
int segments = 1;
|
||||
if (maxStep > 0.0f) {
|
||||
const float wanted = std::ceil(std::fabs(sweep) / maxStep);
|
||||
segments = wanted >= static_cast<float>(kMaxArcSegments)
|
||||
? kMaxArcSegments
|
||||
: (std::max)(1, static_cast<int>(wanted));
|
||||
} else {
|
||||
// A radius large enough that flatness/radius underflows the cosine's resolution. The cap
|
||||
// is the termination guarantee, not a quality choice.
|
||||
segments = kMaxArcSegments;
|
||||
}
|
||||
out.reserve(out.size() + static_cast<std::size_t>(segments) + 1);
|
||||
for (int i = 0; i <= segments; ++i) {
|
||||
const float a = startRad + sweep * (static_cast<float>(i) / static_cast<float>(segments));
|
||||
out.push_back(StrokePoint{cx + radius * std::sin(a), cy - radius * std::cos(a)});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user