Tldraw/packages/editor/src/lib/editor/shapes/shared/arrow/curved-arrow.ts

442 wiersze
13 KiB
TypeScript

import { TLArrowShape } from '@tldraw/tlschema'
import { Mat } from '../../../../primitives/Mat'
import { Vec, VecLike } from '../../../../primitives/Vec'
import { intersectCirclePolygon, intersectCirclePolyline } from '../../../../primitives/intersect'
import {
PI,
PI2,
clockwiseAngleDist,
counterClockwiseAngleDist,
isSafeFloat,
} from '../../../../primitives/utils'
import type { Editor } from '../../../Editor'
import { TLArcInfo, TLArrowInfo } from './arrow-types'
import {
BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH,
STROKE_SIZES,
WAY_TOO_BIG_ARROW_BEND_FACTOR,
getArrowTerminalsInArrowSpace,
getBoundShapeInfoForTerminal,
getBoundShapeRelationships,
} from './shared'
import { getStraightArrowInfo } from './straight-arrow'
export function getCurvedArrowInfo(
editor: Editor,
shape: TLArrowShape,
extraBend = 0
): TLArrowInfo {
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 = Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end) // point between start and end
const distance = Vec.Sub(terminalsInArrowSpace.end, terminalsInArrowSpace.start)
// Check for divide-by-zero before we call uni()
const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance) // unit vector between start and end
const middle = Vec.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()
if (Vec.Equals(a, b)) {
return {
isStraight: true,
start: {
handle: a,
point: a,
arrowhead: shape.props.arrowheadStart,
},
end: {
handle: b,
point: b,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
isValid: false,
length: 0,
}
}
const isClockwise = shape.props.bend < 0
const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist
const handleArc = getArcInfo(a, b, c)
const handle_aCA = Vec.Angle(handleArc.center, a)
const handle_aCB = Vec.Angle(handleArc.center, b)
const handle_dAB = distFn(handle_aCA, handle_aCB)
if (
handleArc.length === 0 ||
handleArc.size === 0 ||
!isSafeFloat(handleArc.length) ||
!isSafeFloat(handleArc.size)
) {
return getStraightArrowInfo(editor, shape)
}
const tempA = a.clone()
const tempB = b.clone()
const tempC = c.clone()
const arrowPageTransform = editor.getShapePageTransform(shape)!
let offsetA = 0
let offsetB = 0
let minLength = MIN_ARROW_LENGTH
if (startShapeInfo && !startShapeInfo.isExact) {
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA)
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center)
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB)
const inverseTransform = Mat.Inverse(startShapeInfo.transform)
const startInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace)
const centerInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace)
const endInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace)
const { isClosed } = startShapeInfo
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
let point: VecLike | undefined
let intersections = fn(centerInStartShapeLocalSpace, handleArc.radius, startShapeInfo.outline)
if (intersections) {
const angleToStart = centerInStartShapeLocalSpace.angle(startInStartShapeLocalSpace)
const angleToEnd = centerInStartShapeLocalSpace.angle(endInStartShapeLocalSpace)
const dAB = distFn(angleToStart, angleToEnd)
// Filter out any intersections that aren't in the arc
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInStartShapeLocalSpace.angle(pt)) <= dAB
)
const targetDist = dAB * 0.25
intersections.sort(
isClosed
? (p0, p1) =>
Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) - targetDist) <
Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) - targetDist)
? -1
: 1
: (p0, p1) =>
distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) <
distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1))
? -1
: 1
)
point = intersections[0] ?? (isClosed ? undefined : startInStartShapeLocalSpace)
} else {
point = isClosed ? undefined : startInStartShapeLocalSpace
}
if (point) {
tempA.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(startShapeInfo.transform, point))
)
startShapeInfo.didIntersect = true
if (arrowheadStart !== 'none') {
const strokeOffset =
STROKE_SIZES[shape.props.size] / 2 +
('size' in startShapeInfo.shape.props
? STROKE_SIZES[startShapeInfo.shape.props.size] / 2
: 0)
offsetA = BOUND_ARROW_OFFSET + strokeOffset
minLength += strokeOffset
}
}
}
if (endShapeInfo && !endShapeInfo.isExact) {
// get points in shape's coordinates?
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA)
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB)
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center)
const inverseTransform = Mat.Inverse(endShapeInfo.transform)
const startInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace)
const centerInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace)
const endInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace)
const isClosed = endShapeInfo.isClosed
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
let point: VecLike | undefined
let intersections = fn(centerInEndShapeLocalSpace, handleArc.radius, endShapeInfo.outline)
if (intersections) {
const angleToStart = centerInEndShapeLocalSpace.angle(startInEndShapeLocalSpace)
const angleToEnd = centerInEndShapeLocalSpace.angle(endInEndShapeLocalSpace)
const dAB = distFn(angleToStart, angleToEnd)
const targetDist = dAB * 0.75
// or simplified...
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInEndShapeLocalSpace.angle(pt)) <= dAB
)
intersections.sort(
isClosed
? (p0, p1) =>
Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) - targetDist) <
Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) - targetDist)
? -1
: 1
: (p0, p1) =>
distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) <
distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1))
? -1
: 1
)
if (intersections[0]) {
point = intersections[0]
} else {
point = isClosed ? undefined : endInEndShapeLocalSpace
}
} else {
point = isClosed ? undefined : endInEndShapeLocalSpace
}
if (point) {
// Set b to target local point -> page point -> shape local point
tempB.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(endShapeInfo.transform, point))
)
endShapeInfo.didIntersect = true
if (arrowheadEnd !== 'none') {
const strokeOffset =
STROKE_SIZES[shape.props.size] / 2 +
('size' in endShapeInfo.shape.props ? STROKE_SIZES[endShapeInfo.shape.props.size] / 2 : 0)
offsetB = BOUND_ARROW_OFFSET + strokeOffset
minLength += strokeOffset
}
}
}
// Apply arrowhead offsets
let aCA = Vec.Angle(handleArc.center, tempA) // angle center -> a
let aCB = Vec.Angle(handleArc.center, tempB) // angle center -> b
let dAB = distFn(aCA, aCB) // angle distance between a and b
let lAB = dAB * handleArc.radius // length of arc between a and b
// Try the offsets first, then check whether the distance between the points is too small;
// if it is, flip the offsets and expand them. We need to do this using temporary points
// so that we can apply them both in a balanced way.
const tA = tempA.clone()
const tB = tempB.clone()
if (offsetA !== 0) {
const n = (offsetA / lAB) * (isClockwise ? 1 : -1)
const u = Vec.FromAngle(aCA + dAB * n)
tA.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
if (offsetB !== 0) {
const n = (offsetB / lAB) * (isClockwise ? -1 : 1)
const u = Vec.FromAngle(aCB + dAB * n)
tB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) {
offsetA *= -1.5
offsetB *= -1.5
} else if (offsetA !== 0) {
offsetA *= -2
} else if (offsetB !== 0) {
offsetB *= -2
} else {
// noop
}
}
if (offsetA !== 0) {
const n = (offsetA / lAB) * (isClockwise ? 1 : -1)
const u = Vec.FromAngle(aCA + dAB * n)
tempA.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
if (offsetB !== 0) {
const n = (offsetB / lAB) * (isClockwise ? -1 : 1)
const u = Vec.FromAngle(aCB + dAB * n)
tempB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
// Did we miss intersections? This happens when we have overlapping shapes.
if (startShapeInfo && endShapeInfo && !startShapeInfo.isExact && !endShapeInfo.isExact) {
aCA = Vec.Angle(handleArc.center, tempA) // angle center -> a
aCB = Vec.Angle(handleArc.center, tempB) // angle center -> b
dAB = distFn(aCA, aCB) // angle distance between a and b
lAB = dAB * handleArc.radius // length of arc between a and b
const relationship = getBoundShapeRelationships(
editor,
startShapeInfo.shape.id,
endShapeInfo.shape.id
)
if (relationship === 'double-bound' && lAB < 30) {
tempA.setTo(a)
tempB.setTo(b)
tempC.setTo(c)
} else if (relationship === 'safe') {
if (startShapeInfo && !startShapeInfo.didIntersect) {
tempA.setTo(a)
}
if (
(endShapeInfo && !endShapeInfo.didIntersect) ||
distFn(handle_aCA, aCA) > distFn(handle_aCA, aCB)
) {
const n = Math.min(0.9, MIN_ARROW_LENGTH / lAB) * (isClockwise ? 1 : -1)
const u = Vec.FromAngle(aCA + dAB * n)
tempB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
}
}
placeCenterHandle(
handleArc.center,
handleArc.radius,
tempA,
tempB,
tempC,
handle_dAB,
isClockwise
)
if (tempA.equals(tempB)) {
tempA.setTo(tempC.clone().addXY(1, 1))
tempB.setTo(tempC.clone().subXY(1, 1))
}
a.setTo(tempA)
b.setTo(tempB)
c.setTo(tempC)
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 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
*/
function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo {
// 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 = Vec.Dist(center, a)
// Whether to draw the arc clockwise or counter-clockwise (are the points clockwise?)
const sweepFlag = +Vec.Clockwise(a, c, b)
// The base angle of the arc in radians
const ab = ((a.y - b.y) ** 2 + (a.x - b.x) ** 2) ** 0.5
const bc = ((b.y - c.y) ** 2 + (b.x - c.x) ** 2) ** 0.5
const ca = ((c.y - a.y) ** 2 + (c.x - a.x) ** 2) ** 0.5
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,
}
}
function placeCenterHandle(
center: VecLike,
radius: number,
tempA: Vec,
tempB: Vec,
tempC: Vec,
originalArcLength: number,
isClockwise: boolean
) {
const aCA = Vec.Angle(center, tempA) // angle center -> a
const aCB = Vec.Angle(center, tempB) // angle center -> b
let dAB = clockwiseAngleDist(aCA, aCB) // angle distance between a and b
if (!isClockwise) dAB = PI2 - dAB
const n = 0.5 * (isClockwise ? 1 : -1)
const u = Vec.FromAngle(aCA + dAB * n)
tempC.setTo(center).add(u.mul(radius))
if (dAB > originalArcLength) {
tempC.rotWith(center, PI)
const t = tempB.clone()
tempB.setTo(tempA)
tempA.setTo(t)
}
}