Design swept ceiling - orlandomenke/Lightbox GitHub Wiki
The ceiling of a swept soft mark (B349)
Design note, 2026-09-02, implemented the same day. Measured before it was believed;
the measurements are tests/Lightbox.Raster.Tests/SweptCeilingTests.cs and run in the suite.
The defect, restated as geometry
Q157 gave a soft brush a ceiling: no pixel of a mark may be more opaque
than the brush's own footprint allows at that point, so that a hardness-0.35
brush keeps a hardness-0.35 edge instead of saturating into a hard one. The
ceiling is accumulated as a running maximum of dab shapes
(StampFootprint, Lighten), and B349 is what that maximum looks like where
the artist has swept back and forth: between two passes no dab centre is near,
the maximum dips, and because the mark has saturated to the ceiling there, the
dip is printed at full contrast β lines along the sweep, and a hard rim where
the core meets the halo.
B349's afternoon proved something stronger than "the repairs tried did not work": no operator on that buffer can work. Flattening a ripple means lowering peaks or raising troughs; raising troughs while keeping peaks leaves the difference, and lowering anything clips a lone dab against its own profile, which is the one thing the ceiling exists to keep exact. The blur that came closest costs a lone dab 4%.
So the defect is in what the ceiling is.
The definition
Today's ceiling at a pixel is the best any single dab does there:
ceiling_today(p) = max_i F( |p β c_i| )
where F is the dab's radial shape β and the engine's F is a plain ramp,
1 out to hardnessΒ·R and then linear to 0 at R.
The candidate is the dab's shape, applied to how far inside the stroke's reach the pixel sits:
ceiling(p) = F( R β d(p) ), d(p) = distance from p to the edge of β_i disc(c_i, R)
With the ramp written out, that is one line:
ceiling(p) = min( 1, d(p) / ((1 β hardness) Β· R) )
β the distance to the edge of everything the stroke reached, over the width of the brush's own falloff.
Why it cannot clip. For any pixel, take the dab centre nearest to it. That
dab's whole disc lies inside the union, so the distance to the union's edge is
at least the distance to that disc's edge, and a falling F of a larger
argument is a larger value. Hence ceiling β₯ ceiling_today everywhere β the
new ceiling is a relaxation of the old one, never a cut. That is the
requirement B349 showed no post-hoc operator could meet, and it is met here by
definition rather than by operator.
Where the two coincide. For a lone dab the union is that dab's disc and
R β d is the radius: identical. Across a straight stroke, through a dab's
centre, the union's edge is R β |y| away: identical β which is exactly the
column Q157's tests measure. Along the stroke they differ: today reads F(0)
on a dab centre and F(pitch/2) between two, the candidate reads the stadium's
edge and is flat. That difference is the dab-pitch ripple B349 found that finer
sampling could not remove, on a single pass.
Measured
Size 70, flow 1, 900Γ460, eight passes at the pitch that ridges worst under today's ceiling. Ripple is B349's own metric β the detrended peak-to-trough of a cut through the interior, out of 255 β with the cut kept clear of the outermost passes' own falloff.
| today | candidate | uncapped mark | |
|---|---|---|---|
| Soft round (hardness 0.35), pitch 40 | 82.4 | 0.0 | 0.0 |
| Airbrush (hardness 0.05), pitch 40 | 138.3 | 2.1 | 2.1 |
The Airbrush's 2.1 is the uncapped mark's own texture β a fully soft brush at flow 1 does not quite saturate between 40 px passes β and the candidate adds nothing to it. Today's ceiling adds 136 levels.
| constraint | Soft round | Airbrush |
|---|---|---|
| lone dab, worst pixel against today (Q157's Β±2) | 2 | 2 |
| straight stroke, cross-profile through a dab centre | 1 | 1 |
| straight stroke, ripple along the centreline (today β candidate) | 0 β 0 | 8 β 0 |
| pixels where the candidate is below today's ceiling by > 3 | 0 | 0 |
Two probe-side lessons, recorded because they cost two runs: Skia evaluates a
dab at pixel centres, so the column r pixels from a dab centre samples the
shape at β((r+Β½)Β² + ΒΌ), not r; and the pixel's centre has to be exactly a
supersample centre. Both read as a half-pixel shift, which in the Airbrush's
seven-levels-per-pixel falloff was seven levels of "residual" that was never in
the definition.
Pitch matters, and the report's pitch did not ridge Soft round
At the report's 18.4 px pitch, Soft round at size 70 shows no ripple under today's ceiling β its flat core (hardness 0.35 Γ radius 35 β 12 px) covers that pitch. The ridge appears from about 28 px and grows with the pitch: 14, 38, 61, 82 at 28, 32, 36, 40. The Airbrush ridges at every pitch (57 at 18.4). So the reported picture was a large brush at a wider pitch than measured, or the softer preset; either way the mechanism is the same and the candidate is flat at every pitch tried.
What it costs, and where
The candidate needs a distance transform of the union of dab supports.
Exact Euclidean, two separable passes (Felzenszwalb and Huttenlocher), O(n)
in pixels, deterministic β no random access, no clock, the same pixels for the
same dabs (invariant 2). The probe's implementation is naive (supersampled 5Γ,
allocating per line) and costs ~340 ms for the 0.41 Mpx image; an in-engine
one runs at document resolution with exact geometry and should sit near
5 ns/px.
Three places compute a ceiling today, and each has its own cost shape:
| path | today | with the candidate |
|---|---|---|
commit (CapToFootprint, whole mark) |
one max-stamp per dab, one min per pixel | plus one EDT over the mark's bounds β same order as the min loop |
live, band-local (CapToFootprintBand, B293/B313) |
per event: max-stamp new dabs, min over the band | per event: EDT over band β halo, where the halo is R β a pixel's distance saturates at R, so nothing beyond R can change it |
preview scale (LiveFootprintScale, B189) |
the same at 0.375Γ | the same at 0.375Γ; distances scale with the buffer |
The live band is the one that touches feel. A 173Γ173 band with a 35 px halo is 243Β² β 59 k pixels; at 5 ns/px that is ~0.3 ms per event against the 0.9 ms the cap costs today and the ~4.8 ms the stamp costs (B189). It has to be measured under the existing per-event budgets before it ships, not estimated here β that is the first step of the implementation branch, and the promise this note is written under is that nothing reaches the paint path that makes the pen wait.
Implemented, 2026-09-02, and what it measured
The plan below landed as written, with three things learned on the way:
- The reach is
ceil(R) + 2, notR: one pixel for the dab's own anti-aliased edge and one for the pixel-centre convention.CeilingReachPxis the one place that says so, and every caller β commit, band, worker crop β takes it from there. - The live band never grew. The band-local path already keeps the whole stroke's coverage buffer for B313, so the transform reads the support beyond the band from that buffer and the band itself stays the size it was. The worker pass crops its footprint one reach larger and takes the space offset from the grown crop, which is the only arithmetic that changed.
- A whole-canvas commit is bounded twice. Above 400 k cells the transform runs on a coarse grid, with the bias grown to match so the exact red channel still owns the edge; and when the coarse step would pass a quarter of the reach β a small brush across a huge canvas, where the ridge is a few percent over a few pixels β the term stands down and the shape maximum alone is used, which is free.
| measured in-engine, warm, min of 3 | value |
|---|---|
| distance term added to one size-70 live event, fit-to-window at 4K (0.375) | 0.10 ms (0.54 β 0.64) |
| the same zoomed to 100% | 1.07 ms (0.15 β 1.22) |
| whole 900Γ460 commit window, Soft round / Airbrush | 3.2 / 6.6 ms (from 0.8 / 1.1) |
| lone dab, cross-profile through a dab centre | 0/255 β identical |
| band against whole mark, over the band | 0/255 |
| pixels anywhere below the shape maximum | 0 |
SweptCeilingTests.TheDistanceTermIsBoundedAgainstTheCapItReplaces holds
the first two under 0.5 and 2.0 ms.
Implementation plan
StampFootprintrecords the dab's support as well as its shape: the hard disc of radiusRinto a second channel of the same buffer (the footprint is opaque RGB and only red is read today).Lightenkeeps it a union. A third channel carries the local falloff width(1 β hardness)Β·Ras a maximum, so a pressure-varying stroke evaluates the ramp with the width of the widest dab covering the pixel.CapToFootprint/CapToFootprintBandcomputedover the band βRhalo from the support channel, formmin(1, d / width), and takemax(red, that)β the max is belt and braces: the theorem says it never binds, and keeping it means a pressure ramp can only ever relax the ceiling relative to today, never tighten it.- The live path's halo for the ceiling becomes
Rrather thanLivePassHalo; the whole-mark support buffer already exists incrementally, so the EDT reads outside the band for free. - Fingerprints move, and that is the honest cost Q157 also paid:
RuntimeDeterminismTests,BrushPresetRenderFingerprintTestsand any document fingerprint drawn with an overlapping soft stroke re-record, with this note as the reason. Every existing soft-brush document renders with fewer ridges and no other difference. SweptCeilingTestsstops computing its own candidate and asserts the engine's ceiling directly: sweep ripple β€ uncapped + 0.5, along-stroke ripple 0, andFootprintCapTestsunchanged as Q157's guard.
What is deliberately not in scope
- Media stay excluded from the ceiling (Q157 measured why: the fringe is
the fluid's fuel). Nothing here changes
NeedsFootprintCap. - A bitmap tip keeps its own route; its support is its alpha, not a disc.
- Q175 holds: this is the soft-falloff paint engine's ceiling, not a stage every engine must survive.