[improvement] Refactor curved arrows (#2019)

This PR refactors our curved arrows to use a more reliable filter for
intersections. Its mostly visible on arrows that connect to draw shapes.

Before (tldraw and staging):
<img width="744" alt="image"
src="https://github.com/tldraw/tldraw/assets/23072548/81903e53-ab23-4ea0-a849-fb396e490018">

After:
<img width="668" alt="image"
src="https://github.com/tldraw/tldraw/assets/23072548/67e8615e-11f1-4207-96ad-b8cc8ff92c7b">


### Change Type

- [x] `patch` — Bug fix
This commit is contained in:
Steve Ruiz 2023-10-04 16:45:38 +01:00 committed by GitHub
parent d715fa3a2e
commit fcef86320e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 142 additions and 115 deletions

View file

@ -6,12 +6,9 @@ import { intersectCirclePolygon, intersectCirclePolyline } from '../../../../pri
import {
PI,
PI2,
angleDelta,
getArcLength,
getPointOnCircle,
clockwiseAngleDist,
counterClockwiseAngleDist,
isSafeFloat,
lerpAngles,
shortAngleDist,
} from '../../../../primitives/utils'
import type { Editor } from '../../../Editor'
import { TLArcInfo, TLArrowInfo } from './arrow-types'
@ -52,7 +49,13 @@ export function getCurvedArrowInfo(
const b = terminalsInArrowSpace.end.clone()
const c = middle.clone()
const isClockwise = shape.props.bend < 0
const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist
const handleArc = getArcInfo(a, b, c)
const handle_aCA = Vec2d.Angle(handleArc.center, a)
const handle_aCB = Vec2d.Angle(handleArc.center, b)
const handle_dAB = distFn(handle_aCA, handle_aCB)
if (
handleArc.length === 0 ||
@ -74,12 +77,14 @@ export function getCurvedArrowInfo(
if (startShapeInfo && !startShapeInfo.isExact) {
const startInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, tempA)
const endInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, tempB)
const centerInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, handleArc.center)
const endInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, tempB)
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 endInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const { isClosed } = startShapeInfo
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
@ -89,23 +94,29 @@ export function getCurvedArrowInfo(
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) =>
+Vec2d.Clockwise(startInStartShapeLocalSpace, pt, endInStartShapeLocalSpace) ===
handleArc.sweepFlag
(pt) => distFn(angleToStart, centerInStartShapeLocalSpace.angle(pt)) <= dAB
)
const comparisonAngle = lerpAngles(
Vec2d.Angle(handleArc.center, tempA),
Vec2d.Angle(handleArc.center, tempC),
0.5
)
const targetDist = dAB * 0.25
intersections.sort(
(p0, p1) =>
Math.abs(shortAngleDist(comparisonAngle, centerInStartShapeLocalSpace.angle(p0))) -
Math.abs(shortAngleDist(comparisonAngle, centerInStartShapeLocalSpace.angle(p1)))
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)
@ -122,12 +133,11 @@ export function getCurvedArrowInfo(
if (arrowheadStart !== 'none') {
offsetA =
(BOUND_ARROW_OFFSET +
STROKE_SIZES[shape.props.size] / 2 +
('size' in startShapeInfo.shape.props
? STROKE_SIZES[startShapeInfo.shape.props.size] / 2
: 0)) *
(handleArc.sweepFlag ? 1 : -1)
BOUND_ARROW_OFFSET +
STROKE_SIZES[shape.props.size] / 2 +
('size' in startShapeInfo.shape.props
? STROKE_SIZES[startShapeInfo.shape.props.size] / 2
: 0)
}
}
}
@ -137,36 +147,51 @@ export function getCurvedArrowInfo(
const startInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, tempA)
const endInPageSpace = Matrix2d.applyToPoint(arrowPageTransform, tempB)
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 endInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const isClosed = endShapeInfo.isClosed
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.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) =>
+Vec2d.Clockwise(startInEndShapeLocalSpace, pt, endInEndShapeLocalSpace) ===
handleArc.sweepFlag
(pt) => distFn(angleToStart, centerInEndShapeLocalSpace.angle(pt)) <= dAB
)
intersections.sort(
(p0, p1) =>
Math.abs(shortAngleDist(comparisonAngle, centerInEndShapeLocalSpace.angle(p0))) -
Math.abs(shortAngleDist(comparisonAngle, centerInEndShapeLocalSpace.angle(p1)))
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
)
point = intersections[0] ?? (isClosed ? undefined : endInEndShapeLocalSpace)
if (intersections[0]) {
point = intersections[0]
} else {
point = isClosed ? undefined : endInEndShapeLocalSpace
}
} else {
point = isClosed ? undefined : endInEndShapeLocalSpace
}
@ -181,23 +206,19 @@ export function getCurvedArrowInfo(
if (arrowheadEnd !== 'none') {
offsetB =
(BOUND_ARROW_OFFSET +
STROKE_SIZES[shape.props.size] / 2 +
('size' in endShapeInfo.shape.props
? STROKE_SIZES[endShapeInfo.shape.props.size] / 2
: 0)) *
(handleArc.sweepFlag ? -1 : 1)
BOUND_ARROW_OFFSET +
STROKE_SIZES[shape.props.size] / 2 +
('size' in endShapeInfo.shape.props ? STROKE_SIZES[endShapeInfo.shape.props.size] / 2 : 0)
}
}
}
// Apply arrowhead offsets
const startAngle = Vec2d.Angle(handleArc.center, tempA)
const endAngle = Vec2d.Angle(handleArc.center, tempB)
const midAngle = Vec2d.Angle(handleArc.center, tempC)
const lAC = getArcLength(handleArc.center, handleArc.radius, tempA, tempC)
const lBC = getArcLength(handleArc.center, handleArc.radius, tempB, tempC)
let aCA = Vec2d.Angle(handleArc.center, tempA) // angle center -> a
let aCB = Vec2d.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
@ -206,25 +227,15 @@ export function getCurvedArrowInfo(
const tB = tempB.clone()
if (offsetA !== 0) {
tA.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(startAngle, midAngle, offsetA / lAC)
)
)
const n = (offsetA / lAB) * (isClockwise ? 1 : -1)
const u = Vec2d.FromAngle(aCA + dAB * n)
tA.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
if (offsetB !== 0) {
tB.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(endAngle, midAngle, offsetB / lBC)
)
)
const n = (offsetB / lAB) * (isClockwise ? -1 : 1)
const u = Vec2d.FromAngle(aCB + dAB * n)
tB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
const distAB = Vec2d.Dist(tA, tB)
@ -244,31 +255,27 @@ export function getCurvedArrowInfo(
}
}
tempA.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(startAngle, midAngle, offsetA / lAC)
)
)
tempB.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(endAngle, midAngle, offsetB / lBC)
)
)
if (offsetA !== 0) {
const n = (offsetA / lAB) * (isClockwise ? 1 : -1)
const u = Vec2d.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 = Vec2d.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) {
const startAngle = Vec2d.Angle(handleArc.center, tempA)
const endAngle = Vec2d.Angle(handleArc.center, tempB)
const length = getArcLength(handleArc.center, handleArc.radius, tempA, tempB)
aCA = Vec2d.Angle(handleArc.center, tempA) // angle center -> a
aCB = Vec2d.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
if (startShapeInfo.shape === endShapeInfo.shape) {
if (Math.abs(length) < 100) {
if (lAB < 100) {
tempA.setTo(a)
tempB.setTo(b)
tempC.setTo(c)
@ -278,44 +285,27 @@ export function getCurvedArrowInfo(
tempA.setTo(a)
}
if (endShapeInfo && !endShapeInfo.didIntersect) {
const size = angleDelta(startAngle, endAngle)
let mid = lerpAngles(startAngle, endAngle, Math.abs(MIN_ARROW_LENGTH / length))
if (+(size > 0) !== handleArc.sweepFlag) {
mid = PI2 - mid
}
tempB.setTo(getPointOnCircle(handleArc.center.x, handleArc.center.y, handleArc.radius, mid))
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 = Vec2d.FromAngle(aCA + dAB * n)
tempB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
}
}
tempC.setTo(
getPointOnCircle(
handleArc.center.x,
handleArc.center.y,
handleArc.radius,
lerpAngles(Vec2d.Angle(handleArc.center, tempA), Vec2d.Angle(handleArc.center, tempB), 0.5)
)
placeCenterHandle(
handleArc.center,
handleArc.radius,
tempA,
tempB,
tempC,
handle_dAB,
isClockwise
)
// Put the middle point in the middle of the short angle distance between the two points
// this MIGHT BE WRONG if the "middle" should be the long angle distance, but we don't know
// that yet; or at least, I haven't figured out how to know that yet based on just the
// intersection points.
// ...so we check whether the handle is on the other side of the arc as the drag handle, and flip
// the position of the middle point if so.
if (+Vec2d.Clockwise(tempA, tempC, tempB) !== handleArc.sweepFlag) {
tempC.rotWith(handleArc.center, PI)
}
if (+Vec2d.Clockwise(tempA, tempC, tempB) !== handleArc.sweepFlag) {
const t = tempB.clone()
tempB.setTo(tempA)
tempA.setTo(t)
}
if (tempA.equals(tempB)) {
tempA.setTo(tempC.clone().addXY(1, 1))
tempB.setTo(tempC.clone().subXY(1, 1))
@ -480,3 +470,29 @@ export function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo {
sweepFlag,
}
}
function placeCenterHandle(
center: VecLike,
radius: number,
tempA: Vec2d,
tempB: Vec2d,
tempC: Vec2d,
originalArcLength: number,
isClockwise: boolean
) {
const aCA = Vec2d.Angle(center, tempA) // angle center -> a
const aCB = Vec2d.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 = Vec2d.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)
}
}

View file

@ -125,6 +125,17 @@ export function clockwiseAngleDist(a0: number, a1: number): number {
return a1 - a0
}
/**
* Get the counter-clockwise angle distance between two angles.
*
* @param a0 - The first angle.
* @param a1 - The second angle.
* @public
*/
export function counterClockwiseAngleDist(a0: number, a1: number): number {
return PI2 - clockwiseAngleDist(a0, a1)
}
/**
* Get the short angle distance between two angles.
*