apps/assembler/AGENTS.md
Rama main · réplica de lectura del repositorio original.
# assembler — Agent Guide
The geometry service: STEP → GLB + assembly graph (`/convert`), collision-free
disassembly motion planning (`/plan`), the compact preview GLB (`/optimize`), raw
compaction (`/compact`) and the PNG preview thumbnail (`/thumbnail`), as a Rust
**axum** HTTP service. It runs
over the C++ **FCL** (collision) and **OpenCASCADE** (CAD) libraries via `cxx`
bridges. Ported from a former Python/FastAPI service; the byte-for-byte outputs
(nodeIds, geometry hashes, collision truth) are preserved so previously stored
graphs and plans stay valid.
Design + history: `.ai/plans/2026-07-10-geometry-service-rust-rewrite.md`,
`.ai/runs/2026-07-10-geometry-rust-rewrite.md`.
## Workspace
The service binary is `apps/assembler`; the heavy lifting lives in workspace crates:
```
apps/assembler/ # axum HTTP: /health, /convert, /plan (async; long-poll GET /plan/{id}?wait=),
# /cache/invalidate; Redis job+result store (REDIS_URL)
# + bearer auth, URL validation, admission by memory, graceful shutdown
crates/
├── collision/ # cxx bridge over C++ FCL 0.7.0. new_bvh / collide_pair / distance_pair.
├── occt-bridge/ # cxx bridge over OpenCASCADE. read_step: XCAF walk + tessellation → flat node tree.
│ # Flat multi-body products (one PRODUCT, ≥2 solids, no assembly tree — the common
│ # Fusion/SolidWorks export) split into per-solid child components; guarded so any
│ # sheet/surface geometry beside the solids keeps the merged mesh (nothing vanishes).
│ # write_test_step generates hermetic multi-solid STEP fixtures for tests.
│ # pmi.cc: read_pmi — a second, read-only pass over the XCAF doc for the PMI
│ # (dimensions, geometric tolerances, datums, saved views) in world mm;
│ # write_test_pmi_step writes a hermetic AP242 fixture with semantic PMI.
├── converter/ # STEP → graph.json + GLB. nodeid (sha1), graph (tree/bbox/source-unit), convert, glb.
│ # pmi: the pmi.json document (contract: packages/viewer/src/pmi.ts).
├── thumbnail/ # GLB → PNG preview, on the CPU: reads plain or EXT_meshopt_compression GLBs,
│ # z-buffer rasteriser through the viewer's camera (45° perspective, Z up; home
│ # direction unless the caller names one),
│ # material base colours, transparent background, hand-written PNG encoder.
│ # `cargo run --release -p thumbnail --example render -- in.glb out.png` to eyeball one.
└── planner/ # assembly-by-disassembly motion planner: greedy/geom/fasteners/collide/steps.
# stability.rs adds a support-polygon check (part CoM outside the hull of the
# contact points below it ⇒ `needsSupport`); pipeline2 `compute_waves` levels
# the precedence DAG into parallel-buildable `wave`s. Both purely additive to
# plan.json — the linear `sequence` is unchanged.
# view.rs bakes a mesh-precise per-step camera DIRECTION into plan.json
# (`viewDirection`): Fibonacci-hemisphere candidates scored by ray-vs-triangle
# sight lines (Möller–Trumbore + AABB broadphase) against the bodies installed
# earlier in the sequence, so a part seating inside a hollow enclosure gets a
# view through the open side. The viewer fits the frame live at the real aspect.
```
Dependency flow: `apps/assembler → planner → converter → occt-bridge`; `planner → collision`.
## Native deps
**Dev (macOS):** `brew install fcl` (pulls libccd, eigen, octomap). OCCT is not
Homebrew's: build the patched static one with `scripts/build-occt.sh` (see Run).
Each bridge's `build.rs` resolves the lib prefix from `<PKG>_PREFIX` env, else a
fixed per-target default (macOS-arm `/opt/homebrew/opt/<pkg>`, Linux `/usr`) — no
`brew` shell-out, so the build is reproducible.
**Deploy (Docker):** OCCT and FCL/ccd are **static-linked into the binary**, so the
runtime image is just the ~24 MB binary + OpenBLAS/libstdc++ (no collision or OCCT
shared objects). `occt.Dockerfile` builds a kept base image (OCCT **V8_0_0_p1**
static + the thread_local allocator patch in `occt-patches/`); `Dockerfile` builds
FCL 0.7 + libccd static (`FCL_STATIC_LIBRARY=ON`, no octomap) and links it all in.
Carbon's CI publishes the base to `public.ecr.aws/b8l5b8t9/carbon/occt:<hash>`
(hash of `occt.Dockerfile` + `occt-patches/`, `.github/scripts/build-images.sh`).
`Dockerfile`'s default `OCCT_IMAGE` pins that tag, so a local build compiles no
OCCT. It is linux/amd64 only: on Apple Silicon pass `--platform linux/amd64`.
When the OCCT sources change, move the pin (and `.oss-scanner/Dockerfile`'s) to
the new hash once CI has published it. CI uses its own registry's copy, then the
public one with the same tag, and compiles only when neither has it.
## Why bind the same C++ libs (not parry3d / pure-Rust)
The planner's correctness keys off FCL penetration depths at a 0.15mm tolerance;
parry3d's contact model differs structurally and can't match it. nodeIds derive
from a sha1 of quantized tessellation vertices; a different OCCT version tessellates
differently → different nodeIds → existing stored graphs/plans break. So both are
bound via `cxx`, not reimplemented.
## Verification
Self-contained Rust tests (no live Python):
```bash
cargo test -p collision -p converter -p planner --tests
```
- **`collision/tests/calibration.rs`** — the FCL byte-parity guard: replays a
committed fixture (`calibration.json`, generated from python-fcl 0.7.0.11 /
FCL 0.7.0) and asserts identical contacts/distances. This is what proves the
C++ collision layer stays faithful.
- **`planner/tests/{synthetic_plan,plan_step_smoke}.rs`** — planner behaviour over
in-code synthetic geometry + a smoke plan.
- **`converter`** unit tests (nodeid / source-unit / geom byte-parity).
- `converter/tests/convert_parity.rs` diffs graph.json against Python reference
fixtures — **dormant**: it skips unless `ASSEMBLER_FIXTURES` points at a fixture
dir (the former Python service produced these; only regenerate if re-establishing
cross-impl parity).
## Run
```bash
REDIS_URL=redis://localhost:6379 ASSEMBLER_DEV_MODE=true cargo run -p assembler # listens on 0.0.0.0:8000 (PORT to override)
```
Under `crbn up`, the assembler needs Rust (rustup; `crbn` also finds
`~/.cargo/bin/cargo` when the shell has not sourced `~/.cargo/env`), and both
slow builds are shared by every worktree on the machine.
OCCT is built once into `~/.cache/carbon-occt/8.0.0-p1`
(`brew install cmake ninja`, then `scripts/build-occt.sh`). FCL comes from
`brew install fcl`. `crbn up` refuses the assembler, naming the install step,
until Rust, FCL and OCCT are all present. Cargo builds into
`~/.cache/carbon-cargo-target` (`assemblerCargoTargetDir`,
`packages/dev/src/services/apps.ts`), so a new worktree compiles only what its
branch changed. A `CARGO_TARGET_DIR` you set yourself wins.
Env: `REDIS_URL` (required — the job store; boot fails without it),
`ASSEMBLER_SERVICE_API_KEY` (bearer auth), `ASSEMBLER_DEV_MODE=true` (allow
unauth + http + skip TLS verify, local only), `PORT` (8000). Every other limit is
a constant or derived in `config.rs` (max parts 5000, shutdown grace 600 s, job
and result TTLs 24 h, pending TTL 5 min, long-poll cap 25 s) — deliberately not
env-tunable.
## Tracing
`src/telemetry.rs` — OpenTelemetry traces over OTLP/HTTP, off unless
`OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) is set.
It reads the same variables as the Node apps (`packages/logger`):
`OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`
(default `assembler`), `OTEL_TRACES_SAMPLER[_ARG]`. Traces only — no metrics or
logs; `eprintln!` stays the log.
One trace per job: the caller's `traceparent` → the request span
(`POST /v1/convert`, named by matched route; `/health` has none) → `job <action>`
→ `download source` / `compute` / `upload artifact` / `callback`.
- **A job is spawned with `telemetry::spawn_job`, never bare `tokio::spawn`.**
The job outlives its request, so the span context has to travel with the task;
a bare spawn starts a job whose spans belong to no trace.
- **Across the Lambda self-invoke the parent rides in the worker spec**
(`traceparent`, written by `telemetry::inject` in `lambda_dispatch`, restored
by `telemetry::attach` in `main::spawn_traced`). A spec built anywhere else
can carry the same key.
- **Outbound requests go through `http::{download_hashed, upload, post_json}`**,
which record the host only. The URLs are signed, and error text that quotes
one is reduced to its origin before it is put on a span (`without_urls`).
- **Export uses the reqwest already in the binary** (`ExportClient`), not the
exporter's bundled client: that one is reqwest 0.13 and brings a second TLS
stack (aws-lc) beside ours (ring).
- **On Lambda every request flushes before it responds** — the process freezes
once the response is sent. That adds one export round trip to each response
there, capped at 3 s (`LAMBDA_FLUSH_WAIT`) so a slow collector cannot push a
25 s job poll past API Gateway's 30 s; ECS and local runs export on the batch
timer.
- **Every exit path calls `telemetry::shutdown()`** — `process::exit` runs no
destructors, so a path that skips it drops the last batch.
`apps/assembler/sst.config.ts` forwards the `OTEL_*` variables that are set at
deploy time; `ci/src/assembler.ts` does not set any yet.
## Completion & lifecycle
Every action is an async job: POST returns 202 and the job runs in a background
task. Callers pass signed upload URLs at submit (`X-Carbon-Upload-Urls`) and a
`callback_url`; the job uploads its outputs and POSTs the terminal envelope the
moment compute ends. `GET /v1/jobs/{id}` is the fallback when the callback is
lost. It accepts `?wait=<secs>` (a long poll, capped at 25 s), but
`runAssemblerJob` never sends it: a long poll is an executing Inngest step, so
it held a slot of the shared concurrency key for its whole wait. The runner
polls once per wake and waits between polls in `waitForEvent`, which holds no
slot. Inngest matches only events sent after the wait is registered, so a
callback that lands between a poll and its wait is seen at the next poll. The
waits therefore start at 15 s and double to 5 min (`waitSliceMs`), restarting
at each resubmit: a missed callback on a short job costs seconds, not five
minutes. A 5xx poll counts as unreachable, not as a lost job. When the runner gives
up on a job that is still queued or running, it calls
`POST /v1/jobs/{id}/cancel` (best effort) so the job does not keep its memory.
**Job status + pointers live in Redis** (`jobs.rs`): `asm:job:{jobId}` →
`{status, result, stats, …}`. Redis is required, so a restart, a sibling replica
or another Lambda invocation can answer a poll. It holds pointers, never artifact
bytes — with one exception below.
`GET /health` pings Redis (2 s limit) and answers 503 when it does not reply:
without Redis no job can be recorded or polled, so the instance is not healthy
and the container healthcheck / load balancer replaces it. The client
(`connect` in `jobs.rs`) bounds every connect (5 s) and reply (15 s) and
retries a lost connection with delays that double from 1 s to 5 s, so the
service is healthy again within seconds of Redis returning. The crate's
defaults wait forever and back off to a minute or two.
**Outputs go from memory to storage, never through Redis.** The service has no
storage credentials: the caller signs one upload URL per output at submit
(`X-Carbon-Upload-Urls`), and `finish()` PUTs each output straight from `Bytes`.
A PUT that fails is retried after 1, 2 and 4 s; still failing (or a missing
URL), the job fails with `UPLOAD_FAILED` and the caller resubmits with fresh
URLs. Outputs are never parked for a later poll: a late-mint hand-off used to
keep them on disk, or in Redis on Lambda, which put whole models in a shared
Redis.
**One parser per action** (`src/request.rs`). `POST /v1/{action}` and the
Lambda worker both parse the body through `request::parse`, so a default
cannot differ between runtimes. Convert and plan share one tessellation
(`config::TESSELLATION_*`): nodeIds hash the tessellated positions, so a
graph.json and a plan.json made at different deflections disagree. A plan
needs `meta.planPath` (400 without); there is no inline plan result, no
`?sync` mode and no `run-job` CLI.
**Job state is a Redis hash, changed only while the job is active.** A
record is `asm:job2:{id}` (fields `action`, `status`, `result`, `stats`,
`error`, `upload_urls`, `callback_url`). Every status change goes through one
Lua script (`TRANSITION`) that checks the job is still pending, starting,
running or uploading before it writes, so a finish can never overwrite a cancel
that landed meanwhile.
**Leases: who is running a job.** A working job (not `pending`) holds
`asm:lease:{id}` (30 s), renewed every 10 s by the process's `heartbeat` for
every job `jobs::track` lists (`telemetry::spawn_job` tracks each job). A poll
that finds a working job with no lease ends it `WORKER_LOST`, which the caller
resubmits: the process running it died. On Lambda the lease is the worker's
claim (`SET NX`): of two deliveries one runs, and a redelivery after a crashed
or timed-out invocation takes the job over; the request body stays in
`asm:req:{id}` until the job ends so it can.
**Cancel stops transfers.** `track` gives each job a cancel token; `cancel`
fires it at once on the replica running the job, and the heartbeat fires it on
any other within 10 s. Downloads and uploads race it (`http::unless_canceled`)
and fail `canceled`.
**A compute has a deadline** (`compute.rs`, 60 min; Lambda's 900 s comes
first there). OCCT and FCL cannot be interrupted and a blocking thread cannot
be killed, so past the deadline, or on a cancel, the job ends at once
(`COMPUTE_TIMEOUT` / `canceled`) and the thread finishes in the background
still holding the job's memory grant: admission never hands out memory a
stuck thread may still be using. A plan's grant lives inside its work (it is
resized as parts are counted), so it is released the same way.
**Secrets stay out of payloads, records and errors.**
- The Lambda self-invoke spec is `{ action, job_id, traceparent }` and nothing
else. The request body waits in Redis (`asm:req:{id}`, 2 h) and the worker
reads it (`GET`); it is deleted when the job ends (`ended()`), so a
redelivery can take over a job whose worker died. No signed URL, caller
token or service key rides in the Invoke payload, which AWS keeps for its
async retries, but the stored body holds them, a `fetch`'s `authorization`
included, until the job ends.
- `/events` is mounted only on Lambda, where only an IAM-signed Invoke reaches
it; a spec names a job, so a delivery can only start a pending job with the
body its create stored.
- A job's upload URLs are cleared when it ends. Its `callback_url` stays (the
worker re-sends the callback; it only reports that one job).
- Every reqwest error goes through `without_url()`: signed URLs keep their
token in the query string, and the message is logged, stored on the job and
returned to the caller.
- The shared HTTP client follows no redirects: a redirect would reach a host
`validate_url` never checked.
**Content-hash result cache** (`asm:result:{model}:{contentHash}:{optsHash}:v{CODE_VERSION}`
→ pointer, 24 h): a repeat of the same model + bytes + options + code version
reuses the prior plan's storage pointer, skipping the FCL compute.
`CODE_VERSION` (`cache.rs`, shared with the convert cache) is the single version
lever — **bump it on any converter OR planner behavior change** to
auto-invalidate every cache. `optsHash` includes `units`/`sequence`, so a fresh
regenerate that drops auto-swarm units misses automatically. `POST /cache/invalidate`
`{modelUploadId}` is the central explicit bust (called best-effort from the app's
`invalidateAssemblyPlanCache`/`invalidateAssemblyModelCache`).
On SIGTERM/SIGINT the service stops accepting requests, waits for the whole
memory budget to come back (every compute done), then force-exits after the
shutdown grace.
## Memory
Built to sit as a small always-on pod beside other workloads.
- **Admission is by memory** (`admission.rs`), not by core. The budget is the
container's limit (`/sys/fs/cgroup/memory.max`, cgroup v1's
`memory.limit_in_bytes`, else `/proc/meminfo`) less a 300 MB baseline. Each
compute holds its estimate out of it: 8× the source bytes for convert,
optimize, compact and thumbnail; for a plan, 10× the source until the model is
read, then 8 MB per part (`plan_step_observed` reports the count). Jobs that
fit run together; one that does not waits in arrival order; one estimated
above the whole budget takes all of it and runs alone. Nothing is rejected.
The reservation covers the compute only, not the download or the upload. CPU
is bounded separately by the blocking pool (cores + 2).
- **Downloaded sources are bounded too** (`source.rs`). A job takes one of
`cores + 2` source slots before it downloads and holds it until its `Source`
drops, which deletes the file on every path (a panic included). A queue past
that waits with no file on disk; it used to hold every queued job's source
at once.
- **Every transfer has a deadline**: a download 1 h end to end (the 60 s read
timeout only catches a stalled one), an upload 2 min plus 2 MB/s of body, the
Lambda self-invoke 10 s. A create claims `asm:create:{id}` (`SET NX`, 30 s,
released when done) so two POSTs with one key cannot both spawn; a Lambda
worker claims the job's lease (`claim_lease`, `SET NX`), so of two live
deliveries exactly one runs. Callbacks from `set_error`/`cancel` go out in their own task.
- **A waiting job is `pending` or `starting`, not `running`.** Each action sets
`running` only after `admission.acquire` returns. On Lambda the worker inlet
sets `starting` when it takes a `pending` job, before the download and the
admission wait, so a second delivery of the same job finds it taken and does
not run it again. The job API reports both `pending` and `starting` as
`queued`, and both count as active for cancel. A job cancelled while it
waited for memory is dropped right after admission, before any compute.
`runAssemblerJob` (`@carbon/jobs`, `tasks/assembler-client.ts`) starts an
action's time budget at `running`, so time spent queued behind other jobs never
fails a job. The optimize action's own deadline (`opts.deadline`) also starts
after admission, not at the download.
- **The estimate is not a limit.** Nothing stops a job from using more than it
was admitted for, and geometry can be far heavier than its file size: a
5 MB plate with 1,200 drilled holes still peaks at 840 MB, thirteen times its
estimate. Such a job can still get the pod OOM-killed.
- **Flat faces with many holes are meshed lean** (`mesh()` in
`crates/occt-bridge/src/occt.cc`). BRepMesh's default algorithm for a plane
runs an interior-node pass whose memory grows with the square of the hole
count; that 1,200-hole plate peaked at 6.2 GB. A part with more than 64 wires
on one face is meshed with that pass off for planes only: 840 MB, and faster.
The vertices are the same, so graph.json, nodeIds and geometry hashes do not
change; the triangle wiring in the GLB can (co-circular ties on a regular hole
pattern), and with it the contact-derived details of a plan for parts that
touch such a face (one `needsSupport` flag flipped on a test assembly; the
sequence, motions and contact graph did not). That is why parts under the
threshold keep the default path and stay byte-identical. `ASSEMBLER_LEAN_PLANES=0` turns it off. OCCT's other
mesher (Delabella) is not an answer: lean on memory, but minutes on the same
plates.
- **The allocator** on Linux is jemalloc for the whole process (unprefixed, so
OCCT and FCL use it too) with a background thread that returns freed pages
after a second (`malloc_conf` in `main.rs`). `MALLOC_CONF=stats_print:true`
prints its settings at exit.
- **The convert result cache** is files under `<tmp>/asm-cache` (2 GiB LRU, off
on Lambda), memory-mapped when a hit is served and cleared at startup.
- **`<tmp>` must be real disk** with room for the cache and downloaded
sources. A memory-backed `emptyDir` would charge all of it to the
pod's memory limit. The server clears downloaded sources (`geometry-*`) left
by an earlier process when it starts: a killed container skips its own
cleanup and the volume outlives the restart.
## Fetch
`POST /v1/fetch` `{ source: { url, authorization? }, maxBytes?, output: { path?,
contentType? } }` copies the file at `source.url` into storage unchanged, as the
`raw` output, and returns `{ contentHash, outputs: { raw: { path, bytes } } }`
(`contentHash` = xxh3-128 hex). It exists so a caller never holds a large file:
the Onshape sync has the assembler pull a STEP export straight from Onshape into
staging instead of streaming it through the app's function.
- `authorization` is sent as the `Authorization` header (marked sensitive) and
lives only in the request: never in the job record, a log or a span. On
Lambda the request is the stored body (`asm:req:{id}`), so the token sits in
Redis until the job ends, at most the request's 2 h.
- `fetch` alone follows redirects, at most five and each to a URL that passes
`validate_url`; reqwest drops `Authorization` on a redirect to another host.
- Outside dev mode `fetch` connects only to public addresses: its resolver
(`PublicOnly`) drops private, loopback, link-local, CGNAT and IPv6
unique-local results and refuses a host with none left. Its URL is the one a
third party decides (an Onshape Government tenant URL is the customer's to
set); every other action reaches our own signed storage URLs, which a
self-hosted install may serve from a private network. `validate_url` checks
IP literals by the same rule (`config::is_public_ip`).
- `maxBytes` fails the job `LIMIT_EXCEEDED` (413), from `Content-Length` when
sent, else as the bytes cross it.
- No zstd decoding: the stored bytes are exactly what the source served.
- No memory grant (no compute); it takes a source slot like every download.
## Thumbnails
`POST /v1/thumbnail` `{ source: { url }, output: { path?, size?, direction? } }`
renders the GLB at `source.url` to a square PNG (300 px unless `size` says
otherwise) and uploads it as the `thumbnail` output.
The camera is the viewer's: a 45° perspective looking at the centre of the
bounding box, standing back far enough to fit the bounding sphere (`FOV_DEGREES`
/ `FIT_MARGIN` in `crates/thumbnail`, mirroring `AssemblyViewer`'s camera and
`frameBox` in `packages/viewer/src/ModelCanvas.tsx` — change one and change the
other). `direction` is `[x, y, z]` from the model towards the camera, Z up;
absent or unusable, it is the viewer's home view `[1, -1, 1]`. The viewer's
camera button (`ModelPreview` `onCaptureThumbnail`) sends the direction its
camera stands at, through `api+/model.thumbnail.ts` and the
`carbon/model-thumbnail` event. Only the direction travels: the image is always
re-framed to fit the whole model, whatever the viewer's zoom or pan. The caller is `@carbon/jobs`
`tasks/model-thumbnail.ts`, which hands it the model's optimised GLB (else the
lossless `convert` GLB). It replaced a headless-browser screenshot of the viewer
page. A Draco-compressed GLB is refused (`thumbnail_failed`); textures and vertex
colours are ignored in favour of the material colour.
## Ingest (optimize with compact, thumbnail and PMI)
A new model is processed by ONE `/v1/optimize` job, which reads the raw once.
Three optional request fields add outputs:
| Field | Output | What it writes |
|---|---|---|
| `compact: { path? }` | `raw` (`application/zstd`) | A STEP is converted to BinXCAF once; the optimiser tessellates from that xbf, and the xbf is compressed (`.xbf.zst`). A mesh source is compressed as it is. An xbf source needs nothing. |
| `thumbnail: { path?, size?, direction? }` | `thumbnail` (`image/png`) | Rendered from the optimised GLB in memory, with the camera of `/v1/thumbnail`. |
| `pmi: { path? }` | `pmi` (`application/json`) | The STEP's PMI (`pmi.json`, `converter::pmi`), read by `occt_bridge::read_pmi` from the same xbf (or the STEP/xbf source). Only for STEP and xbf sources, and only when the model has PMI; `result.outputs.pmi` carries `{ path, bytes, items, views }`. |
No extra can fail the job: a failed compaction, render or PMI read becomes a
warning and the output is left out.
`read_pmi` opens an xbf from a STREAM and closes it after: `TDocStd_Application`
keeps every document it opened by path until it is closed, and refuses to open
the same path again (`PCDM_RS_AlreadyRetrieved`) — `read_xbf` has just opened
it. Coordinates are world mm in the GLB's frame: a part's PMI is moved to the
first world placement of the part its first referenced shape belongs to. OCCT
reads AP242 saved views only when every camera has a draughting model, so an
edition 3 file such as NIST STC 06 can arrive with none. The NIST MBE PMI models
(public domain, not committed) run in `converter/tests/pmi.rs` when
`PMI_FIXTURES` names their directory. The caller is `@carbon/jobs` `tasks/model-optimize.ts`.
It asks for `compact` only while the raw is still in `temp-staging`; without a
`raw` output it copies the raw out of staging as it is. `/v1/compact` and
`/v1/thumbnail` stay for the cases outside ingest: a raw left in staging with the
assembler off, and a thumbnail from a captured camera direction.
## meshopt / Draco compression
`meshopt` + `EXT_meshopt_compression` (and optional `KHR_draco_mesh_compression`)
live in the **`/v1/optimize`** action (`crates/optimize`), NOT the converter. The
**convert** action still serves an uncompressed, contract-valid, lossless GLB (it
feeds the animated assembly viewer); optimise produces the separate compact
preview GLB. Gotcha grounded in `crates/optimize`: the meshopt vertex codec
requires every attribute stride be a multiple of 4 — i16 VEC3 normals are padded
to i16 VEC4 (8 bytes), or the spec JS decoder rejects the output.
## Never
- Never swap the FCL/OCCT bridges for parry3d or a pure-Rust CAD lib — parity breaks.
- Never change nodeId derivation (`crates/converter/src/nodeid.rs`) — stored graphs
reference these IDs. The byte-parity unit tests guard it.