~eliasnaur/gio-patches

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
9 3

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

Details
Message ID
<8wQQVjIakbBINcvjPRpOEjBC6cQm0dzwG19CqOiRJ0@cp3-web-021.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 6637bd6..98fdabc 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 5d713db..6bb08b4 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 f780534..68306d2 100644
--- a/internal/rendertest/clip_test.go
+++ b/internal/rendertest/clip_test.go
@@ -111,17 +111,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, colornames.Red)
	}, func(r result) {
@@ -138,17 +128,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, colornames.Red)
	}, func(r result) {
@@ -165,17 +145,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, colornames.Red)
	}, func(r result) {
@@ -192,17 +162,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, colornames.Red)
	}, func(r result) {
@@ -220,34 +180,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, colornames.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, colornames.Red)

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

	}, func(r result) {
		r.expect(0, 0, colornames.White)
@@ -265,34 +202,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, colornames.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, colornames.Red)

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

	}, func(r result) {
		r.expect(0, 0, colornames.White)
@@ -332,3 +246,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 a90e7b3..90e4631 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

[gio/patches] build success

builds.sr.ht
Details
Message ID
<C72B63BUITTX.3A5V7NRP6NG52@cirno>
In-Reply-To
<8wQQVjIakbBINcvjPRpOEjBC6cQm0dzwG19CqOiRJ0@cp3-web-021.plabs.ch> (view parent)
DKIM signature
missing
Download raw message
gio/patches: SUCCESS in 19m5s

[gpu,op/clip: implement stroked paths with dashed lines][0] v2 from [Sebastien Binet][1]

[0]: https://lists.sr.ht/~eliasnaur/gio-patches/patches/14980
[1]: mailto:s@sbinet.org

✓ #341602 SUCCESS gio/patches/linux.yml   https://builds.sr.ht/~eliasnaur/job/341602
✓ #341601 SUCCESS gio/patches/freebsd.yml https://builds.sr.ht/~eliasnaur/job/341601
✓ #341603 SUCCESS gio/patches/openbsd.yml https://builds.sr.ht/~eliasnaur/job/341603
✓ #341600 SUCCESS gio/patches/apple.yml   https://builds.sr.ht/~eliasnaur/job/341600
Details
Message ID
<C72V9L5QIIH4.39HOHFANTOQ09@testmac>
In-Reply-To
<8wQQVjIakbBINcvjPRpOEjBC6cQm0dzwG19CqOiRJ0@cp3-web-021.plabs.ch> (view parent)
DKIM signature
pass
Download raw message
On Fri Nov 13, 2020 at 6:03 PM CET, Sebastien Binet wrote:
> 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
> +
> +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

return strokeQuads{} ?

> +	}
> +
> diff --git a/gpu/gpu.go b/gpu/gpu.go
> index 6637bd6..98fdabc 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/internal/ops/reader.go b/internal/ops/reader.go
> index 5d713db..6bb08b4 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.

If we're making clip operations dynamically sized, I think you should
make the rest of the stroke data optional as well.

> +			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/op/clip/stroke.go b/op/clip/stroke.go
> index e22f58b..7bf7929 100644
> --- a/op/clip/stroke.go
> +++ b/op/clip/stroke.go
> @@ -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.

Is this the same as an initial space? If so, can it be merged with
Dashes below?

> +	Dashes []float32 // Dashes is the sequence of lengths of [dash,space,...]

This forces an allocation on the user of DashStyle. It should be
recorded in the ops slice just like Path.

> +}
Details
Message ID
<6Ykf2JOIc0b0T2mD4CTEu5qtNjXs4870qRiWBpIgleJbf2IsMd0nihbdda0F-X4w4DMoZ102kBC2nK4L7wk00bV07nTLwrMd2Mi7vwghmQ4=@sbinet.org>
In-Reply-To
<C72V9L5QIIH4.39HOHFANTOQ09@testmac> (view parent)
DKIM signature
missing
Download raw message
> On Fri Nov 13, 2020 at 6:03 PM CET, Sebastien Binet wrote:
> > 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
> > +
> > +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
>
> return strokeQuads{} ?

ok.

>
> > +	}
> > +
> > diff --git a/gpu/gpu.go b/gpu/gpu.go
> > index 6637bd6..98fdabc 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/internal/ops/reader.go b/internal/ops/reader.go
> > index 5d713db..6bb08b4 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.
>
> If we're making clip operations dynamically sized, I think you should
> make the rest of the stroke data optional as well.

