From e71bf13c9a0e7b6944cda00e566bdb4f69c8f840 Mon Sep 17 00:00:00 2001 From: Sebastien Binet Date: Wed, 9 Dec 2020 09:59:46 +0000 Subject: [PATCH] gpu,op/clip: implement dashed stroked paths Signed-off-by: Sebastien Binet --- gpu/dash.go | 390 ++++++++++++++++++ gpu/gpu.go | 43 +- gpu/stroke.go | 45 +- internal/opconst/ops.go | 3 + internal/rendertest/clip_test.go | 193 +++++++++ .../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 .../refs/TestPaintClippedBorder.png | Bin 0 -> 582 bytes io/router/clipboard_test.go | 3 +- op/clip/clip.go | 10 + op/clip/shapes.go | 6 +- op/clip/stroke.go | 59 +++ 14 files changed, 745 insertions(+), 7 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 create mode 100644 internal/rendertest/refs/TestPaintClippedBorder.png diff --git a/gpu/dash.go b/gpu/dash.go new file mode 100644 index 00000000..06f0cbf8 --- /dev/null +++ b/gpu/dash.go @@ -0,0 +1,390 @@ +// 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" +) + +func isSolidLine(sty dashOp) bool { + return sty.phase == 0 && len(sty.dashes) == 0 +} + +func (qs strokeQuads) dash(sty dashOp) strokeQuads { + sty = dashCanonical(sty) + + switch { + case len(sty.dashes) == 0: + return qs + case len(sty.dashes) == 1 && sty.dashes[0] == 0.0: + return strokeQuads{} + } + + 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 dashOp) dashOp { + 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 dashOp{ + phase: 0.0, + dashes: []float32{0.0}, + } + } + o.phase -= 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 dashOp{} + } + o.phase += 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 dashOp{ + phase: 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 dashOp) (int, float32) { + i0 := 0 // i0 is the index into dashes. + for sty.dashes[i0] <= sty.phase { + sty.phase -= sty.dashes[i0] + i0++ + if i0 == len(sty.dashes) { + i0 = 0 + } + } + // pos0 may be negative if the offset lands halfway into dash. + pos0 := -sty.phase + if sty.phase < 0.0 { + var sum float32 + for _, d := range sty.dashes { + sum += d + } + pos0 = -(sum + sty.phase) // 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 60cf3f50..a5912bec 100644 --- a/gpu/gpu.go +++ b/gpu/gpu.go @@ -109,6 +109,23 @@ type imageOp struct { place placement } +type dashOp struct { + phase float32 + dashes []float32 +} + +func decodeDashOp(data []byte) dashOp { + _ = data[5] + if opconst.OpType(data[0]) != opconst.TypeDash { + panic("invalid op") + } + bo := binary.LittleEndian + return dashOp{ + phase: math.Float32frombits(bo.Uint32(data[1:])), + dashes: make([]float32, data[5]), + } +} + func decodeStrokeOp(data []byte) clip.StrokeStyle { _ = data[10] if opconst.OpType(data[0]) != opconst.TypeStroke { @@ -812,6 +829,7 @@ func (d *drawOps) collectOps(r *ops.Reader, state drawState) int { var ( quads quadsOp stroke clip.StrokeStyle + dashes dashOp ) loop: for encOp, ok := r.Decode(); ok; encOp, ok = r.Decode() { @@ -822,6 +840,22 @@ loop: dop := ops.DecodeTransform(encOp.Data) state.t = state.t.Mul(dop) + case opconst.TypeDash: + dashes = decodeDashOp(encOp.Data) + if len(dashes.dashes) > 0 { + encOp, ok = r.Decode() + if !ok { + panic("gpu: could not decode dashes pattern") + } + data := encOp.Data[1:] + bo := binary.LittleEndian + for i := range dashes.dashes { + dashes.dashes[i] = math.Float32frombits(bo.Uint32( + data[i*4:], + )) + } + } + case opconst.TypeStroke: stroke = decodeStrokeOp(encOp.Data) @@ -851,7 +885,9 @@ loop: // Why is this not used for the offset shapes? op.bounds = v.bounds } else { - quads.aux, op.bounds = d.buildVerts(quads.aux, trans, op.outline, stroke) + quads.aux, op.bounds = d.buildVerts( + quads.aux, trans, op.outline, stroke, dashes, + ) // add it to the cache, without GPU data, so the transform can be // reused. d.pathCache.put(quads.key, opCacheValue{bounds: op.bounds}) @@ -865,6 +901,7 @@ loop: d.addClipPath(&state, quads.aux, quads.key, op.bounds, off) quads = quadsOp{} stroke = clip.StrokeStyle{} + dashes = dashOp{} case opconst.TypeColor: state.matType = materialColor @@ -1251,7 +1288,7 @@ func (d *drawOps) writeVertCache(n int) []byte { } // transform, split paths as needed, calculate maxY, bounds and create GPU vertices. -func (d *drawOps) buildVerts(aux []byte, tr f32.Affine2D, outline bool, stroke clip.StrokeStyle) (verts []byte, bounds f32.Rectangle) { +func (d *drawOps) buildVerts(aux []byte, tr f32.Affine2D, outline bool, stroke clip.StrokeStyle, dashes dashOp) (verts []byte, bounds f32.Rectangle) { inf := float32(math.Inf(+1)) d.qs.bounds = f32.Rectangle{ Min: f32.Point{X: inf, Y: inf}, @@ -1273,7 +1310,7 @@ func (d *drawOps) buildVerts(aux []byte, tr f32.Affine2D, outline bool, stroke c quads = append(quads, quad) aux = aux[ops.QuadSize+4:] } - quads = quads.stroke(stroke) + quads = quads.stroke(stroke, dashes) for _, quad := range quads { d.qs.contour = quad.contour quad.quad = quad.quad.Transform(tr) diff --git a/gpu/stroke.go b/gpu/stroke.go index edfb923f..785acff0 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{ @@ -106,7 +118,11 @@ func (qs strokeQuads) split() []strokeQuads { return o } -func (qs strokeQuads) stroke(stroke clip.StrokeStyle) strokeQuads { +func (qs strokeQuads) stroke(stroke clip.StrokeStyle, dashes dashOp) strokeQuads { + if !isSolidLine(dashes) { + qs = qs.dash(dashes) + } + var ( o strokeQuads hw = 0.5 * stroke.Width @@ -291,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 } @@ -349,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 e0db436c..361abda1 100644 --- a/internal/opconst/ops.go +++ b/internal/opconst/ops.go @@ -33,6 +33,7 @@ const ( TypeCursor TypePath TypeStroke + TypeDash ) const ( @@ -61,6 +62,7 @@ const ( TypeCursorLen = 1 + 1 TypePathLen = 1 + 4 TypeStrokeLen = 1 + 4 + 4 + 1 + 1 + TypeDashLen = 1 + 4 + 1 ) func (t OpType) Size() int { @@ -90,6 +92,7 @@ func (t OpType) Size() int { TypeCursorLen, TypePathLen, TypeStrokeLen, + TypeDashLen, }[t-firstOpIndex] } diff --git a/internal/rendertest/clip_test.go b/internal/rendertest/clip_test.go index f8f0f337..a92cda84 100644 --- a/internal/rendertest/clip_test.go +++ b/internal/rendertest/clip_test.go @@ -36,6 +36,28 @@ func TestPaintClippedRect(t *testing.T) { }) } +func TestPaintClippedBorder(t *testing.T) { + run(t, func(o *op.Ops) { + var dashes clip.Dash + dashes.Begin(o) + dashes.Phase(1) + dashes.Dash(2) + dashes.Dash(1) + + clip.Border{ + Rect: f32.Rect(25, 25, 60, 60), + Width: 4, + Dashes: dashes.End(), + }.Add(o) + paint.FillShape(o, red, clip.Rect(image.Rect(0, 0, 50, 50)).Op()) + }, func(r result) { + r.expect(0, 0, colornames.White) + r.expect(25, 25, colornames.Red) + r.expect(50, 0, colornames.White) + r.expect(10, 50, colornames.White) + }) +} + func TestPaintClippedCirle(t *testing.T) { run(t, func(o *op.Ops) { r := float32(10) @@ -318,6 +340,177 @@ func TestStrokedPathZeroWidth(t *testing.T) { }) } +func TestDashedPathFlatCapEllipse(t *testing.T) { + run(t, func(o *op.Ops) { + { + stk := op.Push(o) + p := newEllipsePath(o) + + var dash clip.Dash + dash.Begin(o) + dash.Dash(5) + dash.Dash(3) + + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{ + Width: 10, + Cap: clip.FlatCap, + Join: clip.BevelJoin, + Miter: float32(math.Inf(+1)), + }, + Dashes: dash.End(), + }.Op().Add(o) + + paint.Fill( + o, + red, + ) + stk.Pop() + } + { + stk := op.Push(o) + p := newEllipsePath(o) + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{ + Width: 2, + }, + }.Op().Add(o) + + paint.Fill( + o, + black, + ) + stk.Pop() + } + + }, 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) { + { + stk := op.Push(o) + p := newZigZagPath(o) + var dash clip.Dash + dash.Begin(o) + dash.Dash(5) + dash.Dash(3) + + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{ + Width: 10, + Cap: clip.FlatCap, + Join: clip.BevelJoin, + Miter: float32(math.Inf(+1)), + }, + Dashes: dash.End(), + }.Op().Add(o) + paint.Fill(o, red) + stk.Pop() + } + + { + stk := op.Push(o) + p := newZigZagPath(o) + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{Width: 2}, + }.Op().Add(o) + paint.Fill(o, black) + stk.Pop() + } + }, 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) { + { + stk := op.Push(o) + p := newZigZagPath(o) + var dash clip.Dash + dash.Begin(o) + dash.Phase(1) + + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{ + Width: 10, + Cap: clip.FlatCap, + Join: clip.BevelJoin, + Miter: float32(math.Inf(+1)), + }, + Dashes: dash.End(), + }.Op().Add(o) + paint.Fill(o, red) + stk.Pop() + } + { + stk := op.Push(o) + clip.Stroke{ + Path: newZigZagPath(o), + Style: clip.StrokeStyle{Width: 2}, + }.Op().Add(o) + paint.Fill(o, black) + stk.Pop() + } + }, 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) { + { + stk := op.Push(o) + var dash clip.Dash + dash.Begin(o) + dash.Dash(0) + clip.Stroke{ + Path: newZigZagPath(o), + Style: clip.StrokeStyle{ + Width: 10, + Cap: clip.FlatCap, + Join: clip.BevelJoin, + Miter: float32(math.Inf(+1)), + }, + Dashes: dash.End(), + }.Op().Add(o) + paint.Fill(o, red) + stk.Pop() + } + { + stk := op.Push(o) + p := newZigZagPath(o) + clip.Stroke{ + Path: p, + Style: clip.StrokeStyle{Width: 2}, + }.Op().Add(o) + paint.Fill(o, black) + stk.Pop() + } + }, 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.PathSpec { p := new(clip.Path) p.Begin(o) 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 zcmb_gU2ht^7Qqm0N_JE$ZGzJ@c%U|&_Azibd?1Fkmo~XWwd>> z{u*O@k;<(+!>&iIJS;yX@#Q1Ree+MS%MtXxoSccr0dPet;$`$Xwu6>~VNa1cbisr3 zXa~Fhz5DTSiSvv7hK&cisD1U_`X;s3wH{+nyA5i zRCFH8o)lm+ohq{7^dL-;3G|y5#t+hPVkY`7tQSOPU~DX&P%xN61_X+u2Lk1j;DG;Q zSviCYeq2)CbG=$ngrUkRWrHV#3Qr>XR8~*g(11m#APeS9lP0f++O2}8XE&BbNXh>D zK_s*iZRoNd!^|9=7*&yA+@HrLE=TkuNQ~AG>>?yZD}aNIogw~lWO#47%HGy)fMNG? zj+hEjUfnh^vrC3-Fbf+%+xnGo-j_aHCd7l1q;*TcMK3ftdn@j^WA3{`CjhzidggI3Td zh6#pftks|>hXJZX4sjhC)(-U*~StBac^s)qPS$1;H zbOecfiRjDBrIRNmkd2Oo9ao_Y^>Pf!0&(x{L1p4^&SfT5BDt==tZhqt)y#= zW}_Ln$L&53y)ju?S@E7bM;zR&(q5`U6P>}&Vn@x4PrWf2)g7^b3OF?RhlN~wpQfRq zqiOx_#_6VFlbY`M@o|IPf`T`1BLws8{CsL^N-GT#0K#&mpnjWlvsY0&L|Rmh<-mN6QL$(9Caku6^Y;9#~{}pK_jfLuiyOX@eUb=flcDZdGeJ>hwzn!*RyNKI=NwG zN~U5%L**imvoPp)P9d#??0aFABL21C7<}$4e;1-631EB9nCj3N@44O>3?QGaLyVta zN|y3*r<&vJNn};&YZ+;ew_}-dUs#Y^>7E~1Plh=#fV{lgI?Tz&Q^j6eg^i!&h8V5}kYZhvbZD z?R>n!aJFg-(i|j4BQcCAOQK;q4xtY5SuJckn#9WBQ&8gMoPI9H`jC)Fh>MTT_>X5&)jT*5k46KvzCyH|yof;!>$%o5?c*xn1*c2kkT*eMVzkCZ|SS2NEFHD7a z`V(z+blQB5JC0fpDs0Ysq;pmltiP(}&K~)ntK4nU`K``XX6k}0FCNb24_5tHb@UK? zetfo)6$E1kA>2$cZ*aD@DTQdoKcuEY4AXS!^Ez^x3i|R%%rKMX<2Rl(lt=sHsj^4X zu$e1}lTyB;^tGn^8QXa&3LXOeA7MDSeBpnFzJP^8;-$&E@R%enl29&lzqhwEjHp#| zl*zg3&14L0!PH{%ddAF|^DHCS4!Qnr^FKmEL7!&V^LQ=!LBD$T4x8}(P*=jiCR6K| zmnj(~ga{Bc)Aw=)@Z!Y_CqXJQGGt}b8PZ8`>gH72U{UjV)j@Ran=6~BC?Q43{t{Cc zvY0Siq##?uH)+oEH?<381IeF16b9sZZATvSPEaTmfUy$jnWMfL3VJS$f7g4X@L8?+Mrh*`E8kK5%!Or0) zWYDB~zvqEe;tqNPHHV|ma~lm(xe#5{V;MxlQ%F~u@x2YN(jZ7A(zw-YXn1%7qeE3X zk#_l|lOD};lv&&Xn)}I0K>o?sGJ^!L2L{1E#P+6+Nh=X0rS>`JV1QJIJ-DHVNYh!xM~gBpW@hxRIE{iVVqy%T;ODkgkA|&^ zJkK571nN=%H-JkHNzE(AyPYJ)#$}Xuy;|HVdO8Bzo5XEeB``}Zb~hK)vMCB)5DJ55 zQm%AU1(s|MB%(DJn0D3fDc7Aq#RRV3WQ8nx|Uk+tqiHS#{UOJxmc6)&O zvnhCb#y!3LKehwywl9W~vG1;z-v?ZKHx|vg-|@@IA`sa;^ykHx7upRvfU4ow7&hN){n`H<;@r2FAhhLey1MR47~@q1|1R%lN~s;p9=7hW0F+c%HbZ=$pDLeZ3nkVjnHQVg?Q=j7eTftzwr{Oxxz+ z;Nn*59$f5AwVY~czWXMKEYUC`hPi+g73-g>GT%?>stzAi!odamgo~`(=K%ulTjaN|7*h779E)<(i$SzBwobF`{3jEM$zV}x0 z4l9ZP-6=JQicy@Jgf%}nI6VAAGQaM5Q8IaMYYL7A1oO13*2XPn(ti#A7LctJbsr8EA1NE->o45o;?(uXZ=(f z#*i1kOI9T$s_i#ZFGKobNHRppFbPiN)tB~1b0ikIFpqQYrcT@unw_7rtlDVo?IYTJ zEW1N97!}NFP7>5LLpiEW8eY;+n49Nc0=;xP+Z$!FMW%Y49u^rJEivWegdd;olfDmf zR7uN*OMUh7l2tf}A}%96O}>lwE_}4TDM8~h>0NTQ93+~a$<}OwMAnatH8}%e0u6AT zN69U}#vw(0hfz4uV_DFC(0{#Bw0p{d&v$d0b5*;O5mXomaz}EKN6yjz!Z6L>y0^H# zh0d8Q@p1i5`@;C??6}JKTe5=Zx0s>YUJ3@rb#JOA|6mrduXOT1=mjh~37Wd5w5JVS zA62$s#`zVZMMja)K&WfPrqPL9Bv|WO3OQyvWWdE*gJE}f{(zwSt4l^Ix>UE1uh`jp z*HiZkkOut>7m$4&oj|q+Wp$mtOSjby3Z6V&6!9l(XNaa2FOAO3s%#nqo z9V4^??^&k=f^AZECu!ada_-q&16+r{Rlq+#364k7S?}PU3vI zqOKBlOlUPZRoa}f?9xBKnJBeVX(;qK#KU$`9jasa4atWrj*)ua|2ZB>l>~KA1ZZi}@ zeCt|H!U&tqPG~+6pBy&TdoWXpBm<|Whvv0dzQ{}a`tF#f)%rK6cD;eI9BIH~6nbM~ z>Vw+UX0ADGmiK2e#9!TCD|Nkh_+=aS%9f9hO6?Dfh3LN``E;J#(p8ee$gkBtE8QVz z9b;j)Q|Ps|H@mpFB<{XXn7e2xU&r`i?AEqlmQX_q@cH(XEF+eY(=Lc~LkGLM#-f#d z{uI<_6@o=?7AA_P_w$B9@9{3nn0}+WL6n`n9yZ`0p!f66;d_1s`I!RIggnA?W^0cU zc)eoSVm;Dj=o+7x$SvuUkY3T6sj0OW!+PN?kqw5yjC#kjpbi2E1R~&aU%+jxYnV|X zqeE3Gv1P{UMhTAk-JeicX;@km$=e7!n9az@xIN#lc^_rBEF2rNjy~BW=0gy8^Izj@ zX08P1<(umk&w_#iDIK&Rp8nmo= zJa^I%^(sN;tlNFe5%;dpA``B7C0hOJvnR|nj91BKdsQ`yhxe*t%1@&yI@;~#$MbCy zhcNXn@;$W)_^D#{t}jul@#nS4rdxZ*FV}tkMFe7D^y_y~i}DIxJ|3+!<8jYLrtI2tQ&-Z1wI~zi_ zTv(~0D?gG>6{!M+4p00CcmhxR$u?U87fLFL)ph$L@&1b}jh;L?`Me+cHCxOl7GZa8 zQ$%VRjt=e4lva-jTd6>w(V&zSIO9Kzi)CB!z%tAF+M++QB}oXztZGz`Wy4TJOo^z1m8juB58eM=Eoa{O=QJH%?n=#eOxOOIOf8zfeGOGOZQK<9tpu zrXpuuxGnWoUMU?1bOk`}+XlY_)h%5*Xv%~{PfrkF*$Hn*ExR=O3)JbqB`Zv)6bL9_ zYEg*e3B}+3GZA#HVPkV&`j#6ZDe(l(+HP_E3tQD;^3hN`r58IsAsc7T!uD0L` zqah26UXoC8xfT@~Ola^^-TRBXGmq8-uGqLg*^XC#uU-uI*c6C(@_%&^#5LY>6Hea# zof;w|o#S!>;J3uk(QVWNv9r&IV~YZ<1ef6dW^Krs79JiR`VeT)oTS7g1DG(W|8;~i z837!3cG7cmzkc0qQ6tW869*AY6UU|yN>Ef<;sD0g;F8a$yb%SaWI0urDJm+~>T~j% zn^Q%gL$IO~g&x8ZrHURN8VQ!~j@mMNeOlOF%kaBrQ*QHeF)?xS^S^mBkSE|uuM(;+p;4JOq5^=jp6C<_{V^$tVAd?K-Bp7Ti^=ga+L2j-gOob;#G63WgwZ? zn)%#knP?w!dP#q=)n@26ao&GTOWQMzn~=QZj-a+<}NN_ z8{^a^%@oynh^Q>FTVLx&mJMg)rU3W@n`?GZd*4;Q_2vu`ypt|?TUfaFmR~7tKq+~~ zD%n~@M5JZ+#C(5ohTd$Hq7pgNG~gEhILz_jojp=iP@CN7y3<5R)&K4O#vKy$6c`4` zV}n5cHHnHEeBQ#AP9wV5s56r(cpRts0{9kxcgdHMkuN+D2fhFE#k?o-Bj-y5=QtWc zM0m4oHmoJ(E7T$l>igpE&!p6oOr4EAx7$P*=+D+^?$FS3Z6-hT;IGAP=uBCQ_Q9MZ z`SGfnZn;t#;n0~;7bi3}KaPTz0RkBlaW&b5-L}TY#J9)=iKNcdSCX)GRe%kbyZ3i?-?<)5(E%9GOpddY?Cv zwv6ci5ejS)7Ql~T4<;U}36qU4DORL6hfRJ2!FMDv`p5D6%OTk6>4!BJUc+NE;PNSG z=PiEeeU`qj@0%aa+oP}eY%<1d>W01pG`jGhioi*_z#n;edHMOF*%AkGa8a_J8rwux zASYu%etv#lp4sv0Q(ArpE{Eor%4h4kg{+K>=EE@|rAsU=2hyOT>g}IyAxe#O#)k7a z;G1U#p-*+K0C_2*k&qJe#Ec8e5zBf+pa1oR*Kz=NSvfR~n37$DS~w_kV{>G9IHCCa zAMxvDo3*?G-pPW;1E(HBQ%KKlUaw z_hV%U#{01^<(7C3y!^KWM3}51>C0pm|Cv;6Nc`F-)KE)TdP!n1VV zhtuh%{7pCgJvur%I5_yRZu>{3@2o`&czQg$eCybaLQ!8|_V#wMblIr&%E12slGEZ7 z_N1M?LIs-Rw$;&+;7gDvS}uIiWhJuutO>(Xo@N0U+4c_i?Jn~WP@0&ICtWTKiueOVAY|zgh z{@FPvCZXJ4QhE$xk7?Ggwo#%dIwm3bg?1>9`C#<_BWFJZ|I_K8pC1jJ58Z)-o&OR5 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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 jMFxSO|F0h!0IaS`DT@S{qNv8 literal 0 HcmV?d00001 diff --git a/internal/rendertest/refs/TestPaintClippedBorder.png b/internal/rendertest/refs/TestPaintClippedBorder.png new file mode 100644 index 0000000000000000000000000000000000000000..eb866d2cbfb0c364876cc0b8c58f976e0decd369 GIT binary patch literal 582 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^VB+<3aSW-L^X8Uq-zx(dhC=Ir zPgfmTS$F>L%-F${t5EleTS(-##NVZwHug(DtP2ynl<+twVtUXd3+BH}Rp|^f7!%kU zBpK3(Vnk##WO`X%fBm(J|Cyz1|H~@hrB~$rm;ahQ=W<%`|9=H5!ZkCut^yJ=_nY(9 zycGX%-{!yVJBNUVImypn_RnYBAoKa^k@vO923gGu7beq?d;K=yx_Y>!z^MCkAJTnv_E?7tk iLs}{Rrq96e|9^N~T-)DWS|@<%gTd3)&t;ucLK6V^ThF2Z literal 0 HcmV?d00001 diff --git a/io/router/clipboard_test.go b/io/router/clipboard_test.go index 6aa30798..30373312 100644 --- a/io/router/clipboard_test.go +++ b/io/router/clipboard_test.go @@ -1,10 +1,11 @@ package router import ( + "testing" + "gioui.org/io/clipboard" "gioui.org/io/event" "gioui.org/op" - "testing" ) func TestClipboardDuplicateEvent(t *testing.T) { diff --git a/op/clip/clip.go b/op/clip/clip.go index 825cba05..b16a27ba 100644 --- a/op/clip/clip.go +++ b/op/clip/clip.go @@ -21,6 +21,7 @@ type Op struct { outline bool stroke StrokeStyle + dashes DashSpec } func (p Op) Add(o *op.Ops) { @@ -42,6 +43,15 @@ func (p Op) Add(o *op.Ops) { data[10] = uint8(p.stroke.Join) } + if p.dashes.phase != 0 || p.dashes.size > 0 { + data := o.Write(opconst.TypeDashLen) + data[0] = byte(opconst.TypeDash) + bo := binary.LittleEndian + bo.PutUint32(data[1:], math.Float32bits(p.dashes.phase)) + data[5] = p.dashes.size // FIXME(sbinet) uint16? uint32? + p.dashes.spec.Add(o) + } + data := o.Write(opconst.TypeClipLen) data[0] = byte(opconst.TypeClip) bo := binary.LittleEndian diff --git a/op/clip/shapes.go b/op/clip/shapes.go index 3227b0fc..6bfe0154 100644 --- a/op/clip/shapes.go +++ b/op/clip/shapes.go @@ -50,8 +50,9 @@ func (rr RRect) Add(ops *op.Ops) { // Border represents the clip area of a rectangular border. type Border struct { // Rect is the bounds of the border. - Rect f32.Rectangle - Width float32 + Rect f32.Rectangle + Width float32 + Dashes DashSpec // The corner radii. SE, SW, NW, NE float32 } @@ -68,6 +69,7 @@ func (b Border) Op(ops *op.Ops) Op { Style: StrokeStyle{ Width: b.Width, }, + Dashes: b.Dashes, }.Op() } diff --git a/op/clip/stroke.go b/op/clip/stroke.go index 8cfb721b..170c076b 100644 --- a/op/clip/stroke.go +++ b/op/clip/stroke.go @@ -2,10 +2,22 @@ package clip +import ( + "encoding/binary" + "math" + + "gioui.org/internal/opconst" + "gioui.org/op" +) + // Stroke represents a stroked path. type Stroke struct { Path PathSpec Style StrokeStyle + + // Dashes specify the dashes of the stroke. + // The empty value denotes no dashes. + Dashes DashSpec } // Op returns a clip operation representing the stroke. @@ -13,6 +25,7 @@ func (s Stroke) Op() Op { return Op{ path: s.Path, stroke: s.Style, + dashes: s.Dashes, } } @@ -57,3 +70,49 @@ const ( // RoundJoin joins path segments with a round segment. RoundJoin ) + +// Dash records dashes' lengths and phase for a stroked path. +type Dash struct { + ops *op.Ops + macro op.MacroOp + phase float32 + size uint8 // size of the pattern +} + +func (d *Dash) Begin(ops *op.Ops) { + d.ops = ops + d.macro = op.Record(ops) + // Write the TypeAux opcode + data := ops.Write(opconst.TypeAuxLen) + data[0] = byte(opconst.TypeAux) +} + +func (d *Dash) Phase(v float32) { + d.phase = v +} + +func (d *Dash) Dash(length float32) { + if d.size == math.MaxUint8 { + panic("clip: too large dash pattern") + } + data := d.ops.Write(4) + bo := binary.LittleEndian + bo.PutUint32(data[0:], math.Float32bits(length)) + d.size++ +} + +func (d *Dash) End() DashSpec { + c := d.macro.Stop() + return DashSpec{ + spec: c, + phase: d.phase, + size: d.size, + } +} + +// DashSpec describes a dashed pattern. +type DashSpec struct { + spec op.CallOp + phase float32 + size uint8 // size of the pattern +}