tldraw
Version:
A tiny little drawing editor.
594 lines (518 loc) • 15.4 kB
text/typescript
import {
CubicBezier2d,
EASINGS,
HALF_PI,
PI,
PI2,
TLDefaultSizeStyle,
Vec,
VecModel,
centerOfCircleFromThreePoints,
getPointOnCircle,
getPointsOnArc,
perimeterOfEllipse,
rng,
toDomPrecision,
} from '@tldraw/editor'
import { getStrokePoints } from '../shared/freehand/getStrokePoints'
import { getSvgPathFromStrokePoints } from '../shared/freehand/svg'
/* ---------------------- Oval ---------------------- */
export function getOvalPerimeter(h: number, w: number) {
if (h > w) return (PI * (w / 2) + (h - w)) * 2
else return (PI * (h / 2) + (w - h)) * 2
}
/* ---------------------- Heart --------------------- */
export function getHeartPath(w: number, h: number) {
return (
getHeartParts(w, h)
.map((c, i) => c.getSvgPathData(i === 0))
.join(' ') + ' Z'
)
}
export function getDrawHeartPath(w: number, h: number, sw: number, id: string) {
const o = w / 4
const k = h / 4
const random = rng(id)
const mutDistance = sw * 0.75
const mut = (v: Vec) => v.addXY(random() * mutDistance, random() * mutDistance)
const A = new Vec(w / 2, h)
const B = new Vec(0, k * 1.2)
const C = new Vec(w / 2, k * 0.9)
const D = new Vec(w, k * 1.2)
const Am = mut(new Vec(w / 2, h))
const Bm = mut(new Vec(0, k * 1.2))
const Cm = mut(new Vec(w / 2, k * 0.9))
const Dm = mut(new Vec(w, k * 1.2))
const parts = [
new CubicBezier2d({
start: A,
cp1: new Vec(o * 1.5, k * 3),
cp2: new Vec(0, k * 2.5),
end: B,
}),
new CubicBezier2d({
start: B,
cp1: new Vec(0, -k * 0.32),
cp2: new Vec(o * 1.85, -k * 0.32),
end: C,
}),
new CubicBezier2d({
start: C,
cp1: new Vec(o * 2.15, -k * 0.32),
cp2: new Vec(w, -k * 0.32),
end: D,
}),
new CubicBezier2d({
start: D,
cp1: new Vec(w, k * 2.5),
cp2: new Vec(o * 2.5, k * 3),
end: Am,
}),
new CubicBezier2d({
start: Am,
cp1: new Vec(o * 1.5, k * 3),
cp2: new Vec(0, k * 2.5),
end: Bm,
}),
new CubicBezier2d({
start: Bm,
cp1: new Vec(0, -k * 0.32),
cp2: new Vec(o * 1.85, -k * 0.32),
end: Cm,
}),
new CubicBezier2d({
start: Cm,
cp1: new Vec(o * 2.15, -k * 0.32),
cp2: new Vec(w, -k * 0.32),
end: Dm,
}),
new CubicBezier2d({
start: Dm,
cp1: new Vec(w, k * 2.5),
cp2: new Vec(o * 2.5, k * 3),
end: A,
}),
]
return parts.map((c, i) => c.getSvgPathData(i === 0)).join(' ') + ' Z'
}
export function getHeartPoints(w: number, h: number) {
const points = [] as Vec[]
const curves = getHeartParts(w, h)
for (let i = 0; i < curves.length; i++) {
for (let j = 0; j < 20; j++) {
points.push(CubicBezier2d.GetAtT(curves[i], j / 20))
}
if (i === curves.length - 1) {
points.push(CubicBezier2d.GetAtT(curves[i], 1))
}
}
}
export function getHeartParts(w: number, h: number) {
const o = w / 4
const k = h / 4
return [
new CubicBezier2d({
start: new Vec(w / 2, h),
cp1: new Vec(o * 1.5, k * 3),
cp2: new Vec(0, k * 2.5),
end: new Vec(0, k * 1.2),
}),
new CubicBezier2d({
start: new Vec(0, k * 1.2),
cp1: new Vec(0, -k * 0.32),
cp2: new Vec(o * 1.85, -k * 0.32),
end: new Vec(w / 2, k * 0.9),
}),
new CubicBezier2d({
start: new Vec(w / 2, k * 0.9),
cp1: new Vec(o * 2.15, -k * 0.32),
cp2: new Vec(w, -k * 0.32),
end: new Vec(w, k * 1.2),
}),
new CubicBezier2d({
start: new Vec(w, k * 1.2),
cp1: new Vec(w, k * 2.5),
cp2: new Vec(o * 2.5, k * 3),
end: new Vec(w / 2, h),
}),
]
}
/* --------------------- Ellipse -------------------- */
function getEllipseStrokeOptions(strokeWidth: number) {
return {
size: 1 + strokeWidth,
thinning: 0.25,
end: { taper: strokeWidth },
start: { taper: strokeWidth },
streamline: 0,
smoothing: 1,
simulatePressure: false,
}
}
function getEllipseStrokePoints(id: string, width: number, height: number, strokeWidth: number) {
const getRandom = rng(id)
const rx = width / 2
const ry = height / 2
const perimeter = perimeterOfEllipse(rx, ry)
const points: Vec[] = []
const start = PI2 * getRandom()
const length = PI2 + HALF_PI / 2 + Math.abs(getRandom()) * HALF_PI
const count = Math.max(16, perimeter / 10)
for (let i = 0; i < count; i++) {
const t = i / (count - 1)
const r = start + t * length
const c = Math.cos(r)
const s = Math.sin(r)
points.push(
new Vec(
rx * c + width * 0.5 + 0.05 * getRandom(),
ry * s + height / 2 + 0.05 * getRandom(),
Math.min(
1,
0.5 +
Math.abs(0.5 - (getRandom() > 0 ? EASINGS.easeInOutSine(t) : EASINGS.easeInExpo(t))) / 2
)
)
)
}
return getStrokePoints(points, getEllipseStrokeOptions(strokeWidth))
}
export function getEllipseDrawIndicatorPath(
id: string,
width: number,
height: number,
strokeWidth: number
) {
return getSvgPathFromStrokePoints(getEllipseStrokePoints(id, width, height, strokeWidth))
}
export function getEllipsePath(w: number, h: number) {
const cx = w / 2
const cy = h / 2
const rx = Math.max(0, cx)
const ry = Math.max(0, cy)
return `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0`
}
/* --------------------- Polygon -------------------- */
import { VecLike, precise } from '@tldraw/editor'
/** @public */
export function getRoundedInkyPolygonPath(points: VecLike[]) {
let polylineA = `M`
const len = points.length
let p0: VecLike
let p1: VecLike
let p2: VecLike
for (let i = 0, n = len; i < n; i += 3) {
p0 = points[i]
p1 = points[i + 1]
p2 = points[i + 2]
polylineA += `${precise(p0)}L${precise(p1)}Q${precise(p2)}`
}
polylineA += `${precise(points[0])}`
return polylineA
}
/** @public */
export function getRoundedPolygonPoints(
id: string,
outline: VecLike[],
offset: number,
roundness: number,
passes: number
) {
const results: VecLike[] = []
const random = rng(id)
let p0 = outline[0]
let p1: VecLike
const len = outline.length
for (let i = 0, n = len * passes; i < n; i++) {
p1 = Vec.AddXY(outline[(i + 1) % len], random() * offset, random() * offset)
const delta = Vec.Sub(p1, p0)
const distance = Vec.Len(delta)
const vector = Vec.Div(delta, distance).mul(Math.min(distance / 4, roundness))
results.push(Vec.Add(p0, vector), Vec.Add(p1, vector.neg()), p1)
p0 = p1
}
return results
}
/* ---------------------- Cloud --------------------- */
type PillSection =
| {
type: 'straight'
start: VecModel
delta: VecModel
}
| {
type: 'arc'
center: VecModel
startAngle: number
}
function getPillPoints(width: number, height: number, numPoints: number) {
const radius = Math.min(width, height) / 2
const longSide = Math.max(width, height) - radius * 2
const circumference = Math.PI * (radius * 2) + 2 * longSide
const spacing = circumference / numPoints
const sections: PillSection[] =
width > height
? [
{
type: 'straight',
start: new Vec(radius, 0),
delta: new Vec(1, 0),
},
{
type: 'arc',
center: new Vec(width - radius, radius),
startAngle: -PI / 2,
},
{
type: 'straight',
start: new Vec(width - radius, height),
delta: new Vec(-1, 0),
},
{
type: 'arc',
center: new Vec(radius, radius),
startAngle: PI / 2,
},
]
: [
{
type: 'straight',
start: new Vec(width, radius),
delta: new Vec(0, 1),
},
{
type: 'arc',
center: new Vec(radius, height - radius),
startAngle: 0,
},
{
type: 'straight',
start: new Vec(0, height - radius),
delta: new Vec(0, -1),
},
{
type: 'arc',
center: new Vec(radius, radius),
startAngle: PI,
},
]
let sectionOffset = 0
const points: Vec[] = []
for (let i = 0; i < numPoints; i++) {
const section = sections[0]
if (section.type === 'straight') {
points.push(Vec.Add(section.start, Vec.Mul(section.delta, sectionOffset)))
} else {
points.push(
getPointOnCircle(section.center, radius, section.startAngle + sectionOffset / radius)
)
}
sectionOffset += spacing
let sectionLength = section.type === 'straight' ? longSide : PI * radius
while (sectionOffset > sectionLength) {
sectionOffset -= sectionLength
sections.push(sections.shift()!)
sectionLength = sections[0].type === 'straight' ? longSide : PI * radius
}
}
return points
}
const SIZES: Record<TLDefaultSizeStyle, number> = {
s: 50,
m: 70,
l: 100,
xl: 130,
}
const BUMP_PROTRUSION = 0.2
export function getCloudArcs(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle,
scale: number
) {
const getRandom = rng(seed)
const pillCircumference = getOvalPerimeter(width, height)
const numBumps = Math.max(
Math.ceil(pillCircumference / SIZES[size]),
6,
Math.ceil(pillCircumference / Math.min(width, height))
)
const targetBumpProtrusion = (pillCircumference / numBumps) * BUMP_PROTRUSION
// if the aspect ratio is high, innerWidth should be smaller
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1)
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1)
const innerCircumference = getOvalPerimeter(innerWidth, innerHeight)
const distanceBetweenPointsOnPerimeter = innerCircumference / numBumps
const paddingX = (width - innerWidth) / 2
const paddingY = (height - innerHeight) / 2
const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => {
return p.addXY(paddingX, paddingY)
})
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3
// wiggle the points from either end so that the bumps 'pop'
// in at the bottom-right and the top-left looks relatively stable
// note: it's important that we don't mutate here! these points are also the bump points
const wiggledPoints = bumpPoints.slice(0)
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
wiggledPoints[i] = Vec.AddXY(
wiggledPoints[i],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
)
wiggledPoints[numBumps - i - 1] = Vec.AddXY(
wiggledPoints[numBumps - i - 1],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
)
}
const arcs: Arc[] = []
for (let i = 0; i < wiggledPoints.length; i++) {
const j = i === wiggledPoints.length - 1 ? 0 : i + 1
const leftWigglePoint = wiggledPoints[i]
const rightWigglePoint = wiggledPoints[j]
const leftPoint = bumpPoints[i]
const rightPoint = bumpPoints[j]
// when the points are on the curvy part of a pill, there is a natural arc that we need to extends past
// otherwise it looks like the bumps get less bumpy on the curvy parts
const distanceBetweenOriginalPoints = Vec.Dist(leftPoint, rightPoint)
const curvatureOffset = distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints
const distanceBetweenWigglePoints = Vec.Dist(leftWigglePoint, rightWigglePoint)
const relativeSize = distanceBetweenWigglePoints / distanceBetweenOriginalPoints
const finalDistance = (Math.max(paddingX, paddingY) + curvatureOffset) * relativeSize
const arcPoint = Vec.Lrp(leftPoint, rightPoint, 0.5).add(
Vec.Sub(rightPoint, leftPoint).uni().per().mul(finalDistance)
)
if (arcPoint.x < 0) {
arcPoint.x = 0
} else if (arcPoint.x > width) {
arcPoint.x = width
}
if (arcPoint.y < 0) {
arcPoint.y = 0
} else if (arcPoint.y > height) {
arcPoint.y = height
}
const center = centerOfCircleFromThreePoints(leftWigglePoint, rightWigglePoint, arcPoint)
const radius = Vec.Dist(
center ? center : Vec.Average([leftWigglePoint, rightWigglePoint]),
leftWigglePoint
)
// todo: could use Arc2d here
arcs.push({
leftPoint: leftWigglePoint,
rightPoint: rightWigglePoint,
arcPoint,
center,
radius,
})
}
return arcs
}
interface Arc {
leftPoint: Vec
rightPoint: Vec
arcPoint: Vec
center: Vec | null
radius: number
}
export function cloudOutline(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle,
scale: number
) {
const path: Vec[] = []
const arcs = getCloudArcs(width, height, seed, size, scale)
for (const { center, radius, leftPoint, rightPoint } of arcs) {
path.push(...getPointsOnArc(leftPoint, rightPoint, center, radius, 10))
}
return path
}
export function getCloudPath(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle,
scale: number
) {
// const points = cloudOutline(width, height, seed, size)
// {
// let path = `M${toDomPrecision(points[0].x)},${toDomPrecision(points[0].y)}`
// for (const point of points.slice(1)) {
// path += ` L${toDomPrecision(point.x)},${toDomPrecision(point.y)}`
// }
// return path
// }
const arcs = getCloudArcs(width, height, seed, size, scale)
let path = `M${arcs[0].leftPoint.toFixed()}`
// now draw arcs for each circle, starting where it intersects the previous circle, and ending where it intersects the next circle
for (const { leftPoint, rightPoint, radius, center } of arcs) {
if (center === null) {
// draw a line to rightPoint instead
path += ` L${rightPoint.toFixed()}`
continue
}
// use the large arc if the center of the circle is to the left of the line between the two points
const arc = Vec.Clockwise(leftPoint, rightPoint, center) ? '0' : '1'
path += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${rightPoint.toFixed()}`
}
path += ' Z'
return path
}
const DRAW_OFFSETS: Record<TLDefaultSizeStyle, number> = {
s: 0.5,
m: 0.7,
l: 0.9,
xl: 1.6,
}
export function inkyCloudSvgPath(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle,
scale: number
) {
const getRandom = rng(seed)
const mutMultiplier = DRAW_OFFSETS[size] * scale
const arcs = getCloudArcs(width, height, seed, size, scale)
const avgArcLengthSquared =
arcs.reduce((sum, arc) => sum + Vec.Dist2(arc.leftPoint, arc.rightPoint), 0) / arcs.length
const shouldMutatePoints = avgArcLengthSquared > (mutMultiplier * 15) ** 2
const mutPoint = shouldMutatePoints
? (p: Vec) => Vec.AddXY(p, getRandom() * mutMultiplier * 2, getRandom() * mutMultiplier * 2)
: (p: Vec) => p
let pathA = `M${arcs[0].leftPoint.toFixed()}`
let leftMutPoint = mutPoint(arcs[0].leftPoint)
let pathB = `M${leftMutPoint.toFixed()}`
for (const { leftPoint, center, rightPoint, radius, arcPoint } of arcs) {
if (center === null) {
// draw a line to rightPoint instead
pathA += ` L${rightPoint.toFixed()}`
const rightMutPoint = mutPoint(rightPoint)
pathB += ` L${rightMutPoint.toFixed()}`
leftMutPoint = rightMutPoint
continue
}
const arc = Vec.Clockwise(leftPoint, rightPoint, center) ? '0' : '1'
pathA += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${rightPoint.toFixed()}`
const rightMutPoint = mutPoint(rightPoint)
const mutArcPoint = mutPoint(arcPoint)
const mutCenter = centerOfCircleFromThreePoints(leftMutPoint, rightMutPoint, mutArcPoint)
// handle situations where the points are colinear (this happens when the cloud is very small)
if (!mutCenter) {
// draw a line to rightMutPoint instead
pathB += ` L${rightMutPoint.toFixed()}`
leftMutPoint = rightMutPoint
continue
}
const mutRadius = Math.abs(Vec.Dist(mutCenter, leftMutPoint))
pathB += ` A${toDomPrecision(mutRadius)},${toDomPrecision(
mutRadius
)} 0 ${arc},1 ${rightMutPoint.toFixed()}`
leftMutPoint = rightMutPoint
}
return pathA + pathB + ' Z'
}