Tldraw/packages/editor/src/lib/editor/shapeutils/ArrowShapeUtil/arrow/curved-arrow.ts

423 wiersze
12 KiB
TypeScript

import {
Box2d,
getArcLength,
getPointOnCircle,
intersectCirclePolygon,
intersectCirclePolyline,
isSafeFloat,
lerpAngles,
Matrix2d,
PI,
PI2,
shortAngleDist,
Vec2d,
VecLike,
} from '@tldraw/primitives'
import { TLArrowShape } from '@tldraw/tlschema'
import {
BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH,
WAY_TOO_BIG_ARROW_BEND_FACTOR,
} from '../../../../constants'
import type { Editor } from '../../../Editor'
import { ArcInfo, ArrowInfo } from './arrow-types'
import { getArrowTerminalsInArrowSpace, getBoundShapeInfoForTerminal } from './shared'
import { getStraightArrowInfo } from './straight-arrow'
export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBend = 0): ArrowInfo {
const { arrowheadEnd, arrowheadStart } = shape.props
const bend = shape.props.bend + extraBend
if (Math.abs(bend) > Math.abs(shape.props.bend * WAY_TOO_BIG_ARROW_BEND_FACTOR)) {
return getStraightArrowInfo(editor, shape)
}
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape)
const med = Vec2d.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end) // point between start and end
const u = Vec2d.Sub(terminalsInArrowSpace.end, terminalsInArrowSpace.start).uni() // unit vector between start and end
const middle = Vec2d.Add(med, u.per().mul(-bend)) // middle handle
const startShapeInfo = getBoundShapeInfoForTerminal(editor, shape.props.start)
const endShapeInfo = getBoundShapeInfoForTerminal(editor, shape.props.end)
// The positions of the body of the arrow, which may be different
// than the arrow's start / end points if the arrow is bound to shapes
const a = terminalsInArrowSpace.start.clone()
const b = terminalsInArrowSpace.end.clone()
const c = middle.clone()
const handleArc = getArcInfo(a, b, c)
if (
handleArc.length === 0 ||
handleArc.size === 0 ||
!isSafeFloat(handleArc.length) ||
!isSafeFloat(handleArc.size)
) {
return getStraightArrowInfo(editor, shape)
}
const arrowPageTransform = editor.getPageTransform(shape)!
if (startShapeInfo && !startShapeInfo.isExact) {
// Points in page space
const startInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, a)
const endInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, b)
const centerInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, handleArc.center)
// Points in local space of the start shape
const inverseTransform = Matrix2d.Inverse(startShapeInfo.transform)
const startInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, startInPageSpace)
const endInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const centerInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, centerInPageSpace)
const isClosed = startShapeInfo.util.isClosed(startShapeInfo.shape)
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
let point: VecLike | undefined
let intersections = fn(
centerInStartShapeLocalSpace,
handleArc.radius,
startShapeInfo.util.outline(startShapeInfo.shape)
)
if (intersections) {
intersections = intersections.filter(
(pt) =>
+Vec2d.Clockwise(startInStartShapeLocalSpace, pt, endInStartShapeLocalSpace) ===
handleArc.sweepFlag
)
const angleToMiddle = Vec2d.Angle(handleArc.center, middle)
const angleToStart = Vec2d.Angle(handleArc.center, terminalsInArrowSpace.start)
const comparisonAngle = lerpAngles(angleToMiddle, angleToStart, 0.5)
intersections.sort(
(p0, p1) =>
Math.abs(shortAngleDist(comparisonAngle, centerInStartShapeLocalSpace.angle(p0))) -
Math.abs(shortAngleDist(comparisonAngle, centerInStartShapeLocalSpace.angle(p1)))
)
point = intersections[0] ?? (isClosed ? undefined : startInStartShapeLocalSpace)
} else {
point = isClosed ? undefined : startInStartShapeLocalSpace
}
if (point) {
a.setTo(
editor.getPointInShapeSpace(shape, Matrix2d.applyToPoint(startShapeInfo.transform, point))
)
startShapeInfo.didIntersect = true
if (arrowheadStart !== 'none') {
const offset =
BOUND_ARROW_OFFSET +
editor.getStrokeWidth(shape.props.size) / 2 +
('size' in startShapeInfo.shape.props
? editor.getStrokeWidth(startShapeInfo.shape.props.size) / 2
: 0)
a.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(
Vec2d.Angle(handleArc.center, a),
Vec2d.Angle(handleArc.center, middle),
offset / Math.abs(getArcLength(handleArc.center, handleArc.radius, a, middle))
)
)
)
}
}
}
if (endShapeInfo && !endShapeInfo.isExact) {
// get points in shape's coordinates?
const startInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, a)
const endInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, b)
const centerInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, handleArc.center)
const inverseTransform = Matrix2d.Inverse(endShapeInfo.transform)
const startInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, startInPageSpace)
const endInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const centerInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, centerInPageSpace)
const isClosed = endShapeInfo.util.isClosed(endShapeInfo.shape)
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
const angleToMiddle = Vec2d.Angle(handleArc.center, middle)
const angleToEnd = Vec2d.Angle(handleArc.center, terminalsInArrowSpace.end)
const comparisonAngle = lerpAngles(angleToMiddle, angleToEnd, 0.5)
let point: VecLike | undefined
let intersections = fn(
centerInEndShapeLocalSpace,
handleArc.radius,
endShapeInfo.util.outline(endShapeInfo.shape)
)
if (intersections) {
intersections = intersections.filter(
(pt) =>
+Vec2d.Clockwise(startInEndShapeLocalSpace, pt, endInEndShapeLocalSpace) ===
handleArc.sweepFlag
)
intersections.sort(
(p0, p1) =>
Math.abs(shortAngleDist(comparisonAngle, centerInEndShapeLocalSpace.angle(p0))) -
Math.abs(shortAngleDist(comparisonAngle, centerInEndShapeLocalSpace.angle(p1)))
)
point = intersections[0] ?? (isClosed ? undefined : endInEndShapeLocalSpace)
} else {
point = isClosed ? undefined : endInEndShapeLocalSpace
}
if (point) {
// Set b to target local point -> page point -> shape local point
b.setTo(
editor.getPointInShapeSpace(shape, Matrix2d.applyToPoint(endShapeInfo.transform, point))
)
endShapeInfo.didIntersect = true
if (arrowheadEnd !== 'none') {
let offset =
BOUND_ARROW_OFFSET +
editor.getStrokeWidth(shape.props.size) / 2 +
('size' in endShapeInfo.shape.props
? editor.getStrokeWidth(endShapeInfo.shape.props.size) / 2
: 0)
if (Vec2d.Dist(a, b) < MIN_ARROW_LENGTH) {
offset *= -2
}
b.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(
Vec2d.Angle(handleArc.center, b),
Vec2d.Angle(handleArc.center, middle),
offset / Math.abs(getArcLength(handleArc.center, handleArc.radius, b, middle))
)
)
)
}
}
}
const length = Math.abs(getArcLength(handleArc.center, handleArc.radius, a, b))
if (length < MIN_ARROW_LENGTH / 2) {
a.setTo(terminalsInArrowSpace.start)
b.setTo(terminalsInArrowSpace.end)
}
if (
startShapeInfo &&
endShapeInfo &&
startShapeInfo.shape !== endShapeInfo.shape &&
!startShapeInfo.isExact &&
!endShapeInfo.isExact
) {
// If we missed an intersection, then try
const startAngle = Vec2d.Angle(handleArc.center, a)
const endAngle = Vec2d.Angle(handleArc.center, b)
const offset = handleArc.sweepFlag ? MIN_ARROW_LENGTH : -MIN_ARROW_LENGTH
const arcLength = getArcLength(handleArc.center, handleArc.radius, b, a)
const {
center: { x, y },
radius,
} = handleArc
if (startShapeInfo && !startShapeInfo.didIntersect) {
a.setTo(getPointOnCircle(x, y, radius, lerpAngles(startAngle, endAngle, offset / arcLength)))
}
if (endShapeInfo && !endShapeInfo.didIntersect) {
b.setTo(getPointOnCircle(x, y, radius, lerpAngles(startAngle, endAngle, -offset / arcLength)))
}
}
let midAngle = lerpAngles(Vec2d.Angle(handleArc.center, a), Vec2d.Angle(handleArc.center, b), 0.5)
let midPoint = getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
midAngle
)
if (+Vec2d.Clockwise(a, midPoint, b) !== handleArc.sweepFlag) {
midAngle += PI
midPoint = getPointOnCircle(handleArc.center.x, handleArc.center.y, handleArc.radius, midAngle)
}
c.setTo(midPoint)
const bodyArc = getArcInfo(a, b, c)
return {
isStraight: false,
start: {
point: a,
handle: terminalsInArrowSpace.start,
arrowhead: shape.props.arrowheadStart,
},
end: {
point: b,
handle: terminalsInArrowSpace.end,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
handleArc,
bodyArc,
isValid: bodyArc.length !== 0 && isFinite(bodyArc.center.x) && isFinite(bodyArc.center.y),
}
}
/**
* Get a solid path for a curved arrow's handles.
*
* @param info - The arrow info.
*/
export function getCurvedArrowHandlePath(info: ArrowInfo & { isStraight: false }) {
const {
start,
end,
handleArc: { radius, largeArcFlag, sweepFlag },
} = info
return `M${start.handle.x},${start.handle.y} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.handle.x},${end.handle.y}`
}
/**
* Get a solid path for a curved arrow's body.
*
* @param info - The arrow info.
*/
export function getSolidCurvedArrowPath(info: ArrowInfo & { isStraight: false }) {
const {
start,
end,
bodyArc: { radius, largeArcFlag, sweepFlag },
} = info
return `M${start.point.x},${start.point.y} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.point.x},${end.point.y}`
}
/**
* Get a point along an arc.
*
* @param center - The arc's center.
* @param radius - The arc's radius.
* @param startAngle - The start point of the arc.
* @param size - The size of the arc.
* @param t - The point along the arc to get.
*/
export function getPointOnArc(
center: VecLike,
radius: number,
startAngle: number,
size: number,
t: number
) {
const angle = startAngle + size * t
return new Vec2d(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
}
/**
* Get a bounding box for an arc.
*
* @param center - The arc's center.
* @param radius - The arc's radius.
* @param start - The start point of the arc.
* @param size - The size of the arc.
*/
export function getArcBoundingBox(center: VecLike, radius: number, start: VecLike, size: number) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const startAngle = Vec2d.Angle(center, start)
// Test 20 points along the arc
for (let i = 0; i < 20; i++) {
const angle = startAngle + size * (i / 19)
const x = center.x + radius * Math.cos(angle)
const y = center.y + radius * Math.sin(angle)
minX = Math.min(x, minX)
minY = Math.min(y, minY)
maxX = Math.max(x, maxX)
maxY = Math.max(y, maxY)
}
return new Box2d(minX, minY, maxX - minX, maxY - minY)
}
/**
* Get info about an arc formed by three points.
*
* @param a - The start of the arc
* @param b - The end of the arc
* @param c - A point on the arc
*/
export function getArcInfo(a: VecLike, b: VecLike, c: VecLike): ArcInfo {
// find a circle from the three points
const u = -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y)
const center = {
x:
((a.x * a.x + a.y * a.y) * (c.y - b.y) +
(b.x * b.x + b.y * b.y) * (a.y - c.y) +
(c.x * c.x + c.y * c.y) * (b.y - a.y)) /
u,
y:
((a.x * a.x + a.y * a.y) * (b.x - c.x) +
(b.x * b.x + b.y * b.y) * (c.x - a.x) +
(c.x * c.x + c.y * c.y) * (a.x - b.x)) /
u,
}
const radius = Vec2d.Dist(center, a)
// Whether to draw the arc clockwise or counter-clockwise (are the points clockwise?)
const sweepFlag = +Vec2d.Clockwise(a, c, b)
// The base angle of the arc in radians
const ab = Math.hypot(a.y - b.y, a.x - b.x)
const bc = Math.hypot(b.y - c.y, b.x - c.x)
const ca = Math.hypot(c.y - a.y, c.x - a.x)
const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2
// Whether to draw the long arc or short arc
const largeArcFlag = +(PI > theta)
// The size of the arc to draw in radians
const size = (PI2 - theta) * (sweepFlag ? 1 : -1)
// The length of the arc to draw in distance units
const length = size * radius
return {
center,
radius,
size,
length,
largeArcFlag,
sweepFlag,
}
}