so, stroke-width, stroke-cap, stroke-join, stroke-miter and the rest of the dash-style struct?

>
> > +			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/op/clip/stroke.go b/op/clip/stroke.go
> > index e22f58b..7bf7929 100644
> > --- a/op/clip/stroke.go
> > +++ b/op/clip/stroke.go
> > @@ -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.
>
> Is this the same as an initial space? If so, can it be merged with
> Dashes below?

if Offset >0, yes it's an initial space (ie: shifts the pattern to the right)
otherwise, it shifts the dash pattern to the right.

having Offset simplifies the logic of interpreting the dash pattern and eases
the job on the user side to reason on and build the dash pattern.

>
> > +	Dashes []float32 // Dashes is the sequence of lengths of [dash,space,...]
>
> This forces an allocation on the user of DashStyle. It should be
> recorded in the ops slice just like Path.

you mean something along the lines of:

package clip

type Dashes struct {
   ops   *op.Ops
   macro op.MacroOp
   size  int
}

func (ds *Dashes) Offset(ops *op.Ops, offset float32) {
   ds.ops = ops
   ds.macro = op.Record(ops)
   data := ops.Write(opconst.TypeDashesAuxLen)
   data[0] = byte(opconst.TypeDashesAux)
   bo := binary.LittleEndian
   bo.PutUint32(data[1:], math.Float32bits(offset))
}

func (ds *Dashes) Dash(v float32) {
    data := ds.ops.Write(ops.DashSize)
    bo := binary.LittleEndian
    bo.PutUint32(data[0:], math.Float32bits(v))
    ds.size++
}

func (ds *Dashes) Dashes() DashesOp {
   c := ds.macro.Stop()
   return DashesOp{
      call: c,
      size: ds.size,
   }
}

type DashesOp struct {
    call op.CallOp
    size int
}

func (ds DashesOp) Add(o *op.Ops) {
    data := o.Write(opconst.TypeDashesLen+(ds.size+1)*4)
    data[0] = byte(byte(opconst.TypeDashes)
    ds.call.Add(o)
}

do you ?

and then:

func f(o *op.Ops) {
   var ds clip.Dashes
   ds.Offset(o, -3)
   ds.Dash(5)
   ds.Dash(3)
   line := ds.Dashes()

   var p clip.Path
   p.Begin(o)
   p.Line(f32.Pt(10,10))
   p.Stroke(width, clip.StrokeStyle{Line: line}).Add(o)
}

-s
Details
Message ID
<C73RPW2K66HQ.1XICGQTM9O2T8@themachine>
In-Reply-To
<6Ykf2JOIc0b0T2mD4CTEu5qtNjXs4870qRiWBpIgleJbf2IsMd0nihbdda0F-X4w4DMoZ102kBC2nK4L7wk00bV07nTLwrMd2Mi7vwghmQ4=@sbinet.org> (view parent)
DKIM signature
pass
Download raw message
With the addition of DashesOp it seems the clip.Op is becoming a bit
convoluted. I suggest we split up clipping similar to how painting is
split between setting the brush (ImageOp, ColorOp) and applying it
(PaintOp).

For clip, there is the defining of the path, filling it (path, stroke,
stroke style, dashes), and applying the clip.

In other words, I suggest

	package clip

	// Op sets the clip to the intersection of the existing clip with the
	// clip specified by the current outline or stroke.
	type Op struct {
	}

	// StrokeOp applies a stroke to the current path.
	type StrokeOp struct {
		Cap StrokeCap
		Join StrokeJoin
		Miter float32
	}

	// OutlineOp fills the the current path according to the non-zero
	// winding rule.
	type OutlineOp struct {
	}

	// PathOp sets the current path. Construct PathOps with Path.
	type PathOp struct {
	}

	// DashOp configures dashing for a stroked path. Construct a DashOp
	// with Dashes and use it in combination with StrokeOp.
	type DashOp struct {
	}

	// Dash records dashes and spaces for a stroked path.
	type Dash struct {
	}

	func (d *Dash) Begin(ops *op.Ops)
	func (d *Dash) Dash(length float32)
	func (d *Dash) Space(length float32)
	func (d *Dash) Op() DashOp

StrokeStyle will be gone. Path will no longer have Outline nor
Stroke; they're replaced with Op:

	// Op completes the path and set the current path.
	func (p *Path) Op() PathOp

WDYT? I know this is a non-trivial refactor, but I don't think the
current API can sustain much more overloading as is.

Elias


On Sat Nov 14, 2020 at 10:43, Sebastien Binet wrote:
> > On Fri Nov 13, 2020 at 6:03 PM CET, Sebastien Binet wrote:
> > > Signed-off-by: Sebastien Binet <s@sbinet.org>
> >
> > > +	}
> > > +
> > > diff --git a/gpu/gpu.go b/gpu/gpu.go
> > > index 6637bd6..98fdabc 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/internal/ops/reader.go b/internal/ops/reader.go
> > > index 5d713db..6bb08b4 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.
> >
> > If we're making clip operations dynamically sized, I think you should
> > make the rest of the stroke data optional as well.
>
> so, stroke-width, stroke-cap, stroke-join, stroke-miter and the rest of the dash-style struct?
>

