Skip to content

Commit 6846df5

Browse files
authored
fix(arrows): clamp boundary anchors to avoid degenerate intersections (tldraw#8130)
Closes tldraw#8125 https://github.com/user-attachments/assets/cf96fc2a-c7f2-4448-8ccf-42e0eaa5ba2b Arrow endpoints flicker when `normalizedAnchor` coordinates are exactly 0 or 1 (shape edges/corners). At these exact values, the intersection math becomes numerically unstable — small floating-point errors cause intersections to toggle between found and not-found across frames. **Solution:** if arrow is precise or forceImprecise, clamp each anchor coordinate from [0, 1] to [0.001, 0.999]. This clamping happens inside `getArrowTerminalInArrowSpace`, which runs every time the arrow info is recomputed (i.e. whenever the arrow or its bound shapes change). The binding record in the store is never modified — the nudge only exists ephemerally during computation, so the intersection math never sees an exact boundary value. ### Change type - [x] `bugfix` ### Test plan 1. Create a shape and programmatically bind a self-referential arrow with anchors at exact boundary positions (e.g. `{x: 0, y: 0}`, `{x: 0.5, y: 0}`) 2. Verify arrows render without flickering 3. Resize and move the shape — arrows should remain stable - [x] Unit tests ### Release notes - Fix arrow endpoint flickering when anchors are at exact shape boundaries <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core arrow terminal computation; clamping precise anchors near 0/1 can subtly shift endpoints (up to ~0.1% of shape size) and may affect arrow rendering/attachment in edge cases. > > **Overview** > Prevents arrow endpoint flicker when a bound terminal’s `normalizedAnchor` lands exactly on a shape edge/corner by clamping precise anchors away from `0`/`1` during `getArrowTerminalInArrowSpace` computations. > > Adds a small epsilon-based `clampNormalizedAnchor` helper and refactors the terminal point calculation to use the clamped anchor without mutating stored binding data. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f4ecde2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0d748e7 commit 6846df5

1 file changed

Lines changed: 15 additions & 10 deletions

File tree

  • packages/tldraw/src/lib/shapes/arrow

packages/tldraw/src/lib/shapes/arrow/shared.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import {
1515
import { createComputedCache } from '@tldraw/store'
1616

1717
const MIN_ARROW_BEND = 8
18+
// Keep anchors off exact edges/corners to avoid degenerate arrow intersections.
19+
const NORMALIZED_ANCHOR_EPSILON = 1e-3
20+
21+
function clampNormalizedAnchor(anchor: { x: number; y: number }) {
22+
const clamp = (v: number) =>
23+
Math.max(NORMALIZED_ANCHOR_EPSILON, Math.min(1 - NORMALIZED_ANCHOR_EPSILON, v))
24+
return { x: clamp(anchor.x), y: clamp(anchor.y) }
25+
}
1826

1927
export function getIsArrowStraight(shape: TLArrowShape) {
2028
if (shape.props.kind !== 'arc') return false
@@ -78,16 +86,13 @@ export function getArrowTerminalInArrowSpace(
7886
// the bound shape and transform it to page space, then transform
7987
// it to arrow space
8088
const { point, size } = editor.getShapeGeometry(boundShape).bounds
81-
const shapePoint = Vec.Add(
82-
point,
83-
Vec.MulV(
84-
// if the parent is the bound shape, then it's ALWAYS precise
85-
binding.props.isPrecise || forceImprecise
86-
? binding.props.normalizedAnchor
87-
: { x: 0.5, y: 0.5 },
88-
size
89-
)
90-
)
89+
// If the parent is the bound shape, then it's always treated as precise.
90+
const shouldUsePreciseAnchor = binding.props.isPrecise || forceImprecise
91+
const normalizedAnchor = shouldUsePreciseAnchor
92+
? clampNormalizedAnchor(binding.props.normalizedAnchor)
93+
: { x: 0.5, y: 0.5 }
94+
95+
const shapePoint = Vec.Add(point, Vec.MulV(normalizedAnchor, size))
9196
const pagePoint = Mat.applyToPoint(editor.getShapePageTransform(boundShape)!, shapePoint)
9297
const arrowPoint = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), pagePoint)
9398
return arrowPoint

0 commit comments

Comments
 (0)