Documentation
Viewport
Canvas drawing tools and Three.js rendering share core geometry while keeping editing and playback state explicit.
Viewport components
apps/desktop/src/viewport/index.ts exports Editor2D, View3D, their prop types, available tool identifiers, and solidsFromProfiles. Both components fill their parent: provide a container with a non-zero width and height. Core CAD entities and CAM programs are the shared data model.
The 2D editor renders to Canvas 2D and maintains its own selection, camera, and command history. The 3D viewer uses Three.js, orbit and transform controls, asynchronous mesh jobs, and program playback. Rendering a path or solid is independent of saving it or verifying a manufacturing operation.
Integration contract
Editor2DProps requires drawing, onChange, and selectedTool. Feed emitted drawings back into the prop. The component recognizes its own emitted document objects; replacing the document externally loads the new drawing and resets editor state.
import { createSignal } from "solid-js"
import { Editor2D, type ToolId } from "./viewport/index.ts"
const [drawing, setDrawing] = createSignal(initialDrawing)
const [tool, setTool] = createSignal<ToolId>("select")
<Editor2D drawing={drawing()} onChange={setDrawing} selectedTool={tool()} /> Selection excludes locked entities and entities on locked layers. Invisible entities and hidden layers are excluded from the rendered spatial index. New entities receive document-unique identifiers and are assigned to a visible, unlocked layer when one is available.
Drawing tools and gestures
The tool registry has factories for select, line, rectangle, circle, ellipse, polyline, arc, text, dimension, move, rotate, scale, mirror, offset, trim, and fillet. AVAILABLE_TOOLS is derived from those registered factories. startTool additionally recognizes chamfer through the fillet tool factory, but chamfer is not advertised in that array. Consumers should use the actual registry to decide which tools to enable.
- Wheel or pinch zooms; Space-drag, middle-drag, and right-drag pan.
Ffits the indexed drawing. Delete or Backspace removes the unlocked selection.Cmd/Ctrl+Zundoes;Cmd/Ctrl+Shift+ZorCmd/Ctrl+Yredoes.- Enter commits a polyline. Escape is delivered to the active tool to cancel its current gesture.
- With text active, type characters or Backspace before placement; the initial text is
Text.
Pointer positions are converted to world coordinates before tool dispatch. Snapping combines the spatial index, geometric candidates, and a camera-dependent grid; the selection hit tolerance corresponds to eight screen pixels. Tool machines produce commands and previews rather than writing files or calling the API.
3D rendering and playback
View3D accepts drawings, solids, optional profile extrusion, a cam.Program, stock dimensions, and tool definitions. It supports perspective, top, bottom, front, back, left, right, isometric, and four-view presets. Display modes are shaded, shaded with edges, wireframe, and x-ray. Lighting, material properties, clipping sections, grid, axes, and origin are configurable through props.
Control playback with playing, playbackTime in seconds, playbackRate, and onPlaybackTimeChange. Playback expands drilling cycles, tessellates circular/helical motion, preserves dwell time, and draws rapid moves distinctly. Tool maps and an optional default cutter supply cutter, shank, and holder geometry; assumed geometry is visually distinguished.
Selection callbacks report a solid index. Transform controls emit position, rotation, and scale through onTransformCommit; callers decide how to persist that transform. Toolpath overlays can show points and labels, point-kind filters, arc centers, arc radii, and stock transparency.
Playback is a visual representation
The viewer animates the supplied program and draws stock and tools. It does not perform material removal. Collision and machine-envelope checks belong to the CAM verification modules; viewing a program does not constitute machine approval.
Coordinates and units
Core drawing coordinates and stock sizes use millimetres. Drawing angles and Three.js rotation components use radians; do not apply that convention to CAM rotary-axis values or feature inputs that explicitly use degrees. viewport/units.ts contains conversion helpers for displayed millimetres and inches. Convert at input/output boundaries so snapping, geometry, and toolpath motion retain a consistent unit system.
The 2D camera maps world coordinates to CSS pixels; backing canvas dimensions incorporate device pixel ratio. Resizing updates the camera and backing buffer through ResizeObserver. Camera scale determines grid spacing and screen-space hit tolerances.
Inch display converts with exactly 25.4 mm per inch, and lengths default to 3 decimals (apps/desktop/src/viewport/units.ts). In the 2D editor, each wheel step zooms by a factor of 1.15, the scale is clamped between 0.001 and 100,000, and fit-to-view leaves a 10% margin (editor2d/camera.ts).
Resources and lifecycle
The 2D component cancels queued animation frames and disconnects its resize observer on unmount. The 3D component releases resize and visibility listeners, orbit and transform controls, mesh workers, animation scheduling, geometries, materials, sprites, tool meshes, and the WebGL renderer.
A frame scheduler coalesces invalidations and requests further frames while camera damping or playback needs them. Visibility changes suspend work. This is a scheduling policy, not a guaranteed frame-rate budget for every model and device.
Module map
editor2d/editor2d.tsx: controlled component, pointer dispatch, camera, selection, history, and canvas painting.editor2d/tools/: gesture state machines and command construction.editor2d/camera.ts,render.ts, andsnap-pipeline.ts: coordinate transforms, entity rendering, picking, and snap orchestration.spatial/grid.ts: drawing spatial index.view3d/view3d.tsx: scene composition, camera controls, selection, transforms, stock, and tools.view3d/playback.ts,toolpath-overlays.ts, andmodeling.ts: motion timeline, overlay data, and profile-to-solid conversion.view3d/mesh-jobs.tsandmesh-worker.ts: background geometry construction and job ownership.
Tool availability
ToolId describes the larger tool vocabulary; AVAILABLE_TOOLS describes the registered subset. For interactive tools, use startTool(id, lookup, context, text). Selection lookup queries candidate entities; context supplies the current selection and entity access; text supplies the annotation contents.
A missing factory returns undefined. Handle that explicitly rather than presenting a disabled or unimplemented tool as an active editing mode. Tools that modify existing geometry require current selection and entity context; invoking a factory without those dependencies is insufficient for a useful edit.
Meshing and verification
For solids with an estimated vertex count above 50,000 and browser Worker support, the viewer queues mesh construction in a module worker. Smaller solids mesh synchronously. The mesh queue handles completion, stale jobs, worker errors, and cleanup. The threshold is an estimate used for scheduling, not a documented model-size limit.
bun test apps/desktop/src/viewport
bun run --filter @cadcamfun/desktop typecheck Logic tests cover rendering helpers, tool gestures, spatial indexing, playback, and overlays. Browser/WebGL behavior also depends on the graphics environment; inspect actual viewport interaction when changing camera controls, scene lifecycle, or rendering.
Controlled playback example
A parent can own transport controls without duplicating the viewer's motion timeline. This component accepts an already constructed CAM program and controls time, play state, speed, and the viewport preset. Its import paths assume a file directly under apps/desktop/src.
import { createSignal } from "solid-js"
import { cam } from "@cadcamfun/core"
import { View3D } from "./viewport/index.ts"
export function ProgramPreview(props: { program: cam.Program }) {
const [seconds, setSeconds] = createSignal(0)
const [playing, setPlaying] = createSignal(false)
return <div style={{ height: "600px" }}>
<button onClick={() => setPlaying(value => !value)}>Play / pause</button>
<button onClick={() => { setSeconds(0); setPlaying(true) }}>Restart</button>
<View3D
program={props.program}
preset="isometric"
playing={playing()}
playbackTime={seconds()}
playbackRate={1}
onPlaybackTimeChange={setSeconds}
stock={{ origin: { x: 0, y: 0, z: -10 }, size: { x: 100, y: 80, z: 10 } }}
/>
</div>
} The playback builder starts at the origin, expands cycles into moves, and calculates durations from travel length and feed. Its default rapid speed is 5,000 mm/min and default arc tessellation tolerance is 0.1 mm. These are preview assumptions, not measured machine acceleration or controller timing. Dwell commands retain their stated seconds; comments and orientation-only entries do not add linear travel.
Tool colors are assigned by each tool identifier's first occurrence and retained across its paths. Supply a tool map/default tool for assembly geometry; a colored path alone does not establish the cutter or holder dimensions used by verification.
Editing recipes and current tool limits
| Operation | Gesture and preconditions | Current behavior |
|---|---|---|
| Offset | Select a supported entity, then drag; Shift chooses the entity under the cursor instead. | Lines/polylines/rectangles produce a polyline result. Circle and arc implementations translate the center while retaining radius; this tool is not a general concentric-offset operation for curves. |
| Trim / extend | Select source and boundary entities first, activate Trim, and click the target. Alt selects extend mode. | The implementation searches the other selected entities for intersections. It changes lines and polylines; unsupported target kinds or missing intersections produce no edit. |
| Fillet / chamfer | Pick two intersecting lines. Shift switches to chamfer. | The shared tool uses an initial radius of 5 mm and replaces the pair with trimmed lines and a connecting polyline. It is not an arbitrary solid-edge fillet. |
| Transform a solid | Provide onTransformCommit, select a mesh, then activate translate/rotate/scale. | The callback reports the solid index and transform values. Picking and transform gizmos are disabled in four-view mode. |
These details come from the gesture implementations rather than their palette labels. Inspect the preview before committing and use Undo if the result differs from the intended geometry. A tool returning no command can mean its geometric prerequisites were not satisfied, not that the editor lost the pointer event.
Rendering and meshing failures
The background mesh queue admits at most 128 pending jobs by default, runs one at a time, and has a default 15-second timeout per job. Resetting a scene terminates the worker and clears queued work; stale responses are ignored. Invalid worker responses, message decoding errors, timeouts, and queue capacity failures report errors through the queue callback.
| Symptom | Action |
|---|---|
| Empty canvas with no geometry | Give the parent a non-zero size, check entity/layer visibility, and fit the view. |
| Nothing can be selected | Check entity/layer locks in 2D; check four-view mode and transform state in 3D. |
| Mesh timeout or full queue | Reduce scene complexity or the number of simultaneously submitted solids and inspect the worker error. A failed batch is cleared rather than installed partially as a completed job. |
| Playback time seems wrong | Check program feeds, dwell moves, rapid-speed assumptions, and whether a changed program reset the parent time. |
| WebGL is unavailable | Use a supported graphics environment and inspect the component's unavailable-renderer state; logic tests cannot prove GPU availability. |
Relevant tests include editor2d/tools/offset.test.ts, annotation-transform.test.ts, spatial/grid.test.ts, view3d/playback.test.ts, and toolpath-overlays.test.ts. The mesh queue implementation is view3d/mesh-jobs.ts.
Display units in detail
The editor always works in millimetres. Only what the user reads and types is converted, using the exact factors below. A stored drawing's own unit stays mm or inch; the extra units are display choices only.
| Unit | Label | mm per unit | Default decimals | G-code program unit |
|---|---|---|---|---|
mm | mm | 1 | 3 | mm |
um | µm | 0.001 | 1 | mm |
cm | cm | 10 | 4 | mm |
m | m | 1000 | 6 | mm |
inch | inch | 25.4 | 4 | inch |
thou | thou | 0.0254 | 2 | inch |
import { formatLength, parseLength, formatAngle, programUnitsOf } from "./units"
formatLength(25.4, "inch") // "1.0000 inch"
formatLength(12.5) // "12.500 mm"
formatLength(0.0254, "thou") // "1.00 thou"
parseLength("2 in", "inch") // 50.8 (a trailing unit word is ignored)
parseLength("abc") // undefined
formatAngle(Math.PI / 2) // "90.0°"
programUnitsOf("thou") // "inch" parseLength strips a trailing alphabetic suffix (including µ and °) and interprets the number in the current display unit. The suffix is not used to pick a unit, so typing 2 mm while the display is in inches yields 50.8 mm. Empty, non-numeric, and infinite input returns undefined, and editors should keep the previous value in that case.
2D camera and grid
worldToScreen flips Y so that world +Y points up on screen.| Constant / function | Value | Behaviour |
|---|---|---|
ZOOM_STEP | 1.15 | Factor per wheel step; zoomAt keeps the world point under the cursor fixed. |
MIN_SCALE / MAX_SCALE | 0.001 / 100,000 px per mm | Scale is clamped to this range. |
FIT_MARGIN | 0.1 | Fraction of the viewport kept as margin by cameraForBounds. |
createCamera() | centre (0, 0), scale 1, 800 × 600 | Initial camera before the first resize. |
gridStep(scale, major = 10, minPixels = 8) | 1-2-5 ladder | Smallest step that keeps grid dots at least 8 px apart. |
const camera = createCamera({ x: 0, y: 0 }, 2, 800, 600) // 2 px per mm
worldToScreen(camera, { x: 10, y: 10 }) // { x: 420, y: 280 }
screenToWorld(camera, { x: 420, y: 280 }) // { x: 10, y: 10 }
gridStep(2) // 5 -> 5 mm apart = 10 px
gridStep(0.5) // 20 -> 20 mm apart = 10 px Rendering culls entities against visibleBounds(camera). Snapping also avoids scanning the whole drawing: a uniform spatial grid returns entities whose bounds lie within the snap tolerance plus a 2 mm candidate margin, and only those are passed to the core findSnap. This keeps pointer moves fast in drawings with many entities.
Snapping reference
Snap types are tried in priority order. The default order matches the legacy editor: endpoint, midpoint, center, intersection, perpendicular, tangent, nearest, and quadrant. Endpoints are the source entity's vertices, not its tessellation's.
| Setting | Meaning |
|---|---|
enabled | Master switch; false disables all snapping. |
types | Enabled snap types, highest priority first. |
tolerance | Snap radius in millimetres; it does not depend on zoom. |
gridEnabled, gridSpacing | Snap to the grid when no object snap applies. |
referencePoint | The point being drawn from. perpendicular and tangent find nothing without it. |
The pipeline returns the snap, the IDs of entities under the cursor (nearest first, which the select tool consumes), and the number of candidates considered.
How toolpath playback is timed
buildPlayback(program, rapidRate = 5000, tolerance = 0.1) converts a CAM program into timed segments. Each move's duration is its length divided by its feed (or by the rapid rate for rapids), times 60, giving seconds. Dwells add their own time without moving. Comments, orientation moves, and canned cycles are not drawn as segments. Arcs are split into chords whose sagitta stays within tolerance (between 2 and 65,536 chords per arc), and Z is interpolated along the arc for helices.
const playback = buildPlayback(program) // rapids at 5000 mm/min
playback.duration // total seconds
positionAt(playback, 12.5) // tool position at t = 12.5 s
toolAt(playback, 12.5) // toolId cutting at that time
toolColors(program) // Map toolId -> colour, first use order
// A 10 mm feed move at 600 mm/min lasts 10 / 600 × 60 = 1 s.
// The same 10 mm as a rapid lasts 10 / 5000 × 60 = 0.12 s. segmentIndexAt finds the playing segment by binary search, so seeking is cheap even for long programs. Seeking past the end returns the final position, and toolAt then reports the last tool. Playback time is a visual estimate based on programmed feeds; it ignores machine acceleration, so a real machine takes longer.
Viewport practices
- Convert at the edge: store and compute in millimetres and radians, and format only for display with
formatLengthandformatAngle. - Never reuse a parsed length after
parseLengthreturnsundefined; keep the previous value and show a validation hint. - When adding a drawing tool, register it in the tool factory table so the toolbar, shortcuts, and tests address it by the same ID.
- Keep snap candidate queries spatial. Passing every entity to
findSnapon each pointer move defeats the index. - Treat playback duration as a preview, not a cycle-time quote.
Hit-testing at scale: the spatial grid
The 2D editor targets 60 fps with 10,000 entities, so hit-testing and culling never scan every entity per pointer event. viewport/spatial/grid.ts keeps a uniform grid: each item is registered in every cell its bounding box touches, and a query returns all items whose bounds intersect the query box, in insertion order. The result is exact, never a sample.
| Setting | Value | Why |
|---|---|---|
| Default cell size | 50 world units (mm) | Suits a drawing about 1 m across |
| Minimum cell size | 1e-6 | Guards against a zero or negative size, which would loop forever |
| Cells per item limit | 4,096 | Larger items (a long line across the sheet) are tracked aside instead of per cell |
| Method | Behaviour |
|---|---|
build(items) | Rebuild from scratch |
upsert(id, item) | Insert or replace one item by id |
remove(id) | Remove; unknown ids are ignored |
query(bounds) | Ids whose bounds intersect the box |
Cells touched by the dashed query box are visited; A and the long line are returned because their bounds intersect it, B is not.
3D meshing queue
Solids are meshed in a Web Worker so the UI thread never blocks. MeshWorkerQueue in view3d/mesh-jobs.ts runs one job at a time:
| Rule | Value | Message on failure |
|---|---|---|
| Capacity (queued + running) | 128 | The 3D mesh queue is full. Reduce the scene complexity. |
| Timeout per job | 15 s | 3D meshing timed out. Reduce the scene complexity. |
| Worker error | — | The worker's message, or "The mesh worker stopped unexpectedly." |
| Undecodable reply | — | The mesh worker could not decode its response. |
| Bad geometry | positions must be Float32Array, indices Uint32Array | The mesh worker returned invalid geometry. |
Any failure terminates the worker and clears the whole queue, so stale scene work never lands after a newer edit; the next submission starts a fresh worker. Replies are matched by job id, so a late answer from a terminated worker is ignored.
2D tool implementations
| File | Tools |
|---|---|
tools/two-point.ts | line, rectangle, circle by diameter, ellipse |
tools/chained.ts | polyline, three-point arc |
tools/fillet-chamfer.ts | round or bevel the corner between two lines |
tools/offset.ts | offset a polyline, rectangle, circle or arc by a distance |
tools/trim.ts | trim and extend |
tools/select.ts | click, box and add/remove selection |
tools/annotation-transform.ts | move and transform annotations |
Each tool is a small state machine with onPointerDown, onPointerMove and onPointerUp, created by a factory in tools/index.ts, one per palette entry. Pointer positions pass through snap-pipeline.ts before a tool sees them.