Yes. However, see above.

> >
> > > +			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/op/clip/stroke.go b/op/clip/stroke.go
> > > index e22f58b..7bf7929 100644
> > > --- a/op/clip/stroke.go
> > > +++ b/op/clip/stroke.go
> > > @@ -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.
> >
> > Is this the same as an initial space? If so, can it be merged with
> > Dashes below?
>
> if Offset >0, yes it's an initial space (ie: shifts the pattern to the right)
> otherwise, it shifts the dash pattern to the right.
>
> having Offset simplifies the logic of interpreting the dash pattern and eases
> the job on the user side to reason on and build the dash pattern.
>
> >
> > > +	Dashes []float32 // Dashes is the sequence of lengths of [dash,space,...]
> >
> > This forces an allocation on the user of DashStyle. It should be
> > recorded in the ops slice just like Path.
>
> you mean something along the lines of:
>
> package clip
>
> type Dashes struct {
>    ops   *op.Ops
>    macro op.MacroOp
>    size  int
> }
>
> func (ds *Dashes) Offset(ops *op.Ops, offset float32) {

Offset is an overloaded name. Have about "Space"?
Details
Message ID
<2FsO9gsdrptDbN6fOcwdtbasDr0JTw2H0nSFyvlyJR9w_ikV8gnLpf3A35nMSrsmuzBQ9QS62LoJP1XwhxHMio401PkiEKYkZ8_8vBhF_Ew=@sbinet.org>
In-Reply-To
<C73RPW2K66HQ.1XICGQTM9O2T8@themachine> (view parent)
DKIM signature
missing
Download raw message
On Sunday, November 15th, 2020 at 11:33 AM, Elias Naur <mail@eliasnaur.com> wrote:

> With the addition of DashesOp it seems the clip.Op is becoming a bit
> convoluted. I suggest we split up clipping similar to how painting is
> split between setting the brush (ImageOp, ColorOp) and applying it
> (PaintOp).
>
> For clip, there is the defining of the path, filling it (path, stroke,
> stroke style, dashes), and applying the clip.
>
> In other words, I suggest
>
> 	package clip
>
> 	// Op sets the clip to the intersection of the existing clip with the
> 	// clip specified by the current outline or stroke.
> 	type Op struct {
> 	}
>
> 	// StrokeOp applies a stroke to the current path.
> 	type StrokeOp struct {
> 		Cap StrokeCap
> 		Join StrokeJoin
> 		Miter float32
> 	}
>
> 	// OutlineOp fills the the current path according to the non-zero
> 	// winding rule.
> 	type OutlineOp struct {
> 	}
>
> 	// PathOp sets the current path. Construct PathOps with Path.
> 	type PathOp struct {
> 	}
>
> 	// DashOp configures dashing for a stroked path. Construct a DashOp
> 	// with Dashes and use it in combination with StrokeOp.
> 	type DashOp struct {
> 	}
>
> 	// Dash records dashes and spaces for a stroked path.
> 	type Dash struct {
> 	}
>
> 	func (d *Dash) Begin(ops *op.Ops)
> 	func (d *Dash) Dash(length float32)
> 	func (d *Dash) Space(length float32)
> 	func (d *Dash) Op() DashOp
>
> StrokeStyle will be gone. Path will no longer have Outline nor
> Stroke; they're replaced with Op:
>
> 	// Op completes the path and set the current path.
> 	func (p *Path) Op() PathOp
>
> WDYT? I know this is a non-trivial refactor, but I don't think the
> current API can sustain much more overloading as is.

I am not sure I see how everything fits together (yet) but I'll give it a try.
and it's better to fix the API now than later.

thanks for the suggestions.

[...]

> > type Dashes struct {
> >    ops   *op.Ops
> >    macro op.MacroOp
> >    size  int
> > }
> >
> > func (ds *Dashes) Offset(ops *op.Ops, offset float32) {
>
> Offset is an overloaded name. Have about "Space"?

true, but... Space is also a bit overloaded :)
as a matter of fact, perusing your proposed API above I thought you meant to replace
 "my" Dash(float32) with the pair "Dash(float32) / Space(float32)" (one for the dash, the
other for the space) :)

