~eliasnaur/gio-patches

[PATCH gio v3 1/2] gpu,op/clip: implement stroked paths with dashed lines

Details
Message ID
<9w1q7RojkhChZxvWXbCSqiDdRATweWEuvOk1K1POU@cp4-web-034.plabs.ch>
DKIM signature
missing
Download raw message
Patch: +632 -101
Signed-off-by: Sebastien Binet <s@sbinet.org>
---
 gpu/dash.go                                   | 392 ++++++++++++++++++
 gpu/gpu.go                                    |   6 +
 gpu/stroke.go                                 |  44 ++
 internal/opconst/ops.go                       |   2 +-
 internal/ops/reader.go                        |   6 +
 internal/rendertest/clip_test.go              | 264 +++++++-----
 .../refs/TestDashedPathFlatCapEllipse.png     | Bin 0 -> 6107 bytes
 .../refs/TestDashedPathFlatCapZ.png           | Bin 0 -> 3681 bytes
 .../refs/TestDashedPathFlatCapZNoDash.png     | Bin 0 -> 2262 bytes
 .../refs/TestDashedPathFlatCapZNoPath.png     | Bin 0 -> 1553 bytes
 op/clip/clip.go                               |   8 +-
 op/clip/stroke.go                             |  11 +-
 12 files changed, 632 insertions(+), 101 deletions(-)
 create mode 100644 gpu/dash.go
 create mode 100644 internal/rendertest/refs/TestDashedPathFlatCapEllipse.png
 create mode 100644 internal/rendertest/refs/TestDashedPathFlatCapZ.png
 create mode 100644 internal/rendertest/refs/TestDashedPathFlatCapZNoDash.png
 create mode 100644 internal/rendertest/refs/TestDashedPathFlatCapZNoPath.png

diff --git a/gpu/dash.go b/gpu/dash.go
new file mode 100644
index 0000000..1460228
--- /dev/null
+++ b/gpu/dash.go
@@ -0,0 +1,392 @@
// SPDX-License-Identifier: Unlicense OR MIT

// The algorithms to compute dashes have been extracted, adapted from
// (and used as a reference implementation):
//  - github.com/tdewolff/canvas (Licensed under MIT)

package gpu

import (
	"math"
	"sort"

	"gioui.org/f32"
	"gioui.org/internal/ops"
	"gioui.org/op/clip"
)

func isSolidLine(sty clip.DashStyle) bool {
	return sty.Offset == 0 && len(sty.Dashes) == 0
}

func (qs strokeQuads) dash(sty clip.DashStyle) strokeQuads {
	sty = dashCanonical(sty)

	switch {
	case len(sty.Dashes) == 0:
		return qs
	case len(sty.Dashes) == 1 && sty.Dashes[0] == 0.0:
		var o strokeQuads
		return o
	}

	if len(sty.Dashes)%2 == 1 {
		// If the dash pattern is of uneven length, dash and space lengths
		// alternate. The following duplicates the pattern so that uneven
		// indices are always spaces.
		sty.Dashes = append(sty.Dashes, sty.Dashes...)
	}

	var (
		i0, pos0 = dashStart(sty)
		out      strokeQuads

		contour uint32 = 1
	)

	for _, ps := range qs.split() {
		var (
			i      = i0
			pos    = pos0
			t      []float64
			length = ps.len()
		)
		for pos+sty.Dashes[i] < length {
			pos += sty.Dashes[i]
			if 0.0 < pos {
				t = append(t, float64(pos))
			}
			i++
			if i == len(sty.Dashes) {
				i = 0
			}
		}

		j0 := 0
		endsInDash := i%2 == 0
		if len(t)%2 == 1 && endsInDash || len(t)%2 == 0 && !endsInDash {
			j0 = 1
		}

		var (
			qd strokeQuads
			pd = ps.splitAt(&contour, t...)
		)
		for j := j0; j < len(pd)-1; j += 2 {
			qd = qd.append(pd[j])
		}
		if endsInDash {
			if ps.closed() {
				qd = pd[len(pd)-1].append(qd)
			} else {
				qd = qd.append(pd[len(pd)-1])
			}
		}
		out = out.append(qd)
		contour++
	}
	return out
}

