Drawables and geometry
A shape’s frame says where it is. Its drawable says what is inside the frame. This page covers the box family: rectangle, ellipse and diamond. Their geometry follows the frame, so they store almost nothing. The adapter interface here is shared by every kind. The line and freehand drawables are on Points drawables, text is on Text, and arrows are on Arrows.
Drawables
Drawable is a union tagged by kind:
| Kind | Fields |
|---|---|
rectangle |
cornerRadius, in frame-local units |
ellipse |
None |
diamond |
None |
line |
points, see Points drawables |
freehand |
samples, see Points drawables |
text |
text, sizing, fontSize, fontFamily and align, see Text |
arrow |
start, end, bend, startHead and endHead, see Arrows |
DrawableKind is the union of the kind tags. DrawableOf<K> selects the member of Drawable with kind K.
Adapters
Each kind has one stateless DrawableAdapter<K>:
kindis the kind it handles.create(frame, env)returns the default drawable for a new shape. A new rectangle has square corners.geometry(frame, drawable)returns the drawable’s outline.resize(drawable, change, env)applies aFrameChangeand returns aFittedpair.handles(frame, drawable)returns the adapter’s own handles, in page space.dragHandle(drawable, drag, env)applies a handle drag and returns aFittedpair.
adapterFor(kind) returns the adapter for a kind. drawableGeometry(frame, drawable) looks up the adapter and returns its geometry. Code outside the adapters calls these two functions and never switches on kind.
The adapters are held in a registry typed so that every kind must have an adapter. Adding a kind to Drawable without an adapter is a compile error.
Every adapter has all five members. None is optional.
AdapterEnv is { measureText }, the I/O the pure package cannot do for itself. Only text uses it. Callers always pass it, as in adapter.resize(drawable, change, env). The box and points adapters declare shorter functions that ignore it, which TypeScript accepts, so there is still one adapter shape.
Fitted<K> is the result of an edit. It holds the new frame and the new drawable of kind K. An edit returns both because an adapter that stores points refits the frame to them.
Handle is a grab point that belongs to the adapter. It is a union tagged by kind, and every member has its point in page space:
| Type | kind |
Fields | Used by |
|---|---|---|---|
VertexHandle |
"vertex" |
index, the index of the point it moves |
line |
TerminalHandle |
"terminal" |
terminal, a Terminal: "start" or "end" |
arrow |
BendHandle |
"bend" |
None | arrow |
An adapter given a handle of a kind it does not own returns drag.frame and the drawable unchanged. The eight resize handles belong to every frame and come from RESIZE_HANDLES, so handles never lists them.
HandleDrag holds what dragHandle needs. It carries the frame the drag started from, the handle being dragged and the current pointer in page space.
The box adapters keep no data of their own. Their resize returns the frame from the change, their handles is empty, and their dragHandle returns the frame from the drag. Both leave the drawable as it was.
Callers use the adapter directly, as in adapterFor(shape.drawable.kind).resize(shape.drawable, change, env).
Geometry
Geometry holds outline, a list of LocalPoints, and closed. When closed is true, the outline is a polygon and the last point connects back to the first. When it is false, the outline is an open polyline and has no closing edge. The box adapters always return closed: true.
- Rectangle: the four corners, clockwise from the top-left. When
cornerRadiusis above 0, each corner becomes a flattened quarter arc. The radius is clamped to half the shorter side. - Diamond: the four edge midpoints, clockwise from the top.
- Ellipse: points spaced evenly around the ellipse, starting at its rightmost point. The number of points keeps the distance between the curve and each straight segment at 0.5 units or less, within a range of 8 to 512 points.
A zero-size frame gives an outline whose points are all at the local origin. It never contains NaN.
Hit testing
hitTest(frame, geometry, point, options) checks a page point against a shape’s outline. It converts the point to local space, so rotation is handled. The HitTestOptions are:
| Option | Meaning |
|---|---|
tolerance |
The largest distance from the outline that still counts as a hit |
filled |
When true, every point inside the outline also counts as a hit |
An open outline is hit only near its stroke. It has no interior, so filled has no effect and the edge from the last point back to the first does not exist. An open outline with one point is a single zero-length edge, so a dot is hittable within the tolerance.
The tolerance is in frame-local units. Convert screen pixels to page units with the camera zoom before you pass them.
import { adapterFor, drawableGeometry, hitTest, pagePoint, radians } from "@draw/shapes";import type { AdapterEnv, Drawable, Frame, Geometry } from "@draw/shapes";
const env: AdapterEnv = { measureText: (line, font) => line.length * font.size * 0.6 };
const frame: Frame = { x: 0, y: 0, width: 100, height: 60, rotation: radians(0) };
const diamond: Drawable = adapterFor("diamond").create(frame, env);
export const geometry: Geometry = drawableGeometry(frame, diamond);
export const centreHits: boolean = hitTest(frame, geometry, pagePoint(50, 30), { tolerance: 4, filled: true,});
export const cornerHits: boolean = hitTest(frame, geometry, pagePoint(5, 5), { tolerance: 4, filled: true,});The point (5, 5) is inside the frame but outside the diamond, so it misses even though the shape is filled.