I'd propose to replace "Space" with "Phase" which -in my view- better describes what it's about.

-s
Details
Message ID
<xGhnMHkYKEHps40wlWTyOo7CSu2LAZFO-FepnFJrarPnpliZQx3aFa3-QbH-UlfNrKe_3-XHOmEX4C1We-LzwcL_b3Jr5tbfEXsbpC1fBB8=@sbinet.org>
In-Reply-To
<2FsO9gsdrptDbN6fOcwdtbasDr0JTw2H0nSFyvlyJR9w_ikV8gnLpf3A35nMSrsmuzBQ9QS62LoJP1XwhxHMio401PkiEKYkZ8_8vBhF_Ew=@sbinet.org> (view parent)
DKIM signature
missing
Download raw message
‐‐‐‐‐‐‐ Original Message ‐‐‐‐‐‐‐

>
> On Sunday, November 15th, 2020 at 11:33 AM, Elias Naur <mail@eliasnaur.com> wrote:
>
> > With the addition of DashesOp it seems the clip.Op is becoming a bit
> > convoluted. I suggest we split up clipping similar to how painting is
> > split between setting the brush (ImageOp, ColorOp) and applying it
> > (PaintOp).
> >
> > For clip, there is the defining of the path, filling it (path, stroke,
> > stroke style, dashes), and applying the clip.
> >
> > In other words, I suggest
> >
> > 	package clip
> >
> > 	// Op sets the clip to the intersection of the existing clip with the
> > 	// clip specified by the current outline or stroke.
> > 	type Op struct {
> > 	}
> >
> > 	// StrokeOp applies a stroke to the current path.
> > 	type StrokeOp struct {
> > 		Cap StrokeCap
> > 		Join StrokeJoin
> > 		Miter float32
> > 	}
> >
> > 	// OutlineOp fills the the current path according to the non-zero
> > 	// winding rule.
> > 	type OutlineOp struct {
> > 	}
> >
> > 	// PathOp sets the current path. Construct PathOps with Path.
> > 	type PathOp struct {
> > 	}
> >
> > 	// DashOp configures dashing for a stroked path. Construct a DashOp
> > 	// with Dashes and use it in combination with StrokeOp.
> > 	type DashOp struct {
> > 	}
> >
> > 	// Dash records dashes and spaces for a stroked path.
> > 	type Dash struct {
> > 	}
> >
> > 	func (d *Dash) Begin(ops *op.Ops)
> > 	func (d *Dash) Dash(length float32)
> > 	func (d *Dash) Space(length float32)
> > 	func (d *Dash) Op() DashOp
> >
> > StrokeStyle will be gone. Path will no longer have Outline nor
> > Stroke; they're replaced with Op:
> >
> > 	// Op completes the path and set the current path.
> > 	func (p *Path) Op() PathOp
> >
> > WDYT? I know this is a non-trivial refactor, but I don't think the
> > current API can sustain much more overloading as is.
>
> I am not sure I see how everything fits together (yet) but I'll give it a try.
> and it's better to fix the API now than later.
>
> thanks for the suggestions.

reaching out for a few pointers/suggestions to solidify a bit what I've been writing so far.

let's take the example of clip.Border and clip.RRect.

right now, we have the following for clip.Border:

