Hot elbows (#2258)

Our ink has kinda homely elbows sometimes. This PR believes elbows can
and should be beautiful.

The way this is achieved is by partitioning the points fed into
perfect-freehand at elbow points, and then rendering each partition
separately.

Doing this naively ballooned the size of the SVG path data so I also did
a tiny bit of refactoring to allow us to use the SVG arc command. At the
same time we are able to easily omit some of the points around the
beginnings and ends of segments thanks to the nature of the corners.

All of this results in an average 13% reduction in SVG path data size
over the current version.


![Kapture 2023-11-27 at 14 39
57](https://github.com/tldraw/tldraw/assets/1242537/44882d93-f06b-4956-af97-0113b2d8e8ef)


### Change Type

- [x] `patch` — Bug fix

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
David Sheldrick 2023-11-29 11:56:47 +00:00 committed by GitHub
parent a55989f420
commit 82b6287ab3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 282 additions and 48 deletions

View file

@ -27,6 +27,7 @@ import { getStrokeOutlinePoints } from '../shared/freehand/getStrokeOutlinePoint
import { getStrokePoints } from '../shared/freehand/getStrokePoints'
import { setStrokePointRadii } from '../shared/freehand/setStrokePointRadii'
import { getSvgPathFromStrokePoints } from '../shared/freehand/svg'
import { svgInk } from '../shared/freehand/svgInk'
import { useForceSolid } from '../shared/useForceSolid'
import { getDrawShapeStrokeDashArray, getFreehandOptions, getPointsFromSegments } from './getPath'
@ -108,27 +109,23 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
}
const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
: getDot(allPointsFromSegments[0], sw)
if ((!forceSolid && shape.props.dash === 'draw') || strokePoints.length < 2) {
setStrokePointRadii(strokePoints, options)
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options)
if (!forceSolid && shape.props.dash === 'draw') {
return (
<SVGContainer id={shape.id}>
<ShapeFill
theme={theme}
fill={shape.props.isClosed ? shape.props.fill : 'none'}
color={shape.props.color}
d={solidStrokePath}
/>
{shape.props.isClosed && shape.props.fill && allPointsFromSegments.length > 1 ? (
<ShapeFill
theme={theme}
fill={shape.props.isClosed ? shape.props.fill : 'none'}
color={shape.props.color}
d={getSvgPathFromStrokePoints(
getStrokePoints(allPointsFromSegments, options),
shape.props.isClosed
)}
/>
) : null}
<path
d={getSvgPathFromPoints(strokeOutlinePoints, true)}
d={svgInk(allPointsFromSegments, options)}
strokeLinecap="round"
fill={theme[shape.props.color].solid}
/>
@ -136,21 +133,27 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
)
}
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const isDot = strokePoints.length < 2
const solidStrokePath = isDot
? getDot(allPointsFromSegments[0], 0)
: getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
return (
<SVGContainer id={shape.id}>
<ShapeFill
theme={theme}
color={shape.props.color}
fill={shape.props.isClosed ? shape.props.fill : 'none'}
fill={isDot || shape.props.isClosed ? shape.props.fill : 'none'}
d={solidStrokePath}
/>
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
fill={isDot ? theme[shape.props.color].solid : 'none'}
stroke={theme[shape.props.color].solid}
strokeWidth={strokeWidth}
strokeDasharray={getDrawShapeStrokeDashArray(shape, strokeWidth)}
strokeDasharray={isDot ? 'none' : getDrawShapeStrokeDashArray(shape, strokeWidth)}
strokeDashoffset="0"
/>
</SVGContainer>

View file

@ -7,26 +7,17 @@ const { PI } = Math
const FIXED_PI = PI + 0.0001
/**
* ## getStrokeOutlinePoints
*
* Get an array of points (as `[x, y]`) representing the outline of a stroke.
*
* @param points - An array of StrokePoints as returned from `getStrokePoints`.
* @param options - An object with options.
* @public
* @internal
*/
export function getStrokeOutlinePoints(
export function getStrokeOutlineTracks(
strokePoints: StrokePoint[],
options: StrokeOptions = {}
): Vec2d[] {
const { size = 16, smoothing = 0.5, start = {}, end = {}, last: isComplete = false } = options
const { cap: capStart = true } = start
const { cap: capEnd = true } = end
): { left: Vec2d[]; right: Vec2d[] } {
const { size = 16, smoothing = 0.5 } = options
// We can't do anything with an empty array or a stroke with negative size.
if (strokePoints.length === 0 || size <= 0) {
return []
return { left: [], right: [] }
}
const firstStrokePoint = strokePoints[0]
@ -35,20 +26,6 @@ export function getStrokeOutlinePoints(
// The total length of the line
const totalLength = lastStrokePoint.runningLength
const taperStart =
start.taper === false
? 0
: start.taper === true
? Math.max(size, totalLength)
: (start.taper as number)
const taperEnd =
end.taper === false
? 0
: end.taper === true
? Math.max(size, totalLength)
: (end.taper as number)
// The minimum allowed distance between points (squared)
const minDistance = Math.pow(size * smoothing, 2)
@ -185,6 +162,65 @@ export function getStrokeOutlinePoints(
continue
}
/*
Return the points in the correct winding order: begin on the left side, then
continue around the end cap, then come back along the right side, and finally
complete the start cap.
*/
return {
left: leftPts,
right: rightPts,
}
}
/**
* ## getStrokeOutlinePoints
*
* Get an array of points (as `[x, y]`) representing the outline of a stroke.
*
* @param points - An array of StrokePoints as returned from `getStrokePoints`.
* @param options - An object with options.
* @public
*/
export function getStrokeOutlinePoints(
strokePoints: StrokePoint[],
options: StrokeOptions = {}
): Vec2d[] {
const { size = 16, start = {}, end = {}, last: isComplete = false } = options
const { cap: capStart = true } = start
const { cap: capEnd = true } = end
// We can't do anything with an empty array or a stroke with negative size.
if (strokePoints.length === 0 || size <= 0) {
return []
}
const firstStrokePoint = strokePoints[0]
const lastStrokePoint = strokePoints[strokePoints.length - 1]
// The total length of the line
const totalLength = lastStrokePoint.runningLength
const taperStart =
start.taper === false
? 0
: start.taper === true
? Math.max(size, totalLength)
: (start.taper as number)
const taperEnd =
end.taper === false
? 0
: end.taper === true
? Math.max(size, totalLength)
: (end.taper as number)
// The minimum allowed distance between points (squared)
// Our collected left and right points
const { left: leftPts, right: rightPts } = getStrokeOutlineTracks(strokePoints, options)
/*
Drawing caps

View file

@ -0,0 +1,195 @@
import {
Vec2d,
VecLike,
assert,
average,
precise,
shortAngleDist,
toDomPrecision,
} from '@tldraw/editor'
import { getStrokeOutlineTracks } from './getStrokeOutlinePoints'
import { getStrokePoints } from './getStrokePoints'
import { setStrokePointRadii } from './setStrokePointRadii'
import { StrokeOptions, StrokePoint } from './types'
export function svgInk(rawInputPoints: VecLike[], options: StrokeOptions = {}) {
const { start = {}, end = {} } = options
const { cap: capStart = true } = start
const { cap: capEnd = true } = end
assert(!start.taper && !end.taper, 'cap taper not supported here')
assert(!start.easing && !end.easing, 'cap easing not supported here')
assert(capStart && capEnd, 'cap must be true')
const points = getStrokePoints(rawInputPoints, options)
setStrokePointRadii(points, options)
const partitions = partitionAtElbows(points)
let svg = ''
for (const partition of partitions) {
svg += renderPartition(partition, options)
}
return svg
}
function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
if (points.length <= 2) return [points]
const result: StrokePoint[][] = []
let currentPartition: StrokePoint[] = [points[0]]
for (let i = 1; i < points.length - 1; i++) {
const prevPoint = points[i - 1]
const thisPoint = points[i]
const nextPoint = points[i + 1]
const prevAngle = Vec2d.Angle(prevPoint.point, thisPoint.point)
const nextAngle = Vec2d.Angle(thisPoint.point, nextPoint.point)
// acuteness is a normalized representation of how acute the angle is.
// 1 is an infinitely thin wedge
// 0 is a straight line
const acuteness = Math.abs(shortAngleDist(prevAngle, nextAngle)) / Math.PI
if (acuteness > 0.8) {
// always treat such acute angles as elbows
// and use the extended .input point as the elbow point for swooshiness in fast zaggy lines
const elbowPoint = {
...thisPoint,
point: thisPoint.input,
}
currentPartition.push(elbowPoint)
result.push(cleanUpPartition(currentPartition))
currentPartition = [elbowPoint]
continue
}
currentPartition.push(thisPoint)
if (acuteness < 0.25) {
// this is not an elbow, bail out
continue
}
// so now we have a reasonably acute angle but it might not be an elbow if it's far
// away from it's neighbors
const avgRadius = (prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3
const incomingNormalizedDist = Vec2d.Dist(prevPoint.point, thisPoint.point) / avgRadius
const outgoingNormalizedDist = Vec2d.Dist(thisPoint.point, nextPoint.point) / avgRadius
// angular dist is a normalized representation of how far away the point is from it's neighbors
// (normalized by the radius)
const angularDist = incomingNormalizedDist + outgoingNormalizedDist
if (angularDist < 1.5) {
// if this point is kinda close to its neighbors and it has a reasonably
// acute angle, it's probably a hard elbow
currentPartition.push(thisPoint)
result.push(cleanUpPartition(currentPartition))
currentPartition = [thisPoint]
continue
}
}
currentPartition.push(points[points.length - 1])
result.push(cleanUpPartition(currentPartition))
return result
}
function cleanUpPartition(partition: StrokePoint[]) {
// clean up start of partition (remove points that are too close to the start)
const startPoint = partition[0]
while (partition.length > 2) {
const nextPoint = partition[1]
const dist = Vec2d.Dist(startPoint.point, nextPoint.point)
const avgRadius = (startPoint.radius + nextPoint.radius) / 2
if (dist < avgRadius * 0.5) {
partition.splice(1, 1)
} else {
break
}
}
// clean up end of partition in the same fashion
const endPoint = partition[partition.length - 1]
while (partition.length > 2) {
const prevPoint = partition[partition.length - 2]
const dist = Vec2d.Dist(endPoint.point, prevPoint.point)
const avgRadius = (endPoint.radius + prevPoint.radius) / 2
if (dist < avgRadius * 0.5) {
partition.splice(partition.length - 2, 1)
} else {
break
}
}
// now readjust the cap point vectors to point to their nearest neighbors
if (partition.length > 1) {
partition[0] = {
...partition[0],
vector: Vec2d.FromAngle(Vec2d.Angle(partition[1].point, partition[0].point)),
}
partition[partition.length - 1] = {
...partition[partition.length - 1],
vector: Vec2d.FromAngle(
Vec2d.Angle(partition[partition.length - 1].point, partition[partition.length - 2].point)
),
}
}
return partition
}
function circlePath(cx: number, cy: number, r: number) {
return (
'M ' +
cx +
' ' +
cy +
' m -' +
r +
', 0 a ' +
r +
',' +
r +
' 0 1,1 ' +
r * 2 +
',0 a ' +
r +
',' +
r +
' 0 1,1 -' +
r * 2 +
',0'
)
}
export function renderPartition(strokePoints: StrokePoint[], options: StrokeOptions = {}): string {
if (strokePoints.length === 0) return ''
if (strokePoints.length === 1) {
return circlePath(strokePoints[0].point.x, strokePoints[0].point.y, strokePoints[0].radius)
}
const { left, right } = getStrokeOutlineTracks(strokePoints, options)
right.reverse()
let svg = `M${precise(left[0])}T`
// draw left track
for (let i = 1; i < left.length; i++) {
svg += average(left[i - 1], left[i])
}
// draw end cap arc
{
const point = strokePoints[strokePoints.length - 1]
const radius = point.radius
const direction = point.vector.clone().per().neg()
const arcStart = Vec2d.Add(point.point, Vec2d.Mul(direction, radius))
const arcEnd = Vec2d.Add(point.point, Vec2d.Mul(direction, -radius))
svg += `${precise(arcStart)}A${toDomPrecision(radius)},${toDomPrecision(
radius
)} 0 0 1 ${precise(arcEnd)}T`
}
// draw right track
for (let i = 1; i < right.length; i++) {
svg += average(right[i - 1], right[i])
}
// draw start cap arc
{
const point = strokePoints[0]
const radius = point.radius
const direction = point.vector.clone().per()
const arcStart = Vec2d.Add(point.point, Vec2d.Mul(direction, radius))
const arcEnd = Vec2d.Add(point.point, Vec2d.Mul(direction, -radius))
svg += `${precise(arcStart)}A${toDomPrecision(radius)},${toDomPrecision(
radius
)} 0 0 1 ${precise(arcEnd)}Z`
}
return svg
}