Tldraw/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts

197 wiersze
5.6 KiB
TypeScript

import { Vec, VecLike, assert, average, precise, 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]]
let prevV = Vec.Sub(points[1].point, points[0].point).uni()
let nextV: Vec
let dpr: number
let prevPoint: StrokePoint, thisPoint: StrokePoint, nextPoint: StrokePoint
for (let i = 1, n = points.length; i < n - 1; i++) {
prevPoint = points[i - 1]
thisPoint = points[i]
nextPoint = points[i + 1]
nextV = Vec.Sub(nextPoint.point, thisPoint.point).uni()
dpr = Vec.Dpr(prevV, nextV)
prevV = nextV
if (dpr < -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 (dpr > 0.7) {
// Not an elbow
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, angular dist is a normalized representation of how far away the point is from it's neighbors
// (normalized by the radius)
if (
(Vec.Dist2(prevPoint.point, thisPoint.point) + Vec.Dist2(thisPoint.point, nextPoint.point)) /
((prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3) ** 2 <
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]
let nextPoint: StrokePoint
while (partition.length > 2) {
nextPoint = partition[1]
if (
Vec.Dist2(startPoint.point, nextPoint.point) <
(((startPoint.radius + nextPoint.radius) / 2) * 0.5) ** 2
) {
partition.splice(1, 1)
} else {
break
}
}
// clean up end of partition in the same fashion
const endPoint = partition[partition.length - 1]
let prevPoint: StrokePoint
while (partition.length > 2) {
prevPoint = partition[partition.length - 2]
if (
Vec.Dist2(endPoint.point, prevPoint.point) <
(((endPoint.radius + prevPoint.radius) / 2) * 0.5) ** 2
) {
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: Vec.Sub(partition[0].point, partition[1].point).uni(),
}
partition[partition.length - 1] = {
...partition[partition.length - 1],
vector: Vec.Sub(
partition[partition.length - 2].point,
partition[partition.length - 1].point
).uni(),
}
}
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'
)
}
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 = Vec.Add(point.point, Vec.Mul(direction, radius))
const arcEnd = Vec.Add(point.point, Vec.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 = Vec.Add(point.point, Vec.Mul(direction, radius))
const arcEnd = Vec.Add(point.point, Vec.Mul(direction, -radius))
svg += `${precise(arcStart)}A${toDomPrecision(radius)},${toDomPrecision(
radius
)} 0 0 1 ${precise(arcEnd)}Z`
}
return svg
}