# NullCAM API

Upload a CAD file, get G-code. Two HTTP calls, no UI, no configuration required.

NullCAM is a GPU-native CAM system for N×2.5D milling — pockets and holes approached
from multiple discrete directions, cut with helix plunges and trochoidal paths. You
send a solid (STEP, IGES) or a mesh (STL, OBJ, PLY, 3MF, GLB) and get back a zip
containing the `.nc` program and a machining plan.

Base URL: `https://api.nullcam.com`

## The two calls

```bash
# 1. Create a job. Always 202, always this shape, plus `Location: /v1/jobs/{id}`.
JOB=$(curl -sS https://api.nullcam.com/v1/jobs \
  -H "Authorization: Bearer $NULLCAM_TOKEN" \
  -F "file=@part.step" | jq -r .job_id)

# 2. Wait for the artifact. 303 -> a short-lived signed URL; -L follows it.
curl -L -o output.zip "https://api.nullcam.com/v1/jobs/$JOB/result?wait=90" \
  -H "Authorization: Bearer $NULLCAM_TOKEN"
```

That is the whole flow. If step 2 returns `202` the job is not finished — call it
again. `wait` is clamped to 90 seconds; a longer wait is not more efficient, it just
holds a connection.

Creating a job and fetching its artifact are two different operations on two
different resources, which is why they are two calls. A single endpoint that did both
had to answer six different response shapes across eleven status codes, because it
was answering two questions at once.

### Large files

The multipart body above is buffered in memory and capped at 20 MB. For anything
large, presign instead — same job, the bytes just never transit the API:

```bash
# 1. Ask for a URL (valid 15 minutes).
read FILE_ID UPLOAD_URL < <(curl -sS https://api.nullcam.com/v1/files \
  -H "Authorization: Bearer $NULLCAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename":"part.step"}' | jq -r '.file_id + " " + .upload_url')

# 2. PUT the bytes straight to storage.
curl -sS -X PUT --upload-file part.step "$UPLOAD_URL"

# 3. Create the job from the file_id (JSON body this time).
curl -sS https://api.nullcam.com/v1/jobs \
  -H "Authorization: Bearer $NULLCAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"file_id\":\"$FILE_ID\"}"
```

## Authentication

Every call takes `Authorization: Bearer nc_...`. Create a token in the web console
under Settings → API Token; it is shown exactly once. Tokens expire after 90 days —
`GET /v1/profile/api-token` reports `expires_at` so you can rotate before that
happens rather than discovering it mid-run.

A `401` with code `INVALID_API_TOKEN` is permanent: stop and re-authenticate. A `503`
is transient: retry. These are deliberately different answers, because the API cannot
always tell "this token is bad" from "I could not check right now", and reporting the
second as the first would tell a client with a perfectly good token to give up.

## You do not have to configure anything

`settings` is optional. Omit it and the server resolves your saved machine profile,
or the canonical default if you have never saved one. The web client itself never
sends `settings` — machine setup is server-authoritative.

So the minimum viable request really is a file and nothing else. Everything below is
for when you want to override that.

## Per-part options

These describe the part rather than the machine, and travel with a single request.
Send them as JSON: in the `options` part of a multipart body, or as top-level fields
of a JSON body.

| Field | Type | Meaning |
|---|---|---|
| `material_id` | string | Material glob. `aluminum_*`, `steel_*`, `stainless_*`, `delrin*`, or `*` for automatic. Selects feeds and speeds from your machine's per-material table. |
| `units` | string | Units of the uploaded file (`mm`, `in`, …). Meshes carry no units, so the same STL can load 25.4× or 1000× off. Absent = the worker guesses. Ignored for STEP/IGES, which are self-describing. |
| `stock_override` | `[x,y,z]` | Explicit billet size in meters. Centred on the part and clamped so the part is always contained. |
| `stock_padding` | `[p]` or `[x,y,z]` | Extra material per side, in meters. Default `[0.005]`. |
| `notify_email` | bool | Email the result when it finishes. |
| `workflow` | string | `mill` (default), or `analyze` for geometry analysis with no toolpaths. |

