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:
@@ -779,20 +779,42 @@ concession. Everything with a slope or a curve must draw through a primitive tha
|
|||||||
antialiased and adds width.
|
antialiased and adds width.
|
||||||
- `LICE_FillTriangle` takes **no** `aa` parameter at all — its sloped edges alias, and the
|
- `LICE_FillTriangle` takes **no** `aa` parameter at all — its sloped edges alias, and the
|
||||||
only fix inside the kit is to re-stroke those edges with an AA line in the same ink.
|
only fix inside the kit is to re-stroke those edges with an AA line in the same ink.
|
||||||
- LICE has no thick-arc primitive. A wider ring is drawn as adjacent 1 px `LICE_Arc` calls at
|
- **`LICE_Arc` does not rasterize an arc.** It rasterizes a whole circle clipped to a
|
||||||
stepped radii, which keeps every ring antialiased.
|
rectangular bounding box per 90° chunk (`lice_arc.cpp` `__DrawArc`), and its AA circle splits
|
||||||
|
one unit of ink across two adjacent pixels by the **fractional part of the radius**
|
||||||
|
(`w = yf - floor(yf)`, then `wa` and `ai - wa`). A half-integer radius therefore puts 50% on
|
||||||
|
each of two pixels at the cardinal points, and stacked radii do not tile — vertical spacing
|
||||||
|
between rings `r` and `r-1` dilates from 1.0 px at the top to 1.41 px at 45°. Measured on the
|
||||||
|
shipped 3-ring knob arc: weakest cross-section peak **138/255** and perpendicular weight
|
||||||
|
**1.62–3.24 px** against a nominal 3 (67% ripple).
|
||||||
|
- **`LICE_ThickFLine` lays its width along the MINOR axis**, so perpendicular weight is
|
||||||
|
`wid·cos θ`. Measured at width 2: **1.41–2.00 px** across a 0–90° sweep — it thins to
|
||||||
|
`1/√2` of nominal at every diagonal.
|
||||||
|
- Neither of those two is usable for a stroke that must hold a consistent weight. Arcs and
|
||||||
|
spline contours draw through the analytic stroker instead (`core/ui/stroke_aa` +
|
||||||
|
`shell/instrument/editor_stroke`): coverage is distance-to-polyline, MAX-accumulated into a
|
||||||
|
scratch mask and blended **once**. The single blend is the structural part — compositing
|
||||||
|
per segment re-lays ink over the previous segment's fringe.
|
||||||
- A min/max waveform column plot cannot be antialiased by the column fill itself (the columns
|
- A min/max waveform column plot cannot be antialiased by the column fill itself (the columns
|
||||||
are vertical). The outline is what reads as jagged, so it is stroked separately.
|
are vertical). The outline is what reads as jagged, so it is stroked separately.
|
||||||
|
|
||||||
|
> **Methodological lesson — why this table got two rows wrong.** The original audit verified
|
||||||
|
> *which primitive each surface called* and treated an `aa=true` argument as the answer. It
|
||||||
|
> never verified *what the primitive rasterized*. Both misses hid behind a true-looking
|
||||||
|
> statement: `LICE_Arc` really does antialias, and `LICE_ThickFLine` really is always
|
||||||
|
> antialiased — neither fact says anything about opacity or perpendicular weight, which is
|
||||||
|
> what was actually broken. **A disposition row is only earned by a measurement of the
|
||||||
|
> rendered output** (peak alpha, weight across angle), not by reading the call site.
|
||||||
|
|
||||||
| Surface | Where | Disposition |
|
| Surface | Where | Disposition |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Radial knob track + value arc | `editor_internal.h` `drawKnobFace` | Was AA (`LICE_Arc`, 1 px). **Widened** to a 3 px stacked-radius ring; the bigger knob is what made 1 px read thin. |
|
| Radial knob track + value arc | `editor_internal.h` `drawKnobFace` | **Fixed (2026-08-01)** — the stacked-radius `LICE_Arc` ring never reached an opaque core and rippled 67% in weight. Now ONE analytic stroke (`strokeArcAA`), outer edge on the knob radius. Measured: peak **255/255** at every cross-section, weight **2.95–3.11 px** (5% ripple). |
|
||||||
| Knob needle | `drawKnobFace` | **Fixed** — was integer-endpoint `LICE_Line`; now `LICE_ThickFLine` (always AA, float endpoints, 2 px). |
|
| Knob needle | `drawKnobFace` | **Fixed (2026-08-01)** — `LICE_ThickFLine`'s minor-axis width thinned it to `cos θ` as the knob swept. Now `strokeLineAA`, 2 px. |
|
||||||
| Inner curve dial arc + needle | `drawInnerDial` | **Fixed** — 2 px stacked-radius arc; needle moved to `LICE_FLine` with float endpoints. |
|
| Inner curve dial arc + needle | `drawInnerDial` | **Fixed (2026-08-01)** — same as the knob: one analytic 2 px arc; needle via `strokeLineAA`. |
|
||||||
| Staged envelope segment slopes | `editor_paint_waveform.cpp` | **Fixed** — `LICE_ThickFLine` at 2 px, replacing integer-endpoint `LICE_Line`. |
|
| Staged envelope segment slopes | `editor_paint_waveform.cpp` | **Fixed (2026-08-01)** — one `strokePolylineAA` over the whole polyline, so the stage joints blend once. Vertices stay INTEGER by design: they are the positions the draggable handles are drawn at. |
|
||||||
| Spline (drawn EG) contour | `editor_paint_waveform.cpp` `paintSplineOverlay` | **Fixed** — same treatment, one trace grammar. |
|
| Spline (drawn EG) contour | `editor_paint_waveform.cpp` `paintSplineOverlay` | **Fixed (2026-08-01)** — the trace was never gapped; it was fully aliased (every pixel full or empty) because the loop passed INTEGER `cy`, quantizing the slope into alternating 1/2 px steps. Now sub-pixel y (`subpixelFromPoint`) through `strokePolylineAA`. Measured: peak **255/255**, weight **1.95–2.01 px** (3% ripple). |
|
||||||
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed** — same treatment. |
|
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed (2026-08-01)** — same cause, same treatment. |
|
||||||
| Velocity-curve mini thumbnail | `editor_paint_curve.cpp` | Left at 1 px AA `LICE_Line` — a 2 px trace blots at thumbnail scale. |
|
| Velocity-curve mini thumbnail | `editor_paint_curve.cpp` | **Fixed (2026-08-01)** — stays a 1 px hairline (a 2 px trace blots at thumbnail scale), but strokes analytically at sub-pixel y instead of integer-endpoint `LICE_Line`. |
|
||||||
| Waveform min/max columns | `draw_kit.cpp` `drawWaveform` | **Fixed** — column fill unchanged (it cannot alias), plus an AA `LICE_FLine` stroke joining each column's extremes to its neighbour's, in the same ink. Shared with the docked bank panel and the browser cards. **Measured cost** (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke is ~0.41 ms, about 2.5% of a 60 Hz frame, and the grid repaints on hover/scroll/drag, not continuously. One-off scratchpad measurement, 2026-08-01, harness not committed — not a standing regression guard; re-measure before relying on it again. |
|
| Waveform min/max columns | `draw_kit.cpp` `drawWaveform` | **Fixed** — column fill unchanged (it cannot alias), plus an AA `LICE_FLine` stroke joining each column's extremes to its neighbour's, in the same ink. Shared with the docked bank panel and the browser cards. **Measured cost** (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke is ~0.41 ms, about 2.5% of a 60 Hz frame, and the grid repaints on hover/scroll/drag, not continuously. One-off scratchpad measurement, 2026-08-01, harness not committed — not a standing regression guard; re-measure before relying on it again. |
|
||||||
| Preview play triangle | `editor_paint_chrome.cpp` | **Fixed** — `LICE_FillTriangle` has no `aa`; its two sloped edges are re-stroked with AA `LICE_FLine`. |
|
| Preview play triangle | `editor_paint_chrome.cpp` | **Fixed** — `LICE_FillTriangle` has no `aa`; its two sloped edges are re-stroked with AA `LICE_FLine`. |
|
||||||
| Envelope/spline node handles (squares) | `editor_paint_waveform.cpp` | Already clean — axis-aligned `LICE_FillRect`. |
|
| Envelope/spline node handles (squares) | `editor_paint_waveform.cpp` | Already clean — axis-aligned `LICE_FillRect`. |
|
||||||
@@ -807,6 +829,19 @@ concession. Everything with a slope or a curve must draw through a primitive tha
|
|||||||
| Docked bank panel chrome | `panel_render.cpp` | Already clean — axis-aligned fills, rects and hairlines. Its only exposure to this pass is the shared `drawWaveform`. |
|
| Docked bank panel chrome | `panel_render.cpp` | Already clean — axis-aligned fills, rects and hairlines. Its only exposure to this pass is the shared `drawWaveform`. |
|
||||||
| Text | `draw_kit.cpp` `text` | Already clean — `LICE_CachedFont` AA glyph cache (§1.1). |
|
| Text | `draw_kit.cpp` `text` | Already clean — `LICE_CachedFont` AA glyph cache (§1.1). |
|
||||||
|
|
||||||
|
**Analytic stroker cost** (Release, MSVC, real LICE, one-off scratchpad harness 2026-08-01,
|
||||||
|
not committed — re-measure before relying on it): 30 knob arcs **0.113 ms → 0.169 ms**; a
|
||||||
|
500 px spline contour **0.013 ms → 0.047 ms**. About +0.09 ms per full editor repaint, on a
|
||||||
|
surface that repaints on interaction rather than continuously. Both figures beat the
|
||||||
|
prototype's targets (0.285 ms / 0.106 ms). Micro-optimisation, each lever measured in
|
||||||
|
isolation: writing the blend straight to the bitmap's bits rather than through
|
||||||
|
`LICE_PutPixel` is the big one (arcs 0.169 vs 0.253 ms); reusing the scratch mask across
|
||||||
|
calls matters on the contour's large bounding box (0.047 vs 0.073 ms); `float` over `double`
|
||||||
|
is small but real (contour coverage 0.045 vs 0.051 ms). The per-row valid-extent bookkeeping
|
||||||
|
in the mask is a **wash** against the simpler clear-the-whole-box design (0.218 vs 0.218 ms
|
||||||
|
for a full repaint) — it wins on the contour and loses on the small arc boxes; it is kept
|
||||||
|
because the contour is the drag-interactive surface.
|
||||||
|
|
||||||
### 8.1 Was the piano-key width defect an aliasing artifact?
|
### 8.1 Was the piano-key width defect an aliasing artifact?
|
||||||
|
|
||||||
**No.** Every piano key is an axis-aligned `LICE_FillRect` with an integer width, so there is
|
**No.** Every piano key is an axis-aligned `LICE_FillRect` with an integer width, so there is
|
||||||
|
|||||||
@@ -25,18 +25,30 @@ double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
|
|||||||
if (h <= 1) return 0.0;
|
if (h <= 1) return 0.0;
|
||||||
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
|
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);
|
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);
|
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);
|
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 lo = curveYMin(d);
|
||||||
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
|
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
|
} // namespace
|
||||||
@@ -187,6 +199,11 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
|
|||||||
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
|
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 {
|
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.
|
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
|
||||||
VelocityPoint p;
|
VelocityPoint p;
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ public:
|
|||||||
};
|
};
|
||||||
CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const;
|
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
|
// 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
|
// 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).
|
// reads the domain's max (the top row is what a collapsed box draws).
|
||||||
|
|||||||
@@ -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.
|
- `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_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.
|
- `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
|
## 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
|
- `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
|
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.
|
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_pure_library(card_drag SOURCES card_drag.cpp LINK PUBLIC drag_out bank_grid)
|
||||||
reasampler_test(card_drag LINK card_drag)
|
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
|
||||||
@@ -103,6 +103,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
|||||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||||
|
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
editor_input_deck.cpp
|
editor_input_deck.cpp
|
||||||
editor_input_browse.cpp
|
editor_input_browse.cpp
|
||||||
editor_input_curve.cpp
|
editor_input_curve.cpp
|
||||||
|
editor_stroke.cpp
|
||||||
editor_platform.cpp
|
editor_platform.cpp
|
||||||
reasampler_embed.cpp
|
reasampler_embed.cpp
|
||||||
reaper_bridge.cpp
|
reaper_bridge.cpp
|
||||||
@@ -87,7 +88,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
waveform_view bank_sync browser_scroll param_slider tooltip
|
waveform_view bank_sync browser_scroll param_slider tooltip
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||||
file_bytes curve_law)
|
file_bytes curve_law stroke_aa)
|
||||||
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
||||||
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
||||||
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace)
|
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace)
|
||||||
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
|
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
|
||||||
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
|
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
|
||||||
|
#include "shell/instrument/editor_stroke.h" // strokeArcAA / strokePolylineAA
|
||||||
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice
|
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -111,11 +112,11 @@ inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
|||||||
kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary);
|
kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stroke widths for the radial faces. The value arc is drawn as adjacent 1px AA arcs rather
|
// Stroke widths for the radial faces. Every arc is ONE analytic stroke (editor_stroke.h) whose
|
||||||
// than one thick primitive — LICE has no thick-arc call, and stacking radii is what keeps every
|
// outer edge sits on the knob's radius, so the centerline is inset by half the width.
|
||||||
// ring antialiased.
|
inline constexpr float kKnobTrackArcPx = 1.0f;
|
||||||
inline constexpr int kKnobValueArcPx = 3;
|
inline constexpr float kKnobValueArcPx = 3.0f;
|
||||||
inline constexpr int kInnerDialArcPx = 2;
|
inline constexpr float kInnerDialArcPx = 2.0f;
|
||||||
inline constexpr int kKnobNeedlePx = 2;
|
inline constexpr int kKnobNeedlePx = 2;
|
||||||
|
|
||||||
// Draws one radial knob face: param_slider owns the value<->angle map; this turns it into
|
// Draws one radial knob face: param_slider owns the value<->angle map; this turns it into
|
||||||
@@ -143,27 +144,26 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
|||||||
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
|
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
|
||||||
const float a1 = static_cast<float>(
|
const float a1 = static_cast<float>(
|
||||||
(arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
(arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||||||
LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0,
|
strokeArcAA(bmp, cx, cy, rOuter - kKnobTrackArcPx * 0.5f, a0, a1, kKnobTrackArcPx,
|
||||||
true);
|
toLice(ui::roleColor(ui::Role::LineHairline)));
|
||||||
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
|
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
|
||||||
if (v > 0.0) {
|
if (v > 0.0) {
|
||||||
const float av = static_cast<float>(
|
const float av = static_cast<float>(
|
||||||
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||||||
const ui::Role valueRole = disabled ? ui::Role::TextDim
|
const ui::Role valueRole = disabled ? ui::Role::TextDim
|
||||||
: (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary);
|
: (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary);
|
||||||
const LICE_pixel valueCol = toLice(ui::roleColor(valueRole));
|
strokeArcAA(bmp, cx, cy, rOuter - kKnobValueArcPx * 0.5f, a0, av, kKnobValueArcPx,
|
||||||
for (int i = 0; i < kKnobValueArcPx; ++i) {
|
toLice(ui::roleColor(valueRole)));
|
||||||
LICE_Arc(bmp, cx, cy, rOuter - static_cast<float>(i), a0, av, valueCol, 1.0f, 0, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Needle: from ~35% radius out to the rim at the value's angle. ThickFLine keeps the float
|
// Needle: from ~35% radius out to the rim at the value's angle, stroked through the same
|
||||||
// endpoints AND is always antialiased, so the needle is smooth at every angle.
|
// analytic path as the arcs so it holds its weight at every knob position.
|
||||||
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
|
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
|
||||||
const double ix = kg.centerX + (tip.x - kg.centerX) * 0.35;
|
const double ix = kg.centerX + (tip.x - kg.centerX) * 0.35;
|
||||||
const double iy = kg.centerY + (tip.y - kg.centerY) * 0.35;
|
const double iy = kg.centerY + (tip.y - kg.centerY) * 0.35;
|
||||||
const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary;
|
const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary;
|
||||||
LICE_ThickFLine(bmp, ix, iy, tip.x, tip.y, toLice(ui::roleColor(needleRole)), 1.0f, 0,
|
strokeLineAA(bmp, static_cast<float>(ix), static_cast<float>(iy),
|
||||||
kKnobNeedlePx);
|
static_cast<float>(tip.x), static_cast<float>(tip.y),
|
||||||
|
static_cast<float>(kKnobNeedlePx), toLice(ui::roleColor(needleRole)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The concentric INNER dial: a second value on the same cell, drawn in the categorical
|
// The concentric INNER dial: a second value on the same cell, drawn in the categorical
|
||||||
@@ -194,15 +194,12 @@ inline void drawInnerDial(LICE_IBitmap* bmp, const instrument::ui::Rect& innerRe
|
|||||||
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||||||
const ui::Role arcRole = disabled ? ui::Role::TextDim
|
const ui::Role arcRole = disabled ? ui::Role::TextDim
|
||||||
: (hot ? ui::Role::AccentHot : ui::Role::AccentTertiary);
|
: (hot ? ui::Role::AccentHot : ui::Role::AccentTertiary);
|
||||||
const LICE_pixel arcCol = toLice(ui::roleColor(arcRole));
|
strokeArcAA(bmp, cx, cy, r - kInnerDialArcPx * 0.5f, a0, av, kInnerDialArcPx,
|
||||||
for (int i = 0; i < kInnerDialArcPx; ++i) {
|
toLice(ui::roleColor(arcRole)));
|
||||||
LICE_Arc(bmp, cx, cy, r - static_cast<float>(i), a0, av, arcCol, 1.0f, 0, true);
|
|
||||||
}
|
|
||||||
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
|
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
|
||||||
LICE_FLine(bmp, static_cast<float>(kg.centerX), static_cast<float>(kg.centerY),
|
strokeLineAA(bmp, static_cast<float>(kg.centerX), static_cast<float>(kg.centerY),
|
||||||
static_cast<float>(tip.x), static_cast<float>(tip.y),
|
static_cast<float>(tip.x), static_cast<float>(tip.y), 1.0f,
|
||||||
toLice(ui::roleColor(disabled ? ui::Role::TextDim : ui::Role::AccentTertiary)),
|
toLice(ui::roleColor(disabled ? ui::Role::TextDim : ui::Role::AccentTertiary)));
|
||||||
1.0f, 0, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // _WIN32
|
#endif // _WIN32
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||||
#include "shell/instrument/editor_internal.h" // kit adapters + curveBoxFromRect
|
#include "shell/instrument/editor_internal.h" // kit adapters + curveBoxFromRect
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
@@ -17,6 +19,10 @@ using namespace reasampler::instrument::ui; // popup geometry
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// The deck thumbnail traces a hairline; the full editor matches the envelope traces' weight.
|
||||||
|
constexpr float kMiniTracePx = 1.0f;
|
||||||
|
constexpr float kCurveTracePx = 2.0f;
|
||||||
|
|
||||||
const char* curveTitle(CurveTarget target) {
|
const char* curveTitle(CurveTarget target) {
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case CurveTarget::kPitch: return "VELOCITY -> PITCH";
|
case CurveTarget::kPitch: return "VELOCITY -> PITCH";
|
||||||
@@ -57,15 +63,15 @@ void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveT
|
|||||||
}
|
}
|
||||||
const LICE_pixel trace =
|
const LICE_pixel trace =
|
||||||
toLice(roleColor(disabled ? Role::LineHairline : Role::AccentSecondary));
|
toLice(roleColor(disabled ? Role::LineHairline : Role::AccentSecondary));
|
||||||
int prevX = 0, prevY = 0;
|
static thread_local std::vector<ui::StrokePoint> pts;
|
||||||
|
pts.clear();
|
||||||
for (int px = 0; px <= mini.width; ++px) {
|
for (int px = 0; px <= mini.width; ++px) {
|
||||||
const int mx = mini.left + px;
|
const int mx = mini.left + px;
|
||||||
const double vel = curve.pointFromPixel(mini, mx, mini.top).velocity;
|
const double vel = curve.pointFromPixel(mini, mx, mini.top).velocity;
|
||||||
const int my = curve.pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
const auto p = curve.subpixelFromPoint(mini, {vel, curve.eval(vel)});
|
||||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
pts.push_back(ui::StrokePoint{static_cast<float>(mx), static_cast<float>(p.y)});
|
||||||
prevX = mx;
|
|
||||||
prevY = my;
|
|
||||||
}
|
}
|
||||||
|
strokePolylineAA(bmp, pts, kMiniTracePx, trace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,19 +120,18 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
|
|||||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||||
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
||||||
// velocity and value -> y mappings both go through the pure module so the trace, the node
|
// velocity and value -> y mappings both go through the pure module so the trace, the node
|
||||||
// handles, and the hit-test all share one coordinate system.
|
// handles, and the hit-test all share one coordinate system (sub-pixel for the trace — see
|
||||||
|
// subpixelFromPoint).
|
||||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||||
int prevX = 0, prevY = 0;
|
static thread_local std::vector<ui::StrokePoint> pts;
|
||||||
|
pts.clear();
|
||||||
for (int px = 0; px <= box.width; ++px) {
|
for (int px = 0; px <= box.width; ++px) {
|
||||||
const int cx = box.left + px;
|
const int cx = box.left + px;
|
||||||
const double vel = curve.pointFromPixel(box, cx, box.top).velocity;
|
const double vel = curve.pointFromPixel(box, cx, box.top).velocity;
|
||||||
const int cy = curve.pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
const auto p = curve.subpixelFromPoint(box, {vel, curve.eval(vel)});
|
||||||
// Same weight and antialiasing the envelope traces use — one trace grammar across every
|
pts.push_back(ui::StrokePoint{static_cast<float>(cx), static_cast<float>(p.y)});
|
||||||
// curve surface (editor_paint_waveform.cpp owns why ThickFLine and not LICE_Line).
|
|
||||||
if (px > 0) LICE_ThickFLine(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, 2);
|
|
||||||
prevX = cx;
|
|
||||||
prevY = cy;
|
|
||||||
}
|
}
|
||||||
|
strokePolylineAA(bmp, pts, kCurveTracePx, line);
|
||||||
|
|
||||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||||||
|
|||||||
@@ -42,11 +42,9 @@ constexpr int kEnvHandleRadius = 3;
|
|||||||
constexpr int kEnvHandleGrabbedRadius = 5;
|
constexpr int kEnvHandleGrabbedRadius = 5;
|
||||||
constexpr int kEnvHandleRingPx = 2;
|
constexpr int kEnvHandleRingPx = 2;
|
||||||
|
|
||||||
// Both envelope traces — staged and drawn — are one grammar and one weight. LICE_ThickFLine is
|
// Both envelope traces — staged and drawn — are one grammar and one weight. Two pixels is what
|
||||||
// ALWAYS antialiased (unlike LICE_Line, whose aa flag does nothing on an axis-aligned run), and
|
// reads as a trace rather than a hairline over the waveform behind it.
|
||||||
// the second pixel of width is what stops a shallow slope reading as a staircase over the
|
constexpr float kEnvTracePx = 2.0f;
|
||||||
// waveform behind it.
|
|
||||||
constexpr int kEnvTracePx = 2;
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
|
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
|
||||||
@@ -143,21 +141,22 @@ void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea&
|
|||||||
if (box.width <= 0 || box.height <= 1) return;
|
if (box.width <= 0 || box.height <= 1) return;
|
||||||
const VelocityCurve& curve = splineFor(overlayEnv_);
|
const VelocityCurve& curve = splineFor(overlayEnv_);
|
||||||
|
|
||||||
// One eval per drawn column, through the curve's own pixel maps, so the trace and the
|
// One eval per drawn column, through the curve's own pixel maps, so the trace and the handles
|
||||||
// handles share the coordinate system the hit-test resolves against.
|
// share the coordinate system the hit-test resolves against (sub-pixel here — see
|
||||||
|
// subpixelFromPoint).
|
||||||
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
|
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
|
||||||
int prevX = 0, prevY = 0;
|
static thread_local std::vector<ui::StrokePoint> trace;
|
||||||
|
trace.clear();
|
||||||
// < not <=: box.left + box.width is the overlay's own EXCLUSIVE right edge (the box has no
|
// < not <=: box.left + box.width is the overlay's own EXCLUSIVE right edge (the box has no
|
||||||
// inset, unlike the popup's), so a <= column paints one pixel into the next band's pad —
|
// inset, unlike the popup's), so a <= column paints one pixel into the next band's pad —
|
||||||
// and it is redundant with the clamped endpoint handle below anyway.
|
// and it is redundant with the clamped endpoint handle below anyway.
|
||||||
for (int px = 0; px < box.width; ++px) {
|
for (int px = 0; px < box.width; ++px) {
|
||||||
const int cx = box.left + px;
|
const int cx = box.left + px;
|
||||||
const double t = curve.pointFromPixel(box, cx, box.top).velocity;
|
const double t = curve.pointFromPixel(box, cx, box.top).velocity;
|
||||||
const int cy = curve.pixelFromPoint(box, {t, curve.eval(t)}).y;
|
const auto p = curve.subpixelFromPoint(box, {t, curve.eval(t)});
|
||||||
if (px > 0) LICE_ThickFLine(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, kEnvTracePx);
|
trace.push_back(ui::StrokePoint{static_cast<float>(cx), static_cast<float>(p.y)});
|
||||||
prevX = cx;
|
|
||||||
prevY = cy;
|
|
||||||
}
|
}
|
||||||
|
strokePolylineAA(bmp, trace, kEnvTracePx, line);
|
||||||
|
|
||||||
// Handles carry two independent states on the same mark, so they use two independent
|
// Handles carry two independent states on the same mark, so they use two independent
|
||||||
// channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower
|
// channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower
|
||||||
@@ -211,18 +210,18 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
|
|||||||
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
||||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||||
|
|
||||||
// Clip x to the wave rect. Knots are handles, not line vertices.
|
// Clip x to the wave rect. Knots are handles, not line vertices. Vertices stay INTEGER here
|
||||||
|
// — unlike the spline traces above — because they are the same positions the draggable
|
||||||
|
// handles are drawn at, and a sub-pixel trace would sit off its own handles.
|
||||||
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
|
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
|
||||||
const EnvVertex* prev = nullptr;
|
static thread_local std::vector<ui::StrokePoint> trace;
|
||||||
|
trace.clear();
|
||||||
for (const EnvVertex& v : poly) {
|
for (const EnvVertex& v : poly) {
|
||||||
if (v.knot) continue;
|
if (v.knot) continue;
|
||||||
if (prev != nullptr) {
|
const int vx = (std::max)(area.x, (std::min)(area.right() - 1, v.x));
|
||||||
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, prev->x));
|
trace.push_back(ui::StrokePoint{static_cast<float>(vx), static_cast<float>(v.y)});
|
||||||
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, v.x));
|
|
||||||
LICE_ThickFLine(bmp, x0, prev->y, x1, v.y, line, 1.0f, 0, kEnvTracePx);
|
|
||||||
}
|
|
||||||
prev = &v;
|
|
||||||
}
|
}
|
||||||
|
strokePolylineAA(bmp, trace, kEnvTracePx, line);
|
||||||
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Every
|
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Every
|
||||||
// vertex is guaranteed in-bounds; the handle is additionally clamped inside the band so one
|
// vertex is guaranteed in-bounds; the handle is additionally clamped inside the band so one
|
||||||
// on an edge node never overhangs into the neighbouring bands.
|
// on an edge node never overhangs into the neighbouring bands.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#include "shell/instrument/editor_stroke.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Draw-thread-only scratch. It lives here rather than on the editor because a stroke is a leaf
|
||||||
|
// draw call reached from eight paint sites — threading a canvas through every one of them would
|
||||||
|
// grow those signatures to carry an allocation detail. Reuse is the point: after the first paint
|
||||||
|
// the mask and the point list are resized, never reallocated.
|
||||||
|
thread_local ui::StrokeCanvas g_canvas;
|
||||||
|
thread_local std::vector<ui::StrokePoint> g_arcPoints;
|
||||||
|
|
||||||
|
// One blend of the finished mask. The arithmetic matches LICE's own mode-0 combine
|
||||||
|
// (src + (dst-src)*(256-a)/256 on all four channels, alpha in .8 fixed point) so a stroke
|
||||||
|
// composites identically to every other kit draw on the same surface — written straight to the
|
||||||
|
// bitmap's bits rather than through LICE_PutPixel, which re-derives the row pointer per pixel.
|
||||||
|
void blendCanvas(LICE_IBitmap* bmp, const ui::StrokeCanvas& canvas, LICE_pixel color,
|
||||||
|
float alpha) {
|
||||||
|
const ui::Rect& b = canvas.bounds();
|
||||||
|
if (b.empty() || alpha <= 0.0f) return;
|
||||||
|
LICE_pixel* const bits = bmp->getBits();
|
||||||
|
const int span = bmp->getRowSpan();
|
||||||
|
if (bits == nullptr || span <= 0) return;
|
||||||
|
const bool flipped = bmp->isFlipped();
|
||||||
|
const int lastRow = bmp->getHeight() - 1;
|
||||||
|
|
||||||
|
const int sr = LICE_GETR(color);
|
||||||
|
const int sg = LICE_GETG(color);
|
||||||
|
const int sb = LICE_GETB(color);
|
||||||
|
const int sa = LICE_GETA(color);
|
||||||
|
for (int y = b.y; y < b.bottom(); ++y) {
|
||||||
|
const int lo = canvas.rowLo(y);
|
||||||
|
const int hi = canvas.rowHi(y);
|
||||||
|
if (hi <= lo) continue;
|
||||||
|
const float* const cov = canvas.rowData(y);
|
||||||
|
LICE_pixel* const row =
|
||||||
|
bits + static_cast<std::size_t>(flipped ? lastRow - y : y) *
|
||||||
|
static_cast<std::size_t>(span) + static_cast<std::size_t>(b.x);
|
||||||
|
for (int i = lo; i < hi; ++i) {
|
||||||
|
const int ia = static_cast<int>(cov[i] * alpha * 256.0f);
|
||||||
|
if (ia <= 0) continue;
|
||||||
|
if (ia >= 256) {
|
||||||
|
row[i] = color;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int sc = 256 - ia;
|
||||||
|
LICE_pixel_chan* const d = reinterpret_cast<LICE_pixel_chan*>(row + i);
|
||||||
|
d[LICE_PIXEL_R] = static_cast<LICE_pixel_chan>(sr + ((d[LICE_PIXEL_R] - sr) * sc) / 256);
|
||||||
|
d[LICE_PIXEL_G] = static_cast<LICE_pixel_chan>(sg + ((d[LICE_PIXEL_G] - sg) * sc) / 256);
|
||||||
|
d[LICE_PIXEL_B] = static_cast<LICE_pixel_chan>(sb + ((d[LICE_PIXEL_B] - sb) * sc) / 256);
|
||||||
|
d[LICE_PIXEL_A] = static_cast<LICE_pixel_chan>(sa + ((d[LICE_PIXEL_A] - sa) * sc) / 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui::Rect bitmapRect(LICE_IBitmap* bmp) {
|
||||||
|
return ui::Rect{0, 0, bmp->getWidth(), bmp->getHeight()};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void strokePolylineAA(LICE_IBitmap* bmp, const ui::StrokePoint* pts, std::size_t count,
|
||||||
|
float widthPx, LICE_pixel color, float alpha) {
|
||||||
|
if (bmp == nullptr || pts == nullptr || count == 0 || widthPx <= 0.0f) return;
|
||||||
|
ui::strokePolyline(g_canvas, pts, count, widthPx * 0.5f, bitmapRect(bmp));
|
||||||
|
blendCanvas(bmp, g_canvas, color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
void strokeArcAA(LICE_IBitmap* bmp, float cx, float cy, float radius, float startRad, float endRad,
|
||||||
|
float widthPx, LICE_pixel color, float alpha) {
|
||||||
|
if (bmp == nullptr || widthPx <= 0.0f) return;
|
||||||
|
g_arcPoints.clear();
|
||||||
|
ui::appendArc(g_arcPoints, cx, cy, radius, startRad, endRad);
|
||||||
|
strokePolylineAA(bmp, g_arcPoints.data(), g_arcPoints.size(), widthPx, color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
|
|
||||||
|
#endif // _WIN32
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// editor_stroke.h — the editor's LICE side of the analytic stroker: build a coverage mask with
|
||||||
|
// the pure `core/ui/stroke_aa` module, blend it into the bitmap ONCE.
|
||||||
|
//
|
||||||
|
// Every radial and spline stroke on the editor goes through here. LICE's own primitives cannot
|
||||||
|
// serve these two shapes: LICE_Arc rasterizes a whole circle clipped per 90-degree box and splits
|
||||||
|
// one unit of ink across two pixels by the radius's fractional part (so a stacked-radii arc never
|
||||||
|
// reaches an opaque core), and LICE_ThickFLine lays its width along the MINOR axis (so a curve's
|
||||||
|
// perpendicular weight falls off as cos(theta) and thins at every diagonal).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "lice/lice.h"
|
||||||
|
#include "wdltypes.h"
|
||||||
|
|
||||||
|
#include "core/ui/rect.h"
|
||||||
|
#include "core/ui/stroke_aa.h"
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
// Blends a polyline as one antialiased stroke of `widthPx`. Clipped to the bitmap. Takes a raw
|
||||||
|
// span so a short stroke (a knob needle) can pass a stack array and cost no allocation.
|
||||||
|
void strokePolylineAA(LICE_IBitmap* bmp, const ui::StrokePoint* pts, std::size_t count,
|
||||||
|
float widthPx, LICE_pixel color, float alpha = 1.0f);
|
||||||
|
|
||||||
|
inline void strokePolylineAA(LICE_IBitmap* bmp, const std::vector<ui::StrokePoint>& pts,
|
||||||
|
float widthPx, LICE_pixel color, float alpha = 1.0f) {
|
||||||
|
strokePolylineAA(bmp, pts.data(), pts.size(), widthPx, color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single antialiased segment — the two-point polyline, spelled out for the call sites that
|
||||||
|
// have exactly two endpoints.
|
||||||
|
inline void strokeLineAA(LICE_IBitmap* bmp, float x0, float y0, float x1, float y1, float widthPx,
|
||||||
|
LICE_pixel color, float alpha = 1.0f) {
|
||||||
|
const ui::StrokePoint pts[2] = {{x0, y0}, {x1, y1}};
|
||||||
|
strokePolylineAA(bmp, pts, 2, widthPx, color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blends a circular arc as one antialiased stroke of `widthPx`. Angles are radians in LICE's
|
||||||
|
// convention (0 = 12 o'clock, increasing clockwise), matching what LICE_Arc took.
|
||||||
|
void strokeArcAA(LICE_IBitmap* bmp, float cx, float cy, float radius, float startRad, float endRad,
|
||||||
|
float widthPx, LICE_pixel color, float alpha = 1.0f);
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
|
|
||||||
|
#endif // _WIN32
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// 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.
|
||||||
|
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 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.
|
||||||
|
StrokeCanvas c;
|
||||||
|
c.reset(kBig);
|
||||||
|
c.addSegment(30.0f, 100.0f, 30.0f, 140.0f, 1.5f); // dirty the buffer on the left
|
||||||
|
c.reset(kBig);
|
||||||
|
std::vector<StrokePoint> ring;
|
||||||
|
appendArc(ring, 120.0f, 120.0f, 60.0f, 0.0f, 2.0f * kPi);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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();
|
||||||
|
testPerpendicularWeightIsAngleIndependent();
|
||||||
|
testWeightHoldsAtTheExactDiagonal();
|
||||||
|
testZeroLengthSegmentIsARoundDot();
|
||||||
|
testZeroLengthPolylineOfOnePointDraws();
|
||||||
|
testVerticalSegmentIsContinuousAndFullWeight();
|
||||||
|
testNearVerticalSegmentIsContinuous();
|
||||||
|
testCoverageFallsOffOverExactlyOnePixel();
|
||||||
|
testOverlappingSegmentsTakeTheMaxNotTheSum();
|
||||||
|
testNoPixelEverExceedsFullCoverage();
|
||||||
|
testRevisitedRowGapReadsZeroNotGarbage();
|
||||||
|
testBoundsClipToTheClipRectAndCoverTheReach();
|
||||||
|
testStrokeEntirelyOutsideTheClipDrawsNothing();
|
||||||
|
testCoverageOutsideTheValidSpanReadsZero();
|
||||||
|
testSubdivisionDoesNotChangeTheRenderedStroke();
|
||||||
|
testArcPointsLieOnTheCircleAndRespectTheFlatness();
|
||||||
|
testArcDensityGrowsWithRadius();
|
||||||
|
testArcFlatteningTerminatesOnDegenerateInputs();
|
||||||
|
testKnobArcIsOpaqueAndEvenAllTheWayRound();
|
||||||
|
if (g_fail == 0) std::printf("test_stroke_aa: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -391,6 +391,48 @@ static void testPixelFromPointMapsCornersAndMidpoint() {
|
|||||||
CHECK(clamped.x == 110 && clamped.y == 20);
|
CHECK(clamped.x == 110 && clamped.y == 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testSubpixelMapIsTheIntegerMapBeforeRounding() {
|
||||||
|
// The antialiased trace draws off subpixelFromPoint while the hit-test still resolves against
|
||||||
|
// pixelFromPoint. If the two were separate formulas the trace would drift off its own handles,
|
||||||
|
// so pin the relationship rather than the sub-pixel values: int == round(subpixel), everywhere.
|
||||||
|
const Box boxes[] = {{10, 20, 100, 51}, {0, 0, 127, 101}, {7, 3, 33, 17}};
|
||||||
|
for (const Box& box : boxes) {
|
||||||
|
for (const VelocityCurve& c : {uni(), bip()}) {
|
||||||
|
for (double v = 0.0; v <= 127.0; v += 1.0) {
|
||||||
|
for (double a = -1.0; a <= 1.0; a += 0.125) {
|
||||||
|
const auto ip = c.pixelFromPoint(box, {v, a});
|
||||||
|
const auto fp = c.subpixelFromPoint(box, {v, a});
|
||||||
|
CHECK(ip.x == box.left + static_cast<int>(fp.x - box.left + 0.5));
|
||||||
|
CHECK(ip.y == box.top + static_cast<int>(fp.y - box.top + 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSubpixelMapResolvesSlopesTheIntegerMapFlattens() {
|
||||||
|
// The defect this exists for: adjacent columns of a gentle slope round to the SAME integer
|
||||||
|
// row, so an integer-only trace is a staircase. The sub-pixel map must separate them.
|
||||||
|
const Box box{0, 0, 400, 101};
|
||||||
|
const VelocityCurve c = VelocityCurve::linear();
|
||||||
|
int identicalIntRows = 0;
|
||||||
|
double maxSubpixelStep = 0.0, minSubpixelStep = 1e18;
|
||||||
|
for (int x = 1; x <= 200; ++x) {
|
||||||
|
const double v0 = c.pointFromPixel(box, x - 1, box.top).velocity;
|
||||||
|
const double v1 = c.pointFromPixel(box, x, box.top).velocity;
|
||||||
|
const int y0 = c.pixelFromPoint(box, {v0, c.eval(v0)}).y;
|
||||||
|
const int y1 = c.pixelFromPoint(box, {v1, c.eval(v1)}).y;
|
||||||
|
if (y0 == y1) ++identicalIntRows;
|
||||||
|
const double d = std::fabs(c.subpixelFromPoint(box, {v1, c.eval(v1)}).y -
|
||||||
|
c.subpixelFromPoint(box, {v0, c.eval(v0)}).y);
|
||||||
|
if (d > maxSubpixelStep) maxSubpixelStep = d;
|
||||||
|
if (d < minSubpixelStep) minSubpixelStep = d;
|
||||||
|
}
|
||||||
|
CHECK(identicalIntRows > 100); // the integer map really does flatten
|
||||||
|
CHECK(maxSubpixelStep - minSubpixelStep < 1e-9); // the sub-pixel one advances evenly
|
||||||
|
CHECK(maxSubpixelStep > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
static void testPointFromPixelInvertsAndClamps() {
|
static void testPointFromPixelInvertsAndClamps() {
|
||||||
const Box box{10, 20, 100, 51};
|
const Box box{10, 20, 100, 51};
|
||||||
// Exact corners invert exactly.
|
// Exact corners invert exactly.
|
||||||
@@ -472,6 +514,8 @@ int main() {
|
|||||||
testBipolarPixelMapPutsZeroOnTheCentreLine();
|
testBipolarPixelMapPutsZeroOnTheCentreLine();
|
||||||
testBipolarDragCoversTwiceTheValueRange();
|
testBipolarDragCoversTwiceTheValueRange();
|
||||||
testPixelFromPointMapsCornersAndMidpoint();
|
testPixelFromPointMapsCornersAndMidpoint();
|
||||||
|
testSubpixelMapIsTheIntegerMapBeforeRounding();
|
||||||
|
testSubpixelMapResolvesSlopesTheIntegerMapFlattens();
|
||||||
testPointFromPixelInvertsAndClamps();
|
testPointFromPixelInvertsAndClamps();
|
||||||
testPixelMapsRoundTripWithinOnePixelQuantum();
|
testPixelMapsRoundTripWithinOnePixelQuantum();
|
||||||
testPixelFromPointAgreesWithPointAtPixel();
|
testPixelFromPointAgreesWithPointAtPixel();
|
||||||
|
|||||||
Reference in New Issue
Block a user