fix: stroke arcs and splines analytically — opaque core, angle-independent weight

LICE_Arc never reaches opacity and ThickFLine's width is minor-axis. One
distance-to-polyline coverage mask, blended once, replaces both.
This commit is contained in:
2026-08-01 13:18:21 -04:00
parent ae9019465e
commit 2e09776342
16 changed files with 1001 additions and 72 deletions
+44 -9
View File
@@ -779,20 +779,42 @@ concession. Everything with a slope or a curve must draw through a primitive tha
antialiased and adds width.
- `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.
- LICE has no thick-arc primitive. A wider ring is drawn as adjacent 1 px `LICE_Arc` calls at
stepped radii, which keeps every ring antialiased.
- **`LICE_Arc` does not rasterize an arc.** It rasterizes a whole circle clipped to a
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.623.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.412.00 px** across a 090° 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
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 |
|---|---|---|
| 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. |
| Knob needle | `drawKnobFace` | **Fixed** — was integer-endpoint `LICE_Line`; now `LICE_ThickFLine` (always AA, float endpoints, 2 px). |
| Inner curve dial arc + needle | `drawInnerDial` | **Fixed** — 2 px stacked-radius arc; needle moved to `LICE_FLine` with float endpoints. |
| Staged envelope segment slopes | `editor_paint_waveform.cpp` | **Fixed**`LICE_ThickFLine` at 2 px, replacing integer-endpoint `LICE_Line`. |
| Spline (drawn EG) contour | `editor_paint_waveform.cpp` `paintSplineOverlay` | **Fixed** — same treatment, one trace grammar. |
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed** 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. |
| 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.953.11 px** (5% ripple). |
| 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 (2026-08-01)** — same as the knob: one analytic 2 px arc; needle via `strokeLineAA`. |
| 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 (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.952.01 px** (3% ripple). |
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed (2026-08-01)** — same cause, same treatment. |
| 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. |
| 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`. |
@@ -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`. |
| 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?
**No.** Every piano key is an axis-aligned `LICE_FillRect` with an integer width, so there is