From 33c5fb63dbd6bec7707b1ba32e52dcf722eede3c Mon Sep 17 00:00:00 2001 From: Sebastien Binet Date: Mon, 9 Nov 2020 14:13:11 +0000 Subject: [PATCH] gpu,op/clip: implement stroked paths Flat and Square caps are implemented. Bevel joins are implemented. Round caps, Round joins and Miter joins are left for another PR. Signed-off-by: Sebastien Binet --- gpu/gpu.go | 46 +- gpu/stroke.go | 435 ++++++++++++++++++ internal/opconst/ops.go | 2 +- internal/rendertest/clip_test.go | 84 ++++ .../refs/TestStrokedPathBevelFlat.png | Bin 0 -> 3398 bytes .../refs/TestStrokedPathBevelSquare.png | Bin 0 -> 3415 bytes .../refs/TestStrokedPathZeroWidth.png | Bin 0 -> 390 bytes op/clip/clip.go | 24 + op/clip/stroke.go | 23 + 9 files changed, 605 insertions(+), 9 deletions(-) create mode 100644 gpu/stroke.go create mode 100644 internal/rendertest/refs/TestStrokedPathBevelFlat.png create mode 100644 internal/rendertest/refs/TestStrokedPathBevelSquare.png create mode 100644 internal/rendertest/refs/TestStrokedPathZeroWidth.png create mode 100644 op/clip/stroke.go diff --git a/gpu/gpu.go b/gpu/gpu.go index 7d6d9952..33d2c327 100644 --- a/gpu/gpu.go +++ b/gpu/gpu.go @@ -25,6 +25,7 @@ import ( gunsafe "gioui.org/internal/unsafe" "gioui.org/layout" "gioui.org/op" + "gioui.org/op/clip" ) type GPU struct { @@ -125,6 +126,8 @@ type material struct { type clipOp struct { // TODO: Use image.Rectangle? bounds f32.Rectangle + width float32 + style clip.StrokeStyle } // imageOpData is the shadow of paint.ImageOp. @@ -157,6 +160,10 @@ func (op *clipOp) decode(data []byte) { } *op = clipOp{ bounds: layout.FRect(r), + width: math.Float32frombits(bo.Uint32(data[17:])), + style: clip.StrokeStyle{ + Cap: clip.StrokeCap(data[21]), + }, } } @@ -805,7 +812,7 @@ loop: // Why is this not used for the offset shapes? op.bounds = v.bounds } else { - aux, op.bounds = d.buildVerts(aux, trans) + aux, op.bounds = d.buildVerts(aux, trans, op.width, op.style) // add it to the cache, without GPU data, so the transform can be // reused. d.pathCache.put(auxKey, opCacheValue{bounds: op.bounds}) @@ -1210,7 +1217,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) (verts []byte, bounds f32.Rectangle) { +func (d *drawOps) buildVerts(aux []byte, tr f32.Affine2D, width float32, sty clip.StrokeStyle) (verts []byte, bounds f32.Rectangle) { inf := float32(math.Inf(+1)) d.qs.bounds = f32.Rectangle{ Min: f32.Point{X: inf, Y: inf}, @@ -1219,14 +1226,37 @@ func (d *drawOps) buildVerts(aux []byte, tr f32.Affine2D) (verts []byte, bounds d.qs.d = d bo := binary.LittleEndian startLength := len(d.vertCache) - for qi := 0; len(aux) >= (ops.QuadSize + 4); qi++ { - d.qs.contour = bo.Uint32(aux) - quad := ops.DecodeQuad(aux[4:]) - quad = quad.Transform(tr) - d.qs.splitAndEncode(quad) + switch { + default: + // Outline path. + for qi := 0; len(aux) >= (ops.QuadSize + 4); qi++ { + d.qs.contour = bo.Uint32(aux) + quad := ops.DecodeQuad(aux[4:]) + quad = quad.Transform(tr) - aux = aux[ops.QuadSize+4:] + d.qs.splitAndEncode(quad) + + aux = aux[ops.QuadSize+4:] + } + case width > 0: + // Stroke path. + quads := make(strokeQuads, 0, 2*len(aux)/(ops.QuadSize+4)) + for qi := 0; len(aux) >= (ops.QuadSize + 4); qi++ { + quad := strokeQuad{ + contour: bo.Uint32(aux), + quad: ops.DecodeQuad(aux[4:]), + } + quads = append(quads, quad) + aux = aux[ops.QuadSize+4:] + } + quads = quads.stroke(width, sty) + for _, quad := range quads { + d.qs.contour = quad.contour + quad.quad = quad.quad.Transform(tr) + + d.qs.splitAndEncode(quad.quad) + } } fillMaxY(d.vertCache[startLength:]) diff --git a/gpu/stroke.go b/gpu/stroke.go new file mode 100644 index 00000000..75e9189e --- /dev/null +++ b/gpu/stroke.go @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Most of the algorithms to compute strokes and their offsets have been +// extracted, adapted from (and used as a reference implementation): +// - github.com/tdewolff/canvas (Licensed under MIT) +// +// These algorithms have been implemented from: +// Fast, precise flattening of cubic Bézier path and offset curves +// Thomas F. Hain, et al. +// +// An electronic version is available at: +// https://seant23.files.wordpress.com/2010/11/fastpreciseflatteningofbeziercurve.pdf +// +// Possible improvements (in term of speed and/or accuracy) on these +// algorithms are: +// +// - Polar Stroking: New Theory and Methods for Stroking Paths, +// M. Kilgard +// https://arxiv.org/pdf/2007.00308.pdf +// +// - https://raphlinus.github.io/graphics/curves/2019/12/23/flatten-quadbez.html +// R. Levien + +package gpu + +import ( + "math" + + "gioui.org/f32" + "gioui.org/internal/ops" + "gioui.org/op/clip" +) + +type strokeQuad struct { + contour uint32 + quad ops.Quad +} + +type strokeState struct { + p0, p1 f32.Point // p0 is the start point, p1 the end point. + n0, n1 f32.Point // n0 is the normal vector at the start point, n1 at the end point. + r0, r1 float32 // r0 is the curvature at the start point, r1 at the end point. + ctl f32.Point // ctl is the control point of the quadratic Bézier segment. +} + +type strokeQuads []strokeQuad + +func (qs *strokeQuads) lineTo(pt f32.Point) { + end := (*qs)[len(*qs)-1].quad.To + *qs = append(*qs, strokeQuad{ + quad: ops.Quad{ + From: end, + Ctrl: end.Add(pt).Mul(0.5), + To: pt, + }, + }) +} + +// split splits a slice of quads into slices of quads grouped +// by contours (ie: splitted at move-to boundaries). +func (qs strokeQuads) split() []strokeQuads { + if len(qs) == 0 { + return nil + } + + var ( + c uint32 + o []strokeQuads + i = len(o) + ) + for _, q := range qs { + if q.contour != c { + c = q.contour + i = len(o) + o = append(o, strokeQuads{}) + } + o[i] = append(o[i], q) + } + + return o +} + +func (qs strokeQuads) stroke(width float32, sty clip.StrokeStyle) strokeQuads { + var ( + o strokeQuads + hw = 0.5 * width + ) + + for _, ps := range qs.split() { + rhs, lhs := ps.offset(hw, sty) + switch lhs { + case nil: + o = o.append(rhs) + default: + // Closed path. + // Inner path should go opposite direction to cancel outer path. + switch { + case ps.ccw(): + lhs = lhs.reverse() + o = o.append(rhs) + o = o.append(lhs) + default: + rhs = rhs.reverse() + o = o.append(lhs) + o = o.append(rhs) + } + } + } + return o +} + +// offset returns the right-hand and left-hand sides of the path, offset by +// the half-width hw. +// The stroke style sty handles how segments are joined and ends are capped. +func (qs strokeQuads) offset(hw float32, sty clip.StrokeStyle) (rhs, lhs strokeQuads) { + var ( + states []strokeState + beg = qs[0].quad.From + end = qs[len(qs)-1].quad.To + closed = beg == end + ) + for i := range qs { + q := qs[i].quad + + var ( + n0 = strokePathNorm(q.From, q.Ctrl, q.To, 0, hw) + n1 = strokePathNorm(q.From, q.Ctrl, q.To, 1, hw) + r0 = strokePathCurv(q.From, q.Ctrl, q.To, 0) + r1 = strokePathCurv(q.From, q.Ctrl, q.To, 1) + ) + states = append(states, strokeState{ + p0: q.From, + p1: q.To, + n0: n0, + n1: n1, + r0: r0, + r1: r1, + ctl: q.Ctrl, + }) + } + + const tolerance = 0.01 + for i, state := range states { + rhs = rhs.append(strokeQuadBezier(state, +hw, tolerance)) + lhs = lhs.append(strokeQuadBezier(state, -hw, tolerance)) + + // join the current and next segments + if hasNext := i+1 < len(states); hasNext || closed { + var next strokeState + switch { + case hasNext: + next = states[i+1] + case closed: + next = states[0] + } + if state.n1 != next.n0 { + strokePathJoin(sty, &rhs, &lhs, hw, state.p1, state.n1, next.n0, state.r1, next.r0) + } + } + } + + if closed { + rhs.close() + lhs.close() + return rhs, lhs + } + + qbeg := &states[0] + qend := &states[len(states)-1] + + // Default to counter-clockwise direction. + lhs = lhs.reverse() + strokePathCap(sty, &rhs, hw, qend.p1, qend.n1) + + rhs = rhs.append(lhs) + strokePathCap(sty, &rhs, hw, qbeg.p0, qbeg.n0.Mul(-1)) + + rhs.close() + + return rhs, nil +} + +func (qs *strokeQuads) close() { +} + +// ccw returns whether the path is counter-clockwise. +func (qs strokeQuads) ccw() bool { + // Use the Shoelace formula: + // https://en.wikipedia.org/wiki/Shoelace_formula + var area float32 + for _, ps := range qs.split() { + for i := 1; i < len(ps); i++ { + pi := ps[i].quad.To + pj := ps[i-1].quad.To + area += (pi.X - pj.X) * (pi.Y + pj.Y) + } + } + return area <= 0.0 +} + +func (qs strokeQuads) reverse() strokeQuads { + if len(qs) == 0 { + return nil + } + + ps := make(strokeQuads, 0, len(qs)) + for i := range qs { + q := qs[len(qs)-1-i] + q.quad.To, q.quad.From = q.quad.From, q.quad.To + ps = append(ps, q) + } + + return ps +} + +func (qs strokeQuads) append(ps strokeQuads) strokeQuads { + switch { + case len(ps) == 0: + return qs + case len(qs) == 0: + return ps + } + return append(qs, ps...) +} + +// strokePathNorm returns the normal vector at t. +func strokePathNorm(p0, p1, p2 f32.Point, t, d float32) f32.Point { + switch t { + case 0: + n := p1.Sub(p0) + if n.X == 0 && n.Y == 0 { + return f32.Point{} + } + n = rot90CW(n) + return normPt(n, d) + case 1: + n := p2.Sub(p1) + if n.X == 0 && n.Y == 0 { + return f32.Point{} + } + n = rot90CW(n) + return normPt(n, d) + } + panic("impossible") +} + +func rot90CW(p f32.Point) f32.Point { return f32.Pt(+p.Y, -p.X) } +func rot90CCW(p f32.Point) f32.Point { return f32.Pt(-p.Y, +p.X) } + +func normPt(p f32.Point, l float32) f32.Point { + d := math.Hypot(float64(p.X), float64(p.Y)) + l64 := float64(l) + if math.Abs(d-l64) < 1e-10 { + return f32.Point{} + } + n := float32(l64 / d) + return f32.Point{X: p.X * n, Y: p.Y * n} +} + +func dotPt(p, q f32.Point) float32 { + return p.X*q.X + p.Y*q.Y +} + +func perpDot(p, q f32.Point) float32 { + return p.X*q.Y - p.Y*q.X +} + +// strokePathCurv returns the curvature at t, along the quadratic Bézier +// curve defined by the triplet (beg, ctl, end). +func strokePathCurv(beg, ctl, end f32.Point, t float32) float32 { + var ( + d1p = quadBezierD1(beg, ctl, end, t) + d2p = quadBezierD2(beg, ctl, end, t) + + // Negative when bending right, ie: the curve is CW at this point. + a = float64(perpDot(d1p, d2p)) + ) + + // We check early that the segment isn't too line-like and + // save a costly call to math.Pow that will be discarded by dividing + // with a too small 'a'. + if math.Abs(a) < 1e-10 { + return float32(math.NaN()) + } + return float32(math.Pow(float64(d1p.X*d1p.X+d1p.Y*d1p.Y), 1.5) / a) +} + +// quadBezierSample returns the point on the Bézier curve at t. +// B(t) = (1-t)^2 P0 + 2(1-t)t P1 + t^2 P2 +func quadBezierSample(p0, p1, p2 f32.Point, t float32) f32.Point { + t1 := 1 - t + c0 := t1 * t1 + c1 := 2 * t1 * t + c2 := t * t + + o := p0.Mul(c0) + o = o.Add(p1.Mul(c1)) + o = o.Add(p2.Mul(c2)) + return o +} + +// quadBezierD1 returns the first derivative of the Bézier curve with respect to t. +// B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1) +func quadBezierD1(p0, p1, p2 f32.Point, t float32) f32.Point { + p10 := p1.Sub(p0).Mul(2 * (1 - t)) + p21 := p2.Sub(p1).Mul(2 * t) + + return p10.Add(p21) +} + +// quadBezierD2 returns the second derivative of the Bézier curve with respect to t: +// B''(t) = 2(P2 - 2P1 + P0) +func quadBezierD2(p0, p1, p2 f32.Point, t float32) f32.Point { + p := p2.Sub(p1.Mul(2)).Add(p0) + return p.Mul(2) +} + +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. + var qs strokeQuads + return flattenQuadBezier(qs, state.p0, state.ctl, state.p1, d, flatness) +} + +// flattenQuadBezier splits a Bézier quadratic curve into linear sub-segments, +// themselves also encoded as Bézier (degenerate, flat) quadratic curves. +func flattenQuadBezier(qs strokeQuads, p0, p1, p2 f32.Point, d, flatness float32) strokeQuads { + var t float32 + for t < 1 { + s2 := float64((p2.X-p0.X)*(p1.Y-p0.Y) - (p2.Y-p0.Y)*(p1.X-p0.X)) + den := math.Hypot(float64(p1.X-p0.X), float64(p1.Y-p0.Y)) + if s2*den == 0.0 { + break + } + + s2 /= den + flat64 := float64(flatness) + t = 2.0 * float32(math.Sqrt(flat64/3.0/math.Abs(s2))) + if t >= 1.0 { + break + } + var q0, q1, q2 f32.Point + q0, q1, q2, p0, p1, p2 = quadBezierSplit(p0, p1, p2, t) + qs.addLine(q0, q1, q2, 0, d) + } + qs.addLine(p0, p1, p2, 1, d) + return qs +} + +func (qs *strokeQuads) addLine(p0, ctrl, p1 f32.Point, t, d float32) { + p0 = p0.Add(strokePathNorm(p0, ctrl, p1, 0, d)) + p1 = p1.Add(strokePathNorm(p0, ctrl, p1, 1, d)) + + *qs = append(*qs, + strokeQuad{ + quad: ops.Quad{ + From: p0, + Ctrl: p0.Add(p1).Mul(0.5), + To: p1, + }, + }, + ) +} + +// quadInterp returns the interpolated point at t. +func quadInterp(p, q f32.Point, t float32) f32.Point { + return f32.Pt( + (1-t)*p.X+t*q.X, + (1-t)*p.Y+t*q.Y, + ) +} + +// quadBezierSplit returns the pair of triplets (from,ctrl,to) Bézier curve, +// split before (resp. after) the provided parametric t value. +func quadBezierSplit(p0, p1, p2 f32.Point, t float32) (f32.Point, f32.Point, f32.Point, f32.Point, f32.Point, f32.Point) { + + var ( + b0 = p0 + b1 = quadInterp(p0, p1, t) + b2 = quadBezierSample(p0, p1, p2, t) + + a0 = b2 + a1 = quadInterp(p1, p2, t) + a2 = p2 + ) + + return b0, b1, b2, a0, a1, a2 +} + +// strokePathJoin joins the two paths rhs and lhs, according to the provided +// stroke style sty. +func strokePathJoin(sty clip.StrokeStyle, rhs, lhs *strokeQuads, hw float32, pivot, n0, n1 f32.Point, r0, r1 float32) { + strokePathBevelJoin(rhs, lhs, hw, pivot, n0, n1, r0, r1) +} + +func strokePathBevelJoin(rhs, lhs *strokeQuads, hw float32, pivot, n0, n1 f32.Point, r0, r1 float32) { + + rp := pivot.Add(n1) + lp := pivot.Sub(n1) + + rhs.lineTo(rp) + lhs.lineTo(lp) +} + +// strokePathCap caps the provided path qs, according to the provided stroke style sty. +func strokePathCap(sty clip.StrokeStyle, qs *strokeQuads, hw float32, pivot, n0 f32.Point) { + switch sty.Cap { + case clip.FlatCap: + strokePathFlatCap(qs, hw, pivot, n0) + case clip.SquareCap: + strokePathSquareCap(qs, hw, pivot, n0) + default: + panic("impossible") + } +} + +// strokePathFlatCap caps the start or end of a path with a flat cap. +func strokePathFlatCap(qs *strokeQuads, hw float32, pivot, n0 f32.Point) { + end := pivot.Sub(n0) + qs.lineTo(end) +} + +// strokePathSquareCap caps the start or end of a path with a square cap. +func strokePathSquareCap(qs *strokeQuads, hw float32, pivot, n0 f32.Point) { + var ( + e = pivot.Add(rot90CCW(n0)) + corner1 = e.Add(n0) + corner2 = e.Sub(n0) + end = pivot.Sub(n0) + ) + + qs.lineTo(corner1) + qs.lineTo(corner2) + qs.lineTo(end) +} diff --git a/internal/opconst/ops.go b/internal/opconst/ops.go index c901ac8f..59b31508 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 + TypeClipLen = 1 + 4*4 + 4 + 1 TypeProfileLen = 1 ) diff --git a/internal/rendertest/clip_test.go b/internal/rendertest/clip_test.go index d006d79c..c20c6dc0 100644 --- a/internal/rendertest/clip_test.go +++ b/internal/rendertest/clip_test.go @@ -102,3 +102,87 @@ func TestPaintClippedTexture(t *testing.T) { r.expect(25, 35, colornames.Blue) }) } + +func TestStrokedPathBevelFlat(t *testing.T) { + run(t, func(o *op.Ops) { + const width = 2.5 + sty := clip.StrokeStyle{ + Cap: clip.FlatCap, + } + + 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) + + paint.Fill(o, colornames.Red) + }, func(r result) { + r.expect(0, 0, colornames.White) + r.expect(10, 50, colornames.Red) + }) +} + +func TestStrokedPathBevelSquare(t *testing.T) { + run(t, func(o *op.Ops) { + const width = 2.5 + sty := clip.StrokeStyle{ + Cap: clip.SquareCap, + } + + 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) + + paint.Fill(o, colornames.Red) + }, func(r result) { + r.expect(0, 0, colornames.White) + r.expect(10, 50, colornames.Red) + }) +} + +func TestStrokedPathZeroWidth(t *testing.T) { + run(t, func(o *op.Ops) { + const width = 2 + var sty clip.StrokeStyle + { + p := new(clip.Path) + p.Begin(o) + p.Move(f32.Pt(10, 50)) + p.Line(f32.Pt(50, 0)) + p.Stroke(width, sty).Add(o) + + paint.Fill(o, colornames.Black) + } + + { + p := new(clip.Path) + p.Begin(o) + p.Move(f32.Pt(10, 50)) + p.Line(f32.Pt(30, 0)) + p.Stroke(0, sty).Add(o) // width=0, disable stroke + + paint.Fill(o, colornames.Red) + } + + }, func(r result) { + r.expect(0, 0, colornames.White) + r.expect(10, 50, colornames.Black) + r.expect(30, 50, colornames.Black) + r.expect(65, 50, colornames.White) + }) +} diff --git a/internal/rendertest/refs/TestStrokedPathBevelFlat.png b/internal/rendertest/refs/TestStrokedPathBevelFlat.png new file mode 100644 index 0000000000000000000000000000000000000000..861b57f7de58d870b19df2ecad8c4c40f4bd1e85 GIT binary patch literal 3398 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 literal 0 HcmV?d00001 diff --git a/internal/rendertest/refs/TestStrokedPathZeroWidth.png b/internal/rendertest/refs/TestStrokedPathZeroWidth.png new file mode 100644 index 0000000000000000000000000000000000000000..1d47f68d3c090f477fc6c20ac3dfee0effab06e0 GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^VD$BLaSW-L^XAS*UM52U*1$cx z{#kGHVRvBI^WfQ1zc8u4SFT$=7fk=cSRT%>h9QD+15*O)0j>s725p8g>I>|SvtPgU zU+Kx^X=(4*uKzJhtYL4YgZ!+&QQWk%lR6&x#>2qy|Nl&FF2>_!GCjcXW$<+Mb6Mw< G&;$UK+-+w7 literal 0 HcmV?d00001 diff --git a/op/clip/clip.go b/op/clip/clip.go index 71224527..35d13b50 100644 --- a/op/clip/clip.go +++ b/op/clip/clip.go @@ -39,6 +39,8 @@ func (p *Path) Pos() f32.Point { return p.pen } type Op struct { call op.CallOp bounds image.Rectangle + width float32 // Width of the stroked path, 0 for outline paths. + style StrokeStyle // Style of the stroked path, 0 for outline paths. } func (p Op) Add(o *op.Ops) { @@ -50,6 +52,8 @@ func (p Op) Add(o *op.Ops) { bo.PutUint32(data[5:], uint32(p.bounds.Min.Y)) bo.PutUint32(data[9:], uint32(p.bounds.Max.X)) bo.PutUint32(data[13:], uint32(p.bounds.Max.Y)) + bo.PutUint32(data[17:], math.Float32bits(p.width)) + data[21] = uint8(p.style.Cap) } // Begin the path, storing the path data and final Op into ops. @@ -321,6 +325,26 @@ func (p *Path) Outline() Op { } } +// Stroke returns a stroked path with the specified width +// and configuration. +// If the provided width is <= 0, the path won't be stroked. +func (p *Path) Stroke(width float32, sty StrokeStyle) Op { + if width <= 0 { + // Explicitly discard the macro to ignore the path. + p.macro.Stop() + return Op{ + call: op.Record(p.ops).Stop(), + } + } + + c := p.macro.Stop() + return Op{ + call: c, + width: width, + style: sty, + } +} + // Rect represents the clip area of a pixel-aligned rectangle. type Rect image.Rectangle diff --git a/op/clip/stroke.go b/op/clip/stroke.go new file mode 100644 index 00000000..c85fd49b --- /dev/null +++ b/op/clip/stroke.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package clip + +// StrokeStyle describes how a stroked path should be drawn. +// StrokeStyle zero value draws a Bevel-joined and Flat-capped stroked path. +type StrokeStyle struct { + Cap StrokeCap +} + +// StrokeCap describes the head or tail of a stroked path. +type StrokeCap uint8 + +const ( + // FlatCap caps stroked paths with a flat cap, joining the right-hand + // and left-hand sides of a stroked path with a straight line. + FlatCap StrokeCap = iota + + // SquareCap caps stroked paths with a square cap, joining the right-hand + // and left-hand sides of a stroked path with a half square of length + // the stroked path's width. + SquareCap +)