// Op returns the Op for the border.
func (b Border) Op(ops *op.Ops) Op {
        var p Path
        p.Begin(ops)

        p.Move(b.Rect.Min)
        roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)

        return p.Stroke(b.Width, b.Style)
}

under the new API, I believe it would look like that:

func (b Border) Op(ops *op.Ops) Op {
        var p Path
        p.Begin(ops)

        p.Move(b.Rect.Min)
        roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)
        p.Close()
        p.Op().Add(ops)

        StrokeOp{Width: b.Width}.Add(ops)

        var dash Dash
        dash.Begin(ops)
        dash.Phase(0)
        dash.Dash(2)
        dash.Dash(4)
        dash.Op().Add(ops)

        return Op{}
}

and, for clip.RRect:

// Op returns the op for the rounded rectangle.
func (rr RRect) Op(ops *op.Ops) Op {
        var p Path
        p.Begin(ops)
        p.Move(rr.Rect.Min)
        roundRect(&p, rr.Rect.Size(), rr.SE, rr.SW, rr.NW, rr.NE)
        p.Close()
        p.Op().Add(ops)

        OutlineOp{}.Add(ops)

        return Op{}
}


and then, inside gpu.drawOps.collectOps, have these variables:
var (
    outline = false
    stroke  strokeOp
    dash    dashOp
    aux     []byte
    auxKey  ops.Key
)

these vars would be reset to their zero value after the end of the TypeClip processing case.

also, I am unclear on whether one should do:
// PathOp sets the current path. Construct PathOps with Path.
type PathOp struct {
        call op.CallOp
}

func (op PathOp) Add(o *op.Ops) {
        op.call.Add(o)
        data := o.Write(opconst.TypePathLen)
        data[0] = byte(opconst.TypePath)
}

or:

func (op PathOp) Add(o *op.Ops) {
        data := o.Write(opconst.TypePathLen)
        data[0] = byte(opconst.TypePath)
        op.call.Add(o) // "inside" TypePath scope so we can decode quads together w/ PathOp ?
}

WDYT?

-s
Details
Message ID
<C74T7EWHGLKR.3NLQSOAV8MDGT@themachine>
In-Reply-To
<xGhnMHkYKEHps40wlWTyOo7CSu2LAZFO-FepnFJrarPnpliZQx3aFa3-QbH-UlfNrKe_3-XHOmEX4C1We-LzwcL_b3Jr5tbfEXsbpC1fBB8=@sbinet.org> (view parent)
DKIM signature
pass
Download raw message
On Mon Nov 16, 2020 at 14:23, Sebastien Binet wrote:
>
> ‐‐‐‐‐‐‐ Original Message ‐‐‐‐‐‐‐
>
> >
> > On Sunday, November 15th, 2020 at 11:33 AM, Elias Naur <mail@eliasnaur.com> wrote:
> >
> > > With the addition of DashesOp it seems the clip.Op is becoming a bit
> > > convoluted. I suggest we split up clipping similar to how painting is
> > > split between setting the brush (ImageOp, ColorOp) and applying it
> > > (PaintOp).
> > >
> > > For clip, there is the defining of the path, filling it (path, stroke,
> > > stroke style, dashes), and applying the clip.
> > >
> > > In other words, I suggest
> > >
> > > 	package clip
> > >
> > > 	// Op sets the clip to the intersection of the existing clip with the
> > > 	// clip specified by the current outline or stroke.
> > > 	type Op struct {
> > > 	}
> > >
> > > 	// StrokeOp applies a stroke to the current path.
> > > 	type StrokeOp struct {
> > > 		Cap StrokeCap
> > > 		Join StrokeJoin
> > > 		Miter float32
> > > 	}
> > >
> > > 	// OutlineOp fills the the current path according to the non-zero
> > > 	// winding rule.
> > > 	type OutlineOp struct {
> > > 	}
> > >
> > > 	// PathOp sets the current path. Construct PathOps with Path.
> > > 	type PathOp struct {
> > > 	}
> > >
> > > 	// DashOp configures dashing for a stroked path. Construct a DashOp
> > > 	// with Dashes and use it in combination with StrokeOp.
> > > 	type DashOp struct {
> > > 	}
> > >
> > > 	// Dash records dashes and spaces for a stroked path.
> > > 	type Dash struct {
> > > 	}
> > >
> > > 	func (d *Dash) Begin(ops *op.Ops)
> > > 	func (d *Dash) Dash(length float32)
> > > 	func (d *Dash) Space(length float32)
> > > 	func (d *Dash) Op() DashOp
> > >
> > > StrokeStyle will be gone. Path will no longer have Outline nor
> > > Stroke; they're replaced with Op:
> > >
> > > 	// Op completes the path and set the current path.
> > > 	func (p *Path) Op() PathOp
> > >
> > > WDYT? I know this is a non-trivial refactor, but I don't think the
> > > current API can sustain much more overloading as is.
> >
> > I am not sure I see how everything fits together (yet) but I'll give it a try.
> > and it's better to fix the API now than later.
> >
> > thanks for the suggestions.
>
> reaching out for a few pointers/suggestions to solidify a bit what I've been writing so far.
>
> let's take the example of clip.Border and clip.RRect.
>
> right now, we have the following for clip.Border:
>
> // Op returns the Op for the border.
> func (b Border) Op(ops *op.Ops) Op {
>         var p Path
>         p.Begin(ops)
>
>         p.Move(b.Rect.Min)
>         roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)
>
>         return p.Stroke(b.Width, b.Style)
> }
>
> under the new API, I believe it would look like that:
>
> func (b Border) Op(ops *op.Ops) Op {
>         var p Path
>         p.Begin(ops)
>
>         p.Move(b.Rect.Min)
>         roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)
>         p.Close()

