mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 15:45:38 +00:00
8c8d1dc16f
Complex strokes are not yet supported in either of the current renderers, so they are converted to filled outlines in package gpu. We're about to move that complexity up to the op/clip package, so we're going to need the converter available from outside package gpu. This change extracts the conversion code and related types to the separate, internal package stroke. No functional changes; a follow-up moves the stroke conversion. Signed-off-by: Elias Naur <mail@eliasnaur.com>
395 lines
8.0 KiB
Go
395 lines
8.0 KiB
Go
// 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 stroke
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
|
|
"gioui.org/f32"
|
|
)
|
|
|
|
type DashOp struct {
|
|
Phase float32
|
|
Dashes []float32
|
|
}
|
|
|
|
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: QuadSegment{
|
|
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: QuadSegment{
|
|
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)
|
|
}
|