func dashCanonical(sty clip.DashStyle) clip.DashStyle {
	var (
		o  = sty
		ds = o.Dashes
	)

	if len(sty.Dashes) == 0 {
		return sty
	}

	// Remove zeros except first and last.
	for i := 1; i < len(ds)-1; i++ {
		if f32Eq(ds[i], 0.0) {
			ds[i-1] += ds[i+1]
			ds = append(ds[:i], ds[i+2:]...)
			i--
		}
	}

	// Remove first zero, collapse with second and last.
	if f32Eq(ds[0], 0.0) {
		if len(ds) < 3 {
			return clip.DashStyle{
				Offset: 0.0,
				Dashes: []float32{0.0},
			}
		}
		o.Offset -= ds[1]
		ds[len(ds)-1] += ds[1]
		ds = ds[2:]
	}

	// Remove last zero, collapse with fist and second to last.
	if f32Eq(ds[len(ds)-1], 0.0) {
		if len(ds) < 3 {
			return clip.DashStyle{}
		}
		o.Offset += ds[len(ds)-2]
		ds[0] += ds[len(ds)-2]
		ds = ds[:len(ds)-2]
	}

	// If there are zeros or negatives, don't draw dashes.
	for i := 0; i < len(ds); i++ {
		if ds[i] < 0.0 || f32Eq(ds[i], 0.0) {
			return clip.DashStyle{
				Offset: 0.0,
				Dashes: []float32{0.0},
			}
		}
	}

	// Remove repeated patterns.
loop:
	for len(ds)%2 == 0 {
		mid := len(ds) / 2
		for i := 0; i < mid; i++ {
			if !f32Eq(ds[i], ds[mid+i]) {
				break loop
			}
		}
		ds = ds[:mid]
	}
	return o
}

func dashStart(sty clip.DashStyle) (int, float32) {
	i0 := 0 // i0 is the index into dashes.
	for sty.Dashes[i0] <= sty.Offset {
		sty.Offset -= sty.Dashes[i0]
		i0++
		if i0 == len(sty.Dashes) {
			i0 = 0
		}
	}
	// pos0 may be negative if the offset lands halfway into dash.
	pos0 := -sty.Offset
	if sty.Offset < 0.0 {
		var sum float32
		for _, d := range sty.Dashes {
			sum += d
		}
		pos0 = -(sum + sty.Offset) // handle negative offsets
	}
	return i0, pos0
}

func (qs strokeQuads) len() float32 {
	var sum float32
	for i := range qs {
		q := qs[i].quad
		sum += quadBezierLen(q.From, q.Ctrl, q.To)
	}
	return sum
}

// splitAt splits the path into separate paths at the specified intervals
// along the path.
// splitAt updates the provided contour counter as it splits the segments.
func (qs strokeQuads) splitAt(contour *uint32, ts ...float64) []strokeQuads {
	if len(ts) == 0 {
		qs.setContour(*contour)
		return []strokeQuads{qs}
	}

	sort.Float64s(ts)
	if ts[0] == 0 {
		ts = ts[1:]
	}

	var (
		j int     // index into ts
		t float64 // current position along curve
	)

	var oo []strokeQuads
	var oi strokeQuads
	push := func() {
		oo = append(oo, oi)
		oi = nil
	}

	for _, ps := range qs.split() {
		for _, q := range ps {
			if j == len(ts) {
				oi = append(oi, q)
				continue
			}
			speed := func(t float64) float64 {
				return float64(lenPt(quadBezierD1(q.quad.From, q.quad.Ctrl, q.quad.To, float32(t))))
			}
			invL, dt := invSpeedPolynomialChebyshevApprox(20, gaussLegendre7, speed, 0, 1)

			var (
				t0 float64
				r0 = q.quad.From
				r1 = q.quad.Ctrl
				r2 = q.quad.To

				// from keeps track of the start of the 'running' segment.
				from = r0
			)
			for j < len(ts) && t < ts[j] && ts[j] <= t+dt {
				tj := invL(ts[j] - t)
				tsub := (tj - t0) / (1.0 - t0)
				t0 = tj

				var q1 f32.Point
				_, q1, _, r0, r1, r2 = quadBezierSplit(r0, r1, r2, float32(tsub))

				oi = append(oi, strokeQuad{
					contour: *contour,
					quad: ops.Quad{
						From: from,
						Ctrl: q1,
						To:   r0,
					},
				})
				push()
				(*contour)++

				from = r0
				j++
			}
			if !f64Eq(t0, 1) {
				if len(oi) > 0 {
					r0 = oi.pen()
				}
				oi = append(oi, strokeQuad{
					contour: *contour,
					quad: ops.Quad{
						From: r0,
						Ctrl: r1,
						To:   r2,
					},
				})
			}
			t += dt
		}
	}
	if len(oi) > 0 {
		push()
		(*contour)++
	}

	return oo
}

func f32Eq(a, b float32) bool {
	const epsilon = 1e-10
	return math.Abs(float64(a-b)) < epsilon
}

func f64Eq(a, b float64) bool {
	const epsilon = 1e-10
	return math.Abs(a-b) < epsilon
}

