mirror of
https://github.com/hajimehoshi/ebiten.git
synced 2025-09-26 20:11:28 +08:00

This should improve the result of transforming a path with a geometry matrix. Updates #3311
427 lines
12 KiB
Go
427 lines
12 KiB
Go
// Copyright 2025 The Ebitengine Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package vector
|
|
|
|
import (
|
|
"math"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
)
|
|
|
|
// LineCap represents the way in which how the ends of the stroke are rendered.
|
|
type LineCap int
|
|
|
|
const (
|
|
LineCapButt LineCap = iota
|
|
LineCapRound
|
|
LineCapSquare
|
|
)
|
|
|
|
// LineJoin represents the way in which how two segments are joined.
|
|
type LineJoin int
|
|
|
|
const (
|
|
LineJoinMiter LineJoin = iota
|
|
LineJoinBevel
|
|
LineJoinRound
|
|
)
|
|
|
|
// StrokeOptions is options to render a stroke.
|
|
type StrokeOptions struct {
|
|
// Width is the stroke width in pixels.
|
|
//
|
|
// The default (zero) value is 0.
|
|
Width float32
|
|
|
|
// LineCap is the way in which how the ends of the stroke are rendered.
|
|
// Line caps are not rendered when the sub-path is marked as closed.
|
|
//
|
|
// The default (zero) value is [LineCapButt].
|
|
LineCap LineCap
|
|
|
|
// LineJoin is the way in which how two segments are joined.
|
|
//
|
|
// The default (zero) value is [LineJoinMiter].
|
|
LineJoin LineJoin
|
|
|
|
// MiterLimit is the miter limit for [LineJoinMiter].
|
|
// For details, see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit.
|
|
//
|
|
// The default (zero) value is 0.
|
|
MiterLimit float32
|
|
}
|
|
|
|
// AddStrokeOptions is options for [Path.AddStroke].
|
|
type AddStrokeOptions struct {
|
|
// StrokeOptions is options for the stroke.
|
|
StrokeOptions
|
|
|
|
// GeoM is a geometry matrix to apply to the path.
|
|
//
|
|
// The default (zero) value is an identity matrix.
|
|
GeoM ebiten.GeoM
|
|
}
|
|
|
|
// AddStroke adds a stroke path to the path p.
|
|
//
|
|
// The added stroke path must be rendered with FileRuleNonZero.
|
|
func (p *Path) AddStroke(src *Path, options *AddStrokeOptions) {
|
|
if options == nil {
|
|
return
|
|
}
|
|
if options.Width <= 0 {
|
|
return
|
|
}
|
|
|
|
// Normalize the source path to simplify the logic to generate a stroke path.
|
|
src.normalize()
|
|
|
|
origN := len(p.subPaths)
|
|
// p might be the same as src. Use srcN to avoid modifying the overlapped region.
|
|
srcN := len(src.subPaths)
|
|
for _, subPath := range src.subPaths[:srcN] {
|
|
_, sp1, sp2, sp3, sp4 := strokeStartControlPositions(&subPath, options.Width/2)
|
|
p.MoveTo(sp4.x, sp4.y)
|
|
|
|
appendParalleledPathFromSubPath(p, &subPath, &options.StrokeOptions)
|
|
_, ep1, ep2, ep3, ep4 := strokeEndControlPositions(&subPath, options.Width/2)
|
|
if subPath.closed {
|
|
p.Close()
|
|
p.MoveTo(ep4.x, ep4.y)
|
|
} else {
|
|
switch options.LineCap {
|
|
case LineCapButt:
|
|
p.LineTo(ep4.x, ep4.y)
|
|
case LineCapRound:
|
|
p.ArcTo(ep1.x, ep1.y, ep2.x, ep2.y, options.Width/2)
|
|
p.ArcTo(ep3.x, ep3.y, ep4.x, ep4.y, options.Width/2)
|
|
case LineCapSquare:
|
|
p.LineTo(ep1.x, ep1.y)
|
|
p.LineTo(ep3.x, ep3.y)
|
|
p.LineTo(ep4.x, ep4.y)
|
|
}
|
|
}
|
|
appendParalleledPathFromSubPathReversed(p, &subPath, &options.StrokeOptions)
|
|
if !subPath.closed {
|
|
switch options.LineCap {
|
|
case LineCapButt:
|
|
p.LineTo(sp4.x, sp4.y)
|
|
case LineCapRound:
|
|
p.ArcTo(sp1.x, sp1.y, sp2.x, sp2.y, options.Width/2)
|
|
p.ArcTo(sp3.x, sp3.y, sp4.x, sp4.y, options.Width/2)
|
|
case LineCapSquare:
|
|
p.LineTo(sp1.x, sp1.y)
|
|
p.LineTo(sp3.x, sp3.y)
|
|
p.LineTo(sp4.x, sp4.y)
|
|
}
|
|
}
|
|
p.Close()
|
|
}
|
|
|
|
if options.GeoM != (ebiten.GeoM{}) {
|
|
for i, subPath := range p.subPaths[origN:] {
|
|
x, y := options.GeoM.Apply(float64(subPath.start.x), float64(subPath.start.y))
|
|
p.subPaths[origN+i].start = point{x: float32(x), y: float32(y)}
|
|
for j, op := range subPath.ops {
|
|
switch op.typ {
|
|
case opTypeLineTo:
|
|
x1, y1 := options.GeoM.Apply(float64(op.p1.x), float64(op.p1.y))
|
|
p.subPaths[origN+i].ops[j].p1 = point{x: float32(x1), y: float32(y1)}
|
|
case opTypeQuadTo:
|
|
x1, y1 := options.GeoM.Apply(float64(op.p1.x), float64(op.p1.y))
|
|
x2, y2 := options.GeoM.Apply(float64(op.p2.x), float64(op.p2.y))
|
|
p.subPaths[origN+i].ops[j].p1 = point{x: float32(x1), y: float32(y1)}
|
|
p.subPaths[origN+i].ops[j].p2 = point{x: float32(x2), y: float32(y2)}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func strokeStartControlPositions(subPath *subPath, dist float32) (point, point, point, point, point) {
|
|
p := subPath.startAtOp(0)
|
|
dir := subPath.startDir(0).inv().norm().mul(dist)
|
|
dirPerp := dir.perp()
|
|
// TODO: These values are a little tricky. Refactor this.
|
|
return p.add(dirPerp), p.add(dir).add(dirPerp), p.add(dir), p.add(dir).add(dirPerp.inv()), p.add(dirPerp.inv())
|
|
}
|
|
|
|
func strokeEndControlPositions(subPath *subPath, dist float32) (point, point, point, point, point) {
|
|
p := subPath.endAtOp(len(subPath.ops) - 1)
|
|
dir := subPath.endDir(len(subPath.ops) - 1).norm().mul(dist)
|
|
dirPerp := dir.perp()
|
|
// TODO: These values are a little tricky. Refactor this.
|
|
return p.add(dirPerp), p.add(dir).add(dirPerp), p.add(dir), p.add(dir).add(dirPerp.inv()), p.add(dirPerp.inv())
|
|
}
|
|
|
|
func appendParalleledPathFromSubPath(strokePath *Path, subPath *subPath, options *StrokeOptions) {
|
|
if len(subPath.ops) == 0 {
|
|
panic("not reached")
|
|
}
|
|
|
|
// As the source path is normalized, every operation is guaranteed to be valid.
|
|
// A line operation must have a different point from the start point.
|
|
// A quadratic curve operation must have create a curve, not a line.
|
|
|
|
cur := subPath.start
|
|
|
|
for i, op := range subPath.ops {
|
|
switch op.typ {
|
|
case opTypeLineTo:
|
|
appendParalleledLine(strokePath, cur, op.p1, options.Width/2)
|
|
cur = op.p1
|
|
case opTypeQuadTo:
|
|
appendParalleledQuad(strokePath, cur, op.p1, op.p2, options.Width/2)
|
|
cur = op.p2
|
|
}
|
|
addJoint(strokePath, subPath, i, false, options)
|
|
}
|
|
}
|
|
|
|
func appendParalleledPathFromSubPathReversed(strokePath *Path, subPath *subPath, options *StrokeOptions) {
|
|
if len(subPath.ops) == 0 {
|
|
panic("not reached")
|
|
}
|
|
|
|
// As the source path is normalized, every operation is guaranteed to be valid.
|
|
// A line operation must have a different point from the start point.
|
|
// A quadratic curve operation must have create a curve, not a line.
|
|
|
|
for i := len(subPath.ops) - 1; i >= 0; i-- {
|
|
op := subPath.ops[i]
|
|
nextP := subPath.startAtOp(i)
|
|
switch op.typ {
|
|
case opTypeLineTo:
|
|
appendParalleledLine(strokePath, op.p1, nextP, options.Width/2)
|
|
case opTypeQuadTo:
|
|
appendParalleledQuad(strokePath, op.p2, op.p1, nextP, options.Width/2)
|
|
}
|
|
addJoint(strokePath, subPath, i, true, options)
|
|
}
|
|
}
|
|
|
|
func appendParalleledLine(path *Path, p0, p1 point, dist float32) {
|
|
if p0 == p1 {
|
|
panic("not reached")
|
|
}
|
|
|
|
dir := vec2{x: p1.x - p0.x, y: p1.y - p0.y}
|
|
v := dir.perp().norm().mul(dist)
|
|
pp1 := p1.add(v)
|
|
path.LineTo(pp1.x, pp1.y)
|
|
}
|
|
|
|
// appendParalleledLineForQuadIfNeeded appends a paralleled line for a quadratic curve if the quadratic curve is just a line.
|
|
func appendParalleledLineForQuadIfNeeded(path *Path, p0, p1, p2 point, dist float32) bool {
|
|
if p0 == p1 && p0 == p2 {
|
|
panic("not reached")
|
|
}
|
|
// This curve is empty as the start and the end points are the same.
|
|
if p0 == p2 {
|
|
return true
|
|
}
|
|
// This curve is a line as the control point is the same as the start point.
|
|
if p0 == p1 || p1 == p2 {
|
|
appendParalleledLine(path, p0, p2, dist)
|
|
return true
|
|
}
|
|
// This curve is a line as p0, p1, and p2 are on the same line.
|
|
if (p1.x-p0.x)*(p2.y-p0.y)-(p2.x-p0.x)*(p1.y-p0.y) == 0 {
|
|
appendParalleledLine(path, p0, p2, dist)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func appendParalleledQuad(path *Path, p0, p1, p2 point, dist float32) {
|
|
if appendParalleledLineForQuadIfNeeded(path, p0, p1, p2, dist) {
|
|
return
|
|
}
|
|
doAppendParalleledQuad(path, p0, p1, p2, dist, 0)
|
|
}
|
|
|
|
func doAppendParalleledQuad(path *Path, p0, p1, p2 point, dist float32, level int) {
|
|
if p0 == p1 && p0 == p2 {
|
|
return
|
|
}
|
|
if appendParalleledLineForQuadIfNeeded(path, p0, p1, p2, dist) {
|
|
return
|
|
}
|
|
|
|
// B(t) = (1-t)*(1-t)*p0 + 2*(1-t)*t*p1 + t*t*p2
|
|
// B'(t) = 2*(1-t)*(p1-p0) + 2*t*(p2-p1)
|
|
// B'(0) = 2*(p1-p0)
|
|
// B'(0.5) = p2-p0
|
|
// B'(1) = 2*(p2-p1)
|
|
// B''(t) = 2*(p0 - 2*p1 + p2)
|
|
|
|
// t = 0
|
|
dir0 := vec2{x: p1.x - p0.x, y: p1.y - p0.y}
|
|
v0 := dir0.perp().norm().mul(dist)
|
|
pp0 := p0.add(v0)
|
|
|
|
// t = 1
|
|
dir2 := vec2{x: p2.x - p1.x, y: p2.y - p1.y}
|
|
v2 := dir2.perp().norm().mul(dist)
|
|
pp2 := p2.add(v2)
|
|
|
|
// t = 0.5
|
|
dir1 := vec2{x: p2.x - p0.x, y: p2.y - p0.y}
|
|
v1 := dir1.perp().norm().mul(dist)
|
|
mid := point{
|
|
x: 0.25*p0.x + 0.5*p1.x + 0.25*p2.x,
|
|
y: 0.25*p0.y + 0.5*p1.y + 0.25*p2.y,
|
|
}.add(v1)
|
|
// Calculate the control point P1 from B(0.5).
|
|
pp1 := point{
|
|
x: 2*mid.x - 0.5*(pp0.x+pp2.x),
|
|
y: 2*mid.y - 0.5*(pp0.y+pp2.y),
|
|
}
|
|
|
|
if level > 5 {
|
|
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
|
|
return
|
|
}
|
|
|
|
// If any of the points is not a regular float32, do not call this function recursively.
|
|
if !isRegularF32(pp0.x) || !isRegularF32(pp0.y) || !isRegularF32(pp1.x) || !isRegularF32(pp1.y) || !isRegularF32(pp2.x) || !isRegularF32(pp2.y) {
|
|
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
|
|
return
|
|
}
|
|
|
|
minAllowance := max(dist*63/64, 0)
|
|
maxAllowance := dist * 65 / 64
|
|
|
|
var needSplit bool
|
|
for _, t := range []float32{0.25, 0.75} {
|
|
gotP := point{
|
|
x: (1-t)*(1-t)*pp0.x + 2*(1-t)*t*pp1.x + t*t*pp2.x,
|
|
y: (1-t)*(1-t)*pp0.y + 2*(1-t)*t*pp1.y + t*t*pp2.y,
|
|
}
|
|
|
|
dir := vec2{
|
|
x: (1-t)*(p1.x-p0.x) + t*(p2.x-p1.x),
|
|
y: (1-t)*(p1.y-p0.y) + t*(p2.y-p1.y),
|
|
}
|
|
v := dir.perp().norm().mul(dist)
|
|
p := point{
|
|
x: (1-t)*(1-t)*p0.x + 2*(1-t)*t*p1.x + t*t*p2.x + v.x,
|
|
y: (1-t)*(1-t)*p0.y + 2*(1-t)*t*p1.y + t*t*p2.y + v.y,
|
|
}
|
|
expectedP := p.add(v)
|
|
|
|
if !arePointsInRange(gotP, expectedP, minAllowance, maxAllowance) {
|
|
needSplit = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !needSplit {
|
|
path.QuadTo(pp1.x, pp1.y, pp2.x, pp2.y)
|
|
return
|
|
}
|
|
|
|
// Split a quadratic curve into two quadratic curves by De Casteljau algorithm.
|
|
p01 := point{
|
|
x: (p0.x + p1.x) / 2,
|
|
y: (p0.y + p1.y) / 2,
|
|
}
|
|
p12 := point{
|
|
x: (p1.x + p2.x) / 2,
|
|
y: (p1.y + p2.y) / 2,
|
|
}
|
|
p012 := point{
|
|
x: (p01.x + p12.x) / 2,
|
|
y: (p01.y + p12.y) / 2,
|
|
}
|
|
doAppendParalleledQuad(path, p0, p01, p012, dist, level+1)
|
|
doAppendParalleledQuad(path, p012, p12, p2, dist, level+1)
|
|
}
|
|
|
|
func addJoint(strokePath *Path, subPath *subPath, opIndex int, reverse bool, options *StrokeOptions) {
|
|
var p point
|
|
var dir0, dir1 vec2
|
|
if !reverse {
|
|
nextOpIdx := opIndex + 1
|
|
if nextOpIdx == len(subPath.ops) {
|
|
if !subPath.closed {
|
|
return
|
|
}
|
|
nextOpIdx = 0
|
|
}
|
|
p = subPath.endAtOp(opIndex)
|
|
dir0 = subPath.endDir(opIndex).norm()
|
|
dir1 = subPath.startDir(nextOpIdx).norm()
|
|
} else {
|
|
nextOpIdx := opIndex - 1
|
|
if nextOpIdx == -1 {
|
|
if !subPath.closed {
|
|
return
|
|
}
|
|
nextOpIdx = len(subPath.ops) - 1
|
|
}
|
|
p = subPath.startAtOp(opIndex)
|
|
dir0 = subPath.startDir(opIndex).inv().norm()
|
|
dir1 = subPath.endDir(nextOpIdx).inv().norm()
|
|
}
|
|
|
|
if dir0 == dir1 {
|
|
return
|
|
}
|
|
|
|
v1 := dir1.perp().mul(options.Width / 2)
|
|
p1 := p.add(v1)
|
|
|
|
// If the joint is an internal angle (< 180 degrees), the joint is not rendered. Just connect the two segments.
|
|
// [vec2.cross] has a precision issue. Use a comparison instead.
|
|
if dir0.x*dir1.y > dir0.y*dir1.x {
|
|
strokePath.LineTo(p1.x, p1.y)
|
|
return
|
|
}
|
|
|
|
v0 := dir0.perp().mul(options.Width / 2)
|
|
p0 := p.add(v0)
|
|
|
|
// Add a joint.
|
|
switch options.LineJoin {
|
|
case LineJoinMiter:
|
|
theta := math.Acos(float64(dir0.x*(-dir1.x) + dir0.y*(-dir1.y)))
|
|
exceed := float32(math.Abs(1/math.Sin(float64(theta/2)))) > options.MiterLimit
|
|
if !exceed {
|
|
cp := crossingPointForTwoLines(p0, p0.add(dir0), p1, p1.add(dir1))
|
|
if isRegularF32(cp.x) && isRegularF32(cp.y) {
|
|
strokePath.LineTo(cp.x, cp.y)
|
|
}
|
|
}
|
|
strokePath.LineTo(p1.x, p1.y)
|
|
case LineJoinBevel:
|
|
strokePath.LineTo(p1.x, p1.y)
|
|
case LineJoinRound:
|
|
dir := vec2{
|
|
x: dir0.x - dir1.x,
|
|
y: dir0.y - dir1.y,
|
|
}.norm()
|
|
cp := p.add(dir.mul(options.Width / 2))
|
|
cp0 := crossingPointForTwoLines(p0, p0.add(dir0), cp, cp.add(dir.perp()))
|
|
cp1 := crossingPointForTwoLines(p1, p1.add(dir1), cp, cp.add(dir.perp()))
|
|
if isRegularF32(cp.x) && isRegularF32(cp.y) && isRegularF32(cp0.x) && isRegularF32(cp0.y) && isRegularF32(cp1.x) && isRegularF32(cp1.y) {
|
|
strokePath.ArcTo(cp0.x, cp0.y, cp.x, cp.y, options.Width/2)
|
|
strokePath.ArcTo(cp1.x, cp1.y, p1.x, p1.y, options.Width/2)
|
|
} else {
|
|
strokePath.LineTo(p1.x, p1.y)
|
|
}
|
|
}
|
|
}
|