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:
parent
a55989f420
commit
82b6287ab3
3 changed files with 282 additions and 48 deletions
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
195
packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts
Normal file
195
packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts
Normal 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
|
||||
}
|
Loading…
Reference in a new issue