Rendering & Camera
The canvas is a plain Canvas2D full-repaint renderer: every frame redraws the entire scene from the current document, rather than diffing and patching. Coordinates live in one of two spaces — document millimeters and screen pixels — and the camera is the single mapping between them.
The camera model
camera.ts defines the camera as three numbers:
interface Camera {
pxPerMm: number; // zoom — also the mm→px scale factor
offsetX: number; // screen px of document (0,0)
offsetY: number;
}project/unproject are an exact inverse pair:
project(cam, mm) // { x: mm.x * cam.pxPerMm + cam.offsetX, y: mm.y * cam.pxPerMm + cam.offsetY }
unproject(cam, screen) // { x: (screen.x - cam.offsetX) / cam.pxPerMm, y: (screen.y - cam.offsetY) / cam.pxPerMm }Zoom is clamped to [0.5, 100] px/mm (MIN_PX_PER_MM / MAX_PX_PER_MM) — 0.5 still shows a whole 20HP panel comfortably; 100 is deep enough for 0.1mm node editing without the displayed numbers going silly.
| Function | Behavior |
|---|---|
fit(panelWmm, panelHmm, viewport, margin=48) | Centers the panel in the viewport at the largest zoom that leaves margin px of breathing room on every side. Called on first measure and whenever the panel size changes. |
zoomAt(cam, screen, factor) | Zooms by factor while keeping the mm point currently under screen stationary — the anchored-zoom behavior both the wheel handler and the Zoom tool rely on. |
panBy(cam, dxPx, dyPx) | Translates the camera's screen offset — what the Pan tool calls on every drag move. |
Mouse-wheel zoom is wired directly in Editor.tsx as a non-passive wheel listener (preventDefault() to stop page scroll), anchored at the cursor via the same zoomAt.
The mm rulers
The millimeter ruler strips framing the viewport (Interface → Canvas viewport & rulers) are a pure consumer of the camera: they read pxPerMm and the relevant offset and derive everything else, holding no state of their own. The tick math lives in ruler-ticks.ts (pure, DOM-free, unit-tested); components/ only paints what it returns.
The strips share the camera's exact coordinate model — screen = mm × pxPerMm + offset — so mm 0 always lands at the camera offset, i.e. the panel's top-left corner, for any pan or zoom. getRulerTicksMm(pxPerMm, offset, lengthPx, step) walks integer minor-tick indices across the visible span (overscanned one tick past each end so edge labels don't pop in and out while panning), computing each tick's mm from i × minor — which keeps mm 0 exact and makes the "is this a labeled major tick?" test the float-safe i % 5 === 0.
The tick spacing adapts to zoom on a 1-2-5 × 10ⁿ ladder. pickTickStepMm(pxPerMm) picks the smallest such step whose on-screen major-tick spacing is at least ~50px, so labels never crowd: at deep zoom the major step can drop to a fraction of a millimeter (labels then show one decimal), and when zoomed far out it climbs to 10mm, 20mm, 50mm, and beyond. Minor (unlabeled) ticks sit at one-fifth of the major step.
Crucially, the strips are fixed in layout — they occupy static 20px gutters and never receive a pan/zoom transform. Only their canvas content repaints when the camera changes; the elements themselves don't move. (Position-syncing a ruler DOM element to the scroll/pan offset is a well-known drift-bug source, avoided here by design.) Each strip snaps ticks to the device-pixel grid so lines stay crisp at any devicePixelRatio.
The repaint loop
renderer.ts's renderScene(canvas, doc, panel, cam, extras) runs on every render of Editor.tsx (it's a plain effect with no dependency array — cheap enough at this scene scale to just always run) and does, per frame:
Reset the transform and clear the whole backing store, then scale by
devicePixelRatio— so 1 canvas unit equals 1 CSS px regardless of screen density.Fill the workspace background behind the panel.
Draw a drop shadow + the panel's base fill, in screen space — bare FR4 substrate for a role-aware v5 stack (the black solder mask is composited later, above copper), or the legacy black base for a pre-v5 flat layer array.
When Show content outside the panel is on, run the off-panel ghost pass — the layer content that spills past the panel edge, dimmed, drawn before the clipped pass below into a disjoint exterior region.
One
setTransform-equivalent for the whole layer pass:ctx.save(), clip to the panel rect,ctx.translate(offsetX, offsetY),ctx.scale(pxPerMm, pxPerMm)— from this point until the matchingctx.restore(), 1 canvas unit = 1mm, matching the pattern generators' own coordinate contract (see Patterns → Generator contract).projectPcbLayerSlices()splits the visible ordinary children into Copper / Solder mask / Silkscreen slices, folds ancestor/container visibility, and forces each non-null paint to its owning material. Copper and Silkscreen are then painted positively in that physical order, but the Solder mask slice is not: its leaves are punched out of a full-panel black sheet underdestination-out(mask-sheet.ts) and that sheet is composited above copper, so mask artwork reads as an opening. A hidden Solder mask container skips the sheet entirely — no mask anywhere, bare copper on substrate — while an empty visible one still composites the full sheet.Restore, then stroke the panel outline in screen space.
Draw the guides — thin lines across the whole viewport, above the layer content and below the selection chrome.
Draw unclipped selection chrome (a dashed bbox per selected layer, a combined bbox when more than one is selected, and resize/rotate handles plus path node anchors/handles only when exactly one layer is selected).
Call the active tool's
renderDrafthook, if it has one, so an in-progress gesture (the pen tool's draft path, or the select tool's marquee/hover, for example) paints on top of everything else.
Per-layer-type drawing
drawLayer() switches on layer.type inside the mm-space transform:
shape —
ctx.rect/ctx.ellipsefilled with the projected owning material. Ellipse radii areMath.abs()'d becausectx.ellipsethrowsIndexSizeErroron a negative radius, while a negative width/height is otherwise a valid (mirrored) rect the way therectbranch already handles it.pattern — looked up by
patternTypeinPATTERN_GENERATORS. The layer's ownx/y/sizesquare gets its own nested transform first —ctx.save(),ctx.translate(x, y), clip to(0,0)–(size,size)as a separate clip op composed with whatever clip the caller holds,ctx.restore()after — and the generator'sdraw()is called inside that withwidthMm/heightMmset tosize(see Patterns → Generator contract), so it draws in object-local square space, not panel space. A pattern is only actually drawn when itssizeis finite, positive, and withinMAX_PATTERN_SIZE_MM— a malformed or absurd size (a hand-edited import, say) is silently skipped rather than spinning a generator's draw loop over an unbounded span.path — built into a
Path2D(buildPath2D, from@zpd/core), filled with theevenoddrule when closed (so holes stay holes; in the Solder mask container the same fill punches an opening, and an even-odd hole inside it keeps its mask), then stroked if stroke is enabled and its width is set. Both paints use the projected owning material.text —
ensureFont()is called fire-and-forget on every draw; if the real face isn't loaded yet, the browser's fallback face is drawn immediately, and once the font resolves the next requested repaint (from the tool or inspector that changed it) picks up the real glyphs. Multi-line content is split on\nwith a 1.25× line-height.image — draws the cached
<img>if it's loaded, otherwise a dashed placeholder outline (images are decoded/cached byEditor.tsx's asset-loading effect, keyed by layer id).
A rotated shape/text/image layer is rotated around its own bbox center via ctx.translate/ctx.rotate/ctx.translate before the type-specific draw runs; path and pattern layers have no rotation field.
The off-panel ghost pass
Historically the editor clipped every layer to the panel rect, so a layer dragged partly off the panel had its off-panel portion clipped away while its selection handles still drew — you saw handles floating over empty workspace with no shape under them. The Show content outside the panel view option (default on — see Interface → Right sidebar) fixes that by painting the off-panel region as a dimmed ghost.
The pass runs before the clipped in-panel pass, into its own region. outsidePanelRegion() (outside-panel-region.ts, pure and node-tested) decides the two clip rects and which layers are eligible; renderer.ts turns that into the canvas calls. The region is an even-odd clip of two rects — the whole viewport (outerRect) minus the panel (innerRect) — which clips to exactly the region outside the panel, disjoint from the panel-clipped pass, so every pixel is painted by exactly one pass. That disjointness is load-bearing, not cosmetic: a plain full-viewport clip would also paint the ghost content under the panel, so any in-panel layer with opacity below 1 would double-composite against its own full-alpha draw — a real Porter-Duff alpha bug this feature was careful to avoid. Inside the clip the ghost content is drawn at 35% alpha.
Why dimmed, and why view-only
The dimming is a deliberate design choice, recorded in code and here so it is not "corrected" into full opacity later. In zpd the area beyond the panel edge is physically cut off in fabrication — so a dimmed ghost encodes "this will not be manufactured", not merely "this is off to the side".
The toggle is view-only. Export has never clipped: serializePanelConfig (serialize.ts) emits doc.layers verbatim — off-panel geometry and all — and the clip only ever existed in the renderer. So turning the ghost on or off, like the clip it gates, changes only what you see, never what you order. (An off-panel layer is still a modeling mistake for a real panel; the ghost just makes it visible instead of silently clipped.)
What is ghosted
outsidePanelRegion filters the eligible layers:
Hidden layers are never drawn, as everywhere else.
Pattern layers are eligible, since the pattern square landed. Earlier, patterns were excluded here because a pattern's overscan past its own draw span (
@zpd/patterns'centeredStart()) would otherwise flood the whole gutter with dot-grid the moment it was ghosted. Apatternlayer is now a movable square —layerBbox()returns its ownx/y/sizerect, not the panel rect (see Tools → Select → Pattern squares) — and the renderer clips a pattern's draw to that square as its own separate clip op (ctx.save()/ctx.rect(0, 0, size, size)/ctx.clip(), composed with whichever clip the caller holds). That per-square clip bounds the overscan on every side, including in the ghost pass, so a ghosted pattern paints exactlysquare ∖ panelat the same 35% alpha as any other layer — never the unbounded flood the old exclusion was guarding against.Layers fully inside the panel are skipped — a pure performance cull. A layer whose rotation-aware bbox stays within the panel can't contribute a visible ghost pixel (the exterior clip rejects everything it paints), so drawing it here would just re-render it — and, for a path layer, rebuild its
Path2D— a second time per repaint for zero visual effect, costly on a large trace with hundreds of path layers. This cull is why a cover-default pattern square (the common case) never actually ghosts: its bbox equals the panel, so it never crosses the boundary check in the first place — only a pattern square a user has dragged or resized past the panel edge does.
mm↔px only exists at the transform boundary
Because the layer pass runs entirely inside one ctx.scale(pxPerMm, pxPerMm), every generator and every layer-drawing branch works purely in millimeters — none of them ever multiplies by pxPerMm themselves. The only code that thinks in screen pixels is: the camera module itself, hit-testing/handle math in the Select tool (handle sizes are a fixed screen px regardless of zoom), and the selection-chrome pass, which is deliberately drawn outside the mm-space clip/transform so its stroke widths and handle sizes stay a constant screen size at any zoom.
Note
layerBbox() and resizeHandleRects() — the geometry helpers behind hit-testing and the resize handles — live in renderer.ts alongside the paint code because they need the exact same per-type geometry rules the renderer uses to draw. Tools import them rather than recomputing bounding boxes independently.