`stock_override` and `stock_padding` are mutually exclusive — setting one clears the
other. Send neither and the stock is computed from the part's bounding box.

```bash
curl -sS https://api.nullcam.com/v1/jobs \
  -H "Authorization: Bearer $NULLCAM_TOKEN" \
  -F "file=@bracket.stl" \
  -F 'options={"material_id":"steel_*","units":"mm","stock_padding":[0.01]}'
```

## Overriding the machine

To change the machine itself — tools, work volume, spindle limits, cutting strategy —
send a full `MillSettings` as `settings`. Two ways:

- **Once, persistently:** `PUT /v1/profile` with the `MillSettings` body. Every later
  job uses it. This is what you want if all your parts run on one machine.
- **Per job:** the `settings` field on `POST /v1/jobs`. Overrides the profile for
  that job only.

**Start from `GET /v1/mill/default` and edit it.** Do not build a `MillSettings` from
the proto definition. Submission validates that `machine.max_velocity_cut` is three
positive finite numbers, and the proto default for that field is `[0, 0, 0]` — so a
hand-built settings blob is rejected while one derived from `/v1/mill/default` is
not. The same is true of `machine.extents` and `machine.max_rpm`.

```bash
curl -sS https://api.nullcam.com/v1/mill/default \
  -H "Authorization: Bearer $NULLCAM_TOKEN" > mill.json
jq '.machine.max_rpm = 12000' mill.json > tuned.json
curl -sS -X PUT https://api.nullcam.com/v1/profile \
  -H "Authorization: Bearer $NULLCAM_TOKEN" \
  -H "Content-Type: application/json" --data-binary @tuned.json
```

Validation refuses: zero tools, more than 100 tools, any tool radius that is not
positive and finite, `machine.extents` that is not three positive finite numbers, and
a non-positive `machine.max_rpm`.

### The default machine

A Haas-VF2-class 3-axis mill, and five carbide tools:

| Shape | Tool | Radius |
|---|---|---|
| flat | 1/8" 2-flute flat end mill | 0.0015875 m |
| flat | 1/4" 4-flute flat end mill | 0.003175 m |
| flat | 1/2" 4-flute flat end mill | 0.00635 m |
| cone | 3/8" 2-flute 90° chamfer mill | 0.0047625 m |
| cone | 1/4" 2-flute 142° spot drill | 0.003175 m |

`extents` `[0.762, 0.406, 0.508]` m, `max_rpm` 8100, `max_velocity_cut`
`[0.2822, 0.2822, 0.2822]` m/s, `max_acceleration` `[9.81, 9.81, 9.81]` m/s² (1 g),
default material `aluminum_6061_t6`.

Drills are not tools in the magazine. They are synthesized per hole from the bores
found in your part, sized to the hole, up to `drill_settings.max_drill`.

### Importing a tool library

`POST /v1/tools/parse?format=fusion` or `?format=mastercam` with the raw library file
as the body returns `{"tools": [...]}`. It does not persist anything — merge the
result into a `MillSettings.tools` and `PUT /v1/profile` yourself.
`POST /v1/tools/format` goes the other way.

## Settings the web UI does not expose

These have no control anywhere in the console. They are reachable only through the
API, which makes them the most useful part of this document.

