# RapidCAM > RapidCAM (https://rapidcam.app) is a parametric 2D CAD + > CAM application for CNC mills, rotary (4th-axis) machines, and lasers. Its > project format, `.rcam`, is plain JSON with a published JSON Schema, and is > explicitly designed so that automated tools — including LLMs — can author > valid design files. **Can you only fetch a single URL?** Some AI tools cannot follow links found in a fetched page. Fetch [the single-file bundle](https://rcamhub.com/llms-full.txt) instead — this index plus the full format guide, the JSON Schema, and the AI integration guide, inlined as one plain-text document. ## File format (.rcam) - [Format guide](https://rcamhub.com/docs/rcam-format-v3.md): the human- and AI-readable authoring guide — point-key vocabulary, constraint semantics, CAM operations, and gotchas the schema can't express - [JSON Schema, draft 2020-12](https://rcamhub.com/schema/rcam-v3.schema.json): the machine-readable contract (this URL is also the schema's `$id`) - [Examples index](https://rcamhub.com/examples/index.json): bundled golden example projects, each validated against the schema on every commit; fetch any listed file at `https://rcamhub.com/examples/` - [AI integration guide](https://rcamhub.com/docs/ai-integration.md): every way an AI can work with RapidCAM — the in-app assistant loop, this index, the headless CLI, and the MCP server — plus authoring tips - [Version control guide](https://rcamhub.com/docs/version-control.md): keeping `.rcam` files in git — why saving an unchanged design is byte-identical, and the merge driver that reconciles two edits by shape instead of by line ([rcam-merge.mjs](https://rcamhub.com/rcam-merge.mjs), one dependency-free file run by `node`). Relevant when generating files that will be merged: mint ids the way the format guide describes, not with a counter. ## Parametric features (generators) - `features[]` in `.rcam`: geometry produced by a built-in generator (box, box-joint, spur gear, ...) that stays re-editable — `{id, generatorId, params, paramExprs?, groupId, offset}`. - `paramExprs` (optional) holds an expression per param, evaluated against document `variables` and the built-in keywords (`stock`, `stock_width`, `origin_x`, `pi`, `counter`, …; a user variable of the same name overrides a built-in); `params` holds the last resolved values as cache/fallback for params with no expression, or whose expression fails to evaluate. - Features regenerate automatically when a variable they reference changes (and are self-healed on file open if `params` doesn't match what the expressions currently resolve to). - Entity ids stay stable across a regeneration, so CAM operations, constraints, and dimensions that reference generated geometry survive a parameter edit. - `keyIds` (optional) maps generator-assigned stable keys (e.g. "front-wall") to entity ids; keyed entities keep their ids by key rather than by emit position, so references survive output reordering and partial deletes. ## Groups & blocks (including nesting) - `groups[]` in `.rcam`: `{id, name, entityIds}`. Adding `"rigid": true` plus `origin` and `angle` makes it a **block** — one rigid body with 3 degrees of freedom, whose members keep ordinary world coordinates. - **An entity id may appear in at most one group's `entityIds`.** This is the rule most often broken by generated files: a block inside a block is NOT written by listing the inner block's entities in the outer block as well. - **Nest with `parentGroupId`**, which names the containing block. It is a parent pointer, so a block has one parent at most, and following it must terminate — a group may not be its own ancestor. - A nested block is frozen relative to its parent: the whole tree solves as one rigid body, and only the outermost block carries the 3 transform variables. - `blockDefinitions[]` (`{id, name, entities, children?}`) are shared definitions; a group with a `definitionId` is a linked instance of one. `children[]` are `{definitionId, origin, angle, scale?}` references to other definitions — a definition contains block REFERENCES, never a second copy of the child's geometry — and the reference graph must be acyclic. - `group.scale` is ABSOLUTE (the instance relative to its definition); `children[].scale` is RELATIVE to the parent definition. - None of these rules is expressible in JSON Schema, so a file that breaks them still validates. The importer reports them as `blocks` errors, and the app repairs them on open rather than refusing the file. ## Parametric CAM operations - `operations[]` may carry `paramExprs` too — an expression per **numeric op field**, keyed by field name: `{"depth": "-stock", "feedrate": "baseFeed*1.2"}`. Every numeric field takes one (pinned by `test/everyNumericOpFieldBinds.test.ts`). A key that is not a numeric op field is **ignored silently** rather than rejected, so a typo costs you the binding without costing you the file. Evaluated against `variables` and the built-in keywords before every solve, then clamped to the field's valid range. - Always also emit the plain numeric field (`depth`, `feedrate`, ...): it is the last resolved value and the fallback when a field has no expression or its expression fails. A file never needs an expression evaluated to load. - Bare numbers in an op expression are **millimetres** regardless of `displayUnit`. Note the exception elsewhere: a VARIABLE whose expression is a lone length (`"0.5"`, no arithmetic) is read in the document's display unit. Operation expressions never are. - Nested fields take either key spelling: `"tabCount"`/`"tabs.count"`, `"leadInLen"`/`"leadIn.length"`, `"perforateGapWidth"`/`"perforate.gapWidth"`. Ignored while `tabs`/`leadIn`/`leadOut`/`perforate` is absent. - `"depth": "-stock"` is the common case — a profile that always cuts through whatever the stock currently is. ## Laser jobs from layers - A **layer** may carry a `laser` recipe — `{kind?, feedrate, laserPower, laserPasses, kerfWidth?, side?, airAssist?}` — so a design describes how each colour is cut: cut on black, score on red, engrave on blue. - `feedrate`/`laserPower`/`laserPasses` are **live**: any operation whose `entityIds` all sit on that layer takes them at toolpath time, so re-tuning a layer re-tunes everything on it. An operation spanning several layers keeps its own settings, and `"laserOverride": true` opts one out entirely. - `kind` (`"cut"|"score"|"engrave"|"fill"`) is read when operations are BUILT, not applied live — a cut, an engrave and a fill emit different geometry, so retyping an existing operation at export would not match its preview. A layer with a recipe but no `kind` tunes the operations that cut it without being a job of its own. - Emitting layers with kinds and NO `operations` is a valid, compact way to describe a laser job: the user presses **Toolpaths from Layers** and gets one operation per layer, in layer order, named after the layer. - Hidden layers (`"visible": false`) and workholding layers (`"fixture": true`) are never cut when a job is built FROM LAYERS. An operation that names workholding geometry explicitly still cuts it — pre-flight warns (`machined-fixture`) rather than dropping the cut silently. - A clamp's height above the stock top comes from the layer's `fixtureHeight`, or from the shape's own `fixtureHeight` when clamps of different heights share a layer. Unset at both levels means full-height: it blocks every pass. - Kerf direction is automatic unless you set `side`: a contour enclosed by another on the same layer is treated as a hole and compensated inward while the outline goes outward, so both finish at the drawn size. ## Authoring tips for LLMs - All lengths are millimetres. Angles are radians (CCW) everywhere except the two dimension types listed next, which are in degrees. Neither depends on the file's `displayUnit`. The world frame is Y-up. - `angle-x` — a line's direction from +X, signed — has a `value` in **DEGREES**. - `arc-sweep` — an arc's included angle, 0–360 — has a `value` in **DEGREES**. - Those two back a property field, and a dimension's `expr` lands in `value` with no unit applied, so they store the unit you type. Emitting `{"type": "angle-x", "value": 0.785}` meaning 45° gives a line at 0.785° — plausible-looking and wrong. Write `45`. - You do not need exact coordinates: emit rough positions plus constraints and dimensions, and RapidCAM's parametric solver snaps the geometry exact. - A **rounded, coved or chamfered rectangle is still a `rectangle`** — give it `cornerRadii` (one radius per corner, `bl` `br` `tr` `tl`) and a treatment per corner (`"round"` | `"inverted"` | `"chamfer"`): `cornerType` for one treatment on every corner, or `cornerTypes` (four, same order) to mix a chamfer with a fillet on one rectangle. Do not tessellate one into a polyline: as a rectangle it keeps its `W`/`H` fields, its corner point keys, and an editable radius, and the arcs reach the toolpath as true arcs. The radius is also the scalar DOF `cr`, so a `bindings[]` entry can drive it from a formula (`{"scalarKey": "cr", "expr": "stock * 2"}`), all four corners at once. - A **rounded, coved or chamfered `polyline` is still a `polyline`** — the same two fields, but `cornerRadii` is an object keyed by **vertex id**, not an array: `{"cornerRadii": {"1": 6, "2": 6}, "cornerType": "round"}` shapes the vertices whose `vertexIds` entries are `"1"` and `"2"`. To mix treatments, use `cornerTypes` keyed the same way — `{"1": "chamfer", "2": "round"}`. Do not tessellate an arc into extra vertices — as corner data it stays editable, reaches the toolpath as a true arc, and leaves every `v` constraint alone. An open polyline's first and last vertices cannot be shaped. **The number means the fillet RADIUS for `"round"`/`"inverted"` but the bevel SETBACK for `"chamfer"`**; those are the same figure only at a 90° corner, which is why a `rectangle` gets away with one meaning. - An embedded image carrying **`zRangeMM`** is a HEIGHT MAP from an imported STL, not a picture: byte 255 is the model's top surface (no cut), byte 0 its base (full depth), and `zRangeMM` is the carve depth in mm at true scale. Preserve the field when editing such a file — dropping it turns the model back into a photograph, and the white-threshold default alone then flattens the top 4% of its height range. For the same reason, do not set `reliefGamma` on a relief operation that reads one; a tone curve on a height map is a geometry error. A model with near-vertical walls also takes `"reliefSteepPass": true` on the finish op: anything steeper than 45° is then cut by constant-Z contours rather than by the raster, which cannot hold a cusp on a wall. It has no settings of its own — the contour spacing is derived from the stepover. - A relief rasters the whole image by default. To stop it air-cutting over the background, set `reliefWhiteThreshold` (0..1, default 0.96) so dots at/above that greyscale are background and skipped — and/or `reliefBoundaryIds` to a list of closed-loop entity ids (with `reliefBoundaryOffset` mm to expand it) so only the inside of those loops is carved. Both apply to the rough and the finish pass; the threshold is ignored on an STL heightfield. - Declare `"version": 3` and validate your output against the schema before presenting it to a user. - Opening a file in RapidCAM reports schema, solver, and CAM lint problems; iterate on that feedback rather than aiming for a perfect first attempt. - Agents on the user's machine with a RapidCAM checkout can run those same checks headlessly (CLI: `npm run cli -- validate|post|render|open|decode`) or as MCP tools (`npm run mcp`), including `open` — which delivers a validated design straight into the user's browser via a share-link URL — and `decode`, which turns a copied share link back into .rcam JSON. See the AI integration guide above. ============================================================================== SECTION: .rcam format guide Canonical URL: https://rcamhub.com/docs/rcam-format-v3.md ============================================================================== # 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: `, 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 | |----------|----|-------------| | `@` | 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_` | the point a fraction `t` (0…1) along that edge, from its first named corner toward its second | | `curve@` | `bezier` | the point at curve parameter `t` (0…1) | | `edge@` | `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 `#` — 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`; segment mids `mid_` (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` (solver DOFs, constrainable); handles `n.in` / `n.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` and `mid_` (the midpoint of the segment that *starts* at vertex ``). 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 `"#"` (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. ============================================================================== SECTION: .rcam JSON Schema (draft 2020-12) Canonical URL: https://rcamhub.com/schema/rcam-v3.schema.json ============================================================================== ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://rcamhub.com/schema/rcam-v3.schema.json", "title": "RapidCAM Project (.rcam) — version 3", "description": "Contract for the .rcam v3 file format, RapidCAM's project file. A .rcam file is JSON describing a design (not an editor session): unlike v1 it carries no selection/UI state, and it may embed the fonts its text entities use. All lengths are in millimetres, in a Y-up world frame, regardless of displayUnit. See docs/rcam-format-v3.md for the authoring guide.", "type": "object", "additionalProperties": false, "required": [ "version", "name", "canvas", "displayUnit", "entities", "constraints", "dimensions" ], "properties": { "version": { "const": 3, "description": "Format version. Must be exactly 3. The loader upgrades version-1 and version-2 files automatically. v3 removed machine configuration from the file: a .rcam is a drawing, so it no longer carries the author's post-processor, tool changer, rotary axis word or arc tolerance — those come from the opener's own machine profile." }, "name": { "type": "string", "description": "Human-readable project name." }, "canvas": { "$ref": "#/$defs/canvas" }, "displayUnit": { "enum": ["mm", "in"], "description": "Unit the UI presents values in. Does NOT change the file's units — geometry is always mm." }, "stockThickness": { "type": "number", "exclusiveMinimum": 0, "description": "Stock thickness in mm. Default 10 if omitted." }, "counter": { "type": "number", "description": "Incrementing serial counter, exposed to expressions as the counter/serial/seq keyword. Default 1 if omitted." }, "stockRect": { "oneOf": [ { "type": "object", "additionalProperties": false, "required": ["x", "y", "width", "height"], "properties": { "x": { "type": "number", "description": "Lower-left X of the stock within the work area (canvas), mm." }, "y": { "type": "number", "description": "Lower-left Y of the stock within the work area (canvas), mm." }, "width": { "type": "number", "exclusiveMinimum": 0 }, "height": { "type": "number", "exclusiveMinimum": 0 } } }, { "type": "null" } ], "description": "Optional positioned flat stock blank within the work area (canvas). Omitted/null = the stock fills the work area (legacy). Ignored for a rotary cylinder." }, "origin": { "$ref": "#/$defs/origin" }, "machineKind": { "enum": ["mill", "laser", "mill-rotary", "laser-rotary"], "description": "Machine the document outputs for. Two independent choices: the HEAD (\"mill\" = spindle + Z, the default; \"laser\" = fixed-Z beam, routing export through the beam generator — beam on/off, power + passes, no Z — with the operations using the laser fields laserPower/laserPasses/kerfWidth/laserFill) and the STOCK (a \"-rotary\" kind machines a cylinder, so the canvas is its unrolled surface and the top-level \"rotary\" block is required). The head behaviour is unchanged by the stock. \"mill-rotary\" behaves like \"mill\" except that export WRAPS the finished flat program around the cylinder — one work axis stays linear, the wrapped one is emitted as rotary-axis degrees. \"laser-rotary\" behaves like \"laser\" including at export: the wrapped axis is SUBSTITUTED, i.e. left as its ordinary linear word in surface millimetres, with the machine set up so one revolution equals the circumference of travel on that axis (GRBL, which drives most laser rotaries, has no 4th axis). Its posted program is therefore the flat one plus a setup banner." }, "endPosition": { "oneOf": [ { "$ref": "#/$defs/vec2" }, { "type": "null" } ], "description": "Optional end-of-program park position in work coordinates (mm); the program rapids here at safe Z before M30. { x: 0, y: 0 } parks at the WCS origin. null/omitted leaves the tool where the last toolpath ended." }, "toolChangePosition": { "oneOf": [ { "$ref": "#/$defs/vec2" }, { "type": "null" } ], "description": "Optional park position in work coordinates (mm) the tool rapids to at safe Z before a manual tool change. null/omitted leaves the tool over the work. Ignored with an automatic tool changer and on lasers." }, "flip": { "oneOf": [ { "$ref": "#/$defs/flip" }, { "type": "null" } ], "description": "Optional double-sided (flip) machining setup. When present, operations carry a \"face\" and export produces a top-side and a mirrored bottom-side program. null/omitted = single-sided." }, "rotary": { "oneOf": [ { "$ref": "#/$defs/rotary" }, { "type": "null" } ], "description": "Optional cylindrical/rotary setup — the cylinder the canvas is the unrolled surface of. Required for a \"-rotary\" machineKind, ignored otherwise. null/omitted = flat work." }, "metadata": { "type": "object", "additionalProperties": false, "properties": { "job": { "type": "string", "description": "Job name / part number." }, "revision": { "type": "string", "description": "Revision identifier (e.g. \"A\", \"v2\")." }, "notes": { "type": "string", "description": "Free-form notes." } }, "description": "Optional informational job metadata. Affects no geometry; non-empty fields are written as comments in the G-code header. Omitted when all fields are blank." }, "groups": { "type": "array", "items": { "$ref": "#/$defs/group" }, "description": "Named entity groups. Default []." }, "blockDefinitions": { "type": "array", "items": { "$ref": "#/$defs/blockDefinition" }, "description": "Linked block definitions (Phase 2b). Linked group instances reference one by id via definitionId. Default []." }, "features": { "type": "array", "items": { "$ref": "#/$defs/feature" }, "description": "Parametric generator features: geometry produced by a generator that stays re-editable via its parameters. Default []." }, "layers": { "type": "array", "items": { "$ref": "#/$defs/layer" }, "description": "Layers. If omitted OR empty, a single \"layer-0\" / \"Default\" layer is created — a document always has at least one layer for entities to belong to." }, "activeLayerId": { "type": "string", "description": "Id of the active layer. Default \"layer-0\". Ignored (falling back to the first layer) if it names no layer in \"layers\"." }, "entities": { "type": "array", "items": { "$ref": "#/$defs/entity" }, "description": "Geometry. May be empty. The reserved origin point \"__origin__\" is injected automatically and need not be authored." }, "constraints": { "type": "array", "items": { "$ref": "#/$defs/constraint" }, "description": "Parametric constraints. May be empty — geometry is valid without them." }, "dimensions": { "type": "array", "items": { "$ref": "#/$defs/dimension" }, "description": "Dimensions. Driving dimensions also act as constraints. May be empty." }, "variables": { "type": "array", "items": { "$ref": "#/$defs/variable" }, "description": "Named numeric variables referenced by dimension/pattern/binding expressions." }, "bindings": { "type": "array", "items": { "$ref": "#/$defs/scalarBinding" }, "description": "Headless parametric bindings (formula → an entity scalar DOF). Default []." }, "patterns": { "type": "array", "items": { "$ref": "#/$defs/pattern" }, "description": "Pattern definitions (linear, circular, hex)." }, "operations": { "type": "array", "items": { "$ref": "#/$defs/operation" }, "description": "CAM toolpath operations." }, "tools": { "type": "array", "items": { "$ref": "#/$defs/tool" }, "description": "Tool definitions embedded in the file. Operations reference these by toolId; a single tool can drive many operations. Default []." }, "fonts": { "type": "array", "items": { "$ref": "#/$defs/embeddedFont" }, "description": "Non-bundled fonts referenced by text entities, embedded so glyph outlines — and therefore toolpaths — reproduce on any machine. Bundled fonts (e.g. \"roboto-regular\") resolve by id and are omitted." }, "images": { "type": "array", "items": { "$ref": "#/$defs/embeddedImage" }, "description": "Greyscale pixels for image entities, embedded so a raster engrave reproduces on any machine." } }, "$defs": { "vec2": { "type": "object", "additionalProperties": false, "required": ["x", "y"], "properties": { "x": { "type": "number", "description": "mm" }, "y": { "type": "number", "description": "mm (Y-up: larger = further from the front)" } } }, "canvas": { "type": "object", "additionalProperties": false, "required": ["width", "height"], "properties": { "width": { "type": "number", "exclusiveMinimum": 0, "description": "Work-area width, mm." }, "height": { "type": "number", "exclusiveMinimum": 0, "description": "Work-area height, mm." } } }, "origin": { "type": "object", "additionalProperties": false, "required": ["x", "y", "z"], "description": "Named work-coordinate-system origin. Default front-left-top.", "properties": { "x": { "enum": ["left", "center", "right"] }, "y": { "enum": ["front", "center", "back"] }, "z": { "enum": ["top", "bed"] } } }, "group": { "type": "object", "additionalProperties": false, "required": ["id", "name", "entityIds"], "properties": { "id": { "type": "string", "minLength": 1 }, "name": { "type": "string" }, "entityIds": { "type": "array", "items": { "type": "string" } }, "parentGroupId": { "type": "string", "description": "Id of the group this block is nested inside. A PARENT POINTER, so a block has at most one parent, and entityIds lists only DIRECT entity members. Two rules the schema itself cannot express and the app checks on load: an entity id may appear in at most one group's entityIds, and following parentGroupId must terminate (no cycle)." }, "rigid": { "type": "boolean", "description": "When true the group is a rigid block; origin/angle are required. Default false (loose)." }, "origin": { "$ref": "#/$defs/vec2", "description": "Block base point, world mm. Required when rigid is true." }, "angle": { "type": "number", "description": "Block rotation, radians. Required when rigid is true." }, "definitionId": { "type": "string", "description": "Id of the linked block definition this instance references, when it is one." }, "scale": { "type": "number", "exclusiveMinimum": 0, "description": "Insert-time uniform scale factor (bake-on-scale). Default 1; used to re-materialize linked instances from their definition." } } }, "blockDefinition": { "type": "object", "additionalProperties": false, "required": ["id", "name", "entities"], "properties": { "id": { "type": "string", "minLength": 1 }, "name": { "type": "string" }, "entities": { "type": "array", "items": { "$ref": "#/$defs/entity" }, "description": "DIRECT member geometry in the block's canonical local frame." }, "children": { "type": "array", "items": { "$ref": "#/$defs/blockChildRef" }, "description": "Nested block references. A definition holds REFERENCES to other definitions, never a second copy of their geometry, so editing a child updates every parent that contains it. The reference graph must be acyclic: a definition may not contain itself at any depth." } } }, "blockChildRef": { "type": "object", "additionalProperties": false, "required": ["definitionId", "origin", "angle"], "properties": { "definitionId": { "type": "string", "minLength": 1, "description": "The blockDefinitions[] entry this reference instantiates." }, "name": { "type": "string", "description": "Instance name override; the definition's own name when omitted." }, "origin": { "$ref": "#/$defs/vec2", "description": "Where the child's local (0,0) lands in the PARENT definition's local frame." }, "angle": { "type": "number", "description": "Child rotation relative to the parent, radians." }, "scale": { "type": "number", "exclusiveMinimum": 0, "description": "Uniform scale RELATIVE to the parent (default 1). Note the asymmetry with group.scale, which is ABSOLUTE (an instance relative to its definition): this one composes multiplicatively down the tree." } } }, "feature": { "type": "object", "additionalProperties": false, "required": ["id", "generatorId", "params", "groupId"], "properties": { "id": { "type": "string", "minLength": 1 }, "generatorId": { "type": "string", "minLength": 1, "description": "Generator that produced this feature (e.g. \"box-joint\")." }, "params": { "type": "object", "maxProperties": 128, "propertyNames": { "minLength": 1, "maxLength": 64 }, "additionalProperties": { "type": "number" }, "description": "Parameter name → value the feature was generated with (its re-run inputs)." }, "groupId": { "type": "string", "minLength": 1, "description": "Id of the group holding this feature's entities." }, "offset": { "type": "object", "additionalProperties": false, "required": ["x", "y"], "properties": { "x": { "type": "number" }, "y": { "type": "number" } }, "description": "Translation applied to the generator's origin-based output to place it in the work area; re-applied on regenerate." }, "paramExprs": { "type": "object", "maxProperties": 128, "propertyNames": { "minLength": 1, "maxLength": 64 }, "additionalProperties": { "type": "string", "maxLength": 256 }, "description": "Optional expression per generator param, evaluated against document `variables` and `stock` (the stock thickness). `params` holds the last resolved values as cache/fallback: a param with no entry here, or whose expression fails to evaluate, keeps its cached numeric. The feature regenerates when a variable it references changes." }, "keyIds": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 128 }, "propertyNames": { "minLength": 1, "maxLength": 64 }, "description": "Stable generator key → entity id, for entities the generator named (e.g. \"front-wall\"). Regeneration pairs keyed entities by key rather than by emit position, so constraints, dimensions, and CAM references survive output reordering and partial deletes." } } }, "layer": { "type": "object", "additionalProperties": false, "required": ["id", "name", "color", "visible", "locked"], "properties": { "id": { "type": "string", "minLength": 1 }, "name": { "type": "string" }, "color": { "type": "string", "description": "CSS hex colour, e.g. \"#cdd2da\"." }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixture": { "type": "boolean", "description": "When true, closed shapes on this layer are workholding (clamps/fixtures): not machined, and pre-flight flags any move that would hit one." }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Fixture layers only: clamp height above the stock top, mm. A rapid clears the clamp only above this. Omitted = full-height (blocks any pass)." }, "laser": { "type": "object", "additionalProperties": false, "required": ["feedrate", "laserPower", "laserPasses"], "description": "Laser documents only: beam settings shared by every operation whose geometry sits entirely on this layer (the colour-driven workflow — cut on black, score on red). An operation with \"laserOverride\": true keeps its own instead. Omitted = each operation uses its own settings.", "properties": { "feedrate": { "type": "number", "exclusiveMinimum": 0, "description": "Cutting speed, mm/min." }, "laserPower": { "type": "number", "minimum": 0, "maximum": 100, "description": "Beam power, % of machine maximum." }, "laserPasses": { "type": "integer", "minimum": 1, "description": "How many times the beam re-traces each path." }, "kerfWidth": { "type": "number", "minimum": 0, "description": "Beam kerf, mm. Omitted = leave each operation's own kerf alone." }, "airAssist": { "type": "boolean", "description": "Omitted = leave each operation's own air-assist setting alone." }, "kind": { "enum": ["cut", "score", "engrave", "fill"], "description": "What the geometry on this layer is FOR. Read when operations are BUILT from the layers (the \"Toolpaths from Layers\" action), NOT applied live the way feedrate/laserPower/laserPasses are: a cut, an engrave and a fill emit fundamentally different geometry, so retyping an existing operation at export time would silently differ from the previewed toolpath. Omitted = this layer tunes the operations that cut it but is not itself a job." }, "side": { "enum": ["outside", "inside"], "description": "kind \"cut\" only: which side of the contour the kerf is taken from. OMIT for auto, which is normally what you want: a contour enclosed by another on the same layer is treated as a hole and compensated inward while the outline is compensated outward, so both finish at the drawn size (the layer then builds two operations, holes first). Set explicitly to force one direction throughout." } } } } }, "entity": { "oneOf": [ { "$ref": "#/$defs/lineEntity" }, { "$ref": "#/$defs/pointEntity" }, { "$ref": "#/$defs/circleEntity" }, { "$ref": "#/$defs/ellipseEntity" }, { "$ref": "#/$defs/polyBezierEntity" }, { "$ref": "#/$defs/rectangleEntity" }, { "$ref": "#/$defs/polylineEntity" }, { "$ref": "#/$defs/arcEntity" }, { "$ref": "#/$defs/bezierEntity" }, { "$ref": "#/$defs/textEntity" }, { "$ref": "#/$defs/imageEntity" } ] }, "pointEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "pos"], "description": "A free-standing point. Point DOF keys: p. Snappable, constrainable and dimensionable like any other entity; DXF POINT records import as these. The document's WCS origin is a point too but is NEVER written to a file — it is implicit, and every reader recreates it.", "properties": { "type": { "const": "point" }, "id": { "type": "string", "minLength": 1 }, "pos": { "$ref": "#/$defs/vec2" }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "lineEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "a", "b"], "description": "Point DOF keys: a, b (endpoints), mid (midpoint, pickable but derived).", "properties": { "type": { "const": "line" }, "id": { "type": "string", "minLength": 1 }, "a": { "$ref": "#/$defs/vec2" }, "b": { "$ref": "#/$defs/vec2" }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "circleEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "center", "radius"], "description": "Point DOF key: c (center). Scalar DOF key: r (radius).", "properties": { "type": { "const": "circle" }, "id": { "type": "string", "minLength": 1 }, "center": { "$ref": "#/$defs/vec2" }, "radius": { "type": "number", "exclusiveMinimum": 0 }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "ellipseEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "center", "rx", "ry"], "description": "Point DOF key: c (center). Scalar DOF keys: rx, ry, angle. rx/ry are SEMI-axes (radii), as in AutoCAD's ELLIPSE and SVG's .", "properties": { "type": { "const": "ellipse" }, "id": { "type": "string", "minLength": 1 }, "center": { "$ref": "#/$defs/vec2" }, "rx": { "type": "number", "exclusiveMinimum": 0, "description": "Semi-axis along the ellipse's own x, mm." }, "ry": { "type": "number", "exclusiveMinimum": 0, "description": "Semi-axis along the ellipse's own y, mm." }, "angle": { "type": "number", "description": "CCW rotation of the rx axis from +X, radians (world Y-up). Omitted = 0." }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "polyBezierEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "nodes"], "description": "A chain of cubic Bezier segments. Segment i runs nodes[i].pos -> nodes[i].outH -> nodes[i+1].inH -> nodes[i+1].pos, so adjacent segments SHARE their knot. Point DOF key per node: n (the knot). Handles are draggable as n.in / n.out but are neither solver DOFs nor constraint targets — a smooth node's two handles move each other, so they cannot be independent solver variables. A closed chain has one more segment than it has nodes.", "properties": { "type": { "const": "polybezier" }, "id": { "type": "string", "minLength": 1 }, "nodes": { "type": "array", "minItems": 2, "items": { "type": "object", "additionalProperties": false, "required": ["id", "pos", "inH", "outH", "smooth"], "properties": { "id": { "type": "string", "minLength": 1, "description": "Stable across inserts; point keys are built from it, never from the array index." }, "pos": { "$ref": "#/$defs/vec2", "description": "On-curve knot." }, "inH": { "$ref": "#/$defs/vec2", "description": "Absolute control point for the segment ARRIVING at pos." }, "outH": { "$ref": "#/$defs/vec2", "description": "Absolute control point for the segment LEAVING pos." }, "smooth": { "type": "boolean", "description": "Hold inH and outH collinear through pos. false is a deliberate corner." }, "symmetric": { "type": "boolean", "description": "When smooth, also hold the two handles at equal length (a mirror node). Omitted = false." } } } }, "closed": { "type": "boolean", "description": "Omitted = open." }, "fit": { "type": "boolean", "description": "Omitted = the handles are the author's, drawn with the Pen tool. When true the curve is a FITTED spline: the handles are computed from the knots so the curve passes smoothly through every one of them, and moving a knot recomputes them. inH/outH are still written out, so a reader that ignores this flag gets the same shape." }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "rectangleEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "p0", "p1"], "description": "Axis-aligned. p0/p1 are opposite corners (stored normalised to min/max). Point DOF keys: bl, br, tr, tl (corners); mid_b, mid_r, mid_t, mid_l (edge midpoints); center. Corners may be rounded, coved or chamfered via cornerRadii plus a treatment per corner (cornerType / cornerTypes) — the point keys are unaffected, so a shaped rectangle is constrained and dimensioned exactly like a square one.", "properties": { "type": { "const": "rectangle" }, "id": { "type": "string", "minLength": 1 }, "p0": { "$ref": "#/$defs/vec2" }, "p1": { "$ref": "#/$defs/vec2" }, "cornerRadii": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Corner radius per corner, mm, in the order bl, br, tr, tl (the same order as the corner point keys). 0 = a square corner; omitted = all four square. The value is what was asked for, not necessarily what is drawn: two corners sharing an edge are scaled down to fit it when the outline is built, so shrinking a rectangle below its corner size and growing it back restores the corners. All three cornerTypes consume this same distance along each edge, so for \"chamfer\" it is the bevel's setback." }, "cornerType": { "enum": ["round", "inverted", "chamfer"], "description": "How the non-zero corners are cut when they are all cut the same way — the compact form, written whenever every shaped corner agrees, and what every file written before corners could differ carries. \"round\": a convex fillet tangent to both edges. \"inverted\": a concave cove, a quarter circle centred ON the corner and bitten out of it, meeting both edges square (a shape — unrelated to a dogbone, which is a machining relief applied at toolpath time). \"chamfer\": a straight bevel. Default \"round\"; meaningless without cornerRadii. Ignored where cornerTypes is present.", "default": "round" }, "cornerTypes": { "type": "array", "items": { "enum": ["round", "inverted", "chamfer"] }, "minItems": 4, "maxItems": 4, "description": "How EACH corner is cut, in the order bl, br, tr, tl — parallel to cornerRadii, and the same three treatments cornerType names. Written only when the shaped corners differ (a chamfer beside a fillet); when they agree, the scalar cornerType is written instead, so a rectangle whose corners all match reads and round-trips exactly as it did before mixing was possible. Takes precedence over cornerType when both appear. A square corner still carries a treatment here; it simply draws nothing." }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "polylineEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "points", "closed"], "description": "Point DOF keys: v (vertices), mid_ (segment midpoints, keyed by the segment's START-vertex id). A segment can substitute for a line in line-type constraints via the entity ref \"#\". The id of vertex i is vertexIds[i] when present, else its index as a string (\"0\",\"1\",…) — so files without vertexIds use v0/mid_0/#0 exactly as before. Vertices may be rounded, coved or chamfered via cornerRadii plus a treatment per vertex (cornerType / cornerTypes); the point keys are unaffected, so a shaped polyline is constrained and dimensioned exactly like a sharp one.", "properties": { "type": { "const": "polyline" }, "id": { "type": "string", "minLength": 1 }, "points": { "type": "array", "items": { "$ref": "#/$defs/vec2" }, "minItems": 2 }, "vertexIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Optional stable id per vertex, parallel to points (same length). Decouples a vertex's identity from its position so constraints/dimensions survive edits (chamfer/fillet) that insert or remove vertices ahead of them. Omit to default each id to its index as a string." }, "closed": { "type": "boolean" }, "polygon": { "type": "object", "additionalProperties": false, "description": "Present only for a pristine regular polygon: lets editors regenerate it by side count / across-flats Ø. Dropped once a vertex is hand-edited. radius = circumradius (centre→vertex); rotation = angle (rad) of vertex 0.", "required": ["sides", "center", "radius", "rotation"], "properties": { "sides": { "type": "integer", "minimum": 3, "maximum": 64 }, "center": { "$ref": "#/$defs/vec2" }, "radius": { "type": "number", "exclusiveMinimum": 0 }, "rotation": { "type": "number", "description": "radians" } } }, "cornerRadii": { "type": "object", "description": "Corner size per shaped vertex, mm, keyed by VERTEX ID (the vertexIds entry, not the array index) — the same addressing constraints and dimensions use, so a corner stays on its vertex through any edit that inserts or removes others. Omit, or omit a vertex, for a sharp corner. An open polyline's two end vertices cannot be shaped. The value is what was asked for, not necessarily what is drawn: corners sharing an edge are scaled down to fit it when the outline is built, so pulling a vertex in and back out restores the corner. What the number MEANS follows that vertex's treatment: for \"round\" it is the fillet RADIUS (the arc meets each leg radius/tan(angle/2) back from the vertex); for \"chamfer\" it is the bevel's SETBACK along each leg; for \"inverted\" it is the cove's radius, which is also its setback. Those coincide only at 90°, which is why a rectangle can call all three the same number.", "propertyNames": { "minLength": 1 }, "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } }, "cornerType": { "enum": ["round", "inverted", "chamfer"], "description": "How the shaped vertices are cut when they are all cut the same way — the compact form, written whenever every shaped vertex agrees, and what every file written before vertices could differ carries. \"round\": a convex fillet tangent to both legs. \"inverted\": a concave cove centred ON the vertex, meeting both legs square (a shape — unrelated to a dogbone, which is a machining relief applied at toolpath time). \"chamfer\": a straight bevel. Default \"round\"; meaningless without cornerRadii. Ignored for any vertex named in cornerTypes.", "default": "round" }, "cornerTypes": { "type": "object", "description": "How EACH shaped vertex is cut, keyed by VERTEX ID exactly as cornerRadii is, and holding the same three treatments cornerType names. Written only when the shaped vertices differ (a chamfer beside a fillet); when they agree, the scalar cornerType is written instead, so a polyline whose corners all match round-trips exactly as it did before mixing was possible. A key here without one in cornerRadii names no corner and is ignored; a shaped vertex missing from here falls back to cornerType, then to \"round\".", "propertyNames": { "minLength": 1 }, "additionalProperties": { "enum": ["round", "inverted", "chamfer"] } }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "arcEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "center", "radius", "startAngle", "endAngle"], "description": "CCW from startAngle to endAngle (radians, world Y-up). Point DOF key: c (center); start/end pickable. Scalar DOF keys: r, sa, ea.", "properties": { "type": { "const": "arc" }, "id": { "type": "string", "minLength": 1 }, "center": { "$ref": "#/$defs/vec2" }, "radius": { "type": "number", "exclusiveMinimum": 0 }, "startAngle": { "type": "number", "description": "radians" }, "endAngle": { "type": "number", "description": "radians" }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "bezierEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "p0", "p1", "p2", "p3"], "description": "Cubic Bezier. p0=start, p1=start handle, p2=end handle, p3=end. Point DOF keys: p0, p1, p2, p3 — all four are solver DOFs and all four can be constraint targets. (For a handle that genuinely cannot be constrained, see polybezier.)", "properties": { "type": { "const": "bezier" }, "id": { "type": "string", "minLength": 1 }, "p0": { "$ref": "#/$defs/vec2" }, "p1": { "$ref": "#/$defs/vec2" }, "p2": { "$ref": "#/$defs/vec2" }, "p3": { "$ref": "#/$defs/vec2" }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "textEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "text", "fontId", "sizeMM", "position", "angle"], "description": "Editable text, expanded to glyph contours at CAM export. Line breaks are newlines inside \"text\". position anchors line 0 — its baseline-LEFT for align \"left\", baseline-centre for \"center\", baseline-right for \"right\". angle is CCW radians. Point DOF key: pos. If fontId is not a bundled font, the font's bytes must appear in the top-level \"fonts\" array.", "properties": { "type": { "const": "text" }, "id": { "type": "string", "minLength": 1 }, "text": { "type": "string" }, "fontId": { "type": "string", "description": "e.g. \"roboto-regular\" (bundled), or a \"font-XXXXXXXX\" id present in \"fonts\"." }, "sizeMM": { "type": "number", "exclusiveMinimum": 0 }, "position": { "$ref": "#/$defs/vec2" }, "angle": { "type": "number", "description": "radians" }, "align": { "enum": ["left", "center", "right"], "description": "How the rows of a multi-line block line up. Omitted = \"left\"." }, "lineSpacing": { "type": "number", "exclusiveMinimum": 0, "description": "Row pitch as a multiple of sizeMM. Omitted = 1.2." }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "imageEntity": { "type": "object", "additionalProperties": false, "required": ["type", "id", "imageId", "position", "widthMM", "heightMM", "angle"], "description": "A placed raster image for greyscale laser engraving / mill relief carving. position is the bottom-left anchor; the image fills widthMM × heightMM. Point DOF key: pos. The greyscale pixels referenced by imageId must appear in the top-level \"images\" array. `angle` (CCW radians) rotates the image about its anchor; the engrave/relief sweeps along the image's rotated rows, so a non-zero angle is honoured in both the toolpath and the preview.", "properties": { "type": { "const": "image" }, "id": { "type": "string", "minLength": 1 }, "imageId": { "type": "string", "description": "Id of the pixels in \"images\", e.g. \"img-1a2b3c4d\"." }, "position": { "$ref": "#/$defs/vec2" }, "widthMM": { "type": "number", "exclusiveMinimum": 0 }, "heightMM": { "type": "number", "exclusiveMinimum": 0 }, "angle": { "type": "number", "description": "radians" }, "flipX": { "type": "boolean", "description": "Mirror the image content left↔right. Default false." }, "flipY": { "type": "boolean", "description": "Mirror the image content top↔bottom. Default false." }, "aspectLocked": { "type": "boolean", "description": "Whether the aspect ratio is locked. Default true. Edit-time only: with it on, editing one of width/height writes a proportional value/formula to the other." }, "constraintResize": { "type": "boolean", "description": "Whether geometric constraints and dimensions may RESIZE the image. Default false (omitted), which makes the image a rigid body that constraints merely move — its corners are nonlinear in width/height/angle, so an image left free reflows ambiguously under a single point constraint. With \"aspectLocked\" the two size scalars are ONE degree of freedom, so the ratio is exact rather than converged; without it, width and height are independent. The main use is calibrating a scan: dimension a feature of known size and the whole image scales to suit." }, "constraintRotate": { "type": "boolean", "description": "Whether geometric constraints and dimensions may ROTATE the image (e.g. levelling a tilted scan by making one of its edges horizontal). Default false (omitted). Kept independent of \"constraintResize\" because each freedom lets the solver satisfy the other's constraint the wrong way — a free angle meets a size dimension by tilting, and a free size meets a levelling constraint by shrinking the image away — so grant only the freedom the intent needs." }, "isConstruction": { "type": "boolean" }, "name": { "type": "string" }, "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "fixtureHeight": { "type": "number", "exclusiveMinimum": 0, "description": "Workholding only: this clamp's own height above the stock top, mm, overriding its fixture layer's height so clamps of different heights can share a layer. Omitted = inherit the layer's." }, "layerId": { "type": "string" } } }, "embeddedFont": { "type": "object", "additionalProperties": false, "required": ["id", "name", "format", "data"], "description": "A font embedded in the file, referenced by textEntity.fontId.", "properties": { "id": { "type": "string", "minLength": 1, "description": "Stable content-addressed id, e.g. \"font-1a2b3c4d\"." }, "name": { "type": "string", "description": "Human-readable font name." }, "format": { "enum": ["ttf", "otf", "woff"], "description": "Container format of the embedded bytes." }, "data": { "type": "string", "description": "Base64-encoded font bytes." } } }, "embeddedImage": { "type": "object", "additionalProperties": false, "required": ["id", "name", "width", "height", "data"], "description": "A raster image embedded in the file, referenced by imageEntity.imageId. Stored as a downscaled GREYSCALE buffer (one byte per pixel, row-major, row 0 = top, 0 = black) — colour carries no machining information for a laser. When \"zRangeMM\" is present the bytes are a HEIGHT MAP from an imported STL, not a picture: 255 is the model's top surface (no cut) and 0 its base (full depth).", "properties": { "id": { "type": "string", "minLength": 1, "description": "Stable content-addressed id, e.g. \"img-1a2b3c4d\"." }, "name": { "type": "string", "description": "Human-readable image name." }, "width": { "type": "integer", "minimum": 1, "description": "Pixel width of the greyscale buffer." }, "height": { "type": "integer", "minimum": 1, "description": "Pixel height of the greyscale buffer." }, "data": { "type": "string", "description": "Base64-encoded greyscale bytes (length = width × height)." }, "zRangeMM": { "type": "number", "exclusiveMinimum": 0, "description": "Present only when the buffer is a HEIGHT MAP (an imported STL) rather than a picture: the model's full height range in mm, i.e. the carve depth at true scale. Its presence changes how a relief op reads the bytes — no gamma, no white threshold, encoded tone — because the bytes are lengths, not tones. Omitted for photographs." } } }, "pointRef": { "type": "object", "additionalProperties": false, "required": ["entityId", "key"], "description": "A reference to one named point DOF inside an entity. See each entity's DOF-key list.", "properties": { "entityId": { "type": "string", "minLength": 1 }, "key": { "type": "string", "minLength": 1 } } }, "constraint": { "type": "object", "additionalProperties": false, "required": ["id", "type"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": [ "coincident", "horizontal", "vertical", "parallel", "perpendicular", "equal", "concentric", "pointOnLine", "tangent", "pointOnArc", "pointOnCircle", "symmetric", "collinear", "midpoint", "angle", "fixedPoint", "center", "fixed" ] }, "points": { "type": "array", "items": { "$ref": "#/$defs/pointRef" }, "description": "Point operands. Count/role depends on type (see docs). Optional — defaults to [] when the type uses only entities." }, "entities": { "type": "array", "items": { "type": "string" }, "description": "Entity operands (entity ids, or polyline-segment refs \"#\"). Count/role depends on type. Optional — defaults to [] when the type uses only points." }, "params": { "type": "array", "items": { "type": "number" }, "description": "Type-specific numbers: angle→[radians]; fixedPoint→[x, y]; center→optional [axis] (0=X-only, 1=Y-only; omit for both)." } } }, "dimension": { "type": "object", "additionalProperties": false, "required": ["id", "type", "value", "driving", "offset"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["distance", "horizontal", "vertical", "radius", "diameter", "angle", "arclength", "line-distance", "point-line-distance", "circle-gap", "angle-x", "arc-sweep"] }, "points": { "type": "array", "items": { "$ref": "#/$defs/pointRef" }, "description": "Linear dims: the 2 measured points; point-line-distance: the 1 measured point. Optional — defaults to [] for entity-based dims (radius/diameter/arclength/circle-gap)." }, "entities": { "type": "array", "items": { "type": "string" }, "description": "radius/diameter/arclength: 1 circle or arc id. angle/line-distance: 2 line ids. point-line-distance: 1 line id, with points[0] the measured point. circle-gap: 2 circle/arc ids (the boundary-to-boundary distance, e.g. an inner/outer offset). Optional — defaults to [] for point-based dims." }, "value": { "type": "number", "description": "mm, or radians for type \"angle\"." }, "driving": { "type": "boolean", "description": "When true, also constrains the measurement to value." }, "offset": { "type": "number", "description": "Placement of the dimension line/leader. Visual only." }, "anchors": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[t1, t2] parametric anchors for line-distance extension lines." }, "expr": { "type": "string", "maxLength": 256, "description": "Optional formula driving value, e.g. \"width * 2\"." }, "hidden": { "type": "boolean", "description": "Not drawn. A hidden driving dimension is how a formula typed into a measurement property field (line length, rect W/H) parks in the engine — it drives geometry but shows no annotation." } } }, "variable": { "type": "object", "additionalProperties": false, "required": ["id", "name", "expr", "value"], "properties": { "id": { "type": "string", "minLength": 1 }, "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", "maxLength": 64, "description": "Valid identifier; referenced by expressions. Capped so a generated document cannot carry an unbounded one into anything that renders it." }, "expr": { "type": "string", "maxLength": 256, "description": "Raw input string, e.g. \"100\", \"50mm\", \"3.5in\", or a formula referencing other variables like \"width * 0.1\" (evaluated in dependency order). Capped for the same reason as `name`." }, "value": { "type": "number", "description": "Cached evaluated value in mm." } } }, "scalarBinding": { "type": "object", "additionalProperties": false, "required": ["id", "entityId", "scalarKey", "expr"], "description": "Headless parametric binding: drives one scalar DOF of an entity (by its dofScalars key, e.g. \"r\" for a circle radius) from a variable formula. Draws nothing; contributes a driving residual (currentScalar − expr) to the solver, so it reconciles with dimensions/constraints via the same over-constrained machinery.", "properties": { "id": { "type": "string", "minLength": 1 }, "entityId": { "type": "string", "minLength": 1 }, "scalarKey": { "type": "string", "description": "A dofScalars() key on the entity, e.g. \"r\" (circle/arc radius), \"sa\"/\"ea\" (arc angles), \"w\"/\"h\"/\"angle\" (image width/height/rotation), \"cr\" (rectangle corner radius — one binding drives all four corners, and the entity's own cornerRadii hold the last resolved value)." }, "expr": { "type": "string", "maxLength": 256, "description": "Variable formula, e.g. \"plateW/2\"." }, "scale": { "type": "number", "description": "Multiplier from the formula's display unit to the scalar's internal unit. Omitted/1 for lengths (mm); π/180 for an angle scalar (sa/ea, or image angle) entered in degrees." } } }, "pattern": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "sourceIds", "instanceIds", "params"], "properties": { "id": { "type": "string", "minLength": 1 }, "kind": { "enum": ["linear", "circular", "hex"] }, "sourceIds": { "type": "array", "items": { "type": "string" }, "description": "Original (master) entity ids." }, "instanceIds": { "type": "array", "items": { "type": "array", "items": { "type": "string" } }, "description": "One sub-array per generated step, listing that step's copy entity ids. Those entities must also exist in `entities`." }, "params": { "oneOf": [ { "$ref": "#/$defs/linearPatternParams" }, { "$ref": "#/$defs/circularPatternParams" }, { "$ref": "#/$defs/hexPatternParams" } ] }, "boundaryId": { "type": "string", "minLength": 1, "description": "Optional id of a closed entity the pattern is confined to: copies whose motif does not fit wholly inside it are not generated. Whole copies are dropped, never trimmed, so every instance stays a duplicate of the source. The boundary is not part of the pattern and is never copied." }, "sourceSnapshot": { "type": "number", "description": "FNV-32 hash of source geometry (and of the boundary, when there is one); staleness check. Optional." } } }, "linearPatternParams": { "type": "object", "additionalProperties": false, "required": ["countX", "countY", "spacingX", "spacingY"], "properties": { "countX": { "type": "integer", "minimum": 1, "description": "Resolved cache of countXExpr." }, "countY": { "type": "integer", "minimum": 1, "description": "Resolved cache of countYExpr." }, "spacingX": { "type": "number", "description": "mm" }, "spacingY": { "type": "number", "description": "mm" }, "countXExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving countX, e.g. \"tabs\" or \"n + 1\"; re-evaluated against variables, rounded, clamped ≥1." }, "countYExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving countY." }, "spacingXExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving spacingX." }, "spacingYExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving spacingY." }, "spacingMode": { "enum": ["step", "gap"], "description": "What spacingX/spacingY measure: \"step\" (or absent) = centre to centre; \"gap\" = the clear space between copies, so the step becomes the motif's own size plus the gap and a gap of 0 tiles seamlessly. Resolved against the source geometry at build time, so a gapped pattern stays gapped when the motif is resized." }, "mirrorCols": { "type": "boolean", "description": "Reflect every other column about its own cell centre. Absent = false." }, "mirrorRows": { "type": "boolean", "description": "Reflect every other row about its own cell centre. Absent = false." } } }, "hexPatternParams": { "type": "object", "additionalProperties": false, "required": ["cols", "rows", "spacing", "orientation"], "properties": { "cols": { "type": "integer", "minimum": 1, "description": "Resolved cache of colsExpr." }, "rows": { "type": "integer", "minimum": 1, "description": "Resolved cache of rowsExpr." }, "spacing": { "type": "number", "description": "mm, centre-to-centre between nearest neighbours. The pitch ACROSS the stagger is derived as spacing*√3/2 and is never stored." }, "orientation": { "enum": ["row", "column"], "description": "Which axis staggers by half a pitch." }, "colsExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving cols; rounded, clamped ≥1." }, "rowsExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving rows; rounded, clamped ≥1." }, "spacingExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving spacing." } } }, "circularPatternParams": { "type": "object", "additionalProperties": false, "required": ["count", "cx", "cy", "totalAngle"], "properties": { "count": { "type": "integer", "minimum": 1, "description": "Resolved cache of countExpr." }, "cx": { "type": "number", "description": "rotation centre X, mm" }, "cy": { "type": "number", "description": "rotation centre Y, mm" }, "totalAngle": { "type": "number", "description": "radians; 2π = full circle" }, "countExpr": { "type": "string", "maxLength": 256, "description": "Optional variable expression driving count; re-evaluated against variables, rounded, clamped ≥2." } } }, "leadDef": { "type": "object", "additionalProperties": false, "required": ["type", "length"], "properties": { "type": { "enum": ["none", "linear", "arc"] }, "length": { "type": "number", "description": "mm" } } }, "tabDef": { "type": "object", "additionalProperties": false, "required": ["enabled", "count", "width", "height"], "properties": { "enabled": { "type": "boolean" }, "strategy": { "enum": ["count", "spacing"], "description": "How tab quantity is set: \"count\" (default) places `count` tabs evenly; \"spacing\" places one roughly every `spacing` mm of perimeter (a longer contour gets more)." }, "count": { "type": "integer", "minimum": 0, "description": "Number of tabs, evenly distributed (\"count\" strategy)." }, "spacing": { "type": "number", "exclusiveMinimum": 0, "description": "Perimeter distance (mm) between tabs (\"spacing\" strategy)." }, "width": { "type": "number", "description": "arc-length of each tab, mm" }, "height": { "type": "number", "description": "material left standing above the floor, mm" } } }, "perforateDef": { "type": "object", "additionalProperties": false, "required": ["enabled", "count", "gapWidth"], "description": "Laser profile (cut) only: leave short beam-off gaps along the cut instead of severing it continuously — dashed/perforated lines for a tear-away sheet, or small uncut bridges holding a freed piece in place. The laser analogue of tabDef; unlike a mill tab's width, gapWidth needs no cutter-radius correction (a beam has no radius to sweep into the gap).", "properties": { "enabled": { "type": "boolean" }, "strategy": { "enum": ["count", "spacing"], "description": "How gap quantity is set: \"count\" (default) places `count` gaps evenly; \"spacing\" places one roughly every `spacing` mm of perimeter (a longer contour gets more)." }, "count": { "type": "integer", "minimum": 0, "description": "Number of gaps, evenly distributed (\"count\" strategy)." }, "spacing": { "type": "number", "exclusiveMinimum": 0, "description": "Perimeter distance (mm) between gaps (\"spacing\" strategy)." }, "gapWidth": { "type": "number", "description": "arc-length of each beam-off gap, mm" } } }, "regionRef": { "type": "object", "additionalProperties": false, "required": ["containingLoops"], "description": "A pocket target region, identified by the loops that enclose it (each loop = the entity ids whose geometry forms it). Resolved against live geometry at toolpath time so it survives constraint-driven reflow.", "properties": { "containingLoops": { "type": "array", "items": { "type": "array", "items": { "type": "string" } }, "description": "Entity-id sets of the loops enclosing the region; one inner array per loop. The face lies inside exactly these loops and outside all others. An entity that forms SEVERAL disjoint loops (text: one contour per letter, plus a counter per enclosed island) names one of them with a \"#\" suffix carrying that contour's own geometry as centroidX:centroidY:area, in the text's local frame divided by its height, e.g. \"ent7#0.932:0.264:0.097\". It is not an ordinal: moving, rotating, resizing the text or editing a later character keeps the region, while editing an earlier character makes it stop resolving (skipped with a note) rather than silently re-pointing at a different glyph. A bare id is the whole entity's single loop." } } }, "flip": { "type": "object", "additionalProperties": false, "required": ["axis", "registration", "pinDiameter", "pinDepth", "pins"], "description": "Double-sided machining setup. Operations carry a \"face\"; the top ops cut as drawn, then the stock is flipped and the bottom ops cut from a program mirrored about the flip axis. Mill-only.", "properties": { "axis": { "enum": ["h", "v"], "description": "The mirror the flip performs. \"h\" = flip left ↔ right (mirror X about the stock's vertical centreline); \"v\" = flip near ↔ far (mirror Y about the horizontal centreline)." }, "registration": { "enum": ["pins", "none"], "description": "How the flipped stock is realigned. \"pins\" bores dowel holes through the stock into the spoilboard at the end of the top-side program; \"none\" leaves it to the operator (e.g. a fixed fence)." }, "pinDiameter": { "type": "number", "exclusiveMinimum": 0, "description": "Dowel-pin (hole) diameter, mm." }, "pinDepth": { "type": "number", "minimum": 0, "description": "How far past the stock bottom the pin holes bore into the spoilboard, mm." }, "pins": { "type": "array", "items": { "$ref": "#/$defs/vec2" }, "description": "Registration hole centres, in world/canvas mm. Must be invariant under the mirror for the flip to align (each pin has a mirror-image partner or sits on the flip-axis centreline)." } } }, "rotary": { "type": "object", "additionalProperties": false, "required": ["diameter", "wrapAxis"], "description": "Cylindrical/rotary setup: one work axis stays linear (the cylinder length), the perpendicular one runs around the circumference. How that wrapped axis is posted depends on the head (see machineKind) — a mill emits it as a rotary word in degrees; a laser substitutes it on its ordinary linear word in surface millimetres. Not combinable with flip.", "properties": { "diameter": { "type": "number", "exclusiveMinimum": 0, "description": "Cylinder stock diameter, mm. 360° of rotation = π·diameter of surface travel." }, "wrapAxis": { "enum": ["x", "y"], "description": "Which work axis rolls around the cylinder. \"y\" (default): Y wraps to rotation, X runs along the length. \"x\": swapped." }, "zero": { "enum": ["surface", "center"], "description": "Where Z0 sits. \"surface\" (default): Z0 = top of the cylinder surface, cuts negative; gSender needs its \"Visualize non-center zeros\" toggle to visualize it. \"center\": Z0 = rotary axis (centreline), surface at Z = radius — the native rotary convention gSender visualizes with no toggle. Omitted = \"surface\"." } } }, "operation": { "type": "object", "additionalProperties": false, "required": [ "id", "name", "type", "entityIds", "toolType", "toolNumber", "diameter", "feedrate", "plungeRate", "spindleSpeed", "safeZ", "depth" ], "allOf": [ { "if": { "properties": { "type": { "const": "profile" } }, "required": ["type"] }, "then": { "required": ["side"] } } ], "properties": { "id": { "type": "string", "minLength": 1 }, "name": { "type": "string" }, "type": { "enum": ["profile", "engrave", "drill", "pocket", "chamfer", "vcarve", "inlay", "relief-rough", "score", "face"] }, "entityIds": { "type": "array", "items": { "type": "string" }, "description": "Target geometry ids." }, "side": { "enum": ["outside", "inside"], "description": "Which side of the contour the cut runs on. Required for \"profile\" (and drives laser kerf offset); ignored — and may be omitted — for every other type." }, "followPattern": { "type": "boolean", "description": "When the op's geometry belongs to a pattern, the toolpath covers the whole pattern and follows its count. Set false to cut only the literal entityIds. Default (omitted) = follow." }, "face": { "enum": ["top", "bottom"], "description": "Double-sided machining: which face of the stock this op cuts. \"bottom\" ops are machined after the stock is flipped, with geometry mirrored about the flip axis. Absent/\"top\" = the first setup, cut as drawn. Ignored unless the document has \"flip\" set." }, "toolId": { "type": "string", "description": "Optional reference to a tool id in the top-level \"tools\" array. When it resolves, that tool's geometry/feeds override the inline toolType/diameter/vAngle/tipAngle/feedrate/plungeRate/spindleSpeed/safeZ fields below (which remain as a fallback). toolNumber and the cut settings (depth/stepdown/stepover) always stay per-operation." }, "toolType": { "enum": ["end-mill", "ball-nose", "v-bit", "drill", "tapered-ball-nose"] }, "toolNumber": { "type": "integer", "minimum": 1, "description": "T-number (1-based)." }, "diameter": { "type": "number", "exclusiveMinimum": 0, "description": "Tool diameter, mm." }, "vAngle": { "type": "number", "description": "V-bit / tapered ball-nose included angle, degrees." }, "tipDiameter": { "type": "number", "description": "V-bit flat tip / tapered ball-nose ball tip diameter, mm." }, "tipAngle": { "type": "number", "description": "Drill tip angle, degrees." }, "feedrate": { "type": "number", "exclusiveMinimum": 0, "description": "mm/min" }, "plungeRate": { "type": "number", "exclusiveMinimum": 0, "description": "mm/min" }, "spindleSpeed": { "type": "number", "minimum": 0, "description": "rpm" }, "safeZ": { "type": "number", "description": "mm above work surface" }, "depth": { "type": "number", "description": "mm below surface; NEGATIVE for cuts." }, "startDepth": { "type": "number", "maximum": 0, "description": "Where the cut BEGINS, mm below the stock surface; NEGATIVE, default 0 (the surface). Measured from the stock top exactly as `depth` is, so the operation removes the slab between the two: startDepth -3 with depth -6 cuts from 3mm down to 6mm down. Use it to machine into a floor an earlier operation already cleared (lettering on a pocket floor) instead of wasting the cut on air above it. Must be shallower than `depth`, or there is nothing to cut. Honoured by profile, pocket, engrave, score, face, drill, chamfer and vcarve; ignored by inlay (which uses the same field internally for its glue gap) and relief-rough, and by any laser operation." }, "stepdown": { "type": "number", "exclusiveMinimum": 0, "description": "mm per depth pass (ignored for drill). Optional; defaults to 1.5 when omitted." }, "peckDepth": { "type": "number", "minimum": 0, "description": "Drill only: peck increment (mm). Drills the hole in steps, fully retracting between pecks (G83-style). Omitted/0 = single full-depth plunge." }, "finishPass": { "type": "boolean", "description": "Profile/pocket: leave a thin radial skin during roughing and remove it in a final full-depth wall pass, cleaning the ridges between depth levels. Default false." }, "rampAngle": { "type": "number", "description": "Entry ramp angle (degrees off horizontal) for ops that descend gradually (pocket helix/linear entry, relief-rough ramp). Overrides each mechanism's built-in default; clamped 0.5–45°. Omitted = the default." }, "cutDirection": { "enum": ["climb", "conventional"], "description": "Profile only: cut direction relative to an M3 (CW) spindle. \"climb\" (CW outside / CCW inside) finishes cleanly on rigid machines; \"conventional\" is the reverse. Omitted = leave the raw offset winding untouched." }, "cornerStyle": { "enum": ["none", "dogbone", "tbone"], "description": "Corner relief. \"dogbone\" adds a diagonal overcut at each concave corner so a mating square part seats. \"tbone\" adds a parallel overcut extending the longer wall. Default \"none\"." }, "finishAllowance": { "type": "number", "minimum": 0, "description": "Stock (mm) left for a finishing pass. For profile/pocket: radial wall stock left during roughing, used only when finishPass is true (clamped below the tool radius). For relief-rough: the depth of material left above the final relief surface for the ball-nose finish pass to remove. Default 0.2." }, "chamferWidth": { "type": "number", "minimum": 0, "description": "Chamfer only: horizontal width (mm) of the bevel face. The plunge depth is derived from this and the V-bit angle (depth = width / tan(½·vAngle)). Requires a v-bit tool." }, "chamferSide": { "enum": ["on", "outside", "inside"], "description": "Chamfer only: which side of the contour the bevel sits on (\"on\" = centred on the edge). Default \"on\"." }, "sharpenCorners": { "type": "boolean", "description": "Chamfer only: pull the V-bit tip up into each sharp inside (convex) corner — the bevel tapers to the surface at the corner vertex — so corners come to a crisp point instead of a rounded fillet. Concave corners are left rounded (a V-bit can't reach them). Default false." }, "vStep": { "type": "number", "exclusiveMinimum": 0, "description": "V-carve only: radial inset (mm) between offset-peel passes — the pitch that sets floor smoothness. Requires a v-bit tool; uses vAngle for the slope and |depth| as the max (floor) depth. Default 0.4." }, "vHopClearance": { "type": "number", "minimum": 0, "description": "V-carve only: clearance height (mm above the stock surface) for rapid hops between a region's contours. > 0 hops at this low height instead of retracting to safeZ between every peel ring (less air time). Omitted/0 = retract to safeZ — the safe default, since a low hop does not clear a clamp/fixture standing above the stock within the carve footprint." }, "pocketDepth": { "type": "number", "exclusiveMinimum": 0, "description": "V-carve inlay only: the female pocket's flat floor depth (mm). The male plug runs this deep plus sawAllowance. Default 3." }, "glueGap": { "type": "number", "minimum": 0, "description": "V-carve inlay only: clearance for glue (mm). Sinks the male's V profile so the plug is narrower by glueGap · tan(½·vAngle) at every depth; the void at the apex is where the glue goes. Default 0.25." }, "sawAllowance": { "type": "number", "minimum": 0, "description": "V-carve inlay only: extra depth on the male (mm), so the plug stands proud of the pocket floor with stock to saw and plane flush after gluing. Default 1.5." }, "inlayMargin": { "type": "number", "minimum": 0, "description": "V-carve inlay only: margin (mm) around the design for the male's auto-generated boundary rectangle. Ignored when the design already encloses itself. Default 10." }, "coolant": { "enum": ["off", "mist", "flood"], "description": "Coolant for this operation: \"mist\" → M7, \"flood\" → M8 (M9 off). Default \"off\". Only emitted if the machine is flagged as having coolant (a machine-wide app preference)." }, "stepover": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "description": "fraction of tool diameter (pocket/clearing). Optional; defaults to 0.4 when omitted." }, "pocketStrategy": { "enum": ["offset", "adaptive", "raster"], "description": "Pocket only; default \"offset\". \"adaptive\" trochoids wherever a plain offset loop would bury the cutter deeper than a straight wall step." }, "restToolDiameter": { "type": "number", "minimum": 0, "description": "Pocket and relief-rough only. Rest machining: diameter (mm) of the tool that already roughed this. When larger than this operation's own tool, only what that tool could not reach is cut — the corner radii of a pocket, or the valleys of a relief a big cutter cannot drop into (cells holding more than one stepdown of leftover). The previous tool is assumed to be a flat end mill. 0 or absent = off." }, "faceTarget": { "enum": ["stock", "bed"], "description": "Facing only. What gets skimmed: the blank (default) or the machine spoilboard. Spoilboard surfacing is zeroed on the board with the machine empty, so it must be posted as its own program." }, "faceOverhang": { "type": "number", "minimum": 0, "description": "Facing only. Extra distance (mm) the tool centre runs past the target edge, on top of the tool radius it already overhangs by." }, "faceDirection": { "enum": ["x", "y"], "description": "Facing only. Which way the rows run; default \"x\"." }, "islandIds": { "type": "array", "items": { "type": "string" }, "description": "Pocket only (legacy): entities treated as islands." }, "regions": { "type": "array", "items": { "$ref": "#/$defs/regionRef" }, "description": "Pocket, v-carve and v-carve inlay: the enclosed regions to clear, identified parametrically (by the loops that enclose each) so they reflow with the model. Resolved against live geometry at toolpath time. Supersedes the older absolute-coordinate seeds." }, "tabs": { "$ref": "#/$defs/tabDef" }, "leadIn": { "$ref": "#/$defs/leadDef" }, "leadOut": { "$ref": "#/$defs/leadDef" }, "laserPower": { "type": "number", "minimum": 0, "maximum": 100, "description": "Laser only (machineKind \"laser\"): beam power as a percentage (0–100) of the controller's max (GRBL $30), scaled to an S word. Default 80." }, "laserPasses": { "type": "integer", "minimum": 1, "description": "Laser only: number of times the beam re-traces each path (the fixed-Z analogue of milling stepdown). Default 1." }, "laserOverride": { "type": "boolean", "description": "Laser only: cut with this operation's own beam settings, ignoring any \"laser\" recipe on the layer its geometry sits on. Omitted/false = follow the layer when it has one. Set on operations whose numbers ARE the point, such as the cells of a material-test grid." }, "kerfWidth": { "type": "number", "minimum": 0, "description": "Laser profile only: beam kerf width (mm). Closed profiles are offset by half this — outward for side \"outside\", inward for \"inside\". 0 = cut on the line. Engrave ignores it." }, "perforate": { "$ref": "#/$defs/perforateDef" }, "laserFill": { "type": "boolean", "description": "Laser engrave only: flood the interior of closed shapes with parallel scan lines (area/solid engraving) in addition to outlining them. Closed contours are grouped even–odd so letter counters stay unfilled. Default false." }, "laserFillSpacing": { "type": "number", "exclusiveMinimum": 0, "description": "Laser fill only: spacing (mm) between scan lines — roughly the beam/line width. Default 0.2." }, "laserOverscan": { "type": "number", "minimum": 0, "description": "Laser fill / raster only: overscan 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": { "type": "boolean", "description": "Laser only: enable air assist for this operation (the post's air command, M8/M9 by default), kept on across consecutive ops that request it and off otherwise. Default false." }, "rasterLineInterval": { "type": "number", "exclusiveMinimum": 0, "description": "Image-engrave only (an Engrave op targeting an image entity — a LASER raster or a MILL relief): vertical pitch (mm) between scan rows (the line interval for a laser, the stepover for a mill). Laser default 0.1; mill relief default 0.3." }, "rasterDotPitch": { "type": "number", "minimum": 0, "description": "Image-engrave only: horizontal pitch (mm) between dots within a row. Omitted = square dots (uses rasterLineInterval)." }, "rasterMinPower": { "type": "number", "minimum": 0, "maximum": 100, "description": "Laser raster engrave only: beam power (%) for the lightest engraved dot (laserPower is the power for a fully black dot). Default 0." }, "rasterInvert": { "type": "boolean", "description": "Image-engrave / relief-rough only: engrave/carve the light areas instead of the dark (negative). Default false. Should match between a relief roughing op and its finish op." }, "rasterDither": { "enum": ["none", "floyd-steinberg", "atkinson", "jarvis", "ordered"], "description": "Laser raster engrave only: reproduce tone as a 1-bit dot pattern instead of per-dot power modulation. Every fired dot burns at laserPower and tone comes from dot density; rasterMinPower is then unused. floyd-steinberg/atkinson/jarvis are error-diffusion kernels (FS = classic, Jarvis = smoother/wider, Atkinson = higher contrast); ordered = 8x8 Bayer pattern. Omitted or 'none' = greyscale power (default)." }, "reliefGamma": { "type": "number", "exclusiveMinimum": 0, "description": "Mill relief only (engrave-on-image or relief-rough): tone curve on carved depth — depth proportional to darkness^gamma. 1 = linear (default). >1 lifts the mid-tones (flatter, shallower background), <1 deepens them. Black/white endpoints are fixed. Should match between a relief roughing op and its finish op." }, "halftone": { "type": "boolean", "description": "Mill relief finish only (an Engrave op on an image), V-bit only: reproduce the image as a halftone SCREEN of parallel V-grooves whose WIDTH carries the tone (the PhotoVCarve look) instead of as a carved 2.5-D surface. Row spacing is then DERIVED from the bit's included angle, its flat tip and |depth| so the darkest grooves just meet — rasterLineInterval is ignored — and the along-row pitch stays fine (0.1mm, or rasterDotPitch when set) so the grooves keep the photograph's detail. Default false." }, "reliefSteepPass": { "type": "boolean", "description": "Mill relief finish only (an Engrave op on an image): add constant-Z contouring passes on the steep areas and leave those areas out of the raster, instead of rastering the whole relief. A raster stepping across a near-vertical wall leaves ridges spaced by how far the wall climbs in one stepover, so the cusp the stepover was chosen for is not what the wall gets; where the surface is steeper than 45 degrees, contours land closer together and finish it properly. Nothing to size: the contour Z spacing and the 45 degree split are both derived from rasterLineInterval. Default false, and off the emitted program is byte-identical. Ignored when halftone is set." }, "reliefWhiteThreshold": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "description": "Mill relief only, photo/greyscale image (not an STL heightfield): greyscale value at/above which a dot is background and left at the surface (level 0). Default 0.96. An STL heightfield ignores it and keeps the height-map threshold, because a tone threshold on real heights would flatten the top of the model." }, "reliefBoundaryIds": { "type": "array", "items": { "type": "string" }, "description": "Mill relief only: entity ids of closed loops enclosing the carve region. When present, the relief raster (rough and finish) is clipped to inside these loops — cells outside are left uncut. Absent/empty = the whole image rectangle." }, "reliefBoundaryOffset": { "type": "number", "description": "Mill relief only: distance (mm) to expand reliefBoundaryIds outward before clipping, so the tool's flank reaches the drawn edge. Positive = carve past the loop, negative = inset. Default 0. Ignored without reliefBoundaryIds." }, "halftoneLand": { "type": "number", "minimum": 0, "description": "V-carve halftone only: surface left standing between grooves in the BLACKEST area, mm. 0 (default) makes the darkest tone solid; a positive value trades peak blackness for a visible line texture and a shorter cut. Never negative — overlapping the darkest grooves saturates the tone before full depth." }, "paramExprs": { "type": "object", "maxProperties": 128, "propertyNames": { "minLength": 1, "maxLength": 64 }, "additionalProperties": { "type": "string", "maxLength": 256 }, "description": "Optional expression per numeric op field, keyed by field name (e.g. {\"depth\": \"-stock\", \"feedrate\": \"baseFeed * 1.2\"}). Evaluated against document `variables` and `stock` (the stock thickness) before every solve, then clamped to the field's valid range; bare numbers in an expression are internal mm, matching dimension and feature formulas. The sibling numeric field holds the last resolved value as cache/fallback for fields with no expression, or whose expression fails to evaluate. Keys for nested fields use either the flat or dotted form (\"tabCount\"/\"tabs.count\", \"leadInLen\"/\"leadIn.length\"); a nested expression is skipped while its parent object is absent." } } }, "tool": { "type": "object", "additionalProperties": false, "required": ["id", "name", "toolType", "diameter", "feedrate", "plungeRate", "spindleSpeed", "safeZ"], "description": "A reusable tool definition referenced by operations via toolId.", "properties": { "id": { "type": "string", "minLength": 1, "description": "Unique tool id; the target of operation.toolId." }, "name": { "type": "string", "description": "Human-readable tool name." }, "number": { "type": "integer", "minimum": 1, "description": "T-number this tool is loaded as: the posted T-word, which indexes the controller's tool table (length/diameter offsets) and, on an ATC machine, the pocket. Optional — absent means no slot assigned yet. Seeds operation.toolNumber when the tool is loaded into an operation; the operation owns it from there, so operation.toolNumber stays authoritative for what is posted, and two tools sharing a number is a pre-flight error." }, "unit": { "enum": ["mm", "in"], "description": "The unit this tool is conventionally entered and displayed in (metric vs imperial). Display/entry hint only — diameter/tipDiameter/feedrate/plungeRate/safeZ are always stored in mm." }, "toolType": { "enum": ["end-mill", "ball-nose", "v-bit", "drill", "tapered-ball-nose"] }, "diameter": { "type": "number", "exclusiveMinimum": 0, "description": "Tool diameter, mm." }, "vAngle": { "type": "number", "description": "V-bit / tapered ball-nose included angle, degrees." }, "tipDiameter": { "type": "number", "description": "V-bit flat tip / tapered ball-nose ball tip diameter, mm." }, "tipAngle": { "type": "number", "description": "Drill tip angle, degrees." }, "feedrate": { "type": "number", "exclusiveMinimum": 0, "description": "mm/min" }, "plungeRate": { "type": "number", "exclusiveMinimum": 0, "description": "mm/min" }, "spindleSpeed": { "type": "number", "minimum": 0, "description": "rpm" }, "safeZ": { "type": "number", "description": "mm above work surface" } } } } } ``` ============================================================================== SECTION: AI integration guide Canonical URL: https://rcamhub.com/docs/ai-integration.md ============================================================================== # RapidCAM AI integration RapidCAM is built so AI tools — chat assistants and autonomous agents alike — can author, check, and iterate on real, machinable designs. This guide covers every integration surface. It is published at **`https://rcamhub.com/docs/ai-integration.md`**; the file-format contract it builds on lives in the [.rcam format guide](rcam-format-v3.md). The design principle behind all of it: **optimize the AI's second attempt, not its first.** A language model authoring CAD blind will make mistakes; what makes the workflow reliable is a tight feedback loop — validate, solve, dry-run the toolpaths, render — with errors reported in a form the AI can act on. Every surface below closes that loop at a different level. --- ## The AI Assistant (in the app) **File ▸ AI Assistant** is the copy/paste loop for people using any AI chat (Claude, ChatGPT, Gemini, …). No API keys, no accounts — the human is the transport. **Step 1 — Copy a prompt.** The dialog builds a self-contained prompt and copies it to the clipboard. It bundles: - your machine context: machine kind, post-processor, sheet size, stock size and thickness, origin, rotary/flip setup — everything the AI must respect and would otherwise guess; - your tool library, so operations reference real tools by `toolId`; - the **complete .rcam format guide**, embedded, so it works even for models without web access; - your request (typed into the dialog, or left as a placeholder to fill in chat). Two modes: **New design** authors from scratch for your machine; **Modify current design** embeds the current document (minus font/image payloads) and asks for the complete updated file back. **Step 2 — Paste the reply.** Paste the AI's answer — code fences and surrounding prose are stripped automatically — and click **Check & Import**. The paste runs the full checking pipeline (see below). On success the design loads; the import is **undoable** (Ctrl+Z restores your previous drawing), so a wrong modification costs nothing. On failure, **Copy Error Report for AI** puts a markdown fix-it report on the clipboard; paste it back into the chat and the AI corrects its own file. ### What the checker verifies Every pasted file passes through, in order: | Check | Catches | |-------|---------| | JSON | malformed output, truncated replies | | Schema | wrong/missing fields, bad enum values (violations name the allowed values) | | Loader | anything RapidCAM itself cannot open | | References | operations/constraints/dimensions/bindings pointing at entities that don't exist; unknown `toolId`s | | Solver | contradictory constraint systems that don't converge; geometry stored away from where its own driving dimensions put it (a warning — the file opens, then visibly moves) | | Bounds | geometry outside the sheet | | Toolpath dry-run | operations that would silently produce **no cutting moves** (e.g. a profile over geometry that doesn't close) | | Lint | Apollo pre-flight findings over the dry-run program(s) — toolpaths outside the stock, over-deep cuts, fast plunges, missing tool-change pauses — reported as **warnings**, so machinability advice never blocks an import | The dry-run matters most: it is entirely possible to write a file that is schema-valid, loads, and solves — and cuts air. The dry-run generates the actual machine program(s) — routed by machine kind exactly as export does (mill, laser, rotary wrap, double-sided flip) — and surfaces every skip the generator would emit, then pre-flights the result so the import check says what the export lint would say. The same pipeline also reviews every `.rcam` opened from disk (**File ▸ Open**): the file opens immediately, then a dialog lists any issues found, with the same one-click **Copy AI fix-it report**. Clean files open in silence — so an AI-authored file reaching the app by any route gets checked. --- ## Stable URLs for web-connected AIs Everything an AI needs to author `.rcam` files is served at stable URLs, indexed by [`/llms.txt`](https://rcamhub.com/llms.txt): | URL | Contents | |-----|----------| | `https://rcamhub.com/llms.txt` | index of all of the below | | `https://rcamhub.com/llms-full.txt` | **single-fetch bundle**: the index, format guide, JSON Schema, and this guide inlined in one file — for fetch tools that cannot follow links out of a fetched page | | `https://rcamhub.com/docs/rcam-format-v3.md` | the authoring guide: entity/constraint/dimension vocabulary, CAM operations, gotchas | | `https://rcamhub.com/schema/rcam-v3.schema.json` | machine-readable JSON Schema (draft 2020-12; this URL is its `$id`) | | `https://rcamhub.com/docs/ai-integration.md` | this document | | `https://rcamhub.com/examples/index.json` | list of bundled golden examples | | `https://rcamhub.com/examples/.rcam` | any listed example (all schema-validated on every commit) | --- ## Headless CLI For scripts and agents working in a RapidCAM checkout (`git clone` + `npm install`), the same pipeline runs in Node with no browser window: ```bash npm run cli -- validate part.rcam # the full checking pipeline above npm run cli -- post part.rcam -o out/ # G-code (.nc) + Apollo pre-flight lint npm run cli -- render part.rcam -o part.png # PNG of the design via headless Chromium npm run cli -- open part.rcam # validate, then open in the user's browser npm run cli -- decode "" # share link → .rcam JSON (quote the URL!) ``` - **`validate`** exits 0 on success (warnings allowed), 1 when any check fails, printing the same fix-it report the dialog produces. - **`post`** routes by machine kind exactly as the app's export button does — mill, laser, rotary wrap (one wrapped program), double-sided flip (side A + side B) — writes the `.nc` file(s), and runs the Apollo pre-flight lint (bounds, over-deep, rapid-through-stock, fast plunge, fixture collisions, missing tool-change pauses) on each. Exits 1 if any lint **error** is found (files are still written). - **`render`** boots the real app on an ephemeral dev server and screenshots the drawing canvas — geometry, dimensions, and stock outline exactly as RapidCAM draws them. First call pays a ~10 s browser boot. - **`open`** is the hand-off: it validates (refusing on errors), encodes the design into a share-link URL — the `#d=` fragment format, so the design never touches a server — and launches the default browser at it. RapidCAM opens with the design loaded as a fresh document. `--url http://localhost:5173/` targets a dev server instead of rapidcam.app. Designs too large for a URL (typically image-bearing reliefs) are refused with a save-the-file hint. - **`decode`** is `open` in reverse: it turns a share link — the user copies one with **File ▸ Copy Share Link** — back into pretty-printed `.rcam` JSON (to stdout, or `-o out.rcam`). That makes "modify my current design" a loop: decode the user's link, edit, `validate`, hand back with `open`. Quote the URL so the shell doesn't eat the `#…` fragment. --- ## MCP server The [Model Context Protocol](https://modelcontextprotocol.io) server gives MCP clients — Claude Code, Claude Desktop, and others — the full **author → validate → post → look at a render → open in the user's browser** loop as tools. From a RapidCAM checkout: ```bash claude mcp add rapidcam -- npx tsx mcp/server.ts # Claude Code npm run mcp # or run it directly (stdio) ``` | Tool | Purpose | |------|---------| | `get_format_guide` | the complete .rcam authoring guide (read before authoring) | | `list_examples` / `get_example` | bundled golden example projects | | `validate_rcam` | the full checking pipeline; returns a fix-it report | | `post_gcode` | machine program(s) + Apollo pre-flight lint findings | | `render_preview` | a PNG **image** of the design — the agent can look at what it made and catch geometry that validates but is wrong | | `read_share_link` | the reverse hand-off: decode a share link (**File ▸ Copy Share Link**) back into `.rcam` JSON, so the agent can read the user's current design | | `open_in_app` | the hand-off: validate, then open the design in the **user's** browser via a share-link URL (client-side only; refuses invalid designs) | `render_preview` boots a headless browser on first call (~10 s) and reuses it afterwards. An effective agent loop: `get_format_guide` → author → `validate_rcam` → fix until clean → `render_preview` → eyeball → `post_gcode` → review lint → `open_in_app` to hand the finished design to the user. To **modify** the user's current design, start the loop with their share link: ask for **File ▸ Copy Share Link**, read it with `read_share_link`, then author the changes and hand back as above. --- ## Tips for LLM authors The [format guide](rcam-format-v3.md) is the contract; these are the highest-leverage habits: - **All lengths are millimetres and all angles radians (CCW), always** — `displayUnit` only changes what the human sees. The world frame is Y-up. - **You don't need perfect coordinates.** Emit rough positions plus constraints and driving dimensions; the parametric solver snaps geometry exact. For simple parts, plain coordinates with no constraints are equally valid — don't add constraint systems you don't need. - **Omit what doesn't apply.** `side` is required only on `profile` operations; `stepdown`/`stepover` are optional with sensible defaults. A drill needs none of them. - **`depth` is negative for cuts** (mm below the stock surface). A through cut goes slightly past the stock thickness. - **Built-in keywords are available in any expression.** `stock` (or `$stock`) is the material thickness in mm — use `"expr": "stock"` or `"expr": "stock + 0.1"` for a parametric depth (e.g. a box joint) instead of a hardcoded number. The full set is in the format guide's Variables section: `stock_width`/`stock_height` (the blank), `sheet_width`/`sheet_height` (the work area), `origin_x`/`origin_y`/ `origin_z` (the WCS datum), `pi`/`e`, `counter`/`serial`/`seq`, and — on a rotary job — `stock_diameter`/`stock_length`/`stock_circumference`/`stock_wall`. A user-defined variable of the same name overrides a built-in. - **T-Bones and Dog-bones are supported.** Use `"cornerStyle": "tbone"` or `"dogbone"` on `profile` and `pocket` operations if the tool needs to reach sharp inside corners for mating parts. - **Closed shapes can be composite.** A rounded rectangle authored as 4 lines + 4 tangent fillet arcs profiles as one closed loop — endpoints just have to meet. - **For a laser design, describe the LAYERS, not the operations.** Put a `laser` recipe on each layer — `{kind, feedrate, laserPower, laserPasses}` — and you may leave `operations` empty: the user presses **Toolpaths from Layers** and gets one correctly-typed operation per layer, in layer order. You cannot get the entity lists, the kerf direction or the cut order wrong that way, and `kind` (`cut`/`score`/`engrave`/`fill`) is far easier to get right than an operation `type` plus its flags. Power and speed on a layer are live, so the user can re-tune after a test cut without rebuilding. - **Never author `__origin__`**, and never include `fonts`/`images` arrays; keep existing `fontId`/`imageId` references intact when modifying. - **Expect a report, not applause.** When the user (or agent harness) returns a "RapidCAM import report", fix every listed issue and reply with the complete corrected file as a single JSON code block — never a diff. ============================================================================== SECTION: Version control guide Canonical URL: https://rcamhub.com/docs/version-control.md ============================================================================== # Keeping designs in git A `.rcam` file is plain, pretty-printed JSON, so git can store and diff it without help. Merging is the part that needs help, and this is how to set it up. ## Why the default merge is not enough Git merges by line. A design is not lines — it is a set of objects that happen to be written down in an order. Two people each adding a shape both append to the end of the `entities` array, so both write to the same lines, and git conflicts **every time**, on edits that have nothing to do with each other. Measured on a real design before the merge driver existed: | what the two branches did | plain `git merge` | | --- | --- | | each added a different entity | **conflict** | | each edited a different entity | clean | | each edited a different field of one entity | clean | | each edited 40-circle patterns at adjacent indices | clean | The conflicts are the wrong ones, and the clean merges are luck: nothing guarantees that two hunks of coordinates git spliced together describe a shape either person drew. ## Installing the merge driver RapidCAM ships a three-way merge that works on objects instead of lines. It needs [Node](https://nodejs.org) and nothing else — download one file, then run three commands in the repository holding your designs: ```sh curl -O https://rcamhub.com/rcam-merge.mjs git config merge.rcam.name "RapidCAM .rcam merge" git config merge.rcam.driver "node /full/path/to/rcam-merge.mjs %O %A %B" echo "*.rcam merge=rcam" >> .gitattributes ``` Use an absolute path: git runs the driver from the top of the repository, not from wherever you were standing. Commit the `.gitattributes` file — it travels with the repository, so everyone who clones it gets the same behaviour once they have run the two `git config` lines. (Git deliberately does not let a repository configure a driver command for you: that would be arbitrary code execution on clone.) With it installed, all four rows above merge cleanly and correctly. ### The fuller version If you have this repository checked out, `rapidcam merge` does the same merge and then runs the whole `validate` pass on the result — dangling references, whether the constraint system still solves, whether the toolpaths still cut anything: ```sh git config merge.rcam.driver "npx tsx /path/to/rcam/cli/rapidcam.ts merge %O %A %B" ``` The standalone file deliberately leaves those out. They need the document model, the solver, the CAM stack and the bundled font outlines, and without the fonts a design dimensioned to the width of a word measures a *different* width — the checks would report confidently wrong numbers, which is worse than not running them. Run `rapidcam validate` on the result when you want them. ## What still conflicts, and why that is right The driver disagrees only when the two sides actually set the **same field** to **different values**: - both moved the same corner of the same rectangle - one deleted a shape the other edited (kept, not dropped — deleting it again is one click, redrawing it is not) - both minted the same id for different objects, which should be impossible now that ids carry a timestamp and a random suffix It also reports a shape that has fallen out of the job. Lists like a toolpath's geometry, a block's members and a pattern's instances are merged whole, so when both sides change one, yours wins and theirs' additions come loose — while the shapes themselves, having unique ids, are kept. The list conflict alone would say only "entityIds changed on both sides", and the expensive half is the rest: - a shape the other branch added to a toolpath is still **drawn but no longer cut**, which is the quiet half of a wrong part; - a shape added to a rigid block comes loose and stops moving with it; - two people regenerating one pattern keep **both** sets of instances, leaving the losing set as loose copies sitting exactly where the pattern is. Deliberately ungrouping something is not reported — coming loose is what was asked for. On a conflict the driver still writes a **complete, loadable design**, keeping your side wherever the two disagreed, and exits non-zero so git marks the file unmerged. Open it, fix the handful of places it names, and commit. There are no conflict markers in the file, because a `.rcam` full of `<<<<<<<` would not parse and could not be opened at all. Each conflict names the shape, so you can find it on the canvas; both values, so you can choose between them; and the exact address, so you can search the file for it. ``` ✗ Circle "mounting hole" ⌀10.00 mm at (120.0, 60.0) mm changed on both sides, differently — kept ours yours 5.00 mm · theirs 6.00 mm entities[ent-b2].radius ✗ Toolpath "Outline" changed on both sides, differently — kept ours yours 6 · theirs 9 operations[op-1].depth ✗ Stock thickness changed on both sides, differently — kept ours yours 18.00 mm · theirs 25.00 mm stockThickness ``` Resolving a conflict means choosing between two values, so both are shown — the one that was kept and the one that was discarded. Without the second you would have to dig the other side's file out of git to see what you were deciding against. Sizes and positions are printed in the design's own display unit, and a shape you have named is called by that name. The values shown for the shape itself are the ones in the merged file — your side — so the description matches what you see when you open it. A number that is not a length prints as stored, with no unit: a toolpath depth of `6` is millimetres, but feeds, powers and angles live on the same objects, and a bare number cannot mislabel itself. ## Two things a merge cannot decide Structure and meaning are different questions, so the driver runs the same check as `rapidcam validate` on the result and reports what it finds: - **A reference to something the other side deleted.** You dimension a circle; a colleague deletes it. Neither edit touches the other's object, so there is nothing for the merge to disagree about — and the result names an entity that is gone. - **Two variables with the same name.** Expressions reference a variable *by name*, so unique ids do not help: both variables are kept, the merge is clean, and every formula using that name is now ambiguous. Rename one. ## Seeing what changed, on the canvas A `git diff` of a `.rcam` is honest and nearly unreadable: it tells you `p1.x` went from `145` to `137` and leaves you to picture which wall moved. **File ▸ Compare With…** answers the same question by drawing it. Pick a second `.rcam` and both versions appear on one canvas: | | | |---|---| | **green** | in the open design only — added | | **red**, solid | in the other file only — removed | | **amber** | in both, different — changed | | **red**, dashed | where a changed shape used to be | | **grey** | the same in both | The strip across the top counts each kind, hides any of them, and leaves with **Exit Compare**. Comparing never writes to either file — and you can keep working with it on screen: fix something you spot and the comparison follows the edit, rather than quietly describing a design you no longer have. Beside the canvas is the list of changes, which is where the numbers are: ``` CHANGES since bracket-rev-b ● Circle ⌀10.00 mm moved 0.20 mm ×40 ● Circle ⌀10.00 mm ⌀10.00 mm → ⌀14.00 mm ● Circle ⌀10.00 mm added Not on the canvas ● Toolpath "Outline" changed: depth ``` Pick a row and the view flies to that change and rings it; **◀ ▶** in the strip walk the changes in turn. Identical changes collapse into one row with a count — forty rows saying the same sentence are no more readable than the forty amber circles they were meant to explain. Changes with nothing to draw are listed under their own heading and are not clickable, because there is nowhere to go. Three things it does that a text diff cannot: - **It marks what you would not see.** A hole moved 0.2 mm is about a pixel and a half with the whole design on screen, so the before and after outlines land on the same pixels. Those changes get a scalloped cloud, drawn at a fixed size on screen rather than in millimetres. Zoom in and the clouds go away as the geometry becomes legible on its own. When most of the design qualifies at once — a whole array nudged — no clouds are drawn: there is nothing to point at when everything moved, and the count and the change list say so instead. The change you have stepped onto is always ringed, however big it is. - **It is not fooled by re-identification.** Regenerating a pattern gives every copy a new id, which a comparison by id alone reports as forty deletions and forty additions of an array nobody touched. Shapes that are identical and in the same place are matched up whatever they are called. - **It ignores the solver settling.** Anything under 1e-4 mm is not a change. Editing one shape re-solves the sketch and moves untouched geometry by around 1e-8 mm; reporting that would make the view cry wolf on its first honest use. Toolpaths, tools, stock and machine settings are compared as well. They have no shape to colour, so the strip reports them as a count of changes outside the drawing rather than leaving them silent. ## Working with designs in a repository - **Save before you commit.** Opening a design and saving it produces the same bytes as long as nothing changed, so a diff shows only what you actually edited. Text designs are the exception — the first save embeds the font. - **`rapidcam validate ` in a pre-commit hook** catches a design whose stored geometry no longer satisfies its own dimensions, which is a change you will otherwise notice as the drawing jumping when someone opens it. - **Review the diff, not the file.** Coordinates are stored to full precision; a change of a few microns is the solver settling, not someone moving a wall. **File ▸ Compare With…** applies that judgement for you, and draws the rest.