Skip to content
CADCAMFUN
Docs

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.

Controlled 2D editor
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.
  • F fits the indexed drawing. Delete or Backspace removes the unlocked selection.
  • Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z or Cmd/Ctrl+Y redoes.
  • 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, and snap-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, and modeling.ts: motion timeline, overlay data, and profile-to-solid conversion.
  • view3d/mesh-jobs.ts and mesh-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.

Viewport logic checks, from the repository root
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.

Program preview with external transport controls
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

OperationGesture and preconditionsCurrent behavior
OffsetSelect 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 / extendSelect 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 / chamferPick 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 solidProvide 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.

SymptomAction
Empty canvas with no geometryGive the parent a non-zero size, check entity/layer visibility, and fit the view.
Nothing can be selectedCheck entity/layer locks in 2D; check four-view mode and transform state in 3D.
Mesh timeout or full queueReduce 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 wrongCheck program feeds, dwell moves, rapid-speed assumptions, and whether a changed program reset the parent time.
WebGL is unavailableUse 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.

UnitLabelmm per unitDefault decimalsG-code program unit
mmmm13mm
umµm0.0011mm
cmcm104mm
mm10006mm
inchinch25.44inch
thouthou0.02542inch
Converting and parsing lengths (apps/desktop/src/viewport/units.ts)
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

world (mm, +Y up)screen (px, +Y down) center+X+Y (width/2, height/2)+x+y →
The 2D editor's camera stores the world point at the centre of the viewport and a scale in pixels per millimetre. worldToScreen flips Y so that world +Y points up on screen.
Constant / functionValueBehaviour
ZOOM_STEP1.15Factor per wheel step; zoomAt keeps the world point under the cursor fixed.
MIN_SCALE / MAX_SCALE0.001 / 100,000 px per mmScale is clamped to this range.
FIT_MARGIN0.1Fraction of the viewport kept as margin by cameraForBounds.
createCamera()centre (0, 0), scale 1, 800 × 600Initial camera before the first resize.
gridStep(scale, major = 10, minPixels = 8)1-2-5 ladderSmallest step that keeps grid dots at least 8 px apart.
Mapping between world and screen
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.

SettingMeaning
enabledMaster switch; false disables all snapping.
typesEnabled snap types, highest priority first.
toleranceSnap radius in millimetres; it does not depend on zoom.
gridEnabled, gridSpacingSnap to the grid when no object snap applies.
referencePointThe 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.
Seek and colour a preview
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.
Playback preview Drag to orbit · scroll to zoom · right-drag to pan
The same pocket program as on the CAM page, replayed in the first tool colour (#38bdf8). In the desktop, each additional tool gets the next colour in the palette: #22c55e, #f97316, #e879f9, #facc15, and so on.
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 formatLength and formatAngle.
  • Never reuse a parsed length after parseLength returns undefined; 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 findSnap on 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.

SettingValueWhy
Default cell size50 world units (mm)Suits a drawing about 1 m across
Minimum cell size1e-6Guards against a zero or negative size, which would loop forever
Cells per item limit4,096Larger items (a long line across the sheet) are tracked aside instead of per cell
MethodBehaviour
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
query boxentity ABlong line

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:

RuleValueMessage on failure
Capacity (queued + running)128The 3D mesh queue is full. Reduce the scene complexity.
Timeout per job15 s3D 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 geometrypositions must be Float32Array, indices Uint32ArrayThe 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

FileTools
tools/two-point.tsline, rectangle, circle by diameter, ellipse
tools/chained.tspolyline, three-point arc
tools/fillet-chamfer.tsround or bevel the corner between two lines
tools/offset.tsoffset a polyline, rectangle, circle or arc by a distance
tools/trim.tstrim and extend
tools/select.tsclick, box and add/remove selection
tools/annotation-transform.tsmove 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.