| Field | Default | Meaning |
|---|---|---|
| `detail` | unset → standard | Simulation resolution: 1 draft, 2 standard, 3 high. Higher costs compute and buys fidelity on small features. |
| `prune_aggression` | 3 (standard) | How hard the planner drops unproductive motion: 1 off, 2 conservative, 3 standard, 4 aggressive. |
| `max_directions` | 0 (uncapped) | Cap on the number of discrete tool approach directions. Lower is faster and less complete. |
| `plane_normals` | empty | Explicit `(n,3)` list of approach directions, instead of letting the planner choose. |
| `default_radial_depth_of_cut` | `{relative: 0.12}` | Stepover as a fraction of tool **diameter**. Floored at 0.01. |
| `default_radial_finish` | `{relative: 0.04, limits.min: 5.08e-05}` | Wall material left for the finish pass, as a fraction of tool **radius**, floor 0.002". |
| `default_axial_finish` | same | Floor material left for the finish pass. |
| `default_terrace` | `{relative: 1.0, limits.min: 5.08e-05}` | Roughing Z step, as a fraction of tool **radius**. |
| `max_axial_depth_of_cut` | 0 (no cap) | Absolute metres plus a fraction of flute length. |
| `finish_feed_fraction` | 0.5 | Feed multiplier on finish passes. RPM is unchanged. |
| `feed_ramp` | 0 (auto) | Ramp feed, mm/min. |
| `angle_helix` | 0.05235988 | Helix plunge angle. **Radians**, despite what the proto comment says — this is 3°. |
| `skip_fixture_collisions` | false | Skip collision checking against the vise and table. |
| `default_skip_ball` | false | Skip ball-end finishing. |
| `enable_compensation` | false | Emit `G41 D#` / `G40` cutter compensation on finish contours. |
| `edge_break` | 5.08e-05 | Chamfer width on sharp corners, metres. `0` skips the pass entirely. |
| `inwards_cut` | 0 (all) | 0 all, 1 outside only, 2 top only, 3 disabled. |
| `drill_settings.max_drill` | 0.00635 | Largest hole that gets drilled rather than milled. A **radius** — the UI label says diameter. |
| `drill_settings.peck_min_aspect` | 0 (never peck) | Depth-to-diameter ratio above which pecking starts. 2–3 is typical. |
| `drill_settings.peck_depth_ratio` | 0.3 when pecking | Peck depth as a fraction of drill diameter. |
| `drill_settings.default_spot_angle` | 142.0 | Spot drill included angle, degrees. |
| `drill_settings.skip_spot_drill` | false | Skip spotting. |
| `drill_settings.skip_countersink` | false | Skip countersinking. |
| `drill_settings.skip_drill` | false | Mill every hole instead of drilling. |
| `machine.tool_pad` | 0.001 | Collision padding on tool radius and bottom, metres. |
| `machine.spindle_radius` | 0.1 | Spindle body radius for collision checks, metres. |
| `machine.spindle_length` | 1.0 | Spindle body length, metres. |
| `machine.spindle_pad` | 0.01 | Collision padding on the spindle body, metres. |
| `machine.max_velocity_rapid` | `[1.0, 1.0, 0.5]` | Rapid speeds, m/s. |
| `machine.max_travel` | `[1e9, 1e9, 10.0]` | Per-axis travel limits, metres. Huge = unlimited. |
| `machine.junction_deviation` | 2.54e-05 | Cornering tolerance, metres. |
| `machine.max_biarc_acceleration` | 2.4525 | Acceleration limit in curved moves, m/s². |
| `machine.use_fast_inplane` | true | `G0` rather than `G1` for in-plane repositioning. |
| `machine.use_variables` | true | Emit speed variables in the G-code. |

Per-tool overrides also exist and are not fully exposed: `speed_feed` (mm/min),
`speed_rpm`, `stepover`, `stepover_finish` (metres), `flutes`, and `preferred_index`.

## Units, and five things that will bite you

Everything is **MKS** — metres, kilograms, seconds. Not millimetres, not inches.
`radius: 0.003175` is a 1/4" end mill.

1. **`angle_helix` is in radians**, even though the proto comment says degrees. The
   default `0.05235988` is 3°.
2. **`drill_settings.max_drill` is a radius.** Every UI label and FAQ entry calls it a
   diameter. The default `0.00635` means "drill holes up to 1/2 inch across".
