Geometry & editing contracts
bbox math, hit-testing rules, layer-list ops, path geometry, resize gating, and grid snapping.
Bbox math
packages/ holds the rect/bbox math shared across the other geometry modules. Document space is millimeters; rotation is always degrees clockwise about the rect's own center — the same convention used throughout @zpd/core.
| Function | Purpose |
|---|---|
rectCenter(rect) | Center point of a rect. |
rectCorners(rect) | The 4 corners as [Pt, Pt, Pt, Pt]. |
boundsOfPoints(points) | Axis-aligned bounds enclosing a set of points. |
rotatedRectAABB(rect, rotationDeg) | Rotates the rect's 4 corners about its own center, then takes the min/max — the rotated bounding box. rotationDeg of 0/undefined is a fast no-op; the result is always a fresh copy, never the input rect by reference. |
unionBbox(a, b) | The smallest rect containing both a and b. |
mergeBboxes(rects) | unionBbox reduced over a list — the enclosing bbox of a whole selection. |
Hit-testing
packages/ implements canvas hit-testing in mm space over the already projected flat leaves. hitTestDoc(doc, mmX, mmY) uses two topmost-first tiers: non-pattern artwork first, then patterns only when no ordinary artwork hits. Hidden ancestors/containers have already been folded into the projection.
| Layer type | Hit test |
|---|---|
shape | Point-in-rotated-rect, with an ellipse branch (normalized ellipse equation) when shape === 'ellipse'. |
image | Point-in-rotated-rect (rectangle only, no ellipse branch). |
text | Point-in-rotated-rect against an estimated bbox — @zpd/core has no canvas/DOM font metrics, so estimateTextBbox uses a rough monospace-ish estimate (TEXT_CHAR_WIDTH_FACTOR = 0.6, TEXT_LINE_HEIGHT_FACTOR = 1.2) rather than real glyph measurement. Good enough for click-to-select; not a layout authority. |
path | Even-odd point-in-polygon against the flattened fill (only when closed), plus a stroke distance test with a generous grab-zone floor: Math. (MIN_STROKE_GRAB_MM = 1.5), so thin strokes stay easy to click. |
pattern | Point-in-square against its movable x/y/size bounds; considered only in the fallback pattern tier. |
Patterns do not swallow artwork clicks
A pattern square can be selected from the canvas, but every non-pattern hit wins first even when the pattern is visually above it. This preserves access to artwork beneath a large background pattern.
Because there's no browser Path2D in plain Node/Vitest, path hit-testing doesn't use isPointInPath/isPointInStroke — it flattens beziers to polylines (see below) and does its own even-odd ray casting and point-to-segment distance, keeping @zpd/core dependency-free and testable outside a browser.
Layer-list operations
packages/ retains small flat-array helpers for projected leaf operations. Canonical document mutations use the ordinary-tree and fixed-stack helpers in group-ops.ts. Every function is pure and immutable, and semantic no-ops preserve the exact input reference so callers can avoid phantom history entries.
| Function | Effect |
|---|---|
insertPcbNode(stack, role, node, parentId?, index?) | Inserts an ordinary leaf/group under one fixed material, enforcing depth and id safety. |
movePcbNode(stack, id, role, parentId?, index?) | Reparents or reorders an ordinary subtree, including across materials. |
groupPcbNodes(stack, ids, name?) / ungroupPcbNode(...) | Groups compatible ordinary roots or unwraps one ordinary group; fixed roots never participate. |
clonePcbNode(...) / deletePcbNodeById(...) | Clones with fresh ids or deletes an ordinary subtree. |
updatePcbNodeById(...) / mapPcbLeavesById(...) | Updates ordinary nodes/leaves and re-normalizes effective paint to their owner. |
togglePcbLayerHidden(stack, role) | Toggles persisted, undoable visibility of one fixed container. |
Fixed roots have no rename/delete/select/reorder counterparts by design. Cross-container moves normalize all non-null paint to the destination material; a rejected insertion/move returns the original stack.
Path geometry
packages/ holds bezier path helpers, all pure TypeScript with no runtime browser dependency.
PathPointLike— an anchor (x,y) with optional absolute handle coordinateshin/hout, mm.flattenSubpath(points, closed, segments = 24)— walks each anchor-to-anchor bezier segment and samples it into a polyline (DEFAULT_FLATTEN_SEGMENTS = 24points per segment). This is what powers hit-testing and bbox math outside a browser.flattenPath(points, closed, extraSubpaths?)— flattens the primary subpath plus anyextraSubpaths, which are always treated as closed.pathBbox(points, extraSubpaths?)— an approximation over the raw anchors + handles, not the flattened curve. Cheap, and good enough for selection chrome/bbox display, but not pixel-exact against the rendered curve.buildPath2D(...)— builds a real browserPath2D, for app/render use only; returnsnullin Node (no globalPath2D). Core's own hit-test/bbox logic and tests useflattenPathinstead, which works everywhere.translatePoints/translatePathLayer— shift a subpath (and its handles) bydx/dy.movePathAnchor(points, index, x, y)— moves one anchor and carries its handles along with it, preserving their offset from the anchor.movePathHandle(points, index, which, x, y, mirror)— moves one handle; withmirror: true, reflects the opposite handle about the anchor so the curve stays smooth (standard bezier-editor behavior).
Resize gating
packages/ implements axis-aligned, 8-handle resize math ('n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw').
function isResizable(rotation: number | undefined): boolean {
return !rotation;
}Rotated layers are not resizable in stage 1
Resize is only offered for unrotated layers — a rotated bbox's handles don't align with its visual edges, so isResizable is the one-line guard the app UI checks before showing resize handles at all.
resizeRect(rect, handle, dx, dy, minSize = DEFAULT_MIN_SIZE_MM) applies the drag delta along the handle's axes, clamped so the rect can never shrink past minSize (DEFAULT_MIN_SIZE_MM = 1 mm) or invert. Each handle keeps the opposite edge fixed — e.g. dragging 'e' keeps the west edge in place while dragging 'w' keeps the east edge in place.
Grid snapping
packages/ snaps mm coordinates to a grid:
const DEFAULT_SNAP_MM = 0.1;
function snapToGrid(value: number, gridMm: number = DEFAULT_SNAP_MM): number {
const snapped = Math.round(value / gridMm) * gridMm;
return Number(snapped.toFixed(6));
}The default grid is 0.1 mm. The round-trip through a fixed decimal string (toFixed(6)) is deliberate — it avoids float noise like 0.1 + 0.2 !== 0.3 leaking into snapped mm coordinates. snapPoint(pt, gridMm) applies snapToGrid to both x and y of a point.