From 83bd8b9a8679edfa5e8028b0872fedba18fd6d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 22:30:23 +0000 Subject: [PATCH] Union of many paths in one coverage pass (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shapes of one paint drawn one after another leave seams where they abut or overlap along antialiased edges (1 − (1 − a)(1 − b) instead of a + b). A Union collects fills, strokes and shapes, and Canvas.FillUnion sums their coverage in one accumulator and composites once. - Stroker outlines wind one way everywhere (documented on LineSink, tested over random strokes); elements winding the other way (mirrored non-hairline strokes, fills by their exact signed area) are reversed. - Strokes keep the analytic path: Rasterizer.addStrip sums the closed-form rows into the accumulator band by band, so the union does not fall back to walking outline edges. - Shapes contribute their recorded edges and strips, so banded unions stroke once per page. EvenOdd fills and dense dashes are drawn on their own. - Tests against the supersampled union (overlapping/abutting rectangles of both orientations, dense hatches), single-element equality, bands, concurrency, zero allocations. ADR 0006 records the design and numbers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ndd7AKiqjKUpYoL1CoEmU --- README.md | 12 + canvas.go | 1 + docs/adr/0006-union.md | 158 ++++++++++ docs/adr/README.md | 1 + raster.go | 251 ++++++++++++++-- stroke.go | 8 + union.go | 308 ++++++++++++++++++++ union_bench_test.go | 71 +++++ union_test.go | 640 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1432 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0006-union.md create mode 100644 union.go create mode 100644 union_bench_test.go create mode 100644 union_test.go diff --git a/README.md b/README.md index 6561faf..29097c7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,13 @@ c.PopClip() var sh stilus.Shape sh.SetStroke(path, ctm, style) // or SetFill(path, ctm, rule) c.FillShape(&sh, paint) // same bytes as c.Stroke(path, ctm, style, paint) + +// Union: many fills and strokes of one paint accumulated together and +// composited once, without seams where they abut or overlap. +var u stilus.Union +u.Stroke(line1, ctm, style) // u.Fill(path, ctm, rule), u.Shape(&sh) +u.Stroke(line2, ctm, style) +c.FillUnion(&u, paint) ``` Integration points for other renderers: implement `Blitter` (own pixel @@ -258,6 +265,11 @@ avoided in `simd_amd64.go`: their cost is that of a solid stroke; `Stroker.Coverage()` reports the factor. Walking a pattern dash by dash is bounded by 32 transitions per device pixel of the subpath. +- Conflation: shapes of one paint drawn one after another leave light seams + where they abut or overlap along antialiased edges, 1 − (1 − a)(1 − b) + instead of a + b. `Union` and `Canvas.FillUnion` sum their coverage in + one accumulator instead and match the supersampled union + (`TestFillUnionRef`; [ADR 0006](docs/adr/0006-union.md)). - Fuzz targets: `FuzzFill`, `FuzzStroke`. ## Development diff --git a/canvas.go b/canvas.go index b323697..a57e27b 100644 --- a/canvas.go +++ b/canvas.go @@ -42,6 +42,7 @@ type Canvas struct { dst *image.RGBA r Rasterizer seg segFast + ustrip stripSink // FillUnion's analytic rows s Stroker solid SolidBlitter shader ShaderBlitter diff --git a/docs/adr/0006-union.md b/docs/adr/0006-union.md new file mode 100644 index 0000000..0f9e421 --- /dev/null +++ b/docs/adr/0006-union.md @@ -0,0 +1,158 @@ +# 0006. Union of many paths in one coverage pass + +- Status: accepted (implemented: `Union`, `Canvas.FillUnion`) +- Date: 2026-10-03 +- Needed by: timzifer/stilus#28; cera's batching of consecutive same-paint + operations (timzifer/cera#21) + +## Context + +Opaque shapes of one paint that overlap or abut along antialiased edges +leave light seams when they are drawn one after another. Each fill +composites its own partial coverage over the last, so two edges that +together cover a pixel give 1 − (1 − a)(1 − b) < 1 instead of 1. This is +the conflation artifact of exact-area renderers. cera compares its output +with an exact rendering, and on a hatch of 2.07 px strokes 1.75 px apart, +which cover the area completely, stilus drew 0.971× the ink, with 24.9 % +of the pixels more than 16/255 off. Poppler and Ghostscript are much +closer, because they union a path's geometry before they integrate it. + +Within one path, stilus has no such seams: the subpaths' areas are summed +in the accumulator and the NonZero rule applied to the sum. A union of +many paths only needs the same thing, provided that their windings do not +cancel. + +## Decision + +**Outlines wind in one direction.** The `Stroker`'s outlines (left side +forward, end cap, right side backward, start cap; two loops for a closed +polyline) wind the same way everywhere they cover. That includes inner +corners routed through their vertex, round joins, caps and dashes. The +direction is that of a stroke under the identity unless the transform +mirrors and the stroke is not a hairline (hairlines are outlined in device +space). This held already; it is now documented on `LineSink` and tested +over 600 random strokes with all caps, joins, dashes, curves and mirrored +transforms (`TestStrokeOutlineWinding`). No normalisation of the output +was needed. + +**A `Union` collects the elements, and `Canvas.FillUnion` draws them:** + +```go +type Union struct { /* unexported */ } + +func (u *Union) Reset() +func (u *Union) Len() int +func (u *Union) Fill(p *Path, m Matrix, rule FillRule) +func (u *Union) Stroke(p *Path, m Matrix, st *StrokeStyle) +func (u *Union) Shape(s *Shape) + +func (c *Canvas) FillUnion(u *Union, paint *Paint) +``` + +A `Union` keeps references, like a display list: adding an element only +stores the path, the transform and the style, with what can be derived +once per page rather than once per band: the device box, a stroke's +`strokePrep` and pad, and the orientation. `FillUnion` only reads the +union, so the canvases of all bands can draw one union at the same time, +and it allocates nothing once warm. + +`FillUnion` puts every element into the canvas's rasterizer and calls +`Rasterize(NonZero)` once, through the clip and paint chain of a `Fill`. +For every element: + +- *Orientation.* Elements that wind the other way are reversed after they + are added (`Rasterizer.reverse` negates the direction of their edges). + A stroke is reversed under a mirroring transform unless it is a + hairline. A fill is reversed when its signed area (exact for curves: + the integral of x dy − y dx in closed form) has the sign opposite to a + stroke's outline. Holes are kept, because the whole path is reversed, + not each subpath. +- *Culling.* Elements whose device box misses the clip are skipped + before any work, by the box computed once when they were added. +- *Strokes take the analytic path.* `Canvas.Stroke` fills the rows between + a segment's ends in closed form and composites them directly. In a + union those rows must be summed with everything else. So + `Rasterizer.addStrip` records the strip and `Rasterize` adds its pixels' + areas into the accumulator band by band, as differences of neighbouring + pixels like the edges' cells. They are sorted by band like the edges. + The rows around corners and caps still go to the accumulator as outline + edges. Since everything is summed, the analytic path is valid for any + paint and any clip here, not only for opaque paint. +- *Shapes* contribute their recorded edges and strips (the analytic + form), binned by rows. A union of shapes drawn in bands does the + stroker's work once per page. +- *What cannot be summed* is drawn on its own after the union, as + `Fill`, `Stroke` or `FillShape` would draw it: EvenOdd fills, and strokes + whose dash pattern is too dense to resolve (drawn at the pattern's mean + coverage, which is a scale of the coverage and not an area). + +## Consequences + +- The issue's hatch (2.07 px strokes, 1.75 px apart) goes from 0.971× ink + with 23.4 % of the pixels more than 16/255 off to 1.000× and 0 %. + Against the supersampled union (`TestFillUnionRef`, with overlapping and + abutting rectangles of both orientations, rotated and mirrored, and dense + hatches of strokes and hairlines) the result is within the reference's + usual tolerance. Where edges of different elements cross inside a + pixel, the sum over-covers that pixel, as it does within one path. That + is the exact-area accumulation semantics, not the exact union. +- A union of one element draws the bytes of `Fill` for a fill. For a + stroke it draws within 1/255 of `Stroke`'s outline path + (`TestFillUnionOne`). Unions of shapes draw the bytes of unions of their + paths, whole and in bands (`TestFillUnionShapes`). +- Translucent paint is applied once where elements overlap. That is what + a union means, but it is not the result of drawing the elements one + after another. +- Abutting shapes of *different* paints still conflate. That needs + per-sample coverage (an MSAA-like mask mode) and is out of scope. +- A fill whose subpaths wind both ways where they do not overlap (two + separate squares, one clockwise and one counter-clockwise) keeps parts + that cancel where they overlap other elements of the union. + +**Cost.** A union composites in a second pass: the accumulator is swept +row by row and composited through the blitter chain. `Stroke`'s analytic +rows compute coverage and composite in one fused loop. The coverage work +is the same in both. Measured on one core (cloud Xeon @ 2.1 GHz, A3 at +150 dpi, `BenchmarkUnion`, median of 6; this machine varies by ±10 %). +*separate* is `Stroke` per operation. *union* adds every operation to one +`Union` per page. *shapes* adds them as shapes prepared once per page, +set-up included: + +| scene | bands | separate | union | shapes | +|---|---:|---:|---:|---:| +| hatch-2000-hairline | 1 | 50.3 ms | 65.1 ms | 65.4 ms | +| | 16 | 62.4 ms | 74.8 ms | 68.3 ms | +| hatch-2000-0.35mm | 1 | 61.0 ms | 69.6 ms | 69.4 ms | +| | 16 | 68.2 ms | 77.2 ms | 66.5 ms | +| short-20000-0.35mm | 1 | 34.8 ms | 44.2 ms | 52.6 ms | +| | 16 | 60.0 ms | 49.5 ms | 55.4 ms | + +`BenchmarkScenes`, which does not use unions, is unchanged against `main` +(geomean +0.6 %, no scene significant over 6 interleaved runs). + +For cera: + +- Batch the operations where seams can appear: dense hatches, abutting + fills, strokes along the edges of fills of the same paint. Isolated + shapes gain nothing from a union and cost the second pass. +- Banded, prefer `Union.Shape` for operations that span several bands, + as with `FillShape` (ADR 0005). + +## Alternatives considered + +- *Per-element coverage, unioned in a coverage buffer* (saturating add of + each element's 8-bit coverage, composited once): it supports every fill + rule per element, but it costs one sweep per element plus a page-sized + buffer, it rounds each element to 8 bits before summing, and a stroke + composited in two parts (analytic rows and corner bands) would be + counted twice where they meet. +- *A fill rule "covered if any subpath winds ≠ 0"*: the accumulator only + holds the sum of the windings, so this cannot be evaluated in one pass. + With consistent orientation it is not needed. +- *Strokes from their outlines only* (no analytic rows in the + accumulator): simpler, but the edge walk made the hatch union 1.65× + slower than separate strokes (83 ms against 50 ms), against 1.3× with + the strips. +- *`FillUnion(paths []*Path, m Matrix, paint *Paint)`*, as the issue + first proposed: it cannot hold strokes, per-element transforms or + shapes, and cera's display list mixes them. diff --git a/docs/adr/README.md b/docs/adr/README.md index adc0f84..227ac96 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ when its shaders or kernels exist with tests and cera's ADR can use them. | [0003](0003-group-compositing.md) | Group compositing: backdrop removal and shape | cera ADR 0009 (M8) | accepted | | [0004](0004-knockout-compositing.md) | Knockout compositing by shader | cera ADR 0009 (M8) | proposed | | [0005](0005-prepared-geometry.md) | Prepared geometry shared by bands | cera's banded rendering | accepted | +| [0006](0006-union.md) | Union of many paths in one coverage pass | cera#21 (batched same-paint operations) | accepted | ## Template diff --git a/raster.go b/raster.go index 25b8253..5753753 100644 --- a/raster.go +++ b/raster.go @@ -111,6 +111,18 @@ type Rasterizer struct { bucket []int32 order []int32 active []int32 + + // Analytic stroke rows summed with the edges (see addStrip). + strips []accStrip + sorder []int32 + sactive []int32 +} + +// accStrip is the analytic rows [y0, y1) (relative to the clip's top) of +// a stroke segment (see strip.go), summed into the accumulator. +type accStrip struct { + rc rowCtx + y0, y1 int32 } // NewRasterizer returns a rasterizer clipped to clip. @@ -182,6 +194,7 @@ func (r *Rasterizer) discard() { // describing that path until the next one starts (Reset, AddPath, AddLine). func (r *Rasterizer) clearEdges() { r.edges = r.edges[:0] + r.strips = r.strips[:0] r.minY = math.MaxInt32 r.minX = math.MaxInt32 r.maxX = math.MinInt32 @@ -226,6 +239,11 @@ func (r *Rasterizer) AddPath(p *Path, m Matrix) { if !m.finite() || r.w <= 0 || r.h <= 0 || r.culled(p, m) { return } + r.addPath(p, m) +} + +// addPath is AddPath for a finite m and a path not culled. +func (r *Rasterizer) addPath(p *Path, m Matrix) { ident := m == Identity pts := p.Points var sx, sy, cx, cy float64 // subpath start, current point @@ -533,10 +551,17 @@ func (r *Rasterizer) emit(x0, y0, x1, y1 float64, dir int32) { } } +// reverse reverses the orientation of the edges added since there were n. +func (r *Rasterizer) reverse(n int) { + for i := n; i < len(r.edges); i++ { + r.edges[i].dir = -r.edges[i].dir + } +} + // Rasterize converts all added edges into spans and resets the rasterizer. func (r *Rasterizer) Rasterize(rule FillRule, b Blitter) { defer func() { r.clearEdges(); r.done = true }() - if len(r.edges) == 0 { + if len(r.edges) == 0 && len(r.strips) == 0 { return } // Narrow paths (most strokes, glyphs, small shapes) are swept over their @@ -551,23 +576,62 @@ func (r *Rasterizer) Rasterize(rule FillRule, b Blitter) { for i := range r.edges { mask |= r.rasterEdge(&r.edges[i], row0) } + for i := range r.strips { + mask |= r.rasterStrip(&r.strips[i], row0) + } r.sweep(row0, mask, rule, b) return } - // Sort edges by their first band: a counting sort when bands are few - // relative to edges, else a comparison sort, so that the empty bands of - // a tall sparse path cost nothing. + if len(r.strips) > 0 { + r.rasterizeStrips(row0, nb, rule, b) + return + } + band := func(ei int32) int { return (int(r.edges[ei].y0>>8) - row0) >> bandShift } + order := r.sortEdges(row0, nb) + active := r.active[:0] + next := 0 + for bi := 0; bi < nb; bi++ { + if len(active) == 0 { + // Nothing reaches this band: skip to the next edge's. + if next == len(order) { + break + } + bi = max(bi, band(order[next])) + } + for ; next < len(order) && band(order[next]) <= bi; next++ { + active = append(active, order[next]) + } + bandRow := row0 + bi< bandEnd { + active[j] = ei + j++ + } + } + active = active[:j] + if mask != 0 { + r.sweep(bandRow, mask, rule, b) + } + } + r.active = active[:0] +} + +// sortEdges returns the edges' indices sorted by their first band: a +// counting sort when bands are few relative to edges, else a comparison +// sort, so that the empty bands of a tall sparse path cost nothing. +func (r *Rasterizer) sortEdges(row0, nb int) []int32 { band := func(ei int32) int { return (int(r.edges[ei].y0>>8) - row0) >> bandShift } if cap(r.order) < len(r.edges) { r.order = make([]int32, len(r.edges)) } order := r.order[:len(r.edges)] if nb <= 4*len(r.edges) { - if cap(r.bucket) < nb+1 { - r.bucket = make([]int32, nb+1) - } - bucket := r.bucket[:nb+1] - clear(bucket) + bucket := r.buckets(nb) for i := range r.edges { bucket[band(int32(i))+1]++ } @@ -585,37 +649,188 @@ func (r *Rasterizer) Rasterize(rule FillRule, b Blitter) { } slices.SortFunc(order, func(a, b int32) int { return cmp.Compare(r.edges[a].y0, r.edges[b].y0) }) } - active := r.active[:0] - next := 0 + return order +} + +// sortStrips is sortEdges for the strips. +func (r *Rasterizer) sortStrips(row0, nb int) []int32 { + band := func(si int32) int { return (int(r.strips[si].y0) - row0) >> bandShift } + r.sorder = grow32(r.sorder, len(r.strips)) + order := r.sorder + if nb <= 4*len(r.strips) { + bucket := r.buckets(nb) + for i := range r.strips { + bucket[band(int32(i))+1]++ + } + for i := 1; i <= nb; i++ { + bucket[i] += bucket[i-1] + } + for i := range r.strips { + bi := band(int32(i)) + order[bucket[bi]] = int32(i) + bucket[bi]++ + } + } else { + for i := range order { + order[i] = int32(i) + } + slices.SortFunc(order, func(a, b int32) int { return cmp.Compare(r.strips[a].y0, r.strips[b].y0) }) + } + return order +} + +// buckets returns nb+1 zeroed counters. +func (r *Rasterizer) buckets(nb int) []int32 { + if cap(r.bucket) < nb+1 { + r.bucket = make([]int32, nb+1) + } + b := r.bucket[:nb+1] + clear(b) + return b +} + +// rasterizeStrips is Rasterize's band loop for edges and strips. +func (r *Rasterizer) rasterizeStrips(row0, nb int, rule FillRule, b Blitter) { + band := func(ei int32) int { return (int(r.edges[ei].y0>>8) - row0) >> bandShift } + sband := func(si int32) int { return (int(r.strips[si].y0) - row0) >> bandShift } + order := r.sortEdges(row0, nb) + sorder := r.sortStrips(row0, nb) + active, sactive := r.active[:0], r.sactive[:0] + next, snext := 0, 0 for bi := 0; bi < nb; bi++ { - if len(active) == 0 { - // Nothing reaches this band: skip to the next edge's. - if next == len(order) { + if len(active) == 0 && len(sactive) == 0 { + // Nothing reaches this band: skip to the next edge's or strip's. + nbi := nb + if next < len(order) { + nbi = band(order[next]) + } + if snext < len(sorder) { + nbi = min(nbi, sband(sorder[snext])) + } + if nbi == nb { break } - bi = max(bi, band(order[next])) + bi = max(bi, nbi) } for ; next < len(order) && band(order[next]) <= bi; next++ { active = append(active, order[next]) } + for ; snext < len(sorder) && sband(sorder[snext]) <= bi; snext++ { + sactive = append(sactive, sorder[snext]) + } bandRow := row0 + bi< bandEnd { + if e.y1 > bandEnd<<8 { active[j] = ei j++ } } active = active[:j] + j = 0 + for _, si := range sactive { + st := &r.strips[si] + mask |= r.rasterStrip(st, bandRow) + if st.y1 > bandEnd { + sactive[j] = si + j++ + } + } + sactive = sactive[:j] if mask != 0 { r.sweep(bandRow, mask, rule, b) } } - r.active = active[:0] + r.active, r.sactive = active[:0], sactive[:0] +} + +// fullArea is a fully covered pixel in accumulator units: the area in +// 1/256 pixels times the 512 of a cell's two halves. +const fullArea = 256 * 512 + +// addStrip adds rows [y0, y1) (device rows) of the analytic strip rc to +// the accumulator, as coverage summed with the edges', winding like an +// edge going down on its left. Rows outside the clip are dropped. +func (r *Rasterizer) addStrip(rc *rowCtx, y0, y1 int) { + r.begin() + oy := r.clip.Min.Y + y0, y1 = max(y0, oy), min(y1, r.clip.Max.Y) + if y0 >= y1 || r.w <= 0 { + return + } + // The strip's pixels lie between its left line at its leftmost row and + // its right line at its rightmost one (see rowCtx.span). + f0, f1 := float64(y0), float64(y1-1) + xa := min(rc.xl+f0*rc.sl, rc.xl+f1*rc.sl) + xb := max(rc.xr+f0*rc.sl, rc.xr+f1*rc.sl) + 1 + r.minX = min(r.minX, fixed(math.Floor(xa), r.cx0, r.w)) + r.maxX = max(r.maxX, fixed(math.Floor(xb), r.cx0, r.w)) + r.minY = min(r.minY, int32(y0-oy)<<8) + r.maxY = max(r.maxY, int32(y1-oy)<<8) + r.strips = append(r.strips, accStrip{rc: *rc, y0: int32(y0 - oy), y1: int32(y1 - oy)}) +} + +// rasterStrip accumulates the rows of s inside the band starting at +// bandRow (relative to the clip) and returns the mask of touched band +// rows. Each pixel adds its covered area, as segFast composites it: the +// difference to its left neighbour's goes to its cell. +func (r *Rasterizer) rasterStrip(s *accStrip, bandRow int) uint64 { + j0, j1 := max(int(s.y0), bandRow), min(int(s.y1), bandRow+bandH) + rc := &s.rc + nx, k1, k2, tr := rc.nx, rc.k1, rc.k2, rc.tr + ox, oy := r.clip.Min.X, r.clip.Min.Y + stride, nw := r.stride, r.nw + var mask uint64 + for j := j0; j < j1; j++ { + i0, i1, fy := rc.span(j+oy, ox, ox+r.w) + if i0 >= i1 { + continue + } + d := rc.centre(i0, fy) + ri := j - bandRow + c0, n := i0-ox, i1-i0 + acc := r.acc[ri*stride+c0 : ri*stride+c0+n+1] + a, b := n, n // fully covered interior [a, b), as segFast.emitRow + if rc.runs { + ta, tb := (rc.lo-d)*rc.inx, (rc.hi-d)*rc.inx + if ta > tb { + ta, tb = tb, ta + } + if ia, ib := max(int(math.Ceil(ta-1e-9)), 0), min(ffloor(tb+1e-9)+1, n); ia < ib { + a, b = ia, ib + } + } + var prev int64 + for i := 0; i < a; i++ { + v := int64((tr.area(k1-d) - tr.area(k2-d)) * fullArea) + acc[i] += v - prev + prev = v + d += nx + } + if b > a { + acc[a] += fullArea - prev + prev = fullArea + d += float64(b-a) * nx + } + for i := b; i < n; i++ { + v := int64((tr.area(k1-d) - tr.area(k2-d)) * fullArea) + acc[i] += v - prev + prev = v + d += nx + } + acc[n] -= prev + if !r.narrow { + markRange(r.dirty[ri*nw:(ri+1)*nw], c0, c0+n) + } + r.rmin[ri] = min(r.rmin[ri], int32(c0)) + r.rmax[ri] = max(r.rmax[ri], int32(c0+n)) + mask |= 1 << uint(ri) + } + return mask } // rasterEdge accumulates the part of e inside the band starting at bandRow diff --git a/stroke.go b/stroke.go index 2bc6b16..67bdf7a 100644 --- a/stroke.go +++ b/stroke.go @@ -34,6 +34,14 @@ type StrokeStyle struct { // LineSink receives device-space line segments. Segments emitted by the // Stroker form closed polygons that must be filled with the NonZero rule. // *Rasterizer implements LineSink. +// +// The outline of a stroke winds in one direction everywhere it covers, +// inner corners, joins, caps and dashes included: its winding number is +// never positive where another part of it is negative. The direction is +// that of a stroke under the identity unless the transform mirrors +// (negative determinant) and the stroke is not a hairline, whose outline is +// built in device space. So the union of many strokes, oriented alike, is +// one NonZero fill of their outlines (see Union, TestStrokeOutlineWinding). type LineSink interface { AddLine(x0, y0, x1, y1 float64) } diff --git a/union.go b/union.go new file mode 100644 index 0000000..4ad04b9 --- /dev/null +++ b/union.go @@ -0,0 +1,308 @@ +package stilus + +import "image" + +// Union collects fills and strokes that Canvas.FillUnion paints as one +// shape: their edges are accumulated together and the result is +// composited once. +// +// Drawn one after another, shapes of one paint that overlap or abut along +// antialiased edges leave light seams: each composites its own partial +// coverage over the last, so two edges that together cover a pixel +// completely give 1 − (1 − a)(1 − b) < 1. In a union their areas are summed +// in the accumulator instead, and abutting or overlapping edges cover the +// pixel completely, as one path with several subpaths filled NonZero does. +// +// That works because every element winds the same way. The outlines the +// Stroker produces wind in one direction everywhere they cover, joins and +// caps included (the direction depends only on the sign of the transform's +// determinant, and hairlines, outlined in device space, always wind the +// same way). A fill is taken with the NonZero rule; a path whose area winds +// the other way is reversed as a whole, which keeps its holes. Only a fill +// whose subpaths wind in both directions where they do not overlap (two +// separate squares, one clockwise, one counter-clockwise) still has parts +// that cancel where they overlap other elements. +// +// Elements that cannot be summed are drawn on their own after the union, +// as Canvas.Fill, Canvas.Stroke or Canvas.FillShape draw them: EvenOdd +// fills, and strokes whose dash pattern is too dense to resolve (drawn at +// the pattern's mean coverage). +// +// A Union keeps references to the paths, styles and shapes it is given; +// they must not change until it is drawn. The zero value is empty; Reset +// empties it, keeping its storage, so that it does not allocate once warm. +// FillUnion only reads it, so several canvases may draw one Union at once, +// one band each. +type Union struct { + ops []unionOp +} + +type unionKind uint8 + +const ( + unionFill unionKind = iota + unionStroke + unionShape +) + +// unionOp is one element of a union, with what can be derived from it +// once instead of per band. +type unionOp struct { + kind unionKind + sep bool // drawn on its own: EvenOdd, or a dense dash pattern + neg bool // its edges are reversed to wind like the other elements + rule FillRule + p *Path + st *StrokeStyle + sh *Shape + m Matrix + bb Rect // device box of the path's points + pad float64 // stroke: outline extent beyond bb + pr strokePrep +} + +// Reset empties the union, keeping its storage. +func (u *Union) Reset() { + clear(u.ops) // drop the references + u.ops = u.ops[:0] +} + +// Len returns the number of elements added since the last Reset. +func (u *Union) Len() int { return len(u.ops) } + +// Fill adds p, transformed by m into device space, filled with rule. +func (u *Union) Fill(p *Path, m Matrix, rule FillRule) { + if len(p.Points) == 0 || !m.finite() { + return + } + u.ops = append(u.ops, unionOp{ + kind: unionFill, rule: rule, sep: rule != NonZero, p: p, m: m, + bb: m.transformRect(p.Bounds()), + neg: fillReversed(p, m), + }) +} + +// Stroke adds the stroke of p with style st (in user space) under m. +func (u *Union) Stroke(p *Path, m Matrix, st *StrokeStyle) { + if len(p.Points) == 0 || !m.finite() { + return + } + pr := prepStroke(m, st) + u.ops = append(u.ops, unionOp{ + kind: unionStroke, sep: pr.dense, p: p, st: st, m: m, pr: pr, + bb: m.transformRect(p.Bounds()), + pad: strokePad(st, pr.sm), + neg: strokeReversed(m, st, pr), + }) +} + +// Shape adds the shape s, prepared by SetFill or SetStroke. The shape's +// records are shared with Canvas.FillShape: a union of shapes drawn in +// bands does the stroker's work once per page. +func (u *Union) Shape(s *Shape) { + op := unionOp{kind: unionShape, sh: s, bb: s.bb, pad: s.pad} + switch s.kind { + case shapeFill: + op.sep = s.rule != NonZero + op.neg = fillReversed(&s.path, s.m) + case shapeStroke: + op.sep = s.pr.dense + op.neg = strokeReversed(s.m, &s.st, s.pr) + default: + return + } + u.ops = append(u.ops, op) +} + +// strokeReversed reports whether the outline of a stroke under m winds +// against the outlines of strokes under the identity: under a mirroring +// transform, except for hairlines, which are outlined in device space. +func strokeReversed(m Matrix, st *StrokeStyle, pr strokePrep) bool { + return m.Det() < 0 && !(st.Width*pr.sm < 1) +} + +// fillReversed reports whether p under m winds against stroke outlines +// under the identity, which enclose a negative signed area (with the +// shoelace formula's x·y' − x'·y; Stroker builds the left side of a +// polyline forward and its right side backward). +func fillReversed(p *Path, m Matrix) bool { + return signedArea(p)*m.Det() > 0 +} + +// signedArea returns twice the signed area p encloses, its subpaths closed +// implicitly: the integral of x dy − y dx along it, exact for curves. +// Coordinates are taken relative to the first point, which keeps the +// products small for paths far from the origin. +func signedArea(p *Path) float64 { + pts := p.Points + if len(pts) == 0 { + return 0 + } + ox, oy := float64(pts[0].X), float64(pts[0].Y) + at := func(i int) (float64, float64) { return float64(pts[i].X) - ox, float64(pts[i].Y) - oy } + cr := func(ax, ay, bx, by float64) float64 { return ax*by - ay*bx } + var a, sx, sy, cx, cy float64 + pi := 0 + for _, v := range p.Verbs { + if int(v) >= len(numPoints) || pi+numPoints[v] > len(pts) { + break + } + switch v { + case MoveTo: + a += cr(cx, cy, sx, sy) + sx, sy = at(pi) + cx, cy = sx, sy + case LineTo: + x, y := at(pi) + a += cr(cx, cy, x, y) + cx, cy = x, y + case QuadTo: + x1, y1 := at(pi) + x2, y2 := at(pi + 1) + a += (2*cr(cx, cy, x1, y1) + cr(cx, cy, x2, y2) + 2*cr(x1, y1, x2, y2)) / 3 + cx, cy = x2, y2 + case CubicTo: + x1, y1 := at(pi) + x2, y2 := at(pi + 1) + x3, y3 := at(pi + 2) + a += (6*cr(cx, cy, x1, y1) + 3*cr(cx, cy, x2, y2) + cr(cx, cy, x3, y3) + + 3*cr(x1, y1, x2, y2) + 3*cr(x1, y1, x3, y3) + 6*cr(x2, y2, x3, y3)) / 10 + cx, cy = x3, y3 + case Close: + a += cr(cx, cy, sx, sy) + cx, cy = sx, sy + } + pi += numPoints[v] + } + return a + cr(cx, cy, sx, sy) +} + +// FillUnion paints the union of the elements of u with paint: their +// coverage is accumulated in one pass, NonZero, and composited once (see +// Union). For opaque paint the result is that of drawing them one by one, +// without the seams between them; translucent paint is applied once where +// elements overlap. +// +// Strokes take Canvas.Stroke's analytic path, whatever the paint and the +// clip: the rows between a segment's ends are not edge-walked but their +// pixels' covered areas computed in closed form, and here they are summed +// in the accumulator with everything else instead of being composited +// directly. +func (c *Canvas) FillUnion(u *Union, paint *Paint) { + defer c.guard() + cs := c.top() + if cs.bounds.Empty() || len(u.ops) == 0 || (paint.Shader == nil && paint.Color.A == 0) { + return + } + c.setClip(cs.bounds) + r := &c.r + r.Reset() + c.ustrip.r = r + sep, trunc := false, false + for i := range u.ops { + op := &u.ops[i] + if op.sep { + sep = true + continue + } + n := len(r.edges) + switch op.kind { + case unionFill: + if r.culledBox(op.bb) { + continue + } + r.addPath(op.p, op.m) + case unionStroke: + if strokeMisses(op.bb, op.pad, cs.bounds) { + continue + } + c.s.strokeFast(r, &c.ustrip, op.p, op.m, op.st, op.pr) + trunc = trunc || c.s.Truncated() + case unionShape: + s := op.sh + switch { + case s.kind == shapeFill: + if r.culledBox(s.bb) { + continue + } + c.addEdges(s.form(0, c), cs.bounds) + case strokeMisses(s.bb, s.pad, cs.bounds): + continue + default: + f := s.form(0, c) + c.addEdges(f, cs.bounds) + c.addAccStrips(f, cs.bounds) + trunc = trunc || f.truncated + } + } + if op.neg { + r.reverse(n) + } + } + c.checkBudget() + if trunc { + c.setErr(ErrDashBudget) + } + r.Rasterize(NonZero, c.chain(cs, c.paint(paint))) + if !sep { + return + } + for i := range u.ops { + op := &u.ops[i] + if !op.sep { + continue + } + switch op.kind { + case unionFill: + c.Fill(op.p, op.m, op.rule, paint) + case unionStroke: + c.Stroke(op.p, op.m, op.st, paint) + case unionShape: + c.FillShape(op.sh, paint) + } + } +} + +// stripSink is the segmentFiller of a union's strokes: it sums the analytic +// rows into the accumulator (Rasterizer.addStrip). Their orientation does +// not matter: after FillUnion's reversals every element winds like a +// stroke under the identity, whose rows add positive coverage. +type stripSink struct{ r *Rasterizer } + +func (k *stripSink) middle(ax, ay, bx, by, dx, dy float64, y0, y1 int) { + var rc rowCtx + if rc.set(ax, ay, bx, by, dx, dy) { + k.r.addStrip(&rc, y0, y1) + } +} + +func (k *stripSink) setOrientation(bool) {} +func (k *stripSink) limitRows(y0, y1 int) { k.r.limitRows(y0, y1) } +func (k *stripSink) unlimitRows() { k.r.unlimitRows() } + +// addAccStrips sums the strips of f that reach the rows of cb into the +// accumulator. The order does not matter, as for addEdges. +func (c *Canvas) addAccStrips(f *shapeForm, cb image.Rectangle) { + ss := f.strips + add := func(s *shapeStrip) { + if int(s.y1) > cb.Min.Y && int(s.y0) < cb.Max.Y { + c.r.addStrip(&s.rc, int(s.y0), int(s.y1)) + } + } + if len(f.sb.start) == 0 { + for i := range ss { + add(&ss[i]) + } + return + } + bins := &f.sb + b0, b1 := bins.span(cb.Min.Y, cb.Max.Y) + for bi := b0; bi <= b1; bi++ { + for _, id := range bins.ids[bins.start[bi]:bins.start[bi+1]] { + // A strip is taken in the first of the bins it shares with cb. + if bi == b0 || bins.bin(int64(ss[id].y0)) == bi { + add(&ss[id]) + } + } + } +} diff --git a/union_bench_test.go b/union_bench_test.go new file mode 100644 index 0000000..07683f2 --- /dev/null +++ b/union_bench_test.go @@ -0,0 +1,71 @@ +package stilus_test + +import ( + "fmt" + "image" + "testing" + + "github.com/timzifer/stilus" + "github.com/timzifer/stilus/internal/scenes" +) + +// BenchmarkUnion draws the stroke scenes at 150 dpi on one core, in 1 and +// 16 bands: every operation on its own (separate), all of them as one +// Union of paths (union), and as one Union of shapes prepared once per +// page, set-up included (shapes). +func BenchmarkUnion(b *testing.B) { + const dpi = 150 + m := scenes.Matrix(dpi) + for _, s := range []*scenes.Scene{scenes.Hatch(), scenes.HatchThick(), scenes.Short()} { + for _, nb := range []int{1, 16} { + img := image.NewRGBA(scenes.Size(dpi)) + bands := splitBands(img.Bounds(), nb) + c := stilus.NewCanvas(img) + paint := s.Ops[0].Paint + var u stilus.Union + shapes := make([]stilus.Shape, len(s.Ops)) + modes := map[string]func(){ + "separate": func() { + for _, band := range bands { + c.Reset(img, band) + s.Draw(c, dpi) + } + }, + "union": func() { + u.Reset() + for i := range s.Ops { + u.Stroke(s.Ops[i].Path, m, s.Ops[i].Style) + } + for _, band := range bands { + c.Reset(img, band) + c.ClipRect(s.Clip, m) + c.FillUnion(&u, paint) + } + }, + "shapes": func() { + u.Reset() + for i := range s.Ops { + shapes[i].SetStroke(s.Ops[i].Path, m, s.Ops[i].Style) + u.Shape(&shapes[i]) + } + for _, band := range bands { + c.Reset(img, band) + c.ClipRect(s.Clip, m) + c.FillUnion(&u, paint) + } + }, + } + for _, mode := range []string{"separate", "union", "shapes"} { + run := modes[mode] + b.Run(fmt.Sprintf("%s/%d-bands/%s", s.Name, nb, mode), func(b *testing.B) { + run() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + run() + } + }) + } + } + } +} diff --git a/union_test.go b/union_test.go new file mode 100644 index 0000000..1c6abb8 --- /dev/null +++ b/union_test.go @@ -0,0 +1,640 @@ +package stilus + +import ( + "bytes" + "image" + "math" + "math/rand" + "sync" + "testing" +) + +// segSink records the lines a Stroker emits. +type segSink struct{ s [][4]float64 } + +func (k *segSink) AddLine(x0, y0, x1, y1 float64) { + k.s = append(k.s, [4]float64{x0, y0, x1, y1}) +} + +// windAt returns the winding number of the lines segs around (px, py), +// counted as refRenderMode counts it. +func windAt(segs [][4]float64, px, py float64) int { + w := 0 + for _, e := range segs { + ax, ay, bx, by := e[0], e[1], e[2], e[3] + if ay <= py && by > py { + if (bx-ax)*(py-ay)-(px-ax)*(by-ay) > 0 { + w++ + } + } else if ay > py && by <= py { + if (bx-ax)*(py-ay)-(px-ax)*(by-ay) < 0 { + w-- + } + } + } + return w +} + +// polySegs returns the closed polygons' edges, reversed if rev is set. +func polySegs(polys [][][2]float64, rev bool) [][4]float64 { + var s [][4]float64 + for _, p := range polys { + for i := range p { + a, b := p[i], p[(i+1)%len(p)] + if rev { + a, b = b, a + } + s = append(s, [4]float64{a[0], a[1], b[0], b[1]}) + } + } + return s +} + +// strokeSegs returns the device-space outline of a stroke, reversed as +// FillUnion reverses it. +func strokeSegs(p *Path, m Matrix, st *StrokeStyle) [][4]float64 { + var k segSink + var s Stroker + s.Stroke(&k, p, m, st) + if strokeReversed(m, st, prepStroke(m, st)) { + for i, e := range k.s { + k.s[i] = [4]float64{e[2], e[3], e[0], e[1]} + } + } + return k.s +} + +func randStrokePath(rng *rand.Rand, w, h float64) *Path { + var p Path + pt := func() (float32, float32) { return float32(rng.Float64() * w), float32(rng.Float64() * h) } + for s := 0; s < 1+rng.Intn(2); s++ { + p.MoveTo(pt()) + for k := 0; k < 1+rng.Intn(5); k++ { + switch rng.Intn(4) { + case 0: + x1, y1 := pt() + x2, y2 := pt() + x3, y3 := pt() + p.CubicTo(x1, y1, x2, y2, x3, y3) + case 1: + x1, y1 := pt() + x2, y2 := pt() + p.QuadTo(x1, y1, x2, y2) + default: + p.LineTo(pt()) + } + } + if rng.Intn(3) == 0 { + p.Close() + } + } + return &p +} + +func randUnionMatrix(rng *rand.Rand, w, h float64) Matrix { + switch rng.Intn(4) { + case 0: + return Identity + case 1: + return Scale(1, -1).Mul(Translate(0, h)) // PDF's y flip + case 2: + return Translate(-w/2, -h/2).Mul(Rotate(rng.Float64() * 6)).Mul(Translate(w/2, h/2)) + default: + return Translate(-w/2, -h/2).Mul(Scale(-1, 0.4+rng.Float64())).Mul(Rotate(rng.Float64())).Mul(Translate(w/2, h/2)) + } +} + +// TestStrokeOutlineWinding checks what Union relies on: a stroke's outline +// winds the same way everywhere it covers, inner corners, round joins, +// caps and dashes included, and after FillUnion's reversal every outline +// winds the same way as every other. +func TestStrokeOutlineWinding(t *testing.T) { + rng := rand.New(rand.NewSource(28)) + const w, h = 40.0, 40.0 + n := 600 + if testing.Short() { + n = 100 + } + for i := 0; i < n; i++ { + p := randStrokePath(rng, w, h) + m := randUnionMatrix(rng, w, h) + st := StrokeStyle{ + Width: []float64{0, 0.6, 2, 6, 15}[rng.Intn(5)], Cap: Cap(rng.Intn(3)), + Join: Join(rng.Intn(3)), MiterLimit: 1 + rng.Float64()*10, + } + if rng.Intn(4) == 0 { + st.Dash, st.DashPhase = []float64{3 + rng.Float64()*5, 2 + rng.Float64()*5}, rng.Float64()*5 + } + segs := strokeSegs(p, m, &st) + for y := -15.0; y < h+15; y += 0.43 { + for x := -15.0; x < w+15; x += 0.43 { + if wn := windAt(segs, x, y); wn > 0 { + t.Fatalf("case %d (%+v, m %v): winding %d at %.2f,%.2f", i, st, m, wn, x, y) + } + } + } + } +} + +// TestUnionFillOrientation checks that fills of either orientation, under +// mirroring transforms too, wind like stroke outlines after reversal. +func TestUnionFillOrientation(t *testing.T) { + rng := rand.New(rand.NewSource(3)) + const w, h = 40.0, 40.0 + for i := 0; i < 200; i++ { + var p Path + switch i % 3 { + case 0: + p.Rect(5, 7, 20, 13) + case 1: + p.Ellipse(20, 20, 12, 7) + default: + // A ring: outer and inner contours of opposite orientation. + p.Ellipse(20, 20, 15, 12) + p.MoveTo(20, 14) + p.CubicTo(16, 14, 14, 17, 14, 20) + p.CubicTo(14, 23, 16, 26, 20, 26) + p.CubicTo(24, 26, 26, 23, 26, 20) + p.CubicTo(26, 17, 24, 14, 20, 14) + p.Close() + } + if rng.Intn(2) == 0 { + reversePath(&p) + } + m := randUnionMatrix(rng, w, h) + segs := polySegs(refPolys(&p, m), fillReversed(&p, m)) + inside := 0 + for y := -10.0; y < h+10; y += 0.7 { + for x := -10.0; x < w+10; x += 0.7 { + switch wn := windAt(segs, x, y); { + case wn > 0: + t.Fatalf("case %d: winding %d at %.2f,%.2f", i, wn, x, y) + case wn < 0: + inside++ + } + } + } + if inside == 0 { + t.Fatalf("case %d: nothing inside", i) + } + } +} + +// reversePath reverses p, which must consist of closed subpaths of lines +// and cubics. +func reversePath(p *Path) { + var q Path + pi := 0 + type seg struct { + v Verb + pts []Point + } + var segs []seg + var start Point + flush := func() { + if len(segs) == 0 { + return + } + cur := segs[len(segs)-1].pts[len(segs[len(segs)-1].pts)-1] + q.MoveTo(cur.X, cur.Y) + for i := len(segs) - 1; i >= 0; i-- { + s := segs[i] + prev := start + if i > 0 { + prev = segs[i-1].pts[len(segs[i-1].pts)-1] + } + switch s.v { + case LineTo: + q.LineTo(prev.X, prev.Y) + case CubicTo: + q.CubicTo(s.pts[1].X, s.pts[1].Y, s.pts[0].X, s.pts[0].Y, prev.X, prev.Y) + } + } + q.Close() + segs = nil + } + for _, v := range p.Verbs { + switch v { + case MoveTo: + flush() + start = p.Points[pi] + case LineTo, CubicTo: + segs = append(segs, seg{v, p.Points[pi : pi+numPoints[v]]}) + } + pi += numPoints[v] + } + flush() + *p = q +} + +func TestSignedArea(t *testing.T) { + rng := rand.New(rand.NewSource(5)) + for i := 0; i < 100; i++ { + p := randStrokePath(rng, 100, 100) + want := 0.0 + for _, poly := range refPolys(p, Identity) { + for k := range poly { + a, b := poly[k], poly[(k+1)%len(poly)] + want += a[0]*b[1] - a[1]*b[0] + } + } + got := signedArea(p) + if math.Abs(got-want) > 5e-3*math.Abs(want)+0.5 { + t.Fatalf("path %d: signed area %g, want %g", i, got, want) + } + // Far from the origin. + for k := range p.Points { + p.Points[k].X += 1e5 + } + if far := signedArea(p); math.Abs(far-got) > 1e-4*math.Abs(got)+1 { + t.Fatalf("path %d: signed area %g far from the origin, %g near it", i, far, got) + } + } +} + +// unionElem is one element of a reference union: its device-space edges, +// wound as FillUnion winds them. +type unionElem [][4]float64 + +// refUnion renders the union of the elements by 16×16 supersampling. With +// exact set, a point is covered when it is inside any of them; else, as +// exact-area accumulation computes it (refRenderMode's accum), the +// elements' winding numbers are integrated over the pixel and the NonZero +// rule applied to the integral, which differs from the exact union only +// where edges cross inside a pixel. +func refUnion(elems []unionElem, clip image.Rectangle, exact bool) *image.Alpha { + const ss = 16 + img := image.NewAlpha(clip) + for y := clip.Min.Y; y < clip.Max.Y; y++ { + for x := clip.Min.X; x < clip.Max.X; x++ { + n, wsum := 0, 0 + for sy := 0; sy < ss; sy++ { + py := float64(y) + (float64(sy)+0.5)/ss + for sx := 0; sx < ss; sx++ { + px := float64(x) + (float64(sx)+0.5)/ss + in := false + for _, e := range elems { + w := windAt(e, px, py) + wsum += w + in = in || w != 0 + } + if in { + n++ + } + } + } + f := float64(n) / (ss * ss) + if !exact { + f = math.Min(math.Abs(float64(wsum))/(ss*ss), 1) + } + img.SetAlpha(x, y, alphaOf(f)) + } + } + return img +} + +// coverageOf draws with an opaque white paint onto a transparent image and +// returns the alpha channel: the coverage composited. +func coverageOf(clip image.Rectangle, draw func(c *Canvas, p *Paint)) *image.Alpha { + img := image.NewRGBA(clip) + c := NewCanvas(img) + draw(c, &Paint{Color: rgba(255, 255, 255, 255)}) + if err := c.Err(); err != nil { + panic(err) + } + a := image.NewAlpha(clip) + for i := range a.Pix { + a.Pix[i] = img.Pix[4*i+3] + } + return a +} + +type unionCase struct { + name string + fills []*Path + m Matrix + stroke *StrokeStyle + seams bool // drawn one by one, the elements leave seams +} + +func unionCases() []unionCase { + rect := func(x, y, w, h float32, rev bool) *Path { + var p Path + p.Rect(x, y, w, h) + if rev { + reversePath(&p) + } + return &p + } + line := func(x0, y0, x1, y1 float32) *Path { + var p Path + p.MoveTo(x0, y0) + p.LineTo(x1, y1) + return &p + } + // A dense diagonal hatch as in timzifer/stilus#28: 2.07 px strokes, + // 1.75 px apart, covering the clip completely. + var hatch []*Path + for o := float32(-60); o < 60; o += 1.75 * math.Sqrt2 { + hatch = append(hatch, line(o-5, -5, o+45, 45)) + } + var hair []*Path + for o := float32(-60); o < 60; o += 0.5 * math.Sqrt2 { + hair = append(hair, line(o-5, -5, o+45, 45)) + } + var poly []*Path + for i := float32(0); i < 8; i++ { + var p Path + p.MoveTo(2+4.3*i, 3) + p.LineTo(5+4.3*i, 20) + p.LineTo(1+4.3*i, 37) + poly = append(poly, &p) + } + return []unionCase{ + {name: "overlapping rects", fills: []*Path{rect(3.3, 4.6, 20.4, 13.2, false), rect(14.7, 9.1, 18.9, 22.8, true)}, m: Identity}, + {seams: true, name: "abutting rects", fills: []*Path{rect(3.3, 4.6, 12.45, 25.2, false), rect(15.75, 4.6, 14.1, 25.2, true), rect(3.3, 29.8, 26.55, 6.35, false)}, m: Identity}, + {seams: true, name: "abutting rects, rotated", fills: []*Path{rect(3.3, 4.6, 12.45, 25.2, true), rect(15.75, 4.6, 14.1, 25.2, false)}, + m: Translate(-20, -20).Mul(Rotate(0.37)).Mul(Translate(20, 20))}, + {seams: true, name: "abutting rects, mirrored", fills: []*Path{rect(3.3, 4.6, 12.45, 25.2, false), rect(15.75, 4.6, 14.1, 25.2, true)}, + m: Scale(1, -1).Mul(Translate(0, 40))}, + {seams: true, name: "hatch", fills: hatch, m: Identity, stroke: &StrokeStyle{Width: 2.07}}, + {seams: true, name: "hatch, mirrored", fills: hatch, m: Scale(-1, 1).Mul(Translate(40, 0)), stroke: &StrokeStyle{Width: 2.07}}, + {name: "hatch, hairlines mirrored", fills: hair, m: Scale(1, -1).Mul(Translate(0, 40)), stroke: &StrokeStyle{Width: 0.5}}, + {name: "polylines, round", fills: poly, m: Identity, stroke: &StrokeStyle{Width: 3.1, Join: RoundJoin, Cap: RoundCap}}, + {name: "polylines, miter, dashed", fills: poly, m: Scale(1, -1).Mul(Translate(0, 40)), + stroke: &StrokeStyle{Width: 3.6, Join: MiterJoin, MiterLimit: 10, Cap: SquareCap, Dash: []float64{6, 1.5}}}, + } +} + +// TestFillUnionRef compares unions with the exact union of their elements: +// overlapping and abutting rectangles of either orientation, and a dense +// hatch, where elements drawn one by one leave seams. +func TestFillUnionRef(t *testing.T) { + clip := image.Rect(0, 0, 40, 40) + for _, tc := range unionCases() { + var elems []unionElem + var u Union + for _, p := range tc.fills { + if tc.stroke != nil { + u.Stroke(p, tc.m, tc.stroke) + elems = append(elems, strokeSegs(p, tc.m, tc.stroke)) + } else { + u.Fill(p, tc.m, NonZero) + elems = append(elems, polySegs(refPolys(p, tc.m), fillReversed(p, tc.m))) + } + } + want := refUnion(elems, clip, false) + got := coverageOf(clip, func(c *Canvas, paint *Paint) { c.FillUnion(&u, paint) }) + mean, max := diff(got, want) + // Where edges of the elements do not cross inside pixels, that is + // the exact union. + if emean, _ := diff(got, refUnion(elems, clip, true)); tc.seams && emean > 0.5 { + t.Errorf("%s: union differs from the exact union: mean %.3f/255", tc.name, emean) + } + // As TestFillRandom and checkAgainstRef: the supersampling grid + // alone is off by a few levels at edges, and curves are flattened + // at 0.1 px, up to ~26/255 for round joins. + lim := 16 + if tc.stroke != nil && (tc.stroke.Join == RoundJoin || tc.stroke.Cap == RoundCap) { + lim = 32 + } + if mean > 0.5 || max > lim { + t.Errorf("%s: union differs from the reference: mean %.3f/255, max %d/255", tc.name, mean, max) + } + sep := coverageOf(clip, func(c *Canvas, paint *Paint) { + for _, p := range tc.fills { + if tc.stroke != nil { + c.Stroke(p, tc.m, tc.stroke, paint) + } else { + c.Fill(p, tc.m, NonZero, paint) + } + } + }) + if _, smax := diff(sep, want); tc.seams && smax <= max+16 { + t.Errorf("%s: separate fills show no seams (max %d/255, union %d/255)", tc.name, smax, max) + } + } +} + +// TestFillUnionOne checks that a union of one element draws what the +// element draws on its own: the bytes of Fill for fills, and for strokes +// those of Stroke's outline path (translucent paint, more than one +// segment) within a level. Elements a union cannot sum (EvenOdd fills, +// dense dashes) are drawn on their own. +func TestFillUnionOne(t *testing.T) { + const w, h = 120, 100 + rng := rand.New(rand.NewSource(11)) + var u Union + for i := 0; i < 300; i++ { + c := randShapeCase(rng, w, h, nil) + if c.paint.Shader == nil { + c.paint.Color = rgba(0, 0, 100, 160) + } + if c.stroke && singleSegment(&c.p, &c.st) { + c.p.LineTo(3, 4) + } + want := image.NewRGBA(image.Rect(0, 0, w, h)) + got := image.NewRGBA(image.Rect(0, 0, w, h)) + clip := Rect{X0: rng.Float64() * 20, Y0: rng.Float64() * 20, X1: w - rng.Float64()*20, Y1: h - rng.Float64()*20} + dc, uc := NewCanvas(want), NewCanvas(got) + dc.ClipRect(clip, Identity) + uc.ClipRect(clip, Identity) + c.draw(dc) + u.Reset() + if c.stroke { + u.Stroke(&c.p, c.m, &c.st) + } else { + u.Fill(&c.p, c.m, c.rule) + } + uc.FillUnion(&u, &c.paint) + md := 0 + for k := range want.Pix { + md = max(md, int(want.Pix[k])-int(got.Pix[k]), int(got.Pix[k])-int(want.Pix[k])) + } + // A union's strokes take the analytic path, which computes the + // rows between their ends in closed form: within a level of the + // outline's accumulated coverage. + if lim := map[bool]int{false: 0, true: 1}[c.stroke]; md > lim { + t.Fatalf("case %d (stroke %v, rule %d, style %+v): union of one differs in %d bytes, by up to %d", + i, c.stroke, c.rule, c.st, diffBytes(want.Pix, got.Pix), md) + } + } +} + +// TestFillUnionShapes checks that a union of shapes draws the bytes of the +// union of their paths, drawn whole and in bands, and that a union drawn +// in bands stays within 3/255 of the union drawn whole (as FillShape). +func TestFillUnionShapes(t *testing.T) { + const w, h = 160, 150 + rng := rand.New(rand.NewSource(12)) + for i := 0; i < 60; i++ { + var pu, su Union + n := 1 + rng.Intn(12) + shapes := make([]Shape, n) + for k := 0; k < n; k++ { + c := randShapeCase(rng, w, h, nil) + c.set(&shapes[k]) + if c.stroke { + pu.Stroke(&c.p, c.m, &c.st) + } else { + pu.Fill(&c.p, c.m, c.rule) + } + su.Shape(&shapes[k]) + } + paint := &Paint{Color: rgba(0, 0, 0, 255)} + bandH := 1 + rng.Intn(40) + draw := func(u *Union, banded bool) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + c := NewCanvas(img) + c.s.noCull = true + if !banded { + c.FillUnion(u, paint) + return img + } + for y := 0; y < h; y += bandH { + c.Reset(img, image.Rect(0, y, w, min(y+bandH, h))) + c.FillUnion(u, paint) + } + return img + } + whole := draw(&pu, false) + for _, banded := range []bool{false, true} { + pi, si := draw(&pu, banded), draw(&su, banded) + if !bytes.Equal(pi.Pix, si.Pix) { + t.Fatalf("case %d, banded %v: shapes differ from paths in %d bytes", i, banded, diffBytes(pi.Pix, si.Pix)) + } + for k := range whole.Pix { + if d := int(whole.Pix[k]) - int(pi.Pix[k]); d < -3 || d > 3 { + t.Fatalf("case %d, bands of %d: byte %d is %d, %d unbanded", i, bandH, k, pi.Pix[k], whole.Pix[k]) + } + } + } + } +} + +// TestFillUnionConcurrent draws one union of shapes from several canvases +// at once, band by band, under the race detector. +func TestFillUnionConcurrent(t *testing.T) { + const w, h, bands = 200, 192, 12 + rng := rand.New(rand.NewSource(13)) + shapes := make([]Shape, 80) + var u Union + for i := range shapes { + randShapeCase(rng, w, h, nil).set(&shapes[i]) + u.Shape(&shapes[i]) + } + paint := &Paint{Color: rgba(20, 40, 90, 255)} + want := image.NewRGBA(image.Rect(0, 0, w, h)) + got := image.NewRGBA(image.Rect(0, 0, w, h)) + var wg sync.WaitGroup + for b := 0; b < bands; b++ { + band := image.Rect(0, h*b/bands, w, h*(b+1)/bands) + dc := NewCanvas(want) + dc.Reset(want, band) + dc.FillUnion(&u, paint) + wg.Add(1) + go func() { + defer wg.Done() + c := NewCanvas(got) + c.Reset(got, band) + c.FillUnion(&u, paint) + }() + } + wg.Wait() + if !bytes.Equal(want.Pix, got.Pix) { + t.Fatalf("concurrent unions differ in %d bytes", diffBytes(want.Pix, got.Pix)) + } +} + +func TestFillUnionEmpty(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + c := NewCanvas(img) + paint := &Paint{Color: rgba(0, 0, 0, 255)} + var u Union + c.FillUnion(&u, paint) + var p, empty Path + p.Rect(0, 0, 8, 8) + u.Fill(&p, Matrix{math.NaN(), 0, 0, 1, 0, 0}, NonZero) + u.Stroke(&p, Matrix{1, 0, 0, math.Inf(1), 0, 0}, &StrokeStyle{Width: 1}) + u.Fill(&empty, Identity, NonZero) + u.Stroke(&empty, Identity, &StrokeStyle{Width: 1}) + var s Shape + u.Shape(&s) + if u.Len() != 0 { + t.Errorf("Len = %d after adding nothing drawable", u.Len()) + } + c.FillUnion(&u, paint) + u.Fill(&p, Identity, NonZero) + c.FillUnion(&u, &Paint{}) + for _, v := range img.Pix { + if v != 0 { + t.Fatal("an empty union painted") + } + } + u.Reset() + if u.Len() != 0 { + t.Errorf("Len = %d after Reset", u.Len()) + } +} + +func TestFillUnionEdgeBudget(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + c := NewCanvas(img) + c.r.MaxEdges = 5 // each rectangle has two edges that are not horizontal + var u Union + paths := make([]Path, 4) + for i := range paths { + paths[i].Rect(float32(i*10+3), 3, 5, 50.5) + u.Fill(&paths[i], Identity, NonZero) + } + c.FillUnion(&u, &Paint{Color: rgba(0, 0, 0, 255)}) + if c.Err() != ErrEdgeBudget { + t.Errorf("Err = %v, want ErrEdgeBudget", c.Err()) + } +} + +func TestFillUnionNoAllocs(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 300, 300)) + c := NewCanvas(img) + paths := make([]Path, 60) + for i := range paths { + p := &paths[i] + p.MoveTo(float32(i*5), 0) + p.LineTo(float32(300-i*5), 300) + if i%3 == 0 { + p.CubicTo(10, 20, 200, 30, float32(i), 290) + } + } + st := &StrokeStyle{Width: 2, Join: RoundJoin, Dash: []float64{20, 4}} + dense := &StrokeStyle{Width: 2, Dash: []float64{0.05, 0.07}} + paint := &Paint{Color: rgba(0, 0, 0, 255)} + var shapes [2]Shape + var u Union + run := func() { + u.Reset() + for i := range paths { + switch i % 4 { + case 0: + u.Fill(&paths[i], Identity, NonZero) + case 1: + u.Stroke(&paths[i], Identity, st) + case 2: + u.Stroke(&paths[i], Scale(1, -1).Mul(Translate(0, 300)), dense) + default: + u.Fill(&paths[i], Identity, EvenOdd) + } + } + shapes[0].SetStroke(&paths[1], Identity, st) + shapes[1].SetFill(&paths[3], Identity, NonZero) + u.Shape(&shapes[0]) + u.Shape(&shapes[1]) + for y := 0; y < 300; y += 25 { + c.Reset(img, image.Rect(0, y, 300, y+25)) + c.FillUnion(&u, paint) + } + } + run() + if a := testing.AllocsPerRun(5, run); a != 0 { + t.Errorf("%.1f allocs per run", a) + } +}