It seems easy to forget Close. Perhaps outlines can be closed
automatically?

>         p.Op().Add(ops)
>
>         StrokeOp{Width: b.Width}.Add(ops)
>
>         var dash Dash
>         dash.Begin(ops)
>         dash.Phase(0)
>         dash.Dash(2)
>         dash.Dash(4)

Dash seems complicated. How common is adjustable individual space and
dash lengths in other 2D APIs? If you really need it, will multiple
stroked Paths each with simpler dash setups suffice?

>         dash.Op().Add(ops)
>
>         return Op{}
> }
>
> and, for clip.RRect:
>
> // Op returns the op for the rounded rectangle.
> func (rr RRect) Op(ops *op.Ops) Op {
>         var p Path
>         p.Begin(ops)
>         p.Move(rr.Rect.Min)
>         roundRect(&p, rr.Rect.Size(), rr.SE, rr.SW, rr.NW, rr.NE)
>         p.Close()
>         p.Op().Add(ops)
>
>         OutlineOp{}.Add(ops)
>
>         return Op{}

The return value is always the same, so why have it? If we continue down
this path, Op should return a CallOp that contains all of the operations.

> }
>
>
> and then, inside gpu.drawOps.collectOps, have these variables:
> var (
>     outline = false
>     stroke  strokeOp
>     dash    dashOp
>     aux     []byte
>     auxKey  ops.Key
> )
>
> these vars would be reset to their zero value after the end of the TypeClip processing case.
>

I'd expected the new state to belong in drawState, so they're zeroed
after a Pop. In fact, I'm not sure why aux and auxKey are local
variables; probably because the encoding guarantees aux data is always
followed by a TypeClip (to be TypePath).

> also, I am unclear on whether one should do:
> // PathOp sets the current path. Construct PathOps with Path.
> type PathOp struct {
>         call op.CallOp
> }
>
> func (op PathOp) Add(o *op.Ops) {
>         op.call.Add(o)
>         data := o.Write(opconst.TypePathLen)
>         data[0] = byte(opconst.TypePath)
> }
>
> or:
>
> func (op PathOp) Add(o *op.Ops) {
>         data := o.Write(opconst.TypePathLen)
>         data[0] = byte(opconst.TypePath)
>         op.call.Add(o) // "inside" TypePath scope so we can decode quads together w/ PathOp ?
> }
>

I don't know the answer. It seems like an implementation detail, so I'd
choose the encoding that is most convenient to decode.
Details
Message ID
<c6RxJDkza4ypd7sL-3Ths_8Z6WY_NNvYTeDXPL-sVTopswyZTSzPrgoJm1ZBO6QI47I6bPCvh_FE6PMM6X4-QPbDbxZZ4lty-A_8cPx39WM=@sbinet.org>
In-Reply-To
<C74T7EWHGLKR.3NLQSOAV8MDGT@themachine> (view parent)
DKIM signature
missing
Download raw message
On Monday, November 16th, 2020 at 4:56 PM, Elias Naur <mail@eliasnaur.com> wrote:

> On Mon Nov 16, 2020 at 14:23, Sebastien Binet wrote:
> >
> > ‐‐‐‐‐‐‐ Original Message ‐‐‐‐‐‐‐
> >
> > >
> > > On Sunday, November 15th, 2020 at 11:33 AM, Elias Naur <mail@eliasnaur.com> wrote:

[...]

> > under the new API, I believe it would look like that:
> >
> > func (b Border) Op(ops *op.Ops) Op {
> >         var p Path
> >         p.Begin(ops)
> >
> >         p.Move(b.Rect.Min)
> >         roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)
> >         p.Close()
>
> It seems easy to forget Close. Perhaps outlines can be closed
> automatically?

sure. (but it's also rather obvious on screen when it's missing)

but then one would need OutlineOp to somehow get a reference to clip.Path?
(but then PathOp would be useless)

the nice thing (if I may say so myself) with the Close method, is that one can
easily reuse a clip.PathOp to draw a shape w/ an outline and a stroke.

>
> >         p.Op().Add(ops)
> >
> >         StrokeOp{Width: b.Width}.Add(ops)
> >
> >         var dash Dash
> >         dash.Begin(ops)
> >         dash.Phase(0)
> >         dash.Dash(2)
> >         dash.Dash(4)
>
> Dash seems complicated. How common is adjustable individual space and
> dash lengths in other 2D APIs?

pretty much all the 2D APIs have this sort thing:
- svg: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset
- pdf: https://godoc.org/github.com/jung-kurt/gofpdf#Fpdf.SetDashPattern
- latex: (with pgftex and pgfsetdash) https://stuff.mit.edu/afs/athena/contrib/tex-contrib/beamer/pgf-1.01/doc/generic/pgf/version-for-tex4ht/en/pgfmanualse23.html
- png/...:
  - https://godoc.org/github.com/fogleman/gg#Context.SetDash
  - https://godoc.org/github.com/llgcode/draw2d#GraphicContext
and, "of course", gonum/plot/vg.Canvas (which uses or used all the above at some point).

> If you really need it, will multiple
> stroked Paths each with simpler dash setups suffice?

sorry, I am not sure I understand what you are suggesting.
(the Border+Dash example values were just that: example. I guess one can leave Border
without any Dash customization for the time being.)

>
> >         dash.Op().Add(ops)
> >
> >         return Op{}
> > }
> >
> > and, for clip.RRect:
> >
> > // Op returns the op for the rounded rectangle.
> > func (rr RRect) Op(ops *op.Ops) Op {
> >         var p Path
> >         p.Begin(ops)
> >         p.Move(rr.Rect.Min)
> >         roundRect(&p, rr.Rect.Size(), rr.SE, rr.SW, rr.NW, rr.NE)
> >         p.Close()
> >         p.Op().Add(ops)
> >
> >         OutlineOp{}.Add(ops)
> >
> >         return Op{}
>
> The return value is always the same, so why have it? If we continue down
> this path, Op should return a CallOp that contains all of the operations.

CallOp makes sense, will do.

>
> > }
> >
> >
> > and then, inside gpu.drawOps.collectOps, have these variables:
> > var (
> >     outline = false
> >     stroke  strokeOp
> >     dash    dashOp
> >     aux     []byte
> >     auxKey  ops.Key
> > )
> >
> > these vars would be reset to their zero value after the end of the TypeClip processing case.
> >
>
> I'd expected the new state to belong in drawState, so they're zeroed
> after a Pop. In fact, I'm not sure why aux and auxKey are local
> variables; probably because the encoding guarantees aux data is always
> followed by a TypeClip (to be TypePath).
>
> > also, I am unclear on whether one should do:
> > // PathOp sets the current path. Construct PathOps with Path.
> > type PathOp struct {
> >         call op.CallOp
> > }
> >
> > func (op PathOp) Add(o *op.Ops) {
> >         op.call.Add(o)
> >         data := o.Write(opconst.TypePathLen)
> >         data[0] = byte(opconst.TypePath)
> > }
> >
> > or:
> >
> > func (op PathOp) Add(o *op.Ops) {
> >         data := o.Write(opconst.TypePathLen)
> >         data[0] = byte(opconst.TypePath)
> >         op.call.Add(o) // "inside" TypePath scope so we can decode quads together w/ PathOp ?
> > }
> >
>
> I don't know the answer. It seems like an implementation detail, so I'd
> choose the encoding that is most convenient to decode.

