refactor: add DesignIR distillation pipeline

This commit is contained in:
Jerry
2026-07-27 17:24:04 +08:00
parent 0adaac2b2a
commit 550b339bcc
1128 changed files with 10248 additions and 426666 deletions
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-209
View File
@@ -1,209 +0,0 @@
---
name: bambu-labs
description: Dry-run, upload, and cautiously initiate local Bambu Lab print jobs from validated plain `.gcode`, using Bambu LAN FTPS/MQTT handoffs.
---
# Bambu Labs
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill for local-network Bambu Lab print handoffs after a plain `.gcode`
file already exists and has been validated. This skill does not slice models.
## Safety Rules
- Default to dry-run plans. Real printer traffic requires `--execute`.
- Never start a print without `--execute --confirm-start-print`.
- Pause and cancel controls are live printer requests; default to dry-run plans.
Canceling a print requires `--execute --confirm-cancel-print`.
- Treat an explicit user request to print or start a specific job as live-start
authorization; do not pause for a second confirmation solely for physical
checks. Still validate the G-code, inspect the dry-run payload, read printer
status, prefer upload-only before upload-start, state the physical checks, and
stop if validation/status/intent is unsafe or ambiguous.
- Do not ask for the printer serial by default; fetch it from the printer TLS certificate with `serial` or let `send` cache it.
- Prefer workspace-root `bambu-printers.json` over repeating access codes in commands. The file is local config and should be ignored by Git.
- Before a live start, state the physical checks: clear build plate, correct plate/filament/nozzle, safe surroundings, and operator nearby.
- Publishing MQTT is only a start request. Confirm acceptance with printer status/UI and physical observation.
## CAD Viewer Handoff
After completing Bambu work that creates or modifies a local supported print artifact such as `.gcode` or `.3mf`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Workflow
1. Generate and validate plain G-code with `$gcode`.
If no slicer is installed, install OrcaSlicer and retry; do not treat the missing slicer as a blocker. On macOS, prefer `brew install --cask orcaslicer`.
2. Configure the printer. The user can either give the IP/access code in the thread and let the agent write JSON, or edit `bambu-printers.json` directly.
For a new printer setup or onboarding request, read
`references/new-printer-onboarding.md` first. Walk the user through the
model-specific touchscreen steps to find the IP and LAN access code, and make
**Enable LAN Only** plus **Enable Developer Mode** explicit before running
local start workflows.
```bash
python scripts/bambu_lan_print.py config set \
--printer a1-mini \
--host 192.168.1.34 \
--access-code 12345678 \
--model a1-mini \
--fetch-serial
```
Manual JSON shape:
```json
{
"printers": {
"a1-mini": {
"host": "192.168.1.34",
"access_code": "12345678",
"model": "a1-mini"
}
}
}
```
On A1/A1 Mini, find the IP and LAN access code on the printer touchscreen under
network/LAN settings. Enable LAN Only and Developer Mode when offered, then
power-cycle before retrying local start commands.
3. Read status before live work:
```bash
python scripts/bambu_lan_print.py status \
--printer a1-mini \
--push-all \
--wait-seconds 10
```
4. Dry-run the exact handoff, inspect the JSON payload, then run upload-only.
Only after upload succeeds should you run upload-start. If the user explicitly
asked to print or start the job, proceed to `upload-start --execute
--confirm-start-print` after the validation, status, and upload checks pass. If
the user only asked to prepare, slice, upload, or review, stop before the start
request.
## Handoff Modes
`--handoff template-project` is the validated A1 Mini path from this repo's LAN
debugging. It starts from validated plain `.gcode`, copies a known-good
same-printer `.gcode.3mf` template, replaces `Metadata/plate_N.gcode`, writes
the plate MD5, uploads the project to the FTPS root, and publishes
`print.project_file` with `url: ftp:///<name>.gcode.3mf`.
```bash
python scripts/bambu_lan_print.py send \
--printer a1-mini \
--gcode /tmp/job.gcode \
--handoff template-project \
--template-project /path/to/same-printer-template.gcode.3mf \
--action upload-start
```
Execute after review when the user explicitly asked to print or start, or after
physical confirmation when intent is unclear:
```bash
python scripts/bambu_lan_print.py send \
--printer a1-mini \
--gcode /tmp/job.gcode \
--handoff template-project \
--template-project /path/to/same-printer-template.gcode.3mf \
--action upload-start \
--execute \
--confirm-start-print
```
`--handoff plain` uploads `cache/<name>.gcode` and publishes
`print.gcode_file`. Keep it for diagnostics or printers/firmware where this is
known to work. On the tested A1 Mini, direct plain G-code was uploaded
successfully but `gcode_file` failed or was ignored, so do not use it as the
A1 Mini live-start path.
`--handoff bambox-project` packages plain `.gcode` with `bambox`, uploads the
`.gcode.3mf` project to FTPS root, and publishes `print.project_file`.
Currently enabled only for `p1s-0.4` with `PLA`, `ASA`, or `PETG-CF`.
Known but disabled until validated profiles exist: `a1-mini-0.4`, `a1-0.4`,
`x1c-0.4`, and `p1p-0.4`.
## Common Debugging Commands
Fetch/cache serial:
```bash
python scripts/bambu_lan_print.py serial \
--printer a1-mini \
--json
```
Clear a stale printer error after fixing the underlying cause:
```bash
python scripts/bambu_lan_print.py clear-error \
--printer a1-mini \
--execute
```
Use `--mqtt-qos 1 --wait-after-publish 10` on `send` when debugging whether the
printer acknowledged the MQTT publish and what status it reported immediately
afterward.
## Print Controls
For a running print, use dedicated print-control commands rather than ad hoc
MQTT snippets. These commands publish only a control request; they do not upload
files or start a new job. Read status after execution to confirm the printer
state changed.
Dry-run pause payload:
```bash
python scripts/bambu_lan_print.py pause \
--printer a1-mini
```
Execute pause and collect printer reports:
```bash
python scripts/bambu_lan_print.py pause \
--printer a1-mini \
--execute \
--mqtt-qos 1 \
--wait-after-publish 10
```
Dry-run cancel payload. The Bambu LAN command sent to the printer is `stop`:
```bash
python scripts/bambu_lan_print.py cancel \
--printer a1-mini
```
Execute cancel only when the user explicitly asks to cancel/stop the print or
after confirmation when intent is ambiguous:
```bash
python scripts/bambu_lan_print.py cancel \
--printer a1-mini \
--execute \
--confirm-cancel-print \
--mqtt-qos 1 \
--wait-after-publish 10
```
## Failure Modes
- `gcode_file` returns `result: fail` or leaves the printer `IDLE`: plain G-code upload worked, but the firmware rejected or ignored direct local start. For A1 Mini, switch to `template-project`.
- Project uploaded under `cache/` starts then fails with `print_error: 83935248` or `0500-C010`: clear the error, upload project handoffs to FTPS root, and use `ftp:///<name>.gcode.3mf`.
- `file:///sdcard/cache/...` or local HTTP URLs appear accepted but nothing starts: stop using those URL forms for this workflow.
- Bambu Studio or OrcaSlicer project export crashes on macOS: do not keep retrying GUI-backed project export. Use OrcaSlicer for plain `.gcode`, then this skill for handoff.
- Stale `gcode_state: FAILED` or HMS after enabling Developer Mode: clear the printer error and power-cycle before retrying.
- FTPS login works but upload fails with `553` or missing `cache/`: check printer storage/SD card status before MQTT start.
- MQTT status works but start does not: confirm serial, access code, Developer Mode/LAN Only status, and the exact handoff payload before retrying.
Read `references/new-printer-onboarding.md` for new printer setup,
`references/local-lan-protocol.md` for protocol details, and
`references/real-printer-checklist.md` before first live use on a new printer.
@@ -1,4 +0,0 @@
interface:
display_name: "Bambu Labs"
short_description: "Dry-run and start local Bambu G-code handoffs"
default_prompt: "Use $bambu-labs to dry-run a validated plain G-code handoff before starting it."
@@ -1,123 +0,0 @@
# Bambu Lab Local LAN Protocol Notes
Use this reference only when planning or debugging against a real local printer.
Bambu does not provide this skill with a stable public local-print API
contract, so treat these as observed FTPS/MQTT behaviors.
## Required Inputs
- Validated plain `.gcode`.
- Printer LAN IP/hostname.
- Printer access code.
- Printer serial for MQTT topic `device/{serial}/request`; fetch it from the
printer TLS certificate with `serial` or let `send` fetch/cache it.
- Handoff mode: `template-project`, `plain`, or `bambox-project`.
Workspace-root `bambu-printers.json` stores printer IDs, hostnames, access codes, models,
and cached serials. It is local config and should be ignored by Git.
## Transport
- FTPS upload: implicit TLS on port `990`.
- MQTT control/status: TLS on port `8883`.
- Username: `bblp`.
- Password: printer access code.
- TLS verification is off by default because local printers commonly use
device/self-signed certificates.
- The helper rejects public IPs/hostnames unless `--allow-nonprivate-host` is
set.
- FTPS data connections may require TLS session reuse. The helper reuses the
control TLS session for uploads and listings.
## MQTT Topics
- Request topic: `device/{serial}/request`.
- Report topic: `device/{serial}/report`.
- `status --push-all` subscribes to reports, then publishes `pushing.pushall`
to request a full state report.
- Publishing a start payload is a request, not proof of accepted motion.
## Handoff Payloads
### Template Project
Validated on an A1 Mini during local LAN debugging:
1. Start from a validated plain `.gcode`.
2. Copy a known-good same-printer `.gcode.3mf` template.
3. Replace `Metadata/plate_N.gcode` and update `Metadata/plate_N.gcode.md5`.
4. Upload the resulting `.gcode.3mf` to FTPS root, not `cache/`.
5. Publish `print.project_file` with root FTP URL.
Representative payload:
```json
{
"print": {
"command": "project_file",
"param": "Metadata/plate_1.gcode",
"project_id": "0",
"profile_id": "0",
"task_id": "0",
"subtask_id": "0",
"subtask_name": "job",
"url": "ftp:///job.gcode.3mf",
"md5": "PROJECT_MD5_UPPERCASE",
"timelapse": false,
"bed_type": "auto",
"bed_levelling": true,
"flow_cali": true,
"vibration_cali": false,
"layer_inspect": true,
"use_ams": false,
"ams_mapping": ""
}
}
```
### Plain G-code
The plain path uploads `cache/<job>.gcode` and publishes:
```json
{
"print": {
"command": "gcode_file",
"param": "cache/job.gcode"
}
}
```
On the tested A1 Mini, byte-for-byte verified uploads still produced
`gcode_file` failure/idle behavior. Use this path only for diagnostics or
printer firmware where it has been validated.
### Bambox Project
The optional `bambox-project` path packages plain G-code into `.gcode.3mf` for
enabled profiles, validates the archive, uploads it to FTPS root, and publishes
the same `project_file` shape as template projects. Current enabled profile is
`p1s-0.4`; A1/A1 Mini are disabled until a validated bambox profile exists.
## Observed Failure Modes
- **Direct G-code rejected:** MQTT report after `gcode_file` may include
`{"command":"gcode_file","result":"fail","reason":"error string"}`. Stop and
use a project handoff.
- **Direct G-code ignored:** uploaded file exists, HMS is empty, target
temperatures stay at zero, and `gcode_state` remains `IDLE`. Do not keep
cycling path variants.
- **Project in `cache/` fails:** `project_file` can be accepted and then fail
with `print_error: 83935248` / `0500-C010`. Clear the error and upload project
files to FTPS root with `ftp:///<name>.gcode.3mf`.
- **HTTP/file URLs inert:** `file:///sdcard/cache/...` and local HTTP URLs may
be accepted without fetching or starting. Do not use them for this workflow.
- **Bambu/Orca project export crash:** GUI-backed CLI project export on macOS
can crash inside AppKit/BambuStudio. Use slicer CLI only for plain `.gcode`.
- **Stale printer state:** after LAN Only/Developer Mode changes, clear errors
and power-cycle. Status can retain stale `FAILED`/HMS values.
- **Storage unavailable:** if FTPS auth works but upload fails with `553`, or
status reports no SD/storage, resolve storage before MQTT start.
Use `clear-error --execute` only after the underlying cause is fixed. It
publishes `print.clean_print_error` and does not start motion.
@@ -1,139 +0,0 @@
# New Printer Onboarding
Use this reference whenever the user asks to set up, add, pair, configure, or
test a new Bambu Lab printer for local LAN handoff. The goal is a calm,
step-by-step setup where the user always knows what is safe, what is private,
and what the agent will do next.
## Agent Flow
1. Start by confirming this is setup only. Say that no upload or print will
happen during onboarding unless the user explicitly asks later.
2. Ask for the printer model or family if it is not already known. Use a stable
local printer id such as `a1-mini`, `a1`, `p1s`, `p1p`, `x1c`, or `h2d`.
3. Tell the user you need two values from the printer touchscreen:
printer IP address and LAN access code. Explain that the serial is fetched
from the printer certificate and should not be requested by default.
4. Immediately give the model-specific touchscreen steps below. Do not just ask
for the IP and access code without helping the user find them.
5. Make the LAN prerequisites very explicit: the user must **Enable LAN Only**
and **Enable Developer Mode** before live local start commands. If either
option exists and is off, stop setup and ask them to enable it first.
6. After the user provides the IP and access code, store them with `config set`
in workspace-root `bambu-printers.json`, using `--fetch-serial`. Do not repeat the
access code in the final response.
7. Run `status --push-all --wait-seconds 10` and summarize the result in plain
language: reachable or not, idle/running/error, SD/storage present, and any
`print_error` or HMS entries.
8. If setup succeeds, offer the next safe step as a dry-run handoff plan. Do not
upload or start anything during onboarding unless the user explicitly asks.
## What To Say First
Use a short, reassuring prompt like this:
```text
I can set this up safely. I need the printer's LAN IP address and LAN access
code from the touchscreen. Before you send them, please enable LAN Only and
Developer Mode; local starts often fail or become read-only if Developer Mode is
off. I will store the code only in the workspace-local ignored config and fetch the
serial from the printer automatically.
```
Then give the matching steps for their printer family.
## Touchscreen Steps By Printer Family
### A1 / A1 Mini
1. On the printer touchscreen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Connect the printer to the same trusted local network as this computer.
4. **Enable LAN Only**.
5. **Enable Developer Mode** in the same network/LAN area when shown.
6. Record the IPv4 address shown on the network screen.
7. Record the LAN access code shown near the LAN Only or Developer Mode
controls. If it is all zeros, refresh/regenerate it or toggle LAN Only off
and on, then record the new code.
8. Power-cycle the printer after changing LAN Only or Developer Mode.
### P1P / P1S
1. On the printer screen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Confirm the printer is on the same trusted local network as this computer.
4. **Enable LAN Only**.
5. **Enable Developer Mode**. If it is not visible, update firmware or stop
before live local start workflows.
6. Record the printer IPv4 address.
7. Record the LAN access code. If the code is all zeros or blank, refresh it or
toggle LAN Only off and on, then record the new code.
8. Power-cycle the printer before the first local status check.
### X1 / X1C / X1E
1. On the printer touchscreen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Confirm the printer is connected to the same trusted local network.
4. **Enable LAN Only**.
5. **Enable Developer Mode**. If it is not visible, update firmware or stop
before live local start workflows.
6. Record the IPv4 address.
7. Record the LAN access code from the LAN/network screen.
8. Power-cycle the printer after changing LAN settings.
### H2D / Newer Bambu Printers
1. Open **Settings** on the printer touchscreen.
2. Open **Network**, **WLAN**, or the printer's LAN settings panel.
3. Confirm local network connectivity.
4. **Enable LAN Only**.
5. **Enable Developer Mode**.
6. Record the IPv4 address and LAN access code.
7. Power-cycle after changing LAN settings.
For an unknown Bambu model, guide the user to the printer's network/WLAN
settings, then require the same four items before continuing: same local
network, **Enable LAN Only**, **Enable Developer Mode**, and collect IP plus
LAN access code.
## Agent Commands
Use the active Python environment:
```bash
python scripts/bambu_lan_print.py config set \
--printer a1-mini \
--host 192.168.1.34 \
--access-code 12345678 \
--model a1-mini \
--fetch-serial
```
Then check status:
```bash
python scripts/bambu_lan_print.py status \
--printer a1-mini \
--push-all \
--wait-seconds 10
```
Use the model-specific printer id in both commands. If sandboxing blocks local
network access, rerun the same command with permission to contact the printer.
## Smooth Failure Handling
- If the printer is unreachable, ask the user to confirm the IP, same Wi-Fi or
VLAN reachability, and that the printer is awake.
- If MQTT status fails but TLS serial fetch worked, first confirm **Developer
Mode** is enabled, then power-cycle and retry status.
- If the access code is rejected or all zeros, ask the user to refresh the LAN
access code on the printer and rerun `config set --fetch-serial`.
- If status returns stale `FAILED`, `print_error`, or HMS entries after enabling
LAN Only or Developer Mode, stop. Ask the user to resolve the physical issue,
power-cycle, then retry status. Use `clear-error --execute` only after the
underlying cause is fixed.
- If setup succeeds, do not imply that live printing is validated. Say that LAN
connectivity is configured and status works; live printing still requires the
real-printer checklist, a dry-run plan, upload-only, and supervised start.
@@ -1,56 +0,0 @@
# Real Printer Checklist
Use this before any `--execute` run against a physical Bambu Lab printer.
## Credentials And Network
- Confirm the printer IP/hostname is on the trusted local network.
- Confirm LAN Only/Developer Mode is enabled when the printer exposes it,
especially on A1/A1 Mini.
- Store the access code in workspace-root `bambu-printers.json` with `config set`; do not
print it in final messages.
- Fetch/cache serial with `serial` or `config set --fetch-serial`.
- Run `status --push-all` and verify the intended printer responds.
- If LAN settings changed after failed starts, clear stale errors and
power-cycle before retrying.
## Job
- Confirm `$gcode` generated and validated the plain `.gcode`.
- Confirm scale, orientation, supports, material profile, nozzle, and bed type.
- For A1 Mini LAN start, prefer `--handoff template-project` with a known-good
same-printer `.gcode.3mf` template.
- Use `--handoff plain` only for diagnostics or firmware where `gcode_file` is
already validated.
- Use `--handoff bambox-project` only when the exact printer/nozzle profile is
enabled by the script.
- For project handoffs, upload to FTPS root and use `ftp:///<name>.gcode.3mf`.
## Physical Printer
If the current user request explicitly asks to print or start the job, treat that
request as live-start authorization for this checklist. State these physical
checks before the live command and proceed if automated validation and printer
status are healthy. Ask for another confirmation only when the request intent is
ambiguous or a validation/status check raises concern.
- Build plate is installed, clear, clean, and appropriate for the material.
- No failed-print remnants, loose tools, tape scraps, or debris are in the
printer.
- Filament is loaded and appropriate for the sliced file.
- Operator is nearby for heat-up, homing, and first-layer observation.
- Camera or direct observation is available.
## First Live Sequence
1. Run the exact `send` command without `--execute` and inspect the plan.
2. Run `send --action upload --execute` first.
3. Check status/UI/storage if possible.
4. Re-run the dry `send --action upload-start` plan.
5. Run `send --action upload-start --execute --confirm-start-print` when the
user explicitly asked to print/start, or after confirmation when intent is
unclear.
6. Poll status and watch the printer until the first layer is clearly normal.
If status reports `print_error` or HMS after a failed attempt, stop. Resolve the
cause, optionally run `clear-error --execute`, then poll status before retrying.
File diff suppressed because it is too large Load Diff
+19 -27
View File
@@ -1,15 +1,15 @@
---
name: cad-router
description: Route natural-language CAD, mechanical-part, assembly, 3D-printing, and robot-description requests across text-to-cad, SimpleCADAPI, and CADAM; create one editable model-spec.json per part; convert supplied STEP/STP files into exact-base JSON-controlled models; and enforce generation, modification, validation, visual repair, and CAD Viewer handoff. Use for new or modified STEP/STP, SCAD, STL, 3MF, DXF, URDF, SRDF, SDF, or G-code work when the agent must choose a backend, preserve editable sources, edit JSON model parameters, or parameterize an imported STEP.
description: Route natural-language or image-guided CAD generation and modification, plus uploaded STEP/STP reconstruction and modification, across text-to-cad and SimpleCADAPI. Create one executable DesignIR 2.0 model per part, produce STEP geometry without teacher geometry dependencies, and enforce independent validation, visual repair, and CAD Viewer review.
---
# CAD Router
Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use three projects by role: text-to-cad owns orchestration, general CAD, refinement, validation, repair, and viewing; SimpleCADAPI owns specialized mechanical families; CADAM owns SCAD execution.
Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use two projects by role: text-to-cad owns orchestration, general CAD, refinement, validation, repair, and viewing; SimpleCADAPI owns specialized mechanical families.
## Required workflow
1. Convert the request into a brief: part type, dimensions, units, manufacturing intent, required outputs, assembly relationships, editability, and validation targets.
1. Convert the natural-language or image-guided request into a brief: part type, dimensions, units, assembly relationships, editability, and validation targets. The artifact contract is fixed to executable DesignIR 2.0 plus STEP geometry.
Never treat a teacher STEP or a single-case DesignIR as experience. Batch case
JSON belongs to the separate CAD Experience plugin. Generation may consume
only its promoted schema `2.0` generalized library.
@@ -21,63 +21,55 @@ Choose a backend before modeling, keep its source as the editable authority, and
python3 scripts/route.py "<request>" --explain
```
Add explicit signals when known: `--output step`, `--output stl`, `--manufacturing machining|printing|laser-cutting`, `--edit-source path/to/source`, `--existing-model`, `--assembly`, or `--browser-controls`.
Add explicit signals when known: `--edit-source path/to/source`, `--existing-model`, or `--assembly`.
The router auto-discovers the workspace `cad-experience-library/library.json`,
infers an experience family and feature roles from the request, and emits a
backend-neutral `design_plan`. When promoted `reconstruction_grammar`
records match, instantiate their symbolic parameter roles, datum roles,
canonical stages, and validation roles in the new part's
`model-spec.json`; fill values from explicit user dimensions first and
DesignIR; fill values from explicit user dimensions first and
dimensionless distributions second. Use `--experience-library`,
`--experience-family`, or repeated `--experience-feature` only to override
those defaults.
3. Follow the selected route unless a hard environmental constraint makes it unavailable. If overriding it, record the reason in the task manifest.
4. Load only the selected backend reference from `references/capabilities.md` and the matching installed Skill:
- `build123d`: use `$cad`; this is the default for general STEP-first mechanical parts, assemblies, and existing STEP modification.
- `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, semantic tags, and FreeCAD interchange.
- `cadam`: generate editable `.scad`, then execute `node scripts/cadam_compile.mjs <source.scad> <output.stl|output.dxf>`. Set `CADAM_ROOT` or pass `--cadam-root` when the CADAM checkout is not beside text-to-cad. Prefer it for fast parametric printable models, repeated patterns, decorative CSG, and parameter controls.
- `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, and semantic tags.
5. When the route reports `requirement_refinement` or `visual_repair`, refine the brief, generate, execute, inspect multiple views, and repair no more than three visual mismatch rounds before asking the user.
6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one `model-spec.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/model-spec.md` before creating, importing, or modifying a model specification. Never change backend during an edit unless conversion is explicitly requested; modify the recorded source of truth.
For a supplied STEP/STP without an editable generator, create an
`exact_step_base` specification with `scripts/model_spec.py import-step`.
Preserve the copied STEP as the immutable exact B-Rep base and express later
edits as named JSON parameters and modification features. Do not claim that
arbitrary STEP recovers its original sketches, constraints, or feature
history.
For modification, prefer a native Python/SCAD generator. With STEP only,
inspect the explicitly supplied model and perform the smallest local feature
rebuild possible. For major topology changes, reconstruct from that model
under the promoted experience methods while preserving unchanged interfaces.
6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-2.0.md` before creating, reconstructing, or modifying a model. Never change backend during an edit unless conversion is explicitly requested; modify the recorded DesignIR source of truth.
For a supplied STEP/STP, the deterministic ingestion service may extract
private teacher evidence. The reconstruction agent receives that evidence,
never the STEP itself, and must author complete executable DesignIR. Compile
it in an isolated environment where the teacher STEP is absent.
The DesignIR and generator must not import STEP, embed B-Rep or meshes, retain
face references, or use the source model as a base feature. The acceptance
agent alone compares the teacher and reconstructed STEP.
If the request does not name the source part family, inspect the explicit
source first and rerun the experience query with `--experience-family` and
`--experience-feature`; do not guess across family-scoped experience.
7. Validate against the brief. STEP-first routes must run geometry inspection and snapshot review. Mesh routes must check compilation, bounds, solidity/manifold facts when available, and snapshot review.
7. Validate against the brief. Run STEP geometry inspection and snapshot review.
For experience-informed generation, record which generalized methods were
applied, including the reconstruction grammar ID and instantiated symbolic
roles. Validate against the user's brief and held-out targets, never against
parameters exposed from the same source case.
8. Write a `cad-task.json` manifest using the contract in `references/backend-contract.md`. Hand supported artifacts to `$cad-viewer` and return its live links.
8. Write a `cad-task.json` manifest using the contract in `references/backend-contract.md`. Hand the STEP artifact to `$cad-viewer` and return its live link.
## Routing rules
- Prefer an explicit user backend when it can produce the required primary format.
- Prefer an explicit user backend when it can produce the editable STEP contract.
- Prefer SimpleCADAPI when a supported standard mechanical factory or replay/semantic requirement materially reduces custom modeling.
- Prefer build123d for general machined parts such as brackets, connecting rods, housings, shafts, flanges, fixtures, and source-driven assemblies.
- Prefer CADAM for SCAD-native, fast printable, pattern-heavy, or interactive-parameter models. Do not choose it when editable STEP is mandatory.
- Route DXF, URDF, SRDF, SDF, G-code, printer, or fabrication handoffs to their focused installed Skills after primary geometry generation.
- Prefer source quality and future modification over the shortest first generation.
## V1 boundaries
- Do not promise lossless conversion of feature history between backends.
- Do not treat STL as an editable CAD source when a parametric source or STEP exists.
- Do not claim engineering certification, tolerance analysis, FEA, CAM toolpaths, or manufacturability without separate checks.
- Keep GPL/AGPL backends independently installed and invoked; do not copy their implementation into an MIT-distributed Skill.
## References
- Read `references/capabilities.md` when comparing or invoking backends.
- Read `references/backend-contract.md` before writing a task manifest or adding another backend.
- Read `references/model-spec.md` when generating per-part parameter JSON,
converting STEP to a JSON-controlled model, or modifying an existing part.
- Read `references/designir-2.0.md` when generating, reconstructing, or
modifying a per-part executable design.
- `scripts/capabilities.json` is the machine-readable V1 registry used by `scripts/route.py`.
@@ -1,4 +1,4 @@
interface:
display_name: "CAD Router"
short_description: "Route CAD and manage per-part JSON model specs."
default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through its model-spec.json, validate the result, and open it in CAD Viewer."
default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through DesignIR 2.0, validate the result independently, and open the STEP in CAD Viewer."
@@ -6,11 +6,10 @@ Capture these fields when known:
- Natural-language request and normalized part family.
- Units and dimensions.
- Manufacturing intent: machining, printing, laser cutting, simulation, or concept.
- Required primary and secondary output formats.
- STEP is the fixed geometry artifact format.
- New model versus modification, and existing source/artifact paths.
- Part versus assembly, standard components, mating requirements, and motion relationships.
- Required parameter controls, replay, semantic tags, or FreeCAD interchange.
- Required parameter controls, replay, or semantic tags.
- Geometry and visual validation targets.
- Optional schema `2.0` generalized experience library. Never pass a case
DesignIR, teacher STEP, source dimensions, coordinates, or surface references
@@ -23,13 +22,12 @@ Capture these fields when known:
Record:
- `selected_backend`: `build123d`, `simplecadapi`, or `cadam`.
- `selected_backend`: `build123d` or `simplecadapi`.
- Origin project and optional executable adapter.
- `runner_skill`: installed Skill responsible for generation.
- Ranked backend scores and human-readable reasons.
- Fallback order.
- Workflow profiles such as `requirement_refinement` and `visual_repair`.
- Focused downstream Skills.
- Any explicit override and its reason.
- `operation`: create or modify; for modification also record the explicit
source path and whether the route uses native parameter editing, local feature
@@ -47,7 +45,7 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
"selected_backend": "build123d",
"runner_skill": "cad",
"project": "text-to-cad",
"fallback_order": ["simplecadapi", "cadam"],
"fallback_order": ["simplecadapi"],
"workflow_profiles": ["requirement_refinement", "visual_repair"]
},
"source": {
@@ -56,12 +54,11 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
"backend": "build123d"
},
"artifacts": [
{"path": "connecting_rod.step", "role": "primary"},
{"path": "connecting_rod.stl", "role": "secondary"}
{"path": "connecting_rod.step", "role": "primary"}
],
"parameters": {},
"experience": {
"library": "../../../cad-experience-library/library.json",
"library": "../../../designir-pipeline/workspace/promotion/promoted/library.json",
"context_kind": "generalized_cad_experience_query",
"methods_applied": []
},
@@ -73,13 +70,14 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
Paths are relative to the task directory unless an external input must remain absolute.
Do not store authoritative model parameters in `cad-task.json`. Every part must
have a sibling `model-spec.json` that owns its parameter values, reconstruction
mode, parametric modification layer, and output paths. See `model-spec.md`.
have a sibling `*.designir.json` that owns its coordinate system, datums,
parameters, expressions, features, constraints, edit interface, and validation
contract. See `designir-2.0.md`.
For an imported STEP, use `exact_step_base`: copy the explicitly supplied file
into the task directory, record its checksum and geometry signature, and keep
the imported B-Rep unchanged when the modification list is empty. This is an
exact geometric base contract, not recovery of the original CAD feature tree.
For an uploaded STEP, keep teacher geometry in the protected ingestion and
acceptance workspace. The reconstruction task contains private evidence,
DesignIR, backend source, and rebuilt STEP but no teacher STEP or imported
B-Rep.
## Backend adapter requirements
@@ -9,9 +9,9 @@ Best fit:
- Brackets, connecting rods, cranks, levers, shafts, bushings, flanges, plates, housings, covers, enclosures, fixtures, and machined features.
- Parametric Python sources with holes, pockets, slots, bosses, ribs, shells, fillets, chamfers, lofts, sweeps, and revolves.
- Source-level assemblies, named datums, joints, imported components, measurements, alignment checks, and existing STEP inspection.
- STEP primary output with STL, 3MF, or GLB sidecars.
- STEP output compiled from executable DesignIR 2.0.
Weak signals: a specialized mechanical family already implemented by SimpleCADAPI, SCAD-native output, browser-only execution, or decorative repeated CSG where OpenSCAD is substantially shorter.
Weak signals: a specialized mechanical family already implemented by SimpleCADAPI or a browser-only execution requirement.
## SimpleCADAPI
@@ -21,23 +21,11 @@ Best fit:
- Spur, helical, and herringbone gears; internal ring gears; racks; cycloidal discs; ball bearings.
- Planetary and cycloidal reducers, joint actuators, and mechanism-heavy assemblies.
- Parameter expressions, operation-graph JSON, replay, semantic tags, topology queries, and FreeCAD translation.
- Parameter expressions, operation-graph JSON, replay, semantic tags, and topology queries.
- Reusable mechanical product families whose formulas should become deterministic library functions.
Before invocation, inspect the installed package and Skill versions together. Do not mix the current single-`Solid` boolean API with older `*rsolidlist` documentation. If the installed runtime and docs disagree, stop that route and use build123d or repair the installation.
## CADAM
Use CADAM's separately installed OpenSCAD WASM runtime for fast CSG authoring and printable parameterized models. Execute generated SCAD through `scripts/cadam_compile.mjs`; do not copy CADAM's GPL runtime into this Skill.
Best fit:
- Vases, containers, knobs, adapters, printable threads, lattice/honeycomb patterns, repeated details, decorative models, and quick concept geometry.
- SCAD source, STL output, simple projected DXF, and UI-facing parameter controls.
- BOSL/BOSL2/MCAD-backed printable features.
Do not use as the primary route for editable STEP, stable face/edge references, fit-critical source-level assemblies, or modification of imported STEP.
## Router refinement and repair
Use text-to-cad's own requirement expansion and visual repair when requirements
@@ -51,11 +39,4 @@ one-pass generation unreliable:
5. Inspect a multi-view render against the brief.
6. Repair visual mismatches, capped at three automatic visual rounds.
## Focused downstream Skills
- `$dxf`: 2D profiles, flat patterns, laser/plasma/waterjet drawings.
- `$urdf`, `$srdf`, `$sdf`: robot structure, MoveIt semantics, and simulation models/worlds.
- `$gcode`: slice validated meshes with a real slicer.
- `$sendcutsend`: vendor-specific manufacturing preflight.
- `$bambu-labs`: cautious local printer handoff.
- `$cad-viewer`: visual review of supported generated artifacts.
CAD Viewer reviews the generated or reconstructed STEP.
@@ -0,0 +1,58 @@
# DesignIR 2.0 contract
Every generated, reconstructed, or modified part owns one executable
`*.designir.json`. It records design intent rather than source faces or a copied
solid.
Required sections:
- `coordinate_system`
- `datums`
- `parameters`
- `expressions`
- `sketches`
- `constraints`
- `features`
- `patterns`
- `attachments`
- `construction_stages`
- `edit_interface`
- `validation_contract`
The compiler translates this backend-neutral representation to build123d or
SimpleCADAPI and exports STEP.
## Uploaded STEP boundary
The uploaded STEP is teacher and acceptance truth only:
1. A deterministic ingestion process extracts private geometric evidence.
2. The reconstruction agent sees evidence and promoted experience, not the
teacher STEP.
3. It authors complete DesignIR and records uncertainty or missing vocabulary.
4. The compiler rebuilds STEP in a sandbox containing no teacher STEP.
5. A separate acceptance agent compares the teacher and rebuilt STEP and runs
parameter perturbations.
Reject DesignIR or generators that:
- import or reference the teacher STEP;
- embed STEP text, B-Rep bytes, meshes, vertices, triangles, or topology refs;
- retain teacher face, edge, or surface identifiers;
- use the teacher as a base feature;
- fail to rebuild after the teacher is removed.
If the operation vocabulary is insufficient, set
`reconstruction_status` to `unsupported_feature_vocabulary` and enumerate
`missing_capabilities`. Never hide the gap with teacher geometry.
## Acceptance
Keep geometry, semantic, editability, and stability metrics separate. At
minimum report bounding-box error, volume error, center-of-mass error,
symmetric-difference volume, topology deltas, declared feature/constraint
coverage, editable parameter coverage, and perturbation rebuild success.
Only deterministic policy code may move a method from candidate to
`replay_validated` and then promoted experience after several independent cases
pass.
@@ -1,111 +0,0 @@
# Per-part model specification
Use one `model-spec.json` per generated or imported part. Treat it as the
editable geometry contract. Keep `cad-task.json` separate for routing,
experience use, validation results, and viewer links.
## Native generator
Use `native_generator` when the part has editable Python source:
```json
{
"schema_version": "1.0",
"model_spec_kind": "parametric_cad_model",
"model_id": "example_part",
"units": "mm",
"reconstruction": {
"mode": "native_generator",
"generator": {
"path": "example_part.py",
"entrypoint": "gen_step"
}
},
"parameters": {
"width": {
"value": 40,
"unit": "mm",
"role": "overall_width"
}
},
"modifications": [],
"outputs": {
"step": "example_part.step",
"stl": "example_part.stl"
}
}
```
The generator must read its sibling `model-spec.json`. Do not duplicate
authoritative parameter values as editable constants in both files.
## Imported STEP
Convert an explicitly supplied STEP into a task-local exact-base specification:
```bash
python scripts/model_spec.py import-step input.step \
--task-dir models/cad-router-v1/imported_part \
--model-id imported_part
```
This copies the source to `source.step`, records its SHA-256 and geometry
signature, writes `model-spec.json`, and exports regenerated STEP/STL files.
With an empty modification list, `verify` requires the imported B-Rep to retain
the same bounds, center, topology counts, solid count, and volume.
STEP normally contains final B-Rep geometry, not the original sketches,
constraints, names, or feature history. Therefore:
- `exact_step_base` guarantees an unchanged imported base, not recovered native
design history.
- Add later edits as named parametric modifications.
- Use semantic native reconstruction only when the feature family can be
reliably recognized and validate it independently against the source.
- Never claim arbitrary STEP-to-JSON conversion recovers the author’s original
parameters or construction order.
## Parametric modification layer
Supported V1 operations are `add_box`, `cut_box`, `add_cylinder`, and
`cut_cylinder`. Values may be literals, direct parameter references, or affine
parameter expressions:
```json
{
"id": "central_bore",
"operation": "cut_cylinder",
"axis": "z",
"center": [0, 0, 0],
"diameter": {"parameter": "bore_diameter"},
"length": {"parameter": "bore_length"},
"enabled": true
}
```
Use `scale` and `offset` when a tool center must stay anchored while its size
changes. For example, a positive-Z face at `z = 14.1` can remain the lower face
of an added box while `extension` changes:
```json
{
"center": [
13.1,
-1.0,
{"parameter": "extension", "scale": 0.5, "offset": 14.1}
],
"size": [22.2, 2.0, {"parameter": "extension"}]
}
```
Change a named value and rebuild:
```bash
python scripts/model_spec.py set path/to/model-spec.json bore_diameter 18
python scripts/model_spec.py build path/to/model-spec.json
python scripts/model_spec.py verify path/to/model-spec.json
```
Use `apply_patch` when adding or structurally editing parameters and features so
the modification remains reviewable. After every visible rebuild, run the CAD
inspection and snapshot workflow and update `cad-task.json`.
@@ -1,107 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
function usage() {
return "Usage: node cadam_compile.mjs <input.scad> <output.stl|output.dxf> [--cadam-root <path>]";
}
function parseArgs(argv) {
const positional = [];
let cadamRoot = String(process.env.CADAM_ROOT || "").trim();
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--cadam-root") {
cadamRoot = String(argv[index + 1] || "").trim();
index += 1;
} else {
positional.push(value);
}
}
if (positional.length !== 2) {
throw new Error(usage());
}
return { inputPath: path.resolve(positional[0]), outputPath: path.resolve(positional[1]), cadamRoot };
}
function defaultCadamRoot() {
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(scriptDirectory, "../../../../CADAM");
}
function runtimePaths(configuredRoot) {
const root = path.resolve(configuredRoot || defaultCadamRoot());
const runtimeDirectory = path.join(root, "src", "vendor", "openscad-wasm");
const modulePath = path.join(runtimeDirectory, "openscad.js");
const wasmPath = path.join(runtimeDirectory, "openscad.wasm");
if (!fs.existsSync(modulePath) || !fs.existsSync(wasmPath)) {
throw new Error(
`CADAM OpenSCAD WASM runtime not found under ${runtimeDirectory}. ` +
"Set CADAM_ROOT or pass --cadam-root to an installed CADAM checkout."
);
}
return { root, modulePath, wasmPath };
}
async function compile({ inputPath, outputPath, cadamRoot }) {
if (path.extname(inputPath).toLowerCase() !== ".scad") {
throw new Error("CADAM input must be an OpenSCAD .scad source file");
}
const extension = path.extname(outputPath).toLowerCase();
if (!new Set([".stl", ".dxf"]).has(extension)) {
throw new Error("CADAM output must be .stl or .dxf");
}
if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
throw new Error(`CADAM input file not found: ${inputPath}`);
}
const runtime = runtimePaths(cadamRoot);
const logs = { stdout: [], stderr: [] };
const runtimeModule = await import(pathToFileURL(runtime.modulePath).href);
const instance = await runtimeModule.default({
noInitialRun: true,
wasmBinary: fs.readFileSync(runtime.wasmPath),
print: (line) => logs.stdout.push(String(line)),
printErr: (line) => logs.stderr.push(String(line))
});
const virtualInput = "/input.scad";
const virtualOutput = `/out${extension}`;
instance.FS.writeFile(virtualInput, fs.readFileSync(inputPath, "utf8"));
const format = extension === ".stl" ? "binstl" : "dxf";
const exitCode = instance.callMain([
virtualInput,
"-o",
virtualOutput,
`--export-format=${format}`,
"--backend=manifold",
"--enable=lazy-union"
]);
if (exitCode !== 0) {
throw new Error(`CADAM OpenSCAD compilation failed with exit code ${exitCode}: ${logs.stderr.join("\n")}`);
}
const output = instance.FS.readFile(virtualOutput, { encoding: "binary" });
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, output);
return {
backend: "cadam",
runtime: "CADAM OpenSCAD WASM",
cadam_root: runtime.root,
input: inputPath,
output: outputPath,
bytes: output.length,
exit_code: exitCode,
stderr_tail: logs.stderr.slice(-8)
};
}
try {
const result = await compile(parseArgs(process.argv.slice(2)));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
@@ -6,7 +6,6 @@
"runner_skill": "cad",
"source_formats": ["py"],
"primary_formats": ["step", "stp"],
"secondary_formats": ["stl", "3mf", "glb"],
"strengths": [
"general mechanical parts",
"machined features",
@@ -20,31 +19,14 @@
"project": "SimpleCADAPI",
"runner_skill": "simplecadapi",
"source_formats": ["py", "json"],
"primary_formats": ["step", "stp", "fcstd"],
"secondary_formats": ["stl", "json"],
"primary_formats": ["step", "stp"],
"strengths": [
"gears, ring gears, racks, and cycloidal discs",
"bearings, reducers, and joint actuators",
"replayable operation graphs",
"semantic tags and topology queries",
"FreeCAD interchange"
"semantic tags and topology queries"
],
"source_of_truth": "SimpleCADAPI Python generator and model JSON"
},
"cadam": {
"project": "CADAM",
"runner_skill": "cad-router",
"adapter": "scripts/cadam_compile.mjs",
"source_formats": ["scad"],
"primary_formats": ["scad", "stl"],
"secondary_formats": ["dxf"],
"strengths": [
"fast printable CSG models",
"patterns, lattices, and decorative geometry",
"printable threads and BOSL/BOSL2 libraries",
"browser-friendly parameter controls"
],
"source_of_truth": "CADAM-compatible OpenSCAD source"
}
}
}
@@ -1,632 +0,0 @@
#!/usr/bin/env python3
"""Create and execute per-part JSON model specifications.
Two reconstruction modes are supported:
* ``native_generator`` loads a task-local Python generator. The generator reads
the same model-spec.json, so named parameter edits remain the source of truth.
* ``exact_step_base`` imports an immutable task-local STEP file and applies a
parametric modification layer. With no modifications, the imported B-Rep is
preserved geometrically even though the exported STEP text may differ.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import math
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from build123d import (
Align,
Box,
Cylinder,
Part,
Pos,
export_step,
export_stl,
import_step,
)
SCHEMA_VERSION = "1.0"
MODEL_SPEC_KIND = "parametric_cad_model"
MODEL_ID_RE = re.compile(r"[^a-zA-Z0-9._-]+")
SUPPORTED_OPERATIONS = {
"add_box",
"cut_box",
"add_cylinder",
"cut_cylinder",
}
class ModelSpecError(ValueError):
"""Raised when a model specification is invalid or unsafe to execute."""
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ModelSpecError(f"Cannot read model spec {path}: {exc}") from exc
if not isinstance(value, dict):
raise ModelSpecError("Model spec must be a JSON object")
return value
def _write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _safe_model_id(value: str) -> str:
result = MODEL_ID_RE.sub("_", value.strip()).strip("._-")
if not result:
raise ModelSpecError("model_id must contain a letter or number")
return result
def _resolve_relative(spec_path: Path, value: str, field: str) -> Path:
candidate = Path(value)
if candidate.is_absolute():
raise ModelSpecError(f"{field} must be task-relative, not absolute")
return (spec_path.parent / candidate).resolve()
def _parameter_value(spec: dict[str, Any], name: str) -> float:
parameters = spec.get("parameters", {})
entry = parameters.get(name) if isinstance(parameters, dict) else None
if not isinstance(entry, dict) or "value" not in entry:
raise ModelSpecError(f"Unknown parameter reference: {name}")
value = entry["value"]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ModelSpecError(f"Parameter {name} must contain a numeric value")
if not math.isfinite(float(value)):
raise ModelSpecError(f"Parameter {name} must be finite")
return float(value)
def resolve_number(spec: dict[str, Any], value: Any, field: str) -> float:
if isinstance(value, bool):
raise ModelSpecError(f"{field} must be numeric")
if isinstance(value, (int, float)):
result = float(value)
elif isinstance(value, dict) and "parameter" in value:
unexpected = set(value) - {"parameter", "scale", "offset"}
if unexpected:
raise ModelSpecError(
f"{field} parameter expression has unsupported keys: "
+ ", ".join(sorted(unexpected))
)
scale = value.get("scale", 1.0)
offset = value.get("offset", 0.0)
if (
isinstance(scale, bool)
or not isinstance(scale, (int, float))
or isinstance(offset, bool)
or not isinstance(offset, (int, float))
):
raise ModelSpecError(f"{field} scale and offset must be numeric")
result = (
_parameter_value(spec, str(value["parameter"])) * float(scale)
+ float(offset)
)
else:
raise ModelSpecError(
f"{field} must be a number or a parameter expression"
)
if not math.isfinite(result):
raise ModelSpecError(f"{field} must be finite")
return result
def _vector3(spec: dict[str, Any], value: Any, field: str) -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise ModelSpecError(f"{field} must contain three values")
return tuple(
resolve_number(spec, item, f"{field}[{index}]")
for index, item in enumerate(value)
)
def validate_model_spec(spec: dict[str, Any]) -> None:
if spec.get("schema_version") != SCHEMA_VERSION:
raise ModelSpecError(f"Unsupported model spec schema: {spec.get('schema_version')}")
if spec.get("model_spec_kind") != MODEL_SPEC_KIND:
raise ModelSpecError("Not a parametric CAD model specification")
_safe_model_id(str(spec.get("model_id", "")))
if spec.get("units") != "mm":
raise ModelSpecError("V1 model specs use millimetres")
reconstruction = spec.get("reconstruction")
if not isinstance(reconstruction, dict):
raise ModelSpecError("reconstruction must be an object")
mode = reconstruction.get("mode")
if mode not in {"native_generator", "exact_step_base"}:
raise ModelSpecError(f"Unsupported reconstruction mode: {mode}")
if mode == "native_generator":
generator = reconstruction.get("generator")
if not isinstance(generator, dict) or not generator.get("path"):
raise ModelSpecError("native_generator requires generator.path")
else:
source = reconstruction.get("source")
if (
not isinstance(source, dict)
or not source.get("path")
or not source.get("sha256")
):
raise ModelSpecError("exact_step_base requires source.path and source.sha256")
parameters = spec.get("parameters", {})
if not isinstance(parameters, dict):
raise ModelSpecError("parameters must be an object")
for name, entry in parameters.items():
if not isinstance(entry, dict) or "value" not in entry:
raise ModelSpecError(f"Parameter {name} must be an object with value")
_parameter_value(spec, name)
modifications = spec.get("modifications", [])
if not isinstance(modifications, list):
raise ModelSpecError("modifications must be an array")
seen_ids: set[str] = set()
for index, modification in enumerate(modifications):
if not isinstance(modification, dict):
raise ModelSpecError(f"modifications[{index}] must be an object")
feature_id = str(modification.get("id", "")).strip()
if not feature_id or feature_id in seen_ids:
raise ModelSpecError("Every modification requires a unique id")
seen_ids.add(feature_id)
operation = modification.get("operation")
if operation not in SUPPORTED_OPERATIONS:
raise ModelSpecError(f"Unsupported modification operation: {operation}")
outputs = spec.get("outputs")
if not isinstance(outputs, dict) or not outputs.get("step"):
raise ModelSpecError("outputs.step is required")
def geometry_facts(shape: Any) -> dict[str, Any]:
box = shape.bounding_box()
solids = list(shape.solids())
volume = sum(float(solid.volume) for solid in solids)
return {
"size_mm": [
round(float(box.size.X), 9),
round(float(box.size.Y), 9),
round(float(box.size.Z), 9),
],
"center_mm": [
round(float(box.center().X), 9),
round(float(box.center().Y), 9),
round(float(box.center().Z), 9),
],
"solid_count": len(solids),
"face_count": len(shape.faces()),
"edge_count": len(shape.edges()),
"volume_mm3": round(volume, 6),
}
def _rotation_for_axis(axis: str) -> tuple[float, float, float]:
normalized = axis.lower()
if normalized == "z":
return (0.0, 0.0, 0.0)
if normalized == "x":
return (0.0, 90.0, 0.0)
if normalized == "y":
return (-90.0, 0.0, 0.0)
raise ModelSpecError(f"Unsupported cylinder axis: {axis}")
def _make_tool(spec: dict[str, Any], modification: dict[str, Any]) -> Any:
operation = str(modification["operation"])
center = _vector3(spec, modification.get("center", [0, 0, 0]), "center")
if operation.endswith("_cylinder"):
diameter = resolve_number(spec, modification.get("diameter"), "diameter")
length = resolve_number(spec, modification.get("length"), "length")
if diameter <= 0 or length <= 0:
raise ModelSpecError("Cylinder diameter and length must be positive")
tool = Cylinder(
diameter / 2.0,
length,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
rotation=_rotation_for_axis(str(modification.get("axis", "z"))),
)
else:
size = _vector3(spec, modification.get("size"), "size")
if any(item <= 0 for item in size):
raise ModelSpecError("Box dimensions must be positive")
tool = Box(
*size,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
return Pos(*center) * tool
def _apply_modifications(shape: Any, spec: dict[str, Any]) -> Any:
result = shape
for modification in spec.get("modifications", []):
if modification.get("enabled", True) is False:
continue
tool = _make_tool(spec, modification)
operation = modification["operation"]
result = result + tool if operation.startswith("add_") else result - tool
return result
def _load_generator(spec_path: Path, reconstruction: dict[str, Any]) -> Any:
generator = reconstruction["generator"]
source_path = _resolve_relative(spec_path, str(generator["path"]), "generator.path")
if not source_path.is_file():
raise ModelSpecError(f"Generator does not exist: {source_path}")
module_spec = importlib.util.spec_from_file_location(
f"cad_model_{hash(source_path)}",
source_path,
)
if module_spec is None or module_spec.loader is None:
raise ModelSpecError(f"Cannot load generator: {source_path}")
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
entrypoint = str(generator.get("entrypoint", "gen_step"))
function = getattr(module, entrypoint, None)
if not callable(function):
raise ModelSpecError(f"Generator has no callable {entrypoint}: {source_path}")
return function()
def build_shape(spec_path: Path, spec: dict[str, Any] | None = None) -> Any:
resolved_spec = spec_path.expanduser().resolve()
payload = spec or _read_json(resolved_spec)
validate_model_spec(payload)
reconstruction = payload["reconstruction"]
if reconstruction["mode"] == "native_generator":
shape = _load_generator(resolved_spec, reconstruction)
else:
source = reconstruction["source"]
source_path = _resolve_relative(resolved_spec, str(source["path"]), "source.path")
if not source_path.is_file():
raise ModelSpecError(f"Exact STEP base does not exist: {source_path}")
actual_hash = _sha256(source_path)
if actual_hash != source["sha256"]:
raise ModelSpecError(
"Exact STEP base checksum changed; import it again instead of "
"silently rebuilding from a different source"
)
imported = import_step(source_path)
# build123d's STEP importer returns a topology wrapper that is readable
# and boolean-capable but is not always directly accepted by its STEP
# exporter. Normalize it to a Part while preserving the wrapped B-Rep.
shape = Part(imported.wrapped)
return _apply_modifications(shape, payload)
def build_model(spec_path: Path) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
validate_model_spec(payload)
outputs = payload["outputs"]
step_path = _resolve_relative(resolved_spec, str(outputs["step"]), "outputs.step")
step_path.parent.mkdir(parents=True, exist_ok=True)
reconstruction = payload["reconstruction"]
stl_value = outputs.get("stl")
stl_path = (
_resolve_relative(resolved_spec, str(stl_value), "outputs.stl")
if stl_value
else None
)
if reconstruction["mode"] == "native_generator":
generator_path = _resolve_relative(
resolved_spec,
str(reconstruction["generator"]["path"]),
"generator.path",
)
if generator_path.with_suffix(".step").resolve() != step_path:
raise ModelSpecError(
"V1 native generator output must use the generator basename"
)
cad_step = Path(__file__).resolve().parents[2] / "cad" / "scripts" / "step"
command = [sys.executable, str(cad_step), str(generator_path), "--force"]
if stl_path is not None:
if stl_path.parent != generator_path.parent:
raise ModelSpecError(
"V1 native generator STL must remain in the task directory"
)
command.extend(["--stl", stl_path.name])
completed = subprocess.run(
command,
cwd=resolved_spec.parent,
text=True,
capture_output=True,
)
if completed.returncode != 0:
raise ModelSpecError(
"CAD generator failed: "
+ (completed.stderr.strip() or completed.stdout.strip())
)
if not step_path.is_file():
raise ModelSpecError(f"CAD generator did not write {step_path}")
else:
shape = build_shape(resolved_spec, payload)
source_path = _resolve_relative(
resolved_spec,
str(reconstruction["source"]["path"]),
"source.path",
)
if not payload.get("modifications"):
# Empty exact-base rebuilds preserve the STEP byte-for-byte.
if source_path != step_path:
shutil.copy2(source_path, step_path)
else:
export_step(shape, step_path)
if stl_path is not None:
stl_path.parent.mkdir(parents=True, exist_ok=True)
export_stl(shape, stl_path)
exported_shape = Part(import_step(step_path).wrapped)
result: dict[str, Any] = {
"model_spec": str(resolved_spec),
"step": str(step_path),
"facts": geometry_facts(exported_shape),
}
if stl_path is not None:
result["stl"] = str(stl_path)
return result
def import_step_model(
source_step: Path,
task_dir: Path,
model_id: str | None = None,
) -> dict[str, Any]:
source = source_step.expanduser().resolve()
if not source.is_file() or source.suffix.lower() not in {".step", ".stp"}:
raise ModelSpecError(f"Input must be an existing STEP/STP file: {source}")
target_dir = task_dir.expanduser().resolve()
target_dir.mkdir(parents=True, exist_ok=True)
normalized_id = _safe_model_id(model_id or source.stem)
copied_source = target_dir / "source.step"
if copied_source.resolve() != source:
shutil.copy2(source, copied_source)
source_shape = import_step(copied_source)
source_facts = geometry_facts(source_shape)
spec_path = target_dir / "model-spec.json"
payload: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"model_spec_kind": MODEL_SPEC_KIND,
"model_id": normalized_id,
"units": "mm",
"reconstruction": {
"mode": "exact_step_base",
"source": {
"path": "source.step",
"sha256": _sha256(copied_source),
"geometry_signature": source_facts,
},
"history_recovery": {
"status": "not_present_in_step",
"contract": (
"Preserve the imported B-Rep exactly as the immutable base; "
"represent later edits as named parametric modifications."
),
},
},
"parameters": {},
"modifications": [],
"outputs": {
"step": f"{normalized_id}.step",
"stl": f"{normalized_id}.stl",
},
"validation": {
"exact_base_required": True,
"geometry_signature_tolerance_mm": 1e-7,
},
}
_write_json(spec_path, payload)
result = build_model(spec_path)
result["source"] = str(copied_source)
result["source_sha256"] = payload["reconstruction"]["source"]["sha256"]
return result
def _numbers_close(first: Any, second: Any, tolerance: float) -> bool:
if isinstance(first, list) and isinstance(second, list):
return len(first) == len(second) and all(
_numbers_close(left, right, tolerance)
for left, right in zip(first, second)
)
if isinstance(first, (int, float)) and isinstance(second, (int, float)):
return math.isclose(
float(first),
float(second),
rel_tol=tolerance,
abs_tol=tolerance,
)
return first == second
def verify_model(spec_path: Path) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
shape = build_shape(resolved_spec, payload)
facts = geometry_facts(shape)
reconstruction = payload["reconstruction"]
result: dict[str, Any] = {
"model_spec": str(resolved_spec),
"valid": True,
"facts": facts,
"checks": [],
}
if reconstruction["mode"] == "exact_step_base" and not payload.get("modifications"):
expected = reconstruction["source"].get("geometry_signature", {})
source_path = _resolve_relative(
resolved_spec,
str(reconstruction["source"]["path"]),
"source.path",
)
output_path = _resolve_relative(
resolved_spec,
str(payload["outputs"]["step"]),
"outputs.step",
)
output_shape = (
Part(import_step(output_path).wrapped)
if output_path.is_file()
else None
)
output_facts = geometry_facts(output_shape) if output_shape is not None else {}
tolerance = float(
payload.get("validation", {}).get(
"geometry_signature_tolerance_mm",
1e-7,
)
)
signature_exact = all(
_numbers_close(expected.get(key), facts.get(key), tolerance)
for key in (
"size_mm",
"center_mm",
"solid_count",
"face_count",
"edge_count",
"volume_mm3",
)
)
exported_exact = (
output_path.is_file()
and _sha256(output_path) == _sha256(source_path)
and all(
_numbers_close(expected.get(key), output_facts.get(key), tolerance)
for key in (
"size_mm",
"center_mm",
"solid_count",
"face_count",
"edge_count",
"volume_mm3",
)
)
)
exact = signature_exact and exported_exact
result["checks"].append(
{
"check": "exact_base_geometry_signature",
"passed": exact,
"expected": expected,
"actual": facts,
"exported": output_facts,
"byte_identical_step": (
output_path.is_file()
and _sha256(output_path) == _sha256(source_path)
),
}
)
result["valid"] = exact
else:
result["checks"].append(
{
"check": "model_spec_execution",
"passed": True,
"detail": "The parameterized model spec executed successfully.",
}
)
return result
def set_parameter(spec_path: Path, name: str, value: float) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
validate_model_spec(payload)
parameters = payload["parameters"]
if name not in parameters:
raise ModelSpecError(
f"Unknown parameter {name}; add a named parameter and feature binding first"
)
parameters[name]["value"] = value
validate_model_spec(payload)
_write_json(resolved_spec, payload)
return {
"model_spec": str(resolved_spec),
"parameter": name,
"value": value,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create, build, modify, and verify per-part model-spec.json files."
)
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser(
"import-step",
help="Create an exact STEP-base model spec in a task-owned directory.",
)
import_parser.add_argument("source_step", type=Path)
import_parser.add_argument("--task-dir", type=Path, required=True)
import_parser.add_argument("--model-id")
build_command = subparsers.add_parser(
"build",
help="Regenerate STEP/STL from a model specification.",
)
build_command.add_argument("model_spec", type=Path)
verify_command = subparsers.add_parser(
"verify",
help="Validate a model specification and exact-base signature.",
)
verify_command.add_argument("model_spec", type=Path)
set_command = subparsers.add_parser(
"set",
help="Change one existing named parameter in a model specification.",
)
set_command.add_argument("model_spec", type=Path)
set_command.add_argument("name")
set_command.add_argument("value", type=float)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "import-step":
result = import_step_model(args.source_step, args.task_dir, args.model_id)
elif args.command == "build":
result = build_model(args.model_spec)
elif args.command == "verify":
result = verify_model(args.model_spec)
else:
result = set_parameter(args.model_spec, args.name, args.value)
except ModelSpecError as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
if args.command == "verify" and result.get("valid") is False:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90 -188
View File
@@ -12,10 +12,6 @@ from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = Path(__file__).with_name("capabilities.json")
CADAM_RUNTIME_FILES = (
Path("src/vendor/openscad-wasm/openscad.js"),
Path("src/vendor/openscad-wasm/openscad.wasm"),
)
@dataclass
@@ -40,14 +36,9 @@ KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
"lever", "摇臂", "shaft", "轴", "bushing", "轴套", "flange", "法兰",
"housing", "壳体", "enclosure", "外壳", "fixture", "夹具", "plate", "安装板",
),
"printable": (
"3d print", "3d-print", "打印", "printable", "vase", "花瓶", "knob", "旋钮",
"container", "容器", "adapter", "适配器", "honeycomb", "蜂窝", "lattice", "晶格",
"pattern", "阵列", "decorative", "装饰", "openscad", "scad", "bosl",
),
"replay": (
"replay", "重放", "operation graph", "model json", "模型图", "semantic tag",
"语义标签", "freecad", "fcstd",
"语义标签",
),
"visual_complexity": (
"image", "photo", "sketch", "图片", "照片", "草图", "复杂", "complex",
@@ -56,16 +47,6 @@ KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
}
FOCUSED_SKILLS: tuple[tuple[str, tuple[str, ...]], ...] = (
("dxf", ("dxf", "laser cut", "激光切割", "waterjet", "水刀", "flat pattern", "展开")),
("urdf", ("urdf", "robot description", "机器人描述")),
("srdf", ("srdf", "moveit", "planning group", "规划组")),
("sdf", ("sdformat", "gazebo", "simulation world", "仿真世界", ".sdf")),
("gcode", ("g-code", "gcode", "slice", "切片")),
("sendcutsend", ("sendcutsend",)),
("bambu-labs", ("bambu", "拓竹")),
)
EXPERIENCE_FAMILY_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"flanged_hub_adapter",
@@ -184,15 +165,6 @@ def contains_any(text: str, terms: tuple[str, ...]) -> bool:
return any(term in text for term in terms)
def normalize_formats(formats: list[str]) -> list[str]:
result: list[str] = []
for value in formats:
normalized = value.lower().lstrip(".")
if normalized not in result:
result.append(normalized)
return result
def infer_experience_query(request: str) -> tuple[str | None, list[str]]:
text = request.lower()
family = next(
@@ -211,18 +183,6 @@ def infer_experience_query(request: str) -> tuple[str | None, list[str]]:
return family, features
def cadam_root() -> Path:
configured = os.environ.get("CADAM_ROOT", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (SKILL_ROOT.parents[2] / "CADAM").resolve()
def cadam_runtime_available(root: Path | None = None) -> bool:
candidate = root or cadam_root()
return all((candidate / relative_path).is_file() for relative_path in CADAM_RUNTIME_FILES)
def probe_backends() -> dict[str, dict[str, object]]:
return {
"build123d": {
@@ -233,10 +193,6 @@ def probe_backends() -> dict[str, dict[str, object]]:
"available": importlib.util.find_spec("simplecadapi") is not None,
"probe": f"python={sys.executable}",
},
"cadam": {
"available": cadam_runtime_available(),
"probe": str(cadam_root()),
},
}
@@ -265,46 +221,6 @@ MAJOR_EDIT_TERMS = (
"major redesign",
"rebuild the whole",
)
LOCAL_FEATURE_EDIT_TERMS = (
"增加孔",
"添加孔",
"删除孔",
"增加槽",
"添加槽",
"删除槽",
"沉孔",
"倒角",
"圆角",
"add hole",
"remove hole",
"add slot",
"counterbore",
"fillet",
"chamfer",
)
PARAMETER_EDIT_TERMS = (
"孔径",
"直径",
"厚度",
"长度",
"宽度",
"高度",
"间距",
"齿数",
"模数",
"改为",
"调整",
"diameter",
"thickness",
"length",
"width",
"height",
"spacing",
"change",
"resize",
)
def _find_forbidden_experience_key(value: object) -> str | None:
if isinstance(value, dict):
for key, child in value.items():
@@ -325,7 +241,14 @@ def default_experience_library() -> Path | None:
configured = os.environ.get("CAD_EXPERIENCE_LIBRARY", "").strip()
if configured:
return Path(configured).expanduser()
workspace_candidate = SKILL_ROOT.parents[2] / "cad-experience-library" / "library.json"
workspace_candidate = (
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "promotion"
/ "promoted"
/ "library.json"
)
return workspace_candidate if workspace_candidate.is_file() else None
@@ -338,40 +261,43 @@ def classify_edit_context(
return None
source = edit_source.expanduser().resolve() if edit_source else None
if source is not None:
parser_root = (
SKILL_ROOT.parents[2] / "cad-experience-plugin" / "parser"
).resolve()
if source == parser_root or parser_root in source.parents:
protected_roots = [
(
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "teacher"
).resolve(),
(
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "acceptance"
).resolve(),
]
if any(source == root or root in source.parents for root in protected_roots):
raise ValueError(
"cad-router cannot use cad-experience-plugin/parser input or "
"output as an edit source; provide the original model or "
"editable generator outside the distillation staging area"
"cad-router reconstruction cannot read the teacher or acceptance "
"vault; pass the ingestion job reference or an editable DesignIR"
)
suffix = source.suffix.lower() if source else ""
text = request.lower()
if suffix == ".json" and source and source.name == "model-spec.json":
mode = "model_spec_parameter_edit"
reason = "A per-part model specification exists; update its named JSON parameters or modification features."
elif suffix in {".py", ".scad", ".js", ".mjs"}:
if suffix == ".json" and source and source.name.endswith(".designir.json"):
mode = "designir_parameter_edit"
reason = "Executable DesignIR exists; update its named parameters, constraints, or features."
elif suffix == ".py":
mode = "native_parameter_edit"
reason = "An editable generator exists; update its named parameters or feature source."
elif source and suffix in {".step", ".stp"}:
mode = "private_evidence_to_isolated_designir_reconstruction"
reason = (
"The uploaded STEP is teacher truth: ingestion extracts private "
"evidence, then reconstruction authors DesignIR without STEP access."
)
elif contains_any(text, MAJOR_EDIT_TERMS):
mode = "experience_guided_semantic_reconstruction"
reason = "The requested topology change is large; rebuild from the specified source model using only promoted experience."
elif source and suffix in {".step", ".stp"} and contains_any(
text, LOCAL_FEATURE_EDIT_TERMS
):
mode = "direct_step_local_feature_edit"
reason = "Inspect the specified STEP and rebuild only the affected local feature."
elif source and suffix in {".step", ".stp"} and contains_any(
text, PARAMETER_EDIT_TERMS
):
mode = "direct_step_feature_rebuild"
reason = "STEP has no native feature history; inspect the source and reconstruct the affected dimensional feature."
elif source and suffix in {".step", ".stp"}:
mode = "inspect_existing_step_then_modify"
reason = "Inspect the explicitly supplied STEP before choosing local editing or reconstruction."
else:
mode = "existing_source_required"
reason = "An edit was requested without a resolvable editable source or private case."
@@ -381,21 +307,30 @@ def classify_edit_context(
"edit_source": str(source) if source else None,
"modification_mode": mode,
"reason": reason,
"allowed_knowledge_sources": [
"the explicitly supplied original model or editable generator",
"the promoted generalized CAD experience library",
"the current user request",
],
"allowed_knowledge_sources": (
[
"deterministically extracted private teacher evidence",
"the promoted generalized CAD experience library",
"the current user request",
]
if source and suffix in {".step", ".stp"}
else [
"the explicitly supplied DesignIR or editable generator",
"the promoted generalized CAD experience library",
"the current user request",
]
),
"forbidden_knowledge_sources": [
"cad-experience-plugin/parser/input",
"cad-experience-plugin/parser/output",
"private case JSON",
"teacher STEP during reconstruction",
"acceptance vault",
"embedded B-Rep or mesh geometry",
"private cases unrelated to the active teacher evidence",
],
"preservation_contract": [
"Preserve the source coordinate frame unless the request explicitly changes it.",
"Preserve unmodified interfaces, datums, and feature relationships.",
"Prefer the per-part model-spec.json and native generator over inferred STEP reconstruction when they exist.",
"Use generalized experience only as method guidance, never as replacement source geometry.",
"Prefer the per-part DesignIR and native generator when they exist.",
"Use generalized experience only as method guidance, never as teacher geometry.",
],
}
@@ -481,7 +416,6 @@ def load_experience_library(
def route(args: argparse.Namespace) -> dict[str, object]:
request = args.request.strip()
text = request.lower()
outputs = normalize_formats(args.output)
inferred_family, inferred_features = infer_experience_query(request)
experience_family = args.experience_family or inferred_family
experience_features = args.experience_feature or inferred_features
@@ -490,6 +424,11 @@ def route(args: argparse.Namespace) -> dict[str, object]:
args.edit_source,
args.existing_model,
)
teacher_step_reconstruction = bool(
edit_context
and edit_context.get("modification_mode")
== "private_evidence_to_isolated_designir_reconstruction"
)
experience_context = load_experience_library(
args.experience_library,
experience_family,
@@ -499,7 +438,6 @@ def route(args: argparse.Namespace) -> dict[str, object]:
scores = {
"build123d": BackendScore("build123d", 35, ["+35 general STEP-first default"]),
"simplecadapi": BackendScore("simplecadapi", 20, ["+20 specialist mechanical baseline"]),
"cadam": BackendScore("cadam", 10, ["+10 CADAM printable CSG baseline"]),
}
if contains_any(text, KEYWORD_GROUPS["special_mechanical"]):
@@ -508,42 +446,18 @@ def route(args: argparse.Namespace) -> dict[str, object]:
if contains_any(text, KEYWORD_GROUPS["general_mechanical"]):
scores["build123d"].add(35, "general mechanical part with direct parametric features")
scores["simplecadapi"].add(10, "general OCP modeling is available")
if contains_any(text, KEYWORD_GROUPS["printable"]):
scores["cadam"].add(55, "printable, pattern-heavy, or SCAD-native request")
if contains_any(text, KEYWORD_GROUPS["replay"]):
scores["simplecadapi"].add(60, "replay, semantic graph, or FreeCAD requirement")
scores["simplecadapi"].add(60, "replay or semantic graph requirement")
if edit_context:
scores["build123d"].add(55, "existing model inspection or source modification")
scores["cadam"].add(-50, "poor fit for imported editable STEP")
if args.assembly:
scores["build123d"].add(15, "source-level assembly workflow")
scores["simplecadapi"].add(18, "mechanism and constraint workflow")
scores["cadam"].add(-15, "weak fit for engineering assembly relationships")
if args.browser_controls:
scores["cadam"].add(45, "CADAM-style parameter controls requested")
if args.manufacturing == "machining":
scores["build123d"].add(25, "machining favors STEP-first B-Rep")
scores["simplecadapi"].add(18, "OCP STEP geometry suits machining")
scores["cadam"].add(-25, "mesh/CSG route is not preferred for machining")
elif args.manufacturing == "printing":
scores["cadam"].add(30, "printing favors fast parametric CSG")
scores["build123d"].add(8, "can export validated printable meshes")
elif args.manufacturing == "laser-cutting":
scores["build123d"].add(12, "can own projected source geometry")
if any(fmt in {"step", "stp"} for fmt in outputs):
scores["build123d"].add(35, "editable STEP requested")
scores["simplecadapi"].add(30, "OCP STEP export requested")
scores["cadam"].add(-70, "no native editable STEP output")
if "fcstd" in outputs:
scores["simplecadapi"].add(70, "FreeCAD output requested")
scores["cadam"].add(-40, "FreeCAD output is unsupported")
if "scad" in outputs:
scores["cadam"].add(80, "SCAD source requested")
if any(fmt in {"stl", "3mf"} for fmt in outputs):
scores["cadam"].add(12, "print mesh requested")
scores["build123d"].add(5, "secondary mesh export is available")
scores["build123d"].add(35, "editable STEP is the fixed artifact contract")
scores["simplecadapi"].add(
30, "OCP STEP export satisfies the fixed artifact contract"
)
if experience_context and experience_context["experiences"]:
scores["build123d"].add(18, "generalized experience can guide parametric feature composition")
scores["simplecadapi"].add(18, "generalized experience can guide semantic feature composition")
@@ -568,22 +482,9 @@ def route(args: argparse.Namespace) -> dict[str, object]:
profiles.append("requirement_refinement")
profiles = list(dict.fromkeys(profiles))
downstream = [name for name, terms in FOCUSED_SKILLS if contains_any(text, terms)]
if args.manufacturing == "laser-cutting" and "dxf" not in downstream:
downstream.append("dxf")
if any(fmt in {"stl", "3mf", "gcode"} for fmt in outputs) and "cad-viewer" not in downstream:
downstream.append("cad-viewer")
elif selected.name in {"build123d", "simplecadapi"}:
downstream.append("cad-viewer")
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
backend_info = registry["backends"][selected.name]
source_of_truth = backend_info["source_of_truth"]
if (
edit_context
and edit_context.get("modification_mode") == "model_spec_parameter_edit"
):
source_of_truth = "per-part model-spec.json with a backend generator"
source_of_truth = "per-part DesignIR 2.0 with an isolated backend compiler"
gap = selected.score - max((scores[name].score for name in scores if name != selected.name), default=0)
confidence = "high" if gap >= 40 else "medium" if gap >= 15 else "low"
result: dict[str, object] = {
@@ -600,8 +501,10 @@ def route(args: argparse.Namespace) -> dict[str, object]:
],
"fallback_order": fallback,
"workflow_profiles": profiles,
"downstream_skills": downstream,
"requested_outputs": outputs,
"artifact_contract": {
"primary_format": "step",
"editable_contract": "DesignIR 2.0",
},
"source_of_truth": source_of_truth,
"experience_context": experience_context,
"design_plan": {
@@ -664,27 +567,37 @@ def route(args: argparse.Namespace) -> dict[str, object]:
else None
),
"knowledge_boundary": (
"Classify only from the explicitly supplied source model; "
"never from parser input/output or private case JSON."
"Classify from active-job private evidence only; the "
"reconstruction agent never reads teacher STEP."
),
},
"edit_context": edit_context,
"execution_steps": (
[
"Load only the explicitly supplied source model or native generator.",
"Inspect the source model to classify its family and feature roles when the request does not name them, then re-query promoted experience.",
"Run deterministic ingestion in the protected teacher zone.",
"Give Agent A only the active private evidence packet and promoted experience.",
"Author complete executable DesignIR with uncertainty and missing-capability declarations.",
"Run anti-cheat validation and compile in a sandbox without teacher STEP.",
"Give Agent B the teacher, DesignIR, rebuilt STEP, and edit contract.",
"Run geometry comparison and parameter perturbation acceptance.",
"Submit sanitized feedback to deterministic promotion policy.",
]
if teacher_step_reconstruction
else
[
"Load only the explicitly supplied DesignIR or native generator.",
"Map requested changes to named parameters or affected semantic feature roles.",
"Preserve unchanged interfaces and rebuild only the smallest responsible feature region.",
"Use promoted generalized experience to guide methods; never read parser input/output.",
"Use promoted generalized experience to guide methods.",
"Use semantic reconstruction for topology changes that cannot preserve the prior feature structure.",
"Validate changed dimensions plus invariants inherited from the previous model.",
]
if edit_context
else [
"Resolve explicit user dimensions and preserve them as authoritative parameters.",
"Instantiate matched reconstruction grammars as symbolic model-spec.json parameters, datums, and canonical feature stages; never copy teacher dimensions.",
"Instantiate matched reconstruction grammars as DesignIR parameters, datums, constraints, and canonical feature stages; never copy teacher geometry.",
"Translate requested feature roles and matched generalized methods into backend operations.",
"Generate the editable source of truth and STEP-first artifact when requested.",
"Generate the editable source of truth and STEP artifact.",
"Validate topology, dimensions, feature relationships, surface mix, and export integrity against the grammar validation roles.",
]
),
@@ -692,7 +605,6 @@ def route(args: argparse.Namespace) -> dict[str, object]:
"project_contributions": {
"text-to-cad": "core Skill host, build123d CAD backend, refinement, visual repair, validation, artifacts, and CAD Viewer handoff",
"SimpleCADAPI": "specialized mechanical backend with replayable graphs and semantic modeling",
"CADAM": "OpenSCAD-WASM execution for printable and pattern-heavy SCAD models",
},
"availability": probe_backends() if args.probe else None,
}
@@ -710,9 +622,6 @@ def explain(result: dict[str, object]) -> str:
profiles = result["workflow_profiles"]
if profiles:
lines.append(f"Workflow profiles: {', '.join(profiles)}")
downstream = result["downstream_skills"]
if downstream:
lines.append(f"Downstream skills: {', '.join('$' + name for name in downstream)}")
experience_context = result.get("experience_context")
if experience_context:
lines.append(
@@ -734,25 +643,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("request", help="Natural-language CAD request.")
parser.add_argument(
"--backend",
choices=("auto", "build123d", "simplecadapi", "cadam"),
choices=("auto", "build123d", "simplecadapi"),
default="auto",
help="Override automatic routing.",
)
parser.add_argument("--output", action="append", default=[], help="Required output format; repeatable.")
parser.add_argument(
"--manufacturing",
choices=("unspecified", "machining", "printing", "laser-cutting", "concept"),
default="unspecified",
)
parser.add_argument("--existing-model", action="store_true", help="The task modifies or inspects an existing model.")
parser.add_argument(
"--edit-source",
type=Path,
help="Existing model-spec.json, generator, or explicit STEP/STP to modify; private case JSON is forbidden.",
help="Existing *.designir.json, generator, or explicit STEP/STP teacher to reconstruct; private corpus access is forbidden.",
)
parser.add_argument("--assembly", action="store_true", help="The requested result is an assembly.")
parser.add_argument("--browser-controls", action="store_true", help="Interactive browser parameter controls are required.")
parser.add_argument("--probe", action="store_true", help="Probe current-interpreter and OpenSCAD availability.")
parser.add_argument("--probe", action="store_true", help="Probe current-interpreter backend availability.")
parser.add_argument(
"--experience-library",
type=Path,
@@ -1,130 +0,0 @@
from __future__ import annotations
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from build123d import Box, Cylinder, Pos, export_step, import_step
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "model_spec.py"
MODULE_SPEC = importlib.util.spec_from_file_location("cad_router_model_spec", SCRIPT_PATH)
assert MODULE_SPEC is not None and MODULE_SPEC.loader is not None
MODEL_SPEC = importlib.util.module_from_spec(MODULE_SPEC)
MODULE_SPEC.loader.exec_module(MODEL_SPEC)
class ModelSpecTests(unittest.TestCase):
def test_affine_parameter_expression(self) -> None:
payload = {
"parameters": {
"extension": {
"value": 2.0,
}
}
}
self.assertEqual(
MODEL_SPEC.resolve_number(
payload,
{
"parameter": "extension",
"scale": 0.5,
"offset": 14.1,
},
"center[2]",
),
15.1,
)
def test_import_step_exact_base_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "teacher.step"
export_step(Box(30, 20, 10) - Pos(0, 0, -1) * Cylinder(3, 12), source)
result = MODEL_SPEC.import_step_model(
source,
root / "task",
"round_trip",
)
verification = MODEL_SPEC.verify_model(
Path(result["model_spec"])
)
self.assertTrue(verification["valid"])
self.assertEqual(
verification["checks"][0]["check"],
"exact_base_geometry_signature",
)
self.assertTrue((root / "task" / "round_trip.step").is_file())
self.assertTrue((root / "task" / "round_trip.stl").is_file())
def test_parameterized_cut_rebuilds_from_json(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source.step"
export_step(Box(40, 30, 12), source)
imported = MODEL_SPEC.import_step_model(
source,
root / "task",
"parameterized_cut",
)
spec_path = Path(imported["model_spec"])
payload = json.loads(spec_path.read_text(encoding="utf-8"))
payload["parameters"] = {
"bore_diameter": {
"value": 6,
"unit": "mm",
"role": "central_bore_diameter",
},
"bore_length": {
"value": 16,
"unit": "mm",
"role": "through_cut_length",
},
}
payload["modifications"] = [
{
"id": "central_bore",
"operation": "cut_cylinder",
"axis": "z",
"center": [0, 0, 0],
"diameter": {"parameter": "bore_diameter"},
"length": {"parameter": "bore_length"},
"enabled": True,
}
]
spec_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
first = MODEL_SPEC.build_model(spec_path)
first_volume = first["facts"]["volume_mm3"]
MODEL_SPEC.set_parameter(spec_path, "bore_diameter", 12)
second = MODEL_SPEC.build_model(spec_path)
self.assertLess(second["facts"]["volume_mm3"], first_volume)
rebuilt = import_step(second["step"])
self.assertGreater(len(rebuilt.faces()), 0)
def test_exact_base_rejects_changed_source(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source.step"
export_step(Box(10, 10, 10), source)
imported = MODEL_SPEC.import_step_model(
source,
root / "task",
"checksum_guard",
)
export_step(Box(12, 10, 10), root / "task" / "source.step")
with self.assertRaises(MODEL_SPEC.ModelSpecError):
MODEL_SPEC.build_model(Path(imported["model_spec"]))
if __name__ == "__main__":
unittest.main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-94
View File
@@ -1,94 +0,0 @@
---
name: dxf
description: Generate, regenerate, and validate 2D DXF drawings from Python ezdxf sources. Use for DXF files, gen_dxf() sources, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry.
---
# DXF generation and validation
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
## Purpose
Create or modify 2D DXF drawings from natural-language requirements or from CAD geometry, generate validated `.dxf` artifacts, and return checked outputs. DXF sources are Python files defining `gen_dxf()` returning an `ezdxf` document; the CLI owns output paths.
Two source shapes are supported:
- **Standalone drafting**: a Python source defining only `gen_dxf()`. Use for pure 2D outputs — gaskets, panels, templates, cut layouts — with no 3D model behind them.
- **CAD projection**: a `gen_dxf()` added to a CAD generator source that also defines `gen_step()`. Use when the DXF is a drawing or profile of a 3D part; create and validate the STEP geometry with `$cad` first, then add the projection in the same source file.
## Use this skill when
Use this skill when the user asks for DXF files, 2D drawings, profiles, outlines, templates, gaskets, panels, flat patterns, or cut layouts for laser, plasma, waterjet, or CNC routing.
Use `$cad` for the 3D part or assembly a DXF derives from. Use `$sendcutsend` for SendCutSend-specific upload preflight.
## Defaults
Use these defaults unless the user specifies otherwise:
- Units: millimeters; set them explicitly on the document (`doc.units = ezdxf.units.MM`).
- Geometry lives in modelspace at 1:1 scale.
- Cut profiles are closed polylines or closed line/arc loops; open contours only for engraving or reference geometry.
- For CAD-backed parts, prefer deriving DXF cut contours from the actual STEP/solid topology in the same generator script: build the 3D shape, select/project the real planar faces, unfold them into flat coordinates, and emit closed contours from those projected face wires. Use hand-drawn parametric outlines only when there is no reliable 3D topology to project.
- Layers carry intent: keep cut geometry and bend/fold lines on separate layers, and include "bend" in bend-layer names so downstream tools classify them as bends rather than cuts.
- DXF layers are drawing structure, not STEP part/assembly structure.
## Tool
The launcher lives in the DXF skill directory:
```bash
python scripts/dxf targets... [flags]
```
Use the active project Python interpreter; treat `python` as an interpreter placeholder, and use `--help` for the full interface. Target paths resolve from the command's current working directory; run from the workspace that owns the artifacts with cwd-relative target paths. Keep a DXF output and its Python generator in the same directory with the same basename unless the user requests otherwise.
A DXF target is a Python source defining:
```python
def gen_dxf():
...
return document
```
Plain generated Python targets write sibling `.dxf` outputs. Use `-o`/`--output` only with one plain generated Python target, or use `SOURCE.py=OUTPUT.dxf` positional pairs for per-target custom outputs. Do not put output paths in the `gen_dxf()` return value.
`scripts/dxf` is a generator; it does not inspect existing `.dxf` files. For existing DXF inspection, use `ezdxf` for entity/layer checks and `$cad-viewer` for visual review.
## Workflow
1. Convert the request into a short brief: outline dimensions, holes and slots, layers, units, output path, and validation targets.
2. For CAD projections, generate and validate the STEP geometry with `$cad` first, then add or update `gen_dxf()` in the same source. When possible, derive the DXF from in-memory STEP/solid topology rather than duplicating geometry formulas, so the DXF remains a direct projection/unfold of the part being exported.
3. Write or edit the Python source with meaningful dimensions as named parameters.
4. Run `scripts/dxf` on explicit Python source targets only; do not run directory-wide generation.
```bash
python scripts/dxf path/to/source.py
python scripts/dxf path/to/source.py -o path/to/output.dxf
python scripts/dxf path/to/a.py=out/a.dxf path/to/b.py=out/b.dxf
```
5. Validate the generated DXF deterministically, then hand off and report.
## Validation
Verify the generated file with targeted `ezdxf` checks instead of eyeballing: entity counts by type and layer, closed flags on cut profiles, drawing extents, and every dimension the user specified.
```python
import ezdxf
doc = ezdxf.readfile("path/to/output.dxf")
msp = doc.modelspace()
profiles = [e for e in msp.query("LWPOLYLINE") if e.closed]
holes = msp.query('CIRCLE[layer=="0"]')
```
Report only checks that actually ran.
## Handoff
After creating or modifying `.dxf` artifacts, you must ALWAYS hand the explicit file path(s) to `$cad-viewer` when that skill is installed and include its live viewer link(s) in the final response. If `$cad-viewer` is unavailable or startup fails, report that and rely on `ezdxf` checks instead of silently omitting the handoff.
Final responses should include generated files, returned viewer links, validation actually run, and assumptions.
@@ -1,4 +0,0 @@
interface:
display_name: "DXF"
short_description: "Generate and validate 2D DXF drawings."
default_prompt: "Use $dxf to create, regenerate, and validate explicit DXF drawing files from gen_dxf() Python sources, handing outputs to $cad-viewer when available."
-2
View File
@@ -1,2 +0,0 @@
--editable ./scripts/packages/cadpy
ezdxf
@@ -1 +0,0 @@
"""DXF generation CLI."""
@@ -1,16 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
if __package__ in {None, ""}:
tool_dir = Path(__file__).resolve().parent
if str(tool_dir) not in sys.path:
sys.path.insert(0, str(tool_dir))
from cli import main
else:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
-53
View File
@@ -1,53 +0,0 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
def generate_dxf_targets(*args, **kwargs):
from cadpy.generation import generate_dxf_targets as generate
return generate(*args, **kwargs)
def _targets_include_output_pairs(targets: Sequence[str]) -> bool:
return any("=" in str(target or "") for target in targets)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="dxf",
description="Generate explicit DXF targets from Python sources.",
)
parser.add_argument(
"targets",
nargs="+",
help="Explicit Python source file or SOURCE.py=OUTPUT.dxf pair defining gen_dxf() to generate.",
)
parser.add_argument(
"-o",
"--output",
metavar="PATH",
help="Write the generated DXF file to this path. Valid only with one plain generated Python target.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed progress and timing information.",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
if args.output is not None:
if _targets_include_output_pairs(args.targets):
parser.error("--output cannot be combined with SOURCE=OUTPUT targets")
if len(args.targets) != 1:
parser.error("--output can only be used with exactly one target")
return generate_dxf_targets(args.targets, output=args.output, verbose=bool(args.verbose))
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,411 +0,0 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
import ezdxf
DXF_RENDER_SCHEMA_VERSION = 1
SUPPORTED_ENTITY_TYPES = {"LINE", "ARC", "CIRCLE", "LWPOLYLINE"}
ANGLE_EPSILON = 1e-9
@dataclass(frozen=True)
class LineEntity:
layer: str
start: tuple[float, float]
end: tuple[float, float]
@dataclass(frozen=True)
class ArcEntity:
layer: str
center: tuple[float, float]
radius: float
start_angle_deg: float
sweep_angle_deg: float
@property
def end_angle_deg(self) -> float:
return self.start_angle_deg + self.sweep_angle_deg
@dataclass(frozen=True)
class CircleEntity:
layer: str
center: tuple[float, float]
radius: float
def _normalize_layer_name(value: object) -> str:
text = str(value or "").strip()
return text or "0"
def _semantic_kind_for_layer(layer_name: str) -> str:
return "bend" if "bend" in layer_name.strip().lower() else "cut"
def _normalize_angle(angle_deg: float) -> float:
value = math.fmod(angle_deg, 360.0)
return value + 360.0 if value < 0.0 else value
def _angle_in_sweep(angle_deg: float, start_angle_deg: float, sweep_angle_deg: float) -> bool:
abs_sweep_angle_deg = abs(sweep_angle_deg)
if abs_sweep_angle_deg >= 360.0 - ANGLE_EPSILON:
return True
if sweep_angle_deg >= 0.0:
normalized_delta = (_normalize_angle(angle_deg) - _normalize_angle(start_angle_deg)) % 360.0
return normalized_delta <= abs_sweep_angle_deg + ANGLE_EPSILON
normalized_delta = (_normalize_angle(start_angle_deg) - _normalize_angle(angle_deg)) % 360.0
return normalized_delta <= abs_sweep_angle_deg + ANGLE_EPSILON
def _point_on_circle(center: tuple[float, float], radius: float, angle_deg: float) -> tuple[float, float]:
radians = math.radians(angle_deg)
return (
center[0] + radius * math.cos(radians),
center[1] + radius * math.sin(radians),
)
def _arc_extrema_points(arc: ArcEntity) -> list[tuple[float, float]]:
points = [
_point_on_circle(arc.center, arc.radius, arc.start_angle_deg),
_point_on_circle(arc.center, arc.radius, arc.end_angle_deg),
]
for candidate_angle in (0.0, 90.0, 180.0, 270.0):
if _angle_in_sweep(candidate_angle, arc.start_angle_deg, arc.sweep_angle_deg):
points.append(_point_on_circle(arc.center, arc.radius, candidate_angle))
return points
def _line_bounds(line: LineEntity) -> tuple[float, float, float, float]:
xs = (line.start[0], line.end[0])
ys = (line.start[1], line.end[1])
return (min(xs), min(ys), max(xs), max(ys))
def _circle_bounds(circle: CircleEntity) -> tuple[float, float, float, float]:
cx, cy = circle.center
r = circle.radius
return (cx - r, cy - r, cx + r, cy + r)
def _arc_bounds(arc: ArcEntity) -> tuple[float, float, float, float]:
points = _arc_extrema_points(arc)
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return (min(xs), min(ys), max(xs), max(ys))
def _expand_bounds(
current: tuple[float, float, float, float] | None,
next_bounds: tuple[float, float, float, float],
) -> tuple[float, float, float, float]:
if current is None:
return next_bounds
return (
min(current[0], next_bounds[0]),
min(current[1], next_bounds[1]),
max(current[2], next_bounds[2]),
max(current[3], next_bounds[3]),
)
def _screen_point(point: tuple[float, float], *, min_x: float, max_y: float) -> tuple[float, float]:
return (point[0] - min_x, max_y - point[1])
def _format_number(value: float) -> float:
rounded = round(float(value), 6)
return 0.0 if abs(rounded) < ANGLE_EPSILON else rounded
def _build_path_record(layer_name: str, semantic_kind: str, path_data: str) -> dict[str, object]:
return {"layer": layer_name, "kind": semantic_kind, "d": path_data}
def _arc_from_bulge_segment(
*,
layer_name: str,
start: tuple[float, float],
end: tuple[float, float],
bulge: float,
) -> ArcEntity | None:
dx = end[0] - start[0]
dy = end[1] - start[1]
chord_length = math.hypot(dx, dy)
if chord_length <= ANGLE_EPSILON or abs(bulge) <= ANGLE_EPSILON:
return None
included_angle_rad = 4.0 * math.atan(bulge)
radius = (chord_length * (1.0 + bulge * bulge)) / (4.0 * abs(bulge))
midpoint = ((start[0] + end[0]) / 2.0, (start[1] + end[1]) / 2.0)
left_normal = (-dy / chord_length, dx / chord_length)
center_offset = (chord_length * (1.0 - bulge * bulge)) / (4.0 * bulge)
center = (
midpoint[0] + left_normal[0] * center_offset,
midpoint[1] + left_normal[1] * center_offset,
)
start_angle_deg = _normalize_angle(math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])))
return ArcEntity(
layer=layer_name,
center=center,
radius=radius,
start_angle_deg=start_angle_deg,
sweep_angle_deg=math.degrees(included_angle_rad),
)
def _lwpolyline_entities(
entity,
*,
layer_name: str,
dxf_path: Path,
) -> tuple[list[LineEntity], list[ArcEntity]]:
vertices: list[tuple[tuple[float, float], float]] = []
for point in entity:
bulge = float(point[4]) if len(point) > 4 else 0.0
vertices.append(((float(point[0]), float(point[1])), bulge))
if len(vertices) < 2:
raise ValueError(f"Invalid DXF LWPOLYLINE in {dxf_path.as_posix()}: expected at least 2 vertices")
lines: list[LineEntity] = []
arcs: list[ArcEntity] = []
def add_segment(start_vertex: tuple[tuple[float, float], float], end_vertex: tuple[tuple[float, float], float]) -> None:
start, bulge = start_vertex
end, _ = end_vertex
if start == end:
return
if abs(bulge) > ANGLE_EPSILON:
arc = _arc_from_bulge_segment(layer_name=layer_name, start=start, end=end, bulge=bulge)
if arc is not None:
arcs.append(arc)
return
lines.append(LineEntity(layer=layer_name, start=start, end=end))
for start_vertex, end_vertex in zip(vertices, vertices[1:]):
add_segment(start_vertex, end_vertex)
if entity.closed:
add_segment(vertices[-1], vertices[0])
return lines, arcs
def _load_dxf_entities(document, dxf_path: Path) -> tuple[list[LineEntity], list[ArcEntity], list[CircleEntity]]:
modelspace = document.modelspace()
lines: list[LineEntity] = []
arcs: list[ArcEntity] = []
circles: list[CircleEntity] = []
for entity in modelspace:
entity_type = entity.dxftype()
if entity_type not in SUPPORTED_ENTITY_TYPES:
raise ValueError(
f"Unsupported DXF entity {entity_type} in {dxf_path.as_posix()}; "
f"supported types: {', '.join(sorted(SUPPORTED_ENTITY_TYPES))}"
)
layer_name = _normalize_layer_name(entity.dxf.layer)
if entity_type == "LINE":
lines.append(
LineEntity(
layer=layer_name,
start=(float(entity.dxf.start.x), float(entity.dxf.start.y)),
end=(float(entity.dxf.end.x), float(entity.dxf.end.y)),
)
)
continue
if entity_type == "LWPOLYLINE":
polyline_lines, polyline_arcs = _lwpolyline_entities(entity, layer_name=layer_name, dxf_path=dxf_path)
lines.extend(polyline_lines)
arcs.extend(polyline_arcs)
continue
if entity_type == "ARC":
radius = float(entity.dxf.radius)
if radius <= 0.0:
raise ValueError(f"Invalid DXF arc radius in {dxf_path.as_posix()}: {radius}")
start_angle_deg = _normalize_angle(float(entity.dxf.start_angle))
end_angle_deg = _normalize_angle(float(entity.dxf.end_angle))
sweep_angle_deg = (end_angle_deg - start_angle_deg) % 360.0
if sweep_angle_deg <= ANGLE_EPSILON:
sweep_angle_deg = 360.0
arcs.append(
ArcEntity(
layer=layer_name,
center=(float(entity.dxf.center.x), float(entity.dxf.center.y)),
radius=radius,
start_angle_deg=start_angle_deg,
sweep_angle_deg=sweep_angle_deg,
)
)
continue
radius = float(entity.dxf.radius)
if radius <= 0.0:
raise ValueError(f"Invalid DXF circle radius in {dxf_path.as_posix()}: {radius}")
circles.append(
CircleEntity(
layer=layer_name,
center=(float(entity.dxf.center.x), float(entity.dxf.center.y)),
radius=radius,
)
)
if not lines and not arcs and not circles:
raise ValueError(f"No supported DXF entities found in {dxf_path.as_posix()}")
return lines, arcs, circles
def build_dxf_render_payload(dxf_path: Path, *, file_ref: str) -> dict[str, object]:
source_path = dxf_path.resolve()
document = ezdxf.readfile(source_path)
lines, arcs, circles = _load_dxf_entities(document, source_path)
raw_bounds: tuple[float, float, float, float] | None = None
for line in lines:
raw_bounds = _expand_bounds(raw_bounds, _line_bounds(line))
for arc in arcs:
raw_bounds = _expand_bounds(raw_bounds, _arc_bounds(arc))
for circle in circles:
raw_bounds = _expand_bounds(raw_bounds, _circle_bounds(circle))
if raw_bounds is None:
raise ValueError(f"Failed to compute DXF bounds for {source_path.as_posix()}")
min_x, min_y, max_x, max_y = raw_bounds
width = max(max_x - min_x, 0.0)
height = max(max_y - min_y, 0.0)
path_records: list[dict[str, object]] = []
circle_records: list[dict[str, object]] = []
layer_summary: dict[str, dict[str, object]] = {}
def touch_layer(layer_name: str) -> dict[str, object]:
summary = layer_summary.get(layer_name)
if summary is not None:
return summary
summary = {
"name": layer_name,
"kind": _semantic_kind_for_layer(layer_name),
"pathCount": 0,
"circleCount": 0,
}
layer_summary[layer_name] = summary
return summary
for line in lines:
start = _screen_point(line.start, min_x=min_x, max_y=max_y)
end = _screen_point(line.end, min_x=min_x, max_y=max_y)
path_records.append(
_build_path_record(
line.layer,
_semantic_kind_for_layer(line.layer),
(
f"M {_format_number(start[0])} {_format_number(start[1])} "
f"L {_format_number(end[0])} {_format_number(end[1])}"
),
)
)
touch_layer(line.layer)["pathCount"] += 1
for arc in arcs:
start_point = _screen_point(
_point_on_circle(arc.center, arc.radius, arc.start_angle_deg),
min_x=min_x,
max_y=max_y,
)
end_point = _screen_point(
_point_on_circle(arc.center, arc.radius, arc.end_angle_deg),
min_x=min_x,
max_y=max_y,
)
large_arc_flag = 1 if abs(arc.sweep_angle_deg) > 180.0 + ANGLE_EPSILON else 0
sweep_flag = 0 if arc.sweep_angle_deg >= 0.0 else 1
path_records.append(
_build_path_record(
arc.layer,
_semantic_kind_for_layer(arc.layer),
(
f"M {_format_number(start_point[0])} {_format_number(start_point[1])} "
f"A {_format_number(arc.radius)} {_format_number(arc.radius)} 0 "
f"{large_arc_flag} {sweep_flag} {_format_number(end_point[0])} {_format_number(end_point[1])}"
),
)
)
touch_layer(arc.layer)["pathCount"] += 1
for circle in circles:
center = _screen_point(circle.center, min_x=min_x, max_y=max_y)
circle_records.append(
{
"layer": circle.layer,
"kind": _semantic_kind_for_layer(circle.layer),
"cx": _format_number(center[0]),
"cy": _format_number(center[1]),
"r": _format_number(circle.radius),
}
)
touch_layer(circle.layer)["circleCount"] += 1
return {
"schemaVersion": DXF_RENDER_SCHEMA_VERSION,
"fileRef": file_ref,
"sourceUnits": int(getattr(document, "units", 0) or 0),
"defaultThicknessMm": 0.0,
"bounds": {
"minX": 0.0,
"minY": 0.0,
"maxX": _format_number(width),
"maxY": _format_number(height),
"width": _format_number(width),
"height": _format_number(height),
},
"counts": {
"paths": len(path_records),
"circles": len(circle_records),
"entities": len(path_records) + len(circle_records),
},
"layers": [layer_summary[name] for name in sorted(layer_summary)],
"geometry": {
"lines": [
{
"layer": line.layer,
"kind": _semantic_kind_for_layer(line.layer),
"start": [_format_number(line.start[0]), _format_number(line.start[1])],
"end": [_format_number(line.end[0]), _format_number(line.end[1])],
}
for line in lines
],
"arcs": [
{
"layer": arc.layer,
"kind": _semantic_kind_for_layer(arc.layer),
"center": [_format_number(arc.center[0]), _format_number(arc.center[1])],
"radius": _format_number(arc.radius),
"startAngleDeg": _format_number(arc.start_angle_deg),
"sweepAngleDeg": _format_number(arc.sweep_angle_deg),
}
for arc in arcs
],
"circles": [
{
"layer": circle.layer,
"kind": _semantic_kind_for_layer(circle.layer),
"center": [_format_number(circle.center[0]), _format_number(circle.center[1])],
"radius": _format_number(circle.radius),
}
for circle in circles
],
},
"paths": path_records,
"circles": circle_records,
}
@@ -1 +0,0 @@
../../../../packages/cadpy
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-137
View File
@@ -1,137 +0,0 @@
---
name: gcode
description: Generate, inspect, dry-run, and statically validate plain FDM `.gcode` from 3D mesh files by orchestrating real slicer CLIs. Use when Codex needs to slice `.stl`, `.obj`, unsliced `.3mf`, `.ply`, `.glb`, or `.gltf` into printer-profiled G-code, discover local slicer backends, inspect whether a mesh is slice-ready, or validate generated G-code before any printer-specific handoff.
---
# G-code
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill for plain `.gcode` generation from mesh files. It is printer-agnostic and never uploads, starts, or packages print jobs.
## Workflow
1. Confirm the input is a supported mesh: `.stl`, `.obj`, unsliced `.3mf`, `.ply`, `.glb`, or `.gltf`.
2. Require an explicit printer/profile wrapper JSON. Do not invent real-printer profiles.
3. Discover slicer backends when the backend is unknown:
```bash
python scripts/gcode_tool.py discover
```
4. Inspect the input:
```bash
python scripts/gcode_tool.py inspect --input path/to/model.stl --json
```
5. Dry-run the slicer command before executing:
```bash
python scripts/gcode_tool.py slice \
--input path/to/model.stl \
--output /tmp/model.gcode \
--profile path/to/profile.json \
--backend auto \
--dry-run
```
6. Execute only after the dry-run command and profile are appropriate:
```bash
python scripts/gcode_tool.py slice \
--input path/to/model.stl \
--output /tmp/model.gcode \
--profile path/to/profile.json \
--backend auto \
--execute
```
7. Validate the generated G-code:
```bash
python scripts/gcode_tool.py validate \
--gcode /tmp/model.gcode \
--profile path/to/profile.json \
--json
```
## CAD Viewer Handoff
After completing G-code work that creates or modifies a plain `.gcode`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); the preview is diagnostic only and does not replace this skill's static validation. If `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Profile Contract
Every slice requires a wrapper profile JSON with an absolute native slicer profile path:
```json
{
"backend": "orcaslicer",
"native_config": "/absolute/path/to/native-slicer-profile",
"machine": {
"name": "Example Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180,
"motion_bounds_mm": {
"x": [0, 180],
"y": [0, 180],
"z": [0, 180]
}
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65
}
}
```
The wrapper supplies validation bounds and backend selection. `machine.motion_bounds_mm` is optional; omit it for the default `0..bed_size` and `0..z_height` bounds, or set it from a native printer profile when start/end G-code intentionally uses safe wipe/purge positions outside the printable area. The native slicer profile remains the source of detailed process, printer, and filament behavior.
For OrcaSlicer, use `native_settings` and `native_filaments` when the real profile is split across machine, process, and filament JSON files. Keep `native_config` as an absolute path to the primary native profile for compatibility:
```json
{
"backend": "orcaslicer",
"native_config": "/absolute/path/to/machine-or-process.json",
"native_settings": [
"/absolute/path/to/machine.json",
"/absolute/path/to/process.json"
],
"native_filaments": [
"/absolute/path/to/filament.json"
],
"machine": {
"name": "Example Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65
}
}
```
## Backends And Inputs
Preferred slicer backend order is `orcaslicer`, `prusa-slicer`, then `curaengine`. Prefer installing OrcaSlicer when no preferred backend is available; on macOS use `brew install --cask orcaslicer` and then rerun `discover`. The helper checks both `PATH` and the usual `/Applications/OrcaSlicer.app` cask location. Bambu Studio may be reported by discovery as available but is not preferred because its CLI export path has shown macOS instability.
Pass `.stl`, `.obj`, and unsliced `.3mf` directly to the slicer. Convert `.ply`, `.glb`, and `.gltf` to temporary STL at execution time with optional `trimesh`; if `trimesh` is unavailable, ask the user to install it or provide `.stl`, `.obj`, or unsliced `.3mf`.
Reject `.step`, `.stp`, `.dxf`, `.svg`, `.urdf`, and `.sdf` in v1. Use the existing CAD/render workflows to convert those to a supported mesh format before using this skill.
Read `references/slicer-backends.md` when backend behavior, profile expectations, or source links matter.
## Validation
Always validate generated G-code before handing it to printer-specific workflows. The validator checks for non-empty content, temperature commands, movement commands, extrusion moves, XYZ bounds, and unknown command warnings.
Read `references/gcode-validation.md` when interpreting validation output or deciding whether a warning is acceptable.
## Bambu Boundary
This skill generates plain `.gcode` only. It does not create Bambu `.gcode.3mf` archives and does not contact printers. For Bambu upload/start workflows, hand off the validated plain `.gcode` to `$bambu-labs`. Let `$bambu-labs` choose the printer-specific LAN handoff, such as an A1 Mini template project or an explicitly enabled bambox project package.
@@ -1,4 +0,0 @@
interface:
display_name: "G-code"
short_description: "Generate G-code from 3D mesh files"
default_prompt: "Use $gcode to generate printer-profiled G-code from a mesh file."
@@ -1,42 +0,0 @@
# G-code Validation
`scripts/gcode_tool.py validate` performs static checks only. It does not simulate extrusion physics, firmware state, acceleration limits, or slicer-specific semantics.
## Required Checks
Validation fails when:
- The file is empty.
- No `G0`, `G1`, `G2`, or `G3` movement commands are present.
- No extrusion moves are present.
- No nozzle or bed temperature commands are present.
- Parsed absolute `X`, `Y`, or `Z` moves exceed the wrapper profile motion bounds.
Validation warns when:
- Unknown or unsupported G-code commands are encountered.
- Relative positioning is used; bounds checking is skipped while relative mode is active.
Warnings do not rewrite or delete commands. Treat them as review prompts, especially before sending G-code to unfamiliar firmware.
## Bounds Policy
The validator assumes absolute positioning until `G91` appears, and resumes absolute bounds checks after `G90`. This avoids false hard failures for relative motion blocks while still catching obvious out-of-bed absolute moves.
The wrapper profile provides printable bounds:
- `machine.bed_size_mm[0]`: maximum `X`
- `machine.bed_size_mm[1]`: maximum `Y`
- `machine.z_height_mm`: maximum `Z`
By default, motion bounds are `X=0..bed_size_mm[0]`, `Y=0..bed_size_mm[1]`, and `Z=0..z_height_mm`. If a native printer profile intentionally uses safe off-bed wipe, purge, or maintenance positions, set `machine.motion_bounds_mm` with explicit `x`, `y`, and/or `z` `[min, max]` ranges. Do this only from a real printer/profile source, not as a way to silence unknown G-code.
## Interpreting Results
`ok: true` means the file passed these static checks. It does not mean the G-code is safe to print on real hardware. Still review:
- Printer/profile match.
- Filament and temperature settings.
- Start and end G-code.
- Bed origin and coordinate system.
- Any unknown command warnings.
@@ -1,81 +0,0 @@
# Slicer Backends
Use real slicer CLIs for mesh-to-G-code work. Do not rely on Bambu Studio as the default backend for this skill.
## Backend Order
1. `orcaslicer`
2. `prusa-slicer`
3. `curaengine`
If no preferred backend is installed, install OrcaSlicer first instead of treating the missing slicer as a user-facing blocker. On macOS:
```bash
brew install --cask orcaslicer
```
After installation, rerun `scripts/gcode_tool.py discover`. The helper checks `PATH`, backend-specific environment variables, and common macOS app bundle locations such as `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`.
`scripts/gcode_tool.py discover` checks PATH and backend-specific environment variables:
- `ORCASLICER_BIN`
- `PRUSASLICER_BIN`
- `CURAENGINE_BIN`
Bambu Studio may also be reported as available but not preferred. It is intentionally excluded from default backend selection because the local Bambu CLI export path has previously crashed during `.gcode.3mf` generation.
## Profile Expectations
Every slice needs a wrapper profile JSON. The wrapper must include:
- `backend`: one of `orcaslicer`, `prusa-slicer`, or `curaengine`.
- `native_config`: absolute path to the primary native slicer config/profile file.
- `machine.bed_size_mm`: `[width, depth]` in millimeters.
- `machine.z_height_mm`: maximum Z travel in millimeters.
- `machine.motion_bounds_mm`: optional per-axis motion limits for native start/end G-code that intentionally moves outside the printable area.
- `filament.type`, `filament.nozzle_temp_c`, and `filament.bed_temp_c`.
The wrapper is not a complete slicer profile. The native config is authoritative for detailed printer, process, and filament settings.
For OrcaSlicer profiles split across native machine, process, and filament JSON files, add:
- `native_settings`: string or list of absolute paths passed to `--load-settings`.
- `native_filaments`: string or list of absolute paths passed to `--load-filaments`.
If `native_settings` is omitted, `native_config` is passed as the only setting file.
## Command Shapes
For OrcaSlicer:
```bash
OrcaSlicer --load-settings machine.json\;process.json --load-filaments filament.json --outputdir /tmp/out --slice 0 input.stl
```
For PrusaSlicer:
```bash
prusa-slicer --load profile.ini --export-gcode --output output.gcode input.stl
```
For CuraEngine:
```bash
CuraEngine slice -j profile.json -l input.stl -o output.gcode
```
Always run a dry-run first and inspect the emitted command before `--execute`.
## Input Handling
- `.stl`, `.obj`, and unsliced `.3mf` are passed directly to the selected slicer.
- `.ply`, `.glb`, and `.gltf` are converted to temporary STL with `trimesh` during `--execute`.
- Sliced Bambu 3MF files containing `Metadata/plate_N.gcode` are already print jobs; do not re-slice them.
- `.step`, `.stp`, `.dxf`, `.svg`, `.urdf`, and `.sdf` are out of scope for this skill. Convert them to a supported mesh first.
## Source Links
- FullControl procedural G-code context: https://github.com/FullControlXYZ/fullcontrol
- bambox Bambu packaging reference only: https://pypi.org/project/bambox/
- trimesh mesh format support: https://trimesh.org/formats.html
- CuraEngine slicing background: https://github.com/Ultimaker/CuraEngine/wiki/Slicing
@@ -1,741 +0,0 @@
#!/usr/bin/env python3
"""Dry-run-first helpers for generating and validating plain FDM G-code."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
DIRECT_MESH_EXTENSIONS = {".stl", ".obj", ".3mf"}
CONVERT_TO_STL_EXTENSIONS = {".ply", ".glb", ".gltf"}
UNSUPPORTED_EXTENSIONS = {".step", ".stp", ".dxf", ".svg", ".urdf", ".sdf"}
SLICED_BAMBU_PLATE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
PREFERRED_BACKEND_ORDER = ["orcaslicer", "prusa-slicer", "curaengine"]
BACKEND_EXECUTABLES = {
"orcaslicer": ["OrcaSlicer", "orca-slicer", "orcaslicer"],
"prusa-slicer": ["prusa-slicer", "PrusaSlicer", "prusa-slicer-console"],
"curaengine": ["CuraEngine", "curaengine"],
}
BACKEND_APP_GLOBS = {
"orcaslicer": ["OrcaSlicer*.app/Contents/MacOS/OrcaSlicer"],
"prusa-slicer": ["PrusaSlicer*.app/Contents/MacOS/PrusaSlicer"],
}
BACKEND_ENV_VARS = {
"orcaslicer": "ORCASLICER_BIN",
"prusa-slicer": "PRUSASLICER_BIN",
"curaengine": "CURAENGINE_BIN",
}
BAMBU_EXECUTABLES = ["bambu-studio", "BambuStudio"]
SUPPORTED_GCODE_COMMANDS = {
"G0",
"G1",
"G2",
"G3",
"G4",
"G21",
"G28",
"G29",
"G90",
"G91",
"G92",
"M18",
"M73",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M117",
"M118",
"M140",
"M190",
"M201",
"M203",
"M204",
"M205",
"M220",
"M221",
"M400",
"M500",
"M501",
"M900",
}
class GCodeToolError(RuntimeError):
"""Raised for expected user-facing workflow errors."""
@dataclass(frozen=True)
class BackendDiscovery:
id: str
available: bool
path: str | None
preferred: bool
reason: str = ""
@dataclass(frozen=True)
class GCodeProfile:
backend: str
native_config: str
native_settings: tuple[str, ...]
native_filaments: tuple[str, ...]
machine_name: str
bed_size_mm: tuple[float, float]
z_height_mm: float
motion_bounds_mm: dict[str, tuple[float, float]]
filament_type: str
nozzle_temp_c: float
bed_temp_c: float
@dataclass(frozen=True)
class InputInspection:
path: str
extension: str
size_bytes: int
supported: bool
direct_to_slicer: bool
needs_stl_conversion: bool
already_sliced_bambu: bool
status: str
def tail_text(value: str, *, max_lines: int = 80) -> str:
lines = value.splitlines()
if len(lines) <= max_lines:
return value
return "\n".join(lines[-max_lines:])
def find_executable(names: list[str], *, search_path: str | None = None) -> str | None:
for name in names:
found = shutil.which(name, path=search_path)
if found:
return found
return None
def find_app_executable(backend: str) -> str | None:
applications = Path("/Applications")
if not applications.exists():
return None
for pattern in BACKEND_APP_GLOBS.get(backend, []):
for path in sorted(applications.glob(pattern)):
if path.is_file():
return str(path)
return None
def find_backend_executable(backend: str, *, search_path: str | None = None) -> str | None:
env_value = os.environ.get(BACKEND_ENV_VARS[backend], "").strip()
if env_value:
return env_value
return find_executable(BACKEND_EXECUTABLES[backend], search_path=search_path) or find_app_executable(backend)
def discover_backends(*, search_path: str | None = None) -> list[BackendDiscovery]:
discoveries: list[BackendDiscovery] = []
for backend_id in PREFERRED_BACKEND_ORDER:
path = find_backend_executable(backend_id, search_path=search_path)
discoveries.append(
BackendDiscovery(
id=backend_id,
available=bool(path),
path=path or None,
preferred=True,
)
)
return discoveries
def discover_bambu_studio(*, search_path: str | None = None) -> list[BackendDiscovery]:
paths: list[str] = []
env_value = os.environ.get("BAMBU_STUDIO_BIN", "").strip()
if env_value:
paths.append(env_value)
which_value = find_executable(BAMBU_EXECUTABLES, search_path=search_path)
if which_value:
paths.append(which_value)
applications = Path("/Applications")
if applications.exists():
for pattern in ("BambuStudio*.app/Contents/MacOS/BambuStudio", "Bambu Studio*.app/Contents/MacOS/BambuStudio"):
paths.extend(str(path) for path in applications.glob(pattern))
unique_paths = sorted(dict.fromkeys(paths))
if not unique_paths:
return [
BackendDiscovery(
id="bambu-studio",
available=False,
path=None,
preferred=False,
reason="not preferred because Bambu Studio CLI has shown macOS export instability",
)
]
return [
BackendDiscovery(
id="bambu-studio",
available=True,
path=path,
preferred=False,
reason="available but not preferred because Bambu Studio CLI has shown macOS export instability",
)
for path in unique_paths
]
def discovery_report(*, search_path: str | None = None) -> dict[str, Any]:
backends = discover_backends(search_path=search_path)
not_preferred = discover_bambu_studio(search_path=search_path)
return {
"preferred_order": PREFERRED_BACKEND_ORDER,
"backends": [asdict(item) for item in backends],
"not_preferred": [asdict(item) for item in not_preferred],
}
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError as exc:
raise GCodeToolError(f"Profile does not exist: {path}") from exc
except json.JSONDecodeError as exc:
raise GCodeToolError(f"Profile is not valid JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise GCodeToolError("Profile JSON must be an object.")
return data
def require_number(value: Any, label: str) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise GCodeToolError(f"Profile field {label} must be a number.")
number = float(value)
if number <= 0:
raise GCodeToolError(f"Profile field {label} must be greater than zero.")
return number
def require_absolute_file(path_value: str, label: str) -> str:
profile_path = Path(path_value).expanduser()
if not profile_path.is_absolute():
raise GCodeToolError(f"Profile field {label} must be an absolute path.")
if not profile_path.is_file():
raise GCodeToolError(f"Profile {label} does not exist: {profile_path}")
return str(profile_path)
def optional_path_list(data: dict[str, Any], field: str) -> tuple[str, ...]:
value = data.get(field)
if value is None:
return ()
if isinstance(value, str):
values = [value]
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
values = value
else:
raise GCodeToolError(f"Profile field {field} must be a string path or list of string paths.")
paths = [item.strip() for item in values if item.strip()]
if not paths:
raise GCodeToolError(f"Profile field {field} cannot be empty when provided.")
return tuple(require_absolute_file(path, field) for path in paths)
def parse_axis_bounds(value: Any, label: str) -> tuple[float, float]:
if (
not isinstance(value, list)
or len(value) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value)
):
raise GCodeToolError(f"Profile field {label} must be [min, max].")
lower = float(value[0])
upper = float(value[1])
if lower >= upper:
raise GCodeToolError(f"Profile field {label} must have min less than max.")
return lower, upper
def load_profile(path: Path) -> GCodeProfile:
data = load_json(path)
backend = str(data.get("backend") or "").strip().lower()
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Profile backend must be one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
native_config = str(data.get("native_config") or "").strip()
if not native_config:
raise GCodeToolError("Profile field native_config is required.")
native_config = require_absolute_file(native_config, "native_config")
native_settings = optional_path_list(data, "native_settings") or (native_config,)
native_filaments = optional_path_list(data, "native_filaments")
machine = data.get("machine")
if not isinstance(machine, dict):
raise GCodeToolError("Profile field machine must be an object.")
machine_name = str(machine.get("name") or "").strip()
if not machine_name:
raise GCodeToolError("Profile field machine.name is required.")
bed_size = machine.get("bed_size_mm")
if (
not isinstance(bed_size, list)
or len(bed_size) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in bed_size)
):
raise GCodeToolError("Profile field machine.bed_size_mm must be [width, depth].")
bed_x = require_number(bed_size[0], "machine.bed_size_mm[0]")
bed_y = require_number(bed_size[1], "machine.bed_size_mm[1]")
z_height = require_number(machine.get("z_height_mm"), "machine.z_height_mm")
motion_bounds = {
"x": (0.0, bed_x),
"y": (0.0, bed_y),
"z": (0.0, z_height),
}
if "motion_bounds_mm" in machine:
raw_bounds = machine.get("motion_bounds_mm")
if not isinstance(raw_bounds, dict):
raise GCodeToolError("Profile field machine.motion_bounds_mm must be an object.")
for axis in ("x", "y", "z"):
if axis in raw_bounds:
motion_bounds[axis] = parse_axis_bounds(raw_bounds[axis], f"machine.motion_bounds_mm.{axis}")
filament = data.get("filament")
if not isinstance(filament, dict):
raise GCodeToolError("Profile field filament must be an object.")
filament_type = str(filament.get("type") or "").strip()
if not filament_type:
raise GCodeToolError("Profile field filament.type is required.")
nozzle_temp = require_number(filament.get("nozzle_temp_c"), "filament.nozzle_temp_c")
bed_temp = require_number(filament.get("bed_temp_c"), "filament.bed_temp_c")
return GCodeProfile(
backend=backend,
native_config=native_config,
native_settings=native_settings,
native_filaments=native_filaments,
machine_name=machine_name,
bed_size_mm=(bed_x, bed_y),
z_height_mm=z_height,
motion_bounds_mm=motion_bounds,
filament_type=filament_type,
nozzle_temp_c=nozzle_temp,
bed_temp_c=bed_temp,
)
def is_sliced_bambu_3mf(path: Path) -> bool:
try:
with zipfile.ZipFile(path) as archive:
return any(SLICED_BAMBU_PLATE_RE.match(info.filename) for info in archive.infolist())
except zipfile.BadZipFile:
return False
def inspect_input(path: Path) -> InputInspection:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"Input file does not exist: {resolved}")
extension = resolved.suffix.lower()
if extension in UNSUPPORTED_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"{extension} is out of scope for this skill. Supported v1 mesh inputs: {supported}.")
if extension not in DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"Unsupported input extension {extension or '<none>'}. Supported v1 mesh inputs: {supported}.")
already_sliced = extension == ".3mf" and is_sliced_bambu_3mf(resolved)
if already_sliced:
status = "already_sliced_bambu_3mf"
elif extension in DIRECT_MESH_EXTENSIONS:
status = "direct_to_slicer"
else:
status = "requires_stl_conversion"
return InputInspection(
path=str(resolved),
extension=extension,
size_bytes=resolved.stat().st_size,
supported=True,
direct_to_slicer=extension in DIRECT_MESH_EXTENSIONS and not already_sliced,
needs_stl_conversion=extension in CONVERT_TO_STL_EXTENSIONS,
already_sliced_bambu=already_sliced,
status=status,
)
def backend_path(backend: str, *, search_path: str | None = None) -> str:
path = find_backend_executable(backend, search_path=search_path)
if not path:
names = ", ".join(BACKEND_EXECUTABLES[backend])
raise GCodeToolError(f"Backend {backend} is not installed or not on PATH. Checked: {names}.")
return path
def build_backend_command(
*,
backend: str,
executable: str,
slicer_input: Path,
output: Path,
profile: GCodeProfile,
) -> list[str]:
if backend == "orcaslicer":
command = [
executable,
"--load-settings",
";".join(profile.native_settings),
]
if profile.native_filaments:
command.extend(["--load-filaments", ";".join(profile.native_filaments)])
command.extend(
[
"--outputdir",
str(output.parent),
"--slice",
"0",
str(slicer_input),
]
)
return command
if backend == "prusa-slicer":
return [
executable,
"--load",
profile.native_config,
"--export-gcode",
"--output",
str(output),
str(slicer_input),
]
if backend == "curaengine":
return [
executable,
"slice",
"-j",
profile.native_config,
"-l",
str(slicer_input),
"-o",
str(output),
]
raise GCodeToolError(f"Unsupported backend: {backend}")
def generated_gcode_candidate(output: Path, slicer_input: Path, existing_gcodes: set[Path]) -> Path | None:
expected_by_input = output.parent / f"{slicer_input.stem}.gcode"
if expected_by_input.is_file() and expected_by_input.resolve() != output.resolve():
return expected_by_input
generated = [
path
for path in output.parent.glob("*.gcode")
if path.resolve() not in existing_gcodes and path.resolve() != output.resolve()
]
if len(generated) == 1:
return generated[0]
return None
def build_slice_plan(args: argparse.Namespace, *, search_path: str | None = None, converted_input: Path | None = None) -> dict[str, Any]:
profile = load_profile(Path(args.profile).expanduser())
inspection = inspect_input(Path(args.input))
if inspection.already_sliced_bambu:
raise GCodeToolError("Input is already a sliced Bambu .gcode.3mf. Refusing to re-slice it.")
backend = profile.backend if args.backend == "auto" else args.backend
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Backend must be auto or one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
executable = backend_path(backend, search_path=search_path)
input_path = Path(inspection.path)
slicer_input = converted_input or input_path
if inspection.needs_stl_conversion and converted_input is None:
slicer_input = Path(tempfile.gettempdir()) / f"{input_path.stem}.converted.stl"
output = Path(args.output).expanduser().resolve()
command = build_backend_command(
backend=backend,
executable=executable,
slicer_input=slicer_input,
output=output,
profile=profile,
)
return {
"dry_run": not args.execute,
"backend": backend,
"executable": executable,
"command": command,
"input": asdict(inspection),
"output": str(output),
"profile": asdict(profile),
"conversion": {
"required": inspection.needs_stl_conversion,
"slicer_input": str(slicer_input),
"note": "Requires trimesh at execution time." if inspection.needs_stl_conversion else "",
},
}
def convert_mesh_to_stl(input_path: Path, output_path: Path) -> None:
try:
import trimesh # type: ignore
except ImportError as exc:
raise GCodeToolError(
"trimesh is required to convert .ply/.glb/.gltf inputs to temporary STL. "
"Install it in the active Python environment or provide .stl/.obj/.3mf directly."
) from exc
loaded = trimesh.load(str(input_path))
if getattr(loaded, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty: {input_path}")
if hasattr(loaded, "dump"):
mesh = loaded.dump(concatenate=True)
else:
mesh = loaded
if getattr(mesh, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty after scene conversion: {input_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
mesh.export(str(output_path), file_type="stl")
def execute_slice(args: argparse.Namespace, *, search_path: str | None = None) -> tuple[int, dict[str, Any]]:
input_inspection = inspect_input(Path(args.input))
output = Path(args.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="gcode-slice-") as tmp:
converted_input = None
if input_inspection.needs_stl_conversion:
converted_input = Path(tmp) / f"{Path(input_inspection.path).stem}.stl"
convert_mesh_to_stl(Path(input_inspection.path), converted_input)
plan = build_slice_plan(args, search_path=search_path, converted_input=converted_input)
slicer_input = Path(plan["conversion"]["slicer_input"])
existing_gcodes = {path.resolve() for path in output.parent.glob("*.gcode")}
result = subprocess.run(plan["command"], check=False, capture_output=True, text=True)
plan["returncode"] = result.returncode
if result.stdout:
plan["stdout_tail"] = tail_text(result.stdout)
if result.stderr:
plan["stderr_tail"] = tail_text(result.stderr)
if result.returncode != 0:
return result.returncode, plan
if not output.is_file():
candidate = generated_gcode_candidate(output, slicer_input, existing_gcodes)
if candidate is None:
plan["error"] = "Slicer exited successfully but did not create the requested output file."
return 1, plan
candidate.replace(output)
plan["renamed_output_from"] = str(candidate)
plan["generated"] = {"path": str(output), "size_bytes": output.stat().st_size}
return 0, plan
def strip_gcode_comment(line: str) -> str:
return line.split(";", 1)[0].strip()
def parse_command(line: str) -> str:
stripped = strip_gcode_comment(line)
if not stripped:
return ""
first = stripped.split(None, 1)[0].upper()
if re.fullmatch(r"[GMT]\d+(?:\.\d+)?", first):
if "." in first:
first = first.split(".", 1)[0]
return first
return first
def parse_numeric_tokens(line: str) -> dict[str, float]:
stripped = strip_gcode_comment(line).upper()
tokens: dict[str, float] = {}
for key, value in re.findall(r"([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))", stripped):
try:
tokens[key] = float(value)
except ValueError:
continue
return tokens
def validate_gcode_file(path: Path, profile: GCodeProfile) -> dict[str, Any]:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"G-code file does not exist: {resolved}")
text = resolved.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errors: list[str] = []
warnings: list[str] = []
unknown_commands: dict[str, int] = {}
stats = {
"lines": len(lines),
"non_comment_lines": 0,
"movement_commands": 0,
"extrusion_moves": 0,
"temperature_commands": 0,
}
if not text.strip():
errors.append("G-code file is empty.")
absolute_positioning = True
x_min, x_max = profile.motion_bounds_mm["x"]
y_min, y_max = profile.motion_bounds_mm["y"]
z_min, z_max = profile.motion_bounds_mm["z"]
for line_number, line in enumerate(lines, start=1):
command = parse_command(line)
if not command:
continue
stats["non_comment_lines"] += 1
tokens = parse_numeric_tokens(line)
if command not in SUPPORTED_GCODE_COMMANDS and not re.fullmatch(r"T\d+", command):
unknown_commands.setdefault(command, line_number)
if command == "G90":
absolute_positioning = True
elif command == "G91":
absolute_positioning = False
if "Relative positioning found; XYZ bounds validation is skipped while relative mode is active." not in warnings:
warnings.append("Relative positioning found; XYZ bounds validation is skipped while relative mode is active.")
if command in {"G0", "G1", "G2", "G3"}:
stats["movement_commands"] += 1
if command == "G1" and "E" in tokens:
stats["extrusion_moves"] += 1
if absolute_positioning:
if "X" in tokens and not x_min <= tokens["X"] <= x_max:
errors.append(f"Line {line_number}: X={tokens['X']} is outside X motion range {x_min}..{x_max} mm.")
if "Y" in tokens and not y_min <= tokens["Y"] <= y_max:
errors.append(f"Line {line_number}: Y={tokens['Y']} is outside Y motion range {y_min}..{y_max} mm.")
if "Z" in tokens and not z_min <= tokens["Z"] <= z_max:
errors.append(f"Line {line_number}: Z={tokens['Z']} is outside Z motion range {z_min}..{z_max} mm.")
if command in {"M104", "M109", "M140", "M190"}:
stats["temperature_commands"] += 1
if stats["movement_commands"] == 0:
errors.append("No G0/G1/G2/G3 movement commands found.")
if stats["extrusion_moves"] == 0:
errors.append("No extrusion moves found.")
if stats["temperature_commands"] == 0:
errors.append("No nozzle or bed temperature commands found.")
if unknown_commands:
sample = ", ".join(f"{cmd} line {line}" for cmd, line in sorted(unknown_commands.items())[:12])
warnings.append(f"Unknown or unsupported commands were left unchanged: {sample}.")
return {
"ok": not errors,
"path": str(resolved),
"profile": asdict(profile),
"errors": errors,
"warnings": warnings,
"stats": stats,
}
def print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
def discover_main(args: argparse.Namespace) -> int:
print_json(discovery_report(search_path=args.search_path))
return 0
def inspect_main(args: argparse.Namespace) -> int:
inspection = inspect_input(Path(args.input))
payload = asdict(inspection)
if args.json:
print_json(payload)
else:
print(f"{payload['status']}: {payload['path']}")
return 0
def slice_main(args: argparse.Namespace) -> int:
if args.execute and args.dry_run:
raise GCodeToolError("--execute and --dry-run cannot be used together.")
if not args.execute:
plan = build_slice_plan(args, search_path=args.search_path)
plan["dry_run"] = True
print_json(plan)
return 0
code, result = execute_slice(args, search_path=args.search_path)
result["dry_run"] = False
print_json(result)
return code
def validate_main(args: argparse.Namespace) -> int:
profile = load_profile(Path(args.profile).expanduser())
result = validate_gcode_file(Path(args.gcode), profile)
if args.json:
print_json(result)
else:
status = "ok" if result["ok"] else "failed"
print(f"Validation {status}: {result['path']}")
for error in result["errors"]:
print(f"error: {error}")
for warning in result["warnings"]:
print(f"warning: {warning}")
return 0 if result["ok"] else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
discover = subparsers.add_parser("discover", help="Report installed slicer backends.")
discover.add_argument("--search-path", help=argparse.SUPPRESS)
discover.set_defaults(func=discover_main)
inspect = subparsers.add_parser("inspect", help="Classify a mesh input before slicing.")
inspect.add_argument("--input", required=True)
inspect.add_argument("--json", action="store_true")
inspect.set_defaults(func=inspect_main)
slice_parser = subparsers.add_parser("slice", help="Build or run a slicer CLI command.")
slice_parser.add_argument("--input", required=True)
slice_parser.add_argument("--output", required=True)
slice_parser.add_argument("--profile", required=True)
slice_parser.add_argument("--backend", default="auto", choices=["auto", *PREFERRED_BACKEND_ORDER])
slice_parser.add_argument("--dry-run", action="store_true", help="Print the planned slicer command without running it.")
slice_parser.add_argument("--execute", action="store_true", help="Run the slicer command and write local .gcode.")
slice_parser.add_argument("--search-path", help=argparse.SUPPRESS)
slice_parser.set_defaults(func=slice_main)
validate = subparsers.add_parser("validate", help="Statically validate generated G-code.")
validate.add_argument("--gcode", required=True)
validate.add_argument("--profile", required=True)
validate.add_argument("--json", action="store_true")
validate.set_defaults(func=validate_main)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except GCodeToolError as exc:
print_json({"ok": False, "error": str(exc)})
return 2
if __name__ == "__main__":
raise SystemExit(main())
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake and Gradient Control Laboratories
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-169
View File
@@ -1,169 +0,0 @@
---
name: implicit-cad
description: Create, edit, render, and snapshot browser-native implicit CAD `.implicit.js` and `.implicit.mjs` files using GLSL signed-distance fields, shader primitives, smooth booleans, TPMS fields, and direct CAD Viewer raymarch rendering. Experimental.
---
# Implicit CAD
Use this skill for implicit CAD models that should run directly in CAD Viewer as browser JS modules. The primary artifact is a `.implicit.js` or `.implicit.mjs`.
This skill is experimental. ALWAYS prefer conventional STEP-first CAD workflows unless the user explicitly asks for an implicit model.
## File Format
An implicit CAD file is an ES module exporting an `implicit.js/0.1.0` object. The schema source of truth lives in the bundled package at `scripts/packages/implicitjs/src/lib/implicitCad/schema.js`; `scripts/lib/implicit-cad.mjs` re-exports it as `SCHEMA` for helper-authored modules.
```js
export default {
schema: "implicit.js/0.1.0",
name: "rounded capsule block",
glsl: `
float sdf(vec3 p) {
float sphere = implicit_sphere(p, vec3(0.0), 22.0);
float block = implicit_box_centered(p, vec3(34.0, 18.0, 18.0), vec3(0.0));
return implicit_union_round(sphere, block, 3.0);
}
vec3 color(vec3 p, vec3 normal) {
return mix(vec3(0.20, 0.55, 0.95), vec3(0.95, 0.45, 0.20), smoothstep(-15.0, 20.0, p.z));
}
`,
};
```
Models may also declare params and animations. Parameter definitions use the implicitjs control schema: `number`, `boolean`, `enum`/`select`, `color`, `string`, and `button`. Number, boolean, color, and button params automatically become GLSL uniforms with the same name; do not add a separate `uniforms` object. `bounds` is optional and is estimated from the SDF when omitted; add explicit bounds only when the auto estimate is too broad, too slow, or misses an unusual field. `bounds` and `render` may be JavaScript functions that receive `{ ...params, params, animation, animationState, elapsedSec, progress, t }`.
Built-in GLSL helpers use the `implicit_*` namespace, for example `implicit_sphere`, `implicit_box_centered`, and `implicit_union_round`.
```js
export default {
schema: "implicit.js/0.1.0",
name: "breathing orb",
params: {
radius: {
type: "number",
label: "Radius",
min: 12,
max: 34,
default: 22,
unit: "mm",
},
},
animations: {
breathe: {
label: "Breathe",
duration: 3,
update({ progress, set }) {
set("radius", 18 + Math.sin(progress * Math.PI) * 10);
},
},
},
render: { steps: 224, epsilon: 0.004 },
glsl: `
float sdf(vec3 p) {
return length(p) - radius;
}
vec3 color(vec3 p, vec3 normal) {
return mix(vec3(0.10, 0.58, 0.95), vec3(1.0, 0.34, 0.12), smoothstep(-18.0, 18.0, p.z));
}
`,
};
```
Do not copy bundled helper files out of this skill. If helper functions are useful, use `scripts/lib/implicit-cad.mjs` during authoring or emit standalone GLSL into the final `.implicit.js`/`.implicit.mjs` module.
## Authoring Workflow
1. Write a natural-language modeling brief with dimensions, coordinate assumptions, procedural color intent, and visual checks.
2. Create or edit the user-specified `.implicit.js`/`.implicit.mjs` module.
3. Use `scripts/lib/implicit-cad.mjs` helpers for primitives and field composition when useful:
- primitives: `sphere`, `circle`, `boxCentered`, `plane`, `lineSegment`, `torus`, `axis`, `cylinder`, `cylinderCapped`, `capsule`, `cone`, `coneCapped`, `coneCapsule`
- booleans/blends: `unionSharp`, `intersectSharp`, `unionRound`, `intersectRound`, `unionChamfer`, `intersectChamfer`, `unionExp`, `intersectExp`, `unionLpNorm`, `intersectLpNorm`, `unionRvachev`, `intersectRvachev`, `difference`
- modifiers/lattices: `shell`, `rotateAxis`, `repeatCentered`, `remapCylindrical`, `cubicGrid`, `squareHoneycomb`, `squareHoneycombReinforced`, `squareDiagonalHoneycomb`, `octetHoneycomb`, `hexagonalHoneycomb`, `triangularHoneycomb`
- TPMS fields: `tpmsGyroid`, `tpmsSchwarz`, `tpmsDiamond`, `tpmsLidinoid`, `tpmsNeovius`, `tpmsSplitP`, `tpmsIwp`
- shader wrappers: `distanceFunction` emits `float sdf(vec3 p)`, `colorFunction` emits `vec3 color(vec3 p, vec3 normal)`
4. Add optional `params` and `animations` for dimensions, toggles, palettes, mode switches, and animated exploration. Use param names directly in GLSL; the runtime declares matching uniforms.
5. Add optional procedural color with `vec3 color(vec3 p, vec3 normal)` when the model benefits from local material variation. Keep color values in 0..1 RGB.
6. Rely on automatic SDF bounds first. Add explicit bounds when an animated, periodic, translated, or very thin model needs tighter or more reliable framing/export sampling.
7. Run the lightweight visual verification flow below after visible geometry, color, params, animation, bounds, render, or export-affecting changes.
8. Run `node scripts/export.mjs --input <model.implicit.js> --format glb` when a mesh artifact is needed for downstream viewers, slicers, or file handoff.
## Visual Verification
Use this skill's snapshot tool as a fast visual check, not as a substitute for deterministic import/export validation. Keep the packet small and purposeful.
For simple static edits, one image is enough:
```bash
node scripts/snapshot.mjs --input models/implicit-cad/<model>.implicit.js --output /tmp/implicit-review/<model>.png
```
For topology, periodicity, thin features, Boolean blends, object identity, color, or suspected framing issues, render a small packet in one CLI call so the browser, module, and runtime model are reused:
```bash
node scripts/snapshot.mjs --job - <<'JSON'
{
"input": "models/implicit-cad/<model>.implicit.js",
"mode": "view",
"render": { "sizeProfile": "simple", "frameMargin": 1.55 },
"graphics": { "modelColors": true, "detail": 1.2, "shadows": true, "ambientOcclusion": true },
"outputs": [
{ "path": "/tmp/implicit-review/<model>-iso.png", "camera": "iso" },
{ "path": "/tmp/implicit-review/<model>-front.png", "camera": "front" },
{ "path": "/tmp/implicit-review/<model>-top.png", "camera": "top" },
{ "path": "/tmp/implicit-review/<model>-right.png", "camera": "right" }
]
}
JSON
```
Add `implicitParameters` at the job level for one parameter state, or on individual outputs when the point of the review is comparing parameter variants. Use `render.frameMargin` around `1.5` when a model is close to the edge; if a snapshot still appears clipped, first check whether the source `bounds` is cutting the raymarch itself.
For animations, create a short GIF only when motion is part of the request:
```bash
node scripts/snapshot.mjs --job - <<'JSON'
{
"input": "models/implicit-cad/<model>.implicit.js",
"mode": "animate",
"outputs": [{ "path": "/tmp/implicit-review/<model>-animation.gif" }],
"implicitAnimation": { "activeId": "<animation-id>", "durationSeconds": 3, "fps": 12 }
}
JSON
```
Review the resulting PNG/GIF for centered framing, no top/bottom/side clipping, expected silhouette and topology, visible parameter differences, GLSL-defined colors, no unexpected holes/gaps, and smooth enough edges for the requested graphics settings. If the snapshot reveals a mismatch, fix the implicit source or bounds and rerun only the relevant packet.
## Handoff
After completing implicit CAD work that creates or modifies `.implicit.js`, `.implicit.mjs`, `.glb`, `.stl`, or `.3mf` artifacts, you must ALWAYS hand the explicit file path(s) to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); include those live viewer link(s) in the final response. If `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
When verification snapshots are generated, also include the saved PNG/GIF snapshot(s) in the final response. If no snapshot applies, or if snapshot generation fails, say why and report the deterministic validation that still ran.
## Snapshot Tool
From this skill directory:
```bash
node scripts/snapshot.mjs --input <model.implicit.js> --output <snapshot.png>
node scripts/snapshot.mjs --input <model.implicit.js> --output <orbit.gif> --mode orbit
node scripts/snapshot.mjs --job <render-job.json>
node scripts/snapshot.mjs --job - --json
node scripts/snapshot.mjs --help
```
Use `node scripts/snapshot.mjs --help` for the complete current command interface. The tool appends a UTC timestamp before the output extension. JSON jobs may be a single job, one job with multiple `outputs`, a raw array of jobs, or `{ "jobs": [...] }`; prefer a multi-output job for review packets because it avoids rebuilding the same artifact for each camera.
## Export Tool
From this skill directory:
```bash
node scripts/export.mjs --input <model.implicit.js> --format glb
node scripts/export.mjs --input <model.implicit.js> --output <mesh.stl> --resolution <resolution>
node scripts/export.mjs --input <model.implicit.js> --format 3mf --params '<parameter-json>' --json
node scripts/export.mjs --help
```
Supported export formats are `glb`, `stl`, and `3mf`. The exporter samples the implicit SDF inside the declared bounds and extracts a triangle mesh. If `--output` is omitted, the mesh is written next to the source file using the same stem, such as `<model>.glb` for `<model>.implicit.js`. Use `node scripts/export.mjs --help` for the complete current command interface.
@@ -1,4 +0,0 @@
interface:
display_name: "Implicit CAD"
short_description: "Write shader-native implicit CAD files."
default_prompt: "Use $implicit-cad to create browser-rendered implicit CAD `.implicit.js` files with GLSL SDF primitives and snapshots."
@@ -1,22 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const packageCli = path.join(scriptsDir, "packages", "implicitjs", "scripts", "export.mjs");
if (!fs.existsSync(packageCli)) {
process.stderr.write(
`Missing implicitjs export CLI at ${packageCli}. Restore the development symlink layout or rebuild the production skill bundle.\n`
);
process.exit(1);
}
const completed = spawnSync(process.execPath, [packageCli, ...process.argv.slice(2)], {
cwd: process.cwd(),
stdio: "inherit",
});
process.exitCode = completed.status ?? 1;
@@ -1,394 +0,0 @@
import { IMPLICIT_CAD_SCHEMA } from "../packages/implicitjs/src/lib/implicitCad/schema.js";
export const SCHEMA = IMPLICIT_CAD_SCHEMA;
export const PI = Math.PI;
export const TWO_PI = Math.PI * 2;
export const SQRT2 = Math.sqrt(2);
export const SQRT3 = Math.sqrt(3);
function numberLiteral(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`Expected finite number, got ${value}`);
}
if (Object.is(numeric, -0)) {
return "0.0";
}
return Number.isInteger(numeric) ? `${numeric}.0` : String(numeric);
}
function componentList(value, dimension) {
if (typeof value === "number") {
return Array.from({ length: dimension }, () => numberLiteral(value));
}
if (!Array.isArray(value) && !ArrayBuffer.isView(value)) {
throw new Error(`Expected vec${dimension} array or scalar`);
}
return Array.from({ length: dimension }, (_, index) => numberLiteral(value[index] ?? 0));
}
export function vec2(value, y = null) {
const components = y === null ? componentList(value, 2) : [numberLiteral(value), numberLiteral(y)];
return `vec2(${components.join(", ")})`;
}
export function vec3(value, y = null, z = null) {
const components = y === null && z === null
? componentList(value, 3)
: [numberLiteral(value), numberLiteral(y), numberLiteral(z)];
return `vec3(${components.join(", ")})`;
}
export function expr(value) {
if (value && typeof value === "object" && typeof value.expr === "string") {
return value.expr;
}
if (typeof value === "number") {
return numberLiteral(value);
}
return String(value);
}
export function glsl(strings, ...values) {
return strings.reduce((result, part, index) => (
`${result}${part}${index < values.length ? expr(values[index]) : ""}`
), "");
}
function reduceCall(values, callName, radius = null) {
const parts = values.map(expr);
if (parts.length === 0) {
throw new Error(`${callName} requires at least one field`);
}
if (parts.length === 1) {
return parts[0];
}
return parts.slice(1).reduce((acc, value) => (
radius === null
? `${callName}(${acc}, ${value})`
: `${callName}(${acc}, ${value}, ${numberLiteral(radius)})`
), parts[0]);
}
function reduceCallWithArgs(values, callName, args = []) {
const parts = values.map(expr);
if (parts.length === 0) {
throw new Error(`${callName} requires at least one field`);
}
if (parts.length === 1) {
return parts[0];
}
const suffix = args.length > 0 ? `, ${args.map(expr).join(", ")}` : "";
return parts.slice(1).reduce((acc, value) => `${callName}(${acc}, ${value}${suffix})`, parts[0]);
}
export function linearMap(value, inMin, inMax, outMin, outMax) {
return `implicit_linear_map(${expr(value)}, ${numberLiteral(inMin)}, ${numberLiteral(inMax)}, ${numberLiteral(outMin)}, ${numberLiteral(outMax)})`;
}
export function ramp(value, inMin, inMax, outMin, outMax) {
return `implicit_ramp(${expr(value)}, ${numberLiteral(inMin)}, ${numberLiteral(inMax)}, ${numberLiteral(outMin)}, ${numberLiteral(outMax)})`;
}
export function twoBodyField(a, b) {
return `implicit_two_body_field(${expr(a)}, ${expr(b)})`;
}
export function twoBodyPolar(a, b, angle) {
return `implicit_two_body_polar(${expr(a)}, ${expr(b)}, ${numberLiteral(angle)})`;
}
export function triangleWaveEven(value, period) {
return `implicit_triangle_wave_even(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveEvenPositive(value, period) {
return `implicit_triangle_wave_even_positive(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveOdd(value, period) {
return `implicit_triangle_wave_odd(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveOddPositive(value, period) {
return `implicit_triangle_wave_odd_positive(${expr(value)}, ${expr(period)})`;
}
export function unionSharp(values) {
return reduceCall(values, "implicit_union_sharp");
}
export function intersectSharp(values) {
return reduceCall(values, "implicit_intersect_sharp");
}
export function unionRound(values, radius = 0) {
return reduceCall(values, "implicit_union_round", radius);
}
export function intersectRound(values, radius = 0) {
return reduceCall(values, "implicit_intersect_round", radius);
}
export function unionChamfer(values, radius = 0) {
return reduceCall(values, "implicit_union_chamfer", radius);
}
export function intersectChamfer(values, radius = 0) {
return reduceCall(values, "implicit_intersect_chamfer", radius);
}
export function unionExp(values, radius = 0) {
return reduceCall(values, "implicit_union_exp", radius);
}
export function intersectExp(values, radius = 0) {
return reduceCall(values, "implicit_intersect_exp", radius);
}
export function unionLpNorm(values, radius = 0, normPower = 2) {
return reduceCallWithArgs(values, "implicit_union_lp_norm", [numberLiteral(radius), numberLiteral(normPower)]);
}
export function intersectLpNorm(values, radius = 0, normPower = 2) {
return reduceCallWithArgs(values, "implicit_intersect_lp_norm", [numberLiteral(radius), numberLiteral(normPower)]);
}
export function unionRvachev(values, radius = 0) {
return reduceCall(values, "implicit_union_rvachev", radius);
}
export function intersectRvachev(values, radius = 0) {
return reduceCall(values, "implicit_intersect_rvachev", radius);
}
export function booleanSharp() {
return { union: unionSharp, intersect: intersectSharp };
}
export function booleanRound(radius = 0) {
return {
radius,
union: (values) => unionRound(values, radius),
intersect: (values) => intersectRound(values, radius),
};
}
export function booleanExp(radius = 0) {
return {
radius,
union: (values) => unionExp(values, radius),
intersect: (values) => intersectExp(values, radius),
};
}
export function booleanLp(normPower, radius = 0) {
return {
radius,
normPower,
union: (values) => unionLpNorm(values, radius, normPower),
intersect: (values) => intersectLpNorm(values, radius, normPower),
};
}
export function booleanRvachev(radius = 0) {
return {
radius,
union: (values) => unionRvachev(values, radius),
intersect: (values) => intersectRvachev(values, radius),
};
}
export function booleanChamfer(radius = 0) {
return {
radius,
union: (values) => unionChamfer(values, radius),
intersect: (values) => intersectChamfer(values, radius),
};
}
export function difference(target, tools, {
union = unionSharp,
intersect = intersectSharp
} = {}) {
const toolList = Array.isArray(tools) ? tools : [tools];
const toolUnion = union(toolList);
return intersect([target, `-(${toolUnion})`]);
}
export function sphere(p, center, radius) {
return `implicit_sphere(${expr(p)}, ${vec3(center)}, ${numberLiteral(radius)})`;
}
export function circle(p, center, radius) {
return sphere(p, center, radius);
}
export function boxCentered(p, size, { center = [0, 0, 0] } = {}) {
return `implicit_box_centered(${expr(p)}, ${vec3(size)}, ${vec3(center)})`;
}
export function plane(p, origin, normal) {
return `implicit_plane(${expr(p)}, ${vec3(origin)}, ${vec3(normal)})`;
}
export function lineSegment(p, endpointA, endpointB) {
return `implicit_line_segment(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)})`;
}
export function torus(p, majorRadius, minorRadius) {
return `implicit_torus(${expr(p)}, ${numberLiteral(majorRadius)}, ${numberLiteral(minorRadius)})`;
}
export function axis(p, origin, direction) {
return `implicit_axis(${expr(p)}, ${vec3(origin)}, ${vec3(direction)})`;
}
export function cylinder(p, origin, direction, radius) {
return `implicit_cylinder(${expr(p)}, ${vec3(origin)}, ${vec3(direction)}, ${numberLiteral(radius)})`;
}
export function cylinderCapped(p, endpointA, endpointB, radius) {
return `implicit_cylinder_capped(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radius)})`;
}
export function capsule(p, endpointA, endpointB, radius) {
return `implicit_capsule(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radius)})`;
}
export function coneCapsule(p, endpointA, endpointB, radiusA, radiusB) {
return `implicit_cone_capsule(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radiusA)}, ${numberLiteral(radiusB)})`;
}
export function cone(p, apex, direction, halfAngle) {
return `implicit_cone(${expr(p)}, ${vec3(apex)}, ${vec3(direction)}, ${numberLiteral(halfAngle)})`;
}
export function coneCapped(p, endpointA, endpointB, radiusA, radiusB) {
return `implicit_cone_capped(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radiusA)}, ${numberLiteral(radiusB)})`;
}
export function shell(distance, thickness, bias = 0) {
return `implicit_shell(${expr(distance)}, ${numberLiteral(thickness)}, ${numberLiteral(bias)})`;
}
export function rotateAxis(p, origin, direction, angle) {
return `implicit_rotate_axis(${expr(p)}, ${vec3(origin)}, ${vec3(direction)}, ${numberLiteral(angle)})`;
}
export function repeatCentered(p, period) {
return `implicit_repeat_centered(${expr(p)}, ${vec3(period)})`;
}
export function remapCylindrical(p, circumference) {
return `implicit_remap_cylindrical(${expr(p)}, ${numberLiteral(circumference)})`;
}
export function cubicGrid(p, size) {
return `implicit_cubic_grid(${expr(p)}, ${vec3(size)})`;
}
export function squareHoneycomb(p, size) {
return `implicit_square_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function squareHoneycombReinforced(p, size, { rotation = 0.25, rotation2 = null } = {}) {
return `implicit_square_honeycomb_reinforced(${expr(p)}, ${vec2(size)}, ${numberLiteral(rotation)}, ${numberLiteral(rotation2 ?? 0)}, ${numberLiteral(rotation2 === null ? 0 : 1)})`;
}
export function squareDiagonalHoneycomb(p, size) {
return `implicit_square_diagonal_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function octetHoneycomb(p, size) {
return `implicit_octet_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function hexagonalHoneycomb(p, size, { setback = 1 / 3 } = {}) {
const resolvedSize = typeof size === "number" ? [size, size * SQRT3] : size;
return `implicit_hexagonal_honeycomb(${expr(p)}, ${vec2(resolvedSize)}, ${numberLiteral(setback)})`;
}
export function triangularHoneycomb(p, size) {
const resolvedSize = typeof size === "number" ? [size, size * SQRT3] : size;
return `implicit_triangular_honeycomb(${expr(p)}, ${vec2(resolvedSize)})`;
}
export function tpmsGyroid(p, period, drop = [1, 1, 1]) {
return `implicit_tpms_gyroid(${expr(p)}, ${vec3(period)}, ${vec3(drop)})`;
}
export function tpmsSchwarz(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_schwarz(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsDiamond(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_diamond(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsLidinoid(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_lidinoid(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsNeovius(p, period, { drop = [1, 1, 1], schwarzBlend = 0 } = {}) {
return `implicit_tpms_neovius(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(schwarzBlend)})`;
}
export function tpmsSplitP(p, period, { lidinoidBlend = 1, gyroidOctave = 1, schwarzOctave = 1 } = {}) {
return `implicit_tpms_split_p(${expr(p)}, ${vec3(period)}, ${numberLiteral(lidinoidBlend)}, ${numberLiteral(gyroidOctave)}, ${numberLiteral(schwarzOctave)})`;
}
export function tpmsIwp(p, period, drop = [1, 1, 1]) {
return `implicit_tpms_iwp(${expr(p)}, ${vec3(period)}, ${vec3(drop)})`;
}
export function distanceFunction(distanceExpression, { point = "p" } = {}) {
return `float sdf(vec3 ${point}) {\n return ${expr(distanceExpression)};\n}`;
}
export function colorFunction(colorExpression, { point = "p", normal = "normal" } = {}) {
return `vec3 color(vec3 ${point}, vec3 ${normal}) {\n return clamp(${expr(colorExpression)}, vec3(0.0), vec3(1.0));\n}`;
}
function distanceSource(value) {
return typeof value === "function" || /\bsdf\s*\(/.test(expr(value))
? value
: distanceFunction(value);
}
function colorSource(value) {
return typeof value === "function" || /\bcolor\s*\(/.test(expr(value))
? value
: colorFunction(value);
}
export function createImplicitModel({
name = "Implicit CAD",
description = "",
units = "mm",
bounds,
params = {},
values = {},
animations = {},
render = {},
glsl = "",
distance,
color = "",
} = {}) {
const glslSource = glsl || [
distance !== undefined ? distanceSource(distance) : "",
color ? colorSource(color) : "",
].filter(Boolean).join("\n\n");
return {
schema: SCHEMA,
name,
description,
units,
...(bounds !== undefined ? { bounds } : {}),
...(params && Object.keys(params).length ? { params } : {}),
...(values && Object.keys(values).length ? { values } : {}),
...(animations && Object.keys(animations).length ? { animations } : {}),
...(render && Object.keys(render).length ? { render } : {}),
glsl: glslSource,
};
}
@@ -1 +0,0 @@
../../../../packages/implicitjs
@@ -1,20 +0,0 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const packageCli = path.join(scriptsDir, "packages", "implicitjs", "scripts", "snapshot.mjs");
const { runRenderCli } = await import(pathToFileURL(packageCli).href).catch((error) => {
process.stderr.write(
`Missing implicitjs snapshot CLI at ${packageCli}. Restore the development symlink layout or rebuild the production skill bundle.\n${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
});
process.exitCode = await runRenderCli(process.argv.slice(2), {
cwd: process.cwd(),
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-104
View File
@@ -1,104 +0,0 @@
---
name: sdf
description: SDFormat/SDF model and world generation, validation, and simulator handoff. Use for `.sdf` files, SDFormat XML, Python `gen_sdf()` sources, models, worlds, links, joints, poses, frames, inertials, visual/collision geometry, mesh URIs, sensors, lights, physics, plugins, includes, Gazebo, static SDF review, or simulator-specific metadata. Do not use for signed-distance-field geometry.
---
# SDF
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill when the deliverable is an SDFormat document or a Python `gen_sdf()` source. SDFormat describes simulator and world behavior: models, worlds, frames, poses, links, joints, inertials, visuals, collisions, sensors, lights, physics, plugins, includes, and simulator metadata.
This skill is for **SDFormat**, not signed-distance-field geometry.
## Core rules
1. Treat the Python file defining `gen_sdf()` as source of truth. Treat configured `.sdf` files as generated artifacts unless the user explicitly asks for direct XML editing.
2. Identify the target consumer before editing: Gazebo/libsdformat version, another simulator, visualization-only tooling, model package, or world handoff.
3. Decide document kind: model-level SDF, world-level SDF, or model-in-world. Prefer model-level SDF for reusable robot/object exports.
4. Use SI units unless the target explicitly requires otherwise: meters, kilograms, seconds, radians.
5. Prefer `version="1.12"` for new outputs unless the target consumer constrains the version.
6. Establish the design ledger before writing poses, frames, joint axes, mesh scales, inertials, sensors, or plugins. Use `references/design-ledger.md` and `references/llm-guardrails.md`.
7. Do not infer spatial transforms from visual impression alone. Derive poses, axes, scale, mass, inertia, and frame names from upstream source data, drawings, simulator documentation, measured values, or explicit assumptions.
8. Prefer helper functions and named constants over large XML string literals. Hidden numbers are a common SDF failure mode.
9. Generate only explicit targets with `scripts/sdf` or the repository's existing SDF launcher. Do not run directory-wide generation.
10. Regenerate upstream geometry, mesh, robot-description, render, topology, or package assets with their owning workflows before regenerating SDF that references them.
11. After generation, run available checks: bundled validation, optional `gz sdf --check`, simulator load, joint motion, and plugin/sensor startup.
12. Report assumptions, skipped checks, unresolved resource paths, and target-specific compatibility risks.
## Scope
Use this skill for SDFormat outputs and generators. Do not use it for signed-distance-field modeling, raw geometry generation, planning semantics, or to paper over incorrect upstream robot/source data unless the task is explicitly simulator-only.
## CAD Viewer Handoff
After completing SDF work that creates or modifies a `.sdf`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Workflow
1. Locate the `gen_sdf()` source and intended `.sdf` output.
2. Read or create the design ledger.
3. Read `references/frame-semantics.md` before editing any `<pose>`, `<frame>`, joint axis, `relative_to`, `expressed_in`, nested scope, sensor frame, or plugin frame.
4. Edit the generator source, not generated XML.
5. Use optional builder helpers when they make the generated structure clearer; raw ElementTree is still allowed.
6. Regenerate the explicit target.
7. Treat bundled validation as a guardrail, not simulator proof.
8. Run target-consumer smoke tests when available.
9. Report checks run, checks skipped, and assumptions. Static rendering does not execute SDF plugins or read file-authored motion metadata.
## Commands
Run with the project or workspace Python environment. Treat `python` in examples as an interpreter placeholder; if bare `python` is unavailable, substitute `python3`, a project virtualenv interpreter, or the configured interpreter path.
```bash
python scripts/sdf path/to/source.py
python scripts/sdf path/to/source.py -o path/to/output.sdf
python scripts/sdf path/to/a.py=out/a.sdf path/to/b.py=out/b.sdf
```
Plain Python targets write sibling `.sdf` files beside their sources. `-o` / `--output` is valid only with one plain target. `SOURCE.py=OUTPUT.sdf` supports custom multi-target destinations.
If the runtime supports optional external checking:
```bash
python scripts/sdf path/to/source.py --gz-check auto
python scripts/sdf path/to/source.py --gz-check required
python scripts/sdf path/to/source.py --gz-check never
```
`gz sdf --check` is optional target-consumer validation. It should be reported as skipped when unavailable unless explicitly required.
## Required report shape
When finishing an SDF task, include a compact report:
```text
Generated: path/to/model.sdf from path/to/model.py
Checks run:
- bundled SDF validation: passed
- gz sdf --check: skipped, gz not installed
- simulator load: skipped, target simulator unavailable
- viewer handoff: `$cad-viewer` link returned
Assumptions:
- Assumed mesh units are meters.
- Assumed lidar frame is coincident with lidar_link.
Risks:
- Camera plugin filename was not verified in the target simulator environment.
```
## References
- Generation command: `references/gen-sdf.md`
- Generator contract: `references/generator-contract.md`
- SDF workflow: `references/sdf-workflow.md`
- Builder helpers: `references/builder-helpers.md`
- LLM guardrails: `references/llm-guardrails.md`
- Design ledger: `references/design-ledger.md`
- Frame semantics: `references/frame-semantics.md`
- Validation scope: `references/validation.md`
- Smoke tests: `references/smoke-tests.md`
- Interoperability notes: `references/interoperability.md`
- Examples: `references/examples.md`
- Runtime notes and current limitations: `references/implementation-notes.md`
@@ -1,4 +0,0 @@
interface:
display_name: "SDF"
short_description: "Generate and validate SDFormat robot models."
default_prompt: "Use $sdf to generate, update, and validate SDFormat/SDF robot or simulator models from Python sources, handing generated SDFs to $cad-viewer when available."
@@ -1,136 +0,0 @@
# SDF builder helpers
Builder helpers are optional. They exist to reduce common LLM mistakes in XML construction, not to replace SDFormat or libsdformat.
## Why helpers exist
LLMs often make these mistakes when writing raw XML:
- wrong pose value length;
- hidden degree/radian conversion;
- missing `relative_to` or `expressed_in`;
- zero or non-finite joint axes;
- negative primitive dimensions;
- mesh scale applied inconsistently;
- inertial values copied from visuals;
- plugin filenames invented from plausible names.
Helpers should make the common path explicit, typed-ish, and auditable while still returning ordinary `xml.etree.ElementTree.Element` nodes.
## Design constraints
- Standard library only.
- No mandatory Gazebo, ROS, NumPy, lxml, CAD, or mesh dependency.
- Return ElementTree elements.
- Compose with raw ElementTree calls.
- Validate local numeric shape and finiteness.
- Do not attempt full SDFormat schema validation.
- Do not silently invent frames, axes, inertials, plugins, or sensor parameters.
## Recommended helper surface
Names may vary to match the current runtime package, but the helper surface should stay small.
```python
# XML basics
text(parent, tag, value, attrib=None)
fmt_float(value)
fmt_vector(values)
# Poses and axes
pose(parent, xyz=(0, 0, 0), rpy=(0, 0, 0), *, relative_to=None,
rotation_format="euler_rpy", degrees=False)
quat_pose(parent, xyz=(0, 0, 0), quat_xyzw=(0, 0, 0, 1), *, relative_to=None)
axis(parent, xyz=(0, 0, 1), *, expressed_in=None)
# Document structure
sdf_root(version="1.12")
world(parent, name)
model(parent, name, *, static=None, pose=None)
frame(parent, name, *, attached_to=None, pose=None)
link(parent, name, *, pose=None, inertial=None)
joint(parent, name, joint_type, parent_link, child_link, *, pose=None,
axis_xyz=None, axis_expressed_in=None, axis2_xyz=None, limits=None)
# Geometry
visual(parent, name, *, pose=None)
collision(parent, name, *, pose=None)
box(parent, size_xyz)
sphere(parent, radius)
cylinder(parent, radius, length)
capsule(parent, radius, length)
mesh(parent, uri, *, scale=None)
# Physics and metadata
inertial(parent, mass, inertia, *, pose=None)
sensor(parent, name, sensor_type, *, pose=None, topic=None, update_rate=None)
plugin(parent, name, filename, params=None)
include(parent, uri, *, name=None, pose=None)
```
## Numeric behavior
Helpers should reject:
- non-finite numbers;
- vectors with the wrong length;
- negative or zero primitive dimensions;
- zero joint axes;
- zero-mass inertials;
- zero-norm quaternions.
Helpers may warn, but should not silently fix:
- non-unit axes;
- non-normalized quaternions;
- use of degrees;
- missing `relative_to` on nontrivial poses.
## Example
```python
from sdf.builder import axis, box, collision, joint, link, model, sdf_root, visual
BASE_SIZE_M = (0.4, 0.3, 0.1)
LIFT_AXIS_Z = (0.0, 0.0, 1.0)
def gen_sdf():
sdf = sdf_root("1.12")
robot = model(sdf, "lift_fixture", static=False)
base = link(robot, "base_link")
v = visual(base, "base_visual")
box(v, BASE_SIZE_M)
c = collision(base, "base_collision")
box(c, BASE_SIZE_M)
carriage = link(robot, "carriage_link")
j = joint(
robot,
"lift_joint",
"prismatic",
parent_link="base_link",
child_link="carriage_link",
axis_xyz=LIFT_AXIS_Z,
axis_expressed_in="base_link",
)
return {
"xml": sdf,
"assumptions": [
{"code": "inertials_placeholder", "message": "Inertials omitted pending measured masses."}
],
}
```
## When not to use helpers
Raw ElementTree is acceptable when:
- a target simulator needs unusual extension XML;
- a plugin has arbitrary nested configuration;
- an existing generator already has a clear internal abstraction;
- the user asked for a minimal XML-only source.
Even then, keep named constants, ledger comments, and validation.
@@ -1,115 +0,0 @@
# SDF design ledger
Create or update this ledger before writing SDF XML. The ledger can live in the response, a project note, or comments in the generator source. The goal is to externalize spatial and simulator assumptions before they become hard-to-audit XML.
## Document
| Field | Value |
|---|---|
| Output path | |
| Source file | |
| SDF version | `1.12` unless constrained |
| Document kind | model / world / model-in-world |
| Target consumer | Gazebo / other simulator / visualization-only / model package |
| Units | meters, kilograms, seconds, radians unless documented otherwise |
| Coordinate convention | REP-103-like / simulator-specific / documented exception |
| World support needed | yes/no; reason |
| Optional external checks | `gz sdf --check`, simulator load, CAD Viewer, other |
## Model or world scope
| Item | Value |
|---|---|
| Model/world name | |
| Static or dynamic | |
| Canonical link, if relevant | |
| Model/world pose | xyz + rpy/quaternion |
| Model/world pose `relative_to` | |
| Includes | URI + purpose |
| World physics/lights/plugins | source and target simulator |
## Frames
| Frame | Scope | Attached to | Pose | Pose `relative_to` | Purpose | Source |
|---|---|---|---|---|---|---|
| | | | | | | |
Use named frames for clarity when multiple sensors, nested models, tool frames, plugin frames, or repeated transforms depend on the same relationship.
## Links
| Link | Physical / frame-like | Pose | Pose `relative_to` | Inertial source | Sensor/plugin attached | Notes |
|---|---|---|---|---|---|---|
| | | | | | | |
Physical dynamic links need inertials. Frame-like links may omit inertials only when documented.
## Joints
| Joint | Type | Parent | Child | Pose | Pose frame / `relative_to` | Axis | Axis frame / `expressed_in` | Limits | Positive motion | Source |
|---|---|---|---|---|---|---|---|---|---|---|
| | | | | | | | | | | |
For revolute and prismatic joints, record limit units: radians for revolute, meters for prismatic. Continuous joints should not be given artificial finite position limits unless a simulator-specific reason is documented.
## Geometry
| Owner | Visual/collision | Name | Geometry type | Pose | Pose `relative_to` | URI or dimensions | Mesh units | Scale | Source |
|---|---|---|---|---|---|---|---|---|---|
| | | | | | | | | | |
Collision geometry should be selected for simulation cost and stability, not just visual similarity.
## Inertials
| Link | Mass | COM pose | Inertia tensor | Method/source | Confidence |
|---|---|---|---|---|---|
| | | | | | |
Mark approximations clearly. Very small, zero, negative, or guessed inertias are high risk for simulation.
## Sensors and plugins
| Element | Parent | Pose/frame | Filename/type | Parameters | Source docs | Assumptions |
|---|---|---|---|---|---|---|
| | | | | | | |
Do not invent plugin filenames, topics, frame names, namespaces, controller parameters, or update rates. Derive them from simulator documentation or user-provided configuration.
## Mesh URI policy
| URI kind | Allowed? | Resolution expectation | Notes |
|---|---|---|---|
| Relative local path | | Relative to generated `.sdf` location | |
| `file://` | | Absolute local file | |
| `model://` | | Simulator model path | |
| `package://` | | Simulator/ROS package environment | |
| `fuel://`, `http://`, `https://` | | External resource | |
## Assumptions to report
List every guessed or inferred value:
- transform or pose;
- axis sign or positive-motion convention;
- mesh unit or scale;
- mass, COM, or inertia;
- target simulator behavior;
- plugin parameter;
- unresolved external URI;
- skipped validation or smoke test.
If a value cannot be derived or safely assumed, generate a minimal placeholder only when the user asked for a placeholder, and label it as such.
## Compact response template
```text
SDF source: path/to/source.py
Generated target: path/to/output.sdf
Target consumer: Gazebo Harmonic, SDF 1.12
Bundled validation: passed with 2 warnings
External checks: gz sdf --check skipped, gz not installed
Assumptions:
- Assumed mesh units are meters.
- Assumed camera optical frame follows simulator plugin documentation.
```
@@ -1,148 +0,0 @@
# SDF examples
These examples illustrate the intended style: explicit frames, source-relative assumptions, simple helpers, and structured assumptions.
## Minimal model with raw ElementTree
```python
import xml.etree.ElementTree as ET
def text(parent, tag, value, attrib=None):
child = ET.SubElement(parent, tag, attrib or {})
child.text = str(value)
return child
def gen_sdf():
sdf = ET.Element("sdf", {"version": "1.12"})
model = ET.SubElement(sdf, "model", {"name": "calibration_box"})
text(model, "pose", "0 0 0 0 0 0", {"relative_to": "world"})
link = ET.SubElement(model, "link", {"name": "body"})
visual = ET.SubElement(link, "visual", {"name": "body_visual"})
geometry = ET.SubElement(visual, "geometry")
box = ET.SubElement(geometry, "box")
text(box, "size", "0.1 0.1 0.1")
collision = ET.SubElement(link, "collision", {"name": "body_collision"})
c_geometry = ET.SubElement(collision, "geometry")
c_box = ET.SubElement(c_geometry, "box")
text(c_box, "size", "0.1 0.1 0.1")
inertial = ET.SubElement(link, "inertial")
text(inertial, "mass", "1.0")
inertia = ET.SubElement(inertial, "inertia")
text(inertia, "ixx", "0.0016666667")
text(inertia, "iyy", "0.0016666667")
text(inertia, "izz", "0.0016666667")
text(inertia, "ixy", "0")
text(inertia, "ixz", "0")
text(inertia, "iyz", "0")
return {
"xml": sdf,
"assumptions": ["Box inertia uses a uniform-density primitive approximation."],
}
```
## Minimal world
```python
import xml.etree.ElementTree as ET
def text(parent, tag, value, attrib=None):
child = ET.SubElement(parent, tag, attrib or {})
child.text = str(value)
return child
def gen_sdf():
sdf = ET.Element("sdf", {"version": "1.12"})
world = ET.SubElement(sdf, "world", {"name": "empty_lit_world"})
light = ET.SubElement(world, "light", {"name": "sun", "type": "directional"})
text(light, "pose", "0 0 10 0 0 0", {"relative_to": "world"})
text(light, "cast_shadows", "true")
return {
"xml": sdf,
"metadata": {"document_kind": "world"},
"assumptions": ["World intentionally contains no inline model."],
}
```
## Model with builder helpers
```python
from sdf.builder import (
box,
collision,
inertial,
joint,
link,
model,
pose,
sdf_root,
visual,
)
BASE_MASS_KG = 2.0
ARM_MASS_KG = 0.5
def gen_sdf():
sdf = sdf_root("1.12")
robot = model(sdf, "two_link_demo")
pose(robot, relative_to="world")
base = link(robot, "base_link")
box(visual(base, "base_visual"), (0.4, 0.3, 0.1))
box(collision(base, "base_collision"), (0.4, 0.3, 0.1))
inertial(base, BASE_MASS_KG, (0.02, 0.03, 0.04, 0, 0, 0))
arm = link(robot, "arm_link")
box(visual(arm, "arm_visual"), (0.3, 0.05, 0.05))
box(collision(arm, "arm_collision"), (0.3, 0.05, 0.05))
inertial(arm, ARM_MASS_KG, (0.001, 0.004, 0.004, 0, 0, 0))
joint(
robot,
"shoulder_pan",
"revolute",
"base_link",
"arm_link",
axis_xyz=(0, 0, 1),
axis_expressed_in="base_link",
)
return {
"xml": sdf,
"assumptions": [
"Primitive inertials are approximate placeholders.",
"Positive shoulder_pan rotates counterclockwise when viewed from +Z."
],
}
```
## Plugin block from documentation
When a plugin block is copied from target simulator documentation, preserve its explicit parameters and report the source:
```python
import xml.etree.ElementTree as ET
def add_known_plugin(parent):
plugin = ET.SubElement(
parent,
"plugin",
{"name": "example_control", "filename": "libexample_control.so"},
)
ET.SubElement(plugin, "namespace").text = "robot1"
return plugin
```
Do not invent plugin fields. If the documentation source is not available, mark the plugin as unverified in the generator envelope.
@@ -1,87 +0,0 @@
# SDF frame and pose semantics
Use this reference before editing any SDF `<pose>`, `<frame>`, `<joint>`, `<axis>`, `<visual>`, `<collision>`, sensor, or plugin placement.
## Core pose rules
A typical SDF pose is:
```xml
<pose relative_to="some_frame">x y z roll pitch yaw</pose>
```
or, when using quaternion rotation:
```xml
<pose rotation_format="quat_xyzw" relative_to="some_frame">x y z qx qy qz qw</pose>
```
Rules to keep in mind:
- The first three values are position.
- With the default `rotation_format="euler_rpy"`, the pose has six values: `x y z roll pitch yaw`.
- With `rotation_format="quat_xyzw"`, the pose has seven values: `x y z qx qy qz qw`.
- Euler angles are radians by default. `degrees="true"` is valid SDF but should be avoided in generated sources unless the target explicitly requires it.
- `relative_to` names the frame in which the pose is expressed.
- If `relative_to` is omitted, SDF applies element-specific defaults, commonly the frame of the parent XML element. This may be valid but is easy to misread. Prefer explicit `relative_to` for nontrivial generated poses.
- Nested scopes may use `::`, for example `outer_model::inner_model::sensor_frame`.
## Joint pose and axes
For SDF joints:
- `<parent>` names the parent frame or `world`.
- `<child>` names the child frame; `world` is not valid as the child.
- Joint pose defaults are easy to misinterpret. Use explicit `<pose relative_to="...">` when the joint frame is not obviously the child-link frame.
- `<axis><xyz>...</xyz></axis>` is the unit axis vector.
- An axis is expressed in the joint frame unless the axis `expressed_in` attribute specifies another frame.
- `axis2` is used for multi-axis joints such as `revolute2` and `universal`.
- Axis vectors should be finite, nonzero, and normalized.
Record the expected positive motion in the design ledger. Example: “positive shoulder_pan rotates the arm counterclockwise when viewed from +Z.”
## Visual and collision poses
A `<visual>` or `<collision>` pose places that geometry owner relative to its parent frame unless `relative_to` says otherwise. In ordinary model-level use, that parent is the link frame.
Do not use visual offsets to hide a wrong link or joint frame. If a mesh needs an offset because the mesh asset origin is not the link frame, record that fact in the geometry table.
## Named frames
Use `<frame>` when a reusable transform is meaningful:
```xml
<frame name="camera_optical_frame" attached_to="camera_link">
<pose relative_to="camera_link">0 0 0 -1.57079632679 0 -1.57079632679</pose>
</frame>
```
Frames are useful for sensors, plugins, tool frames, nested models, and repeated placement logic. They also make generated SDF more auditable.
`attached_to` and `relative_to` are different:
- `attached_to` says what the frame moves with.
- `relative_to` says how the frame's pose numbers are represented.
The `attached_to` chain should not cycle and should eventually resolve to a link, model, world, joint, or another valid frame target.
## LLM guardrails
Do not infer any of the following from prose alone:
- sign of a joint axis;
- frame in which an axis is expressed;
- RPY order or units;
- mesh origin convention;
- `relative_to` frame;
- nested-scope reference;
- sensor optical-frame transform;
- plugin frame/topic semantics.
When data is missing, either ask for the source data or write an explicitly labeled assumption.
## Useful official references
- SDFormat pose semantics: `https://sdformat.org/tutorials?tut=pose_frame_semantics`
- SDFormat pose fields: `https://sdformat.org/spec/1.12/world/`
- SDFormat joint element: `https://sdformat.org/spec/1.12/joint/`
@@ -1,55 +0,0 @@
# SDF generation command
Use the SDF launcher to regenerate explicit SDFormat outputs from Python sources with top-level `gen_sdf()` functions.
```bash
python scripts/sdf path/to/model.py
python scripts/sdf path/to/model.py -o path/to/robot.sdf
python scripts/sdf path/to/a.py=out/a.sdf path/to/b.py=out/b.sdf
```
Plain Python targets write sibling `.sdf` files beside their sources. `-o` / `--output` is valid only with one plain target. Use `SOURCE.py=OUTPUT.sdf` pairs for custom multi-target destinations.
Relative source targets and CLI output paths resolve from the current working directory. When running from outside the skill directory, prefix the launcher path so source and target files still resolve from the intended workspace.
## What the command does
The tool should:
1. import the target Python source;
2. call top-level zero-argument `gen_sdf()`;
3. normalize the returned SDF XML or envelope;
4. parse the generated XML;
5. run bundled dependency-light validation;
6. optionally run external `gz sdf --check` if requested and available;
7. write the requested `.sdf` only after required checks pass;
8. print structured warnings and assumptions.
The command does **not** regenerate geometry, meshes, GLB/topology outputs, render assets, robot-description files, planning metadata, or simulator resource packages. Regenerate those with their owning workflows before regenerating SDF that references them.
## Optional external check
If implemented, the recommended flag is:
```bash
python scripts/sdf path/to/model.py --gz-check auto
python scripts/sdf path/to/model.py --gz-check required
python scripts/sdf path/to/model.py --gz-check never
python scripts/sdf path/to/model.py --strict
```
- `auto`: run `gz sdf --check` when `gz` is available; otherwise report the check as skipped and continue.
- `required`: fail if `gz` is unavailable or if `gz sdf --check` exits nonzero.
- `never`: skip external checking.
If the external checker requires a path, write to a temporary file first. Do not overwrite the target until bundled validation and all required external checks pass.
## Failure behavior
If validation fails, the newly generated payload is not written. Existing output files may be stale; fix the Python source and regenerate.
Errors should be blocking. Warnings and assumptions should be reported but should not fail generation unless the user or CI explicitly requests strict behavior.
## Execution safety
The launcher imports generator modules. Top-level Python code in generator files may execute. Use this command only for trusted project sources. A future subprocess runner may reduce accidental side effects, but it cannot make untrusted Python safe.
@@ -1,151 +0,0 @@
# SDF generator contract
Use this reference when creating or editing Python sources that generate SDFormat/SDF files.
## Source of truth
The Python source that defines `gen_sdf()` is the source of truth. The configured `.sdf` output is generated and should not be hand-edited.
## Required shape
`gen_sdf()` must be a top-level zero-argument function. Prefer returning the root `xml.etree.ElementTree.Element` for a complete SDFormat document.
```python
import xml.etree.ElementTree as ET
def gen_sdf():
sdf = ET.Element("sdf", {"version": "1.12"})
model = ET.SubElement(sdf, "model", {"name": "sample_robot"})
ET.SubElement(model, "link", {"name": "base_link"})
return sdf
```
The root must be `<sdf>` and must include a non-empty `version` attribute. Default new outputs to `version="1.12"` unless the user or target simulator requires another version.
## Accepted return values
Preferred:
```python
def gen_sdf():
return sdf_root_element
```
Accepted for compatibility:
```python
def gen_sdf():
return """<?xml version="1.0"?>
<sdf version="1.12">...</sdf>
"""
```
Envelope form:
```python
def gen_sdf():
return {
"xml": sdf_root_element,
"assumptions": [
{"code": "mesh_units", "message": "Assumed mesh units are meters."},
"Assumed lidar frame is coincident with lidar_link.",
],
"warnings": [
{"code": "plugin_unverified", "message": "Camera plugin filename was not verified in the target simulator."}
],
"metadata": {
"target_consumer": "Gazebo Harmonic",
"sdf_version": "1.12",
},
}
```
Envelope rules:
- `xml` is required and may be an XML element or XML string.
- `assumptions`, when present, must be a list of strings or dicts with `code`, `message`, and optional `source`.
- `warnings`, when present, must be a list of strings or dicts with `code`, `message`, and optional `source`.
- `metadata`, when present, must be a JSON-serializable dict with scalar values.
- Output-path fields such as `sdf_output` are unsupported; select output paths with CLI targets.
- Unsupported envelope fields should fail with a clear error.
Use envelope assumptions to make spatial, physical, simulator, and resource assumptions auditable. Do not hide guesses in XML literals.
## Output path
The generated `.sdf` output path is selected by the CLI:
- plain source target: sibling `.sdf` beside the source;
- `-o` / `--output`: override for one plain target;
- `SOURCE.py=OUTPUT.sdf`: per-target override for multi-target generation.
The generator should not write the output file itself.
## Source-relative asset paths
Do not rely on the shell working directory for mesh or resource paths inside generator code. Prefer source-relative constants:
```python
from pathlib import Path
SOURCE_DIR = Path(__file__).resolve().parent
MESH_DIR = SOURCE_DIR / "meshes"
```
For model packages, prefer URI conventions understood by the target simulator, such as `model://...`, `package://...`, or stable relative paths from the generated `.sdf` location.
## Pose and frame discipline
Before emitting `<pose>`, `<frame>`, `<joint>`, `<axis>`, `<visual>`, `<collision>`, sensor, or plugin placement elements, fill out the design ledger:
- pose value;
- pose frame or `relative_to`;
- joint axis and `expressed_in` frame;
- positive-motion convention;
- mesh units and scale;
- source of every value.
Use constants with names that expose assumptions:
```python
ASSUMED_BASE_TO_LIDAR_Z_M = 0.18
ASSUMED_LIDAR_YAW_RAD = 0.0
```
Do not hide spatial guesses inside XML literals.
## Minimal model example with explicit pose intent
```python
import xml.etree.ElementTree as ET
def text(parent, tag, value, attrib=None):
child = ET.SubElement(parent, tag, attrib or {})
child.text = str(value)
return child
def pose(parent, xyz=(0, 0, 0), rpy=(0, 0, 0), *, relative_to=None):
attrib = {"relative_to": relative_to} if relative_to else {}
return text(parent, "pose", " ".join(str(v) for v in (*xyz, *rpy)), attrib)
def gen_sdf():
sdf = ET.Element("sdf", {"version": "1.12"})
model = ET.SubElement(sdf, "model", {"name": "sample_robot"})
pose(model, relative_to="world")
base = ET.SubElement(model, "link", {"name": "base_link"})
visual = ET.SubElement(base, "visual", {"name": "base_visual"})
geometry = ET.SubElement(visual, "geometry")
box = ET.SubElement(geometry, "box")
text(box, "size", "0.4 0.3 0.1")
return {
"xml": sdf,
"assumptions": ["base_link is coincident with the model frame."],
"metadata": {"target_consumer": "visualization"},
}
```
@@ -1,56 +0,0 @@
# SDF implementation notes
These notes describe the intended runtime shape after the rewrite. Keep this file accurate when implementation changes.
## Implemented behavior
The SDF skill should:
- generate explicit targets only;
- validate generated XML before writing;
- preserve existing `gen_sdf()` return compatibility: XML element, XML string, or envelope dict with `xml`;
- accept envelope `assumptions`, `warnings`, and `metadata` fields;
- support `--strict` warning-as-failure behavior;
- optionally run `gz sdf --check` through `--gz-check auto|required|never`;
- provide optional stdlib-only authoring helpers;
- parse SDF XML from files or in-memory strings;
- resolve local mesh files relative to the generated output location;
- accept external mesh URI schemes without local filesystem resolution;
- allow pure world files when structurally valid.
## Intended bundled validation scope
The bundled validator should check common structural and numeric errors:
- root element and version;
- document shape and pure world support;
- required names and duplicate names in local scopes;
- pose value counts, rotation formats, finite values, degrees usage, quaternion normalization, and local `relative_to` resolution;
- named frame attachment references and cycles;
- joint type set, parent/child references, axis/axis2 values, limits, and dynamics numbers;
- visual/collision owner names and geometry presence;
- primitive geometry dimensions and mesh URI/scale;
- local mesh path existence;
- inertial mass and inertia tensor plausibility;
- sensor name/type/update-rate structure;
- plugin name/filename structure.
## Remaining limitations
The bundled validator is still not a full libsdformat or simulator validator. It should not claim to fully validate:
- every version-specific SDFormat schema rule;
- all nested-model frame semantics;
- transform math or resolved poses;
- mesh unit conventions;
- arbitrary mesh inertia or collision quality;
- simulator-specific physics settings;
- plugin schemas and runtime availability;
- sensor runtime behavior;
- target simulator support for every element.
Use the design ledger, structured diagnostics, `gz sdf --check`, simulator load tests, and explicit reporting of skipped checks.
## Execution safety
The current launcher imports generator modules in-process. Generator Python files execute code and must be trusted project sources.
@@ -1,76 +0,0 @@
# SDF interoperability notes
Use this reference when SDF work touches upstream geometry, robot-description data, Gazebo/libsdformat, model packages, or CAD Viewer.
## Geometry assets
SDF should reference geometry and mesh assets; it should not regenerate them.
When SDF references generated meshes, record:
- source geometry file;
- exported mesh path;
- mesh unit convention;
- mesh origin convention;
- visual scale;
- collision simplification decision.
Regenerate geometry and mesh artifacts with their owning workflow before regenerating SDF if geometry changed.
## Robot descriptions
Keep the simulator document aligned with the upstream robot-description source when one exists.
Upstream robot-description data usually owns:
- link and joint structure used by robot-state publishing;
- physical joint limits;
- inertials and visual/collision geometry when that source is authoritative;
- control-related structure and runtime interfaces.
Use SDF for simulator/world concerns:
- simulator plugins;
- sensors requiring simulator-specific XML;
- surfaces/contact/friction;
- lights, terrain, physics, and worlds;
- nested models and includes;
- simulator-specific metadata.
Do not use SDF to paper over a wrong upstream frame tree unless the task explicitly targets a simulator-only model.
## Planning metadata
SDF should not define planning groups, end-effectors, group states, or disabled-collision matrices. If the task becomes IK or path-planning work, use the planning metadata workflow that owns those semantics.
## CAD Viewer
CAD Viewer can review `.sdf` files visually through `$cad-viewer` and help catch gross placement or resource issues. It cannot prove simulator dynamics, inertial validity, plugin loading, sensor topics, or joint-axis semantics.
Pass explicit generated or modified `.sdf` paths to `$cad-viewer` whenever it is available, and return the live viewer link it prints.
CAD Viewer renders SDF as static structure plus direct inspection controls. It lists plugins, sensors, lights, includes, and nested models as metadata, but does not execute plugins or consume file-authored motion contracts.
## Gazebo / libsdformat
The bundled validator is a lightweight preflight check. Use the target simulator's parser and loader when compatibility matters.
Good checks include:
```bash
gz sdf --check path/to/model.sdf
```
and a real simulator load in the target environment.
## Model packages and URIs
SDF resource resolution is environment-dependent. Record which URI forms the target consumer can resolve:
- relative paths from the generated `.sdf` location;
- `model://` paths under the simulator model path;
- `package://` paths under ROS/package resolution;
- `fuel://` resources;
- `http://` or `https://` assets if external fetches are allowed.
The bundled validator can confirm local relative paths, but it cannot prove external simulator resource paths unless the target environment is available.
@@ -1,115 +0,0 @@
# LLM guardrails for SDF authoring
This skill assumes agents are useful at structuring SDFormat documents and weak at silently deriving precise spatial, physical, and simulator-specific values. The workflow should route those weaknesses into explicit ledgers, constants, helpers, validators, and smoke tests.
## What agents can usually do well
- organize an SDF model or world into links, joints, frames, visuals, collisions, sensors, plugins, and includes;
- translate user intent into a plausible document structure;
- maintain naming consistency when names are explicit;
- write small Python generators and ElementTree transformations;
- explain assumptions and create checklists;
- preserve existing patterns when examples are nearby.
## What agents should not be trusted to infer silently
- exact link poses, frame transforms, or joint origins;
- positive joint-axis directions from visual appearance;
- mesh units, mesh scale, or coordinate-system conventions;
- center of mass or inertia tensors from rendered shape alone;
- plugin filenames, parameters, topics, namespaces, or sensor schemas;
- whether a plugin is a simulator runtime plugin or a CAD Viewer visualization-only extension;
- target simulator support for a given SDFormat version or extension;
- whether collision geometry is stable for physics;
- whether external URIs resolve in the deployment environment.
## Required mitigation pattern
For every spatial, physical, or simulator-specific value, use one of these sources:
1. user-provided requirement;
2. upstream geometry, robot-description, planning-metadata, mesh manifest, or model package source;
3. target simulator documentation;
4. measured or calculated value with method stated;
5. explicit assumption recorded in the generator envelope, design ledger, or final report.
Do not hide guessed values in raw XML.
## Placeholder policy
Placeholders are allowed only when the user asks for a scaffold, draft, or minimal example. Mark them as placeholders and keep them easy to replace.
Examples of acceptable placeholders:
```python
return {
"xml": sdf,
"assumptions": [
{
"code": "placeholder_inertial",
"message": "Inertial tensor uses a primitive approximation pending measured mass properties.",
}
],
}
```
Examples of unacceptable placeholders:
- invented plugin filenames;
- adding CAD Viewer-only motion plugins to SDF files;
- arbitrary inertia values on a dynamic robot without a warning;
- guessed mesh scale that makes the visual look plausible;
- silently flipping a joint axis to match an expected screenshot.
## Spatial reasoning checklist
Before generating or modifying SDF, answer these questions in the ledger or final report:
| Question | Required evidence |
|---|---|
| What frame is each pose expressed in? | `relative_to`, source file, or documented default |
| What frame is each joint axis expressed in? | `expressed_in` or documented default |
| What is positive motion for each non-fixed joint? | command/test expectation or upstream source |
| Are mesh units and scales known? | manifest, CAD export config, or explicit assumption |
| Are visual and collision poses intentionally different? | simulation reason or source geometry |
| Are inertials measured, calculated, approximated, or omitted? | method and confidence |
| Are plugin and sensor parameters copied from target docs? | target simulator/version and source |
## Code-generation style
Prefer this pattern:
```python
BASE_TO_CAMERA_XYZ_M = (0.18, 0.0, 0.12)
BASE_TO_CAMERA_RPY_RAD = (0.0, -0.2, 0.0)
CAMERA_FRAME = "camera_frame"
# Source: project CAD frame export 2026-05-12. RPY radians.
pose(camera_frame, BASE_TO_CAMERA_XYZ_M, BASE_TO_CAMERA_RPY_RAD, relative_to="base_link")
```
Avoid this pattern:
```python
ET.SubElement(camera, "pose").text = "0.18 0 .12 0 -11.5 0"
```
The second version hides units, uses degrees without saying so, and makes the source of the transform impossible to audit.
## Validation expectations
The validator should catch cheap deterministic mistakes, but it cannot prove the design is physically or simulator-correct. After bundled validation, use optional external checks and simulator smoke tests when the task depends on simulator behavior.
Report skipped checks explicitly. A skipped check is not automatically a failure, but it is relevant risk information.
## Response behavior for agents
When finishing an SDF task, state:
- source generator path and generated target path;
- checks run and their result;
- checks skipped and why;
- assumptions and placeholders;
- risks that need simulator verification.
Do not simply say that the file is valid. Say which validator or smoke test passed.
@@ -1,79 +0,0 @@
# SDF workflow
Use this reference when editing SDF robot model structure, world structure, mesh references, simulator metadata, or generated SDF output.
## Edit loop
1. Find the Python source that defines `gen_sdf()`.
2. Treat that source as authoritative. Do not hand-edit generated `.sdf` output unless explicitly instructed.
3. Identify the target consumer and required SDFormat version.
4. Decide whether the output is model-level, world-level, or model-in-world.
5. Fill or update the design ledger before writing XML.
6. For every pose and axis, state the frame in which it is expressed. Use `relative_to` / `expressed_in` where ambiguity would otherwise remain.
7. Edit the generator source.
8. Regenerate only the explicit target.
9. Review bundled validation errors as structural guardrails, not exhaustive simulator proof.
10. Hand generated or modified `.sdf` files to `$cad-viewer` for live viewer links when available.
11. Run available smoke tests.
12. Report assumptions and skipped checks.
## Model vs world
Use **model-level SDF** when exporting a reusable robot or object model that another world can include.
Use **world-level SDF** when the task includes:
- physics engine settings;
- lights or scene setup;
- terrain or ground plane;
- multiple initial model placements;
- world plugins;
- includes of external model packages;
- simulator scene setup.
Use **model-in-world SDF** when the task explicitly needs both an inline model and world-specific context.
The lightweight validator should allow pure world-only documents. A world-only document with lights, physics, actors, or includes can be valid SDFormat even when it contains no inline `<model>`.
## Mesh references
SDF mesh URIs should be stable from the generated `.sdf` file's perspective or use a simulator/package URI convention understood by the consumer.
Good URI choices include:
- relative paths beside the generated SDF when the model is self-contained;
- `model://...` for simulator model packages;
- `package://...` when the simulator environment resolves package roots;
- `fuel://...`, `http://...`, or `https://...` only when the consumer is expected to fetch external assets.
Do not use generated SDF XML as the source of truth for mesh placement. Prefer deriving visual and collision mesh references from the same source data that owns mesh instance placement.
## Inertials and physics
For dynamic models, inertial data is simulation-critical. If inertials are estimated, record the approximation method. Do not copy visual origins into inertial origins unless that is physically justified.
Collision geometry should be selected for stable and fast physics, not visual fidelity. Use primitive or simplified collision geometry when possible.
## Plugins and sensors
For plugins and sensors, record:
- plugin filename or sensor type;
- expected simulator distribution/version;
- topics, frames, update rates, namespaces;
- parameter source;
- startup smoke test result.
Do not invent plugin parameters. Incorrect plugin XML can pass lightweight validation and still fail at simulator load time.
CAD Viewer reviews SDF files as static model/world structure through `$cad-viewer` links. Do not add Explorer-only motion plugins; use simulator-native controllers, plugins, or test harnesses for simulator behavior.
## Existing SDF inspection
When inspecting existing `.sdf` files, separate three questions:
1. Is the XML structurally valid enough for the bundled validator?
2. Is it compatible with the target SDFormat/libsdformat/simulator version?
3. Does it satisfy this project's packaging, mesh, and workflow policy?
Do not reject valid SDF solely because it violates a project preference unless the task or repository policy requires that preference.
@@ -1,98 +0,0 @@
# SDF smoke tests
Use smoke tests after generated SDF passes bundled validation. The goal is to catch simulator and spatial failures that dependency-light XML checks cannot detect.
## Recommended checks
### Bundled validation
```bash
python scripts/sdf path/to/model.py
python scripts/sdf path/to/model.py --strict
```
Bundled validation runs during explicit target generation. Use `--strict` when warnings should block handoff.
### SDFormat parser check
When Gazebo tooling is installed:
```bash
gz sdf --check path/to/model.sdf
```
or through the skill CLI:
```bash
python scripts/sdf path/to/model.py --gz-check auto
```
Use the exact simulator environment that will consume the file when possible.
### Simulator load check
Load the model or world in the target simulator and check:
- no parser warnings or plugin load errors;
- model appears at the intended pose;
- visual and collision assets resolve;
- collision geometry is not visibly offset from visuals;
- dynamic model does not explode, fall through the floor, or produce invalid inertia warnings.
### Joint motion check
For each non-fixed joint:
- command a small positive motion;
- confirm the moving child moves in the expected direction;
- confirm limits stop motion where expected;
- confirm continuous joints can rotate continuously if intended.
### CAD Viewer static review
After generating or modifying an `.sdf`, hand the explicit path to `$cad-viewer` for a live viewer link when available.
- confirm direct model links, joints, frames, visuals, and collisions are placed correctly;
- confirm includes, plugins, sensors, lights, nested models, and unsupported geometry are listed as static metadata;
- record any simulator-only behavior that CAD Viewer cannot execute.
### Sensor and plugin check
For each sensor or plugin:
- confirm plugin library loads;
- confirm expected topics/services appear;
- confirm frame names match the design ledger;
- confirm update rate and namespace behavior;
- capture one sample output if practical.
### Visual review
When CAD Viewer or an equivalent viewer is available through `$cad-viewer`, return the viewer link. Visual review is useful but insufficient: it can catch gross placement and mesh problems, but it cannot prove axis frames, inertials, dynamics, or plugin behavior.
## Report format
Use a compact report:
```text
Checks run:
- bundled SDF validation: passed
- gz sdf --check: skipped, gz not installed
- simulator load: passed in Gazebo Harmonic
- joint motion: shoulder_pan positive motion verified; gripper joints skipped
- plugin startup: camera plugin unresolved, requires target simulator package
Assumptions:
- Assumed mesh units are meters.
- Assumed lidar frame is coincident with lidar_link.
```
## When to stop
Stop and fix the generator when:
- bundled validation has errors;
- `gz sdf --check` fails under a required external-check policy;
- the simulator reports invalid inertias or unresolved required assets;
- a joint moves opposite from the documented positive direction;
- plugin startup fails for a plugin required by the task.
@@ -1,154 +0,0 @@
# SDF validation
Generation validates every `gen_sdf()` result before writing. This validation is dependency-light and intended to catch common structural errors. It is not a replacement for libsdformat, Gazebo, or target-simulator validation.
## Validation model
The validator should produce structured diagnostics with severities:
- `error`: invalid or unsafe enough to block writing output;
- `warning`: likely problem or unverified simulator behavior; output can be written unless `--strict` is used;
- `info`: assumption, skipped check, or useful context.
`--strict` treats warnings as failures.
## Bundled checks
### Root and document shape
The runtime should check that:
- the root element is `<sdf>`;
- the root has a non-empty `version` attribute;
- the version looks like `major.minor`;
- the document contains meaningful SDF content such as a model, world, actor, light, include, or plugin;
- structurally valid pure world files are accepted even when they contain no inline model.
### Names and scopes
The runtime should check that:
- world names are non-empty and unique at root scope;
- root model names are non-empty and unique;
- model link, joint, frame, sensor, visual, and collision names are non-empty where required and unique within their owner scope;
- duplicate names are reported with a path and scope.
### Poses
The runtime should check all `<pose>` elements:
- default `rotation_format="euler_rpy"` has exactly six finite values;
- `rotation_format="quat_xyzw"` has exactly seven finite values;
- unsupported `rotation_format` is an error;
- quaternion values are approximately normalized;
- `degrees="true"` is a warning unless strict mode is enabled;
- nontrivial omitted `relative_to` is a warning;
- `relative_to` resolves within local scope when possible;
- nested `::` references have valid syntax and resolve when the local tree is available.
### Frames
The runtime should check that:
- `<frame name="...">` has a non-empty unique name in its scope;
- `attached_to`, when present, resolves locally when possible;
- frame attachment chains do not cycle;
- unresolved nested or external frame references are reported as warnings when local validation cannot prove them invalid.
### Joints
Known SDF 1.12 joint types:
```text
continuous, revolute, gearbox, revolute2, prismatic, ball, screw, universal, fixed
```
The runtime should check that:
- joint type is non-empty and known;
- `<parent>` and `<child>` text exists;
- `world` is allowed as parent but not child;
- unscoped parent/child references exist in the same model;
- `axis` and `axis2` vectors are finite, nonzero, and normalized;
- `axis2` is used only where the joint type supports a second axis;
- `expressed_in` resolves when local resolution is possible;
- limit and dynamics values are finite or documented infinities where SDFormat permits them;
- finite lower limits do not exceed finite upper limits;
- continuous joints with fake finite position limits produce a warning.
### Geometry and mesh URIs
The runtime should check that:
- each visual/collision owner has one geometry element;
- each geometry has exactly one known primitive or mesh child when possible;
- box size has 3 positive finite values;
- cylinder radius and length are positive and finite;
- sphere radius is positive and finite;
- plane size has 2 positive finite values;
- mesh URI values are non-empty;
- mesh scale has 3 positive finite values when present;
- local mesh references resolve relative to the generated `.sdf` location;
- known external URI schemes such as `model://`, `package://`, `fuel://`, `http://`, and `https://` are accepted without local filesystem resolution.
### Inertials
The runtime should check that:
- mass is positive and finite;
- inertial pose is valid when present;
- inertia tensor components are finite;
- inertia matrix is positive semidefinite within tolerance;
- missing inertial data on dynamic physical links is at least a warning;
- frame-like or static links can omit inertials when documented.
### Sensors and plugins
The runtime should check that:
- sensor names are non-empty and unique within owner scope;
- sensor `type` is non-empty;
- sensor `update_rate`, when present, is finite and non-negative;
- sensor pose is valid;
- plugin filename is non-empty;
- plugin name, when present, is non-empty;
- arbitrary simulator-specific plugin schemas are not invented by the validator.
Plugin filenames and parameters can pass bundled validation and still fail in the target simulator. Use smoke tests.
### CAD Viewer review
CAD Viewer treats SDF plugins, sensors, lights, includes, and nested models as static metadata. The bundled validator checks generic structure only; it does not validate Explorer-only motion contracts or execute simulator plugins.
After generated `.sdf` files are created or modified, hand explicit paths to `$cad-viewer` for live viewer links when available.
This plugin is for CAD Viewer visualization and review. It is not a Gazebo physics/controller plugin and should not be represented as simulator runtime behavior.
## External checks
When Gazebo tooling is available, run:
```bash
gz sdf --check path/to/file.sdf
```
The CLI option should be:
```bash
python scripts/sdf path/to/source.py --gz-check auto
```
External checks should be recorded in the diagnostics report. A skipped optional check is not a bundled-validation failure unless the user requested `--gz-check required`.
## SDF validity vs project policy
Separate these categories:
| Category | Examples |
|---|---|
| SDF structural validity | root `<sdf>`, version, legal element shape, non-empty names, references |
| Numeric plausibility | finite poses, positive dimensions, positive mass, normalized axes, PSD inertia |
| Simulator compatibility | libsdformat version, supported joint types, plugin availability, sensor support |
| Project policy | mesh location, preferred URI style, STL/DAE preference, collision simplification, no unresolved external URIs |
Do not reject valid SDF merely because it violates a project policy unless the task or repository requires that policy. Prefer warnings and strict-mode controls.
-1
View File
@@ -1 +0,0 @@
--editable ./scripts/packages/cadpy_metadata
@@ -1 +0,0 @@
../../../../packages/cadpy_metadata
@@ -1 +0,0 @@
"""SDFormat/SDF generation and validation helpers."""
@@ -1,16 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
if __package__ in {None, ""}:
scripts_dir = Path(__file__).resolve().parents[1]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from sdf.cli import main
else:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,405 +0,0 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import math
import xml.etree.ElementTree as ET
Number = int | float
PoseValues = Sequence[Number] | ET.Element | None
def fmt_float(value: object) -> str:
number = _finite_float(value, "value")
if number == 0:
return "0"
if 1e-4 <= abs(number) < 1e9:
text_value = f"{number:.12f}".rstrip("0").rstrip(".")
return text_value or "0"
return f"{number:.12g}"
def fmt_vector(values: Sequence[object]) -> str:
return " ".join(fmt_float(value) for value in values)
def text(parent: ET.Element, tag: str, value: object, attrib: Mapping[str, object] | None = None) -> ET.Element:
child = ET.SubElement(parent, tag, _string_attrib(attrib))
child.text = str(value)
return child
def sdf_root(version: str = "1.12") -> ET.Element:
version_text = str(version or "").strip()
if not version_text:
raise ValueError("SDF version must be non-empty")
return ET.Element("sdf", {"version": version_text})
def world(parent: ET.Element, name: str) -> ET.Element:
return ET.SubElement(parent, "world", {"name": _required_name(name, "world")})
def model(
parent: ET.Element,
name: str,
*,
static: bool | None = None,
pose: PoseValues = None,
) -> ET.Element:
model_element = ET.SubElement(parent, "model", {"name": _required_name(name, "model")})
if static is not None:
text(model_element, "static", "true" if static else "false")
_append_pose(model_element, pose)
return model_element
def frame(
parent: ET.Element,
name: str,
*,
attached_to: str | None = None,
pose: PoseValues = None,
) -> ET.Element:
attrib = {"name": _required_name(name, "frame")}
if attached_to:
attrib["attached_to"] = str(attached_to)
frame_element = ET.SubElement(parent, "frame", attrib)
_append_pose(frame_element, pose)
return frame_element
def link(parent: ET.Element, name: str, *, pose: PoseValues = None, inertial: Mapping[str, object] | None = None) -> ET.Element:
link_element = ET.SubElement(parent, "link", {"name": _required_name(name, "link")})
_append_pose(link_element, pose)
if inertial:
inertial_element = inertial_helper(
link_element,
inertial["mass"],
inertial["inertia"],
pose=inertial.get("pose"),
)
del inertial_element
return link_element
def joint(
parent: ET.Element,
name: str,
joint_type: str,
parent_link: str,
child_link: str,
*,
pose: PoseValues = None,
axis_xyz: Sequence[object] | None = None,
axis_expressed_in: str | None = None,
axis2_xyz: Sequence[object] | None = None,
limits: Mapping[str, object] | None = None,
) -> ET.Element:
joint_element = ET.SubElement(
parent,
"joint",
{
"name": _required_name(name, "joint"),
"type": _required_name(joint_type, "joint type"),
},
)
text(joint_element, "parent", _required_name(parent_link, "joint parent"))
text(joint_element, "child", _required_name(child_link, "joint child"))
_append_pose(joint_element, pose)
if axis_xyz is not None:
axis_element = axis(joint_element, axis_xyz, expressed_in=axis_expressed_in)
if limits:
_limit(axis_element, limits)
if axis2_xyz is not None:
axis2_element = axis(joint_element, axis2_xyz, expressed_in=axis_expressed_in, tag="axis2")
if limits and "axis2" in limits and isinstance(limits["axis2"], Mapping):
_limit(axis2_element, limits["axis2"])
return joint_element
def pose(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 0),
rpy: Sequence[object] = (0, 0, 0),
*,
relative_to: str | None = None,
rotation_format: str = "euler_rpy",
degrees: bool = False,
) -> ET.Element:
if rotation_format != "euler_rpy":
raise ValueError("pose() supports rotation_format='euler_rpy'; use quat_pose() for quaternions")
values = [*_vector(xyz, 3, "pose xyz"), *_vector(rpy, 3, "pose rpy")]
attrib: dict[str, object] = {}
if relative_to:
attrib["relative_to"] = str(relative_to)
if degrees:
attrib["degrees"] = "true"
return text(parent, "pose", fmt_vector(values), attrib)
def quat_pose(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 0),
quat_xyzw: Sequence[object] = (0, 0, 0, 1),
*,
relative_to: str | None = None,
) -> ET.Element:
quat = _vector(quat_xyzw, 4, "pose quaternion")
if _norm(quat) == 0:
raise ValueError("pose quaternion must be nonzero")
attrib: dict[str, object] = {"rotation_format": "quat_xyzw"}
if relative_to:
attrib["relative_to"] = str(relative_to)
return text(parent, "pose", fmt_vector([*_vector(xyz, 3, "pose xyz"), *quat]), attrib)
def axis(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 1),
*,
expressed_in: str | None = None,
tag: str = "axis",
) -> ET.Element:
values = _vector(xyz, 3, f"{tag} xyz")
if _norm(values) == 0:
raise ValueError(f"{tag} vector must be nonzero")
axis_element = ET.SubElement(parent, tag)
attrib = {"expressed_in": str(expressed_in)} if expressed_in else None
text(axis_element, "xyz", fmt_vector(values), attrib)
return axis_element
def visual(
parent: ET.Element,
name: str,
*,
pose: PoseValues = None,
geometry_builder: Callable[[ET.Element], object] | None = None,
) -> ET.Element:
owner = ET.SubElement(parent, "visual", {"name": _required_name(name, "visual")})
_append_pose(owner, pose)
_call_geometry_builder(owner, geometry_builder)
return owner
def collision(
parent: ET.Element,
name: str,
*,
pose: PoseValues = None,
geometry_builder: Callable[[ET.Element], object] | None = None,
) -> ET.Element:
owner = ET.SubElement(parent, "collision", {"name": _required_name(name, "collision")})
_append_pose(owner, pose)
_call_geometry_builder(owner, geometry_builder)
return owner
def box(parent: ET.Element, size_xyz: Sequence[object]) -> ET.Element:
values = _positive_vector(size_xyz, 3, "box size")
box_element = ET.SubElement(_geometry_parent(parent), "box")
text(box_element, "size", fmt_vector(values))
return box_element
def sphere(parent: ET.Element, radius: object) -> ET.Element:
sphere_element = ET.SubElement(_geometry_parent(parent), "sphere")
text(sphere_element, "radius", fmt_float(_positive_float(radius, "sphere radius")))
return sphere_element
def cylinder(parent: ET.Element, radius: object, length: object) -> ET.Element:
cylinder_element = ET.SubElement(_geometry_parent(parent), "cylinder")
text(cylinder_element, "radius", fmt_float(_positive_float(radius, "cylinder radius")))
text(cylinder_element, "length", fmt_float(_positive_float(length, "cylinder length")))
return cylinder_element
def capsule(parent: ET.Element, radius: object, length: object) -> ET.Element:
capsule_element = ET.SubElement(_geometry_parent(parent), "capsule")
text(capsule_element, "radius", fmt_float(_positive_float(radius, "capsule radius")))
text(capsule_element, "length", fmt_float(_positive_float(length, "capsule length")))
return capsule_element
def mesh(parent: ET.Element, uri: str, *, scale: Sequence[object] | None = None) -> ET.Element:
uri_text = str(uri or "").strip()
if not uri_text:
raise ValueError("mesh uri must be non-empty")
mesh_element = ET.SubElement(_geometry_parent(parent), "mesh")
text(mesh_element, "uri", uri_text)
if scale is not None:
text(mesh_element, "scale", fmt_vector(_positive_vector(scale, 3, "mesh scale")))
return mesh_element
def inertial(parent: ET.Element, mass: object, inertia: Mapping[str, object] | Sequence[object], *, pose: PoseValues = None) -> ET.Element:
return inertial_helper(parent, mass, inertia, pose=pose)
def sensor(
parent: ET.Element,
name: str,
sensor_type: str,
*,
pose: PoseValues = None,
topic: str | None = None,
update_rate: object | None = None,
) -> ET.Element:
sensor_element = ET.SubElement(
parent,
"sensor",
{"name": _required_name(name, "sensor"), "type": _required_name(sensor_type, "sensor type")},
)
_append_pose(sensor_element, pose)
if topic:
text(sensor_element, "topic", topic)
if update_rate is not None:
rate = _finite_float(update_rate, "sensor update_rate")
if rate < 0:
raise ValueError("sensor update_rate must be non-negative")
text(sensor_element, "update_rate", fmt_float(rate))
return sensor_element
def plugin(
parent: ET.Element,
name: str,
filename: str,
params: Mapping[str, object] | None = None,
**extra_params: object,
) -> ET.Element:
plugin_element = ET.SubElement(
parent,
"plugin",
{
"name": _required_name(name, "plugin"),
"filename": _required_name(filename, "plugin filename"),
},
)
for key, value in {**dict(params or {}), **extra_params}.items():
text(plugin_element, str(key), value)
return plugin_element
def include(parent: ET.Element, uri: str, *, name: str | None = None, pose: PoseValues = None) -> ET.Element:
include_element = ET.SubElement(parent, "include")
text(include_element, "uri", _required_name(uri, "include uri"))
if name:
text(include_element, "name", name)
_append_pose(include_element, pose)
return include_element
def inertial_helper(
parent: ET.Element,
mass: object,
inertia: Mapping[str, object] | Sequence[object],
*,
pose: PoseValues = None,
) -> ET.Element:
inertial_element = ET.SubElement(parent, "inertial")
_append_pose(inertial_element, pose)
text(inertial_element, "mass", fmt_float(_positive_float(mass, "inertial mass")))
inertia_element = ET.SubElement(inertial_element, "inertia")
for key, value in _inertia_items(inertia).items():
text(inertia_element, key, fmt_float(_finite_float(value, f"inertia {key}")))
return inertial_element
def _limit(parent: ET.Element, values: Mapping[str, object]) -> ET.Element:
limit_element = ET.SubElement(parent, "limit")
for key in ("lower", "upper", "effort", "velocity"):
if key in values:
text(limit_element, key, fmt_float(_finite_float(values[key], f"limit {key}")))
return limit_element
def _append_pose(parent: ET.Element, value: PoseValues) -> None:
if value is None:
return
if isinstance(value, ET.Element):
parent.append(value)
return
if len(value) == 6:
pose(parent, xyz=value[:3], rpy=value[3:])
return
if len(value) == 7:
quat_pose(parent, xyz=value[:3], quat_xyzw=value[3:])
return
raise ValueError("pose values must contain 6 Euler values or 7 quaternion values")
def _call_geometry_builder(
owner: ET.Element,
geometry_builder: Callable[[ET.Element], object] | None,
) -> None:
if geometry_builder is None:
return
geometry_builder(_geometry_parent(owner))
def _geometry_parent(parent: ET.Element) -> ET.Element:
if _local_name(parent.tag) == "geometry":
return parent
if _local_name(parent.tag) in {"visual", "collision"}:
existing = next((child for child in list(parent) if _local_name(child.tag) == "geometry"), None)
return existing if existing is not None else ET.SubElement(parent, "geometry")
return ET.SubElement(parent, "geometry")
def _inertia_items(inertia: Mapping[str, object] | Sequence[object]) -> dict[str, object]:
keys = ("ixx", "iyy", "izz", "ixy", "ixz", "iyz")
if isinstance(inertia, Mapping):
missing = [key for key in keys if key not in inertia]
if missing:
raise ValueError(f"inertia is missing component(s): {', '.join(missing)}")
return {key: inertia[key] for key in keys}
if len(inertia) != 6:
raise ValueError("inertia sequence must contain ixx, iyy, izz, ixy, ixz, iyz")
return dict(zip(keys, inertia))
def _vector(values: Sequence[object], expected_len: int, label: str) -> list[float]:
if len(values) != expected_len:
raise ValueError(f"{label} must contain {expected_len} values")
return [_finite_float(value, label) for value in values]
def _positive_vector(values: Sequence[object], expected_len: int, label: str) -> list[float]:
return [_positive_float(value, label) for value in _vector(values, expected_len, label)]
def _finite_float(value: object, label: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label} must be numeric") from exc
if not math.isfinite(number):
raise ValueError(f"{label} must be finite")
return number
def _positive_float(value: object, label: str) -> float:
number = _finite_float(value, label)
if number <= 0:
raise ValueError(f"{label} must be positive")
return number
def _norm(values: Sequence[float]) -> float:
return math.sqrt(sum(value * value for value in values))
def _required_name(value: object, label: str) -> str:
text_value = str(value or "").strip()
if not text_value:
raise ValueError(f"{label} must be non-empty")
return text_value
def _string_attrib(attrib: Mapping[str, object] | None) -> dict[str, str]:
return {str(key): str(value) for key, value in dict(attrib or {}).items() if value is not None}
def _local_name(tag: object) -> str:
return str(tag).rsplit("}", 1)[-1]
-387
View File
@@ -1,387 +0,0 @@
from __future__ import annotations
import argparse
import importlib.util
import inspect
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if __package__ in {None, ""}:
sys.path.insert(0, str(SCRIPTS_DIR))
PACKAGES_DIR = SCRIPTS_DIR / "packages"
CADPY_METADATA_SRC_DIR = PACKAGES_DIR / "cadpy_metadata" / "src"
if str(PACKAGES_DIR) not in sys.path:
sys.path.insert(0, str(PACKAGES_DIR))
if str(CADPY_METADATA_SRC_DIR) not in sys.path:
sys.path.insert(0, str(CADPY_METADATA_SRC_DIR))
from cadpy_metadata import (
GenerationOutput,
python_source_identity,
track_generation_run,
xml_with_text_to_cad_metadata,
)
from sdf.external import run_gz_sdf_check
from sdf.findings import Finding, ValidationResult, format_findings
from sdf.source import SdfSourceError
from sdf.validation import validate_sdf_xml
@dataclass(frozen=True)
class _TargetSpec:
source_path: Path
output_path: Path
def generate_sdf_targets(
targets: Sequence[str],
*,
output: str | Path | None = None,
gz_check: str = "auto",
strict: bool = False,
) -> int:
target_specs = _resolve_target_specs(targets, output=output)
_validate_unique_outputs(target_specs)
for target_spec in target_specs:
_generate_target(
target_spec.source_path,
output_path=target_spec.output_path,
gz_check=gz_check,
strict=strict,
)
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="sdf",
description="Generate explicit SDFormat/SDF targets from Python sources.",
)
parser.add_argument(
"targets",
nargs="+",
help="Explicit Python source file or SOURCE.py=OUTPUT.sdf pair defining gen_sdf() to generate.",
)
parser.add_argument(
"-o",
"--output",
metavar="PATH",
help="Write the generated SDF file to this path. Valid only with one plain Python target.",
)
parser.add_argument(
"--gz-check",
choices=("auto", "required", "never"),
default="auto",
help="Optionally run 'gz sdf --check' before writing output.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat bundled validation warnings and generator envelope warnings as failures.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
if args.output is not None:
if _targets_include_output_pairs(args.targets):
parser.error("--output cannot be combined with SOURCE=OUTPUT targets")
if len(args.targets) != 1:
parser.error("--output can only be used with exactly one target")
return generate_sdf_targets(args.targets, output=args.output, gz_check=args.gz_check, strict=args.strict)
def _resolve_target_specs(targets: Sequence[str], *, output: str | Path | None = None) -> list[_TargetSpec]:
if output is not None and _targets_include_output_pairs(targets):
raise ValueError("sdf --output cannot be combined with SOURCE=OUTPUT targets")
if output is not None and len(targets) != 1:
raise ValueError("sdf --output can only be used with exactly one target")
specs: list[_TargetSpec] = []
for raw_target in targets:
target_text = str(raw_target or "").strip()
if "=" in target_text:
raw_source, raw_output = target_text.split("=", 1)
source_path = _resolve_source_path(raw_source)
output_path = _resolve_cli_output_path(raw_output)
else:
source_path = _resolve_source_path(target_text)
output_path = _resolve_cli_output_path(output) if output is not None else source_path.with_suffix(".sdf")
specs.append(_TargetSpec(source_path=source_path, output_path=output_path))
return specs
def _resolve_source_path(raw_source: object) -> Path:
value = str(raw_source or "").strip()
if not value:
raise ValueError("sdf target source must be a non-empty path")
source_path = Path(value).expanduser()
return source_path.resolve() if source_path.is_absolute() else (Path.cwd() / source_path).resolve()
def _resolve_cli_output_path(raw_output: object) -> Path:
value = str(raw_output or "").strip()
if not value:
raise ValueError("sdf output must be a non-empty path")
if "\\" in value:
raise ValueError("sdf output must use POSIX '/' separators")
output_path = Path(value).expanduser()
resolved = output_path.resolve() if output_path.is_absolute() else (Path.cwd() / output_path).resolve()
if resolved.suffix.lower() != ".sdf":
raise ValueError("sdf output must end in .sdf")
return resolved
def _targets_include_output_pairs(targets: Sequence[str]) -> bool:
return any("=" in str(target or "") for target in targets)
def _validate_unique_outputs(target_specs: Sequence[_TargetSpec]) -> None:
seen: dict[Path, Path] = {}
for target_spec in target_specs:
output_path = target_spec.output_path.resolve()
previous = seen.get(output_path)
if previous is not None:
raise ValueError(f"sdf output path is used more than once: {_display_path(target_spec.output_path)}")
seen[output_path] = target_spec.output_path
def _generate_target(script_path: Path, *, output_path: Path, gz_check: str, strict: bool) -> Path:
script_path = script_path.resolve()
if script_path.suffix.lower() != ".py":
raise ValueError(f"{_display_path(script_path)} must be a Python source file")
if not script_path.is_file():
raise FileNotFoundError(f"Python source not found: {_display_path(script_path)}")
with track_generation_run(
source_path=script_path,
generator="gen_sdf",
outputs=[GenerationOutput(output_path, "sdf")],
):
return _generate_target_inner(script_path, output_path=output_path, gz_check=gz_check, strict=strict)
def _generate_target_inner(script_path: Path, *, output_path: Path, gz_check: str, strict: bool) -> Path:
module = _load_generator_module(script_path)
generator = getattr(module, "gen_sdf", None)
if not callable(generator):
raise RuntimeError(f"{_display_path(script_path)} does not define callable gen_sdf()")
if inspect.signature(generator).parameters:
raise ValueError(f"{_display_path(script_path)} gen_sdf() must not accept arguments")
payload = _normalize_sdf_payload(generator(), script_path=script_path)
xml_text = _payload_xml(payload, script_path=script_path)
validation = validate_sdf_xml(
xml_text,
source_path=output_path,
base_dir=output_path.parent,
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None,
)
validation.warnings.extend(_payload_warning_findings(payload, script_path=script_path))
_raise_for_failed_generation(validation, strict=strict)
external = run_gz_sdf_check(xml_text, output_path=output_path, mode=gz_check)
validation.extend(external)
_raise_for_failed_generation(validation, strict=strict)
_write_sdf_payload(payload, output_path=output_path, script_path=script_path)
_print_generation_report(payload, validation)
if not output_path.exists():
raise RuntimeError(f"{_display_path(script_path)} did not write {_display_path(output_path)}")
return output_path
def _load_generator_module(script_path: Path) -> object:
module_name = (
"_sdf_tool_"
+ _display_path(script_path).replace("/", "_").replace("\\", "_").replace("-", "_").replace(".", "_")
)
module_spec = importlib.util.spec_from_file_location(module_name, script_path)
if module_spec is None or module_spec.loader is None:
raise RuntimeError(f"Failed to load generator module from {_display_path(script_path)}")
module = importlib.util.module_from_spec(module_spec)
original_sys_path = list(sys.path)
search_paths = [
str(Path.cwd().resolve()),
str(script_path.parent),
]
for candidate in reversed(search_paths):
if candidate not in sys.path:
sys.path.insert(0, candidate)
try:
sys.modules[module_name] = module
module_spec.loader.exec_module(module)
finally:
sys.path[:] = original_sys_path
return module
def _normalize_sdf_payload(raw_payload: object, *, script_path: Path) -> dict[str, object]:
if _is_xml_element(raw_payload):
return {"xml": _serialize_xml_element(raw_payload)}
if isinstance(raw_payload, str):
return {"xml": raw_payload}
if not isinstance(raw_payload, dict):
raise TypeError(
f"{_display_path(script_path)} gen_sdf() must return an SDF XML root element, XML string, "
"or generator envelope dict"
)
allowed_fields = {"xml", "metadata", "assumptions", "warnings"}
extra_fields = sorted(str(key) for key in raw_payload if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise TypeError(f"{_display_path(script_path)} gen_sdf() envelope has unsupported field(s): {joined}")
if "xml" not in raw_payload:
raise TypeError(f"{_display_path(script_path)} gen_sdf() envelope must define 'xml'")
payload = dict(raw_payload)
payload["xml"] = _normalize_xml_value(payload["xml"], script_path=script_path, label="gen_sdf() envelope field 'xml'")
payload["metadata"] = _normalize_metadata(payload.get("metadata"), script_path=script_path)
payload["assumptions"] = _normalize_report_items(payload.get("assumptions"), field="assumptions", script_path=script_path)
payload["warnings"] = _normalize_report_items(payload.get("warnings"), field="warnings", script_path=script_path)
return payload
def _write_sdf_payload(payload: dict[str, object], *, output_path: Path, script_path: Path) -> None:
xml = _payload_xml(payload, script_path=script_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
xml = xml_with_text_to_cad_metadata(
xml,
python_source_identity(script_path),
output_path=output_path,
source_path=script_path,
)
text = xml if xml.endswith("\n") else xml + "\n"
output_path.write_text(text, encoding="utf-8")
print(f"Wrote SDF: {output_path}")
def _payload_xml(payload: dict[str, object], *, script_path: Path) -> str:
xml = payload.get("xml")
if not isinstance(xml, str):
raise TypeError(
f"{_display_path(script_path)} gen_sdf() envelope field 'xml' must be a string, "
f"got {type(xml).__name__}"
)
return xml
def _normalize_xml_value(raw_xml: object, *, script_path: Path, label: str) -> str:
if _is_xml_element(raw_xml):
return _serialize_xml_element(raw_xml)
if isinstance(raw_xml, str):
return raw_xml
raise TypeError(
f"{_display_path(script_path)} {label} must be an xml.etree.ElementTree.Element or string, "
f"got {type(raw_xml).__name__}"
)
def _is_xml_element(value: object) -> bool:
return isinstance(value, ET.Element)
def _serialize_xml_element(root: ET.Element) -> str:
ET.indent(root, space=" ")
body = ET.tostring(root, encoding="unicode", short_empty_elements=True)
return f'<?xml version="1.0"?>\n{body}'
def _normalize_metadata(raw_metadata: object, *, script_path: Path) -> dict[str, object]:
if raw_metadata is None:
return {}
if not isinstance(raw_metadata, dict):
raise TypeError(f"{_display_path(script_path)} gen_sdf() envelope field 'metadata' must be a dict")
metadata: dict[str, object] = {}
for key, value in raw_metadata.items():
if not isinstance(value, (str, int, float, bool, type(None))):
raise TypeError(
f"{_display_path(script_path)} gen_sdf() metadata value for {key!r} must be a scalar"
)
metadata[str(key)] = value
return metadata
def _normalize_report_items(raw_items: object, *, field: str, script_path: Path) -> list[dict[str, str]]:
if raw_items is None:
return []
if isinstance(raw_items, (str, bytes)) or not isinstance(raw_items, Sequence):
raise TypeError(f"{_display_path(script_path)} gen_sdf() envelope field '{field}' must be a list")
normalized: list[dict[str, str]] = []
default_code = "generator_assumption" if field == "assumptions" else "generator_warning"
for item in raw_items:
if isinstance(item, str):
message = item.strip()
code = default_code
source = ""
elif isinstance(item, dict):
message = str(item.get("message") or "").strip()
code = str(item.get("code") or default_code).strip()
source = str(item.get("source") or "").strip()
else:
raise TypeError(
f"{_display_path(script_path)} gen_sdf() envelope field '{field}' entries must be strings or dicts"
)
if not message:
raise TypeError(f"{_display_path(script_path)} gen_sdf() envelope field '{field}' has an empty message")
normalized.append({"code": code, "message": message, "source": source})
return normalized
def _payload_warning_findings(payload: dict[str, object], *, script_path: Path) -> list[Finding]:
warnings = payload.get("warnings")
if not isinstance(warnings, list):
return []
findings: list[Finding] = []
for item in warnings:
if not isinstance(item, dict):
continue
source = item.get("source")
findings.append(
Finding(
severity="warning",
code=str(item.get("code") or "generator_warning"),
message=str(item.get("message") or ""),
path=_display_path(script_path),
hint=f"Source: {source}" if source else None,
)
)
return findings
def _raise_for_failed_generation(validation: ValidationResult, *, strict: bool) -> None:
findings = validation.errors + (validation.warnings if strict else [])
if findings:
raise SdfSourceError(format_findings(findings))
def _print_generation_report(payload: dict[str, object], validation: ValidationResult) -> None:
assumptions = payload.get("assumptions")
if isinstance(assumptions, list):
for item in assumptions:
if not isinstance(item, dict):
continue
source = f" Source: {item['source']}." if item.get("source") else ""
message = str(item["message"])
ending = "" if message.endswith(".") else "."
print(f"Assumption [{item['code']}]: {message}{ending}{source}")
for finding in [*validation.warnings, *validation.infos]:
print(finding.format())
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,55 +0,0 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
from .findings import ValidationResult
GzCheckMode = str
def run_gz_sdf_check(xml_text: str, *, output_path: Path, mode: GzCheckMode = "auto") -> ValidationResult:
result = ValidationResult()
normalized_mode = str(mode or "auto").strip().lower()
if normalized_mode not in {"auto", "required", "never"}:
raise ValueError("gz_check must be one of: auto, required, never")
if normalized_mode == "never":
result.add("info", "gz_check_skipped", "gz sdf --check skipped by request")
return result
gz_path = shutil.which("gz")
if gz_path is None:
severity = "error" if normalized_mode == "required" else "warning"
result.add(
severity,
"gz_check_unavailable",
"gz sdf --check skipped because 'gz' is not on PATH",
)
return result
output_parent = output_path.resolve().parent
output_parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".sdf", dir=output_parent, delete=False) as handle:
temp_path = Path(handle.name)
handle.write(xml_text if xml_text.endswith("\n") else xml_text + "\n")
try:
completed = subprocess.run(
[gz_path, "sdf", "--check", str(temp_path)],
check=False,
capture_output=True,
text=True,
)
finally:
temp_path.unlink(missing_ok=True)
if completed.returncode != 0:
details = (completed.stderr or completed.stdout or "").strip()
message = "gz sdf --check failed"
if details:
message = f"{message}: {details}"
result.add("error", "gz_check_failed", message)
else:
result.add("info", "gz_check_passed", "gz sdf --check passed")
return result
@@ -1,61 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
Severity = Literal["error", "warning", "info"]
@dataclass(frozen=True)
class Finding:
severity: Severity
code: str
message: str
path: str | None = None
hint: str | None = None
def format(self) -> str:
location = f" at {self.path}" if self.path else ""
hint = f" Hint: {self.hint}" if self.hint else ""
return f"{self.severity}: {self.code}{location}: {self.message}{hint}"
@dataclass
class ValidationResult:
errors: list[Finding] = field(default_factory=list)
warnings: list[Finding] = field(default_factory=list)
infos: list[Finding] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def add(
self,
severity: Severity,
code: str,
message: str,
*,
path: str | None = None,
hint: str | None = None,
) -> Finding:
finding = Finding(severity=severity, code=code, message=message, path=path, hint=hint)
if severity == "error":
self.errors.append(finding)
elif severity == "warning":
self.warnings.append(finding)
else:
self.infos.append(finding)
return finding
def extend(self, other: ValidationResult) -> None:
self.errors.extend(other.errors)
self.warnings.extend(other.warnings)
self.infos.extend(other.infos)
def all_findings(self) -> list[Finding]:
return [*self.errors, *self.warnings, *self.infos]
def format_findings(findings: list[Finding]) -> str:
return "\n".join(finding.format() for finding in findings)
@@ -1,309 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlparse
import xml.etree.ElementTree as ET
from .validation import raise_for_validation_errors, validate_sdf_root
SDF_SUFFIX = ".sdf"
EXTERNAL_URI_SCHEMES = {"model", "package", "http", "https", "fuel"}
class SdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SdfJoint:
name: str
joint_type: str
parent_link: str
child_link: str
@dataclass(frozen=True)
class SdfSource:
file_ref: str
source_path: Path
version: str
model_names: tuple[str, ...]
world_names: tuple[str, ...]
links: tuple[str, ...]
joints: tuple[SdfJoint, ...]
mesh_paths: tuple[Path, ...]
visual_mesh_paths: tuple[Path, ...] = ()
collision_mesh_paths: tuple[Path, ...] = ()
def file_ref_from_sdf_path(sdf_path: Path) -> str:
resolved = sdf_path.resolve()
if resolved.suffix.lower() != SDF_SUFFIX:
raise SdfSourceError(f"{resolved} is not an SDF source file")
return _relative_to_repo(resolved)
def read_sdf_source(sdf_path: Path) -> SdfSource:
resolved_path = sdf_path.resolve()
if resolved_path.suffix.lower() != SDF_SUFFIX:
raise SdfSourceError(f"{resolved_path} is not an SDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SDF XML") from exc
return parse_sdf_root(root, source_path=resolved_path, base_dir=resolved_path.parent)
def parse_sdf_xml(xml_text: str, *, source_path: Path, base_dir: Path | None = None) -> SdfSource:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise SdfSourceError(f"{_relative_to_repo(source_path)} could not be parsed as SDF XML") from exc
return parse_sdf_root(root, source_path=source_path, base_dir=base_dir)
def parse_sdf_root(root: ET.Element, *, source_path: Path, base_dir: Path | None = None) -> SdfSource:
resolved_path = source_path.resolve()
resolved_base_dir = Path(base_dir).resolve() if base_dir is not None else resolved_path.parent
validation = validate_sdf_root(root, source_path=resolved_path, base_dir=resolved_base_dir)
raise_for_validation_errors(validation)
if _local_name(root.tag) != "sdf":
raise SdfSourceError(f"{_relative_to_repo(resolved_path)} root element must be <sdf>")
version = str(root.attrib.get("version") or "").strip()
if not version:
raise SdfSourceError(f"{_relative_to_repo(resolved_path)} SDF version is required")
world_elements = _children(root, "world")
world_names = [_required_name(world, source_path=resolved_path, label="world") for world in world_elements]
_raise_on_duplicates(world_names, source_path=resolved_path, label="world")
model_elements = list(_children(root, "model"))
for world in world_elements:
model_elements.extend(_children(world, "model"))
model_names: list[str] = []
links: list[str] = []
joints: list[SdfJoint] = []
visual_mesh_paths: list[Path] = []
collision_mesh_paths: list[Path] = []
for model_element in model_elements:
model_name = _required_name(model_element, source_path=resolved_path, label="model")
model_names.append(model_name)
model_links, model_joints, model_visual_meshes, model_collision_meshes = _read_model(
model_element,
source_path=resolved_path,
base_dir=resolved_base_dir,
model_name=model_name,
)
links.extend(model_links)
joints.extend(model_joints)
visual_mesh_paths.extend(model_visual_meshes)
collision_mesh_paths.extend(model_collision_meshes)
return SdfSource(
file_ref=file_ref_from_sdf_path(resolved_path),
source_path=resolved_path,
version=version,
model_names=tuple(model_names),
world_names=tuple(world_names),
links=tuple(links),
joints=tuple(joints),
mesh_paths=tuple(visual_mesh_paths + collision_mesh_paths),
visual_mesh_paths=tuple(visual_mesh_paths),
collision_mesh_paths=tuple(collision_mesh_paths),
)
def _read_model(
model_element: ET.Element,
*,
source_path: Path,
base_dir: Path,
model_name: str,
) -> tuple[list[str], list[SdfJoint], list[Path], list[Path]]:
link_names = [
_required_name(link_element, source_path=source_path, label=f"model {model_name!r} link")
for link_element in _children(model_element, "link")
]
_raise_on_duplicates(link_names, source_path=source_path, label=f"model {model_name!r} link")
link_name_set = set(link_names)
visual_mesh_paths: list[Path] = []
collision_mesh_paths: list[Path] = []
for link_element in _children(model_element, "link"):
visual_mesh_paths.extend(
_geometry_mesh_paths(
link_element,
element_name="visual",
source_path=source_path,
base_dir=base_dir,
)
)
collision_mesh_paths.extend(
_geometry_mesh_paths(
link_element,
element_name="collision",
source_path=source_path,
base_dir=base_dir,
)
)
joint_names: list[str] = []
joints: list[SdfJoint] = []
for joint_element in _children(model_element, "joint"):
joint_name = _required_name(joint_element, source_path=source_path, label=f"model {model_name!r} joint")
joint_names.append(joint_name)
joint_type = str(joint_element.attrib.get("type") or "").strip()
if not joint_type:
raise SdfSourceError(
f"{_relative_to_repo(source_path)} model {model_name!r} joint {joint_name!r} type is required"
)
parent_link = _required_child_text(
joint_element,
"parent",
source_path=source_path,
label=f"model {model_name!r} joint {joint_name!r} parent",
)
child_link = _required_child_text(
joint_element,
"child",
source_path=source_path,
label=f"model {model_name!r} joint {joint_name!r} child",
)
_validate_link_reference(
parent_link,
link_names=link_name_set,
source_path=source_path,
context=f"model {model_name!r} joint {joint_name!r} parent",
)
_validate_link_reference(
child_link,
link_names=link_name_set,
source_path=source_path,
context=f"model {model_name!r} joint {joint_name!r} child",
allow_world=False,
)
joints.append(
SdfJoint(
name=joint_name,
joint_type=joint_type,
parent_link=parent_link,
child_link=child_link,
)
)
_raise_on_duplicates(joint_names, source_path=source_path, label=f"model {model_name!r} joint")
return link_names, joints, visual_mesh_paths, collision_mesh_paths
def _children(parent: ET.Element, tag_name: str) -> list[ET.Element]:
return [child for child in list(parent) if _local_name(child.tag) == tag_name]
def _local_name(tag: str) -> str:
return str(tag).rsplit("}", 1)[-1]
def _required_name(element: ET.Element, *, source_path: Path, label: str) -> str:
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SdfSourceError(f"{_relative_to_repo(source_path)} {label} name is required")
return name
def _required_child_text(
parent: ET.Element,
tag_name: str,
*,
source_path: Path,
label: str,
) -> str:
element = next(iter(_children(parent, tag_name)), None)
value = str(element.text if element is not None else "").strip()
if not value:
raise SdfSourceError(f"{_relative_to_repo(source_path)} {label} is required")
return value
def _validate_link_reference(
link_ref: str,
*,
link_names: set[str],
source_path: Path,
context: str,
allow_world: bool = True,
) -> None:
if allow_world and link_ref == "world":
return
if "::" in link_ref:
return
if link_ref not in link_names:
raise SdfSourceError(f"{_relative_to_repo(source_path)} {context} references missing link {link_ref!r}")
def _geometry_mesh_paths(
link_element: ET.Element,
*,
element_name: str,
source_path: Path,
base_dir: Path,
) -> list[Path]:
mesh_paths: list[Path] = []
for geometry_owner in _children(link_element, element_name):
geometry_element = next(iter(_children(geometry_owner, "geometry")), None)
if geometry_element is None:
continue
mesh_element = next(iter(_children(geometry_element, "mesh")), None)
if mesh_element is None:
continue
uri = _required_child_text(
mesh_element,
"uri",
source_path=source_path,
label=f"{element_name} mesh uri",
)
mesh_path = _resolve_local_mesh_uri(uri, base_dir=base_dir)
if mesh_path is not None:
if not mesh_path.is_file():
raise SdfSourceError(
f"{_relative_to_repo(source_path)} references missing mesh file: {uri!r}"
)
mesh_paths.append(mesh_path)
return mesh_paths
def _resolve_local_mesh_uri(uri: str, *, base_dir: Path) -> Path | None:
parsed = urlparse(uri)
if parsed.scheme in EXTERNAL_URI_SCHEMES:
return None
if parsed.scheme and parsed.scheme != "file":
return None
if parsed.scheme == "file":
return Path(unquote(parsed.path)).resolve()
return (base_dir / uri).resolve()
def _raise_on_duplicates(values: list[str], *, source_path: Path, label: str) -> None:
seen: set[str] = set()
duplicates: set[str] = set()
for value in values:
if value in seen:
duplicates.add(value)
continue
seen.add(value)
if duplicates:
duplicate_text = ", ".join(repr(item) for item in sorted(duplicates))
raise SdfSourceError(
f"{_relative_to_repo(source_path)} {label} names contain duplicates {duplicate_text}"
)
def _relative_to_repo(path: Path) -> str:
try:
return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return path.resolve().as_posix()
@@ -1,846 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
import math
from pathlib import Path
import re
from urllib.parse import unquote, urlparse
import xml.etree.ElementTree as ET
from .findings import ValidationResult, format_findings
EXTERNAL_URI_SCHEMES = {"model", "package", "http", "https", "fuel"}
COMMON_JOINT_TYPES = {
"ball",
"continuous",
"fixed",
"gearbox",
"prismatic",
"revolute",
"revolute2",
"screw",
"universal",
}
SECOND_AXIS_JOINT_TYPES = {"revolute2", "universal"}
BOOLEAN_VALUES = {"0", "1", "true", "false", "yes", "no", "on", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
POSE_TOLERANCE = 1e-12
UNIT_TOLERANCE = 1e-6
PSD_TOLERANCE = 1e-9
def validate_sdf_xml(
xml_text: str,
*,
source_path: Path,
base_dir: Path | None = None,
metadata: Mapping[str, object] | None = None,
) -> ValidationResult:
result = ValidationResult()
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
result.add(
"error",
"invalid_xml",
f"{_display_path(source_path)} could not be parsed as SDF XML: {exc}",
path="/",
)
return result
result.extend(validate_sdf_root(root, source_path=source_path, base_dir=base_dir, metadata=metadata))
return result
def validate_sdf_root(
root: ET.Element,
*,
source_path: Path,
base_dir: Path | None = None,
metadata: Mapping[str, object] | None = None,
) -> ValidationResult:
del metadata
result = ValidationResult()
resolved_path = source_path.resolve()
resolved_base_dir = Path(base_dir).resolve() if base_dir is not None else resolved_path.parent
if _local_name(root.tag) != "sdf":
result.add(
"error",
"invalid_root",
f"{_display_path(resolved_path)} root element must be <sdf>",
path="/",
)
return result
version = str(root.attrib.get("version") or "").strip()
if not version:
result.add(
"error",
"missing_version",
f"{_display_path(resolved_path)} SDF version is required",
path="/sdf",
)
elif not re.match(r"^\d+\.\d+(?:\.\d+)?$", version):
result.add(
"warning",
"unusual_version",
f"SDF version {version!r} does not look like major.minor",
path="/sdf",
)
meaningful_tags = {"model", "world", "actor", "light", "include", "plugin"}
if not any(_local_name(child.tag) in meaningful_tags for child in list(root)):
result.add(
"error",
"empty_document",
f"{_display_path(resolved_path)} must define at least one <model>, <world>, or other SDF content",
path="/sdf",
)
_check_unique_named(_children(root, "world"), result, "/sdf", "world")
_check_unique_named(_children(root, "model"), result, "/sdf", "model")
for include_element in _children(root, "include"):
_validate_include(include_element, result, "/sdf/include")
for plugin_element in _children(root, "plugin"):
_validate_plugin(plugin_element, result, "/sdf/plugin")
for world_element in _children(root, "world"):
_validate_world(world_element, result, resolved_base_dir)
for model_element in _children(root, "model"):
_validate_model(model_element, result, resolved_base_dir, parent_targets={"world"})
return result
def raise_for_validation_errors(result: ValidationResult, *, strict: bool = False) -> None:
findings = result.errors + (result.warnings if strict else [])
if findings:
from .source import SdfSourceError
raise SdfSourceError(format_findings(findings))
def _validate_world(world_element: ET.Element, result: ValidationResult, base_dir: Path) -> None:
world_name = _required_name(world_element, result, _path("world", world_element), "world")
world_path = _path("world", world_element, fallback="/sdf/world")
if not world_name:
world_name = "world"
targets = {"world", world_name}
_check_unique_named(_children(world_element, "model"), result, world_path, "model")
_check_unique_named(_children(world_element, "frame"), result, world_path, "frame")
frames = _children(world_element, "frame")
frame_names = _names(frames)
targets.update(frame_names)
for frame_element in frames:
_validate_frame(frame_element, result, targets, world_path)
_validate_frame_cycles(frames, result, world_path)
for pose_element in _children(world_element, "pose"):
_validate_pose(pose_element, result, f"{world_path}/pose", targets)
for include_element in _children(world_element, "include"):
_validate_include(include_element, result, f"{world_path}/include")
for plugin_element in _children(world_element, "plugin"):
_validate_plugin(plugin_element, result, f"{world_path}/plugin")
for model_element in _children(world_element, "model"):
_validate_model(model_element, result, base_dir, parent_targets=targets)
def _validate_model(
model_element: ET.Element,
result: ValidationResult,
base_dir: Path,
*,
parent_targets: set[str],
) -> None:
model_name = _required_name(model_element, result, _path("model", model_element), "model")
model_path = _path("model", model_element, fallback="/sdf/model")
if not model_name:
model_name = "model"
link_elements = _children(model_element, "link")
joint_elements = _children(model_element, "joint")
frame_elements = _children(model_element, "frame")
nested_model_elements = _children(model_element, "model")
plugin_elements = _children(model_element, "plugin")
_check_unique_named(link_elements, result, model_path, "link")
_check_unique_named(joint_elements, result, model_path, "joint")
_check_unique_named(frame_elements, result, model_path, "frame")
_check_unique_named(nested_model_elements, result, model_path, "model")
link_names = set(_names(link_elements))
frame_names = set(_names(frame_elements))
nested_model_names = set(_names(nested_model_elements))
local_targets = {
"world",
"__model__",
model_name,
*parent_targets,
*link_names,
*frame_names,
*nested_model_names,
}
for pose_element in _children(model_element, "pose"):
_validate_pose(pose_element, result, f"{model_path}/pose", local_targets)
for static_element in _children(model_element, "static"):
_validate_boolean_text(static_element, result, f"{model_path}/static", "static")
for frame_element in frame_elements:
_validate_frame(frame_element, result, local_targets, model_path)
_validate_frame_cycles(frame_elements, result, model_path)
child_links = _joint_child_links(joint_elements)
for link_element in link_elements:
_validate_link(link_element, result, base_dir, local_targets, model_path)
for joint_element in joint_elements:
_validate_joint(joint_element, result, link_names, local_targets, model_path)
for include_element in _children(model_element, "include"):
_validate_include(include_element, result, f"{model_path}/include")
for plugin_element in plugin_elements:
_validate_plugin(plugin_element, result, f"{model_path}/plugin")
if not _model_is_static(model_element):
referenced_links = set(child_links)
referenced_links.update(_joint_parent_links(joint_elements) - {"world"})
for link_element in link_elements:
name = _name(link_element)
if (
name
and name in referenced_links
and _children(link_element, "collision")
and not _children(link_element, "inertial")
):
result.add(
"warning",
"missing_inertial",
f"dynamic link {name!r} has collision geometry but no inertial data",
path=f"{model_path}/link[@name='{name}']",
)
for nested_model in nested_model_elements:
_validate_model(nested_model, result, base_dir, parent_targets=local_targets)
def _validate_link(
link_element: ET.Element,
result: ValidationResult,
base_dir: Path,
targets: set[str],
model_path: str,
) -> None:
link_name = _required_name(link_element, result, f"{model_path}/link", "link")
link_path = f"{model_path}/link[@name='{link_name}']" if link_name else f"{model_path}/link"
for pose_element in _children(link_element, "pose"):
_validate_pose(pose_element, result, f"{link_path}/pose", targets)
_check_unique_named(_children(link_element, "visual"), result, link_path, "visual")
_check_unique_named(_children(link_element, "collision"), result, link_path, "collision")
_check_unique_named(_children(link_element, "sensor"), result, link_path, "sensor")
visual_meshes: set[str] = set()
for visual_element in _children(link_element, "visual"):
visual_meshes.update(_validate_geometry_owner(visual_element, result, base_dir, targets, f"{link_path}/visual"))
for collision_element in _children(link_element, "collision"):
collision_meshes = _validate_geometry_owner(
collision_element,
result,
base_dir,
targets,
f"{link_path}/collision",
)
for mesh_uri in collision_meshes:
if mesh_uri in visual_meshes:
result.add(
"warning",
"collision_reuses_visual_mesh",
f"collision geometry reuses visual mesh URI {mesh_uri!r}",
path=f"{link_path}/collision",
hint="Use simplified collision geometry for physics when possible.",
)
for inertial_element in _children(link_element, "inertial"):
_validate_inertial(inertial_element, result, targets, f"{link_path}/inertial")
for sensor_element in _children(link_element, "sensor"):
_validate_sensor(sensor_element, result, targets, f"{link_path}/sensor")
def _validate_frame(frame_element: ET.Element, result: ValidationResult, targets: set[str], owner_path: str) -> None:
frame_name = _required_name(frame_element, result, f"{owner_path}/frame", "frame")
frame_path = f"{owner_path}/frame[@name='{frame_name}']" if frame_name else f"{owner_path}/frame"
attached_to = str(frame_element.attrib.get("attached_to") or "").strip()
if attached_to:
_check_reference(attached_to, result, frame_path, "attached_to", targets)
for pose_element in _children(frame_element, "pose"):
_validate_pose(pose_element, result, f"{frame_path}/pose", targets)
def _validate_joint(
joint_element: ET.Element,
result: ValidationResult,
link_names: set[str],
targets: set[str],
model_path: str,
) -> None:
joint_name = _required_name(joint_element, result, f"{model_path}/joint", "joint")
joint_path = f"{model_path}/joint[@name='{joint_name}']" if joint_name else f"{model_path}/joint"
joint_type = str(joint_element.attrib.get("type") or "").strip()
if not joint_type:
result.add("error", "missing_joint_type", f"{joint_path} type is required", path=joint_path)
elif joint_type not in COMMON_JOINT_TYPES:
result.add("error", "unknown_joint_type", f"unsupported SDF joint type {joint_type!r}", path=joint_path)
parent_link = _required_child_text(joint_element, "parent", result, f"{joint_path}/parent", "joint parent")
child_link = _required_child_text(joint_element, "child", result, f"{joint_path}/child", "joint child")
if parent_link:
_validate_link_reference(parent_link, result, link_names, f"{joint_path}/parent", allow_world=True)
if child_link:
_validate_link_reference(child_link, result, link_names, f"{joint_path}/child", allow_world=False)
for pose_element in _children(joint_element, "pose"):
_validate_pose(pose_element, result, f"{joint_path}/pose", targets)
axis_elements = _children(joint_element, "axis")
axis2_elements = _children(joint_element, "axis2")
if joint_type in {"continuous", "revolute", "prismatic"} and not axis_elements:
result.add(
"warning",
"missing_joint_axis",
f"{joint_type} joint {joint_name!r} does not declare an explicit axis",
path=joint_path,
)
for axis_element in axis_elements:
_validate_axis(axis_element, result, targets, f"{joint_path}/axis", joint_type=joint_type, axis_name="axis")
for axis2_element in axis2_elements:
if joint_type not in SECOND_AXIS_JOINT_TYPES:
result.add(
"error",
"unsupported_axis2",
f"joint type {joint_type!r} does not support axis2 in the lightweight validator",
path=f"{joint_path}/axis2",
)
_validate_axis(axis2_element, result, targets, f"{joint_path}/axis2", joint_type=joint_type, axis_name="axis2")
def _validate_axis(
axis_element: ET.Element,
result: ValidationResult,
targets: set[str],
axis_path: str,
*,
joint_type: str,
axis_name: str,
) -> None:
del axis_name
xyz_element = _first_child(axis_element, "xyz")
if xyz_element is None:
result.add("error", "missing_axis_xyz", "joint axis must contain <xyz>", path=axis_path)
else:
values = _parse_number_text(str(xyz_element.text or ""), 3, result, f"{axis_path}/xyz", "axis xyz")
if values is not None:
norm = _norm(values)
if norm == 0:
result.add("error", "zero_axis", "joint axis vector must be nonzero", path=f"{axis_path}/xyz")
elif abs(norm - 1.0) > UNIT_TOLERANCE:
result.add(
"warning",
"non_unit_axis",
f"joint axis vector length is {norm:.6g}, not 1",
path=f"{axis_path}/xyz",
)
expressed_in = str(xyz_element.attrib.get("expressed_in") or axis_element.attrib.get("expressed_in") or "").strip()
if expressed_in:
_check_reference(expressed_in, result, f"{axis_path}/xyz", "expressed_in", targets)
for limit_element in _children(axis_element, "limit"):
lower = _optional_number_child(limit_element, "lower", result, f"{axis_path}/limit/lower", allow_infinite=True)
upper = _optional_number_child(limit_element, "upper", result, f"{axis_path}/limit/upper", allow_infinite=True)
if lower is not None and upper is not None and lower > upper:
result.add("error", "invalid_joint_limit", "joint lower limit exceeds upper limit", path=f"{axis_path}/limit")
for tag in ("effort", "velocity", "stiffness", "dissipation"):
_optional_number_child(limit_element, tag, result, f"{axis_path}/limit/{tag}", allow_infinite=True)
if joint_type == "continuous" and (lower is not None or upper is not None):
result.add(
"warning",
"continuous_joint_limits",
"continuous joints usually should not use finite position limits",
path=f"{axis_path}/limit",
)
def _validate_geometry_owner(
owner: ET.Element,
result: ValidationResult,
base_dir: Path,
targets: set[str],
owner_path: str,
) -> set[str]:
owner_name = _required_name(owner, result, owner_path, _local_name(owner.tag))
path = f"{owner_path}[@name='{owner_name}']" if owner_name else owner_path
for pose_element in _children(owner, "pose"):
_validate_pose(pose_element, result, f"{path}/pose", targets)
geometry_elements = _children(owner, "geometry")
if len(geometry_elements) != 1:
result.add(
"error",
"invalid_geometry_count",
f"{_local_name(owner.tag)} must contain exactly one <geometry>",
path=path,
)
return set()
geometry = geometry_elements[0]
known_children = [
child for child in list(geometry) if _local_name(child.tag) in {"box", "sphere", "cylinder", "capsule", "plane", "mesh"}
]
if len(known_children) != 1:
result.add(
"error",
"invalid_geometry_shape",
"geometry must contain exactly one recognized primitive or mesh child",
path=f"{path}/geometry",
)
return set()
shape = known_children[0]
shape_name = _local_name(shape.tag)
if shape_name == "box":
_validate_positive_vector_child(shape, "size", 3, result, f"{path}/geometry/box/size")
elif shape_name == "sphere":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/sphere/radius")
elif shape_name == "cylinder":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/cylinder/radius")
_validate_positive_number_child(shape, "length", result, f"{path}/geometry/cylinder/length")
elif shape_name == "capsule":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/capsule/radius")
_validate_positive_number_child(shape, "length", result, f"{path}/geometry/capsule/length")
elif shape_name == "plane":
_validate_positive_vector_child(shape, "size", 2, result, f"{path}/geometry/plane/size")
elif shape_name == "mesh":
uri = _required_child_text(shape, "uri", result, f"{path}/geometry/mesh/uri", "mesh uri")
_validate_positive_vector_child(shape, "scale", 3, result, f"{path}/geometry/mesh/scale", required=False)
if uri:
_validate_mesh_uri(uri, result, base_dir, f"{path}/geometry/mesh/uri")
return {uri}
return set()
def _validate_inertial(inertial_element: ET.Element, result: ValidationResult, targets: set[str], inertial_path: str) -> None:
for pose_element in _children(inertial_element, "pose"):
_validate_pose(pose_element, result, f"{inertial_path}/pose", targets)
mass = _optional_number_child(inertial_element, "mass", result, f"{inertial_path}/mass", required=True)
if mass is not None and mass <= 0:
result.add("error", "invalid_mass", "inertial mass must be positive", path=f"{inertial_path}/mass")
inertia_element = _first_child(inertial_element, "inertia")
if inertia_element is None:
result.add("warning", "missing_inertia_matrix", "inertial element has mass but no inertia matrix", path=inertial_path)
return
components: dict[str, float] = {}
for tag in ("ixx", "iyy", "izz", "ixy", "ixz", "iyz"):
value = _optional_number_child(inertia_element, tag, result, f"{inertial_path}/inertia/{tag}", required=True)
if value is not None:
components[tag] = value
if len(components) != 6:
return
if not _inertia_is_psd(components):
result.add(
"error",
"invalid_inertia_matrix",
"inertia matrix must be positive semidefinite within tolerance",
path=f"{inertial_path}/inertia",
)
def _validate_sensor(sensor_element: ET.Element, result: ValidationResult, targets: set[str], sensor_path: str) -> None:
sensor_name = _required_name(sensor_element, result, sensor_path, "sensor")
path = f"{sensor_path}[@name='{sensor_name}']" if sensor_name else sensor_path
sensor_type = str(sensor_element.attrib.get("type") or "").strip()
if not sensor_type:
result.add("error", "missing_sensor_type", "sensor type is required", path=path)
for pose_element in _children(sensor_element, "pose"):
_validate_pose(pose_element, result, f"{path}/pose", targets)
update_rate = _optional_number_child(sensor_element, "update_rate", result, f"{path}/update_rate")
if update_rate is not None and update_rate < 0:
result.add("error", "invalid_sensor_update_rate", "sensor update_rate must be non-negative", path=f"{path}/update_rate")
def _validate_plugin(plugin_element: ET.Element, result: ValidationResult, plugin_path: str) -> None:
filename = str(plugin_element.attrib.get("filename") or "").strip()
if not filename:
result.add("error", "missing_plugin_filename", "plugin filename is required", path=plugin_path)
if "name" in plugin_element.attrib and not str(plugin_element.attrib.get("name") or "").strip():
result.add("error", "missing_plugin_name", "plugin name is empty", path=plugin_path)
elif "name" not in plugin_element.attrib:
result.add("warning", "missing_plugin_name", "plugin name is omitted", path=plugin_path)
def _validate_include(include_element: ET.Element, result: ValidationResult, include_path: str) -> None:
_required_child_text(include_element, "uri", result, f"{include_path}/uri", "include uri")
def _validate_pose(pose_element: ET.Element, result: ValidationResult, pose_path: str, targets: set[str]) -> None:
rotation_format = str(pose_element.attrib.get("rotation_format") or "euler_rpy").strip()
if rotation_format == "euler_rpy":
values = _parse_number_text(str(pose_element.text or ""), 6, result, pose_path, "pose")
elif rotation_format == "quat_xyzw":
values = _parse_number_text(str(pose_element.text or ""), 7, result, pose_path, "pose")
if values is not None:
quat = values[3:]
norm = _norm(quat)
if norm == 0:
result.add("error", "zero_quaternion", "quaternion pose has zero norm", path=pose_path)
elif abs(norm - 1.0) > UNIT_TOLERANCE:
result.add("warning", "non_unit_quaternion", f"quaternion pose norm is {norm:.6g}, not 1", path=pose_path)
else:
result.add("error", "unsupported_rotation_format", f"unsupported pose rotation_format {rotation_format!r}", path=pose_path)
values = None
degrees = str(pose_element.attrib.get("degrees") or "").strip().lower()
if degrees and degrees not in BOOLEAN_VALUES:
result.add("error", "invalid_pose_degrees", "pose degrees attribute must be boolean-like", path=pose_path)
elif degrees in TRUE_VALUES:
result.add("warning", "pose_uses_degrees", "pose uses degrees=true; SDF defaults to radians", path=pose_path)
relative_to = str(pose_element.attrib.get("relative_to") or "").strip()
if values is not None and _nontrivial_pose(values) and not relative_to:
result.add(
"warning",
"pose_missing_relative_to",
"nontrivial pose omits explicit relative_to",
path=pose_path,
)
if relative_to:
_check_reference(relative_to, result, pose_path, "relative_to", targets)
def _validate_frame_cycles(frames: list[ET.Element], result: ValidationResult, owner_path: str) -> None:
frame_names = set(_names(frames))
edges: dict[str, str] = {}
for frame in frames:
name = _name(frame)
target = str(frame.attrib.get("attached_to") or "").strip()
if not target:
pose_element = _first_child(frame, "pose")
target = str(pose_element.attrib.get("relative_to") or "").strip() if pose_element is not None else ""
if name and target in frame_names:
edges[name] = target
visiting: set[str] = set()
visited: set[str] = set()
def visit(frame_name: str, stack: list[str]) -> None:
if frame_name in visited:
return
if frame_name in visiting:
cycle = " -> ".join([*stack, frame_name])
result.add("error", "frame_cycle", f"frame graph contains a cycle: {cycle}", path=f"{owner_path}/frame")
return
visiting.add(frame_name)
target = edges.get(frame_name)
if target:
visit(target, [*stack, frame_name])
visiting.remove(frame_name)
visited.add(frame_name)
for frame_name in edges:
visit(frame_name, [])
def _validate_mesh_uri(uri: str, result: ValidationResult, base_dir: Path, uri_path: str) -> None:
parsed = urlparse(uri)
if parsed.scheme in EXTERNAL_URI_SCHEMES:
return
if parsed.scheme and parsed.scheme != "file":
result.add(
"warning",
"unresolved_mesh_uri_scheme",
f"mesh URI scheme {parsed.scheme!r} is not resolved by bundled validation",
path=uri_path,
)
return
mesh_path = Path(unquote(parsed.path)).resolve() if parsed.scheme == "file" else (base_dir / uri).resolve()
if not mesh_path.is_file():
result.add(
"error",
"missing_mesh_file",
f"references missing mesh file: {uri!r}",
path=uri_path,
)
def _validate_link_reference(
link_ref: str,
result: ValidationResult,
link_names: set[str],
path: str,
*,
allow_world: bool,
) -> None:
if link_ref == "world":
if allow_world:
return
result.add("error", "invalid_joint_child", "joint child may not be world", path=path)
return
if "::" in link_ref:
result.add(
"warning",
"scoped_link_reference_unchecked",
f"scoped link reference {link_ref!r} was not fully resolved by bundled validation",
path=path,
)
return
if link_ref not in link_names:
result.add("error", "missing_link_reference", f"references missing link {link_ref!r}", path=path)
def _check_reference(ref: str, result: ValidationResult, path: str, attr: str, targets: set[str]) -> None:
if "::" in ref:
if any(part == "" for part in ref.split("::")):
result.add("error", "invalid_scoped_reference", f"{attr} has malformed scoped reference {ref!r}", path=path)
else:
result.add(
"warning",
"scoped_reference_unchecked",
f"{attr} scoped reference {ref!r} was not fully resolved by bundled validation",
path=path,
)
return
if ref not in targets:
result.add("error", "unresolved_reference", f"{attr} references unknown frame or link {ref!r}", path=path)
def _check_unique_named(elements: list[ET.Element], result: ValidationResult, scope_path: str, label: str) -> None:
seen: set[str] = set()
duplicates: set[str] = set()
for element in elements:
name = _required_name(element, result, f"{scope_path}/{label}", label)
if not name:
continue
if name in seen:
duplicates.add(name)
seen.add(name)
if duplicates:
duplicate_text = ", ".join(repr(item) for item in sorted(duplicates))
result.add(
"error",
"duplicate_names",
f"{label} names contain duplicates {duplicate_text}",
path=f"{scope_path}/{label}",
)
def _validate_positive_vector_child(
parent: ET.Element,
tag: str,
expected_len: int,
result: ValidationResult,
path: str,
*,
required: bool = True,
) -> list[float] | None:
child = _first_child(parent, tag)
if child is None:
if required:
result.add("error", "missing_numeric_vector", f"{tag} is required", path=path)
return None
values = _parse_number_text(str(child.text or ""), expected_len, result, path, tag)
if values is None:
return None
for value in values:
if value <= 0:
result.add("error", "invalid_dimension", f"{tag} values must be positive", path=path)
break
return values
def _validate_positive_number_child(parent: ET.Element, tag: str, result: ValidationResult, path: str) -> float | None:
value = _optional_number_child(parent, tag, result, path, required=True)
if value is not None and value <= 0:
result.add("error", "invalid_dimension", f"{tag} must be positive", path=path)
return value
def _optional_number_child(
parent: ET.Element,
tag: str,
result: ValidationResult,
path: str,
*,
required: bool = False,
allow_infinite: bool = False,
) -> float | None:
child = _first_child(parent, tag)
if child is None or str(child.text or "").strip() == "":
if required:
result.add("error", "missing_number", f"{tag} is required", path=path)
return None
return _parse_optional_number(str(child.text or ""), result, path, tag, required=required, allow_infinite=allow_infinite)
def _parse_optional_number(
value: str,
result: ValidationResult,
path: str,
label: str,
*,
required: bool = False,
allow_infinite: bool = False,
) -> float | None:
value = str(value or "").strip()
if not value:
if required:
result.add("error", "missing_number", f"{label} is required", path=path)
return None
try:
number = float(value)
except ValueError:
result.add("error", "invalid_number", f"{label} must be numeric", path=path)
return None
if allow_infinite:
if math.isnan(number):
result.add("error", "invalid_number", f"{label} must not be NaN", path=path)
return None
elif not math.isfinite(number):
result.add("error", "invalid_number", f"{label} must be finite", path=path)
return None
return number
def _parse_number_text(
text: str,
expected_len: int,
result: ValidationResult,
path: str,
label: str,
) -> list[float] | None:
parts = str(text or "").split()
if len(parts) != expected_len:
result.add("error", "invalid_numeric_vector", f"{label} must contain {expected_len} numeric values", path=path)
return None
values: list[float] = []
for part in parts:
try:
value = float(part)
except ValueError:
result.add("error", "invalid_number", f"{label} contains a non-numeric value {part!r}", path=path)
return None
if not math.isfinite(value):
result.add("error", "invalid_number", f"{label} values must be finite", path=path)
return None
values.append(value)
return values
def _validate_boolean_text(element: ET.Element, result: ValidationResult, path: str, label: str) -> None:
value = str(element.text or "").strip().lower()
if value and value not in BOOLEAN_VALUES:
result.add("error", "invalid_boolean", f"{label} must be boolean-like", path=path)
def _required_name(element: ET.Element, result: ValidationResult, path: str, label: str) -> str:
name = _name(element)
if not name:
result.add("error", "missing_name", f"{label} name is required", path=path)
return name
def _required_child_text(
parent: ET.Element,
tag: str,
result: ValidationResult,
path: str,
label: str,
) -> str:
value = _child_text(parent, tag)
if not value:
result.add("error", "missing_child_text", f"{label} is required", path=path)
return value
def _joint_child_links(joints: list[ET.Element]) -> set[str]:
return {_child_text(joint, "child") for joint in joints if _child_text(joint, "child") and "::" not in _child_text(joint, "child")}
def _joint_parent_links(joints: list[ET.Element]) -> set[str]:
return {_child_text(joint, "parent") for joint in joints if _child_text(joint, "parent") and "::" not in _child_text(joint, "parent")}
def _model_is_static(model_element: ET.Element) -> bool:
value = _child_text(model_element, "static").lower()
return value in TRUE_VALUES
def _inertia_is_psd(values: dict[str, float]) -> bool:
ixx = values["ixx"]
iyy = values["iyy"]
izz = values["izz"]
ixy = values["ixy"]
ixz = values["ixz"]
iyz = values["iyz"]
minor_xy = (ixx * iyy) - (ixy * ixy)
minor_xz = (ixx * izz) - (ixz * ixz)
minor_yz = (iyy * izz) - (iyz * iyz)
det = (ixx * iyy * izz) + (2 * ixy * ixz * iyz) - (ixx * iyz * iyz) - (iyy * ixz * ixz) - (izz * ixy * ixy)
return (
ixx >= -PSD_TOLERANCE
and iyy >= -PSD_TOLERANCE
and izz >= -PSD_TOLERANCE
and minor_xy >= -PSD_TOLERANCE
and minor_xz >= -PSD_TOLERANCE
and minor_yz >= -PSD_TOLERANCE
and det >= -PSD_TOLERANCE
)
def _nontrivial_pose(values: list[float]) -> bool:
return any(abs(value) > POSE_TOLERANCE for value in values)
def _norm(values: list[float]) -> float:
return math.sqrt(sum(value * value for value in values))
def _children(parent: ET.Element, tag_name: str) -> list[ET.Element]:
return [child for child in list(parent) if _local_name(child.tag) == tag_name]
def _first_child(parent: ET.Element, tag_name: str) -> ET.Element | None:
return next(iter(_children(parent, tag_name)), None)
def _child_text(parent: ET.Element, tag_name: str) -> str:
child = _first_child(parent, tag_name)
return str(child.text if child is not None else "").strip()
def _name(element: ET.Element) -> str:
return str(element.attrib.get("name") or "").strip()
def _names(elements: list[ET.Element]) -> list[str]:
return [_name(element) for element in elements if _name(element)]
def _local_name(tag: object) -> str:
return str(tag).rsplit("}", 1)[-1]
def _path(tag: str, element: ET.Element, *, fallback: str | None = None) -> str:
name = _name(element)
if name:
return f"/sdf/{tag}[@name='{name}']"
return fallback or f"/sdf/{tag}"
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-122
View File
@@ -1,122 +0,0 @@
---
name: sendcutsend
description: Review DXF and STEP/STP uploads for SendCutSend.com orders using its ordering guide, catalog, and specs. Use only for SendCutSend.com preflight reports covering upload readiness, selected material/SKU/thickness/service availability, and service-specific checks for laser cutting, CNC routing, bending, tapping, countersinking, hardware insertion, and finishing.
---
# SendCutSend
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill to produce conservative, evidence-backed SendCutSend preflight reports for DXF and STEP/STP files.
Treat SendCutSend's ordering guide, catalog JSON, and specs JSON as evidence feeds, not stable APIs. Field names, types, and coverage may vary. Do not turn missing, unparsable, `N/A`, or conflicting source data into a pass or fail. Fetch sources directly from official URLs and use local inspection code only to measure specific file facts; write the final report from explicit comparisons.
## Geometry Inspection
Use the active project Python environment for local geometry inspection code. If the `$cad` skill is available, use it first for STEP/STP/DXF geometry inspection, measurement, and validation workflows, then add any SendCutSend-specific targeted measurements that are still missing. Use `build123d.import_step` for STEP/STP inspection and `build123d.ezdxf` for DXF inspection when geometry facts are required. Do not use raw text parsing or alternate geometry backends for geometry facts.
## CAD Viewer Handoff
After completing SendCutSend work that creates or modifies a `.dxf`, `.step`, or `.stp` upload candidate, you must ALWAYS hand the explicit file path(s) to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Official Sources
Before each review, fetch and inspect the current SendCutSend source documents directly from the official URLs listed in `references/official-sources.md`:
Use the source URL, access date, and JSON `_meta` values in the source bibliography when helpful. If a source cannot be fetched, report that current SendCutSend sources were unavailable and avoid ready verdicts for dependent checks.
## Workflow
1. Collect order intent.
- Prefer DXF for laser sheet cutting and 2D sheet profiles.
- Prefer STEP/STP for CNC routing and 3D model upload workflows.
- Record file type, intended process, material/SKU, thickness, quantity, services, finish, and hardware.
- If order context is missing or ambiguous, inspect enough source data to present concrete options, then ask the user to confirm before writing a readiness verdict. Include candidate SKUs/materials/thicknesses/services with relevant source links; include `photo_url` images and `learn_more_url` links from the specs JSON when available.
2. Read `references/official-sources.md`, fetch the official sources directly, then inspect the returned documents. Normalize source facts defensively: parse numeric strings, size strings, `N/A`, missing fields, mixed types, and absent service arrays into explicit notes.
3. Inspect the exact upload file with `$cad` when available and with targeted Python/`build123d` for any missing facts. Do not inspect only the source generator, CAD model, or generator console summary.
- DXF: measure units, bounds, layers, entity types, open/duplicate geometry, unsupported annotations, candidate holes/circles, linework stats, bend-line candidates, bend-to-cut distances, bend-adjacent cut geometry, local flange depths, and degenerate zero-area contours as needed for the selected service.
- STEP/STP: measure parseability, units hints, solid/surface signals, bounding box, shell/body signals, validity, sheet thickness where available, cylindrical bend-face radii where bending is in scope, and limitations as needed for the selected service.
- Keep each inspection helper fact-only. It may report measurements, parse errors, and limitations, but it must not emit pass/fail/readiness statuses.
4. Select source records by evidence quality.
- Use exact SKU as the only authoritative catalog/spec join.
- If only material and thickness are provided, use a selected material only when the candidate match is unique and exact enough; otherwise list candidates with links/images from the source records and ask the user to choose.
- Use the catalog JSON for orderability: stock, cutting process, available services, size limits, hardware, and finishes.
- Use the specs JSON for engineering values: tolerances, holes, bridges, bending, tapping, countersinking, hardware insertion, finishing, and material properties.
- Use the ordering guide for plain-language workflow and general file-format rules.
## Comparison
Compare only trustworthy pairs of evidence.
- Determine whether a check applies.
- Cite the source field path or guide section.
- Cite the measured file fact.
- Compare only when both the source requirement and measured file fact are available and trustworthy.
- If a needed measurement is missing or risky, write a small targeted `build123d`/`ezdxf` inspector for that specific geometry fact.
- Treat every measured upload risk, manufacturability issue, or cited requirement violation as an error for now. Do not infer any alternate SendCutSend UI classification.
- For DXF units, inspect `$INSUNITS`, header extents, measured bounds, and order context together. If `$INSUNITS` is missing, unsupported, or not one of the SendCutSend guide's expected DXF unit codes (`1` inches or `4` mm), report a unit/scale error and recommend re-exporting or confirming units before applying size-, flange-, or material-specific comparisons. Do not silently rescale geometry or use an uncertain scale to issue material-specific pass/fail checks.
- For 2D files with bend lines, check flange length locally along every bend line. Measure the nearest cut/free edge on both sides of each bend at each span or sample point, including notches, slots, gaps, split tabs, and cutouts that interrupt the bend span or create a local free edge. Compare the minimum local flange depth to the selected SKU's `bending_specs.min_flange_length_before_bend` and `bending_specs.min_flange_length_after_bend`. Do not apply flange-length limits to ordinary enclosed holes or interior cutouts unless a cited source gives a hole-to-bend or feature-to-bend rule for that service; report those separately with centerline-to-bend or edge-to-bend measurements as the cited rule requires. Do not treat nearby bend-adjacent cut geometry as only corner relief unless the remaining local flange still passes the flange-length minimum. If any local flange depth is below the SKU minimum, report `❌ fail`. Do not rely on aggregate source-level values when exported geometry has local cutouts, interrupted bends, split bend segments, reliefs, tabs, or unsupported regions.
- Keep bend findings separate by physical cause. Do not collapse bend-adjacent geometry into a generic flange failure. Report distinct rows for minimum flange/contact length errors, bend line or die-area geometry crossings, insufficient bend contact/support from nearby free edges or cutouts, bend lines that do not span the bent region, split/common-axis bend segments, and cut geometry touching or crossing bend lines. If a SendCutSend source does not expose the exact die-area/contact threshold, cite the measured file fact as direct file inspection and mark the source-limited comparison explicitly.
- For STEP/STP bent parts, inspect bend radii when the model contains sheet-metal bend geometry or the intended service includes bending. Extract cylindrical or toroidal bend faces and their radii with `$cad`/`build123d`/OCP where possible, group repeated bend radii, and compare them to the selected SKU's `bending_specs.effective_bend_radius` or `bending_specs.bend_radius`. If the selected material/SKU is unknown, report the measured bend-radius set and ask for material/thickness before readiness verdicts. If measured radii conflict with the selected SKU tooling radius, report a bend-radius mismatch error.
Report with restrained status labels:
- `✅ pass`: the measured file fact satisfies the cited current requirement.
- `❌ fail`: a measured upload risk, manufacturability issue, or direct measured violation of a cited current requirement.
- `❓ need more info`: missing context, missing source evidence, unmeasured geometry, source conflicts, or tool limitations.
## Diagnostic Images
When findings would be easier to understand visually, produce a concise diagnostic diagram proactively if image-generation or image-editing capabilities are available. Use generated or edited images for callouts, legends, and before/after explanations. Do this without waiting for the user to ask whenever there is a `❌ fail`, a spatially ambiguous geometry issue, or a geometry edit that needs a before/after explanation. If image-generation tools are unavailable, state that limitation and describe the intended diagram in the report.
Before generating an image, run a layout preflight:
- Choose the smallest set of callouts needed to explain the fix.
- Estimate whether labels will crowd the geometry, overlap each other, or run outside the canvas. If crowding is likely, flag it before generation and switch to numbered markers plus a side legend, a larger canvas, or separate detail views.
- Keep long measured values and rule text in the legend, not directly over dense geometry.
- Include the measured failing distance, the cited minimum, and the proposed movement or clearance target.
After generating an image, inspect the rendered image before delivery. If labels overlap, are clipped, are hard to read, or obscure the geometry, regenerate or revise the diagram before reporting it.
## DXF Review
For laser sheet cutting, start from the refreshed sources and measured DXF geometry facts.
Check for:
- single, uploadable DXF file with model geometry at 1:1 scale
- units and overall part size; treat missing, unsupported, or unexpected `$INSUNITS` as a scale error until the user confirms units
- closed cut profiles where the service requires closed contours
- degenerate or zero-area closed contours, two-point closed polylines, and odd-degree cut endpoints
- duplicate or overlapping cut geometry
- unsupported annotation, text, dimensions, images, construction lines, or hidden instruction layers
- layer/color/linework conventions from the ordering guide and upload workflow
- bend-line entities, bend segment lengths, split/common-axis bends, local flange depth on both sides of each bend line, nearest non-bend cut edge or cutout distances, bend-line span coverage, insufficient bend contact/support, die-area or bend-adjacent cut geometry crossings, and cut geometry touching or crossing bend lines when bending is in scope
- minimum holes, slots, web widths, interior geometry, part density, nesting, and spacing only when both source facts and measured file facts support a comparison
- secondary-service requirements for bending, tapping, countersinking, hardware, finishing, or deburring when requested
## STEP Review
For CNC routing or 3D model upload, start from the refreshed sources and measured STEP geometry facts.
Check for:
- STEP/STP file readability and a solid body rather than loose curves or surfaces
- units, scale, bounding box, thickness, and feature dimensions
- sheet-metal bend radii when bending is in scope; compare measured cylindrical bend-face radii to the selected SKU's `bending_specs.effective_bend_radius` or `bending_specs.bend_radius`
- sharp inside corners, small holes/slots, thin walls, islands, deep pockets, tool access, and tolerances only when the file inspection can measure the fact
- whether geometry represents a sheet profile better served as DXF for laser cutting
- material, thickness, finish, and secondary-service compatibility
## Reporting
Include the file path, assumed service, material/order context, source files checked with access date, inspected geometry facts, findings ordered by practical impact, and specific next edits. In the findings table, include a `Rule source` column with Markdown links to the source URL plus the specific JSON field path or guide section used for that row. If a row is based only on direct file inspection and has no external rule, say `Direct file inspection`; do not leave the source blank. Do not call a file "SendCutSend ready" unless every required cited check either passes or is explicitly outside the selected service.
Use `references/report-template.md` when a structured report would help.
## References
- Official source selection: `references/official-sources.md`
- Report shape: `references/report-template.md`
@@ -1,4 +0,0 @@
interface:
display_name: "SendCutSend"
short_description: "Review DXF/STEP against SendCutSend specs."
default_prompt: "Use $sendcutsend to review this DXF or STEP file for SendCutSend upload readiness."
@@ -1,40 +0,0 @@
# Official SendCutSend Source Map
Use these sources as live evidence, not as a stable API. Field coverage, value types, and `N/A` usage can vary. Before making pass/fail claims, cite the exact source URL and, for JSON facts, the field path used.
## Core Entry Points
- Ordering guide: https://cdn.sendcutsend.com/specs/sendcutsend-ordering-guide.md
- Catalog JSON: https://cdn.sendcutsend.com/specs/sendcutsend-catalog.json
- Engineering specs JSON: https://cdn.sendcutsend.com/specs/sendcutsend-specs.json
Fetch these URLs directly before each review. Use the current response bodies as the evidence for material/service checks, and cite the URL plus the access date in the report.
## Source Roles
- Ordering guide: use for ordering flow, accepted file formats, plain-language design rules, and general service explanations.
- Catalog JSON: use for orderability facts such as material SKUs, material names, thicknesses, stock status, cutting process, min/max part size, available services, hardware items, and finish options.
- Engineering specs JSON: use for SKU-keyed design validation facts such as tolerances, minimum hole/bridge/edge values, bending parameters, tapping, countersinking, hardware insertion, dimple forming, finishing constraints, and material properties.
## Provenance To Capture
- Source URL
- Access date
- JSON `_meta.schema_version`
- JSON `_meta.generated_at`
- JSON `_meta.source_data_generated_at` when present
- Field path for row-level citations, such as `sendcutsend-specs.json materials[sku=ALU-063].cutting_specs.min_hole_size`
## Conflict Handling
Prefer exact SKU joins across catalog and specs. If source facts conflict or a field is missing, unparsable, or `N/A`, report the uncertainty and mark the dependent row `❓ need more info` unless another more specific, cited source resolves it.
Use this precedence for source facts:
1. material/thickness/service-specific configurator or current quote/upload result
2. exact SKU entry in engineering specs JSON
3. exact SKU entry in catalog JSON
4. ordering guide
5. broader official SendCutSend human-readable page, only when the three source files are insufficient
Record the conflict and the page you relied on.
@@ -1,42 +0,0 @@
# SendCutSend Validation Report Template
Use this structure when the user asks for a review, validation, preflight, or manufacturing readiness report.
## Context
- File: `<path>`
- Assumed service: `DXF laser sheet cutting` or `STEP CNC routing`
- Order context: material, thickness, primary process, finish, quantity, secondary operations
- Date checked: `<YYYY-MM-DD>`
## Sources Checked
List official SendCutSend URLs used for the checks as Markdown links. Include only sources actually consulted. Include the access date and JSON `_meta.generated_at` values when available. The findings table must also link the specific rule source for each row, so this section is a bibliography, not a substitute for row-level citations.
## Geometry Facts
Summarize facts from `$cad` inspection when available and targeted `build123d`/`ezdxf` inspection code: file type, units, extents/bounding box, entity/body counts, layers, unsupported entities, obvious open/duplicate geometry, and parsing limitations.
If the reviewed upload was generated or updated in this workflow, include the `$cad-viewer` viewer link when available.
For bent 2D files, include bend geometry facts from the exported file: bend-line count and layers, individual bend line lengths, nearest non-bend cut geometry for each bend line, local flange depths, bend-line span coverage, bend-adjacent cut geometry, insufficient contact/support observations, and any split/interrupted/common-axis bend observations. Use those facts for comparison against the current material/thickness service page.
For bent STEP/STP files, include measured sheet thickness when available and the extracted bend-radius set. Compare bend radii to the selected material/SKU only when that order context is known.
## Findings
Use one row per issue. In `Rule source`, link the specific official page or JSON file that defines the requirement being checked, and include the field path when the source is JSON. Example: `[sendcutsend-specs.json](https://cdn.sendcutsend.com/specs/sendcutsend-specs.json) materials[sku=ALU-063].cutting_specs.min_hole_size`. Prefer exact SKU-specific sources over generic guide text. If no external rule applies, write `Direct file inspection`.
| Status | Check | Evidence | Rule source | Recommendation |
| --- | --- | --- | --- | --- |
| ✅ pass / ❌ fail / ❓ need more info | requirement name | file fact | Markdown link to specific rule doc/table, or `Direct file inspection` | concrete next action |
## Verdict
Use a limited verdict:
- `Ready to upload for this assumed context`
- `Needs edits before upload`
- `Insufficient context to validate`
Never use a ready verdict if any required current official requirement check is marked `❌ fail` or `❓ need more info`. Use `Ready to upload for this assumed context` only when every required cited check has sufficient order context, source evidence, measured file facts, and passes or is explicitly outside the selected service. Until an explicit SendCutSend UI classification model is deliberately added, treat measured upload risks and manufacturability issues as `❌ fail`.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-77
View File
@@ -1,77 +0,0 @@
---
name: srdf
description: MoveIt2 SRDF generation, validation, and planning-semantics workflow. Use when creating, editing, regenerating, inspecting, or validating `.srdf` files, `gen_srdf()` sources, MoveIt planning groups, virtual joints, passive joints, end effectors, group states, disabled collisions, URDF-linked planning semantics, or SRDF handoff for live review. Use the URDF skill for robot structure, the SDF skill for simulator descriptions, and the cad-viewer skill for rendering, live review links, and optional MoveIt2 controls.
---
# SRDF
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill for MoveIt semantic robot descriptions on top of an existing valid URDF. SRDF defines planning semantics; it does not define physical robot structure.
SRDF correctness is a **planning semantics** problem. The common failure is not invalid XML; it is a plausible SRDF that gives MoveIt the wrong planning group, wrong tool link, wrong default state, unsafe disabled-collision matrix, or wrong joint units. Because language models are weak at spatial and kinematic reasoning, derive planning groups, end effectors, group states, and disabled collisions from the URDF topology, MoveIt Setup Assistant output, sampled collision analysis, or explicit user data. Do not infer them from visual appearance alone.
## Format boundary
- **URDF** owns physical robot structure: links, joints, geometry, inertials, limits, mimic joints, transmissions, and robot-state publishing.
- **SRDF** owns MoveIt semantics: virtual joints, passive joints, planning groups, group states, end effectors, and disabled collision pairs.
- **SDF** owns simulator/world semantics: physics, sensors, lights, plugins, worlds, and simulation-specific metadata.
Do not place geometry, inertials, joint origins, link poses, mesh references, physical joint limits, transmissions, or `ros2_control` interfaces in SRDF.
## CAD Viewer Handoff
After completing SRDF work that creates or modifies a `.srdf`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); include optional MoveIt2 controls in the handoff only when the user needs interactive IK or path-planning review. If `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Required workflow
1. **Start from a valid URDF.** Generate or fix the URDF first. The SRDF generator validates against the source-relative `.urdf` path supplied by `gen_srdf()`.
2. **Identify the planning task.** Record whether the goal is arm IK, gripper control, mobile base planning, dual-arm planning, tool use, or local smoke testing.
3. **Create or update the planning ledger.** Use `references/planning-ledger.md` before writing XML.
4. **Define virtual and passive joints deliberately.** Use them when needed by the robot model, even though the current lightweight runtime does not fully inventory them yet.
5. **Define planning groups from URDF topology.** Prefer chain groups for serial manipulators when base/tip form a real path. Use joint/link/subgroup definitions only when they are deliberate.
6. **Define end effectors after group membership is known.** Avoid overlap between an end-effector group and its parent group. Record the actual target/TCP link.
7. **Define group states in URDF-native units.** Revolute and continuous values are radians; prismatic values are meters. Do not store degrees in SRDF.
8. **Generate disabled collisions from evidence.** Use adjacency, MoveIt Setup Assistant sampling, or explicit user-provided collision matrices. Do not invent broad disable lists.
9. **Regenerate only explicit SRDF targets.** Generation validates the generated SRDF against the linked URDF before writing.
10. **Run MoveIt smoke tests when available.** Use MoveIt Setup Assistant or a project MoveIt launch directly.
11. **Report assumptions and skipped checks.** Include incomplete validation, missing MoveIt environment, manually reasoned collision disables, and inferred target links.
## Commands
Run with the Python environment for the project or workspace. Treat `python` in examples as an interpreter placeholder; if bare `python` is unavailable, substitute `python3`, a project virtualenv interpreter, or the configured interpreter path.
From this skill directory, the SRDF launcher shape is:
```bash
python scripts/srdf path/to/source.py
python scripts/srdf path/to/source.py -o path/to/robot.srdf
python scripts/srdf path/to/a.py=out/a.srdf path/to/b.py=out/b.srdf
```
Relative source targets and CLI output overrides are resolved from the current working directory. When running from outside this skill directory, prefix the launcher path so target files still resolve from the intended workspace.
## Hard rules
- SRDF must reference an existing valid URDF.
- The SRDF robot name must match the URDF robot name.
- Group states use URDF-native units: radians for revolute/continuous, meters for prismatic.
- Disabled collision pairs require truthful reasons and provenance.
- End-effector groups should not share links with their parent planning group.
- `$cad-viewer` owns optional local `moveit2_server` guidance for interactive planning review.
- Visual rendering review is useful but cannot prove planning correctness.
## References
- Generation command: `references/gen-srdf.md`
- Generator contract: `references/generator-contract.md`
- SRDF workflow: `references/srdf-workflow.md`
- Planning ledger: `references/planning-ledger.md`
- Validation scope: `references/validation.md`
- End effectors: `references/end-effectors.md`
- Disabled collisions: `references/disabled-collisions.md`
- Runtime notes and current limitations: `references/implementation-notes.md`
For local MoveIt2 controls, use `$cad-viewer`; in that skill, read `references/moveit2-server.md`.
@@ -1,4 +0,0 @@
interface:
display_name: "SRDF"
short_description: "Generate and validate MoveIt2 SRDF semantics."
default_prompt: "Use $srdf to generate and validate MoveIt2 SRDF files, then hand generated SRDFs to $cad-viewer for review and optional MoveIt2 controls."
@@ -1,55 +0,0 @@
# SRDF disabled collisions
Disabled collisions are planning-safety data. Treat them as derived evidence, not as decorative XML.
## Valid sources
Use one of these sources:
- adjacent-link policy from the URDF kinematic graph;
- MoveIt Setup Assistant self-collision matrix generation;
- sampled collision analysis from a known MoveIt configuration;
- explicit user-provided collision matrix;
- a manually reviewed pair with a specific rationale.
Do not infer disabled collision pairs from visual appearance or vague prose.
## XML shape
```xml
<disable_collisions link1="base_link" link2="shoulder_link" reason="Adjacent"/>
```
The current runtime requires:
- `link1` and `link2`;
- both links to exist in the URDF;
- distinct link names;
- a non-empty `reason`;
- no duplicate or reversed duplicate pairs.
## Reason and provenance
Use truthful reasons. Examples:
| Reason | Typical source |
|---|---|
| `Adjacent` | URDF graph adjacency |
| `Never` | Setup Assistant sampled matrix |
| `Always` | Setup Assistant sampled matrix |
| `Default` | Setup Assistant sampled matrix |
| `Manual: tool fixture is outside workspace envelope` | Explicit human review |
The current parser classifies reasons into broad provenance buckets such as adjacent, sampled, setup assistant, manual, or assumed. Avoid `assumed` unless the user explicitly requested a provisional SRDF and the risk is reported.
## Review checklist
Before committing a disabled collision pair:
- Is the pair adjacent or sampled-safe?
- Does disabling the pair hide a possible real collision during the planned task?
- Was the pair generated with sufficient sampling density?
- Is the pair still valid after geometry, limits, or group membership changed?
- Was manual rationale written down?
If many manual pairs are present, prefer regenerating the self-collision matrix with MoveIt Setup Assistant.
@@ -1,73 +0,0 @@
# SRDF end effectors
Use this reference when creating or editing `<end_effector>` entries or preparing MoveIt2 pose-target handoffs for `$cad-viewer`.
## Concept
An end effector is a semantic designation for a tool, gripper, sensor head, or other terminal group. It is typically connected to a parent planning group through a fixed joint or attachment link.
Typical shape:
```xml
<group name="gripper">
<joint name="finger_joint"/>
</group>
<end_effector
name="gripper_eef"
parent_link="tool0"
group="gripper"
parent_group="manipulator"/>
```
## Required ledger fields
Record:
- end-effector name;
- end-effector group;
- parent planning group;
- parent link where the end effector attaches;
- target/TCP link used for IK and planning;
- whether the end-effector group overlaps the parent group;
- whether the parent link is adjacent to the end-effector group in the URDF graph.
## Checks
Before generation:
- The end-effector group exists.
- The parent group exists when specified.
- The parent link exists in the URDF.
- The end-effector group and parent group do not share links.
- The parent link is in the parent group or adjacent to the end-effector group.
- The target/TCP link is explicit when it differs from the inferred group tip.
The current runtime enforces several of these checks, but target/TCP choice remains a semantic decision. Do not rely on inference when planning to a tool center point.
## CAD Viewer MoveIt2 target link
When handing an SRDF to `$cad-viewer` for optional MoveIt2 controls, make the intended target link explicit when possible:
```json
{
"protocolVersion": 1,
"type": "srdf.solvePose",
"payload": {
"file": "robot.srdf",
"target": {
"endEffector": "gripper_eef",
"targetLink": "tool0",
"frame": "base_link",
"xyz": [0.4, 0.0, 0.2],
"quat_xyzw": [0, 0, 0, 1]
},
"moveit2": {
"planningGroup": "manipulator",
"targetLink": "tool0"
}
}
}
```
Use position-only IK only when orientation is intentionally unconstrained. CAD Viewer owns the local MoveIt2 server startup and protocol details.
@@ -1,34 +0,0 @@
# SRDF generation command
Regenerate explicit MoveIt SRDF outputs from Python sources with top-level `gen_srdf()` functions.
```bash
python scripts/srdf path/to/semantic.py
python scripts/srdf path/to/semantic.py -o path/to/robot.srdf
python scripts/srdf path/to/a.py=out/a.srdf path/to/b.py=out/b.srdf
```
Plain Python targets write a sibling `.srdf` beside the source. `-o` / `--output` is valid only with one plain target. Use `SOURCE.py=OUTPUT.srdf` pairs for custom multi-target destinations.
`gen_srdf()` must return an envelope dictionary containing:
- `xml`: complete SRDF `<robot>` XML as an `xml.etree.ElementTree.Element` or XML string;
- `urdf`: a POSIX relative path from the generator source file to the linked `.urdf`.
## What the command does
The tool:
1. imports the target Python source;
2. calls `gen_srdf()`;
3. resolves the linked URDF path relative to the generator source file;
4. injects or updates local `tcad:urdf` metadata so downstream tools can find the URDF;
5. parses the generated SRDF;
6. validates the SRDF against the linked URDF;
7. writes the requested `.srdf` only after validation passes.
There is no hidden SRDF artifact. The generated `.srdf` is the MoveIt inventory handed to `$cad-viewer` for a live CAD Viewer link and optional MoveIt2 controls.
## Execution safety
The CLI imports generator modules directly. Top-level Python code in the generator file will execute. Use this command only for trusted project sources.
@@ -1,126 +0,0 @@
# SRDF generator contract
Define a top-level zero-argument `gen_srdf()` function in a Python source file. It must return an envelope dictionary with the root SRDF XML and the owning URDF path.
```python
import xml.etree.ElementTree as ET
def gen_srdf():
robot = ET.Element("robot", {"name": "sample_robot"})
group = ET.SubElement(robot, "group", {"name": "manipulator"})
ET.SubElement(group, "chain", {"base_link": "base_link", "tip_link": "tool0"})
return {
"xml": robot,
"urdf": "sample_robot.urdf",
}
```
## Required envelope
```python
{
"xml": robot_root_element_or_xml_string,
"urdf": "relative/path/to/robot.urdf",
}
```
The current runtime accepts only `xml` and `urdf` fields. Do not add unsupported fields.
## XML root
The XML root must be:
```xml
<robot name="...">
```
The SRDF `robot` name must match the linked URDF robot name.
## URDF path
The `urdf` field is resolved relative to the generator source file. It must:
- be a non-empty string;
- use POSIX `/` separators;
- be relative, not absolute;
- end in `.urdf`;
- refer to an existing file.
The CLI injects or updates:
```xml
<tcad:urdf path="..."/>
```
The `tcad` prefix is emitted with the `https://text-to-cad.dev/srdf` namespace. The injected path is relative from the generated `.srdf` location to the linked URDF. This metadata is a local SRDF convention for downstream tools; it is not a core SRDF semantic element. Readers also accept existing legacy `<explorer:urdf/>` metadata.
## Supported target forms
```bash
python scripts/srdf path/to/source.py
python scripts/srdf path/to/source.py -o path/to/robot.srdf
python scripts/srdf a.py=out/a.srdf b.py=out/b.srdf
```
The generated output path is selected by the CLI. The generator should not write the `.srdf` file itself.
## Group-state units
Group-state joint values are URDF-native values:
- revolute and continuous joints: radians;
- prismatic joints: meters.
Do not store degrees in SRDF. Legacy UI/protocol degree fields are compatibility aliases and must be converted by joint type.
## Example with common semantic elements
```python
import xml.etree.ElementTree as ET
def gen_srdf():
robot = ET.Element("robot", {"name": "sample_robot"})
ET.SubElement(
robot,
"virtual_joint",
{
"name": "fixed_base",
"type": "fixed",
"parent_frame": "world",
"child_link": "base_link",
},
)
arm = ET.SubElement(robot, "group", {"name": "manipulator"})
ET.SubElement(arm, "chain", {"base_link": "base_link", "tip_link": "tool0"})
gripper = ET.SubElement(robot, "group", {"name": "gripper"})
ET.SubElement(gripper, "joint", {"name": "finger_joint"})
ET.SubElement(
robot,
"end_effector",
{
"name": "gripper_eef",
"parent_link": "tool0",
"group": "gripper",
"parent_group": "manipulator",
},
)
home = ET.SubElement(robot, "group_state", {"name": "home", "group": "manipulator"})
ET.SubElement(home, "joint", {"name": "shoulder_pan_joint", "value": "0.0"})
ET.SubElement(
robot,
"disable_collisions",
{"link1": "base_link", "link2": "shoulder_link", "reason": "Adjacent"},
)
return {"xml": robot, "urdf": "sample_robot.urdf"}
```
Virtual and passive joints are valid SRDF concepts. The current lightweight runtime preserves them but does not yet fully inventory or validate them; verify them with MoveIt Setup Assistant or a MoveIt smoke test.
@@ -1,37 +0,0 @@
# SRDF implementation notes
These notes describe the current runtime shape so the documentation does not overstate what the code enforces.
## Implemented in the current code
- `scripts/srdf` generates explicit targets only.
- `gen_srdf()` must return an envelope with `xml` and `urdf`.
- Generated SRDF is validated against the linked URDF before writing.
- The CLI injects or updates local `tcad:urdf` metadata.
- Group states use `joint_values_by_name` / `jointValuesByName` in URDF-native units.
- Deprecated `joint_values_by_name_rad` / `jointValuesByNameRad` aliases remain for compatibility.
- Group-state values are checked for group membership, finite values, fixed/mimic status, and URDF limits when available.
- End-effector overlap and adjacency checks are implemented.
- Disabled-collision reasons are required and classified into broad provenance buckets.
- Optional CAD Viewer MoveIt2 controls use `protocolVersion: 1`.
- Pose targets support `quat_xyzw` and `rpy`.
- Position-only IK is explicit in request settings and defaults based on whether orientation is provided.
- Legacy degree fields are converted by joint type so prismatic values remain linear.
- Error responses sanitize absolute paths unless debug errors are enabled.
## Not yet fully implemented in code
The following remain process requirements and future code-improvement targets:
- full URDF structural validation shared with the URDF skill;
- virtual joint parsing and validation;
- passive joint parsing and exclusion from active planning variables;
- hard failure for disconnected chain base/tip definitions in every case;
- subgroup cycle detection as a first-class validation error;
- typed SRDF generation helpers;
- structured assumption/warning envelope fields;
- sampled self-collision matrix generation;
- full MoveIt configuration package generation;
- safer generator execution in a subprocess.
Until those are implemented, rely on the planning ledger, MoveIt Setup Assistant, `$cad-viewer` handoff for visual/MoveIt smoke tests when available, and explicit reporting of skipped checks.
@@ -1,102 +0,0 @@
# SRDF planning ledger
Create or update this ledger before writing SRDF XML. The ledger makes planning assumptions explicit and helps prevent plausible but incorrect MoveIt configurations.
## URDF dependency
| Field | Value |
|---|---|
| URDF path | |
| SRDF output path | |
| Robot name | |
| URDF validated? | yes/no; tool/check |
| Root link | |
| Active joints | |
| Fixed joints | |
| Mimic joints | |
| Passive joints | |
| Links used for collision checking | |
| Known URDF limitations | |
## Planning task
| Field | Value |
|---|---|
| Main task | IK / plan-to-pose / gripper / mobile base / dual arm / other |
| Primary planning group | |
| Expected end-effector or TCP | |
| Required solver or planner | |
| Position-only IK? | yes/no; reason |
| Orientation constraints? | yes/no; representation |
## Virtual joints
| Name | Type | Parent frame | Child link | Required? | Rationale |
|---|---|---|---|---|---|
| | fixed / planar / floating | | | | |
Virtual joints describe the robot root pose relative to an external frame. Use fixed for fixed-base manipulators when the planning setup needs a world attachment; use planar/floating only when the robot model requires that planning freedom.
## Passive joints
| Joint | URDF type | Reason passive | Affected groups | Notes |
|---|---|---|---|---|
| | | | | |
Passive joints are unactuated. They should not be treated as controllable planning variables.
## Planning groups
| Group | Representation | Members | Base link | Tip link | Active joints | Excluded joints | Purpose | Solver expectation |
|---|---|---|---|---|---|---|---|---|
| | joints / links / chain / subgroups | | | | | | | |
For serial arms, prefer a chain only when the URDF graph has a real path from base link to tip link. For subgroup groups, check for cycles and duplicate semantics.
## End effectors
| Name | End-effector group | Parent group | Parent link | Target/TCP link | Overlap checked? | Adjacent? | Notes |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
The end-effector group should normally not share links with its parent group. The target/TCP link should be explicit when it differs from the inferred group tip.
## Group states
| State | Group | Joint values | Unit check | Limit check | Purpose |
|---|---|---|---|---|---|
| | | revolute/continuous rad; prismatic m | | | |
Do not store degrees in SRDF group states. Do not set fixed or mimic joints in group states.
## Disabled collisions
| Link 1 | Link 2 | Reason | Source | Evidence | Risk note |
|---|---|---|---|---|---|
| | | Adjacent / Never / Always / Default / Manual | Setup Assistant / sampled / adjacency / user | | |
Do not infer disabled collisions from visual impression. Each pair needs a reason and provenance.
## MoveIt smoke tests
| Test | Group | Target link | Target pose/state | Expected result | Actual result | Notes |
|---|---|---|---|---|---|---|
| IK solve | | | | | | |
| Plan-to-pose | | | | | | |
| Named state | | | | | | |
| Collision check | | | | | | |
## Assumptions to report
List every guessed or inferred value:
- planning group membership;
- chain base/tip;
- target/TCP link;
- virtual joint attachment;
- passive joint classification;
- group-state value;
- disabled collision pair;
- solver or planner setting;
- orientation/position-only IK assumption;
- skipped MoveIt validation.
@@ -1,76 +0,0 @@
# SRDF workflow
SRDF is the MoveIt semantic companion to a URDF. Keep physical robot structure, links, joints, geometry, inertials, mesh references, and limits in URDF. Keep planning semantics in SRDF.
## Edit loop
1. Start from a valid URDF. SRDF cannot repair wrong link frames, joint origins, limits, or geometry.
2. Find the Python source that defines `gen_srdf()`.
3. Treat that source as authoritative. Do not hand-edit generated `.srdf` output.
4. Fill or update the planning ledger.
5. Define virtual joints when the robot root needs a planning/world attachment.
6. Define passive joints for unactuated joints.
7. Define planning groups from URDF topology.
8. Define end effectors only after group membership is known.
9. Define group states in URDF-native units and check them against URDF limits.
10. Define disabled collisions only from adjacency, sampling, Setup Assistant output, or explicit user evidence.
11. Regenerate only the explicit SRDF target with `scripts/srdf`.
12. Hand generated or modified `.srdf` files to `$cad-viewer` for live viewer links when available.
13. Run MoveIt smoke tests when available. Use `$cad-viewer` for local Viewer-based IK or path-planning controls.
14. Report assumptions and skipped checks.
## Typical SRDF content
- `<virtual_joint>` entries for root/world attachment.
- `<passive_joint>` entries for unactuated joints.
- `<group>` planning groups, usually by joint list or chain.
- `<end_effector>` entries that connect a tool group to a parent planning group.
- `<group_state>` named joint states such as `home`.
- `<disable_collisions>` pairs for adjacent, sampled-safe, or intentionally ignored collisions.
## Planning groups
MoveIt acts on a selected planning group. Other joints are left stationary unless they belong to the selected group or are otherwise managed by the planning pipeline.
A group may be represented as:
- a collection of joints;
- a collection of links;
- a serial chain from base link to tip link;
- a collection of subgroups.
For serial chains, the base link is the parent link of the first joint in the chain, and the tip link is the child link of the last joint. Verify that the URDF graph actually contains that path.
## Group states
`<group_state>` values are stored in URDF-native units:
- revolute and continuous joints: radians;
- prismatic joints: meters.
The current runtime validates group-state values against group membership, fixed/mimic status, finite numeric values, and URDF limits when limits are available.
## End effectors
An end-effector is usually a separate tool or gripper group attached to a parent planning group. Avoid overlap between the end-effector group and the parent group. Record the target/TCP link in the planning ledger and in MoveIt2 request settings when it differs from the inferred group tip.
## Disabled collisions
`<disable_collisions>` pairs affect planning safety. Use truthful reasons:
- `Adjacent`;
- `Never`;
- `Always`;
- `Default`;
- `Setup Assistant sampled`;
- explicit manual rationale.
Do not generate broad disabled-collision lists from prose or visual appearance.
## CAD Viewer handoff and MoveIt2 controls
After creating or modifying generated `.srdf` files, hand the explicit output path to `$cad-viewer` for a live viewer link when that skill is available. SRDF does not own Viewer startup.
When the user needs local IK or path-planning controls, include that in the `$cad-viewer` handoff. CAD Viewer owns the local `moveit2_server`, including setup, environment checks, WebSocket URL wiring, and protocol details. Provide the SRDF path plus any known planning group, target/TCP link, target frame, pose, start state, and skipped assumptions.
The local server is a smoke-test helper, not a replacement for a full MoveIt configuration package.
@@ -1,117 +0,0 @@
# SRDF validation
Generation validates every `gen_srdf()` result against its linked URDF before writing. This catches many planning-semantics errors, but it is not a substitute for MoveIt Setup Assistant, MoveIt runtime tests, or collision-matrix sampling.
Generated or modified `.srdf` files should be handed to `$cad-viewer` for live viewer links when available.
## Current generation-time checks
The current runtime checks that:
- `gen_srdf()` returns an envelope dictionary;
- envelope fields are exactly `xml` and `urdf`;
- the URDF path is relative to the generator source, uses POSIX separators, ends in `.urdf`, and exists;
- SRDF XML parses and has root `<robot name="...">`;
- SRDF robot name matches the URDF robot name;
- local `tcad:urdf` metadata is injected or updated and matches the linked URDF path relative to the generated SRDF; legacy `explorer:urdf` metadata remains readable;
- at least one planning group exists;
- planning groups are named and unique;
- each planning group defines joints, links, chains, or subgroups;
- group joint names, link names, and subgroup names exist;
- chain base/tip links exist;
- end effectors reference existing parent links and groups;
- end-effector groups do not overlap their parent group when a parent group is provided;
- end-effector parent links are in the parent group or adjacent to the end-effector group;
- group states reference existing groups;
- group-state joints exist and belong to the selected group after group expansion;
- group-state values are finite;
- group states do not set fixed or mimic joints;
- revolute/prismatic group-state values are within URDF limits when limits are available;
- disabled collision pairs have distinct links, valid link references, non-empty reasons, and no reversed duplicates;
- many manually reasoned disabled collision pairs trigger a warning.
## Optional CAD Viewer MoveIt2 checks
When `$cad-viewer` starts its local MoveIt2 server for SRDF review, the server additionally checks:
- request `protocolVersion`;
- request type;
- repository-relative SRDF path and traversal safety;
- linked URDF path from `tcad:urdf` metadata;
- planning group selection;
- target frame and target link existence;
- target orientation shape and quaternion normalization;
- position-only IK consistency;
- native joint value parsing;
- legacy degree-field conversion by joint type;
- planner and IK setting basic numeric validity.
## Important current limitations
Do not treat a passing validation as proof of planning correctness. The current lightweight runtime does not fully validate:
- full URDF graph consistency; duplicate URDF links or joints can be collapsed by shallow inventory parsing;
- chain base/tip connectivity at generation time unless group-state expansion exposes the issue;
- subgroup cycles as hard errors in every path;
- `<virtual_joint>` inventory and validation;
- `<passive_joint>` inventory and exclusion from all active groups;
- collision matrix correctness through sampled self-collision analysis;
- actual IK solver availability for each group;
- actual planning success in the target MoveIt environment;
- controller configuration;
- full orientation-constraint behavior beyond request normalization.
Use MoveIt Setup Assistant or a MoveIt runtime smoke test for these checks.
## Manual planning checks
After generation, verify:
### URDF dependency
- URDF has been checked by the URDF workflow.
- URDF collision geometry is suitable for MoveIt collision checking.
- Active, fixed, mimic, and passive joints are understood.
### Planning groups
- Each chain has a real URDF path from base to tip.
- Each group contains the intended active joints and no accidental fixed/mimic/passive joints.
- Subgroups do not create ambiguous or cyclic group definitions.
- The selected planner/IK solver supports the group.
### End effectors
- End-effector group and parent group do not share links.
- Parent link is the real attachment point.
- Target/TCP link is explicit for pose requests.
### Group states
- Values use radians/meters, not degrees.
- Values are inside URDF limits.
- Named states are collision-free when intended.
### Disabled collisions
- Pairs came from adjacency, sampling, Setup Assistant output, or explicit user evidence.
- No broad disable list was invented by the model.
- Manual pairs were reviewed for safety.
## Validation report format
Use a compact report:
```text
Checks run:
- SRDF generation validation: passed
- linked URDF validation: previously passed with URDF skill
- CAD Viewer link: returned
- MoveIt Setup Assistant review: skipped, unavailable
- CAD Viewer MoveIt2 IK smoke test: passed for manipulator/tool0
- collision matrix sampling: skipped, no MoveIt environment
Assumptions:
- Assumed tool0 is the desired TCP.
- Disabled collisions are adjacency-only.
```
-1
View File
@@ -1 +0,0 @@
--editable ./scripts/packages/cadpy_metadata
@@ -1 +0,0 @@
../../../../packages/cadpy_metadata
@@ -1 +0,0 @@
"""SRDF generation and validation helpers."""
@@ -1,16 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
if __package__ in {None, ""}:
scripts_dir = Path(__file__).resolve().parents[1]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from srdf.cli import main
else:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
-671
View File
@@ -1,671 +0,0 @@
from __future__ import annotations
import argparse
import importlib.util
import inspect
from math import isfinite
import os
import sys
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
import xml.etree.ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if __package__ in {None, ""}:
sys.path.insert(0, str(SCRIPTS_DIR))
PACKAGES_DIR = SCRIPTS_DIR / "packages"
CADPY_METADATA_SRC_DIR = PACKAGES_DIR / "cadpy_metadata" / "src"
if str(PACKAGES_DIR) not in sys.path:
sys.path.insert(0, str(PACKAGES_DIR))
if str(CADPY_METADATA_SRC_DIR) not in sys.path:
sys.path.insert(0, str(CADPY_METADATA_SRC_DIR))
from cadpy_metadata import (
GenerationOutput,
python_source_identity,
track_generation_run,
xml_with_text_to_cad_metadata,
)
from srdf.source import LEGACY_EXPLORER_NAMESPACE, SRDF_METADATA_NAMESPACE, SrdfSource, SrdfSourceError, parse_srdf_xml
@dataclass(frozen=True)
class _TargetSpec:
source_path: Path
output_path: Path
def generate_srdf_targets(targets: Sequence[str], *, output: str | Path | None = None) -> int:
target_specs = _resolve_target_specs(targets, output=output)
_validate_unique_outputs(target_specs)
for target_spec in target_specs:
_generate_target(target_spec.source_path, output_path=target_spec.output_path)
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="srdf",
description="Generate explicit MoveIt2 SRDF targets from Python sources.",
)
parser.add_argument(
"targets",
nargs="+",
help="Explicit Python source file or SOURCE.py=OUTPUT.srdf pair defining gen_srdf() to generate.",
)
parser.add_argument(
"-o",
"--output",
metavar="PATH",
help="Write the generated SRDF file to this path. Valid only with one plain Python target.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
if args.output is not None:
if _targets_include_output_pairs(args.targets):
parser.error("--output cannot be combined with SOURCE=OUTPUT targets")
if len(args.targets) != 1:
parser.error("--output can only be used with exactly one target")
return generate_srdf_targets(args.targets, output=args.output)
def _resolve_target_specs(targets: Sequence[str], *, output: str | Path | None = None) -> list[_TargetSpec]:
if output is not None and _targets_include_output_pairs(targets):
raise ValueError("srdf --output cannot be combined with SOURCE=OUTPUT targets")
if output is not None and len(targets) != 1:
raise ValueError("srdf --output can only be used with exactly one target")
specs: list[_TargetSpec] = []
for raw_target in targets:
target_text = str(raw_target or "").strip()
if "=" in target_text:
raw_source, raw_output = target_text.split("=", 1)
source_path = _resolve_source_path(raw_source)
output_path = _resolve_cli_output_path(raw_output)
else:
source_path = _resolve_source_path(target_text)
output_path = _resolve_cli_output_path(output) if output is not None else source_path.with_suffix(".srdf")
specs.append(_TargetSpec(source_path=source_path, output_path=output_path))
return specs
def _resolve_source_path(raw_source: object) -> Path:
value = str(raw_source or "").strip()
if not value:
raise ValueError("srdf target source must be a non-empty path")
source_path = Path(value).expanduser()
return source_path.resolve() if source_path.is_absolute() else (Path.cwd() / source_path).resolve()
def _resolve_cli_output_path(raw_output: object) -> Path:
value = str(raw_output or "").strip()
if not value:
raise ValueError("srdf output must be a non-empty path")
if "\\" in value:
raise ValueError("srdf output must use POSIX '/' separators")
output_path = Path(value).expanduser()
resolved = output_path.resolve() if output_path.is_absolute() else (Path.cwd() / output_path).resolve()
if resolved.suffix.lower() != ".srdf":
raise ValueError("srdf output must end in .srdf")
return resolved
def _targets_include_output_pairs(targets: Sequence[str]) -> bool:
return any("=" in str(target or "") for target in targets)
def _validate_unique_outputs(target_specs: Sequence[_TargetSpec]) -> None:
seen: dict[Path, Path] = {}
for target_spec in target_specs:
output_path = target_spec.output_path.resolve()
previous = seen.get(output_path)
if previous is not None:
raise ValueError(f"srdf output path is used more than once: {_display_path(target_spec.output_path)}")
seen[output_path] = target_spec.output_path
def _generate_target(script_path: Path, *, output_path: Path) -> Path:
script_path = script_path.resolve()
if script_path.suffix.lower() != ".py":
raise ValueError(f"{_display_path(script_path)} must be a Python source file")
if not script_path.is_file():
raise FileNotFoundError(f"Python source not found: {_display_path(script_path)}")
with track_generation_run(
source_path=script_path,
generator="gen_srdf",
outputs=[GenerationOutput(output_path, "srdf")],
):
return _generate_target_inner(script_path, output_path=output_path)
def _generate_target_inner(script_path: Path, *, output_path: Path) -> Path:
module = _load_generator_module(script_path)
generator = getattr(module, "gen_srdf", None)
if not callable(generator):
raise RuntimeError(f"{_display_path(script_path)} does not define callable gen_srdf()")
if inspect.signature(generator).parameters:
raise ValueError(f"{_display_path(script_path)} gen_srdf() must not accept arguments")
payload = _normalize_srdf_payload(generator(), script_path=script_path)
xml = str(payload["xml"])
urdf_path = _resolve_relative_file(payload["urdf"], source_path=script_path, suffix=".urdf", label="urdf")
urdf_robot = _read_urdf_robot(urdf_path)
xml = _xml_with_linked_urdf_metadata(xml, output_path=output_path, urdf_path=urdf_path)
srdf_source = parse_srdf_xml(xml, source_path=output_path)
_validate_linked_urdf_ref(
srdf_source,
expected_urdf_ref=_relative_posix_path(urdf_path, output_path.parent),
)
_validate_srdf_against_urdf(srdf_source, urdf_robot=urdf_robot)
_write_srdf_payload(xml, output_path=output_path, script_path=script_path)
if not output_path.exists():
raise RuntimeError(f"{_display_path(script_path)} did not write {_display_path(output_path)}")
return output_path
def _load_generator_module(script_path: Path) -> object:
module_name = (
"_srdf_tool_"
+ _display_path(script_path).replace("/", "_").replace("\\", "_").replace("-", "_").replace(".", "_")
)
module_spec = importlib.util.spec_from_file_location(module_name, script_path)
if module_spec is None or module_spec.loader is None:
raise RuntimeError(f"Failed to load generator module from {_display_path(script_path)}")
module = importlib.util.module_from_spec(module_spec)
original_sys_path = list(sys.path)
search_paths = [
str(Path.cwd().resolve()),
str(script_path.parent),
]
for candidate in reversed(search_paths):
if candidate not in sys.path:
sys.path.insert(0, candidate)
try:
sys.modules[module_name] = module
module_spec.loader.exec_module(module)
finally:
sys.path[:] = original_sys_path
return module
def _normalize_srdf_payload(raw_payload: object, *, script_path: Path) -> dict[str, object]:
if not isinstance(raw_payload, dict):
raise TypeError(f"{_display_path(script_path)} gen_srdf() must return an envelope dict")
allowed_fields = {"xml", "urdf"}
extra_fields = sorted(str(key) for key in raw_payload if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise TypeError(f"{_display_path(script_path)} gen_srdf() envelope has unsupported field(s): {joined}")
if "xml" not in raw_payload:
raise TypeError(f"{_display_path(script_path)} gen_srdf() envelope must define 'xml'")
if "urdf" not in raw_payload:
raise TypeError(f"{_display_path(script_path)} gen_srdf() envelope must define 'urdf'")
xml = _normalize_xml_value(raw_payload.get("xml"), script_path=script_path, label="gen_srdf() envelope field 'xml'")
if not xml.strip():
raise TypeError(f"{_display_path(script_path)} gen_srdf() envelope field 'xml' must be non-empty")
if not isinstance(raw_payload.get("urdf"), str) or not str(raw_payload.get("urdf")).strip():
raise TypeError(f"{_display_path(script_path)} gen_srdf() envelope field 'urdf' must be a non-empty string")
return {
"xml": xml,
"urdf": str(raw_payload["urdf"]).strip(),
}
def _normalize_xml_value(raw_xml: object, *, script_path: Path, label: str) -> str:
if _is_xml_element(raw_xml):
return _serialize_xml_element(raw_xml)
if isinstance(raw_xml, str):
return raw_xml
raise TypeError(
f"{_display_path(script_path)} {label} must be an xml.etree.ElementTree.Element or string, "
f"got {type(raw_xml).__name__}"
)
def _is_xml_element(value: object) -> bool:
return isinstance(value, ET.Element)
def _serialize_xml_element(root: ET.Element) -> str:
ET.indent(root, space=" ")
body = ET.tostring(root, encoding="unicode", short_empty_elements=True)
return f'<?xml version="1.0"?>\n{body}'
def _resolve_relative_file(raw_value: object, *, source_path: Path, suffix: str, label: str) -> Path:
if not isinstance(raw_value, str) or not raw_value.strip():
raise ValueError(f"{label} must be a non-empty relative path")
value = raw_value.strip()
if "\\" in value:
raise ValueError(f"{label} must use POSIX '/' separators")
pure = PurePosixPath(value)
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
raise ValueError(f"{label} must be a relative path")
path = (source_path.parent / Path(*pure.parts)).resolve()
if path.suffix.lower() != suffix:
raise ValueError(f"{label} must end in {suffix}")
if not path.is_file():
raise FileNotFoundError(f"{label} file does not exist: {_display_path(path)}")
return path
def _read_urdf_robot(urdf_path: Path) -> dict[str, object]:
try:
root = ET.parse(urdf_path).getroot()
except ET.ParseError as exc:
raise ValueError(f"URDF is invalid XML: {_display_path(urdf_path)}") from exc
if root.tag != "robot":
raise ValueError("URDF root must be <robot>")
robot_name = str(root.get("name") or "").strip()
links = {
str(link.get("name") or "").strip()
for link in root.findall("link")
if str(link.get("name") or "").strip()
}
joints: dict[str, dict[str, object]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
if not name:
continue
parent_element = joint.find("parent")
child_element = joint.find("child")
joint_type = str(joint.get("type") or "").strip()
lower: float | None = None
upper: float | None = None
limit_element = joint.find("limit")
if limit_element is not None and joint_type in {"revolute", "prismatic"}:
lower = _optional_finite_float(limit_element.get("lower"))
upper = _optional_finite_float(limit_element.get("upper"))
joints[name] = {
"type": joint_type,
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
"child": str(child_element.get("link") if child_element is not None else "").strip(),
"lower": lower,
"upper": upper,
"mimic": joint.find("mimic") is not None,
}
if not robot_name:
raise ValueError("URDF robot name is required")
return {"name": robot_name, "links": links, "joints": joints}
def _validate_srdf_against_urdf(srdf_source: SrdfSource, *, urdf_robot: dict[str, object]) -> None:
urdf_name = str(urdf_robot["name"])
if srdf_source.robot_name != urdf_name:
raise SrdfSourceError(f"SRDF robot name {srdf_source.robot_name!r} must match URDF robot name {urdf_name!r}")
links = urdf_robot["links"]
joints = urdf_robot["joints"]
assert isinstance(links, set)
assert isinstance(joints, dict)
group_names = {group.name for group in srdf_source.planning_groups}
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
if not group_names:
raise SrdfSourceError("SRDF must define at least one planning group")
for group in srdf_source.planning_groups:
if not group.joint_names and not group.link_names and not group.chains and not group.subgroups:
raise SrdfSourceError(f"SRDF planning group {group.name!r} must define joints, links, chains, or subgroups")
_validate_names_exist(group.joint_names, set(joints), label=f"planning group {group.name!r} joint")
_validate_names_exist(group.link_names, links, label=f"planning group {group.name!r} link")
_validate_names_exist(group.subgroups, group_names, label=f"planning group {group.name!r} subgroup")
for chain in group.chains:
if chain.base_link not in links:
raise SrdfSourceError(f"planning group {group.name!r} chain references missing base_link {chain.base_link!r}")
if chain.tip_link not in links:
raise SrdfSourceError(f"planning group {group.name!r} chain references missing tip_link {chain.tip_link!r}")
for end_effector in srdf_source.end_effectors:
if end_effector.parent_link not in links:
raise SrdfSourceError(f"end_effector {end_effector.name!r} references missing parent_link {end_effector.parent_link!r}")
if end_effector.group not in group_names:
raise SrdfSourceError(f"end_effector {end_effector.name!r} references missing group {end_effector.group!r}")
if end_effector.parent_group and end_effector.parent_group not in group_names:
raise SrdfSourceError(
f"end_effector {end_effector.name!r} references missing parent_group {end_effector.parent_group!r}"
)
_validate_end_effector_topology(
end_effector,
groups_by_name=groups_by_name,
urdf_robot=urdf_robot,
)
for group_state in srdf_source.group_states:
if group_state.group not in group_names:
raise SrdfSourceError(f"group_state {group_state.name!r} references missing group {group_state.group!r}")
group_joint_names = set(_joint_names_for_group(groups_by_name[group_state.group], urdf_robot=urdf_robot, groups_by_name=groups_by_name))
for joint_name, value in group_state.joint_values_by_name.items():
if joint_name not in joints:
raise SrdfSourceError(f"group_state {group_state.name!r} joint references missing name {joint_name!r}")
if joint_name not in group_joint_names:
raise SrdfSourceError(
f"group_state {group_state.name!r} joint {joint_name!r} is not in group {group_state.group!r}"
)
_validate_group_state_joint_value(group_state.name, joint_name, value, joints[joint_name])
for pair in srdf_source.disabled_collision_pairs:
_validate_names_exist((pair.link1, pair.link2), links, label="disable_collisions link")
_warn_on_many_manual_disabled_pairs(srdf_source.disabled_collision_pairs)
def _optional_finite_float(value: object) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if isfinite(parsed) else None
def _joint_path_for_chain(urdf_robot: dict[str, object], *, base_link: str, tip_link: str) -> list[str]:
if not base_link or not tip_link or base_link == tip_link:
return []
joints = urdf_robot.get("joints")
if not isinstance(joints, dict):
return []
by_parent: dict[str, list[tuple[str, dict[str, object]]]] = {}
for joint_name, joint in joints.items():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if parent and child:
by_parent.setdefault(parent, []).append((str(joint_name), joint))
stack: list[tuple[str, list[str]]] = [(base_link, [])]
visited: set[str] = set()
while stack:
link_name, path = stack.pop()
if link_name == tip_link:
return path
if link_name in visited:
continue
visited.add(link_name)
for joint_name, joint in reversed(by_parent.get(link_name, [])):
child = str(joint.get("child") or "").strip()
if child:
stack.append((child, [*path, joint_name]))
return []
def _joint_names_for_group(
group: object,
*,
urdf_robot: dict[str, object],
groups_by_name: dict[str, object],
visited: set[str] | None = None,
) -> list[str]:
names: list[str] = []
joints = urdf_robot.get("joints")
if not isinstance(joints, dict):
return names
for joint_name in getattr(group, "joint_names", ()):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "") != "fixed" and not bool(joint.get("mimic")):
_append_unique(names, [joint_name])
for chain in getattr(group, "chains", ()):
chain_joint_names = []
for joint_name in _joint_path_for_chain(
urdf_robot,
base_link=str(getattr(chain, "base_link", "") or ""),
tip_link=str(getattr(chain, "tip_link", "") or ""),
):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "") != "fixed" and not bool(joint.get("mimic")):
chain_joint_names.append(joint_name)
_append_unique(names, chain_joint_names)
if visited is None:
visited = set()
group_name = str(getattr(group, "name", "") or "")
if group_name:
visited.add(group_name)
for subgroup_name in getattr(group, "subgroups", ()):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
_append_unique(
names,
_joint_names_for_group(
subgroup,
urdf_robot=urdf_robot,
groups_by_name=groups_by_name,
visited=visited,
),
)
return names
def _link_names_for_group(
group: object,
*,
urdf_robot: dict[str, object],
groups_by_name: dict[str, object],
visited: set[str] | None = None,
) -> set[str]:
links = set(str(link_name) for link_name in getattr(group, "link_names", ()))
joints = urdf_robot.get("joints")
if not isinstance(joints, dict):
return links
def add_joint_links(joint_name: str) -> None:
joint = joints.get(joint_name)
if not isinstance(joint, dict):
return
child = str(joint.get("child") or "").strip()
if child:
links.add(child)
for joint_name in getattr(group, "joint_names", ()):
add_joint_links(str(joint_name))
for chain in getattr(group, "chains", ()):
links.add(str(getattr(chain, "tip_link", "") or ""))
for joint_name in _joint_path_for_chain(
urdf_robot,
base_link=str(getattr(chain, "base_link", "") or ""),
tip_link=str(getattr(chain, "tip_link", "") or ""),
):
add_joint_links(joint_name)
if visited is None:
visited = set()
group_name = str(getattr(group, "name", "") or "")
if group_name:
visited.add(group_name)
for subgroup_name in getattr(group, "subgroups", ()):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
links.update(
_link_names_for_group(
subgroup,
urdf_robot=urdf_robot,
groups_by_name=groups_by_name,
visited=visited,
)
)
links.discard("")
return links
def _joint_adjacent_to_any_link(urdf_robot: dict[str, object], parent_link: str, child_links: set[str]) -> bool:
joints = urdf_robot.get("joints")
if not isinstance(joints, dict):
return False
for joint in joints.values():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
return True
return False
def _validate_end_effector_topology(
end_effector: object,
*,
groups_by_name: dict[str, object],
urdf_robot: dict[str, object],
) -> None:
group_name = str(getattr(end_effector, "group", "") or "")
parent_group_name = str(getattr(end_effector, "parent_group", "") or "")
parent_link = str(getattr(end_effector, "parent_link", "") or "")
if not group_name or group_name not in groups_by_name:
return
end_effector_links = _link_names_for_group(
groups_by_name[group_name],
urdf_robot=urdf_robot,
groups_by_name=groups_by_name,
)
if parent_group_name and parent_group_name in groups_by_name:
parent_group_links = _link_names_for_group(
groups_by_name[parent_group_name],
urdf_robot=urdf_robot,
groups_by_name=groups_by_name,
)
overlap = sorted(end_effector_links & parent_group_links)
if overlap:
raise SrdfSourceError(
f"end_effector {getattr(end_effector, 'name', '')!r} group shares link(s) with parent_group: {overlap!r}"
)
if parent_link and parent_link not in parent_group_links:
raise SrdfSourceError(
f"end_effector {getattr(end_effector, 'name', '')!r} parent_link {parent_link!r} is not in parent_group {parent_group_name!r}"
)
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
urdf_robot,
parent_link,
end_effector_links,
):
raise SrdfSourceError(
f"end_effector {getattr(end_effector, 'name', '')!r} parent_link {parent_link!r} is not adjacent to its group"
)
def _validate_group_state_joint_value(
state_name: str,
joint_name: str,
value: float,
joint: object,
) -> None:
if not isinstance(joint, dict):
return
joint_type = str(joint.get("type") or "").strip()
if joint_type == "fixed":
raise SrdfSourceError(f"group_state {state_name!r} cannot set fixed joint {joint_name!r}")
if bool(joint.get("mimic")):
raise SrdfSourceError(f"group_state {state_name!r} cannot set mimic joint {joint_name!r}")
if joint_type == "continuous":
return
lower = joint.get("lower")
upper = joint.get("upper")
if isinstance(lower, float) and value < lower:
raise SrdfSourceError(f"group_state {state_name!r} joint {joint_name!r} is below its URDF lower limit")
if isinstance(upper, float) and value > upper:
raise SrdfSourceError(f"group_state {state_name!r} joint {joint_name!r} is above its URDF upper limit")
def _append_unique(target: list[str], values: list[str]) -> None:
seen = set(target)
for value in values:
if value not in seen:
target.append(value)
seen.add(value)
def _warn_on_many_manual_disabled_pairs(pairs: object) -> None:
manual_count = sum(1 for pair in pairs if getattr(pair, "source", "") == "manual")
if manual_count >= 25:
warnings.warn(
f"SRDF contains {manual_count} manually reasoned disabled collision pairs; prefer sampled/setup-assistant provenance.",
stacklevel=3,
)
def _validate_linked_urdf_ref(srdf_source: SrdfSource, *, expected_urdf_ref: str) -> None:
urdf_ref = str(srdf_source.urdf_ref or "").strip()
if not urdf_ref:
raise SrdfSourceError("SRDF must include <tcad:urdf path=\"...\"/> metadata")
if PurePosixPath(urdf_ref) != PurePosixPath(expected_urdf_ref):
raise SrdfSourceError(
f"SRDF tcad:urdf path {urdf_ref!r} must match gen_srdf() envelope urdf {expected_urdf_ref!r}"
)
def _relative_posix_path(target_path: Path, start_dir: Path) -> str:
return Path(os.path.relpath(Path(target_path).resolve(), Path(start_dir).resolve())).as_posix()
def _xml_with_linked_urdf_metadata(xml: str, *, output_path: Path, urdf_path: Path) -> str:
try:
root = ET.fromstring(xml)
except ET.ParseError as exc:
raise SrdfSourceError(f"{_display_path(output_path)} could not be parsed as SRDF XML") from exc
if root.tag != "robot":
raise SrdfSourceError(f"{_display_path(output_path)} root element must be <robot>")
ET.register_namespace("tcad", SRDF_METADATA_NAMESPACE)
ET.register_namespace("explorer", LEGACY_EXPLORER_NAMESPACE)
metadata_tag = f"{{{SRDF_METADATA_NAMESPACE}}}urdf"
legacy_metadata_tag = f"{{{LEGACY_EXPLORER_NAMESPACE}}}urdf"
metadata_element = None
for child in list(root):
if child.tag in {metadata_tag, legacy_metadata_tag} or str(child.tag or "") in {"tcad:urdf", "explorer:urdf"}:
metadata_element = child
break
if metadata_element is None:
metadata_element = ET.Element(metadata_tag)
root.insert(0, metadata_element)
else:
metadata_element.tag = metadata_tag
metadata_element.set("path", _relative_posix_path(urdf_path, output_path.parent))
if root.text is None:
root.text = "\n "
if metadata_element.tail is None:
metadata_element.tail = "\n "
return _serialize_xml_element(root)
def _validate_names_exist(names: object, allowed: set[str], *, label: str) -> None:
for name in names:
if name not in allowed:
raise SrdfSourceError(f"{label} references missing name {name!r}")
def _write_srdf_payload(xml: str, *, output_path: Path, script_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
xml = xml_with_text_to_cad_metadata(
xml,
python_source_identity(script_path),
output_path=output_path,
source_path=script_path,
)
text = xml if xml.endswith("\n") else xml + "\n"
output_path.write_text(text, encoding="utf-8")
print(f"Wrote SRDF: {output_path}")
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,277 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from pathlib import Path
from typing import Literal
import xml.etree.ElementTree as ET
SRDF_SUFFIX = ".srdf"
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
class SrdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SrdfChain:
base_link: str
tip_link: str
@dataclass(frozen=True)
class SrdfPlanningGroup:
name: str
joint_names: tuple[str, ...]
link_names: tuple[str, ...]
chains: tuple[SrdfChain, ...]
subgroups: tuple[str, ...]
@dataclass(frozen=True)
class SrdfEndEffector:
name: str
parent_link: str
group: str
parent_group: str
@dataclass(frozen=True)
class SrdfGroupState:
name: str
group: str
joint_values_by_name: dict[str, float]
@property
def joint_values_by_name_rad(self) -> dict[str, float]:
"""Deprecated compatibility alias. SRDF values use URDF-native units."""
return self.joint_values_by_name
@dataclass(frozen=True)
class SrdfDisabledCollisionPair:
link1: str
link2: str
reason: str
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
@dataclass(frozen=True)
class SrdfSource:
file_ref: str
source_path: Path
robot_name: str
urdf_ref: str
planning_groups: tuple[SrdfPlanningGroup, ...]
end_effectors: tuple[SrdfEndEffector, ...]
group_states: tuple[SrdfGroupState, ...]
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
def file_ref_from_srdf_path(srdf_path: Path) -> str:
resolved = srdf_path.resolve()
if resolved.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved} is not an SRDF source file")
return _relative_to_repo(resolved)
def read_srdf_source(srdf_path: Path) -> SrdfSource:
resolved_path = srdf_path.resolve()
if resolved_path.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=resolved_path)
def parse_srdf_xml(xml_text: str, *, source_path: Path) -> SrdfSource:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=source_path)
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
if root.tag != "robot":
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
urdf_ref = _linked_urdf_ref(root)
planning_groups: list[SrdfPlanningGroup] = []
group_names: list[str] = []
for group_element in root.findall("group"):
name = str(group_element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
group_names.append(name)
planning_groups.append(
SrdfPlanningGroup(
name=name,
joint_names=_child_names(group_element, "joint"),
link_names=_child_names(group_element, "link"),
chains=tuple(
SrdfChain(
base_link=str(chain.attrib.get("base_link") or "").strip(),
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
)
for chain in group_element.findall("chain")
),
subgroups=_child_names(group_element, "group"),
)
)
_raise_on_duplicates(group_names, source_path=source_path, label="group")
end_effectors: list[SrdfEndEffector] = []
end_effector_names: list[str] = []
for element in root.findall("end_effector"):
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
end_effector_names.append(name)
end_effectors.append(
SrdfEndEffector(
name=name,
parent_link=str(element.attrib.get("parent_link") or "").strip(),
group=str(element.attrib.get("group") or "").strip(),
parent_group=str(element.attrib.get("parent_group") or "").strip(),
)
)
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
group_states: list[SrdfGroupState] = []
group_state_keys: list[str] = []
for element in root.findall("group_state"):
name = str(element.attrib.get("name") or "").strip()
group = str(element.attrib.get("group") or "").strip()
if not name or not group:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
group_state_keys.append(f"{group}/{name}")
joint_values: dict[str, float] = {}
joint_names: list[str] = []
for joint_element in element.findall("joint"):
joint_name = str(joint_element.attrib.get("name") or "").strip()
if not joint_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
joint_names.append(joint_name)
try:
joint_value = float(joint_element.attrib.get("value"))
except (TypeError, ValueError) as exc:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
) from exc
if not isfinite(joint_value):
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
)
joint_values[joint_name] = joint_value
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
disabled_pairs: list[SrdfDisabledCollisionPair] = []
pair_keys: list[str] = []
for element in root.findall("disable_collisions"):
link1 = str(element.attrib.get("link1") or "").strip()
link2 = str(element.attrib.get("link2") or "").strip()
if not link1 or not link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
if link1 == link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
reason = str(element.attrib.get("reason") or "").strip()
if not reason:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
)
pair_key = "/".join(sorted((link1, link2)))
pair_keys.append(pair_key)
disabled_pairs.append(
SrdfDisabledCollisionPair(
link1=link1,
link2=link2,
reason=reason,
source=_disabled_collision_source(reason),
)
)
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
return SrdfSource(
file_ref=file_ref_from_srdf_path(source_path),
source_path=source_path.resolve(),
robot_name=robot_name,
urdf_ref=urdf_ref,
planning_groups=tuple(planning_groups),
end_effectors=tuple(end_effectors),
group_states=tuple(group_states),
disabled_collision_pairs=tuple(disabled_pairs),
)
def _local_name(tag: str) -> str:
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
def _linked_urdf_ref(root: ET.Element) -> str:
for child in list(root):
if _local_name(child.tag) != "urdf":
continue
tag = str(child.tag or "")
if (
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
or tag.startswith("tcad:")
or tag.startswith("explorer:")
):
return str(child.attrib.get("path") or "").strip()
return ""
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
names: list[str] = []
for child in element.findall(tag):
name = str(child.attrib.get("name") or "").strip()
if name:
names.append(name)
return tuple(names)
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
seen = set()
duplicates = []
for name in names:
if name in seen:
duplicates.append(name)
seen.add(name)
if duplicates:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
)
def _disabled_collision_source(
reason: str,
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
normalized = str(reason or "").strip().lower()
if "adjacent" in normalized:
return "adjacent"
if any(token in normalized for token in ("never", "always", "sample", "default")):
return "sampled"
if "setup" in normalized or "assistant" in normalized:
return "setup_assistant"
if "assum" in normalized:
return "assumed"
return "manual"
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-70
View File
@@ -1,70 +0,0 @@
---
name: step-parts
description: Find, evaluate, and download common purchasable CAD parts from step.parts, including named off-the-shelf actuators, servos, motors, electronics boards, connectors, screws, bolts, nuts, washers, bearings, standoffs, and other catalog components. Use when Codex needs to search the hosted step.parts catalog before creating simplified placeholder geometry, resolve fuzzy part names, standards, aliases, or dimensions, choose a matching part, fetch a canonical .step file, verify checksums, or use the step.parts API/OpenAPI/catalog endpoints for standard part discovery.
---
# CAD Parts
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
## Overview
Use the hosted step.parts machine endpoints instead of scraping HTML or relying on local repository files. Treat `https://api.step.parts` as the canonical API origin and `https://www.step.parts` as the site/static-asset origin unless the user provides a different hosted mirror. Network/DNS failures are inconclusive: if `api.step.parts` cannot be reached from the sandbox, retry once with network permission before reporting a miss or using placeholder geometry. Do not describe a part as unavailable unless the API was reachable and returned no relevant candidates.
When a CAD assembly includes named off-the-shelf actuators, servos, motors, electronics boards, connectors, or other purchasable components, search step.parts before creating simplified placeholder geometry. For named servos, motors, and actuators, search both exact model strings and common aliases/vendor spellings before giving up. For example, `STS3215` may also appear as `ST3215`, `3215`, `Waveshare Feetech ST3215`, or under `family=feetech`. If the API was reachable and no exact or near-exact match is available, record the search miss and then use a documented envelope or simplified stand-in.
## Quick Workflow
1. Interpret the requested part into search terms and optional facets:
- `q` for fuzzy tokens, standards, aliases, dimensions, source/product URLs, and attribute names/values.
- `category`, `family`, `standard`, or `tag` when the user gives an exact facet.
2. Search `/v1/parts` and inspect `items`, `total`, and `facets`. For actuator model numbers, retry likely aliases, dropped letters, vendor names, and relevant family facets before treating an empty result as a miss.
3. If results are ambiguous, present the best few options with `id`, `name`, `standard`, and key attributes before choosing. If one result clearly matches, return the selected record details without downloading unless the user asked for a local STEP file.
4. When an exact or near-exact off-the-shelf actuator model is found, prefer downloading and using its STEP file unless there is a clear assembly-time reason to use a simplified envelope. Record that choice explicitly.
5. When the user asks to download or save a STEP file, download its `stepUrl`, then verify the file with the record's `sha256` when present.
6. Return the local path when downloaded, plus the selected part id and page/API URLs so the user can trace provenance.
## CAD Viewer Handoff
After completing step.parts work that creates or updates a local `.step` or `.stp` file, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Bundled Downloader
Use `scripts/download_step_part.py` for deterministic search, download, and checksum verification:
```bash
python scripts/download_step_part.py "M3 socket head 12" --download
python scripts/download_step_part.py --id iso4762_socket_head_cap_screw_m3x12 --download
python scripts/download_step_part.py "bearing 608zz" --limit 5
```
Useful options:
- `--origin`: override `https://api.step.parts` only when the user provides another hosted API origin.
- `--tag`, `--category`, `--family`, `--standard`: repeatable facet filters.
- `--out-dir`: override the download directory when the user asks for a specific destination.
- `--all`: with `--download`, download every result on the returned page as individual STEP downloads.
- `--overwrite`: replace an existing output file.
The script prints JSON to stdout. For searches, it prints matched records. For downloads, it prints saved file paths, checksums, and source URLs.
## API Reference
Read `references/step-parts-api.md` when you need endpoint details, field meanings, or query semantics. Prefer:
- `/v1/parts` for filtered search with absolute asset URLs.
- `/v1/parts/{id}` for one enriched record.
- Returned `stepUrl` for STEP downloads.
- `/v1/catalog/parts.index.json` for a compact discovery index.
- `/v1/catalog/schema` for field and family attribute meanings.
- `/v1/openapi.json` when generating a client or tool.
## Search Guidance
- Query tokens are ANDed by the API, so start specific but not overconstrained. For example, use `M3 SHCS 12` before adding exact family and standard filters.
- Values within one facet are ORed together, and selected `tag`, `category`, `family`, and `standard` fields are ANDed together. Use exact facets to narrow within known categories, then rank manually by name and attributes.
- Standards can be queried as `ISO 4762`, `ISO4762`, or the exact `standard.designation`.
- The `attributes` object contains family-specific facts such as `thread`, `lengthMm`, `bore1Mm`, `material`, `profileSeries`, `slotSizeMm`, and dimensions in millimeters.
- Part, GLB, and PNG URL patterns are predictable on `https://www.step.parts`; STEP URLs are environment-aware and may resolve to GitHub LFS media in production. Use catalog/API `stepUrl` for downloads.
@@ -1,4 +0,0 @@
interface:
display_name: "STEP Parts"
short_description: "Find and download STEP files from step.parts"
default_prompt: "Use $step-parts to find and download a STEP file from step.parts."
@@ -1,74 +0,0 @@
# step.parts API Reference
## Origins
Default to the API origin `https://api.step.parts`. Use a different origin only when the user supplies another hosted step.parts-compatible API domain. Static HTML pages live on `https://www.step.parts`; GLB and PNG preview URLs in API records point directly at Vercel Blob. STEP URLs are environment-aware and production records use commit-pinned GitHub LFS media. If the API domain does not resolve, treat the hosted service as unavailable and ask for a deployed origin when a live download is required.
## Machine Endpoints
| Endpoint | Use |
| --- | --- |
| `https://www.step.parts/llms.txt` | Human-readable agent guide with endpoint summary and examples. |
| `/v1/parts` | Search, filter, paginate, and retrieve absolute asset URLs. |
| `/v1/parts/{id}` | Fetch one enriched part record by stable id. |
| `/v1/catalog/schema` | JSON Schema, field semantics, result ordering, and family attribute meanings. |
| `/v1/catalog/parts.index.json` | Compact id/name/facet discovery index for cheap lookups before fetching details. |
| `/v1/openapi.json` | OpenAPI 3.1 contract for generating clients/tools. |
## `/v1/parts` Query Parameters
- `q`: tokenized metadata search across id, name, description, category, family, stepSource, productPage, tags, aliases, standard fields, attribute keys, and attribute values. Every token must match.
- `tag`, `category`, `family`, `standard`: repeatable filters. Values may also be comma-separated. Values within one facet are ORed, and selected facet fields are ANDed together.
- `page`: 1-based page number.
- `pageSize`: default `100`, max `500`.
Unfiltered results start with a fixed 100-part showcase, then continue in stable source catalog order. Filtered results are ordered by stable source catalog order.
Examples:
```text
https://api.step.parts/v1/parts?q=M3&tag=screw&page=2
https://api.step.parts/v1/parts?pageSize=100
https://api.step.parts/v1/parts?category=fastener&family=socket-head-cap-screw&standard=ISO%204762
https://api.step.parts/v1/parts?q=lengthMm%2012
```
## Response Fields
`/v1/parts` returns:
- `catalog`: part count, last modified timestamp, catalog checksum, and URLs for schema/OpenAPI.
- `items`: enriched part records with absolute `pageUrl`, `apiUrl`, `stepUrl`, `glbUrl`, and `pngUrl`.
- `page`, `pageSize`, `total`, `totalPages`, `hasNextPage`, `hasPreviousPage`.
- `facets`: available `tags`, `categories`, `families`, and `standards` with counts.
- `filters`: parsed active filters.
Each part record contains:
- `id`: stable snake_case identifier and asset filename base.
- `name`, `description`, `category`, `family`, `tags`, `aliases`.
- `standard`: optional `{ body, number, designation }`.
- `attributes`: family-specific scalar facts.
- `stepUrl`, `glbUrl`, `pngUrl`.
- `byteSize`, `sha256`.
## Asset URL Patterns
Use returned URLs when possible. Patterns are:
```text
https://www.step.parts/step/{id}.step
Use the absolute `glbUrl` and `pngUrl` returned by the API record. Preview assets are served from Vercel Blob.
https://www.step.parts/parts/{id}
```
The `/step/{id}.step` route serves local checked-out STEP bytes in local/dev mode and redirects to commit-pinned GitHub LFS media in production.
## Download And Verification
When downloading a STEP file:
1. Fetch a part record from `/v1/parts` or `/v1/parts/{id}`.
2. Download `stepUrl`.
3. Compare the file SHA-256 to the part record's `sha256` when it is not null.
4. Keep the original `.step` extension and preserve the source id in the filename unless the user asks otherwise.
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""Search step.parts for common standard parts and download STEP files."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_ORIGIN = "https://api.step.parts"
DEFAULT_OUT_DIR = tempfile.gettempdir()
USER_AGENT = "step-parts-skill/1.0"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Search the step.parts hosted catalog for low-level common standard parts "
"(screws, bolts, bearings, electronics parts, motors, connectors, etc.) "
"and optionally download canonical STEP files."
),
)
parser.add_argument("query", nargs="?", help="Fuzzy search query, for example 'M3 socket head 12'.")
parser.add_argument("--id", dest="part_id", help="Fetch a specific part id instead of searching.")
parser.add_argument("--origin", default=DEFAULT_ORIGIN, help=f"API origin. Default: {DEFAULT_ORIGIN}")
parser.add_argument("--download", action="store_true", help="Download the selected STEP file.")
parser.add_argument("--all", action="store_true", help="With --download, download every result on the returned page.")
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR, help="Directory for downloaded STEP files. Default: active temp directory.")
parser.add_argument("--filename", help="Filename to use when downloading one selected part.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing downloaded file.")
parser.add_argument("--limit", type=int, default=10, help="Search page size. The API caps this at 500.")
parser.add_argument("--page", type=int, default=1, help="1-based search page.")
parser.add_argument("--tag", action="append", default=[], help="Repeatable tag filter.")
parser.add_argument("--category", action="append", default=[], help="Repeatable category filter.")
parser.add_argument("--family", action="append", default=[], help="Repeatable family filter.")
parser.add_argument("--standard", action="append", default=[], help="Repeatable standard filter, for example 'ISO 4762'.")
parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds.")
return parser.parse_args()
def origin_url(origin: str) -> str:
parsed = urllib.parse.urlparse(origin)
if not parsed.scheme or not parsed.netloc:
raise SystemExit(f"Invalid origin: {origin!r}")
return origin.rstrip("/")
def build_url(origin: str, path: str, params: list[tuple[str, str]] | None = None) -> str:
url = f"{origin_url(origin)}{path}"
if params:
return f"{url}?{urllib.parse.urlencode(params)}"
return url
def request(url: str, timeout: float) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {exc.code} for {url}: {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Failed to fetch {url}: {exc.reason}") from exc
def fetch_json(url: str, timeout: float) -> Any:
data = request(url, timeout)
try:
return json.loads(data)
except json.JSONDecodeError as exc:
raise SystemExit(f"Expected JSON from {url}: {exc}") from exc
def search_parts(args: argparse.Namespace) -> dict[str, Any]:
params: list[tuple[str, str]] = [
("page", str(max(1, args.page))),
("pageSize", str(max(1, args.limit))),
]
if args.query:
params.append(("q", args.query))
for key in ("tag", "category", "family", "standard"):
for value in getattr(args, key):
params.append((key, value))
return fetch_json(build_url(args.origin, "/v1/parts", params), args.timeout)
def get_part(args: argparse.Namespace, part_id: str) -> dict[str, Any]:
safe_id = urllib.parse.quote(part_id, safe="")
return fetch_json(build_url(args.origin, f"/v1/parts/{safe_id}"), args.timeout)
def selected_parts(args: argparse.Namespace) -> list[dict[str, Any]]:
if args.part_id:
return [get_part(args, args.part_id)]
result = search_parts(args)
items = result.get("items", [])
if not items:
raise SystemExit("No parts matched the query.")
if args.download and args.all:
return items
return [items[0]]
def filename_for(part: dict[str, Any], requested_filename: str | None, allow_requested: bool) -> str:
if requested_filename and allow_requested:
return requested_filename
step_url = str(part.get("stepUrl") or "")
name = Path(urllib.parse.urlparse(step_url).path).name
if name:
return name
return f"{part['id']}.step"
def step_download_url(part: dict[str, Any], origin: str) -> str:
step_url = part.get("stepUrl")
if not step_url:
raise SystemExit(f"Part {part.get('id', '<unknown>')} does not include stepUrl.")
return urllib.parse.urljoin(f"{origin_url(origin)}/", str(step_url))
def write_download(part: dict[str, Any], args: argparse.Namespace, allow_requested_filename: bool) -> dict[str, Any]:
step_url = step_download_url(part, args.origin)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / filename_for(part, args.filename, allow_requested_filename)
if path.exists() and not args.overwrite:
raise SystemExit(f"Refusing to overwrite existing file: {path}")
data = request(step_url, args.timeout)
path.write_bytes(data)
actual_sha256 = hashlib.sha256(data).hexdigest()
expected_sha256 = part.get("sha256")
checksum_ok = expected_sha256 is None or expected_sha256 == actual_sha256
if not checksum_ok:
raise SystemExit(
f"Checksum mismatch for {path}: expected {expected_sha256}, got {actual_sha256}",
)
return {
"id": part.get("id"),
"name": part.get("name"),
"path": str(path),
"stepUrl": step_url,
"pageUrl": part.get("pageUrl"),
"apiUrl": part.get("apiUrl"),
"byteSize": len(data),
"sha256": actual_sha256,
"checksumVerified": expected_sha256 is not None,
}
def compact_part(part: dict[str, Any]) -> dict[str, Any]:
return {
"id": part.get("id"),
"name": part.get("name"),
"category": part.get("category"),
"family": part.get("family"),
"standard": part.get("standard"),
"attributes": part.get("attributes"),
"stepUrl": part.get("stepUrl"),
"pageUrl": part.get("pageUrl"),
"apiUrl": part.get("apiUrl"),
"sha256": part.get("sha256"),
}
def main() -> int:
args = parse_args()
if args.filename and (not args.download or args.all):
raise SystemExit("--filename can only be used with --download for one selected part.")
if not args.part_id and not args.query and not any([args.tag, args.category, args.family, args.standard]):
raise SystemExit("Provide a query, --id, or at least one facet filter.")
if not args.download:
if args.part_id:
output: Any = compact_part(get_part(args, args.part_id))
else:
result = search_parts(args)
result["items"] = [compact_part(part) for part in result.get("items", [])]
output = result
json.dump(output, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
parts = selected_parts(args)
downloads = [
write_download(part, args, allow_requested_filename=len(parts) == 1)
for part in parts
]
json.dump({"downloads": downloads}, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-66
View File
@@ -1,66 +0,0 @@
---
name: urdf
description: URDF robot description generation and default generation-time validation. Use when creating, editing, regenerating, inspecting, or debugging `.urdf` files, Python `gen_urdf()` sources, robot links, joints, limits, inertials, visual/collision geometry, mesh references, frame conventions, or generated robot-description artifacts. Use the SRDF skill for MoveIt2 semantic groups and IK/path-planning semantics; use the cad-viewer skill for local MoveIt2 server controls; use the CAD skill for STEP/STL/3MF/DXF/GLB outputs.
---
# URDF
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill for URDF robot-description outputs. Treat URDF work as constrained kinematic modeling, not just XML writing. The main correctness risks are frame placement, joint-axis semantics, unit consistency, mesh scale, inertial data, and generated-artifact drift.
## Core Rules
1. Treat the Python source that defines `gen_urdf()` as the source of truth. Treat configured `.urdf` files as generated artifacts.
2. Generate only explicit URDF targets. Do not regenerate unrelated CAD, mesh, render, SRDF, SDF, or simulator artifacts from this skill.
3. The `scripts/urdf` generator validates generated URDFs by default. Do not use or document a separate `validate` command.
4. Before writing or changing URDF XML, establish the robot's frame, joint, geometry, unit, and assumption ledger. See `references/design-ledger.md`.
5. Use URDF frame semantics exactly. Joint origins, link frames, joint axes, and visual/collision/inertial origins use different reference frames. See `references/frame-semantics.md`.
6. Do not infer spatial transforms, mesh units, handedness, axes, or joint signs from vague prose. Use CAD transforms, dimensioned drawings, measured values, existing source data, or explicit documented assumptions.
7. Prefer simple, auditable generator code over clever XML construction. Keep constants named by physical meaning, not by arbitrary numbers.
8. For physical links, model `inertial`, `visual`, and `collision` separately when the target consumer needs them. Frame-only links may intentionally omit mass and geometry.
## CAD Viewer Handoff
After completing URDF work that creates or modifies a `.urdf`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Workflow
1. Identify the `gen_urdf()` Python source and target `.urdf` output.
2. Identify target consumers: RViz, robot_state_publisher, Gazebo/Ignition, MoveIt, a real robot driver, or another simulator.
3. Read or create the design ledger before editing frames, origins, axes, mesh scale, limits, or inertials.
4. Edit the generator source, not generated URDF XML.
5. Regenerate only explicit targets with `scripts/urdf`.
6. Let generation-time validation fail fast on XML, graph, joint, geometry, mesh-reference, and inertial problems.
7. When geometry or mesh references depend on changed CAD or exported mesh outputs, regenerate those explicit artifacts with the owning CAD or mesh workflow, then regenerate the affected URDF target.
8. When available, run a consumer smoke test appropriate to the target: RViz display, robot_state_publisher tree, Gazebo/Ignition loading, or MoveIt model loading.
9. Report remaining assumptions, unchecked spatial data, and validation/smoke-test gaps.
## Commands
Run with the Python environment for the project or workspace. Treat `python` in examples as an interpreter placeholder; if bare `python` is unavailable, substitute `python3`, a project virtualenv interpreter, or the configured interpreter path. The URDF generator and lightweight validator use only the Python standard library; downstream consumers such as RViz, Gazebo, or MoveIt may need their own runtime packages.
From this skill directory, the launcher shape is:
```bash
python scripts/urdf path/to/source.py
python scripts/urdf path/to/source.py -o path/to/robot.urdf
python scripts/urdf path/to/a.py=out/a.urdf path/to/b.py=out/b.urdf
```
Plain Python targets write a sibling `.urdf` beside the source. `-o`/`--output` is valid only with one plain target. Use `SOURCE.py=OUTPUT.urdf` pairs for custom multi-target destinations.
Relative source targets and CLI output overrides are resolved from the current working directory. When running from outside this skill directory, prefix the launcher path so target files still resolve from the intended workspace.
The launcher executes only `gen_urdf()` and validates the generated URDF output. It does not provide a separate validation-only command.
## References
- Design ledger: `references/design-ledger.md`
- Frame semantics: `references/frame-semantics.md`
- URDF generator contract: `references/generator-contract.md`
- URDF generation command: `references/gen-urdf.md`
- URDF edit workflow: `references/urdf-workflow.md`
- Generation-time validation expectations: `references/validation.md`
@@ -1,4 +0,0 @@
interface:
display_name: "URDF"
short_description: "Generate and validate URDF robot descriptions."
default_prompt: "Use $urdf to generate, update, and validate URDF robot descriptions from Python sources, handing generated URDFs to $cad-viewer when available."
@@ -1,110 +0,0 @@
# URDF Design Ledger
Use this reference before creating or editing a URDF generator. The ledger is the written spatial model that prevents silent frame, axis, unit, and mesh-scale mistakes.
The ledger may live in the generator source comments, adjacent project documentation, a README, or a task note. It must be specific enough that another engineer can audit the generated URDF without reverse-engineering the XML.
## Required Sections
### Robot Metadata
Record:
- robot name
- target consumers: RViz, robot_state_publisher, Gazebo/Ignition, MoveIt, real robot driver, or other
- unit convention: meters, kilograms, seconds, radians unless the project explicitly states otherwise
- frame convention: REP-103-style body convention when applicable, or a documented exception
- mesh unit convention: meters, millimeters, inches, or other
- source of dimensions: CAD, drawing, measured data, vendor documentation, existing URDF, or assumption
### Link Ledger
For every link, record:
| Field | Meaning |
|---|---|
| link name | Exact URDF `<link name="...">` value. |
| role | Physical link, frame-only link, sensor frame, tool frame, base frame, or other. |
| frame definition | Where the link frame is located and how its axes point. |
| parent joint | Joint that creates this child link frame, or `none` for root. |
| visual geometry | Primitive or mesh source, with origin relative to the link frame. |
| collision geometry | Primitive or mesh source, with origin relative to the link frame. |
| inertial source | CAD mass properties, vendor data, approximation, or intentionally omitted. |
Frame-only links such as `base_footprint`, optical frames, and `tool0` may omit inertial, visual, and collision blocks. Mark them explicitly as frame-only rather than leaving intent ambiguous.
### Joint Ledger
For every joint, record:
| Field | Meaning |
|---|---|
| joint name | Exact URDF `<joint name="...">` value. |
| type | `fixed`, `revolute`, `continuous`, or `prismatic` for the current `scripts/urdf` validator; record `floating` or `planar` only if the project has a different supported validation path. |
| parent link | Link whose frame expresses the joint origin. |
| child link | Link whose frame is created at the joint frame. |
| origin xyz/rpy | Parent-link-frame transform from parent link to joint frame. |
| axis | Axis vector expressed in the joint frame, for movable joints. |
| limits | Radians for revolute, meters for prismatic, no finite lower/upper limits for continuous. |
| positive motion | What positive joint motion physically does. |
| source | CAD, drawing, measured data, existing model, or documented assumption. |
Do not write a movable joint without an explicit positive-motion convention. The sign of the axis is part of the model, not a cosmetic detail.
### Geometry Ledger
For every visual or collision item, record:
| Field | Meaning |
|---|---|
| link | Owning link. |
| kind | `visual` or `collision`. |
| geometry type | `mesh`, `box`, `cylinder`, or `sphere`. |
| source | CAD export, primitive approximation, vendor mesh, generated mesh, or temporary placeholder. |
| origin xyz/rpy | Transform from link frame to geometry frame. |
| scale | Mesh scale if applicable. |
| units | Mesh source units and URDF scale needed to express meters. |
Visual geometry is for display. Collision geometry is for contact, planning, and physics. It may intentionally be simpler than the visual geometry.
### Inertial Ledger
For every physical link, record:
| Field | Meaning |
|---|---|
| mass | Kilograms. |
| center of mass | Inertial origin xyz in the link frame. |
| inertia tensor | Tensor values and frame. |
| source | CAD mass properties, vendor data, calculation, approximation, or intentionally omitted. |
| confidence | Exact, estimated, placeholder, or unknown. |
Do not silently copy visual origins into inertial origins. The visual frame, collision frame, link frame, and center of mass can all differ.
### Assumption Ledger
Record every inferred or guessed value, including:
- unknown dimensions
- mesh units
- sign conventions
- joint axes
- parent/child direction
- visual or collision offsets
- mass, COM, and inertia approximations
- frame-only link intent
- unverified package URI resolution
Use named constants in generator code for assumed values. Prefer names like `ASSUMED_BASE_TO_SHOULDER_Z_M` over unlabelled numeric literals.
## When Information Is Missing
If spatial information is missing, do not invent a precise-looking transform. Choose one of these outcomes:
1. preserve existing source data unchanged;
2. create a frame-only or placeholder structure with explicit assumption comments;
3. use a clearly named approximate constant;
4. ask for dimensions or CAD data when the workflow allows interaction;
5. report that the generated model is structurally valid but spatially provisional.
A provisional URDF is acceptable when clearly labelled. A plausible but undocumented URDF is not.
@@ -1,105 +0,0 @@
# URDF Frame Semantics
Use this reference whenever editing origins, axes, visual placement, collision placement, or inertials. Most URDF generation errors are frame errors.
## Core Semantics
URDF represents a robot as a tree of links connected by joints.
For a joint:
- `<parent link="...">` names the parent link.
- `<child link="...">` names the child link.
- `<origin xyz="..." rpy="...">` is the transform from the parent link frame to the joint frame.
- The child link frame is coincident with the joint frame.
- `<axis xyz="...">` for a movable joint is expressed in the joint frame, not automatically in the world frame and not in the visual mesh frame.
For link subelements:
- `<visual><origin ...>` is expressed in the link frame.
- `<collision><origin ...>` is expressed in the link frame.
- `<inertial><origin ...>` is the center-of-mass/inertial frame expressed in the link frame.
These origins are independent. A mesh can be offset from its link frame, and the center of mass can be offset differently.
## Units and Angles
Use:
- meters for length;
- kilograms for mass;
- radians for angles;
- seconds for time;
- right-handed coordinate frames unless the project documents an exception.
Do not store revolute limits in degrees in generated URDF. Convert degrees to radians before emission.
Do not use finite lower/upper limits for a `continuous` joint unless the project is intentionally not using URDF continuous-joint semantics.
## Joint Axis Checklist
For every non-fixed joint, confirm:
1. the axis is present;
2. the axis vector has three finite numbers;
3. the vector is nonzero;
4. the vector is normalized or intentionally normalized by helper code;
5. the vector is expressed in the joint frame;
6. positive motion is documented.
Examples:
```xml
<joint name="shoulder_pan_joint" type="revolute">
<parent link="base_link" />
<child link="shoulder_link" />
<origin xyz="0 0 0.24" rpy="0 0 0" />
<axis xyz="0 0 1" />
<limit lower="-3.14159" upper="3.14159" effort="40" velocity="2" />
</joint>
```
This means the child link frame is at `z = 0.24` in `base_link`, and positive joint motion rotates about +Z of the joint/child frame.
## Visual and Collision Placement Checklist
For every visual or collision block, confirm:
1. the origin is relative to the owning link frame;
2. mesh scale converts the mesh source units into meters;
3. visual and collision geometry are intentionally the same or intentionally different;
4. collision geometry is simple enough for the intended physics/planning consumer;
5. mesh paths are stable from the generated URDF's location or use an intended package URI.
Example:
```xml
<link name="forearm_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://robot_description/meshes/forearm.stl" scale="0.001 0.001 0.001" />
</geometry>
</visual>
<collision>
<origin xyz="0.12 0 0" rpy="0 1.57079632679 0" />
<geometry>
<cylinder radius="0.035" length="0.24" />
</geometry>
</collision>
</link>
```
This places the visual mesh at the link frame and uses a simplified collision cylinder offset in the same link frame.
## Inertial Placement Checklist
For every physical link with inertial data, confirm:
1. mass is positive and finite;
2. inertial origin is the center of mass in the link frame;
3. inertia tensor values are in SI units;
4. tensor values correspond to the inertial frame being declared;
5. approximations are documented.
Do not infer inertial origin from visual or collision origin unless the source data proves they coincide.
@@ -1,46 +0,0 @@
# URDF Generation
Regenerate explicit URDF outputs from Python sources with top-level `gen_urdf()` functions.
```bash
python scripts/urdf path/to/assembly.py
python scripts/urdf path/to/assembly.py -o path/to/robot.urdf
python scripts/urdf path/to/a.py=out/a.urdf path/to/b.py=out/b.urdf
```
Plain Python targets write a sibling `.urdf` beside the source. `-o`/`--output` is valid only with one plain target. Use `SOURCE.py=OUTPUT.urdf` pairs for custom multi-target destinations.
`gen_urdf()` must be a top-level zero-argument function returning the root `xml.etree.ElementTree.Element`, a complete URDF XML string, or an accepted envelope whose only field is `xml`. See `references/generator-contract.md`.
Relative source targets and CLI output paths resolve from the current working directory.
## Default Validation Behavior
The generation command validates the generated URDF before accepting the output. There is no separate validation-only command in this skill.
A generation failure should be treated as a model or source problem, not as a reason to hand-edit the generated `.urdf`. Fix the generator source, then regenerate the explicit target.
The generation-time validation is intended to catch compact robot-description issues such as:
- malformed XML or wrong root element;
- missing or duplicate link and joint names;
- invalid parent/child references;
- multiple roots, disconnected graphs, or cycles;
- suspicious joint limits, axes, origins, geometry, mesh references, or inertials when supported by the runtime.
## Boundaries
This tool runs only `gen_urdf()`. It does not regenerate CAD, mesh/export, GLB/topology, render, SDF, SRDF, MoveIt2, or simulator outputs.
If URDF visual/collision mesh references depend on updated CAD or mesh outputs, regenerate those explicit targets separately with the owning CAD or mesh workflow, then regenerate the affected URDF.
After creating or modifying generated `.urdf` files, hand explicit paths to `$cad-viewer` for live viewer links when available.
## Failure Handling
When generation-time validation fails:
1. inspect the reported links, joints, mesh references, or inertial fields;
2. update the source generator, design ledger, or referenced assets;
3. regenerate the same explicit URDF target;
4. do not patch the generated `.urdf` directly unless the task is explicitly forensic and the patch will not be treated as source of truth.
@@ -1,105 +0,0 @@
# URDF Generator Contract
Use this reference when creating or editing Python sources that generate URDF files.
## Source of Truth
The Python source that defines `gen_urdf()` is source of truth. The configured `.urdf` file is generated and should not be hand-edited.
Keep the design ledger close to the source. At minimum, generator source should make units, frame conventions, parent/child choices, joint axes, mesh scale, and assumptions easy to audit.
## Contract
`gen_urdf()` must be a top-level zero-argument function that returns one of:
- the root `xml.etree.ElementTree.Element` for a complete URDF document;
- a complete URDF XML string;
- an envelope dict with exactly one field, `xml`, whose value is an `ElementTree.Element` or XML string.
Default generator shape:
```python
import xml.etree.ElementTree as ET
def gen_urdf():
robot = ET.Element("robot", {"name": "sample"})
ET.SubElement(robot, "link", {"name": "base_link"})
return robot
```
When an envelope is needed, set `xml` to the same root element or XML string:
```python
def gen_urdf():
robot = ET.Element("robot", {"name": "sample"})
ET.SubElement(robot, "link", {"name": "base_link"})
return {"xml": robot}
```
Do not include output-path, validation, Explorer, pose-preset, or consumer metadata in the envelope. The current runtime rejects fields such as `urdf_output`, `validate`, and `explorer_metadata`; output paths are selected only by CLI arguments. Named robot poses belong in SRDF `<group_state>` elements, not URDF-side `explorer.json` or Explorer metadata artifacts.
The CLI serializes the returned payload, writes the configured `.urdf` output path, and validates that generated file before returning success.
The generated `.urdf` output path is selected by the CLI. A plain source target writes a sibling `.urdf`; `-o`/`--output` overrides one target; `SOURCE.py=OUTPUT.urdf` pairs override individual targets.
The host project may impose its own layout policy, but the URDF skill runtime does not hardcode a project directory.
## Authoring Expectations
Use explicit constants for physical dimensions, joint locations, joint limits, mesh scale, and inertial values. Avoid anonymous literals in joint origins and axes.
Prefer:
```python
BASE_TO_SHOULDER_Z_M = 0.240
SHOULDER_PAN_AXIS = (0.0, 0.0, 1.0)
FOREARM_MESH_SCALE_FROM_MM = (0.001, 0.001, 0.001)
```
Over:
```python
origin="0 0 0.24"
axis="0 0 1"
scale="0.001 0.001 0.001"
```
Document assumptions directly in comments near the constants or in `references/design-ledger.md` style project documentation.
## Frame and Unit Rules
Generators must emit URDF values in the project's URDF unit convention, normally meters, kilograms, seconds, and radians.
Before emitting a joint, confirm:
- the parent and child link names are correct;
- the joint origin is expressed in the parent link frame;
- the child link frame is intended to coincide with the joint frame;
- the joint axis is expressed in the joint frame;
- revolute limits are radians;
- prismatic limits are meters;
- continuous joints do not get fake finite lower/upper limits.
Before emitting visual, collision, or inertial data, confirm:
- its origin is expressed in the owning link frame;
- mesh scale converts mesh source units into meters;
- collision geometry is intentionally simplified or intentionally identical to visual geometry;
- inertial origin represents the center of mass, not merely the visual origin.
The current `scripts/urdf` validation path accepts any non-empty mesh filename or URI, checks local file existence when the URI resolves locally, and leaves mesh-format loadability to the target URDF consumer. Visual and collision geometry may use `<mesh>`, `<box>`, `<cylinder>`, or `<sphere>`.
## Runtime Behavior
`scripts/urdf` runs only `gen_urdf()`. It does not regenerate external CAD, mesh/export, GLB/topology, render, SDF, or SRDF/MoveIt2 artifacts.
If URDF visual/collision mesh references depend on updated CAD or mesh outputs, regenerate those explicit targets separately with the owning CAD or mesh workflow.
Importing a generator module executes its top-level Python code. Keep top-level generator modules deterministic and side-effect-light. Place expensive or mutating work behind `gen_urdf()` only when the task explicitly requires it.
## Paths
Relative source targets and CLI output paths resolve from the current working directory.
Inside generator code, prefer paths derived from the generator file or package conventions over implicit shell working-directory assumptions. Mesh filenames emitted into URDF should be stable from the generated `.urdf` file's perspective or use a package URI understood by the consumer.
@@ -1,125 +0,0 @@
# URDF Workflow
Use this reference when editing robot-description structure, frame placement, mesh references, inertial data, or generated URDF output.
## Edit Loop
1. Find the Python source that defines `gen_urdf()`.
2. Treat that Python source as source of truth and the `.urdf` file as generated.
3. Identify target consumers and strictness requirements: visualization, TF tree, simulation, planning, or real robot integration.
4. Create or update the design ledger before changing frames, origins, axes, limits, mesh scales, or inertials.
5. Apply URDF frame semantics: joint origin in parent frame, child link frame at joint frame, joint axis in joint frame, visual/collision/inertial origins in link frame.
6. Edit links, joints, limits, axes, origins, inertials, materials, visual/collision geometry, and mesh filenames deliberately in the generator source.
7. Regenerate only the explicit URDF target with `scripts/urdf <source-file>`, `scripts/urdf <source-file> -o <output.urdf>`, or `scripts/urdf <source-file>=<output.urdf>`.
8. Let generation-time validation catch structural and semantic problems. Fix the generator source instead of hand-editing generated XML.
9. If mesh outputs changed, regenerate only the affected explicit outputs with the owning CAD or mesh workflow.
10. Hand generated or modified `.urdf` files to `$cad-viewer` for live viewer links when available.
11. When available, run a consumer smoke test and report what was not checked.
## Spatial-Reasoning Guardrails
LLMs are prone to plausible-looking spatial mistakes. Use these guardrails:
- Do not infer dimensions, handedness, axes, mesh units, or joint signs from vague descriptions.
- Do not silently mirror left/right parts unless the mirror transform and sign changes are explicit.
- Do not assume visual mesh origin equals link frame, collision frame, or center of mass.
- Do not assume CAD mesh units are meters. STL files often carry no reliable unit metadata.
- Do not encode a kinematic correction by offsetting only the visual mesh; correct the link and joint frames unless the visual mesh is genuinely offset.
- Preserve existing proven transforms unless the task explicitly requires changing them.
- Use named constants and comments for assumptions.
## Standard Link Tags
Use these tags for each link that represents physical robot geometry:
- `inertial`: mass, center of mass, and inertia tensor used by simulators.
- `visual`: display geometry and optional material.
- `collision`: contact geometry used by physics and planning.
Frame-only links, such as `base_footprint`, optical frames, or tool-center marker frames, may intentionally omit these tags when they represent no physical mass or geometry.
For movable physical links, avoid zero or missing mass unless the target simulator explicitly supports that modeling choice. If exact mass properties are unavailable, use a documented approximation and make the approximation easy to replace later.
## Joint Authoring
For every joint, confirm:
- parent and child direction are correct;
- joint origin is expressed in the parent link frame;
- child link frame is intended to coincide with the joint frame;
- non-fixed joint axis is expressed in the joint frame;
- positive motion is documented;
- revolute limits are radians;
- prismatic limits are meters;
- continuous joints are not given artificial finite lower/upper limits;
- fixed joints are used for frame relationships and rigid assemblies.
Supported joint types may vary by project runtime. If the validator/runtime supports only `fixed`, `continuous`, `revolute`, and `prismatic`, do not author `floating` or `planar` joints unless the consumer and validation path support them.
## Mesh References
URDF mesh filenames should be stable from the generated URDF file's perspective or use a package URI convention understood by the consumer.
The current `scripts/urdf` validation path accepts any non-empty mesh filename or URI. Local relative mesh paths are checked relative to the generated URDF file; `package://...` and remote references are left unresolved with warnings unless a package map is supplied through `read_urdf_source()`.
When using package URIs, confirm the consuming environment resolves the package root the same way as the generated URDF expects.
Do not use generated URDF XML as the source of truth for mesh placement. Prefer deriving visual mesh references from the same source data that owns the mesh instance placement.
When mesh references point to generated assets, keep the ownership clear:
- CAD or mesh workflows own mesh generation;
- URDF generation owns references, scales, and placements;
- SRDF/MoveIt workflows own semantic groups, named joint poses via `<group_state>`, and planning metadata.
## Collision Geometry
Add collision geometry under each `<link>` that should participate in physics, contact, or collision-aware planning. Do not encode collision behavior on joints.
Use one or more `<collision>` blocks per link. The `<origin>` is expressed in the link frame, just like `<visual>`, and mesh scales must match the units of the exported mesh:
The current `scripts/urdf` validator allows visual and collision geometry to use `<mesh>`, `<box>`, `<cylinder>`, or `<sphere>`.
```xml
<link name="forearm_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://robot_description/meshes/forearm.stl" scale="0.001 0.001 0.001" />
</geometry>
</visual>
<collision>
<origin xyz="0.12 0 0" rpy="0 1.57079632679 0" />
<geometry>
<cylinder radius="0.035" length="0.24" />
</geometry>
</collision>
</link>
```
Prefer simplified collision geometry over detailed visual meshes. Good options, from simplest to most specific:
- primitive `<box>`, `<cylinder>`, or `<sphere>` geometry when it approximates the part well;
- a coarse, closed collision mesh exported from CAD;
- the visual mesh as a temporary fallback for loading and smoke tests.
In generator sources, model collisions explicitly rather than hand-editing generated URDF. A common pattern is to add a `collisions` collection beside `visuals` in each link spec and emit it with the same origin and scale helper code used for visual meshes.
## Inertials
For each physical link, use an explicit `inertial` block when the target simulator or dynamics consumer needs mass properties.
The inertial origin is the center of mass in the link frame. It is not automatically the visual mesh origin, collision origin, or link origin.
When exact mass properties are unavailable, use a documented approximation and make it easy to replace. Mark approximate mass, COM, and inertia constants clearly.
## Smoke Tests
After generation-time validation, use the most relevant available smoke test:
- load in RViz or equivalent visualization to inspect visible placement;
- run robot_state_publisher or equivalent to check the TF tree;
- load in Gazebo/Ignition or another simulator for physics consumers;
- load in MoveIt only after URDF structure is stable, then handle semantic data through the SRDF workflow.
Report the smoke tests run and any skipped checks that would materially affect confidence.

Some files were not shown because too many files have changed in this diff Show More