func invSpeedPolynomialChebyshevApprox(N int, gaussLegendre gaussLegendreFunc, fp func(float64) float64, tmin, tmax float64) (func(float64) float64, float64) {
	// The TODOs below are copied verbatim from tdewolff/canvas:
	//
	// TODO: find better way to determine N. For Arc 10 seems fine, for some
	// Quads 10 is too low, for Cube depending on inflection points is
	// maybe not the best indicator
	//
	// TODO: track efficiency, how many times is fp called?
	// Does a look-up table make more sense?
	fLength := func(t float64) float64 {
		return math.Abs(gaussLegendre(fp, tmin, t))
	}
	totalLength := fLength(tmax)
	t := func(L float64) float64 {
		return bisectionMethod(fLength, L, tmin, tmax)
	}
	return polynomialChebyshevApprox(N, t, 0.0, totalLength, tmin, tmax), totalLength
}

func polynomialChebyshevApprox(N int, f func(float64) float64, xmin, xmax, ymin, ymax float64) func(float64) float64 {
	var (
		invN = 1.0 / float64(N)
		fs   = make([]float64, N)
	)
	for k := 0; k < N; k++ {
		u := math.Cos(math.Pi * (float64(k+1) - 0.5) * invN)
		fs[k] = f(xmin + 0.5*(xmax-xmin)*(u+1))
	}

	c := make([]float64, N)
	for j := 0; j < N; j++ {
		var a float64
		for k := 0; k < N; k++ {
			a += fs[k] * math.Cos(float64(j)*math.Pi*(float64(k+1)-0.5)/float64(N))
		}
		c[j] = 2 * invN * a
	}

	if ymax < ymin {
		ymin, ymax = ymax, ymin
	}
	return func(x float64) float64 {
		x = math.Min(xmax, math.Max(xmin, x))
		u := (x-xmin)/(xmax-xmin)*2 - 1
		var a float64
		for j := 0; j < N; j++ {
			a += c[j] * math.Cos(float64(j)*math.Acos(u))
		}
		y := -0.5*c[0] + a
		if !math.IsNaN(ymin) && !math.IsNaN(ymax) {
			y = math.Min(ymax, math.Max(ymin, y))
		}
		return y
	}
}

// bisectionMethod finds the value x for which f(x) = y in the interval x
// in [xmin, xmax] using the bisection method.
func bisectionMethod(f func(float64) float64, y, xmin, xmax float64) float64 {
	const (
		maxIter   = 100
		tolerance = 0.001 // 0.1%
	)

	var (
		n    = 0
		x    float64
		tolX = math.Abs(xmax-xmin) * tolerance
		tolY = math.Abs(f(xmax)-f(xmin)) * tolerance
	)
	for {
		x = 0.5 * (xmin + xmax)
		if n >= maxIter {
			return x
		}

		dy := f(x) - y
		switch {
		case math.Abs(dy) < tolY, math.Abs(0.5*(xmax-xmin)) < tolX:
			return x
		case dy > 0:
			xmax = x
		default:
			xmin = x
		}
		n++
	}
}

type gaussLegendreFunc func(func(float64) float64, float64, float64) float64

// Gauss-Legendre quadrature integration from a to b with n=7
func gaussLegendre7(f func(float64) float64, a, b float64) float64 {
	c := 0.5 * (b - a)
	d := 0.5 * (a + b)
	Qd1 := f(-0.949108*c + d)
	Qd2 := f(-0.741531*c + d)
	Qd3 := f(-0.405845*c + d)
	Qd4 := f(d)
	Qd5 := f(0.405845*c + d)
	Qd6 := f(0.741531*c + d)
	Qd7 := f(0.949108*c + d)
	return c * (0.129485*(Qd1+Qd7) + 0.279705*(Qd2+Qd6) + 0.381830*(Qd3+Qd5) + 0.417959*Qd4)
}
diff --git a/gpu/gpu.go b/gpu/gpu.go
index 5eb484b..5f46862 100644
--- a/gpu/gpu.go
+++ b/gpu/gpu.go
@@ -167,6 +167,12 @@ func (op *clipOp) decode(data []byte) {
			Miter: math.Float32frombits(bo.Uint32(data[23:])),
		},
	}
	op.style.Line.Offset = math.Float32frombits(bo.Uint32(data[27:]))
	op.style.Line.Dashes = make([]float32, data[31])
	dashes := data[32:]
	for i := range op.style.Line.Dashes {
		op.style.Line.Dashes[i] = math.Float32frombits(bo.Uint32(dashes[i*4:]))
	}
}

