# RapidCAM `.rcam` file format — version 3

This is the authoring guide and stability contract for the RapidCAM project file
format. A `.rcam` file is plain JSON. The machine-readable contract lives in
[`public/schema/rcam-v3.schema.json`](../public/schema/rcam-v3.schema.json) (JSON Schema, draft
2020-12); this document is the human- (and AI-) readable companion that explains
the parts a schema can't — the vocabulary of point keys, what each constraint
means, and the gotchas.

This guide is published at
**`https://rcamhub.com/docs/rcam-format-v3.md`**, and the bundled example
projects are listed at **`https://rcamhub.com/examples/index.json`** (see also
[`/llms.txt`](https://rcamhub.com/llms.txt)).

The schema's canonical published URL is
**`https://rcamhub.com/schema/rcam-v3.schema.json`** (this is also its `$id`).
In the repository it lives at
[`public/schema/rcam-v3.schema.json`](../public/schema/rcam-v3.schema.json), which
is what gets served at that URL.

If you are an automated tool (including an LLM) generating `.rcam` files: validate
your output against the schema, and prefer the patterns shown in
[`examples/`](../examples/). The bundled examples are golden files and are tested
against this schema on every commit.

## What changed across versions

### Version 3 (current)

Version 3 dropped machine and controller configuration from the file:

- **No controller / machine settings in the drawing.** `postProcessor`,
  `hasToolChanger`, and the machine-configuration half of the `rotary` block
  (`axisWord`, `arcTolerance`) were removed from the file. A `.rcam` is a
  drawing / design, so machine settings now live in the operator's local machine
  profile and are applied when G-code is generated.
- Declares `"version": 3`.

### Version 2

Version 2 treated a `.rcam` file as a **design**, not an editor session:

- **No selection / UI state.** The top-level `isConstructionMode`,
  `selectedPoints`, `selectedConstraintId`, `selectedDimensionId` fields and the
  per-entity `selected` flag are gone. (A file shouldn't record what happened to be
  selected when it was saved.)
- **Embedded fonts.** A new optional top-level [`fonts`](#fonts) array carries the
  bytes of any non-bundled font a text entity uses, so glyph outlines — and
  therefore toolpaths — reproduce on any machine.

Version-1 and version-2 files still open: RapidCAM upgrades them on load (chaining migrations v1 → v2 → v3).
The set of entity types, constraint types, dimension types, and point-key
vocabularies is unchanged.

## Stability promise

- Every file declares `"version": 3`. The loader auto-upgrades `"version": 1`
  and `"version": 2`; migrations chain, so a v1 file becomes v2 then v3.
- The loader is **tolerant of additive growth**: unknown fields are ignored, and
  most top-level sections default sensibly when absent (see *Minimum viable file*).
  New, optional capabilities may be added without bumping the version. Anything
  that would change or remove existing semantics gets a new `version`.
- That tolerance covers unknown **fields**. It does not make a file from a later
  version render fully in an earlier build: a new value in a closed enum (a
  dimension `type`, an operation `type`) is not a field to ignore, so that
  feature simply does not draw. It is dropped rather than guessed at — an
  unreadable dimension contributes nothing to the solve and RapidCAM says on
  load that the file has one — but it will be **lost on the next save**. Old
  files opening in a new build is the direction the promise is about, and that
  one is exact.
- **`null` is never a value inside `entities`, `constraints`, `dimensions`,
  `patterns` or `operations`.** The format uses it in exactly five places, all
  top-level and all meaning "absent": `stockRect`, `endPosition`,
  `toolChangePosition`, `flip` and `rotary`. Omit a field you do not have rather
  than writing `null` into it. A shape carrying a null anywhere is dropped on
  load and counted in the warning, because JSON has no NaN: `{"x": null}` would
  otherwise coerce to `0` and put the shape at the origin as though that were
  where it belonged — and geometry drawn in the wrong place gets cut in the
  wrong place.
- A file written by RapidCAM round-trips losslessly. A hand-authored file only
  needs the required fields below.

## Coordinate system & units

- **All lengths are millimetres**, always — regardless of `displayUnit`.
  `displayUnit` (`"mm"` or `"in"`) only controls how the UI presents numbers.
- The world frame is **Y-up**: increasing `y` moves away from the machine front.
- **Angles are radians**, measured CCW, in the world frame — except the two
  dimension types documented as DEGREES below (`angle-x` and `arc-sweep`), which
  store the unit their property field is typed in.
- The drawing lives inside `canvas` (`width` × `height`, mm), which represents the
  work area / stock footprint.

## Top-level shape

```jsonc
{
  "version": 3,
  "name": "My Part",
  "canvas": { "width": 120, "height": 80 },   // mm
  "displayUnit": "mm",                          // "mm" | "in" (display only)
  "stockThickness": 10,                         // mm, default 10
  "counter": 1,                                 // incrementing serial counter, default 1 (omitted when 1)
  "origin": { "x": "left", "y": "front", "z": "top" },
  "machineKind": "mill",                         // "mill" | "laser" | "mill-rotary" | "laser-rotary", default "mill"
  "endPosition": null,                          // optional park position; see below
  "metadata": { "job": "", "revision": "", "notes": "" }, // optional job info; see below
  "groups": [],
  "layers": [ /* optional; a Default layer is created if omitted OR empty */ ],
  "activeLayerId": "layer-0",
  "entities": [ /* geometry */ ],
  "constraints": [ /* parametric constraints */ ],
  "dimensions": [ /* measurements / driving dims */ ],
  "variables": [ /* named numbers, may reference each other */ ],
  "bindings": [ /* headless formula → an entity scalar (e.g. circle radius) */ ],
  "patterns": [ /* linear / circular patterns */ ],
  "operations": [ /* CAM toolpaths */ ],
  "tools": [ /* reusable tool definitions referenced by operations */ ],
  "fonts": [ /* embedded non-bundled fonts used by text entities */ ],
  "blockDefinitions": [ /* linked rigid-block definitions, see below */ ],
  "features": [ /* re-editable generator output, see below */ ]
}
```

### Groups & rigid blocks

`groups[]` members carry `id`, `name` and `entityIds` (direct members only). A group becomes a
**rigid block** by adding `"rigid": true` plus its transform:

- `origin` — block base point (`{ "x": mm, "y": mm }`), world coordinates.
- `angle` — block rotation in radians.
- `definitionId` — when present, this group is a **linked instance** referencing
  a top-level `blockDefinitions[]` entry, re-materialised from that definition's
  `entities` (member geometry in the block's canonical local frame — origin
  `(0,0)`, rotation 0). `scale` is the insert-time uniform scale (default 1).

`blockDefinitions[]` entries carry `id`, `name`, and `entities` (the DIRECT
member snapshots), plus an optional `children` array of nested block references.
Two groups sharing a `definitionId` share one definition; editing one instance
re-projects every instance.

### Nested blocks

A block may contain other blocks. Nesting is stored on the CHILD, as
`parentGroupId` — the id of the containing group. `entityIds` therefore lists a
block's *direct* entity members only; the block "as a whole" is its own members
plus every descendant's.

Three rules JSON Schema cannot express, checked when a file is loaded:

1. **Disjoint membership** — an entity id appears in at most one group's
   `entityIds`. A block inside a block is expressed with `parentGroupId`, never
   by listing the inner block's entities in the outer one as well.
2. **A forest** — `parentGroupId` names an existing group, and following it
   terminates. No group is its own ancestor.
3. **Acyclic definitions** — a `blockDefinition` may not contain itself at any
   depth through `children`.

A nested block is rigid relative to its parent: the whole tree is one rigid body
in the solver, and only the outermost frozen block carries the three transform
variables. Its stored `origin`/`angle` travel with the parent.

`blockDefinition.children[]` entries are
`{ definitionId, name?, origin, angle, scale? }`, placed in the **parent
definition's local frame**. Note the deliberate asymmetry in `scale`:
`group.scale` is *absolute* (this instance relative to its definition), while
`children[].scale` is *relative* to the parent and composes multiplicatively
down the tree.

The importer reports violations as `blocks` errors; the editor repairs them on
open (an entity claimed twice stays with the first group; a bad parent pointer
or a cyclic child reference is dropped) rather than refusing to open the file.

### Minimum viable file

The loader hard-requires only a few fields; the rest default. The smallest file
that loads cleanly and draws a circle:

```json
{
  "version": 3,
  "name": "Minimal",
  "canvas": { "width": 100, "height": 100 },
  "displayUnit": "mm",
  "entities": [
    { "type": "circle", "id": "ent1", "center": { "x": 50, "y": 50 }, "radius": 10 }
  ],
  "constraints": [],
  "dimensions": []
}
```

Defaults applied when omitted: `stockThickness` → 10,
`counter` → 1,
`origin` → front-left-top, `machineKind` → `"mill"`,
`endPosition` → `null`, `layers` → one `"layer-0"` "Default" layer,
`groups`/`variables`/`patterns`/`operations`/`tools`/`fonts`/`blockDefinitions` → empty.

`machineKind` is two independent choices in one field — the **head** and the
**stock**.

The head selects the output path: `"mill"` (the default) posts spindle + Z-axis
G-code; `"laser"` posts fixed-Z beam G-code (beam on/off, power + passes, no Z)
and the operations use the laser fields instead — see
[CAM operations](#cam-operations).

A `"-rotary"` kind machines a **cylinder** instead of a flat blank, described by
the top-level `rotary` block (see below). The head behaviour is unchanged by it:

- `"mill-rotary"` behaves like `"mill"` everywhere except export, which **wraps**
  the finished flat program around the cylinder — the wrapped coordinate becomes
  rotary-axis degrees.
- `"laser-rotary"` behaves like `"laser"` *including* at export. A laser rotary
  **substitutes** the axis rather than wrapping it: the wrapped coordinate stays
  on its ordinary linear word, in surface millimetres, and the machine is set up
  so one revolution equals the circumference of travel on that axis (its rotary
  is wired in place of that linear motor, with steps/mm rescaled). GRBL 1.1 —
  which drives most laser rotaries — has no 4th axis and would reject an `A`
  word, so this is the only mode offered for a beam. The posted program is
  therefore the ordinary flat one plus a setup banner stating what one revolution
  must measure; what the rotary machine kind buys you is the cylinder-sized
  canvas, the wrap hint, the preview, and that banner.

**The controller is NOT in the file.** As of v3 a `.rcam` is a drawing, so it
carries the design and not the author's machine: the post-processor, the
automatic-tool-changer flag, the rotary axis word and the arc tolerance all live
in the opener's local machine profile and are applied when G-code is generated.
A design you receive is cut with *your* controller, not the sender's.

For reference, the posts a machine can be configured with — for a **mill**,
`"linuxcnc"` or `"grbl"`; for a **laser**, one of the controllers below, each a
separate editable post in `src/cam/laserposts/`:

| id | controller |
|----|------------|
| `grbl-dynamic` | GRBL / FluidNC, `M4` dynamic power (default) |
| `grbl-constant` | GRBL / FluidNC, `M3` constant power |
| `marlin` | Marlin, `M3`, power 0–255 |
| `smoothie` | Smoothieware, inline `S` (0–1) per cut move |
| `linuxcnc-laser` | LinuxCNC, PWM-spindle (`M3`/`M5` + `S`) |
| `ruida` | Ruida DSP (`.rd`) binary format [EXPERIMENTAL] |

(Legacy laser files that stored `"grbl"` map to `grbl-dynamic`. Ruida DSP is an experimental binary format emitting scrambled `.rd` bytecode rather than text G-code.)

Coolant is **per operation** (`operations[].coolant`), not a top-level field.
Custom program start/end G-code and the "machine has coolant" capability are
machine-wide (localStorage) preferences, since they describe the operator's
shop, not the design — so they are not stored in the file either.

`endPosition` is an optional `{ "x", "y" }` (work coordinates, mm) the spindle
rapids to at safe Z just before `M30`; `{ "x": 0, "y": 0 }` parks at the WCS
origin. `null` (or omitted) leaves the tool wherever the last toolpath ended.

`toolChangePosition` is an optional `{ "x", "y" }` (work coordinates, mm) the
tool rapids to at safe Z before a *manual* tool change, so the operator can reach
the spindle. `null`/omitted leaves the tool over the work; ignored with an
automatic tool changer and on lasers.

`flip` is an optional double-sided machining setup. When present, each operation
carries a `face` (`"top"` | `"bottom"`, absent = `"top"`): the top ops are cut as
drawn, then the stock is flipped and the bottom ops are cut from a program whose
geometry is mirrored about the flip axis so features align through the part. Its
fields are `axis` (`"h"` = flip left↔right / mirror X, `"v"` = flip near↔far /
mirror Y), `registration` (`"pins"` bores dowel holes through the stock into the
spoilboard at the end of the top-side program, `"none"` leaves realignment to the
operator), `pinDiameter` and `pinDepth` (mm), and `pins` (an array of `{ "x", "y" }`
hole centres in world mm; must be invariant under the mirror). Mill-only.
`null`/omitted = single-sided.

`rotary` is the optional cylindrical setup, required by a `"-rotary"`
`machineKind` and ignored otherwise. The drawing canvas is the **unrolled
cylinder surface**: one axis runs along the cylinder length and the perpendicular
one spans the circumference, so `canvas` should be authored as length ×
π·`diameter` (360° of rotation = π·diameter of surface travel).

How the wrapped axis is posted depends on the head. A **mill** wraps it: the
coordinate is emitted as rotary-axis degrees and arcs are flattened to G1 chords;
Z is depth below the **top of the cylinder** (touch off the stock top) and
`stockThickness` is the radial wall / max cut depth. A **laser** substitutes it:
nothing is transformed, because the canvas is already in the surface millimetres
the substituted axis wants, and no Z is emitted at all.

Fields in the FILE describe the job, because the cylinder *is* the stock:
`diameter` (mm), `wrapAxis` (`"y"` = Y wraps to rotation and X runs along the
length — the default pairing; `"x"` = swapped), and optional `zero`
(`"surface"` (default) or `"center"` — see Zeroing below). `zero` is
**mill-only**: a beam emits no Z to zero.

The rotary **axis word** (`"A"` about machine X, pairing with `wrapAxis` `"y"`;
`"B"` about Y, pairing with `"x"`) and the **arc tolerance** (chord tolerance in
mm, default 0.1) describe the machine, not the design, so as of v3 they are not
stored in the file — they come from the local machine profile. Both are
mill-only anyway: a beam emits no rotary word and never flattens an arc because
it never wraps one. Not combinable with `flip`.
`null`/omitted = flat work. See
`examples/rotary-spiral-dowel.rcam` — a straight line across the wrapped axis
becomes a ring, a diagonal becomes a helix.

**Zeroing (important for setup and preview).** The `zero` field picks where `Z0`
sits:

- **`"surface"` (default).** `Z0` is the **top of the cylinder** — touch the tool
  off on the stock's top surface, and cuts run negative from there (the tool cuts
  at top-dead-centre as the part rotates under it). This is the easy zero to set
  physically, but note it is *not* the centre-of-rotation zero that some rotary
  previewers assume. To let those previewers place the toolpath on the cylinder,
  the wrap's G-code header carries a machine-readable diameter comment,
  `; Cylinder Dia: <mm>`, which they read and offset by the radius. In **gSender**
  specifically, turn on **Config ▸ Rotary ▸ "Visualize non-center zeros"** so it
  applies that offset; otherwise its visualizer draws the A-axis moves flat.
- **`"center"`.** `Z0` is the **rotary axis** (the cylinder centreline) — the
  native rotary convention. Every emitted `Z` is shifted up by the radius, so the
  surface sits at `Z = radius` and a cut to depth `d` lands at `Z = radius − d`.
  Set this zero by touching off on the stock top and entering the radius as the
  Z work offset. gSender and most controllers visualize this on the cylinder with
  **no** toggle, so the `; Cylinder Dia:` hint is omitted for this mode.

`metadata` is optional informational job data — `job`, `revision`, and `notes`,
all optional strings. It affects no geometry or toolpaths; non-empty fields are
written as comments in the G-code header (`; Job: …`, `; Revision: …`,
`; Notes: …`). Blank fields are dropped on save, and an all-blank object is
omitted entirely.

## IDs

- Every `id` is a string, unique within the file. Any unique non-empty string
  works — the short `ent1`/`con3` form used in the examples below is perfectly
  legal to author by hand.
- Ids RapidCAM itself mints look like `ent-mfk3x9zq-a7b2c1`: a prefix, a base36
  timestamp, and a random suffix. They are globally unique, not merely unique
  within the file, so that two people editing copies of one design never mint
  the same id for different things — a counter would hand both `ent8`, and
  merging the two files would fuse two different shapes into one. If you
  generate files programmatically and they may later be merged, mint ids the
  same way rather than counting.
- `"__origin__"` is **reserved** for the work-coordinate-system origin point.
  RapidCAM injects it automatically on load — you don't need to author it, and you
  shouldn't reuse the id.
- `layerId` on an entity should reference a real layer id; it defaults to
  `"layer-0"`.

## Entities

Each entity is an object tagged by `type`. Common optional fields:

- `isConstruction` (default false) — construction/reference geometry, excluded from CAM.
- `layerId` (default `"layer-0"`).
- `name` — a custom label shown in the design tree. Omit it and the tree derives
  a description from the geometry (`Circle ⌀35.00 mm`); it has no effect on output.
- `visible` (default true) — `false` hides the entity on the canvas; it stops
  being pickable and snappable, and it **is excluded from CAM output**, exactly
  as geometry on a hidden layer is. If it isn't on screen, it isn't in the
  program. Pre-flight warns (`hidden-geometry`) when a toolpath still references
  something hidden, so the exclusion is never silent.
- `locked` (default false) — `true` stops the entity being dragged, scaled,
  rotated, nudged or deleted. It stays selectable, dimensionable and snappable
  (the SolidWorks sense of "lock"), and it is still cut. Locking a whole *layer*
  is the blunter tool: that takes its geometry out of reach entirely.
- `fixtureHeight` — workholding only: how far *this* clamp stands above the stock
  top, in mm. It applies to a closed shape on a layer with `"fixture": true` and
  is ignored anywhere else. Omit it to inherit the layer's `fixtureHeight`, which
  is how every file written before this behaves; set it when clamps of different
  heights share one fixture layer. Resolution is entity → layer → full-height
  (an unknown height blocks any pass, because you cannot clear what you can't
  measure). Where a footprint is several entities that disagree, the tallest wins.

The **point keys** below are the addresses constraints and dimensions use to refer
to a specific point on an entity (via a `{ "entityId", "key" }` pair). Getting
these right is the single most important thing when authoring constraints.

Two further vocabularies sit alongside them, and both are **dimension-only** —
the constraint solver never sees either, because neither names a degree of
freedom the solver may move:

**Anchor keys** address a point that is *derived* from an entity rather than
being one of its named points. A dimension uses them to witness a shape where
the user clicked it, rather than only at the handful of points that have names.

| Key form | On | Resolves to |
|----------|----|-------------|
| `<edgeKey>@<t>` | any straight edge — a line's `mid`, a rectangle's / image's / the stock's `mid_b` `mid_r` `mid_t` `mid_l`, a polyline's `mid_<vertexId>` | the point a fraction `t` (0…1) along that edge, from its first named corner toward its second |
| `curve@<t>` | `bezier` | the point at curve parameter `t` (0…1) |
| `edge@<theta>` | `circle`, `arc` | the point on the rim at angle `theta` **in radians** |

`t = 0.5` IS the midpoint, so a bare `mid_b` and `mid_b@0.5` name the same
point; writers emit the plain spelling for it and files written before anchor
keys existed keep resolving unchanged.

**Segment refs** address one *edge* rather than one point, for the dimension
types whose operand is a whole line (`angle`, `line-distance`,
`point-line-distance`). They go in `entities`, not `points`, and are spelled
`<entityId>#<edgeKey>` — for example `rect1#mid_l` for a rectangle's left side,
`poly1#mid_v3` for the polyline segment starting at vertex `v3`, or
`__stock__#mid_b` for the blank's bottom edge. A bare id with no `#` means the
whole entity, which is what a plain `line` always is.

| `type` | Geometry fields | Point keys (for constraints/dimensions) | Scalar DOFs |
|--------|-----------------|------------------------------------------|-------------|
| `line` | `a`, `b` (Vec2) | `a`, `b` endpoints; `mid` (derived, pickable) | — |
| `circle` | `center` (Vec2), `radius` | `c` center | `r` radius |
| `ellipse` | `center` (Vec2), `rx`, `ry` (**semi**-axes, i.e. radii), `angle` (rad CCW from +X, optional) | `c` center | `rx`, `ry`, `angle` |
| `rectangle` | `p0`, `p1` (opposite corners), `cornerRadii` (number[4], optional), `cornerType` / `cornerTypes` (optional) | corners `bl` `br` `tr` `tl`; edge mids `mid_b` `mid_r` `mid_t` `mid_l`; `center` | `cr` corner radius |
| `polyline` | `points` (Vec2[]), `vertexIds` (string[], optional), `closed` (bool), `cornerRadii` (object keyed by vertex id, optional), `cornerType` / `cornerTypes` (optional), `polygon` (optional, see below) | vertices `v<id>`; segment mids `mid_<id>` (id of the segment's start vertex) | `cr` corner size |
| `arc` | `center`, `radius`, `startAngle`, `endAngle` (rad, CCW) | `c` center; `start`, `end` (derived) | `r`, `sa`, `ea` |
| `bezier` | `p0` `p1` `p2` `p3` (start, start handle, end handle, end) | `p0` `p3` endpoints; `p1` `p2` handles — all four are solver DOFs and all four are constrainable | — |
| `polybezier` | `nodes[]` (each `{id, pos, inH, outH, smooth, symmetric}`), `closed` (bool, optional), `fit` (bool, optional — true means the handles are COMPUTED so the curve passes through its knots; they are still written, so a reader ignoring the flag gets the same shape) | knots `n<id>` (solver DOFs, constrainable); handles `n<id>.in` / `n<id>.out` (draggable only — neither solver DOFs nor constraint targets, because a smooth node's handles move each other) | — |
| `point` | `pos` (Vec2) | `p` | — |
| `text` | `text` (newlines are line breaks), `fontId`, `sizeMM`, `position`, `angle` (rad), `align` (`left`/`center`/`right`, optional), `lineSpacing` (multiple of `sizeMM`, optional) | `pos` baseline-left anchor; ink-box `bl` `br` `tr` `tl`, edge mids `mid_b` `mid_r` `mid_t` `mid_l`, `center` (all derived) | — |
| `image` | `imageId`, `position` (bottom-left), `widthMM`, `heightMM`, `angle` (rad) | `pos` bottom-left anchor | — |

Notes:
- A **Vec2** is `{ "x": number, "y": number }` in mm.
- `rectangle` is axis-aligned; `p0`/`p1` are normalised to min/max corners on load.
- A **rectangle's corners can be shaped** without it ceasing to be a rectangle.
  `cornerRadii` is one radius per corner in mm, ordered `bl`, `br`, `tr`, `tl` —
  the same order as the corner point keys — and a **treatment per corner** says
  how each non-zero one is cut:
  - `"round"` (default) — a convex fillet, tangent to both edges.
  - `"inverted"` — a concave cove: a quarter circle centred *on* the corner and
    bitten out of it, so it meets both edges square. This is a shape, not a
    dogbone; a dogbone is a machining relief added at toolpath time (`cornerStyle`
    on an operation) so a square-cornered part seats in a pocket cut by a round tool.
  - `"chamfer"` — a straight bevel. `cornerRadii` is its setback along each edge,
    which is why all three types share one field.

  The treatments are **per corner**: a chamfer on one corner beside a fillet on
  the next is a single rectangle, as it is in AutoCAD and Fusion. They are
  written two ways, and a reader must accept both:

  - `cornerType` — one of the three strings, meaning *every* corner. Written
    whenever the shaped corners agree, which is the ordinary case and the only
    thing any file written before mixing existed can contain.
  - `cornerTypes` — an array of four, in the same `bl`, `br`, `tr`, `tl` order
    as `cornerRadii`. Written only when the shaped corners differ, and takes
    precedence over `cornerType` when both appear.

  So a rectangle whose corners all match reads, and round-trips, exactly as it
  did before mixing was possible. A square corner still carries a treatment; it
  simply draws nothing. Both fields are optional: omit them for a square
  rectangle, which is how every file written before they existed reads.
  The **point keys are unchanged** — `bl` is still the theoretical corner even
  when it is rounded away — so constraints and dimensions on a shaped rectangle
  behave exactly as on a square one, and adding a radius never disturbs them.
  A radius bigger than the edge it shares with its neighbour is **scaled to fit
  when the outline is built, not on load**: the stored value is what was asked
  for, so a rectangle temporarily too small for its corners reopens with them.

  The corner radius is also a **scalar DOF, `cr`**, so it can be driven by a
  formula through an ordinary `bindings[]` entry — `{"scalarKey": "cr", "expr":
  "stock * 2"}` — the same channel a circle's radius uses. One binding drives all
  four corners (a formula is whole-shape by nature); `cornerRadii` still holds
  the last resolved values, so a file never needs an expression evaluated to
  load. Unlike a circle's `r`, `cr` is **not** a solver freedom: no constraint
  type reads a corner radius, so it is held fixed unless a binding drives it, and
  a rectangle's DOF count is the same either way.
- A **regular polygon keeps a note saying so.** A polyline drawn with the Polygon
  tool carries `polygon: { sides, radius, rotation }` — `sides` is the side count
  (3–64), `radius` the CIRCUMradius (centre to a vertex, not across the flats),
  and `rotation` the angle in radians of vertex 0. It lets an editor offer "8
  sides, 40mm across flats" instead of sixteen loose numbers. It is dropped the
  moment a vertex is hand-edited, because the shape is then no longer regular and
  the note would be a lie. Omit it and you get an ordinary polyline, which is
  always safe.
- A **polyline vertex carries a stable id.** `vertexIds[i]` is the id of `points[i]`;
  point keys are `v<id>` and `mid_<id>` (the midpoint of the segment that *starts*
  at vertex `<id>`). The id is decoupled from the array position so a constraint or
  dimension keeps pointing at the same physical vertex when an edit (chamfer,
  fillet, polygon resize) inserts or removes vertices ahead of it. `vertexIds` is
  optional: when omitted, each id defaults to its index as a string (`"0"`, `"1"`,
  …), so older files — whose keys are `v0`, `mid_0`, … — load unchanged. New
  vertices get fresh ids that are never reused within the polyline.
- A **polyline segment** can stand in for a line anywhere a line-type constraint
  expects an entity: use the entity reference string `"<polylineId>#<startVertexId>"`
  (the segment that starts at that vertex and runs to the next). Legacy files
  encoded the start vertex's *index* here; that resolves identically because a
  loaded vertex's default id is its index.
- A **polyline's corners can be shaped** without it ceasing to be a polyline, and
  without the vertices moving. `cornerRadii` is an object keyed by **vertex id**
  — `{"3": 5}` puts a 5mm corner on the vertex whose id is `"3"` — and
  a **treatment per vertex** (`"round"` | `"inverted"` | `"chamfer"`) says how
  each shaped vertex is cut. As for a rectangle, that treatment is written as
  the scalar `cornerType` while every shaped vertex agrees, and as `cornerTypes`
  — an object keyed by vertex id, exactly as `cornerRadii` is — only when they
  differ; `cornerTypes` wins where both name a vertex, and a shaped vertex named
  by neither falls back to `"round"`. All are optional; omit them, or omit a
  vertex, for sharp corners, which is how every file written before they existed
  reads.

  It is keyed by id rather than being a fourth array parallel to `points` and
  `vertexIds` for the reason `vertexIds` exists at all: a polyline's vertex set
  changes under edits, and an id cannot end up describing a different vertex
  after a splice, an insert or a reversal. The **point keys are unchanged** —
  `v3` is still the theoretical vertex when it is rounded away — so constraints
  and dimensions behave exactly as on a sharp polyline. An open polyline's two
  **end vertices cannot be shaped**: there is no far leg to be tangent to.

  **What the number means follows that vertex's own treatment**, and this is the
  one place a polyline's corners are not simply a rectangle's:
  - `"round"` — the fillet **radius**. The arc is tangent to both legs and meets
    them `radius / tan(θ/2)` back from the vertex, where θ is the angle between
    the legs.
  - `"chamfer"` — the bevel's **setback** along each leg (AutoCAD's CHAMFER
    distance), so it is its own answer.
  - `"inverted"` — the cove's radius. Because the cove is centred *on* the
    vertex, its setback is also exactly that radius.

  At 90° all three coincide, which is why a `rectangle` can call them one number.
  At any other angle they do not, so each type keeps the parameter its own tool
  and the rest of CAD names it by.

  Corners sharing an edge are **scaled to fit when the outline is built, not on
  load**, exactly as a rectangle's are: the stored value is what was asked for,
  so pulling a vertex in and back out restores the corner. The corner size is
  also the scalar DOF **`cr`**, drivable by a formula through `bindings[]`; one
  binding drives every shaped vertex, and like a rectangle's it is not a solver
  freedom.
- `fontId` is either a bundled font (e.g. `"roboto-regular"`) or a `"font-XXXXXXXX"`
  id present in the top-level [`fonts`](#fonts) array. Text stays editable until CAM
  export, where it is expanded to glyph contours.
- A **text** entity exposes its ink-box corners `bl`/`br`/`tr`/`tl`, edge midpoints
  `mid_b`/`mid_r`/`mid_t`/`mid_l`, and `center` as derived point keys — dimension or
  constrain them like a rectangle's. They are derived from the anchor plus the live
  glyph extents (rotated by `angle`), so `position` (`pos`) is the only real DOF: a
  constraint on a box point **translates** the whole text so that point lands on the
  target (the string/size/rotation are unchanged), and it re-solves as the text is
  edited. This is what the **Center** command (Align toolbar / right-click) uses via
  the [`center`](#constraints) constraint.
- An **`image`** entity is a placed raster picture for greyscale engraving — by a
  **laser** (modulating beam power) or a **mill** (carving a depth relief).
  Its `imageId` (`"img-XXXXXXXX"`) must appear in the top-level `images` array,
  which stores a downscaled **greyscale** buffer (one byte per pixel, row-major,
  row 0 = top, 0 = black) — colour carries no machining information for a laser.
  An embedded image that carries **`zRangeMM`** is a **height map** produced by
  importing an STL, not a picture: byte 255 is the model's top surface (no cut),
  byte 0 its base (full depth), and `zRangeMM` is the model's full height range
  in mm — the carve depth at true scale. The flag travels with the *pixels*
  rather than the entity because it is a fact about what the bytes mean, so a
  relief roughing pass and its finishing pass cannot disagree about it. Its
  presence suppresses every tone control in the relief path (`reliefGamma`, the
  0.96 white threshold, linear-light tone, and halftoning), each of which is a
  geometry error on bytes that are already lengths. Dropping the field on a
  rewrite silently re-reads the model as a photograph.
  `angle` (CCW radians) rotates the image about its anchor. The engrave/relief
  sweeps in the image's own (rotated) frame, so a non-zero `angle` is honoured in
  both the toolpath and the preview — the scan rows tilt with the image.
  `flipX` / `flipY` (default false) mirror the image content left↔right / top↔bottom
  about its centrelines; the mirror is baked into the sampled dots, so it is
  honoured identically in the laser engrave, the mill relief, and both previews.
  To **drive** `widthMM` / `heightMM` / `angle` from a variable formula, add a
  scalar binding (see [Bindings](#bindings)) on the image with
  `scalarKey` `"w"` / `"h"` / `"angle"` — exactly the mechanism used for a circle
  radius. An `"angle"` binding is entered in **degrees** and carries
  `scale: 0.0174533` (π/180) to reach the radian DOF, like an arc's `sa`/`ea`.
  (Pre-unification files used inline `widthExpr` / `heightExpr` / `angleExpr`
  fields; these are still read and auto-migrated to bindings on load, but are no
  longer written.) `aspectLocked` (default true) is an **edit-time** convenience
  only: with it on, editing one of width/height writes a proportional value or
  formula to the other — it stores nothing extra in the file and adds no solver
  constraint.
  The image also exposes constrainable **point keys** for use in constraints: the
  four corners `c0` (bottom-left anchor), `c1`, `c2`, `c3` (CCW) and `center`.
  A constraint on one of these (e.g. `coincident` of `c0` with a circle's `c`)
  reflows the image through the solver. How far it may reflow is **two independent
  permissions**, both default false (and omitted from the file), which together
  make an unlocked image rigid — a constraint just moves it, and positioning
  never distorts it:
  * `constraintResize` — constraints and dimensions may change its **size**. With
    `aspectLocked` (the default) the two size scalars are ONE degree of freedom,
    so the ratio is exact rather than merely converged; with the lock off, width
    and height move independently. This is how you *calibrate* a scan: put a
    driving dimension across a feature of known size (image edges and corners are
    dimensionable) and the whole image scales to suit.
  * `constraintRotate` — constraints may **turn** it, e.g. levelling a tilted scan
    by making one of its edges `horizontal`.

  They are separate, and `aspectLocked` governs the solver as well as the panel,
  because every extra freedom is another way for the solver to satisfy a
  constraint *wrongly*: a free angle meets a size dimension by tilting (a 10mm gap
  is also a 32mm edge seen at 72°), and a free size meets a levelling constraint
  by shrinking the image away (`w·sin(angle) = 0` has a root at `w = 0` as much as
  at `angle = 0`). Grant only what the intent needs and neither escape route is
  open.

## Constraints

A constraint contributes equation(s) the solver drives to zero, encoding design
intent so the sketch reflows when dimensions/variables change. Constraints are
**optional** — geometry is fully valid (and machinable) with none. Each
constraint references geometry through `points` (array of point refs) and/or
`entities` (array of entity-id strings), depending on its `type`:

| `type` | Operands | Meaning |
|--------|----------|---------|
| `coincident` | `points[2]` | the two points are equal |
| `horizontal` | `entities[1]` line **or** `points[2]` | endpoints/points share Y |
| `vertical` | `entities[1]` line **or** `points[2]` | endpoints/points share X |
| `parallel` | `entities[2]` lines | directions parallel |
| `perpendicular` | `entities[2]` lines | directions perpendicular |
| `equal` | `entities[2]` | equal length (lines) or equal radius (circles/arcs) |
| `concentric` | `entities[2]` circles/arcs | centres coincide |
| `pointOnLine` | `points[1]` + `entities[1]` line | point lies on the (infinite) line |
| `pointOnCircle` | `points[1]` + `entities[1]` circle | point lies on the circle |
| `pointOnArc` | `points[1]` + `entities[1]` arc | point lies on the arc's circle |
| `tangent` | `entities[2]` | line↔circle/arc, or circle/arc↔circle/arc tangency |
| `symmetric` | `points[2]` + `entities[1]` line | two points mirror across the line |
| `collinear` | `entities[2]` lines | both lie on the same infinite line |
| `midpoint` | `points[1]` + `entities[1]` line, **or** `points[3]` | point at line midpoint, or `points[0]` = midpoint of `points[1]`–`points[2]` |
| `angle` | `entities[2]` lines + `params[0]` | fixed angle between lines, `params[0]` = target **radians** |
| `fixedPoint` | `points[1]` + `params` | pin point to world position, `params` = `[x, y]` |
| `center` | `points[0]` mover + `points[1]` (or `points[1..2]` → their midpoint) reference + optional `params[0]` axis | **one-way**: the mover's centre follows the reference centre — X if `params[0]`=`0`, Y if `1`, both if omitted. The reference is never moved; the mover re-centres live as it (or the reference) changes |
| `fixed` | `entities[1+]` | lock all the entity's DOFs (no equation) |

A constraint object is:

```json
{ "id": "con1", "type": "fixedPoint",
  "points": [{ "entityId": "ent1", "key": "bl" }],
  "entities": [], "params": [15, 12] }
```

`points`, `entities`, and `params` are each **optional and default to `[]`** — a
type that uses only one of them may omit the others entirely (e.g. a `horizontal`
constraint can be just `{ "id": "...", "type": "horizontal", "entities": ["line1"] }`).
RapidCAM always writes the empty arrays out when saving, but you don't need to
author them. The same applies to a dimension's `points`/`entities`.

`center` is **directional** — unlike every other constraint it is not symmetric:
the solver snapshots the reference centre each solve and moves only the mover
toward it, so centring text in a box never nudges the box. It's normally produced
by the **Center** command rather than hand-authored; to author one, put the
mover's centre first (`points[0]`) and the reference last. A reference with no
single centre point (e.g. a rectangle drawn as four lines) uses two diagonal
corners in `points[1]`/`points[2]`, whose midpoint is the centre.

> **Authoring caution.** A syntactically valid constraint set can still be
> over-constrained, under-constrained, or fail to converge — and that can only be
> determined by running the solver, not by reading the JSON. If you are generating
> constraints programmatically and can't run RapidCAM to check, prefer:
> (a) emitting geometry already in its solved positions, and (b) pinning with
> `fixedPoint` + driving `dimensions` rather than dense webs of relational
> constraints. The bundled examples show idiomatic, convergent constraint sets.

## Dimensions

A dimension measures geometry and, when `"driving": true`, forces that measurement
to equal `value` (acting as a constraint). `value` is mm, **radians** for
`type: "angle"`, and **degrees** for `angle-x` and `arc-sweep` — the two types
that back a property field, where the number you type is the number stored.
`offset` is purely visual placement.

| `type` | Operands | Measures |
|--------|----------|----------|
| `distance` | `points[2]` | straight-line distance |
| `horizontal` | `points[2]` | |Δx| |
| `vertical` | `points[2]` | |Δy| |
| `radius` | `entities[1]` circle/arc | radius |
| `diameter` | `entities[1]` circle/arc | diameter |
| `arclength` | `entities[1]` arc | arc length |
| `angle` | `entities[2]` lines (or segment refs) | angle between (radians) |
| `line-distance` | `entities[2]` lines (or segment refs) | perpendicular gap between two parallel lines |
| `point-line-distance` | `points[1]` + `entities[1]` line (or a segment ref) | perpendicular distance from that point to that line. Drawn as one straight run from the point to its foot, so `offset` is unused |
| `circle-gap` | `entities[2]` circles/arcs | edge-to-edge gap: radii difference when one lies inside the other (a ring's wall, even off-centre), otherwise the clearance between the edges |
| `angle-x` | `entities[1]` line | direction from the +X axis, in DEGREES (signed, -180..180). One of the two angular types stored in degrees (`arc-sweep` is the other), because it backs the Angle property field and a dimension's `expr` is evaluated straight into `value` with no unit applied — storing radians would make `45` and a variable worth `45` mean different things in the same box. Backs the Angle property field as a hidden dimension, and is also placed visibly: pick a line, click open space, then Tab. |
| `arc-sweep` | `entities[1]` arc | included angle (sweep) in DEGREES, normalised to [0, 360). Unlike `angle-x` its residual is NOT wrapped: a 350° arc and a 10° arc are different arcs, so the shortest path is the wrong answer. Written only as a hidden driving dimension. |

Optional: `anchors` (`[t1, t2]`, where a `line-distance` sits along its two
edges) and `expr`
(a formula string driving `value`, e.g. `"width * 2"`, evaluated against
`variables`).

```json
{ "id": "dim1", "type": "diameter", "points": [], "entities": ["ent2"],
  "value": 6, "driving": true, "offset": 2.356 }
```

## Variables

Named numbers referenced by dimension/pattern/binding expressions. `expr` is the
raw input string; `value` is its cached evaluation in mm. `name` must match
`^[a-zA-Z_][a-zA-Z0-9_]*$` and is capped at 64 characters; `expr` is capped at
256.

Those caps exist for readers of the format, not for the app. A name is an
identifier so it can never be markup, but a document is not always written by a
person — and these are the fields a site publishing `.rcam` files is most likely
to put on a page. Neither limit is near anything typed by hand: `fingerDepth` is
eleven characters, `(width - 2 * wall) / (count + 1)` is thirty-two.

**Every expression in the format carries the same 256-character cap**, not just
a variable's. A dimension's `expr`, a binding's, the pattern count and spacing
expressions, and the per-param `paramExprs` maps on features and operations are
all the same free text read by the same evaluator, so they are bounded the same
way. `test/rcam-schema.test.ts` walks the schema and fails on any expression
field that has no cap, which is what keeps a newly added one from being the
exception.

The **param maps are bounded in both directions**: `features[].params`,
`features[].paramExprs` and `operations[].paramExprs` each take at most 128
entries, with keys of at most 64 characters. Capping the expression values alone
bounded nothing, because a document could still carry a million one-character
keys. These three are keyed by a fixed vocabulary — a generator's declared
params (twelve at the most, in `spoilboard`) and an operation's numeric fields
(thirty-five) — so 128 is far above anything real. The longest param name in the
repo is `counterboreDiameter`, at nineteen characters.

This deliberately does NOT apply to a polyline's `cornerRadii` and `cornerTypes`,
nor to a feature's `keyIds`. Those are keyed by vertex id and by generator-emitted
entity name, so they scale with the drawing rather than with a fixed vocabulary.
`livingHinge` emits one key per slit and `spoilboard` one per hole, counterbore
and t-slot, and the params driving those counts declare a `min` and no `max` — a
2400 mm table at 10 mm spacing is some 28,800 holes. There is no ceiling to
derive, and one copied from the param maps would refuse real work.

`keyIds` is still bounded per ENTRY: keys at 64 characters and the entity ids
they point at at 128, which is safe at any count. The absence of a count limit
is asserted by a test, so that tidying it into line with the param maps fails
rather than quietly refusing a spoilboard nobody has drawn yet.

`expr` may be a plain length (`"100"`, `"50mm"`, `"3.5in"`) **or a formula that
references other variables** (`"width * 0.1"`). Variables are evaluated in
dependency order, so declaration order doesn't matter; a reference cycle (or a
self-reference) leaves those variables at their last value rather than looping.
Bare numbers inside a formula are millimetres (like dimension formulas).

The global constant `stock` (or `$stock`) is always available to expressions and evaluates to the project's `stockThickness` in mm. This allows parametric designs (e.g. box joints) to automatically scale to the material thickness.

Beyond `stock`, every expression also has a set of built-in keywords, evaluated
live from the document (a user-defined variable of the same name overrides the
built-in). The set present depends on the machine kind:

| Group | Keywords | Meaning |
|---|---|---|
| Math constants | `pi`, `PI` · `e`, `E` | π ≈ 3.141592653589793 · Euler's number ≈ 2.718281828459045 |
| Flat stock | `stock`, `stock_thickness`, `stockThickness`, `stock_t` | material thickness (mm) |
| | `stock_width`, `stockWidth`, `stock_w` · `stock_height`, `stockHeight`, `stock_h` | blank width/height (mm) |
| Rotary stock (rotary only) | `stock_diameter`, `stockDiameter`, `stock_dia`, `stock_d` | cylinder diameter (mm) |
| | `stock_length`, `stockLength`, `stock_len` | cylinder length (mm) |
| | `stock_circumference`, `stockCircumference` | π·diameter (mm of surface per revolution) |
| | `stock_wall`, `stockWall` | radial wall / max cut depth (mm) |
| Sheet | `sheet_width`, `sheetWidth`, `sheet_w` · `sheet_height`, `sheetHeight`, `sheet_h` | work-area (canvas) size (mm); absent for rotary |
| Origin | `origin_x`, `originX`, `ox` · `origin_y`, `originY`, `oy` · `origin_z`, `originZ`, `oz` | WCS origin datum (mm) |
| Counter | `counter`, `count`, `serial`, `serial_number`, `serialNumber`, `seq` | the project's incrementing serial counter |

```json
{ "id": "var1", "name": "pcd", "expr": "60mm", "value": 60 }
{ "id": "var2", "name": "margin", "expr": "pcd * 0.1", "value": 6 }
```

## Bindings

Optional. A **headless parametric binding** drives one *scalar* DOF of an entity
(by its scalar key — `"r"` for a circle/arc radius, `"sa"`/`"ea"` for arc angles,
`"w"`/`"h"`/`"angle"` for image width/height/rotation) from a variable formula. It draws nothing on the canvas: it contributes a driving
residual (`currentScalar − expr`) to the same solver as dimensions/constraints, so
it reconciles through the one over/under-constrained mechanism (no separate
channel). `scale` converts the formula's display unit to the scalar's internal
unit — omit it (or `1`) for lengths; `π/180` for an angle scalar entered in
degrees. Measurement fields without a scalar DOF (line length, rect W/H) use a
`hidden` driving **dimension** instead (see below), not a binding.

```json
{ "id": "bind1", "entityId": "ent3", "scalarKey": "r", "expr": "pcd/2" }
{ "id": "bind2", "entityId": "arc1", "scalarKey": "sa", "expr": "tilt", "scale": 0.0174533 }
```

A dimension may carry `"hidden": true` — a driving dimension that isn't drawn,
used when a formula is typed into a *measurement* property field (line length,
rect W/H). It drives geometry like any dimension but shows no annotation.

## Patterns

Linear or circular replication. `sourceIds` are the master entities; `instanceIds`
holds one sub-array of entity ids per generated step. **The copy entities listed in
`instanceIds` must also appear in `entities`** — a pattern records the relationship;
it does not generate geometry on load.

> **Generating a file by hand or with an LLM?** Patterns are the easy thing to get
> wrong, because you must materialise every copy as a real entity *and* keep
> `instanceIds` consistent with the count. Two safe options: **(a)** the simplest —
> just emit all the copies as ordinary `entities` and omit the `pattern` block
> entirely (you lose the live link, but the geometry is correct and machinable);
> or **(b)** author the `pattern` *and* list one `instanceIds` sub-array per copy.
> If you do author a pattern, RapidCAM self-heals a count mismatch on open
> (it regenerates the instances to match the resolved count), but the file is
> cleanest when they already agree. `count*` is a cache; if you also set a
> `*Expr`, the expression wins on the next regenerate.

```jsonc
{ "id": "pat1", "kind": "circular",
  "sourceIds": ["ent2"],
  "instanceIds": [["ent3"], ["ent4"], ["ent5"], ["ent6"], ["ent7"]],
  "params": { "count": 6, "cx": 45, "cy": 40, "totalAngle": 6.283185 } }
```

- Linear params: `countX`, `countY`, `spacingX`, `spacingY` (mm), optional
  `countXExpr` / `countYExpr` and `spacingXExpr` / `spacingYExpr` (variable
  expressions), optional `mirrorCols` / `mirrorRows` (booleans).
- `spacingMode` says what `spacingX` / `spacingY` MEAN. Absent or `"step"` is
  centre-to-centre, the distance each copy moves — the historical meaning, so
  older files are unchanged. `"gap"` makes them the clear space *between* copies:
  the step becomes the motif's own width/height plus the gap, and a gap of `0`
  tiles seamlessly. The motif size is measured at build time rather than baked in
  when the value is typed, so a gapped pattern stays gapped after the motif is
  resized — a converted one would silently start overlapping.
- `mirrorCols` / `mirrorRows` reflect every OTHER column / row — index 1, 3, 5 —
  each about its own cell centre, so a copy stays inside the cell the grid gave
  it however much smaller than the pitch the motif is. The source's own cell
  (`0,0`) is never mirrored. Absent means `false`, so a file written before these
  existed loads as the plain repeat it was. Text is reflected AutoCAD
  MIRRTEXT=0-style: the footprint moves, the glyphs stay readable.
- Circular params: `count`, `cx`, `cy` (centre, mm), `totalAngle` (radians; `2π`
  = full circle), optional `countExpr`.
- Hex params (`"kind": "hex"`): `cols`, `rows`, `spacing` (mm), `orientation`
  (`"row"` or `"column"`), optional `colsExpr` / `rowsExpr` / `spacingExpr`. A
  60° staggered lattice — every placement has six neighbours at `spacing`.
  `spacing` is the centre-to-centre pitch between nearest neighbours (as a
  perforated sheet is specified); the pitch ACROSS the stagger is **derived** as
  `spacing·√3/2` and is deliberately not a stored field, because a separately
  typed value could describe a lattice that is not hexagonal. `orientation`
  chooses which axis carries the half-pitch offset.
- `count*` and `spacing*` are resolved caches; when an `*Expr` is present it is the
  source of truth — re-evaluated against `variables` (counts rounded, clamped to
  ≥1 linear / ≥2 circular) when the pattern is created or regenerated. So a
  variable can drive how *many* copies exist, not just their spacing. The
  `instanceIds` snapshot still must match the resolved count; regenerating in the
  app reconciles it.
- `boundaryId` (optional) names a closed entity — a rectangle or closed polyline
  — that the pattern is confined to. Copies whose motif does not fit **wholly**
  inside it are not generated at all: whole copies are dropped, never trimmed, so
  every instance stays a true duplicate of the source and keeps its stable id.
  The boundary is not part of the pattern and is never copied. It is hashed into
  `sourceSnapshot` alongside the sources, so moving or resizing the boundary
  re-culls on the next regenerate. A `boundaryId` that no longer resolves stops
  the pattern regenerating rather than quietly reverting to unbounded — the app
  reports it instead of generating the copies the boundary was holding back.
- **CAM follows patterns.** A toolpath whose `entityIds` reference any member of a
  pattern (its source or any instance) is expanded at toolpath/preview time to
  cover the whole pattern — so a profile/drill assigned to patterned geometry
  cuts every copy and tracks the count as it grows or shrinks. You only need to
  assign the op to the master; you don't have to list every copy in `entityIds`.
  Set the op's `followPattern` to `false` to opt out (cut only the literal
  `entityIds`).
- **Instances are owned by the pattern.** Editing a single copy — moving or
  re-layering it — is overwritten the next time the pattern regenerates. Edit the
  source (or the pattern params/variables) instead; the copies follow.

## Features (generator output)

`features[]` records geometry a built-in generator produced — a finger box, a
spur gear, a kumiko lattice — so it stays **re-editable** instead of becoming
anonymous lines the moment it is drawn. Change a parameter and the feature is
regenerated in place.

```jsonc
{
  "id": "feat-1",
  "generatorId": "box-joint",        // which generator made it
  "params": { "width": 120, "fingers": 5 },   // the re-run inputs, resolved values
  "paramExprs": { "width": "boxW" }, // optional: a formula per param
  "groupId": "grp-3",                // the group holding this feature's entities
  "offset": { "x": 40, "y": 20 },    // optional: where the output was placed
  "keyIds": { "front-wall": "ent-9" } // optional: generator key -> entity id
}
```

- **`groupId` is the link to the geometry.** A feature owns no entities directly;
  it names the group that holds them. That group must exist, and the usual rule
  applies — an entity belongs to at most one group.
- **`params` are the last resolved values**, and are the fallback. A param with a
  `paramExprs` entry is re-evaluated against `variables` and the built-in
  keywords (`stock`, `pi`, …) before every solve; one whose expression fails to
  evaluate keeps its cached number. So a file never needs an expression to
  evaluate in order to load.
- **`keyIds` survives reordering.** A generator names the entities it cares about
  (`"front-wall"`), and regeneration pairs them up by KEY rather than by emit
  position — so a constraint, dimension or toolpath pointing at generated
  geometry still points at the same part of it after a parameter edit. Entries
  whose entity has been deleted are pruned.
- Entity ids are otherwise **stable across a regeneration**, which is what lets
  CAM operations reference generated geometry at all.

Writing `features[]` by hand is not the usual path — emit the geometry and leave
the array out. It is documented because a file that carries one is asserting that
the geometry can be rebuilt from these numbers, and a wrong `groupId` or a
`generatorId` this build does not know makes that false.


## CAM operations

Each operation is a toolpath over some `entityIds`. Required fields cover the tool
and cut; several are type-specific and optional. `depth` is mm below the surface
and is **negative** for cuts. `side` (`"outside"`/`"inside"`) is required only on
`profile` ops and should be omitted elsewhere — a drill has no side. `stepdown`
(mm per depth pass, default 1.5) and `stepover` (fraction of tool diameter 0–1,
default 0.4) are optional; omit them where the op type ignores them.
Optional `coolant` (`"off"` | `"mist"` | `"flood"`, default `"off"`) emits `M7`/`M8`
around the operation and `M9` when it changes / at program end — but only if the
machine is flagged as having coolant (a machine-wide app preference); otherwise
it is suppressed.

Optional **`startDepth`** (negative mm, default 0 = the surface) says where the cut
**begins**. It is measured from the top of the stock exactly as `depth` is, so the
operation removes the slab between the two: `startDepth: -3` with `depth: -6` cuts
from 3 mm down to 6 mm down and leaves the first 3 mm untouched. (This is the
Carbide Create / Fusion convention. Vectric is the odd one out: there "Cut Depth"
is measured *from* the start depth, so its two fields sum. Ours do not.) It exists
for machining into stock an **earlier operation already cleared** — lettering
v-carved on a pocket floor being the case it was added for — where a cut calculated
from the surface passes through empty air and the machine cuts nothing. It must be
shallower than `depth`, or the operation has nothing to remove. Honoured by
`profile`, `pocket`, `engrave`, `score`, `face`, `drill`, `chamfer` and `vcarve`;
ignored by `inlay` (which uses the same idea internally for its glue gap) and
`relief-rough`, and by every laser operation. Nothing verifies that the material is
actually gone — the editor's pre-flight check measures what the preceding
operations left and warns both ways (cutting air, or plunging the whole start depth
through solid stock).

An operation may carry an optional **`toolId`** referencing an entry in the
top-level [`tools`](#tools) array (see below). When `toolId` resolves, that tool's
geometry/feeds (`toolType`, `diameter`, `vAngle`, `tipAngle`, `feedrate`,
`plungeRate`, `spindleSpeed`, `safeZ`) drive the operation, and the inline copies
of those fields act only as a fallback for an unresolved id. `toolNumber` and the
cut settings (`depth`, `stepdown`, `stepover`, tabs, leads) always stay per-operation
— the tool's own [`number`](#tools) *seeds* `toolNumber` in the editor when the tool
is loaded, but never overrides it here, so what a file posts is only ever the
operation's own value.
Operations with no `toolId` use their inline fields directly.

For a double-sided job (the top-level `flip` setting, see
[Top-level shape](#top-level-shape)), an operation may carry an optional
**`face`** (`"top"` | `"bottom"`,
default `"top"`): `"bottom"` ops are cut in the flipped setup, with their geometry
mirrored about the flip axis. It is ignored when `flip` is absent.

| `type` | Notes |
|--------|-------|
| `profile` | contours a closed shape; uses `side` (`"outside"`/`"inside"`), optional `tabs`, `leadIn`, `leadOut`. Optional `finishPass` leaves `finishAllowance` mm of stock during roughing and removes it in a final full-depth wall lap |
| `drill` | plunges at each entity (e.g. circle centres); `stepdown` ignored. Optional `peckDepth` (mm) drills in increments, fully retracting between pecks (G83-style) to clear chips |
| `engrave` | follows geometry at depth |
| `face` | skims a surface flat with no geometry to point at: `faceTarget` (`"stock"` the blank, or `"bed"` the spoilboard), `faceOverhang`, `faceDirection`. The tool centre runs to the target's edge, so the cutter overhangs it by a full radius — the difference from pocketing a rectangle. A `"bed"` pass is zeroed on the spoilboard with the machine empty and must be posted on its own |
| `pocket` | clears an area; `pocketStrategy` (`"offset"`/`"adaptive"`/`"raster"` — `"adaptive"` replaces any stretch that would bury the cutter deeper than a straight wall step with trochoidal circles, trading travel for load), `restToolDiameter` (rest machining: cut only what a larger, earlier tool could not reach — see `relief-rough` for the same field on a relief), and optionally uses `regions` OR explicit `entityIds` combined with `islandIds`. Optional `finishPass`/`finishAllowance` leave a wall skin during roughing and clean it in a final full-depth lap (round pockets clear with smooth G2 arcs + a helical entry) |
| `chamfer` | bevels an edge with a **v-bit**: traces the (optionally offset) contour at a depth derived from `chamferWidth` and the bit's `vAngle`. `chamferSide` (`"on"`/`"outside"`/`"inside"`) places the bevel relative to the edge; optional `sharpenCorners` pulls the tip up into sharp inside corners (tapering the bevel to the surface at the corner vertex) so they come to a point instead of a fillet. Used e.g. to chamfer a Shaker-pocket edge after clearing it with a pocket op |
| `vcarve` | **v-carves** a region at variable depth with a **v-bit**: the area (text glyphs, explicit `entityIds` chained into loops, or a flood-fill `region` with islands) is offset-peeled inward and each peel ring is cut at the depth where the bit's flanks touch both walls — so strokes taper to a sharp spine, and areas wider than the bit bottom out flat at \|`depth`\| (the max/floor depth, labelled **Flat depth** in the editor). A `depth` of **0** means *no* floor: every stroke peels all the way to its own spine, however deep that is. `vStep` (mm, default 0.4) is the radial pitch between peel passes — smaller = smoother floor, more passes. Optional `vHopClearance` (mm above the stock) hops between a region's contours at that low height instead of retracting to `safeZ` each time; leave it unset/0 (the safe default) if a clamp stands above the stock inside the carve footprint |
| `inlay` | **v-carves two boards from one design** with a **v-bit**: board A gets the pocket (offset-peeled to a flat floor at `pocketDepth`); board B gets the plug — the same design inside a boundary (`inlayMargin` mm out, or a boundary you drew), field cleared and **mirrored** so the plug flips and seats. `glueGap` shrinks the plug by `glueGap · tan(½·vAngle)` at every depth (the apex void is where the glue goes); `sawAllowance` deepens the male only, leaving stock to saw flush. Posts **two programs** (pocket + plug) |
| `score` | **laser only** (`machineKind: "laser"`): traces the geometry centreline with **no kerf offset** at low power — a fold/crease line rather than a cut (the UI's "Score / Fold", defaulting to 15% power). On a mill there is no score; use `engrave`. Mill-only op types (`drill`, `pocket`, `chamfer`, `vcarve`, `relief-rough`) are conversely skipped with a G-code note on a laser |
| `relief-rough` | **Roughs** a greyscale **image** relief in flat Z-levels with a coarse flat / bull-nose tool: resamples the image at the tool's `stepover` (× diameter), and clears the bulk down to `finishAllowance` mm above the final relief surface in `stepdown` planes, leaving that allowance for the ball-nose relief **finish** pass (an `engrave` op on the same image) to carve. Runs the raster boustrophedon, **ramping into each cut** and hopping over uncut areas at a low clearance above the stock. Set its `depth` / `stepdown` / `stepover` / `reliefGamma` / `rasterInvert` to match the finish op, and order it **before** the finish op (a tool change between them). `restToolDiameter` turns it into a **rest pass**: name the larger tool that already roughed, and it cuts only the cells that tool left more than one `stepdown` of stock on — the narrow valleys a big cutter cannot drop into, which are otherwise handed to the finish bit at full width. The editor warns if a roughing op's depth (less its allowance) would cut past the finish op's surface — a gouge |

`regions` (pocket/vcarve/inlay) is an alternative to explicit `entityIds`. While you can just list your boundary lines/arcs directly in `entityIds` (they will be chained into closed boundaries automatically) and list `islandIds` for any holes, the legacy/flood-fill way clears one or more **enclosed
faces** of the drawing. Each region is identified *parametrically* — not by a
coordinate — so it reflows when a driving dimension moves the geometry. A region
is `{ "containingLoops": [ ... ] }`, where each entry is the set of **entity ids**
whose live geometry forms a loop that encloses the face (a face lies inside
exactly its containing loops and outside all others). At toolpath time the loops
are rebuilt from current geometry, matched back by id-set, and the face — with any
enclosed loops as islands — is recomputed fresh. If a referenced loop no longer
exists, that region is skipped (with a G-code note) rather than cutting the wrong
area.

```jsonc
// Pocket the inside of a rectangle (ids r1..r4 form its boundary loop),
// with a circle "c1" sitting inside it automatically becoming an island:
{ "containingLoops": [ ["r1", "r2", "r3", "r4"] ] }
```

Authoring these by hand is awkward (you must know which entity ids chain into the
enclosing loop); in practice they're produced by region-picking in the toolpath
dialog. A single closed entity (circle, rectangle, closed polyline) is a one-id
loop, e.g. `{ "containingLoops": [ ["circle-7"] ] }`.

**Text** is the one entity that forms *several* disjoint loops — a contour per
letter, plus a counter for every enclosed island (`e`, `o`, `A`). A bare text id
would name all of them at once, so a loop of such an entity carries a `#` suffix
naming which contour it is:

```jsonc
// Pocket the counter of a letter in text entity "ent7", live and still editable:
{ "containingLoops": [ ["ent7#0.932:0.264:0.097"] ] }
```

The suffix is that contour's own geometry — `centroidX:centroidY:area`, measured
in the text's local frame and divided by the text height, so it is in em units.
It is deliberately **not** an index. Consequences worth knowing:

- Moving, rotating or resizing the text keeps every region, because the name is
  taken in the text's own frame and scaled by its height.
- Editing a **later** character keeps the regions before it.
- Editing an **earlier** character (or one whose replacement has a different
  shape) changes what follows it, and those regions no longer resolve: they are
  skipped with a G-code note. They are never re-pointed at a different letter.

That last point is why the suffix is geometric. An ordinal would still *match*
after a shift, so a pocket on the last `o` of `robo` would quietly move onto the
`b` when the text became `arobo` — cut, silently, in the wrong place.
(A glyph's outer contour and its counter are near-concentric, which is why the
area is part of the name and not just the centroid.)

When hand-authoring, **naming just the innermost boundary loop is enough**: a
non-listed loop that fully contains the referenced area (e.g. the part outline
around a recessed panel) is treated as an additional containing loop, not
subtracted. Only loops that genuinely cut into the region (islands, overlapping
shapes) affect it. The app's region picker records the complete containing set;
both spellings resolve to the same face.

```jsonc
{ "id": "op1", "name": "Profile outline", "type": "profile",
  "entityIds": ["ent1"], "side": "outside",
  "toolType": "end-mill", "toolNumber": 2, "diameter": 6,
  "feedrate": 900, "plungeRate": 250, "spindleSpeed": 18000, "safeZ": 5,
  "depth": -12, "stepdown": 2, "stepover": 0.4,
  // `width` is the SPAN the tool rides over, not the tab you get: it machines
  // half its own diameter off each end, so the tab left standing is
  // `width - diameter` — here 10 - 6 = 4mm. A width at or under the diameter
  // leaves NOTHING and the pre-flight linter rejects it (`tab-width-under-tool`).
  // `height` is measured up from the stock bottom, so a through-cut's tabs stay
  // in real material rather than sinking into the spoilboard.
  "tabs":   { "enabled": true, "count": 4, "width": 10, "height": 2 },
  // …or by spacing: { "enabled": true, "strategy": "spacing", "spacing": 50, "width": 10, "height": 2 }
  // A lead brings the cutter onto the wall away from where it plunged.
  // `"arc"` is a quarter-turn tangent to the contour, entered from the WASTE
  // side — outside the loop on an outside profile, inside it on an inside one —
  // and it OWNS the entry: the pass plunges at the lead's own start point
  // instead of ramping down the contour.
  // `"linear"` runs back along the entry tangent with no normal offset, so it
  // lies ON the contour — it is the last `length` mm of the lap, cut first. Its
  // only effect is to start the lap mid-side rather than at a corner; the
  // contour ramp (see `rampAngle`) still does the descent.
  // Omitted / `"none"` is the default.
  "leadIn": { "type": "arc", "length": 3 },
  "leadOut":{ "type": "arc", "length": 3 } }
```

Three optional cut-control fields apply across operation types:

- `cutDirection` (`"climb"` | `"conventional"`, profile only) — the cut direction
  relative to a standard M3 (clockwise) spindle: `"climb"` (CW around an outside
  profile / CCW inside) finishes cleanly on rigid machines, `"conventional"` is
  the reverse. Omitted = the raw offset winding is left untouched.
- `cornerStyle` (`"none"` | `"dogbone"`, inside profiles and pockets) — corner
  relief: `"dogbone"` adds a diagonal overcut at each convex inside corner so a
  mating square part seats instead of hitting the tool-radius fillet. Default
  `"none"`.
- `rampAngle` (degrees off horizontal, clamped 0.5–45) — entry angle for
  operations that descend into the cut gradually (the pocket helical/linear
  entry, the profile's descent around its own contour, the relief-rough ramp).
  Omitted = each mechanism's built-in default. A profile ramps unless something
  takes the entry away from it: an `"arc"` lead-in, a finishing lap (which drops
  into the rough kerf), or a tab the ramp would otherwise machine off — in those
  cases the pass plunges instead.

> **Feeds & speeds are not a recipe.** Any numbers you emit are starting points
> only and must be tuned for the actual material, tool, and machine. Always verify
> `depth`, the chosen `origin`, and tool changes before cutting.

### Parametric operation fields

Optional. `paramExprs` drives numeric operation fields from formulas instead of
fixed numbers, keyed by field name. **Every** numeric operation field can be
bound. A key that names no numeric field is ignored silently rather than
rejected, so a file carrying a typo still loads and simply keeps its plain
numeric value. Expressions are evaluated against
[variables](#variables) and `stock` before every solve, then clamped to the
field's valid range — so an operation can track the material rather than being
re-typed when it changes.

```jsonc
{ "id": "op1", "name": "Profile outline", "type": "profile",
  // …required fields as above…
  "depth": -12, "feedrate": 900,
  "paramExprs": { "depth": "-stock", "feedrate": "baseFeed * 1.2" } }
```

- The sibling numeric field (`depth`, `feedrate`, …) holds the **last resolved
  value**, and is the cache/fallback for fields with no expression or whose
  expression fails to evaluate. Always emit it — a file is valid and loadable
  without ever evaluating an expression.
- Bare numbers inside a `paramExprs` expression are **millimetres**, and
  `displayUnit` does not change that: in an inch-unit document both `"0.5"` and
  `"0.5 * 2"` resolve to 0.5 mm and 1 mm respectively.

  **This differs from a [variable](#variables).** A variable whose expression is
  a *lone length* is parsed in the document's display unit — `"0.5"` in an
  inch-unit document is 0.5 in — while a variable using arithmetic is evaluated
  in millimetres like everything else. Operation expressions have no such
  special case: they are always millimetres. Pinned by
  `test/fileExpressionUnits.test.ts`.
- Nested fields accept either the flat or dotted key: `"tabCount"` or
  `"tabs.count"`, `"leadInLen"` or `"leadIn.length"`, `"perforateGapWidth"` or
  `"perforate.gapWidth"`. An expression for a nested field is ignored while its
  parent object (`tabs`, `leadIn`, `leadOut`, `perforate`) is absent, and
  applies once it exists.
- Renaming a variable in the app rewrites references inside `paramExprs`, as it
  does for dimension and feature expressions.

### Laser operations

When the document's `machineKind` is `"laser"`, the same operations are posted
as fixed-Z beam moves (no spindle, no Z plunge) and only two `type`s apply:
`profile` (cut) and `engrave`. The Z/spindle fields (`spindleSpeed`, `safeZ`,
`depth`, `stepdown`, `plungeRate`) are ignored, and these fields drive the cut
instead:

| Field | Applies to | Meaning |
|-------|-----------|---------|
| `laserPower` | both | beam power as a percentage (0–100) of the controller's max (GRBL `$30`), scaled to an `S` word. Default 80 |
| `laserPasses` | both | times the beam re-traces each path — the fixed-Z analogue of stepdown. Default 1 |
| `kerfWidth` | `profile` | beam kerf (mm); the closed contour is offset outward (`side: "outside"`) or inward (`"inside"`) by half this. 0 = cut on the line |
| `perforate` | `profile` | leave short beam-off gaps along the cut instead of severing it continuously — a dashed/perforated line for a tear-away sheet, or small uncut bridges holding a freed piece in place. See below |
| `laserFill` | `engrave` | flood closed shapes with parallel scan lines (area/solid engraving) on top of the outline; counters (the hole in "O") stay clear. Default false |
| `laserFillSpacing` | `engrave` | scan-line spacing (mm) when `laserFill` is on — roughly the beam width. Default 0.2 |
| `laserOverscan` | `engrave` | fill **or** raster: distance (mm) the head runs past each scan line's/row's ends with the beam off, so it's at full speed when the beam fires (avoids over-burned edges). 0 = off. Default 0 |
| `airAssist` | both | turn on air assist (the post's air command, `M8`/`M9` by default), held across consecutive ops that request it. Default false |
| `rasterLineInterval` | `engrave` (image) | **raster engrave** (an Engrave op whose target is an `image` entity): vertical pitch (mm) between scan rows. Default 0.1 |
| `rasterDotPitch` | `engrave` (image) | raster: horizontal pitch (mm) between dots in a row. Omitted = square dots (= `rasterLineInterval`) |
| `rasterMinPower` | `engrave` (image) | raster: beam power (%) for the lightest engraved dot; `laserPower` is the power for a fully black dot. Default 0 |
| `rasterDither` | `engrave` (image), laser | raster: reproduce tone as a **1-bit dot pattern** instead of modulating power per dot. Every fired dot burns at `laserPower` and tone comes from dot DENSITY, so `rasterMinPower` is unused. `floyd-steinberg` (classic), `jarvis` (smoother, wider kernel), `atkinson` (higher contrast) are error-diffusion; `ordered` is an 8x8 Bayer pattern. Omitted or `none` = greyscale power, the default |
| `rasterInvert` | `engrave` (image) | raster: engrave the light areas instead of the dark (photo negative). Default false |
| `laserOverride` | both | cut with this op's own beam settings, ignoring any `laser` recipe on its layer (see below). Default false |

#### Perforated cuts

`perforate` is the laser analogue of a mill's `tabs` — the same `count`/
`spacing` placement, but a beam has no Z to lift over a bridge, so the region
is simply never cut instead of being ridden over at reduced depth:

```jsonc
{ "id": "op1", "name": "Tear-away cut", "type": "profile",
  "entityIds": ["ent1"], "side": "outside",
  "toolType": "end-mill", "toolNumber": 1, "diameter": 0,
  "feedrate": 300, "plungeRate": 300, "spindleSpeed": 0, "safeZ": 5,
  "depth": -3, "stepdown": 1.5, "stepover": 0.4,
  "laserPower": 100, "kerfWidth": 0.15,
  // 12 gaps of 1mm each, evenly spaced — a coupon-style tear line. Unlike a
  // mill tab's `width`, `gapWidth` needs no cutter-radius correction: a beam
  // has no radius to sweep into the gap, so the gap (and the bridge) you ask
  // for is the one you get.
  "perforate": { "enabled": true, "count": 12, "gapWidth": 1 }
  // …or a few wide gaps to hold a freed piece instead of a tear line:
  // { "enabled": true, "count": 3, "gapWidth": 4 }
  // …or by spacing: { "enabled": true, "strategy": "spacing", "spacing": 15, "gapWidth": 1 }
}
```

A gap wide enough (or numerous enough) to consume the whole contour leaves
nothing to cut; the generator emits a `; NOTE:` comment rather than a silently
vanished profile, the same way an inside kerf wider than the feature does.

#### Per-layer beam recipes

A **layer** may carry a `laser` recipe — `feedrate`, `laserPower`, `laserPasses`
and optionally `kerfWidth` / `airAssist`. Every operation whose `entityIds` all
sit on that layer takes those numbers at toolpath time, so the colour-driven
workflow (cut on black, score on red) is set up once and re-tuned in one place
after a test cut.

The rules, which mirror `toolId` on the mill side:

- The layer's numbers **replace** the operation's own; `kerfWidth` and
  `airAssist` fall back to the operation's when the recipe omits them.
- An operation whose geometry **spans several layers** keeps its own settings —
  there is no single correct answer for it.
- `"laserOverride": true` opts an operation out entirely. Set it where the
  numbers *are* the point, such as the cells of a material-test grid.
- A layer with no `laser` key changes nothing, which is how every file written
  before this existed behaves.
- Building a job skips **hidden** layers (`"visible": false`) and **workholding**
  layers — hiding something is how you take it out of the job.

```jsonc
// The "Cut" layer: everything on it burns at 100% and 300mm/min, three passes.
{ "id": "layer-cut", "name": "Cut", "color": "#000000",
  "visible": true, "locked": false,
  "laser": { "feedrate": 300, "laserPower": 100, "laserPasses": 3 } }
```

#### The layer as a job

A recipe may also say what its geometry is **for**, with `kind`: `"cut"`,
`"score"`, `"engrave"` or `"fill"` (a filled/solid engrave). Layers carrying a
kind can be turned into a whole program in one action — **Toolpaths from Layers**
in the CAM panel — producing one operation per layer, in layer order, named after
the layer.

**This makes `operations` optional for a laser design.** Emitting layers with
kinds and an empty `operations` array is a valid and compact way to describe a
job: the file says what each colour is for and how hard to cut it, and the user
presses one button to get the toolpaths. That is usually a better thing to
generate than hand-built operations, because you cannot get the entity lists,
the kerf direction or the cut order wrong.

`kind` behaves differently from the numbers beside it, and the asymmetry is
deliberate:

| | applied | why |
|---|---|---|
| `feedrate`, `laserPower`, `laserPasses` | **live**, at toolpath time | parameters — they change how hard the same move is cut, so re-tuning a layer re-tunes every operation on it |
| `kind` | when operations are **built** | structure — a cut is a kerf-compensated closed contour, an engrave a centreline, a fill floods the interior. Retyping an existing operation at export would emit different geometry than the previewed toolpath |

So changing a kind does not retype toolpaths that already exist; rebuild to apply
it. A layer with a recipe but no `kind` still tunes the operations that cut it —
it just isn't a job of its own.

##### Kerf direction

`side` applies to `"cut"` only and is normally **omitted**, meaning *auto*.

Kerf compensation has a direction: to finish at the size you drew, the beam
centreline runs **outside** an outline and **inside** a hole. Use one direction
for both and every hole comes out a full kerf oversize. So a cut layer with a
kerf is split by containment — a contour enclosed by another contour on the same
layer is a hole — and builds **two** operations, holes first. That is also the
order you would run it by hand: cut the interior features while the part is
still held by the sheet, then free it with the outline.

With no kerf there is nothing to compensate, so the layer stays one operation.
Set `side` explicitly to force one direction throughout — an inlay or a press
fit, where you want every contour biased the same way.

```jsonc
// A two-colour job: cut the outline, score the fold lines.
"layers": [
  { "id": "l-cut", "name": "Cut", "color": "#000000", "visible": true, "locked": false,
    "laser": { "kind": "cut", "feedrate": 300, "laserPower": 100, "laserPasses": 3,
               "kerfWidth": 0.15 } },
  { "id": "l-score", "name": "Score", "color": "#e05a5a", "visible": true, "locked": false,
    "laser": { "kind": "score", "feedrate": 1800, "laserPower": 15, "laserPasses": 1 } }
]
```

A raster engrave is produced when an **Engrave** op's `entityIds` reference an `image` entity: the greyscale pixels are swept as horizontal scan rows, modulating beam power per dot (`laserPower` for black down to `rasterMinPower` for the lightest mark). `laserPower` is the *darkest* power; `laserPasses` repeats the whole sweep.

On a **mill** (machineKind `"mill"`), the same Engrave-op-targeting-an-image instead carves a **relief**: each dot's darkness maps to **Z depth** (darkest = `depth`, white = the surface), cut as continuous boustrophedon rows reached over `stepdown` passes. It needs a **ball-nose** (the smooth-relief tool, and the default) or a **tapered ball-nose** (the steep-wall choice) — a **V-bit** is allowed but carves an engraving-like result (a cone per dot) and is flagged with a note; a flat end mill is rejected. The default stepover is ~10% of the cutter diameter. `rasterLineInterval` is the stepover and `rasterDotPitch` the horizontal dot pitch; `rasterInvert` carves the light areas instead; `reliefGamma` applies a tone curve (`depth ∝ darkness^gamma`, default linear) to keep a photo from reading flat. (`laserPower`/`rasterMinPower` are ignored.)

### Carve region: boundary and background

By default a relief rasters the whole image rectangle. Three optional fields shrink it to just what should be carved, so the tool stops air-cutting over the surrounding stock:

- **`reliefWhiteThreshold`** (0..1, default 0.96, photo only) — a dot at/above this greyscale is *background*: left at the surface (level 0) and skipped rather than traced at Z=0. Lower it when a photo's backdrop is grey rather than pure white. **Ignored on an STL heightfield**, which keeps the height-map threshold — a tone threshold on real heights would flatten the top of the model.
- **`reliefBoundaryIds`** — entity ids of closed loops enclosing the carve region (the Vectric "machining boundary"). When present, the raster (rough *and* finish) is clipped to inside these loops; cells outside are left uncut. Absent/empty = the whole image, and the emitted program is byte-identical.
- **`reliefBoundaryOffset`** (mm, default 0) — expands the boundary outward before clipping, so the tool's flank reaches the drawn edge. Positive = carve past the loop, negative = inset.

The clip bounds the tool *centre* (applied after the tool-footprint correction). The background/boundary mask is an **even-odd scanline fill on the field's own grid**, so a boundary tessellated to many vertices (a circle is sampled every 0.5 mm) is not a cost multiplier. It is read from one shared source by the G-code emitter and the 3-D preview, so the two cannot disagree about what is carved. A single white fleck inside the subject is ridden at the surface (cheap); a blank stretch wider than about two tool diameters breaks the run, and the tool hops that gap at a low clearance instead of retracting to `safeZ`.


### Steep areas: contours instead of rows

A raster leaves a cusp between adjacent rows, and how tall that cusp is depends on how steeply the surface climbs BETWEEN them: on a wall at 80° a stepover chosen for 15 µm delivers 480 µm, so the wall is effectively unfinished. Setting `reliefSteepPass: true` on the finish op splits the model — anything steeper than 45° is cut by **constant-Z contours** and left out of the raster, and everything flatter is rastered as before.

Nothing about it is a setting. The contour Z spacing is the stepover itself, which is the statement "leave the same cusp on a vertical wall that the raster leaves on a flat floor", and the 45° split then falls out of comparing the two spacings measured on the surface: contours win exactly where the slope exceeds 1. Change `rasterLineInterval` and both move with it. The contours are iso-lines of the tool-contact field — the Z the tool tip may ride at, which the gouge correction already computes — so they are tool-centre paths by construction, with no offsetting involved.

Two behaviours worth knowing when reading a posted program. Levels are emitted top-down, so each contour's entry plunge takes about one Z step of fresh material rather than the full depth. And the raster gives up a cell only where a contour demonstrably runs: an arc shorter than the cutter diameter is dropped (it lies inside one tool footprint), and the cells it would have covered go back to the raster, which is free of consequence there because slope ≈ 45° is where the two strategies leave the same cusp anyway. With `halftone` set this is ignored — a screen of V-grooves has no surface for a contour to mean anything on. Default false, and off the emitted program is byte-identical.

### V-carve halftone

With a **V-bit**, setting `halftone: true` switches that relief from carving a surface to cutting a halftone **screen**: parallel V-grooves whose **width** carries the tone (the "PhotoVCarve" look). A bit of included angle θ with a flat tip `t` cuts a groove `t + 2·d·tan(θ/2)` wide at depth `d` — capped at the bit's major `diameter`, past which the flutes have run out — so the tone at each dot is that width over the row pitch.

Because of that, the row pitch stops being a free parameter: it is **derived** as the widest groove (at full `depth`) plus `halftoneLand`, and `rasterLineInterval` is ignored. Spacing rows finer than a groove width is what a *relief* wants (a smooth surface) and is wrong here — neighbouring grooves re-cut each other, so the carved result is the depth map dilated by half a groove and the extra rows are cutting time spent losing detail. A plain V-bit relief whose rows overlap 3× or more is flagged with a note pointing at this mode. The along-row pitch stays fine (0.1 mm, or `rasterDotPitch` when set) rather than inheriting the millimetre row pitch, so the grooves keep the photograph's detail along their length.

`halftoneLand` is the surface left standing between grooves in the blackest area: 0 (the default) makes the darkest tone solid, and a positive land trades peak blackness for a visible line texture and a shorter cut. Two limits are reported in the dialog and as G-code notes rather than silently absorbed: a bit whose major diameter caps the groove before full `depth` (the darkest tones flatten together), and a **flat-tip** bit, which cuts a `tipDiameter`-wide groove the instant it touches and so cannot render the lightest tones at all.

A halftone also maps tone through **linear light** rather than through the stored byte. Coverage mixes by reflectance — a row half covered reflects about half the light, and half the light reads as byte ~188, not as the 128 that produced it — so middle grey wants ~79% of its row covered, not 50%. An ordinary relief keeps the encoded mapping, because its tone comes from shading a carved surface rather than from area coverage. `reliefGamma` still applies on top, and for a halftone 1 is the matched setting. Because the mapping puts most of the depth range into the light half of the image, the dark end lands within a fraction of a millimetre of full depth — and a groove in pale stock is not black, which compresses it further. So the trim to reach for is **above** 1, which lightens and opens up the shadows; below 1 deepens the mid-tones.

Independently of halftoning, a relief's `stepdown` passes after the first now visit only the rows that actually go deeper than the previous pass reached. Re-tracing a row already at its final depth cuts air for the row's whole length, which on a shallow image was most of every later pass. Where skipping breaks the boustrophedon, the tool retracts and re-approaches rather than feeding across the rows in between.

```jsonc
// Laser: cut a circle with 0.2mm kerf, and area-fill-engrave a rectangle.
// (document-level: "machineKind": "laser")
{ "id": "op1", "name": "Cut", "type": "profile", "entityIds": ["circle-1"],
  "side": "outside", "toolType": "end-mill", "toolNumber": 1, "diameter": 0,
  "feedrate": 1200, "plungeRate": 300, "spindleSpeed": 0, "safeZ": 5,
  "depth": -3, "stepdown": 1.5, "stepover": 0.4,
  "laserPower": 90, "laserPasses": 2, "kerfWidth": 0.2 }
```

## Tools

The top-level `tools` array holds reusable tool definitions. An operation
references one by `toolId`; a single tool can drive many operations, so a feed or
diameter change in one place updates every operation that points at it. `tools` is
optional and defaults to `[]`.

Each tool requires `id`, `name`, `toolType`, `diameter`, `feedrate`, `plungeRate`,
`spindleSpeed`, and `safeZ`; `vAngle`, `tipDiameter`, and `tipAngle` are optional
and type-specific (as on operations). The `id` is the target of an operation's
`toolId`.

An optional `number` is the **T-number this tool is loaded as** — the T-word the
post emits, which on GRBL, LinuxCNC and Mach3 indexes the controller's tool table
(length and diameter offsets) and, on an ATC machine, also picks the pocket. It is
optional and absent means *no slot assigned yet*, which is why it is not
defaulted: a number nobody chose is worse than none, because a program pauses for
a tool change only where the number **changes**. Two tools sharing one is a
pre-flight error (`tool-number-collision`).

Loading a tool into an operation seeds that operation's `toolNumber` from this
field, and the operation owns it from there. It is a seed, not a link, and it
holds in **both** directions: renumbering a job never writes back to the library,
and renumbering a tool in the library never renumbers an operation that already
uses it. What a saved file posts is fixed at the moment the tool was loaded —
`operation.toolNumber` is always the T-word emitted. This is the one respect in
which `toolId` does not drive the operation; see [Operations](#operations).

An optional `unit` (`"mm"` or `"in"`) records whether the tool is conventionally
entered and displayed in millimetres or inches, so a `1/4"` end mill can sit next
to a `3mm` ball nose and each still shows in its own unit. All numeric fields
above remain in mm regardless — `unit` is a display/entry hint only.

`toolType` is one of `end-mill`, `ball-nose`, `v-bit`, `drill`, or
`tapered-ball-nose`. A tapered ball-nose is a cone with a **spherical** tip —
opencamlib's `CompositeCutter`, the steep-wall finish bit that Easel has no shape
for (you enter it there as "other bit" and type a single diameter). `diameter` is
the major (widest) cutting diameter, `vAngle` the included taper angle, and
`tipDiameter` the ball-tip diameter — the narrow end, exactly as a V-bit's
`tipDiameter` is its flat. The tool's profile, and therefore the gouge correction,
the 3-D preview, and the scallop-to-stepover calculator, all follow from those
three numbers.

```json
{ "id": "tool-em-6", "name": "6mm End Mill", "number": 1, "toolType": "end-mill",
  "diameter": 6, "feedrate": 900, "plungeRate": 250, "spindleSpeed": 18000, "safeZ": 5 }
```

When RapidCAM saves a file it embeds only the tools actually referenced by an
operation, so the file is self-contained and portable. See
`mounting-plate-cam.rcam` for an example of two operations driven by a shared
`tools` library.

## Fonts

A text entity's `fontId` must resolve to a font. **Bundled** fonts (currently
`"roboto-regular"` and `"roboto-bold"`) ship with the app and resolve by id, so
they are never embedded. Any **other** font — one a user loaded from disk — is
embedded in the top-level `fonts` array so the file is self-contained: it renders
and cuts identically on a machine that has never seen that font.

Each embedded font has a content-addressed `id` (`"font-XXXXXXXX"`, an FNV-32 hash
of the bytes, so the same font always dedupes to the same id), a human-readable
`name`, a `format` (`"ttf"` | `"otf"` | `"woff"`), and base64-encoded `data`.

```jsonc
{ "id": "font-1a2b3c4d", "name": "Some Custom Font",
  "format": "ttf", "data": "AAEAAAAL..." }   // base64 font bytes
```

RapidCAM embeds only the fonts actually referenced by a text entity. If a text
entity's `fontId` is neither a bundled font nor present in `fonts`, the text cannot
be rendered or cut.

## Validating your output

```bash
# From the repo, the bundled examples are checked on every test run:
npm test -- rcam-schema
```

External tools can validate against [`public/schema/rcam-v3.schema.json`](../public/schema/rcam-v3.schema.json)
with any JSON Schema (draft 2020-12) validator. The schema enforces structure and
enumerations; it cannot tell you whether a constraint system converges or a pocket
seed lands inside its region — for that you need to load the file in RapidCAM.

## Reference examples

The files in [`examples/`](../examples/) form a difficulty progression and are the
canonical, tested references:

Every one is generated from a builder in `scripts/` and checked byte-for-byte, so
what is described here is what the file contains. `test/guideListsEveryExample.test.ts`
fails if this list and the directory disagree — the list is hand-written, and it
had gone stale by eleven files before that check existed.

**Sketch — geometry, constraints, parameters**

- `keychain-tag.rcam` — smallest complete part (rectangle, circle, text, driving dims).
- `mounting-plate.rcam` — fully-constrained plate with `equal` + `symmetric` holes.
- `bracket.rcam` — L-profile driven to "fully constrained" with per-segment H/V constraints.
  Worth reading for how FEW dimensions it needs: six vertices are twelve degrees of
  freedom, the H/V constraints take six and the corner pin two, so exactly four remain.
- `bolt-circle.rcam` — `variables` + a circular `pattern`.
- `parametric-box.rcam` — the three parametric channels side by side: a dimension
  `expr`, a `ScalarBinding` (which drives geometry and draws nothing), and `stock`
  as a built-in, which reaches both the end panel's width and, through `paramExprs`,
  the toolpath's depth.
- `block-hole-pattern.rcam` — `blockDefinitions` + `groups` with `definitionId`: one
  definition placed four times, each instance `rigid` with its own `angle`. Note that
  a rigid block's degrees of freedom belong to the block, so a `fixedPoint` on one of
  its members is dropped as internal — `fixed` is what holds an instance.
- `rcam-logo.rcam` — the only file with no `operations` at all: `isConstruction`
  geometry, a `bezier`, and driving `diameter` dimensions.

**CAM — toolpaths and the order they run in**

- `mounting-plate-cam.rcam` — drill + tabbed profile `operations`.
- `enclosure-lid.rcam` — pocket with parametric region `containingLoops` and an island.
- `round-bar-center-finder.rcam` — variables that derive other variables (`slotStart`, `slotEnd`,
  `run` from `barMax` and `barMin`), dimensions whose `expr` names them, a `drill` over four registration holes, and a pocket named by a
  `regions` entry rather than by `entityIds`. 45° steps are paired horizontal and vertical
  dimensions; there is no trig in the expression language, so an angle cannot be a variable.
- `clamped-toolplate.rcam` — a positioned `stockRect` (the blank inside a larger work
  area), a `features[]` entry from the `clamp` generator, a layer with `fixture: true`
  so pre-flight treats its shapes as keep-outs, and the only use of
  `pocketStrategy: "adaptive"`. Note `safeZ` clears the *fixtures*, not the stock.
- `nested-sheet.rcam` — many parts on one sheet: holes bored while each part is still
  attached, all outlines cut last, tabs on every part.
- `flip-spacer-block.rcam` — `flip` + per-operation `face`: two setups, two posted
  programs, located by registration pins whose positions must be unchanged by the
  mirror.
- `surfaced-sign-blank.rcam` — `face` (which names no geometry, taking its extent from
  the blank) followed by a profile carrying `startDepth`, because skimming does not
  move the Z datum.
- `box-joint-tab-slot.rcam` — `cornerStyle: "tbone"`, without which a round cutter
  leaves a fillet in every square inside corner and the joint will not seat.
- `vcarve-sign.rcam` — `vcarve` + `chamfer` operations sharing one v-bit via the `tools` library.
- `inlay-coaster.rcam` — `inlay`, where `glueGap`, `sawAllowance` and `pocketDepth` are
  a fit rather than three independent preferences.
- `relief-medallion.rcam` — a height-map `image` with `zRangeMM` (so the bytes are
  lengths, not tone), roughed by `relief-rough` then finished.

**Other machine kinds**

- `laser-coaster.rcam` — `machineKind: "laser"` with outline/fill engraves and a kerf-compensated cut.
- `round-bar-center-finder-laser.rcam` — the same sketch under `machineKind: "laser"`: a `cut` layer
  whose `kerfWidth` offsets each outline outward and the slot inward.
- `fold-tray.rcam` — `score` beside `profile`, on layers carrying their own `laser`
  recipes, so re-tuning a layer moves every operation on it.
- `rotary-spiral-dowel.rcam` — `machineKind: "mill-rotary"` + the `rotary` cylinder block.
- `tumbler-wrap.rcam` — `machineKind: "laser-rotary"`: the same `rotary` block with a beam
  head, so the wrapped axis is substituted rather than wrapped.

In both rotary files the canvas height is π × diameter **exactly**, because the
canvas *is* the unrolled surface: a rounded constant leaves the wrap seam open.
