diff --git a/state/shape-utils/arrow.tsx b/state/shape-utils/arrow.tsx index cd2ffa8ae..62d294854 100644 --- a/state/shape-utils/arrow.tsx +++ b/state/shape-utils/arrow.tsx @@ -12,6 +12,8 @@ import { isAngleBetween, getPerfectDashProps, clampToRotationToSegments, + lerpAngles, + clamp, } from 'utils' import { ArrowShape, @@ -106,26 +108,26 @@ const arrow = registerShapeUtils({ const isStraightLine = vec.dist(_bend.point, vec.round(vec.med(start.point, end.point))) < 1 + const isDraw = shape.style.dash === DashStyle.Draw + const styles = getShapeStyle(style) const strokeWidth = +styles.strokeWidth - const sw = strokeWidth * 1.618 - const arrowDist = vec.dist(start.point, end.point) + const arrowHeadlength = Math.min(arrowDist / 3, strokeWidth * 8) + let shaftPath: JSX.Element - let startAngle: number - let endAngle: number + let insetStart: number[] + let insetEnd: number[] if (isStraightLine) { - const straight_sw = - strokeWidth * (style.dash === DashStyle.Draw ? 0.618 : 1.618) + const sw = strokeWidth * (isDraw ? 0.618 : 1.618) - const path = - shape.style.dash === DashStyle.Draw - ? renderFreehandArrowShaft(shape) - : 'M' + vec.round(start.point) + 'L' + vec.round(end.point) + const path = isDraw + ? renderFreehandArrowShaft(shape) + : 'M' + vec.round(start.point) + 'L' + vec.round(end.point) const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( arrowDist, @@ -134,9 +136,8 @@ const arrow = registerShapeUtils({ 2 ) - startAngle = Math.PI - - endAngle = 0 + insetStart = vec.nudge(start.point, end.point, arrowHeadlength) + insetEnd = vec.nudge(end.point, start.point, arrowHeadlength) // Straight arrow path shaftPath = ( @@ -154,7 +155,7 @@ const arrow = registerShapeUtils({ d={path} fill={styles.stroke} stroke={styles.stroke} - strokeWidth={straight_sw} + strokeWidth={sw} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} strokeLinecap="round" @@ -164,29 +165,34 @@ const arrow = registerShapeUtils({ } else { const circle = getCtp(shape) - const path = getArrowArcPath(start, end, circle, bend) + const sw = strokeWidth * (isDraw ? 0.618 : 1.618) + + const path = isDraw + ? renderCurvedFreehandArrowShaft(shape, circle) + : getArrowArcPath(start, end, circle, bend) + + const arcLength = getArcLength( + [circle[0], circle[1]], + circle[2], + start.point, + end.point + ) const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - getArcLength( - [circle[0], circle[1]], - circle[2], - start.point, - end.point - ) - 1, + arcLength - 1, sw, shape.style.dash, 2 ) - startAngle = - vec.angle([circle[0], circle[1]], start.point) - - vec.angle(end.point, start.point) + - (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98) + const center = [circle[0], circle[1]] + const radius = circle[2] + const sa = vec.angle(center, start.point) + const ea = vec.angle(center, end.point) + const t = arrowHeadlength / Math.abs(arcLength) - endAngle = - vec.angle([circle[0], circle[1]], end.point) - - vec.angle(start.point, end.point) + - (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98) + insetStart = vec.nudgeAtAngle(center, lerpAngles(sa, ea, t), radius) + insetEnd = vec.nudgeAtAngle(center, lerpAngles(ea, sa, t), radius) // Curved arrow path shaftPath = ( @@ -202,7 +208,7 @@ const arrow = registerShapeUtils({ /> ({ {shaftPath} {shape.decorations.start === Decoration.Arrow && ( )} {shape.decorations.end === Decoration.Arrow && ( @@ -434,23 +440,22 @@ const arrow = registerShapeUtils({ const ap = vec.add(midPoint, vec.mul(vec.per(u), distance / 2)) const bp = vec.sub(midPoint, vec.mul(vec.per(u), distance / 2)) + const bendPoint = vec.nearestPointOnLineSegment(ap, bp, bend.point, true) + // Find the distance between the midpoint and the nearest point on the // line segment to the bend handle's dragged point - const bendDist = vec.dist( - midPoint, - vec.round(vec.nearestPointOnLineSegment(ap, bp, bend.point, true)) - ) + const bendDist = vec.dist(midPoint, bendPoint) // The shape's "bend" is the ratio of the bend to the distance between // the start and end points. If the bend is below a certain amount, the // bend should be zero. - shape.bend = bendDist / (distance / 2) + shape.bend = clamp(bendDist / (distance / 2), -0.99, 0.99) // If the point is to the left of the line segment, we make the bend // negative, otherwise it's positive. - const angleToBend = vec.angle(start.point, bend.point) + const angleToBend = vec.angle(start.point, bendPoint) - if (isAngleBetween(angle, angle + Math.PI / 2, angleToBend)) { + if (isAngleBetween(angle, angle + Math.PI, angleToBend)) { shape.bend *= -1 } } @@ -520,11 +525,13 @@ function getBendPoint(shape: ArrowShape) { const bendDist = (dist / 2) * shape.bend const u = vec.uni(vec.vec(start.point, end.point)) - return vec.round( + const point = vec.round( Math.abs(bendDist) < 10 ? midPoint : vec.add(midPoint, vec.mul(vec.per(u), bendDist)) ) + + return point } function renderFreehandArrowShaft(shape: ArrowShape) { @@ -561,37 +568,68 @@ function renderFreehandArrowShaft(shape: ArrowShape) { return path } -function getArrowHeadPath(shape: ArrowShape, point: number[], angle = 0) { - const { left, right } = getArrowHeadPoints(shape, point, angle) +function renderCurvedFreehandArrowShaft(shape: ArrowShape, circle: number[]) { + const { style, id } = shape + const { start, end } = shape.handles + + const getRandom = rng(id) + + const strokeWidth = +getShapeStyle(style).strokeWidth * 2 + + const st = Math.abs(getRandom()) + + const center = [circle[0], circle[1]] + const radius = circle[2] + + const startAngle = vec.angle(center, start.point) + + const endAngle = vec.angle(center, end.point) + + const points: number[][] = [] + + for (let i = 0; i < 14; i++) { + const t = i / 13 + const angle = lerpAngles(startAngle, endAngle, t) + points.push(vec.round(vec.nudgeAtAngle(center, angle, radius))) + } + + const stroke = getStroke( + [...points, end.point, end.point, end.point, end.point], + { + size: strokeWidth / 2, + thinning: 0.5 + getRandom() * 0.3, + easing: (t) => t * t, + end: { taper: 1 }, + start: { taper: 1 + 32 * (st * st * st) }, + simulatePressure: true, + last: true, + } + ) + + const path = getSvgPathFromStroke(stroke) + + return path +} + +function getArrowHeadPath(shape: ArrowShape, point: number[], inset: number[]) { + const { left, right } = getArrowHeadPoints(shape, point, inset) return ['M', left, 'L', point, right].join(' ') } -function getArrowHeadPoints(shape: ArrowShape, point: number[], angle = 0) { - const { start, end } = shape.handles - - const stroke = +getShapeStyle(shape.style).strokeWidth * 2 - - const arrowDist = vec.dist(start.point, end.point) - - const arrowHeadlength = Math.min(arrowDist / 3, stroke * 4) - - // Unit vector from start to end - const u = vec.uni(vec.vec(start.point, end.point)) - - // The end of the arrowhead wings - const v = vec.rot(vec.mul(vec.neg(u), arrowHeadlength), angle) - +function getArrowHeadPoints( + shape: ArrowShape, + point: number[], + inset: number[] +) { // Use the shape's random seed to create minor offsets for the angles const getRandom = rng(shape.id) return { - left: vec.add( + left: vec.rotWith(inset, point, Math.PI / 6 + (Math.PI / 12) * getRandom()), + right: vec.rotWith( + inset, point, - vec.rot(v, Math.PI / 6 + (Math.PI / 12) * getRandom()) - ), - right: vec.add( - point, - vec.rot(v, -(Math.PI / 6) + (Math.PI / 12) * getRandom()) + -Math.PI / 6 + (Math.PI / 12) * getRandom() ), } } diff --git a/utils/utils.ts b/utils/utils.ts index f8f72a951..df84a5ceb 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -196,20 +196,6 @@ export function getClosestPointOnCircle( return vec.sub(C, vec.mul(vec.div(v, vec.len(v)), r)) } -function det( - a: number, - b: number, - c: number, - d: number, - e: number, - f: number, - g: number, - h: number, - i: number -): number { - return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g -} - /** * Get a circle from three points. * @param A @@ -222,47 +208,27 @@ export function circleFromThreePoints( B: number[], C: number[] ): number[] { - const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1) + const [x1, y1] = A + const [x2, y2] = B + const [x3, y3] = C - const bx = -det( - A[0] * A[0] + A[1] * A[1], - A[1], - 1, - B[0] * B[0] + B[1] * B[1], - B[1], - 1, - C[0] * C[0] + C[1] * C[1], - C[1], - 1 - ) - const by = det( - A[0] * A[0] + A[1] * A[1], - A[0], - 1, - B[0] * B[0] + B[1] * B[1], - B[0], - 1, - C[0] * C[0] + C[1] * C[1], - C[0], - 1 - ) - const c = -det( - A[0] * A[0] + A[1] * A[1], - A[0], - A[1], - B[0] * B[0] + B[1] * B[1], - B[0], - B[1], - C[0] * C[0] + C[1] * C[1], - C[0], - C[1] - ) + const a = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2 - const x = -bx / (2 * a) - const y = -by / (2 * a) - const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)) + const b = + (x1 * x1 + y1 * y1) * (y3 - y2) + + (x2 * x2 + y2 * y2) * (y1 - y3) + + (x3 * x3 + y3 * y3) * (y2 - y1) - return [x, y, r] + const c = + (x1 * x1 + y1 * y1) * (x2 - x3) + + (x2 * x2 + y2 * y2) * (x3 - x1) + + (x3 * x3 + y3 * y3) * (x1 - x2) + + const x = -b / (2 * a) + + const y = -c / (2 * a) + + return [x, y, Math.hypot(x - x1, y - y1)] } /** @@ -281,7 +247,12 @@ export function perimeterOfEllipse(rx: number, ry: number): number { * @param a0 * @param a1 */ -export function shortAngleDist(a0: number, a1: number): number { +export function shortAngleDist(a0: number, a1: number, clamp = true): number { + if (!clamp) { + const da = a1 - a0 + return 2 * da - da + } + const max = Math.PI * 2 const da = (a1 - a0) % max return ((2 * da) % max) - da @@ -303,7 +274,7 @@ export function longAngleDist(a0: number, a1: number): number { * @param t */ export function lerpAngles(a0: number, a1: number, t: number): number { - return a0 + shortAngleDist(a0, a1) * t + return a0 + shortAngleDist(a0, a1, true) * t } /**