func decodeImageOp(data []byte, refs []interface{}) imageOpData {
diff --git a/gpu/stroke.go b/gpu/stroke.go
index 9a76c50..7db08ae 100644
--- a/gpu/stroke.go
+++ b/gpu/stroke.go
@@ -46,10 +46,22 @@ type strokeState struct {

type strokeQuads []strokeQuad

func (qs *strokeQuads) setContour(n uint32) {
	for i := range *qs {
		(*qs)[i].contour = n
	}
}

func (qs *strokeQuads) pen() f32.Point {
	return (*qs)[len(*qs)-1].quad.To
}

func (qs *strokeQuads) closed() bool {
	beg := (*qs)[0].quad.From
	end := (*qs)[len(*qs)-1].quad.To
	return f32Eq(beg.X, end.X) && f32Eq(beg.Y, end.Y)
}

func (qs *strokeQuads) lineTo(pt f32.Point) {
	end := qs.pen()
	*qs = append(*qs, strokeQuad{
@@ -107,6 +119,10 @@ func (qs strokeQuads) split() []strokeQuads {
}

func (qs strokeQuads) stroke(width float32, sty clip.StrokeStyle) strokeQuads {
	if !isSolidLine(sty.Line) {
		qs = qs.dash(sty.Line)
	}

	var (
		o  strokeQuads
		hw = 0.5 * width
@@ -132,6 +148,7 @@ func (qs strokeQuads) stroke(width float32, sty clip.StrokeStyle) strokeQuads {
			}
		}
	}

	return o
}

@@ -290,6 +307,10 @@ func normPt(p f32.Point, l float32) f32.Point {
	return f32.Point{X: p.X * n, Y: p.Y * n}
}

func lenPt(p f32.Point) float32 {
	return float32(math.Hypot(float64(p.X), float64(p.Y)))
}

func dotPt(p, q f32.Point) float32 {
	return p.X*q.X + p.Y*q.Y
}
@@ -348,6 +369,29 @@ func quadBezierD2(p0, p1, p2 f32.Point, t float32) f32.Point {
	return p.Mul(2)
}

// quadBezierLen returns the length of the Bézier curve.
// See:
//  https://malczak.linuxpl.com/blog/quadratic-bezier-curve-length/
func quadBezierLen(p0, p1, p2 f32.Point) float32 {
	a := p0.Sub(p1.Mul(2)).Add(p2)
	b := p1.Mul(2).Sub(p0.Mul(2))
	A := float64(4 * dotPt(a, a))
	B := float64(4 * dotPt(a, b))
	C := float64(dotPt(b, b))
	if f64Eq(A, 0.0) {
		// p1 is in the middle between p0 and p2,
		// so it is a straight line from p0 to p2.
		return lenPt(p2.Sub(p0))
	}

	Sabc := 2 * math.Sqrt(A+B+C)
	A2 := math.Sqrt(A)
	A32 := 2 * A * A2
	C2 := 2 * math.Sqrt(C)
	BA := B / A2
	return float32((A32*Sabc + A2*B*(Sabc-C2) + (4*C*A-B*B)*math.Log((2*A2+BA+Sabc)/(BA+C2))) / (4 * A32))
}

func strokeQuadBezier(state strokeState, d, flatness float32) strokeQuads {
	// Gio strokes are only quadratic Bézier curves, w/o any inflection point.
	// So we just have to flatten them.
diff --git a/internal/opconst/ops.go b/internal/opconst/ops.go
index 80a3bc6..b2c76ad 100644
--- a/internal/opconst/ops.go
+++ b/internal/opconst/ops.go
@@ -47,7 +47,7 @@ const (
	TypePushLen           = 1
	TypePopLen            = 1
	TypeAuxLen            = 1
	TypeClipLen           = 1 + 4*4 + 4 + 2 + 4
	TypeClipLen           = 1 + 4*4 + (4 + 2 + 4 + (4 + 1))
	TypeProfileLen        = 1
)

diff --git a/internal/ops/reader.go b/internal/ops/reader.go
index 0bab3a0..5376dc2 100644
--- a/internal/ops/reader.go
+++ b/internal/ops/reader.go
@@ -98,6 +98,12 @@ func (r *Reader) Decode() (EncodedOp, bool) {
		refs = refs[r.pc.refs:]
		refs = refs[:nrefs]
		switch t {
		case opconst.TypeClip:
			// A Clip operation may have trailing dashes float32 data.
			// The last element of the fixed-length clip data is the number
			// of elements describing the dashes pattern.
			n += 4 * int(data[len(data)-1])
			data = data[:n]
		case opconst.TypeAux:
			// An Aux operations is always wrapped in a macro, and
			// its length is the remaining space.
diff --git a/internal/rendertest/clip_test.go b/internal/rendertest/clip_test.go
index 91eafae..efc98b7 100644
--- a/internal/rendertest/clip_test.go
+++ b/internal/rendertest/clip_test.go
@@ -131,17 +131,7 @@ func TestStrokedPathBevelFlat(t *testing.T) {
			Join: clip.BevelJoin,
		}

		p := new(clip.Path)
		p.Begin(o)
		p.Move(f32.Pt(10, 50))
		p.Line(f32.Pt(10, 0))
		p.Arc(f32.Pt(10, 0), f32.Pt(20, 0), math.Pi)
		p.Line(f32.Pt(10, 0))
		p.Line(f32.Pt(10, 10))
		p.Arc(f32.Pt(0, 30), f32.Pt(0, 30), 2*math.Pi)
		p.Line(f32.Pt(-20, 0))
		p.Quad(f32.Pt(-10, -10), f32.Pt(-30, 30))
		p.Stroke(width, sty).Add(o)
		newStrokedPath(o).Stroke(width, sty).Add(o)

		paint.Fill(o, red)
	}, func(r result) {
@@ -158,17 +148,7 @@ func TestStrokedPathBevelRound(t *testing.T) {
			Join: clip.BevelJoin,
		}

		p := new(clip.Path)
		p.Begin(o)
		p.Move(f32.Pt(10, 50))
		p.Line(f32.Pt(10, 0))
		p.Arc(f32.Pt(10, 0), f32.Pt(20, 0), math.Pi)
		p.Line(f32.Pt(10, 0))
		p.Line(f32.Pt(10, 10))
		p.Arc(f32.Pt(0, 30), f32.Pt(0, 30), 2*math.Pi)
		p.Line(f32.Pt(-20, 0))
		p.Quad(f32.Pt(-10, -10), f32.Pt(-30, 30))
		p.Stroke(width, sty).Add(o)
		newStrokedPath(o).Stroke(width, sty).Add(o)

		paint.Fill(o, red)
	}, func(r result) {
@@ -185,17 +165,7 @@ func TestStrokedPathBevelSquare(t *testing.T) {
			Join: clip.BevelJoin,
		}

		p := new(clip.Path)
		p.Begin(o)
		p.Move(f32.Pt(10, 50))
		p.Line(f32.Pt(10, 0))
		p.Arc(f32.Pt(10, 0), f32.Pt(20, 0), math.Pi)
		p.Line(f32.Pt(10, 0))
		p.Line(f32.Pt(10, 10))
		p.Arc(f32.Pt(0, 30), f32.Pt(0, 30), 2*math.Pi)
		p.Line(f32.Pt(-20, 0))
		p.Quad(f32.Pt(-10, -10), f32.Pt(-30, 30))
		p.Stroke(width, sty).Add(o)
		newStrokedPath(o).Stroke(width, sty).Add(o)

		paint.Fill(o, red)
	}, func(r result) {
@@ -212,17 +182,7 @@ func TestStrokedPathRoundRound(t *testing.T) {
			Join: clip.RoundJoin,
		}

		p := new(clip.Path)
		p.Begin(o)
		p.Move(f32.Pt(10, 50))
		p.Line(f32.Pt(10, 0))
		p.Arc(f32.Pt(10, 0), f32.Pt(20, 0), math.Pi)
		p.Line(f32.Pt(10, 0))
		p.Line(f32.Pt(10, 10))
		p.Arc(f32.Pt(0, 30), f32.Pt(0, 30), 2*math.Pi)
		p.Line(f32.Pt(-20, 0))
		p.Quad(f32.Pt(-10, -10), f32.Pt(-30, 30))
		p.Stroke(width, sty).Add(o)
		newStrokedPath(o).Stroke(width, sty).Add(o)

		paint.Fill(o, red)
	}, func(r result) {
@@ -240,34 +200,11 @@ func TestStrokedPathFlatMiter(t *testing.T) {
			Miter: 5,
		}

		beg := f32.Pt(40, 10)
		{
			p := new(clip.Path)
			p.Begin(o)
			p.Move(beg)
			p.Line(f32.Pt(50, 0))
			p.Line(f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
			p.Quad(f32.Pt(-50, 20), f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))

			p.Stroke(width, sty).Add(o)
			paint.Fill(o, red)
		}

		{
			p := new(clip.Path)
			p.Begin(o)
			p.Move(beg)
			p.Line(f32.Pt(50, 0))
			p.Line(f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
			p.Quad(f32.Pt(-50, 20), f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
		newZigZagPath(o).Stroke(width, sty).Add(o)
		paint.Fill(o, red)

			p.Stroke(2, clip.StrokeStyle{}).Add(o)
			paint.Fill(o, black)
		}
		newZigZagPath(o).Stroke(2, clip.StrokeStyle{}).Add(o)
		paint.Fill(o, black)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
@@ -285,34 +222,11 @@ func TestStrokedPathFlatMiterInf(t *testing.T) {
			Miter: float32(math.Inf(+1)),
		}

		beg := f32.Pt(40, 10)
		{
			p := new(clip.Path)
			p.Begin(o)
			p.Move(beg)
			p.Line(f32.Pt(50, 0))
			p.Line(f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
			p.Quad(f32.Pt(-50, 20), f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))

			p.Stroke(width, sty).Add(o)
			paint.Fill(o, red)
		}

		{
			p := new(clip.Path)
			p.Begin(o)
			p.Move(beg)
			p.Line(f32.Pt(50, 0))
			p.Line(f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
			p.Quad(f32.Pt(-50, 20), f32.Pt(-50, 50))
			p.Line(f32.Pt(50, 0))
		newZigZagPath(o).Stroke(width, sty).Add(o)
		paint.Fill(o, red)

			p.Stroke(2, clip.StrokeStyle{}).Add(o)
			paint.Fill(o, black)
		}
		newZigZagPath(o).Stroke(2, clip.StrokeStyle{}).Add(o)
		paint.Fill(o, black)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
@@ -352,3 +266,157 @@ func TestStrokedPathZeroWidth(t *testing.T) {
		r.expect(65, 50, colornames.White)
	})
}

func TestDashedPathFlatCapEllipse(t *testing.T) {
	run(t, func(o *op.Ops) {
		const width = 10
		sty := clip.StrokeStyle{
			Cap:   clip.FlatCap,
			Join:  clip.BevelJoin,
			Miter: float32(math.Inf(+1)),
			Line: clip.DashStyle{
				Dashes: []float32{5, 3},
			},
		}
		paint.FillShape(
			o,
			colornames.Red,
			newEllipsePath(o).Stroke(width, sty),
		)
		paint.FillShape(
			o,
			colornames.Black,
			newEllipsePath(o).Stroke(2, clip.StrokeStyle{}),
		)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
		r.expect(0, 62, colornames.Red)
		r.expect(0, 65, colornames.Black)
	})
}

func TestDashedPathFlatCapZ(t *testing.T) {
	run(t, func(o *op.Ops) {
		const width = 10
		sty := clip.StrokeStyle{
			Cap:   clip.FlatCap,
			Join:  clip.BevelJoin,
			Miter: float32(math.Inf(+1)),
			Line: clip.DashStyle{
				Dashes: []float32{5, 3},
			},
		}
		paint.FillShape(
			o,
			colornames.Red,
			newZigZagPath(o).Stroke(width, sty),
		)
		paint.FillShape(
			o,
			colornames.Black,
			newZigZagPath(o).Stroke(2, clip.StrokeStyle{}),
		)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
		r.expect(40, 10, colornames.Black)
		r.expect(40, 12, colornames.Red)
		r.expect(46, 12, colornames.White)
	})
}

func TestDashedPathFlatCapZNoDash(t *testing.T) {
	run(t, func(o *op.Ops) {
		const width = 10
		sty := clip.StrokeStyle{
			Cap:   clip.FlatCap,
			Join:  clip.BevelJoin,
			Miter: float32(math.Inf(+1)),
			Line: clip.DashStyle{
				Offset: 1,
			},
		}
		paint.FillShape(
			o,
			colornames.Red,
			newZigZagPath(o).Stroke(width, sty),
		)
		paint.FillShape(
			o,
			colornames.Black,
			newZigZagPath(o).Stroke(2, clip.StrokeStyle{}),
		)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
		r.expect(40, 10, colornames.Black)
		r.expect(40, 12, colornames.Red)
		r.expect(46, 12, colornames.Red)
	})
}

func TestDashedPathFlatCapZNoPath(t *testing.T) {
	run(t, func(o *op.Ops) {
		const width = 10
		sty := clip.StrokeStyle{
			Cap:   clip.FlatCap,
			Join:  clip.BevelJoin,
			Miter: float32(math.Inf(+1)),
			Line: clip.DashStyle{
				Dashes: []float32{0},
			},
		}
		paint.FillShape(
			o,
			colornames.Red,
			newZigZagPath(o).Stroke(width, sty),
		)
		paint.FillShape(
			o,
			colornames.Black,
			newZigZagPath(o).Stroke(2, clip.StrokeStyle{}),
		)

	}, func(r result) {
		r.expect(0, 0, colornames.White)
		r.expect(40, 10, colornames.Black)
		r.expect(40, 12, colornames.White)
		r.expect(46, 12, colornames.White)
	})
}

func newStrokedPath(o *op.Ops) *clip.Path {
	p := new(clip.Path)
	p.Begin(o)
	p.Move(f32.Pt(10, 50))
	p.Line(f32.Pt(10, 0))
	p.Arc(f32.Pt(10, 0), f32.Pt(20, 0), math.Pi)
	p.Line(f32.Pt(10, 0))
	p.Line(f32.Pt(10, 10))
	p.Arc(f32.Pt(0, 30), f32.Pt(0, 30), 2*math.Pi)
	p.Line(f32.Pt(-20, 0))
	p.Quad(f32.Pt(-10, -10), f32.Pt(-30, 30))
	return p
}

func newZigZagPath(o *op.Ops) *clip.Path {
	p := new(clip.Path)
	p.Begin(o)
	p.Move(f32.Pt(40, 10))
	p.Line(f32.Pt(50, 0))
	p.Line(f32.Pt(-50, 50))
	p.Line(f32.Pt(50, 0))
	p.Quad(f32.Pt(-50, 20), f32.Pt(-50, 50))
	p.Line(f32.Pt(50, 0))
	return p
}

func newEllipsePath(o *op.Ops) *clip.Path {
	p := new(clip.Path)
	p.Begin(o)
	p.Move(f32.Pt(0, 65))
	p.Line(f32.Pt(20, 0))
	p.Arc(f32.Pt(20, 0), f32.Pt(70, 0), 2*math.Pi)
	return p
}
diff --git a/internal/rendertest/refs/TestDashedPathFlatCapEllipse.png b/internal/rendertest/refs/TestDashedPathFlatCapEllipse.png
new file mode 100644
index 0000000000000000000000000000000000000000..03843489480091ad0ef5e3bc219b5c666ac22324
GIT binary patch
literal 6107
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
NR8Cd4>YZ8W{{ZkuvV{Nu

literal 0
HcmV?d00001

diff --git a/internal/rendertest/refs/TestDashedPathFlatCapZ.png b/internal/rendertest/refs/TestDashedPathFlatCapZ.png
new file mode 100644
index 0000000000000000000000000000000000000000..10695941055117b058a526352e31bc4ca1b90ce6
GIT binary patch
literal 3681
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

literal 0
HcmV?d00001

diff --git a/internal/rendertest/refs/TestDashedPathFlatCapZNoDash.png b/internal/rendertest/refs/TestDashedPathFlatCapZNoDash.png
new file mode 100644
index 0000000000000000000000000000000000000000..23a13c3b1013304c74b3b15320a9995aa346b9b8
GIT binary patch
literal 2262
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

literal 0
HcmV?d00001

diff --git a/internal/rendertest/refs/TestDashedPathFlatCapZNoPath.png b/internal/rendertest/refs/TestDashedPathFlatCapZNoPath.png
new file mode 100644
index 0000000000000000000000000000000000000000..35158fc4aaefe26987d01f41fd416efac1864310
GIT binary patch
literal 1553
zcma)+eKga19LK-E&Db>$i>*ARNF^1DBUhPamXgOPJup*}E5b!IYQLgXZrtwaQJdi&
zl_3*04`H#I4jQ-QIZuVy%=6PPxx4q@`_Db+{r-IZ`JDIpoY(oB&n-`PXLVIQRRDnc
zp@U9G6dnG@Sd^l=`pvTdz;qmP+V2z3lCs@yYK0pJR}oV8L2a*Oo#@@Sd8sKHYu8s2
z*RYL^ZyFn`O7YnTGo|?b_*?Adjg^E}fr(GdO_v=FV_G^?y+?&N%?ol$?B0uOUa2?m
zXZgtVr|+DizHK4Wr3Iu}t>k?{nxv@-R!Xnsso>Y^0^R@Uaa6%|@!Pk&2R|@#4F3<-
z7QXkKg_^G}&Ac5VwxXImlU|HZ#T$UZsl^97Pl<?E>%x%A7YPn<3)CXRt%sG7LFFt_
zO%^m-Y;hWJrMPvl4RRWj)lx%-MoTSRK&TA21`bBPt7P?%MxarFBpPgyM(e{wBvF~(
zKuUo+UP?}ZVQDlGrXt^!=pRX2p^h>M1w55mZGdBuFqM8GsUBM7NkYK|sg*9g6_H~4
zn@DG%#S+O8U@o)LhJBGRrTz{Q8d~IgB!CH-_j<AtGOLUik#ZqXvBw#ZE%jbUwnkzw
z_!bfs5|w%!27xl~HRK><5mFMQ8<4xiY8z!${Yt;jJNGNaPxfDJ-_TfM$*9g0n?;g%
z+q7<F73{URbD(~R7=;SS7@kVAQR(LQ)iRmWUk*89LBYwgvAXNHufJZJ8*8Ig35f&t
zHv+%REUh35RjVuPy>X-~-^)LIqc?Bz1pe(C>=~u8<HzEP(#1pa&(&6L+Hqz^sF<qW
zFdO|_P6Vn^GRr=J?`OU@Ey=!JZ53ive^vyw7X^4=r;wG0=KkFF@eaxg5J1Z-Kk8F7
zdm9VyZ}+8DGs@aR12yNgZPn-*!6Z%}KV07^=*?}S?+d@jESAV{EMQ<KInT!~W)u%h
zZ~yTwNbipFywi-(0}d{^G~A>+&DP%Pp13rPNyx#xroaP{j~c&zDmZ}#RTpIpWPayn
zUt8SOCBSiDlHw;f8ky**1($Tbro;BF%%lO3^t!*R>^zr?BAeaMS&dosKCbSW=QA(s
ze#RYp>h?tSI*KyF`kA**__DyyToBf|8`xf6p8fcdFz>XAdy6XVmid@bu_pu<dS^x?
zoxM}eJVKVAxxAy8;&pG6bDFEGD?NQCfxbnUy3WGdZBw$}tN6PWhuHx_GH85xp{E=I
zSIPaICFcxS7lSDYpx*MYZ`MXZGsDds(&akCkp)UDe5ID-k_+gPO#9zx!xCII6yxt<
zqILM8`JDrQ$}Z|BupBVC3Z_?#?`}*Mo!%wiK%COjL#w4w7MiqE-;^`MQY!&J{M>F`
zs#i{{X&+L9i-jh7Dina?<iRWUH!zOA@af{`rY<I0*|JYdoHJ4mHl`)Snjg_e%k!bt
zB<gCU6pN>@r9~y|q+c?gxwDri2kHw?2}H%3v->jQ7bg525AJ?kx&xQ6)+2k6;FI50
zHxOy5lej#a*C!VnioGXmTRNjy3P5po>=?V98JDvL2(!y`=Z_vuH?%kFdMCy#VJ+Qe
zdMctpq_kt#LiX8KvWDl5wesGohpt;F+4u>Q<8<XngOTa4jer*?r-^n&zE+!@8WKO*
z6Jh9ZrRVZ<H&;vazDv`h>dtGnXuA*xmC;H=)$7)7+X<>9j^<fqJ@Q_ZOYV{UHoL?{
zUc>w@G$EA=I?AIqeFFDjZDACvTM%JEdlfJ1Dl<j5pxqV6wKUe2k5<trm)NALxx*2C
zHIVRzy1wVJXR0hvcCvW|2KU<XP%~Jp63R5+8{-Za1=OO1iWR;79cc?Cf(JWJTzhGE
zl&<Vaz?_Zc3M;k+6dnujrj>@=<#Lnp-F)?W02~_@7s~^6NSp`;BLknq4Hb093Zj@Q
zIzTE1Fqiw_Yy~s3Ipj=SV{1Nv8nwr;TC|eiS}RW**lg=8oEvZd$!HoYOtf+zyvFpV
jMFxSO|F0h!0IaS`<Ss#danuRLs|Sau?oKZp>DT@S{qNv8

literal 0
HcmV?d00001

diff --git a/op/clip/clip.go b/op/clip/clip.go
index 2cd9caf..f33ef3a 100644
--- a/op/clip/clip.go
+++ b/op/clip/clip.go
@@ -45,7 +45,7 @@ type Op struct {

func (p Op) Add(o *op.Ops) {
	p.call.Add(o)
	data := o.Write(opconst.TypeClipLen)
	data := o.Write(opconst.TypeClipLen + len(p.style.Line.Dashes)*4)
	data[0] = byte(opconst.TypeClip)
	bo := binary.LittleEndian
	bo.PutUint32(data[1:], uint32(p.bounds.Min.X))
@@ -56,6 +56,12 @@ func (p Op) Add(o *op.Ops) {
	data[21] = uint8(p.style.Cap)
	data[22] = uint8(p.style.Join)
	bo.PutUint32(data[23:], math.Float32bits(p.style.Miter))
	bo.PutUint32(data[27:], math.Float32bits(p.style.Line.Offset))
	data[31] = uint8(len(p.style.Line.Dashes))
	dashes := data[32:]
	for i, v := range p.style.Line.Dashes {
		bo.PutUint32(dashes[i*4:], math.Float32bits(v))
	}
}

// Begin the path, storing the path data and final Op into ops.
diff --git a/op/clip/stroke.go b/op/clip/stroke.go
index e22f58b..7bf7929 100644
--- a/op/clip/stroke.go
+++ b/op/clip/stroke.go
@@ -4,7 +4,7 @@ package clip

// StrokeStyle describes how a stroked path should be drawn.
// The zero value of StrokeStyle represents bevel-joined and flat-capped
// strokes.
// strokes with a solid line.
type StrokeStyle struct {
	Cap  StrokeCap
	Join StrokeJoin
@@ -13,6 +13,8 @@ type StrokeStyle struct {
	// The zero Miter disables the miter joint; setting Miter to +∞
	// unconditionally enables the miter joint.
	Miter float32

	Line DashStyle // Line defines the stroked path line.
}

// StrokeCap describes the head or tail of a stroked path.
@@ -44,3 +46,10 @@ const (
	// RoundJoin joins path segments with a round segment.
	RoundJoin
)

// DashStyle describes how a stroked path line should be drawn.
// DashStyle zero value draws a solid line.
type DashStyle struct {
	Offset float32   // Offset before the dash pattern.
	Dashes []float32 // Dashes is the sequence of lengths of [dash,space,...]
}
-- 
2.29.2
Reply to thread Export thread (mbox)