3. **A `RelativeQuantity`'s reference differs per field.** `{absolute, relative,
   limits}` resolves to `absolute + relative × reference`, then clamps. The reference
   is tool **radius** for the finish allowances and `default_terrace`, tool
   **diameter** for `radial_depth_of_cut`, and flute length for
   `max_axial_depth_of_cut`.
4. **`limits.min` or `limits.max` of `0` means unbounded**, not "a floor of zero".
5. **`edge_break: 0` is the explicit off switch** for the chamfer pass. There is no
   separate boolean.

Every field is `#[serde(default)]`, so anything you omit takes its proto default —
`0`, `false`, `""`, or `[]`. That means omitting a field and sending it as `0` are
the same request, which matters for the fields where `0` is a real setting
(`edge_break`, `max_directions`, `max_axial_depth_of_cut`).

## Status codes

`POST /v1/jobs`:

| Code | Meaning |
|---|---|
| 202 | Created. Body has `job_id`; `Location` header has the poll URL. |
| 400 | Malformed body, or settings failed validation. The message names the field. |
| 401 | Bad or missing token. |
| 402 | Out of credits. |
| 413 | File over 20 MB — use the presign flow. |
| 429 | Too many jobs already running for this account. Wait for one to finish. |
| 503 | Queue unreachable. Retry in a few minutes. |

`GET /v1/jobs/{id}/result`:

| Code | Meaning |
|---|---|
| 303 | Ready. Follow the redirect to the artifact. |
| 202 | Not finished. Call again. |
| 404 | No such job, not yours, or it produced no artifact. Another account's job is a 404, never a 403 — the API will not confirm a job exists to someone who cannot read it. |
| 410 | Cancelled. |
| 422 | The job ran and failed. The body says why; retrying unchanged will not help. |

Errors are always `{"error": "<human message>", "code": "<SCREAMING_CASE>"}`. Branch
on `code`; show `error`.

There is also `GET /v1/jobs/{id}?wait=N` for status without fetching the artifact
(`wait` clamped to 20s), `GET /v1/jobs` to list, `GET /v1/jobs/{id}/quality` for the
simulation report, and `POST /v1/jobs/{id}/rerun` to re-cut the same uploaded file
with different settings — no re-upload.

## The anonymous tier

You can run a part with no account at all: `POST /v1/anon/session` mints a session,
then `POST /v1/anon/upload` takes the file. Polling uses the same
`GET /v1/jobs/{id}` and `GET /v1/jobs/{id}/result` as everything else.

The session arrives as an httpOnly cookie, not a bearer token, so a script has to
keep a cookie jar (`curl -c jar -b jar`). It lasts an hour.

It is deliberately limited, and knowing the limits is cheaper than discovering them:

- **No machine configuration.** The anonymous upload route accepts per-part options
  only — `material_id`, `units`, `stock_override`, `stock_padding`, `notify_email`,
  `workflow`. It has no `settings` field at all, so sending one is a `400` naming the
  field. Anonymous jobs always run on the canonical default machine. Consistently,
  `GET /v1/mill/default` requires a login, so an anonymous caller cannot read the
  defaults it is not allowed to override.
- **10 seconds of compute**, against 90 for an account. A part that needs more comes
  back `422 JOB_FAILED`, with a message that says so rather than a generic error.
- **One job at a time** (`ANON_ONE_AT_A_TIME`), and a small per-session quota
  (`ANON_QUOTA_EXHAUSTED`).
- **Lowest queue priority.** When there is no slack the upload is refused outright
  with `503 ANON_LOW_PRIORITY` rather than queued behind everything.
- **Per-IP rate limiting**, and a stricter file filter: plain uncompressed STEP, STL
  or IGES only. Archives are rejected without being opened.
- Everything else — listing jobs, the quality report, rerun, profiles, tokens,
  credits — requires a login.

If you are writing an integration, get a token. The anonymous tier exists so a person
can try the product without signing up, not as an API.

## Files

Accepted: **STEP**, **IGES**, **STL**, **OBJ**, **PLY**, **3MF**, **GLTF/GLB**.

Solids (STEP, IGES) carry units and exact surfaces, and give better results. Meshes
are approximations and carry no units — set `units` when you send one.

The 20 MB cap applies to the inline multipart path and to presigned uploads alike.
