apps/docs/content/sdk-features/geometry.mdx
Geometry in tldraw is a mathematical description of a shape's form. Each shape has a Geometry2d that defines its outline, bounds, and spatial properties. The editor uses it to decide whether a click hit the shape, whether a selection brush intersects it, and where an arrow should snap to its edge. For the broader shape system, see Shapes.
Each ShapeUtil implements a ShapeUtil#getGeometry method that returns a Geometry2d instance. The editor calls this method to calculate bounds, test hits, find intersections, and measure distances. It receives an optional TLGeometryOpts with a context string, and callers can pass the same option to Editor#getShapeGeometry to request context-specific geometry.
class MyShapeUtil extends ShapeUtil<MyShape> {
getGeometry(shape: MyShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
}
The isFilled property controls hit testing behavior. A filled geometry registers hits inside its area. An unfilled geometry only responds to hits on its outline—useful for shapes like frames where you want to click through the middle.
Geometry also powers snapping. When you drag shapes, the snapping system uses geometry to find edges, centers, and corners to align to. Custom shapes can provide additional snap points by implementing ShapeUtil#getBoundsSnapGeometry.
The SDK includes geometry classes for common shapes.
Axis-aligned rectangles. The most common geometry for box-shaped elements.
new Rectangle2d({
width: 200,
height: 100,
isFilled: true,
})
You can offset the rectangle from the origin:
new Rectangle2d({
x: 10,
y: 10,
width: 200,
height: 100,
isFilled: true,
})
Circles and ellipses.
new Ellipse2d({
width: 100,
height: 100, // circle
isFilled: true,
})
new Ellipse2d({
width: 200,
height: 100, // ellipse
isFilled: true,
})
A specialized circle geometry that stores radius directly. The x and y parameters offset the circle's bounding box, not its center.
new Circle2d({
radius: 50,
isFilled: true,
})
// Offset from origin
new Circle2d({
x: 10,
y: 10,
radius: 50,
isFilled: true,
})
Arbitrary closed polygons defined by vertices.
new Polygon2d({
points: [new Vec(0, 50), new Vec(100, 0), new Vec(100, 100), new Vec(0, 100)],
isFilled: true,
})
Polygon2d requires at least three points and automatically closes the path.
Open paths defined by vertices. Use this for lines that don't form closed shapes. Requires at least two points.
new Polyline2d({
points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 0)],
})
Polylines are never filled since they don't enclose an area. Polygon2d extends Polyline2d but sets isClosed to true.
A single line segment between two points.
new Edge2d({
start: new Vec(0, 0),
end: new Vec(100, 100),
})
Arrows use Edge2d for straight arrow bodies.
A circular arc defined by center, start, end, and arc flags. All parameters are required.
new Arc2d({
center: new Vec(50, 50),
start: new Vec(0, 50),
end: new Vec(100, 50),
sweepFlag: 1,
largeArcFlag: 0,
})
The sweepFlag and largeArcFlag follow SVG arc conventions: sweepFlag controls clockwise vs counterclockwise direction, and largeArcFlag chooses between the two possible arcs. Arrows use Arc2d for curved arrow bodies.
A pill or capsule shape (rectangle with semicircular ends). The shorter dimension determines the radius of the rounded ends.
new Stadium2d({
width: 200,
height: 50,
isFilled: true,
})
A single cubic bezier curve segment.
new CubicBezier2d({
start: new Vec(0, 0),
cp1: new Vec(30, 100),
cp2: new Vec(70, 100),
end: new Vec(100, 0),
})
A smooth curve through multiple points, automatically generating smooth cubic bezier segments between them.
new CubicSpline2d({
points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 50), new Vec(150, 100)],
})
A single point. The constructor requires both point and margin parameters.
new Point2d({
point: new Vec(50, 50),
margin: 10,
})
The margin option doesn't expand the hit area: hit-test margins are supplied by the editor at query time, like every other geometry.
Combines multiple geometries into a single composite geometry. The children don't need to be the same type.
new Group2d({
children: [
new Rectangle2d({ width: 100, height: 80, isFilled: true }),
new Circle2d({ x: 50, y: -20, radius: 20, isFilled: true }),
],
})
Use Group2d for shapes with multiple parts. The geo shape uses it to combine its outline with its label bounds. The arrow shape uses it to combine the arrow body with its label. Nested groups are flattened: a Group2d passed as a child contributes its own children.
All Geometry2d classes provide methods for spatial queries.
Get the axis-aligned bounding box:
const geometry = editor.getShapeGeometry(shape)
const bounds = geometry.bounds // Box { x, y, w, h, ... }
const center = geometry.center // Vec at center of bounds
Get the points that define the geometry's outline:
const vertices = geometry.vertices // Vec[]
For curves, this returns a discretized approximation. By default vertices excludes label geometry.
Test if a point hits the geometry:
geometry.hitTestPoint(point, margin, hitInside)
The margin expands the hit area. The hitInside parameter controls whether points inside unfilled shapes count as hits.
Test if a line segment passes within distance of the geometry:
geometry.hitTestLineSegment(A, B, distance)
After a hit succeeds, the editor calls geometry.ignoreHit(point). Override it to reject hits at specific points and let shapes behind this one be selected instead; the image shape uses this for transparent pixels.
Find the nearest point on the geometry to a given point:
const nearest = geometry.nearestPoint(point)
Get the distance from a point to the geometry:
const distance = geometry.distanceToPoint(point)
Negative distances mean the point is inside a filled geometry.
Get intersection points with a line segment, circle, polygon, or polyline:
const intersections = geometry.intersectLineSegment(A, B)
const circleHits = geometry.intersectCircle(center, radius)
const polygonHits = geometry.intersectPolygon(points)
Get the perimeter length and area:
const length = geometry.length // perimeter length
const area = geometry.area // enclosed area (0 for open paths)
A Group2d reports the area of its first child, not the union of its children.
Find a point at a fraction along the edge:
const point = geometry.interpolateAlongEdge(0.5) // midpoint
Convert a point back to a fraction:
const t = geometry.uninterpolateAlongEdge(point)
Generate an SVG path:
const pathData = geometry.toSimpleSvgPath() // "M0,0 L100,0 L100,100 L0,100 Z"
The getGeometry method receives the shape and returns geometry in shape-local coordinates (origin at top-left of shape).
For shapes with a single outline:
getGeometry(shape: MyShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: shape.props.fill !== 'none',
})
}
Shapes that have text labels typically return a Group2d with the main geometry and a label rectangle:
getGeometry(shape: MyShape) {
const outline = new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: shape.props.fill !== 'none',
})
const label = new Rectangle2d({
x: labelX,
y: labelY,
width: labelWidth,
height: labelHeight,
isFilled: true,
isLabel: true,
})
return new Group2d({
children: [outline, label],
})
}
The isLabel property marks geometry that represents text labels; see Geometry filtering.
For non-rectangular shapes, calculate vertices and use Polygon2d:
getGeometry(shape: HouseShape) {
const { w, h } = shape.props
const roofPeak = h * 0.3
return new Polygon2d({
points: [
new Vec(0, roofPeak),
new Vec(w / 2, 0),
new Vec(w, roofPeak),
new Vec(w, h),
new Vec(0, h),
],
isFilled: true,
})
}
For shapes with multiple distinct parts:
getGeometry(shape: HouseShape) {
const house = new Polygon2d({
points: getHouseVertices(shape),
isFilled: true,
})
const door = new Rectangle2d({
x: shape.props.w / 2 - 15,
y: shape.props.h - 40,
width: 30,
height: 40,
isFilled: true,
})
return new Group2d({
children: [house, door],
})
}
The editor caches geometry computations. Without caching, dragging a selection box over hundreds of shapes would recompute each shape's geometry on every frame.
Access cached geometry through the editor:
const geometry = editor.getShapeGeometry(shape)
const pageBounds = editor.getShapePageBounds(shape)
The cache invalidates automatically when a shape's props or meta change. You don't need to manage invalidation yourself.
Group2d supports filtering to include or exclude certain geometry children during operations. This lets you mark parts of a shape's geometry for different purposes.
The isLabel flag marks geometry that represents text label bounds. Label geometry participates in click-to-edit detection but is typically excluded from outline calculations and snapping.
The isInternal flag marks geometry that exists for internal calculations but shouldn't be part of the shape's visible outline.
// Mark geometry as a label
new Rectangle2d({
// ...
isLabel: true,
})
// Mark geometry as internal (not part of main outline)
new Rectangle2d({
// ...
isInternal: true,
})
The geometry system provides filter presets for common scenarios:
| Filter | Includes labels | Includes internal |
|---|---|---|
EXCLUDE_NON_STANDARD | No | No |
INCLUDE_ALL | Yes | Yes |
EXCLUDE_LABELS | No | Yes |
EXCLUDE_INTERNAL | Yes | No |
The vertices and length getters, and Group2d's hit tests, default to EXCLUDE_LABELS: labels are left out but internal geometry is included. Arrow routing passes EXCLUDE_NON_STANDARD to get the bare outline. Pass a filter explicitly to any method that accepts one when you need something else.
Geometry2d has additional options for special cases.
When set, the geometry won't contribute to the shape's bounding box calculation. The geometry still participates in hit testing and other operations, but getBoundsVertices() returns an empty array for it.
const label = new Rectangle2d({
x: labelX,
y: labelY,
width: labelWidth,
height: labelHeight,
isFilled: true,
isLabel: true,
excludeFromShapeBounds: true, // label won't affect shape bounds
})
When set on geometry inside a Group2d, that geometry is placed in an ignoredChildren array and won't participate in the group's hit testing, bounds, or other queries. The geometry debug view still draws it.
new Group2d({
children: [
mainGeometry,
new Rectangle2d({
// ...
ignore: true, // won't participate in group operations
}),
],
})
A color string used when rendering geometry in the debug view. Defaults to red if not specified.
new Rectangle2d({
width: 100,
height: 100,
isFilled: true,
debugColor: 'blue', // shows as blue in geometry debugging view
})
Turn on the debugGeometry flag in the debug menu to draw shape geometry on the canvas during development.
The TransformedGeometry2d class wraps a geometry with a transformation matrix, so you can query it in a different coordinate space without rebuilding it. Group shapes use this to combine their children's geometry in the group's local space.
const transformed = geometry.transform(matrix)
All operations on the transformed geometry apply the transformation automatically. One limitation: transformed geometry throws from getSvgPathData(). Call it on the source geometry and transform the result if you need path data.