refactor: add DesignIR distillation pipeline
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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 +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]
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 +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())
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user