Skip to content

Commit 00f6e9c

Browse files
fix(draw): scale loop-closing threshold by zoom (tldraw#8293)
In order to keep draw-shape loop closing consistent in screen space across zoom levels, this PR updates the draw tool's closing-distance threshold logic to account for zoom behavior (including dynamic resize mode). Closes tldraw#8240. ### Change type - [x] `bugfix` ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +12 / -1 | ### Test plan 1. Open the examples app and select the draw tool. 2. Draw a nearly closed loop at a low zoom level (far zoomed out) and confirm it closes predictably. 3. Repeat at a high zoom level (far zoomed in) and confirm closing sensitivity remains usable. 4. Toggle dynamic resize mode and repeat to verify behavior remains stable. - [ ] Unit tests - [ ] End to end tests ### Release notes - Fix draw-shape loop-closing sensitivity so closing works more consistently across zoom levels. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
1 parent 127e2c6 commit 00f6e9c

2 files changed

Lines changed: 138 additions & 3 deletions

File tree

packages/tldraw/src/lib/shapes/draw/DrawShapeTool.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { TLDrawShape } from '@tldraw/editor'
12
import { TestEditor } from '../../../test/TestEditor'
3+
import { Drawing } from './toolStates/Drawing'
24

35
let editor: TestEditor
46

@@ -9,6 +11,10 @@ afterEach(() => {
911
editor?.dispose()
1012
})
1113

14+
function getDrawingState(): Drawing {
15+
return editor.root.children!['draw'].children!['drawing'] as Drawing
16+
}
17+
1218
describe('When in the idle state', () => {
1319
it('Returns to select on cancel', () => {
1420
editor.setCurrentTool('draw')
@@ -45,3 +51,120 @@ describe('When in the drawing state', () => {
4551
editor.expectToBeIn('draw.idle')
4652
})
4753
})
54+
55+
describe('zoomOnEnter', () => {
56+
it('Captures the zoom level when entering the drawing state', () => {
57+
editor.setCamera({ x: 0, y: 0, z: 2 })
58+
editor.setCurrentTool('draw')
59+
editor.pointerDown(50, 50)
60+
61+
const drawing = getDrawingState()
62+
expect(drawing.zoomOnEnter).toBe(2)
63+
})
64+
65+
it('Uses the zoom level at entry, not the current zoom level', () => {
66+
editor.setCamera({ x: 0, y: 0, z: 3 })
67+
editor.setCurrentTool('draw')
68+
editor.pointerDown(50, 50)
69+
70+
const drawing = getDrawingState()
71+
expect(drawing.zoomOnEnter).toBe(3)
72+
73+
// Change zoom mid-stroke
74+
editor.setCamera({ x: 0, y: 0, z: 1 })
75+
editor.pointerMove(60, 60)
76+
77+
// zoomOnEnter should still reflect the zoom at entry
78+
expect(drawing.zoomOnEnter).toBe(3)
79+
})
80+
81+
it('Defaults to 1 when zoom is at default level', () => {
82+
editor.setCurrentTool('draw')
83+
editor.pointerDown(50, 50)
84+
85+
const drawing = getDrawingState()
86+
expect(drawing.zoomOnEnter).toBe(1)
87+
})
88+
})
89+
90+
describe('Close threshold with zoom', () => {
91+
function drawNearlyClosedShape(gap: number) {
92+
// Draw a shape that loops back near the start point
93+
// Start at origin, go right, down, left, then almost back to start
94+
editor.setCurrentTool('draw')
95+
editor.pointerDown(100, 100)
96+
editor.pointerMove(200, 100)
97+
editor.pointerMove(200, 200)
98+
editor.pointerMove(100, 200)
99+
// Come back near the start with the specified gap
100+
editor.pointerMove(100 + gap, 100)
101+
editor.pointerUp()
102+
103+
const shapes = editor.getCurrentPageShapes()
104+
return shapes[shapes.length - 1] as TLDrawShape
105+
}
106+
107+
it('Closes a shape when the endpoint is near the start at zoom=1', () => {
108+
const shape = drawNearlyClosedShape(2)
109+
expect(shape.props.isClosed).toBe(true)
110+
})
111+
112+
it('Does not close a shape when the endpoint is far from the start', () => {
113+
const shape = drawNearlyClosedShape(50)
114+
expect(shape.props.isClosed).toBe(false)
115+
})
116+
117+
it('Has a larger close threshold at low zoom levels', () => {
118+
// threshold = 6 + 2*sqrt(strokeWidth*0.8) + 100/(1+(zoom/0.18)^3)
119+
// At zoom=0.1: threshold ≈ 95 page units
120+
// At zoom=1: threshold ≈ 10 page units
121+
// Note: screen gap * (1/zoom) = page gap
122+
123+
// At zoom=1, screen gap of 15px = 15 page units > threshold ~10 → NOT closed
124+
editor.setCamera({ x: 0, y: 0, z: 1 })
125+
const shapeAtZoom1 = drawNearlyClosedShape(15)
126+
expect(shapeAtZoom1.props.isClosed).toBe(false)
127+
128+
// At zoom=0.1, screen gap of 5px = 50 page units < threshold ~95 → CLOSED
129+
// The larger page-space threshold at low zoom makes closing easier
130+
editor.setCamera({ x: 0, y: 0, z: 0.1 })
131+
const shapeAtLowZoom = drawNearlyClosedShape(5)
132+
expect(shapeAtLowZoom.props.isClosed).toBe(true)
133+
})
134+
135+
it('Has a small close threshold at high zoom levels', () => {
136+
// At zoom=5: threshold ≈ 9.4 page units
137+
// Screen gap of 60px = 12 page units > threshold → NOT closed
138+
editor.setCamera({ x: 0, y: 0, z: 5 })
139+
const shape = drawNearlyClosedShape(60)
140+
expect(shape.props.isClosed).toBe(false)
141+
})
142+
143+
it('Uses a different threshold formula when dynamic resize mode is enabled', () => {
144+
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
145+
146+
// In dynamic resize mode, threshold = (strokeWidth + 2) * scale
147+
// where scale = 1/zoom. At zoom=0.1, scale=10, strokeWidth(m)=3.5
148+
// threshold = (3.5+2)*10 = 55 page units
149+
// Screen gap of 3px at zoom=0.1 = 30 page units < 55 → CLOSED
150+
editor.setCamera({ x: 0, y: 0, z: 0.1 })
151+
const shapeAtLowZoom = drawNearlyClosedShape(3)
152+
expect(shapeAtLowZoom.props.isClosed).toBe(true)
153+
})
154+
155+
it('Does not close highlight shapes regardless of zoom', () => {
156+
editor.setCamera({ x: 0, y: 0, z: 0.1 })
157+
editor.setCurrentTool('highlight')
158+
editor.pointerDown(100, 100)
159+
editor.pointerMove(200, 100)
160+
editor.pointerMove(200, 200)
161+
editor.pointerMove(100, 200)
162+
editor.pointerMove(102, 100)
163+
editor.pointerUp()
164+
165+
const shapes = editor.getCurrentPageShapes()
166+
const shape = shapes[shapes.length - 1]
167+
// Highlight shapes don't have isClosed
168+
expect((shape as any).props.isClosed).toBeUndefined()
169+
})
170+
})

packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class Drawing extends StateNode {
4949
lastRecordedPoint = {} as Vec
5050
mergeNextPoint = false
5151
currentLineLength = 0
52+
zoomOnEnter = 1
5253

5354
// Cache for current segment's points to avoid repeated b64 decode/encode
5455
currentSegmentPoints: Vec[] = []
@@ -59,6 +60,7 @@ export class Drawing extends StateNode {
5960
this.markId = null
6061
this.info = info
6162
this.lastRecordedPoint = this.editor.inputs.getCurrentPagePoint().clone()
63+
this.zoomOnEnter = this.editor.getZoomLevel()
6264
this.startShape()
6365
}
6466

@@ -80,7 +82,7 @@ export class Drawing extends StateNode {
8082
if (this.isPenOrStylus) {
8183
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
8284
const currentPagePoint = inputs.getCurrentPagePoint()
83-
if (Vec.Dist(currentPagePoint, this.lastRecordedPoint) >= 1 / this.editor.getZoomLevel()) {
85+
if (Vec.Dist(currentPagePoint, this.lastRecordedPoint) >= 1 / this.zoomOnEnter) {
8486
this.lastRecordedPoint = currentPagePoint.clone()
8587
this.mergeNextPoint = false
8688
} else {
@@ -149,12 +151,22 @@ export class Drawing extends StateNode {
149151
const lastSegment = segments[segments.length - 1]
150152
const lastPoint = b64Vecs.decodeLastPoint(lastSegment.path)
151153

154+
const isDynamicResizingEnabled = this.editor.user.getIsDynamicResizeMode()
155+
156+
const threshold = isDynamicResizingEnabled // when dynamic resizing is enabled scale is 1/zoom, so the threshold should not scale directly with zoom at all
157+
? (strokeWidth + 2) * scale // +2 keeps tiny strokes from being too hard to close
158+
: // 6 is a base floor, 2 is stroke influence, 0.8 tempers width growth
159+
6 +
160+
2 * Math.sqrt(strokeWidth * 0.8) +
161+
// 100 is low-zoom boost, 0.18 is the zoom knee, 3 controls falloff steepness
162+
100 / (1 + Math.pow(this.zoomOnEnter / 0.18, 3))
163+
152164
return (
153165
firstPoint !== null &&
154166
lastPoint !== null &&
155167
firstPoint !== lastPoint &&
156168
this.currentLineLength > strokeWidth * 4 * scale &&
157-
Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2 * scale)
169+
Vec.DistMin(firstPoint, lastPoint, threshold)
158170
)
159171
}
160172

@@ -481,7 +493,7 @@ export class Drawing extends StateNode {
481493
if (shouldSnap) {
482494
if (newSegments.length > 2) {
483495
let nearestPoint: VecModel | undefined = undefined
484-
let minDistance = 8 / this.editor.getZoomLevel()
496+
let minDistance = 8 / this.zoomOnEnter
485497

486498
// Don't try to snap to the last two segments
487499
for (let i = 0, n = segments.length - 2; i < n; i++) {

0 commit comments

Comments
 (0)