ok, if none of the 2 options is fundamentally wrong, I'll see which one is the most
convenient to handle.

thanks,
-s
Details
Message ID
<C74UWHINHN5Z.1TE25W328C39T@themachine>
In-Reply-To
<c6RxJDkza4ypd7sL-3Ths_8Z6WY_NNvYTeDXPL-sVTopswyZTSzPrgoJm1ZBO6QI47I6bPCvh_FE6PMM6X4-QPbDbxZZ4lty-A_8cPx39WM=@sbinet.org> (view parent)
DKIM signature
pass
Download raw message
On Mon Nov 16, 2020 at 17:10, Sebastien Binet wrote:
>
> On Monday, November 16th, 2020 at 4:56 PM, Elias Naur <mail@eliasnaur.com> wrote:
>
> > On Mon Nov 16, 2020 at 14:23, Sebastien Binet wrote:
> > >
> > > ‐‐‐‐‐‐‐ Original Message ‐‐‐‐‐‐‐
> > >
> > > >
> > > > On Sunday, November 15th, 2020 at 11:33 AM, Elias Naur <mail@eliasnaur.com> wrote:
>
> [...]
>
> > > under the new API, I believe it would look like that:
> > >
> > > func (b Border) Op(ops *op.Ops) Op {
> > >         var p Path
> > >         p.Begin(ops)
> > >
> > >         p.Move(b.Rect.Min)
> > >         roundRect(&p, b.Rect.Size(), b.SE, b.SW, b.NW, b.NE)
> > >         p.Close()
> >
> > It seems easy to forget Close. Perhaps outlines can be closed
> > automatically?
>
> sure. (but it's also rather obvious on screen when it's missing)
>
> but then one would need OutlineOp to somehow get a reference to clip.Path?
> (but then PathOp would be useless)
>

OutlineOp was meant as an op for cancelling the effect of StrokeOp. It
can be omitted if we're ok with StrokeOp only being reset with a stack
pop. In other words, once you've added a StrokeOp you can no longer clip
to the outline of a path. That's ok with me.

> the nice thing (if I may say so myself) with the Close method, is that one can
> easily reuse a clip.PathOp to draw a shape w/ an outline and a stroke.
>

I meant that gpu.go would automatically close the path when used as an
outline, otherwise not. Then PathOp can be re-used as you suggest.

> >
> > >         p.Op().Add(ops)
> > >
> > >         StrokeOp{Width: b.Width}.Add(ops)
> > >
> > >         var dash Dash
> > >         dash.Begin(ops)
> > >         dash.Phase(0)
> > >         dash.Dash(2)
> > >         dash.Dash(4)
> >
> > Dash seems complicated. How common is adjustable individual space and
> > dash lengths in other 2D APIs?
>
> pretty much all the 2D APIs have this sort thing:
> - svg: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset
> - pdf: https://godoc.org/github.com/jung-kurt/gofpdf#Fpdf.SetDashPattern
> - latex: (with pgftex and pgfsetdash) https://stuff.mit.edu/afs/athena/contrib/tex-contrib/beamer/pgf-1.01/doc/generic/pgf/version-for-tex4ht/en/pgfmanualse23.html
> - png/...:
>   - https://godoc.org/github.com/fogleman/gg#Context.SetDash
>   - https://godoc.org/github.com/llgcode/draw2d#GraphicContext
> and, "of course", gonum/plot/vg.Canvas (which uses or used all the above at some point).
>

Got it, thanks.

> > If you really need it, will multiple
> > stroked Paths each with simpler dash setups suffice?
>
> sorry, I am not sure I understand what you are suggesting.
> (the Border+Dash example values were just that: example. I guess one can leave Border
> without any Dash customization for the time being.)
>

I was suggesting that complicated dash patterns could be drawn with
multiple paths and simpler DashOps. But I realize now that they can't.

I believe I understand Dash and Phase now. Perhaps we can reduce the
Dash builder to a constructor function:

	package clip

	func Dash(offset|phase float32, dashes ...float32) DashOp

?
Reply to thread Export thread (mbox)