← Commit history

v0.12.0 / pkg 1.2.0: fix #5 (silk in plain convert) + #6 (thumbnail-batch async polling); README rewritten as the unified CLI + service page

John Lauer ·8d9f0488e9 ·2mo ago ·parent 1c0754d
10 files changed +368−76
Cargo.lock+1−1
@@ -527,7 +527,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"  [[package]] name = "step2glb"-version = "0.11.0"+version = "0.12.0" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "step2glb"-version = "0.11.0"+version = "0.12.0" edition = "2021" description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app." license = "MIT"
README.md+160−28
@@ -1,56 +1,188 @@ # adom-step2glb -Thin CLI for STEP → GLB conversion with color preservation. Forwards requests to the shared **service-step2glb** container so individual containers don't need to ship the ~1.1 GB OCCT runtime.+**The Adom STEP engine: one name for the CLI and the shared OCCT service behind it.** -> **Renamed:** this app was formerly published as `step2glb`. It's now `adom-step2glb` (binary `adom-step2glb`), using the v1.2 async job protocol against the shared service. A `step2glb` binary alias is kept for back-compat — existing scripts that call `step2glb …` keep working.+`adom-step2glb` is two halves of one thing, and this page is the home for both: -![step2glb preview — LQFP-100 converted from STEP with colors preserved + ground plane + cast shadow](docs/screenshots/01-iso-lqfp100.png)+1. **The CLI**: a ~3 MB Rust binary (`step2glb`, also installed as `adom-step2glb`) that you install on any container. No Python, no CAD kernel, no venv.+2. **The service**: a shared `default-light` container running the full OCCT XCAF pipeline (~1.1 GB runtime the CLI never has to ship). It converts, renders, analyzes, and *edits* STEP files for the whole Adom fleet. -## What it does+Anything STEP-related that needs a real CAD kernel goes through here: converting STEP to color-preserving GLB, rendering thumbnails and vector outlines, extracting geometry, laser-embossing part numbers into the die, generating chip and footprint STEPs from JSON, baking transforms, and running the full molecule pipeline for boards. +> **Renamed:** this app was formerly published as `step2glb`, and the backend was known separately as "service-step2glb". It is all `adom-step2glb` now. The `step2glb` binary alias is kept, existing scripts keep working, and issues for *either* half belong on this page.++![step2glb preview: LQFP-100 converted from STEP with colors preserved + ground plane + cast shadow](docs/screenshots/01-iso-lqfp100.png)++## Quick start++```bash+adom-wiki pkg install adom/adom-step2glb   # installs the binary + AI skill+step2glb health                            # checks the shared service+step2glb convert part.step -o part.glb     # your first conversion+step2glb preview part.step                 # convert + open a Hydrogen webview+```++The CLI follows adom-cli-design: `OK:` / `ERROR:` / `Hint:` on the first line, then a JSON companion line so AI callers can parse results structurally.++## How it works++```+your container                        shared service container+┌────────────────────┐    HTTPS      ┌──────────────────────────────────┐+│ step2glb CLI (3MB) │ ───────────▶  │ HTTP server (never touches OCCT) │+│  or raw curl       │  202 job_id   │   └─ job queue (4 workers)       │+│                    │ ◀───────────  │       └─ OCCT subprocess per job │+│ polls /jobs/{id}   │               │          STEPCAFControl_Reader   │+│ downloads /result  │               │          RWGltf_CafWriter        │+└────────────────────┘               └──────────────────────────────────┘ ```++- **Async everywhere.** Every POST returns `202 + job_id` immediately. Clients poll `GET /jobs/{id}` until `status: complete`, then fetch `GET /jobs/{id}/result`. The CLI does all polling for you.+- **Crash-proof by design.** The server process never imports OCCT. Every job runs in an isolated subprocess, so a segfault on a pathological STEP kills one job, not the service.+- **Color preservation is the point.** OCCT's XCAF path (`STEPCAFControl_Reader` with colors, names, and materials enabled, then `RWGltf_CafWriter`) keeps per-face colors from the source CAD. The legacy paths everyone hits first (`cadquery.Assembly.save('…glTF')`, `kicad-cli pcb export glb`) collapse everything to all-white.+- **Large uploads gzip automatically** (STEP is text; it compresses about 5x).+- **Identity required.** Every POST carries `X-Client` and `X-Job-Name` headers so the service dashboard can attribute load. The CLI sends them automatically; raw HTTP callers must set both.++## What it can do++### Convert: STEP to color-preserving GLB++```bash step2glb convert SOT-89-3.step -o SOT-89-3.glb-step2glb from-library Package_TO_SOT_SMD/SOT-89-3-step2glb preview Package_QFP/LQFP-100_14x14mm_P0.5mm      # opens a Hydrogen webview+step2glb from-library Package_TO_SOT_SMD/SOT-89-3   # fetch from service-kicad + convert in one round-trip+```++Output is mode-aware compressed: plain converts get decoder-free `KHR_mesh_quantization` (loads anywhere, even in the wiki readme sandbox whose CSP can never fetch a Draco decoder), molecule boards get the full join+weld+Draco optimize (7 to 8x smaller on CAD tessellations). Override with `compress=quantize|draco|none` on the HTTP endpoint. Units are normalized to glTF-spec meters. Over-budget models also get a decimated `<slug>.web.glb` LOD for component pages.++### Silkscreen overlays (plain *and* molecule mode)++```bash+step2glb convert board.step -o board.glb --silk-top overlay.png+```++Attach white-on-transparent PNG artwork with `--silk-top` / `--silk-bottom` and the service drapes it onto the board's top/bottom face. Works in plain converts and molecule mode; the response reports `silkscreen_applied` and `silkscreen_sides` so you can assert on it. (Before v0.12.0 the flags were silently ignored without `--molecule`; see issue #5. Now they apply in both modes, and a specified overlay that cannot be read or applied is a hard error, never a silent no-op.)++### Molecule mode: the full board pipeline in one call++```bash+step2glb convert board.step --board board.kicad_pcb --pin medium -o my-board.glb+# --board implies --molecule; EAGLE/Fusion .brd boards work too+```++One command runs the whole server-side molecule pipeline: MP-marker anchoring (Z-up, front-left machine pin at origin), canonical gold machine-pin material, silkscreen overlay, meter normalization, Draco optimize, and, with a board attached, **footprint + symbol JSON** with per-pad wire-attach Z, downloaded beside the GLB as `<stem>_footprint.json` / `<stem>_symbol.json`. The final GLB carries `asset.extras.adomMolecule` provenance. Gate on the printed stats before publishing (the molecule-publish skill treats them as stop-signs).++### Thumbnails: one pose or the whole family++```bash+step2glb thumbnail part.step --width 800 --height 600 --pose iso+step2glb thumbnail-batch part.step --orientations asIs,zDown,yUp --sizes lg -o thumbs/ ``` -All three pass through to `service-step2glb`, which:-1. Reads STEP via OCCT's `STEPCAFControl_Reader` with colors, names, and materials enabled.-2. Tessellates with `BRepMesh_IncrementalMesh`.-3. Writes glTF with `RWGltf_CafWriter` — the XCAF path that **preserves per-face colors** from the source CAD (unlike `cadquery.Assembly.save('…glTF')` or `kicad-cli pcb export glb`, which both collapse to all-white).+`/thumbnail` renders one shaded PNG (poses: iso, iso2, top, front, side; six up-axis orientations). `/thumbnail-batch` loads the STEP once and renders every orientation x size variant in a single V3d session, about 5 to 10x faster than looping single calls. Batch jobs are async like everything else; the CLI polls and unpacks the PNGs for you (fixed in v0.12.0, issue #6). -The CLI follows adom-cli-design: `OK:` / `ERROR:` / `Hint:` on the first line, then a JSON companion line so AI callers can parse the result structurally.+### The chip-icon family: outlines and laser marks -## The preview webview+```bash+curl -X POST "$SVC/outline?upAxis=z&name=BME688" --data-binary @chip.step        # {style: svg} x 16 OCCT-HLR outline styles+curl -X POST "$SVC/name-emboss?name=BME688&rot=180" --data-binary @chip.step     # MPN lasered into the die: embossed STEP + rendered iso+```++`/outline` produces 16 vector outline styles (hidden-line removal: solid, dashed, neon, x-ray, blueprint, in 7 colors and 3 weights). `/name-emboss` is real STEP editing: it cuts the part number into the chip top as geometry and returns both the embossed STEP and a rendered preview. These two power chip-thumbnailer (below).++### STEP creation and editing++```bash+curl -X POST "$SVC/create-chip"       -d @chip_3d.json       # parametric chip body JSON to STEP+curl -X POST "$SVC/create-footprint"  -d @pads.json          # pad array JSON to STEP+curl -X POST "$SVC/bake" -F [email protected] -F [email protected] # bake transforms into the geometry+```++The service does not just read STEP files, it writes and edits them: generating chip bodies and footprint geometry from JSON specs, baking transforms, and embossing text. Output of every one of these is a downloadable STEP.++### Geometry analysis++```bash+step2glb features part.step -o -     # bbox, floor_z, longest_axis, pin1 detection+curl -X POST "$SVC/step-meta" --data-binary @part.step   # part names, assembly structure, units+```++### Human verification++```bash+step2glb preview Package_QFP/LQFP-100_14x14mm_P0.5mm   # Hydrogen webview: Babylon scene, materials drawer, gizmos+step2glb thumb-playground                              # drag-drop a STEP, watch all 6 orientations x 3 sizes render+``` -`step2glb preview <source>` opens a Hydrogen tab with a Babylon scene tuned per the `3d-viewer-design` skill: brightened gradient bg, medium-gray transparent ground with real cast shadows, depth-tested world-origin gizmo, screen-space corner triad with X/Y/Z labels, Shift+Alt+Click orbit recentering, adaptive camera precision.+![Materials drawer showing per-material color swatches extracted from the STEP](docs/screenshots/02-materials-drawer.png) -### Materials preserved from the STEP file+The preview scene follows the 3d-viewer-design skill: cast shadows, a 65%-transparent ground so bottom views stay readable, world-origin gizmo, corner triad. -The drawer lists every material extracted by OCCT's XCAF pipeline with a live color swatch and RGB values. This is the thing that was missing in the legacy `kicad-cli pcb export glb` path.+![Bottom view: chip visible through the 65%-transparent ground plane](docs/screenshots/03-bottom-transparent-ground.png) -![Materials drawer open showing per-material color swatches extracted from the STEP](docs/screenshots/02-materials-drawer.png)+## Who builds on it -### Bottom view works through a 65%-transparent ground+Real integrations, all in production today: -Per the `3d-viewer-design` §4d ground rule, the ground plane is alpha 0.35 so the chip's bottom face stays visible through it — critical for "is the chip seated on pads or floating above?"+### Barrett's Altium import pipeline -![Bottom view — chip visible through the 65%-transparent ground plane](docs/screenshots/03-bottom-transparent-ground.png)+`barrett-land/adom-desktop-altium-bridge` brings Altium Designer boards into the Adom molecule world. It composites the board's Gerber layers (exposed copper = `.GTL` ∩ `.GTS` mask openings) into one RGBA overlay PNG and hands it to `step2glb --silk-top`, so the converted board GLB shows real copper and silk without baking 3D copper into the STEP (which balloons a 1.9 MB board export to 57.7 MB). His part importer renders orientation thumbnail batches for imported parts the same way. -### Regenerates the 3 broken wiki 3dcomp chips+### chip-thumbnailer, the chip-icon factory++`adom/adom-chip-thumbnailer` gives every chip its complete icon family: 6 shaded orientations x 3 sizes, transparent alpha-keyed icons, the part number lasered onto the die (180 and 90 variants), the embossed STEP that travels to KiCad, and 16 vector outline styles. It is a lean Rust client; every OCCT-heavy render is a call to this service (`/thumbnail`, `/thumbnail-batch`, `/outline`, `/name-emboss`). Those icons flow into adom-symbol overlays, library cards, and wiki chip pages.++### Ray's adom-chip-laser++`adom/adom-chip-laser` etches a component's MPN into its CAD *at the STEP layer* (a light-grey inlay body, real geometry, so the mark survives a Fusion 360 import). Its `board` command then uses this service to derive the etched chip GLB with real materials and the animated board GLB with a toggleable `laser_etch` layer. First live on the `adom/stm32f103c8t6` component page.++### And the molecule pipeline++`molecule-pipeline`, `kicad-export-molecule`, and `fusion-export-molecule` drive this CLI as their conversion stage; the Hydrogen editor consumes the anchored, Draco'd molecule GLBs it emits.++## Direct HTTP API++The service lives at `https://step2glb-gmdoncpxdwx0.adom.cloud` (override with `STEP2GLB_SERVICE_API`; the CLI's `step2glb config` shows what is in effect).++| Endpoint | In | Out |+|---|---|---|+| `GET /health` | | version, git_sha, worker/job counts |+| `POST /convert` | STEP bytes, or multipart `step` + `silk_top`/`silk_bottom`/`board` | GLB (colors preserved) |+| `GET /convert?library=L&name=N` | | GLB (STEP fetched from service-kicad) |+| `POST /thumbnail?width=&height=&pose=&upAxis=` | STEP or GLB bytes | PNG |+| `POST /thumbnail-batch?orientations=&sizes=&pose=` | STEP bytes | JSON with base64 PNGs |+| `POST /outline?upAxis=&name=&suffix=&line2=&rot=` | STEP bytes | JSON `{style: svg}` x 16 |+| `POST /name-emboss?name=&rot=&variant=` | STEP bytes | embossed STEP + rendered iso |+| `POST /features` | STEP bytes | JSON: bbox, floor_z, pin1 |+| `POST /step-meta` | STEP bytes | JSON: names, structure, units |+| `POST /create-chip` | chip_3d JSON | STEP |+| `POST /create-footprint` | pads JSON | STEP |+| `POST /bake` | multipart step + bake JSON | STEP |+| `GET /jobs/{id}` | | job status + stats (silkscreen, molecule, warnings, stage timings) |+| `GET /jobs/{id}/result` | | the binary result |+| `GET /jobs/{id}/footprint` `/symbol` | | molecule side artifacts |++Every POST needs identity headers:++```+X-Client:   <tool-name>/<user-or-container>     e.g. adom-tsci/john-dev+X-Job-Name: <endpoint>-<what>-<part>-<context>  e.g. convert-usbc-type-c-31-m-12+``` -`Package_TO_SOT_SMD/SOT-89-3` was one of three wiki 3dcomps whose GLBs had no chip body — just the baked PCB demo — because the legacy kicad-cli GLB export stripped the body. step2glb regenerates it cleanly:+All POSTs return `202 {"job_id": …, "poll": "/jobs/<id>"}`. Poll, then download. The service root URL serves a live HTML dashboard (health, recent jobs, per-client counts) if you just open it in a browser. -![SOT-89-3 iso view — clean conversion with ground + shadow](docs/screenshots/04-iso-sot-89-3.png)+## The service container (ops) -## Install (Tier B, from the wiki)+- **Container:** `john/service-step2glb` (slug `gm21g865swdl`), a small `default-light` container owned by John. SSH: `ssh [email protected]`.+- **Source:** [`adom-inc/service-occt`](https://github.com/adom-inc/service-occt) (renamed from `service-step2glb`; the old remote redirects). Python + OCCT (`cadquery-ocp`), Xvfb for offscreen V3d rendering, gltf-transform for compression.+- **Deploy:** push to `main`; a cron watchdog (every 2 min) health-checks, auto-pulls, and restarts. Logs at `/tmp/service-step2glb.log` on the container.+- **Version check:** `curl -s https://step2glb-gmdoncpxdwx0.adom.cloud/health` shows the exact `git_sha_short` deployed. -See `SKILL.md` for the one-liner. No venv, no Python, no OCCT on the local container — just a 3 MB binary.+## What's new -## Related repos+**Service 1.2.1 + CLI 0.12.0** (2026-08-06), both filed by barrett-land, thanks for the sharp repros: -- **adom-inc/service-step2glb** — the shared-service container this CLI talks to. Owns the Python helper, OCCT venv, and watchdog.-- **adom-inc/service-kicad** — supplies STEP files (+ all other KiCad library content) to `from-library` calls.+- **Fixed #6:** every queued `thumbnail-batch` job died server-side with `NameError: name 'job_type' is not defined` (a copy-pasted resident-worker block referencing out-of-scope variables). And the CLI now polls `202` async batch jobs instead of erroring out with an empty output directory.+- **Fixed #5:** `--silk-top` / `--silk-bottom` were silently ignored without `--molecule` (the CLI never sent the PNGs on the plain path). They now apply in both modes, the plain-convert output reports `silkscreen_applied` / `silkscreen_sides`, and an overlay that fails to apply is a loud error with the service warnings printed.  ## Local development @@ -59,4 +191,4 @@ See `SKILL.md` for the one-liner. No venv, no Python, no OCCT on the local conta ./tests/ralph.sh      # 5-package conversion smoke test; pass criteria: meshes>=1, materials>=1, sizeBytes>1000 ``` -The `preview` subcommand still spawns a local `tiny_http` — Hydrogen webviews can't reach the external service URL directly, so the GLB is fetched once from the service and re-served on the user's container-proxy port for the webview.+The `preview` subcommand spawns a local `tiny_http` server: Hydrogen webviews cannot reach the external service URL directly, so the GLB is fetched once from the service and re-served on the container-proxy port.
SKILL.md+22−5
@@ -1,13 +1,15 @@ --- name: step2glb-description: Convert STEP (.step/.stp) CAD files to color-preserving GLBs via the shared OCCT service — plain converts, geometric features, thumbnails, previews, and FULL MOLECULE MODE (--molecule/--board: MP-marker anchoring, gold machine pins, footprint + symbol JSON with per-pad wire-attach Z). Thin ~3MB Rust CLI; the 4.7GB OCCT runtime stays on the service. Use when the user asks to convert a STEP to GLB, convert a molecule board, render a component with real materials, extract STEP features, or make STEP thumbnails. Triggers: step to glb, step2glb, convert step, cad to glb, molecule convert, kicad step to glb, occt gltf, step preview, step thumbnail, step features.+description: The Adom STEP engine, CLI + shared OCCT service under one name. Convert STEP (.step/.stp) CAD files to color-preserving GLBs, render thumbnails (single + batch) and 16 vector outline styles, laser-emboss part numbers into the die, create/edit STEP geometry (create-chip, create-footprint, bake), extract features, and run FULL MOLECULE MODE (--molecule/--board: MP-marker anchoring, gold machine pins, footprint + symbol JSON with per-pad wire-attach Z). Thin ~3MB Rust CLI; the OCCT runtime stays on the service. Use when the user asks to convert a STEP to GLB, convert a molecule board, render a component with real materials, extract STEP features, make STEP thumbnails, etch a name onto a chip, or edit/generate STEP geometry. Triggers: step to glb, step2glb, convert step, cad to glb, molecule convert, kicad step to glb, occt gltf, occt service, step preview, step thumbnail, step features, edit step file, create chip step, emboss mpn. --- -# step2glb — STEP → GLB via the shared OCCT service+# adom-step2glb — the Adom STEP engine (CLI + shared OCCT service)  Thin Rust CLI (~3 MB, no CAD kernel) that shells to the shared OCCT XCAF-service. Returns `OK:`/`ERROR:` + `Hint:` lines so humans and agents both-parse results.+service. This one name covers BOTH halves: the CLI you install and the+shared service container behind it (formerly "service-step2glb"); issues+for either belong on the adom/adom-step2glb wiki page. Returns+`OK:`/`ERROR:` + `Hint:` lines so humans and agents both parse results.  - **Install / update:** `adom-wiki pkg install adom/adom-step2glb` - **Service:** `https://step2glb-gmdoncpxdwx0.adom.cloud` (the canonical shared@@ -59,16 +61,31 @@ The final GLB carries `asset.extras.adomMolecule` (anchored, pin size, etc.) — durable provenance you can check on any downloaded GLB. (Do NOT look for a `molecule_anchor` node: the optimizer flattens it by design.) +## Silkscreen overlays work in BOTH modes (v0.12.0+)++`--silk-top` / `--silk-bottom` (white-on-transparent PNG) apply in plain+converts AND molecule mode. The output reports `silkscreen_applied` ++`silkscreen_sides`; an overlay that fails to apply is a hard error, never+a silent no-op. Composited copper/mask art (e.g. from Gerbers) works too.+ ## Other verbs  ```bash step2glb features part.step          # bbox, floor_z, longest axis, pin1 → JSON step2glb thumbnail part.step         # PNG render (--width/--height/--pose)-step2glb thumbnail-batch part.step   # N orientations x M sizes in one call+step2glb thumbnail-batch part.step   # N orientations x M sizes in one call (async; CLI polls for you) step2glb config                      # show the service URL in effect step2glb install                     # refresh this skill + bash completions ``` +The service also exposes HTTP-only verbs the CLI does not wrap yet:+`/outline` (16 OCCT-HLR vector outline styles), `/name-emboss` (laser the+MPN into the die: embossed STEP + rendered iso), `/create-chip` ++`/create-footprint` (JSON spec → STEP), `/bake` (bake transforms into+geometry), `/step-meta` (names/structure/units). All async: 202 + job_id,+poll `/jobs/{id}`, download `/jobs/{id}/result`. POSTs need `X-Client` ++`X-Job-Name` headers.+ ## Where this fits  `molecule-publish` (wiki publishing) and the EDA export skills
VERSION+1−1
@@ -1 +1 @@-0.11.3+0.12.0
adom-step2glb
⋯ 1 unchanged line ⋯
package.json+23−6
@@ -1,15 +1,18 @@ {   "slug": "adom-step2glb",-  "version": "1.1.5",+  "version": "1.2.0",   "type": "app",-  "description": "Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.",+  "description": "The Adom STEP engine: color-preserving STEP to GLB conversion, thumbnails, vector outlines, MPN laser embossing, STEP creation/editing, and molecule mode. One name for the thin Rust CLI and the shared OCCT service behind it. Formerly 'step2glb' + 'service-step2glb'.",   "tags": [     "step",     "glb",     "cad",     "converter",     "occt",-    "3d"+    "3d",+    "thumbnails",+    "step-editing",+    "service"   ],   "hero": {     "type": "image",@@ -31,9 +34,23 @@     "step2glb molecule",     "render step with materials",     "kicad step to glb",-    "regenerate chip glb"+    "regenerate chip glb",+    "occt service",+    "step service",+    "step thumbnails",+    "thumbnail batch",+    "chip outline svg",+    "laser etch mpn",+    "emboss part number",+    "edit step file",+    "create chip step",+    "create footprint step",+    "bake step transform",+    "step features",+    "step metadata",+    "silkscreen overlay glb"   ],-  "discovery_pitch": "Convert STEP CAD files to color-preserving GLBs via the shared OCCT service - plain converts, thumbnails, features, and full molecule mode (anchored GLB + footprint + symbol with --molecule --board).",+  "discovery_pitch": "Anything STEP that needs a real CAD kernel: color-preserving GLB conversion, single + batch thumbnails, 16 vector outline styles, MPN laser embossing, STEP creation/editing (create-chip, create-footprint, bake), feature extraction, and full molecule mode (anchored GLB + footprint + symbol). Thin ~3MB CLI; the shared OCCT service container does the heavy lifting.",   "sample_prompts": [     {       "label": "Plain convert",@@ -48,4 +65,4 @@       "prompt": "Convert this STEP and show me the result with materials"     }   ]-}\ No newline at end of file+}
page.json+23−6
@@ -1,15 +1,18 @@ {   "slug": "adom-step2glb",-  "version": "1.1.5",+  "version": "1.2.0",   "type": "app",-  "description": "Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.",+  "description": "The Adom STEP engine: color-preserving STEP to GLB conversion, thumbnails, vector outlines, MPN laser embossing, STEP creation/editing, and molecule mode. One name for the thin Rust CLI and the shared OCCT service behind it. Formerly 'step2glb' + 'service-step2glb'.",   "tags": [     "step",     "glb",     "cad",     "converter",     "occt",-    "3d"+    "3d",+    "thumbnails",+    "step-editing",+    "service"   ],   "hero": {     "type": "image",@@ -38,9 +41,23 @@     "step2glb molecule",     "render step with materials",     "kicad step to glb",-    "regenerate chip glb"+    "regenerate chip glb",+    "occt service",+    "step service",+    "step thumbnails",+    "thumbnail batch",+    "chip outline svg",+    "laser etch mpn",+    "emboss part number",+    "edit step file",+    "create chip step",+    "create footprint step",+    "bake step transform",+    "step features",+    "step metadata",+    "silkscreen overlay glb"   ],-  "discovery_pitch": "Convert STEP CAD files to color-preserving GLBs via the shared OCCT service - plain converts, thumbnails, features, and full molecule mode (anchored GLB + footprint + symbol with --molecule --board).",+  "discovery_pitch": "Anything STEP that needs a real CAD kernel: color-preserving GLB conversion, single + batch thumbnails, 16 vector outline styles, MPN laser embossing, STEP creation/editing (create-chip, create-footprint, bake), feature extraction, and full molecule mode (anchored GLB + footprint + symbol). Thin ~3MB CLI; the shared OCCT service container does the heavy lifting.",   "sample_prompts": [     {       "label": "Plain convert",@@ -55,4 +72,4 @@       "prompt": "Convert this STEP and show me the result with materials"     }   ]-}\ No newline at end of file+}
src/cli.rs+52−7
@@ -86,10 +86,11 @@ pub struct ConvertArgs {     /// marker names when they carry a size (pin_size_source: markers).     #[arg(long, default_value = "medium")]     pub pin: String,-    /// Top silkscreen PNG overlay (white artwork on transparent).+    /// Top silkscreen PNG overlay (white artwork on transparent). Works in+    /// both plain and --molecule mode; the response reports silkscreen_applied.     #[arg(long)]     pub silk_top: Option<PathBuf>,-    /// Bottom silkscreen PNG overlay.+    /// Bottom silkscreen PNG overlay. Works in both plain and --molecule mode.     #[arg(long)]     pub silk_bottom: Option<PathBuf>, }@@ -242,6 +243,17 @@ pub fn convert_cmd(args: ConvertArgs) {         Ok(b) => b,         Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }     };+    // Silk overlays must never be silently dropped (#5): a specified file that+    // can't be read is a hard error, not a skipped part.+    let read_silk = |p: &Option<PathBuf>| -> Option<Vec<u8>> {+        p.as_ref().map(|p| match std::fs::read(p) {+            Ok(b) => b,+            Err(e) => {+                err(format!("read silk overlay {}: {e}", p.display()));+                std::process::exit(1);+            }+        })+    };     // Molecule mode (#99): --molecule or an attached --board routes through the     // molecule pipeline (anchor, gold, footprint/symbol emit) instead of the     // plain convert.@@ -263,14 +275,11 @@ pub fn convert_cmd(args: ConvertArgs) {             }             None => None,         };-        let read_opt = |p: &Option<PathBuf>| -> Option<Vec<u8>> {-            p.as_ref().and_then(|p| std::fs::read(p).ok())-        };         let opts = client::MoleculeOpts {             board,             pin: args.pin.clone(),-            silk_top: read_opt(&args.silk_top),-            silk_bottom: read_opt(&args.silk_bottom),+            silk_top: read_silk(&args.silk_top),+            silk_bottom: read_silk(&args.silk_bottom),         };         match client::convert_molecule(&bytes, &opts, &out) {             Ok(r) => print_molecule_success(&r, &out, args.board.is_some()),@@ -283,6 +292,42 @@ pub fn convert_cmd(args: ConvertArgs) {         return;     } +    // Plain convert with silk overlays (#5): route through the multipart+    // endpoint (molecule=false) so the PNGs actually reach the service.+    if args.silk_top.is_some() || args.silk_bottom.is_some() {+        let opts = client::MoleculeOpts {+            board: None,+            pin: args.pin.clone(),+            silk_top: read_silk(&args.silk_top),+            silk_bottom: read_silk(&args.silk_bottom),+        };+        match client::convert_multipart(&bytes, &opts, false, &out) {+            Ok(r) => {+                print_success(&r.convert, &out, Some(&args.input.display().to_string()));+                let applied = r.stats["silkscreen_applied"].as_bool().unwrap_or(false);+                crate::note!("  silkscreen_applied: {applied}");+                if !r.stats["silkscreen_sides"].is_null() {+                    crate::note!("  silkscreen_sides: {}", r.stats["silkscreen_sides"]);+                }+                if !applied {+                    warn("silk overlay was sent but the service did not apply it — check the warnings in the job stats.".to_string());+                    if let Some(warns) = r.stats["warnings"].as_array() {+                        for w in warns {+                            warn(w.as_str().unwrap_or(&w.to_string()).to_string());+                        }+                    }+                    std::process::exit(4);+                }+            }+            Err(e) => {+                err(format!("conversion failed: {e}"));+                hint("Run `step2glb health` to check the shared service is reachable.");+                std::process::exit(4);+            }+        }+        return;+    }+     match client::convert_bytes(&bytes, &out) {         Ok(r) => print_success(&r, &out, Some(&args.input.display().to_string())),         Err(e) => {
src/client.rs+85−21
@@ -105,9 +105,10 @@ pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, Stri     write_response(r, out_path) } -/// Molecule-mode conversion options (#99). `board` is (filename, bytes) — the+/// Multipart conversion options (#99). `board` is (filename, bytes) — the /// server sniffs .kicad_pcb vs .brd from the content; the filename just aids-/// logs. Silk PNGs are white-on-transparent overlays.+/// logs. Silk PNGs are white-on-transparent overlays, applied in BOTH plain+/// and molecule mode (#5). pub struct MoleculeOpts {     pub board: Option<(String, Vec<u8>)>,     pub pin: String,@@ -115,9 +116,10 @@ pub struct MoleculeOpts {     pub silk_bottom: Option<Vec<u8>>, } -/// Molecule job outcome: the GLB stats, the FULL job-status JSON (anchor/gold/-/// footprint stats — printed generically so new service fields surface without-/// a CLI rebuild), and which side artifacts were written beside the GLB.+/// Multipart job outcome: the GLB stats, the FULL job-status JSON (anchor/gold/+/// footprint/silkscreen stats — printed generically so new service fields+/// surface without a CLI rebuild), and which side artifacts were written+/// beside the GLB. pub struct MoleculeResult {     pub convert: ConvertResult,     pub stats: serde_json::Value,@@ -125,19 +127,27 @@ pub struct MoleculeResult {     pub symbol_path: Option<std::path::PathBuf>, } -/// POST /convert?molecule=true&pin=… as multipart (step + board + silks), poll-/// the job, download the GLB and — when the job produced them — the footprint-/// and symbol JSON beside it (`<stem>_footprint.json` / `<stem>_symbol.json`).-pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Result<MoleculeResult, String> {-    let job_name = format!(-        "molecule-{}",-        out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown")-    );-    let url = format!(-        "{}/convert?molecule=true&pin={}",-        service_url(),-        urlencode(&opts.pin)-    );+/// POST /convert as multipart (step + board + silks), poll the job, download+/// the GLB and — when the job produced them — the footprint and symbol JSON+/// beside it (`<stem>_footprint.json` / `<stem>_symbol.json`). With+/// `molecule=false` this is a plain convert that still carries silk overlays+/// (#5): the service applies them in either mode.+pub fn convert_multipart(step: &[u8], opts: &MoleculeOpts, molecule: bool, out_path: &Path) -> Result<MoleculeResult, String> {+    let stem = out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");+    let job_name = if molecule {+        format!("molecule-{stem}")+    } else {+        format!("convert-silk-{stem}")+    };+    let url = if molecule {+        format!(+            "{}/convert?molecule=true&pin={}",+            service_url(),+            urlencode(&opts.pin)+        )+    } else {+        format!("{}/convert", service_url())+    };      let boundary = "----step2glb-molecule-7f9c2ad41b";     let mut body: Vec<u8> = Vec::new();@@ -182,10 +192,15 @@ pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Re     let job_id = job["job_id"]         .as_str()         .ok_or_else(|| format!("no job_id in response: {body_s}"))?;-    eprintln!("Molecule conversion queued (job {job_id}). Polling...");+    eprintln!("Conversion queued (job {job_id}). Polling...");     poll_molecule_job(job_id, out_path) } +/// Back-compat wrapper: molecule-mode multipart convert.+pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Result<MoleculeResult, String> {+    convert_multipart(step, opts, true, out_path)+}+ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, String> {     let base = service_url();     let poll_url = format!("{base}/jobs/{job_id}");@@ -223,7 +238,7 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St             }             "failed" => {                 let e = status["error"].as_str().unwrap_or("unknown error");-                return Err(format!("molecule job {job_id} failed: {e}"));+                return Err(format!("convert job {job_id} failed: {e}"));             }             st => {                 if round % 6 == 0 {@@ -232,7 +247,7 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St             }         }     }-    Err(format!("molecule job {job_id} timed out after 20 minutes"))+    Err(format!("convert job {job_id} timed out after 20 minutes")) }  /// GET /jobs/{id}/footprint|symbol → write `<glb stem>_<kind>.json` beside the@@ -397,6 +412,17 @@ pub fn thumbnail_batch_bytes(         .send_bytes(body)         .map_err(|e| format!("POST {url} failed: {e}"))?;     let status = r.status();+    // Large inputs are queued as async jobs (#6): 202 + {job_id, poll}.+    // Poll the job and download out.json, same contract as async /convert.+    if status == 202 {+        let body_s = r.into_string().unwrap_or_default();+        let job: serde_json::Value = serde_json::from_str(&body_s)+            .map_err(|_| format!("async 202 but invalid JSON: {body_s}"))?;+        let job_id = job["job_id"].as_str()+            .ok_or_else(|| format!("async 202 but no job_id: {body_s}"))?;+        eprintln!("Large file queued for async batch render (job {job_id}). Polling...");+        return poll_thumbnail_batch_job(job_id);+    }     if status != 200 {         let body = r.into_string().unwrap_or_default();         return Err(format!("service returned HTTP {status}: {body}"));@@ -405,6 +431,44 @@ pub fn thumbnail_batch_bytes(         .map_err(|e| format!("batch JSON parse: {e}")) } +fn poll_thumbnail_batch_job(job_id: &str) -> Result<BatchThumbnailResult, String> {+    let base = service_url();+    let poll_url = format!("{base}/jobs/{job_id}");+    let result_url = format!("{base}/jobs/{job_id}/result");++    for round in 1..=240 {+        // 240 × 3s = 12 minutes; the service kills batch jobs at 600s.+        std::thread::sleep(Duration::from_secs(3));+        let r = ureq::get(&poll_url).timeout(Duration::from_secs(15)).call()+            .map_err(|e| format!("poll {poll_url} failed: {e}"))?;+        let status: serde_json::Value =+            serde_json::from_str(&r.into_string().unwrap_or_default()).unwrap_or_default();++        match status["status"].as_str().unwrap_or("unknown") {+            "complete" => {+                let r = ureq::get(&result_url).timeout(Duration::from_secs(120)).call()+                    .map_err(|e| format!("GET {result_url} failed: {e}"))?;+                if r.status() != 200 {+                    let b = r.into_string().unwrap_or_default();+                    return Err(format!("result download HTTP error: {b}"));+                }+                return r.into_json::<BatchThumbnailResult>()+                    .map_err(|e| format!("batch JSON parse: {e}"));+            }+            "failed" => {+                let e = status["error"].as_str().unwrap_or("unknown error");+                return Err(format!("batch job {job_id} failed: {e}"));+            }+            st => {+                if round % 10 == 0 {+                    eprintln!("  [{round}] still {st}...");+                }+            }+        }+    }+    Err(format!("batch job {job_id} timed out after 12 minutes of polling"))+}+ /// GET /convert?library=<L>&name=<N> — service fetches STEP from service-kicad /// and converts in one round-trip. pub fn convert_from_library(lib_slash_name: &str, out_path: &Path) -> Result<ConvertResult, String> {