kopia lustrzana https://github.com/Tldraw/Tldraw
442 wiersze
13 KiB
TypeScript
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 = 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,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|