Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+209
View File
@@ -0,0 +1,209 @@
---
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.
@@ -0,0 +1,4 @@
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."
@@ -0,0 +1,123 @@
# 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.
@@ -0,0 +1,139 @@
# 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.
@@ -0,0 +1,56 @@
# 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
+50
View File
@@ -0,0 +1,50 @@
---
name: cad-router
description: Route natural-language CAD, mechanical-part, assembly, 3D-printing, and robot-description requests across text-to-cad, SimpleCADAPI, CADAM, and the CADDesigner workflow, then enforce shared generation, validation, 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 the best modeling backend and preserve editable sources.
---
# CAD Router
Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use all four projects by role: text-to-cad owns orchestration, general CAD, validation, and viewing; SimpleCADAPI owns specialized mechanical families; CADAM owns SCAD execution; CADDesigner supplies the ECIP refinement and repair workflow without starting its separate LLM service.
## Required workflow
1. Convert the request into a brief: part type, dimensions, units, manufacturing intent, required outputs, assembly relationships, editability, and validation targets.
2. Run the deterministic router from this skill directory:
```bash
python3 scripts/route.py "<request>" --explain
```
Add explicit signals when known: `--output step`, `--output stl`, `--manufacturing machining|printing|laser-cutting`, `--existing-model`, `--assembly`, or `--browser-controls`.
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.
5. Always apply CADDesigner's `caddesigner_ecip` profile. When the route also 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. Never change backend during an edit unless conversion is explicitly requested; modify the recorded source of truth.
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.
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.
## Routing rules
- Prefer an explicit user backend when it can produce the required primary format.
- 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.
- `scripts/capabilities.json` is the machine-readable V1 registry used by `scripts/route.py`.
@@ -0,0 +1,4 @@
interface:
display_name: "CAD Router"
short_description: "Route CAD work across four complementary projects."
default_prompt: "Use $cad-router to select and run the best CAD backend, validate the result, and open only the task outputs in CAD Viewer."
@@ -0,0 +1,74 @@
# Backend and artifact contract
## Router input
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.
- 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.
- Geometry and visual validation targets.
## Route decision
Record:
- `selected_backend`: `build123d`, `simplecadapi`, or `cadam`.
- 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.
## Task manifest
Write `cad-task.json` beside the task artifacts with this minimum shape:
```json
{
"schema_version": "1.0",
"request": "Create a 120 mm connecting rod",
"route": {
"selected_backend": "build123d",
"runner_skill": "cad",
"project": "text-to-cad",
"fallback_order": ["simplecadapi", "cadam"],
"workflow_profiles": ["caddesigner_ecip"]
},
"source": {
"path": "connecting_rod.py",
"format": "python",
"backend": "build123d"
},
"artifacts": [
{"path": "connecting_rod.step", "role": "primary"},
{"path": "connecting_rod.stl", "role": "secondary"}
],
"parameters": {},
"assumptions": [],
"validation": [],
"viewer_links": []
}
```
Paths are relative to the task directory unless an external input must remain absolute.
## Backend adapter requirements
Any future executable adapter must:
1. Accept an explicit task directory and explicit source/output paths.
2. Never scan or generate an entire workspace implicitly.
3. Preserve a human-editable source of truth.
4. Report backend name and version.
5. Return nonzero on generation failure.
6. Emit machine-readable artifact paths and validation facts.
7. Avoid importing code from sibling Skill directories; call installed tools or self-contained runtimes.
8. Keep permanent CAD artifacts under the repository `models/` root when working inside this repository.
9. Start CAD Viewer at the task directory, not the repository-wide examples directory.
@@ -0,0 +1,59 @@
# Backend capabilities
## build123d through CAD Skill
Use as the default STEP-first backend.
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.
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.
## SimpleCADAPI
Use for its standard mechanical factories and replayable modeling graph.
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.
- 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.
## CADDesigner workflow profile
CADDesigner is not a fourth geometry engine. Apply its ECIP workflow to every route, then enable requirement expansion and visual repair when requirements are vague, reference images carry important intent, or visual complexity makes one-pass generation unreliable. Do not launch CADDesigner's separate LLM/Redis stack:
1. Refine the request into a measurable brief.
2. Confirm important fit- or safety-critical unknowns.
3. Generate and execute backend source.
4. Validate deterministic geometry facts.
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.
@@ -0,0 +1,107 @@
#!/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;
}
@@ -0,0 +1,56 @@
{
"schema_version": "1.0",
"backends": {
"build123d": {
"project": "text-to-cad",
"runner_skill": "cad",
"source_formats": ["py"],
"primary_formats": ["step", "stp"],
"secondary_formats": ["stl", "3mf", "glb"],
"strengths": [
"general mechanical parts",
"machined features",
"source-level assemblies",
"existing STEP inspection and modification",
"geometry measurement and validation"
],
"source_of_truth": "build123d Python generator"
},
"simplecadapi": {
"project": "SimpleCADAPI",
"runner_skill": "simplecadapi",
"source_formats": ["py", "json"],
"primary_formats": ["step", "stp", "fcstd"],
"secondary_formats": ["stl", "json"],
"strengths": [
"gears, ring gears, racks, and cycloidal discs",
"bearings, reducers, and joint actuators",
"replayable operation graphs",
"semantic tags and topology queries",
"FreeCAD interchange"
],
"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"
}
},
"workflow_projects": {
"CADDesigner-Code": {
"profiles": ["caddesigner_ecip", "requirement_refinement", "visual_repair"],
"role": "Explicit context, requirement expansion, execution feedback, and bounded visual repair without launching a second LLM agent"
}
}
}
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
from dataclasses import dataclass, field
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
class BackendScore:
name: str
score: int
reasons: list[str] = field(default_factory=list)
def add(self, points: int, reason: str) -> None:
self.score += points
self.reasons.append(f"{points:+d} {reason}")
KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
"special_mechanical": (
"gear", "gears", "齿轮", "齿条", "rack", "ring gear", "内齿圈",
"cycloidal", "摆线", "bearing", "轴承", "planetary", "行星",
"reducer", "gearbox", "减速器", "减速箱", "joint actuator", "关节执行器",
),
"general_mechanical": (
"bracket", "支架", "connecting rod", "连杆", "crank", "曲柄",
"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",
"像", "参考图", "reference",
),
}
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", "拓竹")),
)
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 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": {
"available": importlib.util.find_spec("build123d") is not None,
"probe": f"python={sys.executable}",
},
"simplecadapi": {
"available": importlib.util.find_spec("simplecadapi") is not None,
"probe": f"python={sys.executable}",
},
"cadam": {
"available": cadam_runtime_available(),
"probe": str(cadam_root()),
},
}
def route(args: argparse.Namespace) -> dict[str, object]:
request = args.request.strip()
text = request.lower()
outputs = normalize_formats(args.output)
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"]):
scores["simplecadapi"].add(65, "supported standard mechanical family")
scores["build123d"].add(8, "can model the family generically")
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")
if args.existing_model:
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")
forced_backend = args.backend if args.backend != "auto" else None
ranked = sorted(scores.values(), key=lambda item: (-item.score, item.name))
if forced_backend:
selected = scores[forced_backend]
selected.reasons.append("explicit backend override")
fallback = [item.name for item in ranked if item.name != forced_backend]
else:
selected = ranked[0]
fallback = [item.name for item in ranked[1:]]
profiles: list[str] = ["caddesigner_ecip"]
if contains_any(text, KEYWORD_GROUPS["visual_complexity"]):
profiles.extend(["requirement_refinement", "visual_repair"])
if not any(char.isdigit() for char in request) and contains_any(
text,
KEYWORD_GROUPS["general_mechanical"] + KEYWORD_GROUPS["special_mechanical"],
):
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]
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] = {
"schema_version": "1.0",
"request": request,
"selected_backend": selected.name,
"runner_skill": backend_info["runner_skill"],
"project": backend_info["project"],
"adapter": backend_info.get("adapter"),
"confidence": confidence,
"backend_scores": [
{"backend": item.name, "score": item.score, "reasons": item.reasons}
for item in sorted(scores.values(), key=lambda item: (-item.score, item.name))
],
"fallback_order": fallback,
"workflow_profiles": profiles,
"downstream_skills": downstream,
"requested_outputs": outputs,
"source_of_truth": backend_info["source_of_truth"],
"project_contributions": {
"text-to-cad": "core Skill host, build123d CAD backend, 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",
"CADDesigner-Code": "ECIP brief expansion, execution feedback, and bounded visual repair workflow",
},
"availability": probe_backends() if args.probe else None,
}
return result
def explain(result: dict[str, object]) -> str:
lines = [
f"Selected backend: {result['selected_backend']}",
f"Runner skill: ${result['runner_skill']}",
f"Project: {result['project']}",
f"Confidence: {result['confidence']}",
f"Source of truth: {result['source_of_truth']}",
]
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)}")
lines.append("Ranking:")
for item in result["backend_scores"]:
lines.append(f" {item['backend']}: {item['score']}")
for reason in item["reasons"]:
lines.append(f" - {reason}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Route a CAD request to a V1 modeling backend.")
parser.add_argument("request", help="Natural-language CAD request.")
parser.add_argument(
"--backend",
choices=("auto", "build123d", "simplecadapi", "cadam"),
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("--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("--explain", action="store_true", help="Print a human-readable decision instead of JSON.")
parser.add_argument("--manifest", type=Path, help="Also write the route decision as JSON to this explicit path.")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result = route(args)
payload = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.manifest:
args.manifest.parent.mkdir(parents=True, exist_ok=True)
args.manifest.write_text(payload, encoding="utf-8")
print(explain(result) if args.explain else payload, end="\n" if args.explain else "")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -0,0 +1,21 @@
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.
+95
View File
@@ -0,0 +1,95 @@
---
name: cad-viewer
description: Start or reuse CAD Viewer and return review links for explicit CAD, implicit CAD, robot-description, and G-code files. Use when visually reviewing `.step`, `.stp`, `.implicit.js`, `.implicit.mjs`, `.glb`, `.stl`, `.3mf`, `.gcode`, `.dxf`, `.urdf`, `.srdf`, or `.sdf` files, especially when handed off from CAD, implicit-cad, G-code, URDF, SRDF, or SDF generation skills.
---
# CAD Viewer
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 open existing or newly generated CAD, implicit CAD,
robot-description, DXF, or plain FDM G-code files in CAD Viewer and hand back
live review links. The expected input is one or more explicit file paths.
## Start Viewer
Start or reuse one local CAD Viewer with `npm run agent:start`, passing the
absolute artifact directory as `--dir`. The `agent:start` launcher owns port
selection, compatible-server reuse, directory activation, and the `?dir=` query
parameter. Dev-mode viewers are reused only for matching git identities; dist
bundle viewers can be reused across git branches when their viewer versions
match. It activates reused servers through the Viewer's lightweight directory
activation API, without requiring agents to probe ports or trigger catalog
scans manually. Use the Viewer URL printed by `agent:start` as-is, then add only
a `file=` query value for the artifact you want to review.
Choose `--dir` as the absolute directory that contains the model
artifacts and sidecars, commonly `<repo>/models` or the consuming project's
equivalent model directory. The `file=` value must be relative to that `--dir`.
Do not manually choose ports, probe servers, rewrite `?dir=`, or start a
separate Viewer just to change directories.
Run from this skill directory:
```bash
npm --prefix scripts/viewer run agent:start -- --host 127.0.0.1 --dir <absolute-model-root>
```
Use the printed Viewer URL and append `file=`:
```bash
http://127.0.0.1:<printed-port>/?dir=/absolute/project/models&file=path/to/model.step
```
If a non-Viewer process or another worktree's Viewer occupies the candidate
port, the launcher will continue automatically. In sandboxed agent environments,
local binding or probe failures such as `EPERM` or `EACCES` can be expected;
rerun the same command with the needed permission/escalation.
## Links
- Before returning any `file=` link, resolve `<dir>/<file>` and confirm the
artifact exists. Pass the generated artifact (e.g. `.step`), not its
generator source (e.g. `.py`). If the resolved path is missing, do not
return the link, and instead report the problem and point to the correct
generated artifact path.
- Return one Viewer URL per requested file.
- Start/reuse the Viewer once per absolute directory `--dir`, then append
`file=<path>` for each requested file. The file path must be relative to
`--dir`.
- For directory-only review links, return the URL printed by `agent:start`
without adding `file=`.
- Do not stop an existing Viewer server unless the user asks.
- If Viewer startup fails, report the failure and continue with the owning skill's non-GUI validation or artifacts.
## Claude Preview
The viewer port is dynamic — it is chosen at startup and may differ across
worktrees. To integrate with the Claude Preview tool, add `--json` to the
`agent:start` command:
```bash
npm --prefix scripts/viewer run agent:start -- --host 127.0.0.1 --dir <absolute-model-root> --json
```
The launcher writes a JSON result line to stdout after the human-readable lines.
Parse it by taking the last line of stdout that begins with `{`:
```json
{"url":"http://127.0.0.1:<port>/?dir=<absolute-model-root>","port":<port>,"action":"reuse"}
```
`action` is `"reuse"` when an existing server was reused and is immediately
ready, or `"start"` when a new server process was spawned and may still be
initializing. For a `"start"` result, probe `GET /__cad/server` on the base
URL (e.g. `http://127.0.0.1:<port>/__cad/server`) until it returns HTTP 200
before passing the `url` value to the Claude Preview tool.
## References
- Read `references/development.md` when the user asks to modify, debug, or
iterate on CAD Viewer source.
- Read `references/viewer-features.md` when you need supported file types, Viewer controls, or file-specific feature details.
- Read `references/moveit2-server.md` only when the user specifically needs optional SRDF MoveIt2 IK or path-planning controls.
@@ -0,0 +1,4 @@
interface:
display_name: "CAD Viewer"
short_description: "Open CAD, G-code, and robot files in Viewer."
default_prompt: "Use $cad-viewer to start CAD Viewer and return review links for explicit CAD, G-code, or robot-description files."
@@ -0,0 +1,12 @@
# CAD Viewer Development
Use this reference only when the user asks to modify, debug, or iterate on CAD
Viewer source.
CAD Viewer source development happens in the root
source workspace, not inside this generated skill runtime.
If you are not currently working directly in the editable source workspace, ask
the user for the correct checkout and run the agent from that repository.
Once inside the repository, read root `AGENTS.md` for guidance on modifying and running the Viewer.
@@ -0,0 +1,29 @@
# MoveIt2 Server
Load this only when the user specifically needs CAD Viewer's optional SRDF IK or path-planning controls. Plain SRDF review and normal Viewer links do not require MoveIt2.
## Start
Run from the `cad-viewer` skill directory:
```bash
npm --prefix scripts/viewer run moveit2:setup
npm --prefix scripts/viewer run moveit2:check
npm --prefix scripts/viewer run moveit2:serve
```
The server defaults to `ws://127.0.0.1:8765/ws`. CAD Viewer connects to that URL when `VIEWER_MOVEIT2_WS_URL` or the browser `?moveit2Ws=` query points at it.
Use the configured ROS 2 / MoveIt2 environment. Do not install ROS 2 or MoveIt2 packages into the repository CAD `.venv`.
## Viewer Controls
Open an `.srdf` file, expand the right-side `MoveIt2` sheet, and use:
- Status: linked SRDF and MoveIt2 server status.
- Target: planning group, end effector, target frame, and X/Y/Z target coordinates.
- Solver: IK timeout, attempts, and tolerance.
- Planning: pipeline, planner ID, plan time, velocity scale, and acceleration scale.
- Actions: select pose from the model, reset to the current pose, solve pose, or plan to pose.
Report whether the environment check passed, whether pose solving/planning worked, and any viewer/server error text.
@@ -0,0 +1,22 @@
# CAD Viewer Features
Load this only when a task needs Viewer file-support details or UI control guidance.
## Supported Files
- `.step`, `.stp`: STEP/STP review through hidden GLB sidecars; supports assembly trees, part hide/show, inspect/focus, face/edge/vertex/part selection, copied `#...` CAD references, display modes, clip planes, and optional STEP module parameters/animations when a sidecar module exists.
- `.stl`, `.3mf`, `.glb`: mesh viewing with orbit/pan/zoom, screenshots, appearance controls, and solid/wireframe display where available.
- `.dxf`: read-only flat-pattern viewing, plus plate thickness and bend direction/angle controls when bend preview data is available.
- `.gcode`: diagnostic toolpath preview; shows layer-colored extrusion ribbons, optional travel moves, visible-layer and detail controls, feature markers, movement stats, bounds, and parser warnings. It does not reslice, simulate firmware, or replace G-code validation.
- `.urdf`: robot link/mesh viewing with movable joint sliders, reset pose, and copied joint values.
- `.srdf`: linked-URDF viewing with planning groups, group-state presets, joint controls, and optional MoveIt2 IK/planning controls.
- `.sdf`: SDF model/world viewing with metadata, counts, warnings, and joint controls when available.
## Controls
- Navigation: left-drag to orbit, right/middle-drag to pan, wheel or pinch to zoom, and Arrow/WASD keys to orbit. Use the view sphere for top/bottom/front/back/left/right views; click its center for the default isometric view.
- File browser: toggle the left CAD Viewer sidebar, search files/ids/paths, expand folders, select entries, or switch files from the breadcrumb menus.
- Floating toolbar: `Select` copies STEP topology references, `Draw` opens annotation tools, `Select Pose` appears for robot target picking when available, `Open orbit preview` starts an auto-rotating preview, and the copy/download buttons capture screenshots.
- Drawing tools: freehand, line, arrow, expand, rectangle, circle, fill, erase, undo, redo, and clear.
- File sheet: open the right sheet for file-specific controls such as STEP tree/parameters, G-code layers/detail/travel, DXF thickness/bends, URDF/SRDF/SDF joints and metadata, plus display and appearance controls.
- Display/appearance: use the file sheet and theme menu for solid/wireframe/x-ray modes, STEP clipping, an independent Explode toggle for animated vertical STEP disassembly, surface colors, edge visibility/detail, highlight styling, backdrop, floor/grid, lighting, theme presets, and color mode.
@@ -0,0 +1 @@
--editable ./scripts/viewer/packages/cadpy
+1
View File
@@ -0,0 +1 @@
../../../viewer
+21
View File
@@ -0,0 +1,21 @@
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.
+102
View File
@@ -0,0 +1,102 @@
---
name: cad
description: Create, modify, inspect, and validate STEP-first parametric CAD parts and assemblies. Use for natural-language CAD specs, reference images, 2D technical drawings, STEP/STP generation or direct inspection, Python CAD source, source-level joints, selector references, geometry facts, measurements, mating deltas, snapshots, and secondary STL/3MF/native GLB outputs from CAD geometry.
---
# CAD generation, inspection, 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 parametric CAD models from natural-language requirements, generate validated STEP/STP artifacts, inspect geometry references, and return checked outputs. Treat STEP as the primary CAD artifact. Treat STL, 3MF, and native GLB as secondary export workflows that branch from a STEP-first process. For assemblies, prefer `cadpy.assembly.AssemblyHelper` with source-level build123d joints, named mating datums, and native labels when the parts have functional assembly relationships.
There are two ways into the STEP workflow: generate from build123d Python source (the default when designing from scratch or modifying a generated model), or import an existing STEP/STP file directly (when no generator exists or the user explicitly targets the STEP file). Both produce the same inspectable artifacts.
## Use this skill when
Use this skill when the user asks for CAD files, STEP/STP files, build123d source, selector refs such as `#o1.2.f1`, mechanical parts, assemblies, enclosures, brackets, fixtures, holes, counterbores, countersinks, slots, pockets, bosses, standoffs, ribs, fillets, chamfers, shells, source-level joints, mating, or measurements. Also use it when the user supplies reference images or 2D technical drawings of a part to reproduce or take design intent from.
Also use it when the user asks for STL, 3MF, or native GLB output from CAD geometry. Keep those workflows secondary and load `supported-exports.md` for details. For 2D DXF drawings, use the `$dxf` skill; when a DXF projects from a 3D part, this skill owns the STEP geometry and `$dxf` owns the drawing.
Do not use this skill for render-only concept art, CAM toolpaths, engineering certification, FEA conclusions, architectural BIM, or freehand illustration unless the user also needs CAD geometry.
## Default assumptions
Use these defaults unless the user specifies otherwise. These are first-pass modeling defaults, not manufacturability, tolerance, or certification claims:
- Units: millimeters.
- Origin: per the part-type defaults in `references/positioning.md`; center of the main part or assembly when nothing better applies.
- Base plane: XY.
- Up/extrusion axis: positive Z.
- Output geometry: closed, positive-volume solids unless the user requests surfaces or construction geometry.
- STEP structure: one valid solid, a compound of solids, or a labeled assembly compound.
- Assembly structure: fixed root part, part-local frames, named mating datums, `AssemblyHelper` relationships backed by build123d joints where applicable, explicit generated placements, and verbose native labels.
- Small plastic enclosure wall: 2.0-3.0 mm when unspecified.
- Cosmetic fillet: 1.0-3.0 mm when safe for local geometry.
- M3/M4/M5 normal clearance holes: 3.4/4.5/5.5 mm unless another standard is requested.
Ask one focused clarification question only when missing information makes the model impossible, fit-critical, safety-critical, or compliance-bound. Otherwise proceed with explicit assumptions.
## Tools and paths
From the CAD skill directory, the launcher shape is:
```bash
python scripts/step ... # STEP generation, GLB/topology artifacts, mesh sidecars
python scripts/inspect ... # refs, measure, align, frame, diff
python scripts/snapshot ... # PNG/GIF visual review packets
```
Use the active project Python interpreter; treat `python` in examples as an interpreter placeholder. Use `python scripts/<tool> --help` for the complete current command interface; reference docs show recommended workflows, not every flag.
Target paths resolve from the command's current working directory, not from the skill directory. Run commands from the workspace that owns the artifacts and pass cwd-relative target paths so project CAD files never resolve accidentally under the skill directory. Keep a STEP output and its Python generator in the same directory with the same basename unless the user explicitly requests otherwise.
CAD references are `#...` selector tokens local to a target, for example `#o1.2` or `#o1.2.f1`. Pass the STEP/CAD file as a separate target argument when using CAD CLIs.
## Required workflow
Scale depth to the task: a simple part needs a short brief and few spec-driven checks; assemblies and fit-critical work need full positioning and alignment validation.
1. **Classify the task.** New part, new assembly, source modification, direct STEP/STP inspection, reference selection, measurement/alignment check, snapshot review, or secondary output request.
2. **Load only the needed references.** Use the triggers below instead of reading the whole reference set.
3. **Write a natural-language CAD brief.** Extract dimensions, units, coordinate convention, feature intent, output paths, assumptions, and validation targets from all provided inputs — prose, reference images, technical drawings. Use `references/cad-brief.md`.
4. **Check named purchasable components.** When an assembly includes named off-the-shelf actuators, servos, motors, electronics boards, connectors, or other purchasable components, search `$step-parts` before creating simplified placeholder geometry. If no exact match is found, record the miss and then use a documented envelope.
5. **Plan before coding.** Define parameters, intent labels, source paths, expected bounding boxes, and any mating/positioning datums before editing.
6. **Edit source, not generated artifacts.** Author build123d Python with `gen_step()`. When a Python generator exists, run `scripts/step` on the generator, never on its exported STEP. Use direct STEP/STP targets (`--kind part|assembly`) only for imports with no generator or when the user explicitly identifies the STEP/STP file as the target.
7. **Generate explicit targets.** Run `scripts/step` on explicit targets only; do not run directory-wide generation.
8. **Validate geometrically.** Run `scripts/inspect refs <step-or-cad-target> --facts --planes --positioning` as the baseline, then verify the dimensions and relationships the user's spec calls out with targeted `measure`, `align`, `frame`, or `diff` checks.
9. **Snapshot the primary STEP — snapshot validation is mandatory.** After creating or visibly updating a primary STEP/STP part or assembly, ALWAYS run CAD `scripts/snapshot` against it and review the output; deterministic checks passing is not a reason to skip. The only skip cases are documented in `references/snapshot-review.md` (no visible geometry changed, or no valid artifact exists); report the reason when skipping.
10. **Repair and rerun.** If a check fails, change the smallest responsible source section, regenerate, and rerun the failed validation.
## Handoff
After completing CAD work that creates or modifies `.step`, `.stp`, `.stl`, `.3mf`, or native `.glb` 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 and rely on CLI inspection plus snapshots instead of silently omitting the handoff. This rule applies to every workflow in this skill, including secondary STL/3MF/GLB outputs.
When verification snapshots are generated, 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.
## Non-negotiables
- Keep STEP as the primary validated CAD artifact. Generated STEP/STP, STL, 3MF, GLB/topology outputs, and render sidecars are derived artifacts; STL/3MF are secondary unless the user explicitly says otherwise.
- Use named parameters, closed solids, verbose native build123d labels, and source-controlled geometry intent.
- Author assembly positioning in source. `references/positioning.md` is authoritative for `AssemblyHelper`, build123d joints, explicit `Location` transforms, and alignment validation.
- Do not use `git status`, `git diff`, or file-size churn as CAD comparison for large exported STEP/STP, GLB/topology, STL, or 3MF artifacts. Compare source changes, `scripts/inspect` summaries, snapshots, or generated topology output instead; use path-limited git status only for bookkeeping.
- Report only checks that actually ran or are directly supported by tool output.
## Progressive references
Load these files only when their trigger applies:
- `references/cad-brief.md` — converting prose, reference images, and technical drawings into a CAD brief.
- `references/build123d-modeling.md` — build123d modeling patterns, topology, selectors, features, labels.
- `references/step-generation.md` — STEP generation from Python source, direct STEP/STP imports, and post-generation steps.
- `references/inspection-and-validation.md` — validation sequence, selector refs, facts, planes, measurements, alignment, diff, frame, and validation reporting.
- `references/snapshot-review.md` — mandatory snapshot policy, packet sizing, targeted views, and converting visual findings into geometry checks.
- `references/positioning.md` — part-local datums and origins, assembly transforms, build123d joints, CLI alignment validation, and positioning reports.
- `references/parameters.md` — parameterizing or animating a STEP model: source parameters, `.step.js` sidecar modules, viewer controls, and animation design.
- `references/supported-exports.md` — secondary STL/3MF/native GLB sidecar workflows.
- `references/repair-loop.md` — diagnosis and repair procedures.
Final responses should include generated files, returned `$cad-viewer` viewer links, verification snapshots, validation actually run, assumptions, and caveats. Use `references/inspection-and-validation.md` for report structure.
@@ -0,0 +1,4 @@
interface:
display_name: "CAD"
short_description: "Generate and validate CAD artifacts."
default_prompt: "Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, handing supported outputs to $cad-viewer when available."
@@ -0,0 +1,155 @@
# build123d modeling patterns
Read this file when writing or repairing build123d Python source.
## Modeling objective
Create a valid STEP-ready BREP model, not a visual mesh. Prefer closed solids, explicit labels, and stable parametric dimensions. Define `gen_step()` returning the STEP-ready shape or labeled compound; the CLI owns output paths (see `step-generation.md`).
## Design strategy
Decide how the part is constructed before writing geometry code:
- **Choose the construction that makes the spec's dimensions direct parameters.** Profile-driven shapes get one closed sketch plus `extrude`/`revolve`/`sweep`/`loft`; block-and-feature parts get a base solid plus subtractive features. Prefer whichever construction lets the user's controlling dimensions appear as named parameters instead of derived values.
- **Decide part vs assembly before modeling.** Bodies that are separately manufactured, purchased, or movable belong in a labeled assembly (see `positioning.md`); monolithic manufacturing intent gets a single fused solid. Avoid unlabeled compounds of solids — multi-body output without occurrence labels loses traceability in inspection and viewer review.
- **Pick the origin and orientation from the functional datum before sculpting.** Model on the mating interface, mounting plane, or symmetry axis; see `positioning.md` for part-type origin defaults.
- **Order operations so fragile steps come last and failures localize.** Base solid → major additions → subtractive features → shell → through-wall holes → fillets and chamfers last. Fillets are the most failure-prone operation and every boolean invalidates selectors, so postpone them. Structure the source so each feature is a named step — a per-feature function or a distinct intermediate variable — so a failed operation points at exactly one feature and a parameter change touches one obvious place.
- **Overshoot boolean tools.** Extend cutting tools past the faces they enter and exit; for through-cuts, go roughly 1 mm beyond both faces. Coincident or coplanar tool/target faces are a classic kernel failure. Cut repeated or patterned features in one combined operation.
- **Sanity-check proportions before generating.** Compare the expected bounding box against the real-world object, wall thickness against overall size, and feature positions against edges and neighboring features. Order-of-magnitude and collision errors pass geometric validation but fail visual review.
## Topology stack
Think in this order:
```text
Vertex → Edge → Wire → Face → Shell → Solid → Compound
```
For assemblies, use these repo topology terms consistently:
- **Occurrence**: a placed node in the assembly tree. An occurrence has a parent, transform, path, and user-facing role such as `lid` or `m3_screw:front_left`.
- **Shape**: an exported geometry/body inside an occurrence. Shape rows own topology; faces and edges belong to a shape, and the shape belongs to an occurrence.
- **Face/edge**: selectable topology owned by a shape. Do not assume arbitrary faces or edges have persistent intent labels; inspect them by occurrence, shape, ordinal, surface/curve type, and measured geometry.
When inspecting topology, follow `assembly occurrence -> shape/body -> faces -> edges`. Every face/edge row should be traceable through both `occurrenceId` and `shapeId`.
For normal STEP output, return one of:
- a valid `Solid`
- a compound of valid solids
- a labeled assembly compound
Avoid returning loose wires, open faces, or construction surfaces unless the user explicitly requested them.
## Parameters first
Put meaningful dimensions in named variables:
```python
width = 80.0
depth = 50.0
thickness = 6.0
hole_diameter = 4.5
hole_offset_x = 30.0
hole_offset_y = 17.5
```
Avoid burying important numbers inside geometry calls.
## Coordinate system
Declare or comment the convention:
```text
Origin: center of primary part or chosen mating datum
XY: main base/sketch plane
+Z: up/extrusion direction
```
Use `Location`, `Plane`, and `Axis` intentionally. For positioning-sensitive tasks and source-level assembly relationships, read `positioning.md`.
## Builder contexts
Use the context that matches the geometry:
```python
with BuildLine() as path:
...
with BuildSketch() as profile:
...
with BuildPart() as part:
...
```
Typical flow:
```text
curves/paths → sketches/profiles → solids/features → labels → STEP
```
## Selection practices
Avoid fragile topology order when possible. Select by:
- axis or normal
- location or bounding position
- plane grouping
- feature intent
- stable construction plane
- inspected local selector ref for downstream validation
For source operations, prefer robust selectors such as top/bottom by axis or position rather than arbitrary list indexes.
## Assemblies and positioning
For assemblies, keep this file focused on BREP modeling patterns and labels. Use `positioning.md` as the single source of truth for:
- part-local coordinate conventions
- when to use `cadpy.assembly.AssemblyHelper`, build123d joints, or explicit `Location` transforms
- `connect_to()` behavior
- CLI `inspect align` as read-only selector-pair alignment validation
- frame, measure, and positioning report expectations
## Labels and assemblies
Label every exported part and assembly child with native build123d labels. Prefer concise intent labels through `cadpy.assembly` helpers:
```python
from cadpy.assembly import AssemblyHelper, label_shape
asm = AssemblyHelper("electronics_enclosure")
base = asm.add(make_base(), "base")
lid = asm.add(make_lid(), "lid")
boss = label_shape(Cylinder(radius=3.0, height=12.0), "m3_boss", "front_left")
```
Do not prefix labels with topology categories like assembly, component, feature, datum, mate, or hardware. The assembly tree and topology inspection already expose those structural categories. Use labels for the intent topology cannot reliably infer: role, placement, interface, repetition, or mating purpose. Feature labels survive STEP export best when the feature remains a labeled child shape in a `Compound`; boolean-subtracted or fused feature history should be represented by source parameters, named datums, and validation refs instead of assumed persistent feature labels.
Label for inspection:
- Label the root assembly.
- Label every exported part, subassembly/module, and repeated component occurrence.
- Use occurrence labels for assembly role and placement, especially repeated parts: `m3_screw:front_left`, `m3_screw:rear_right`.
- Use shape labels for retained exported geometry/body roles where useful.
- Use feature/datum labels only when that geometry remains exported as a child shape.
- Use named mate datums for source-level positioning intent, then validate the exported STEP topology and occurrence frames.
Occurrence and shape labels are exported through STEP names and surfaced in `STEP_topology` when available. The viewer uses occurrence labels for assembly/tree references and shape labels for shape references. Faces and edges inherit their context from `occurrenceId` and `shapeId`; do not promise persistent face/edge intent labels unless explicit tested support exists.
For repeated parts, keep occurrence labels, transforms, or joint connections explicit and inspect frames/positioning after generation.
## Common failure modes
- Fillet radius larger than local edge geometry.
- Open sketch profile produces invalid or missing face.
- Face selector changes after a boolean or fillet.
- Part origin is arbitrary and later alignment checks become ambiguous.
- Source-level joints are treated as if they were persistent STEP constraints rather than one-time source placement operations.
- Joint labels are missing, duplicated, or attached to the wrong local datum.
- `.connect_to()` fixes the wrong side of the relationship, moving the part intended to remain fixed.
Use `repair-loop.md` when generation or validation fails.
@@ -0,0 +1,130 @@
# CAD brief
Read this file when converting a user's request — prose, reference images, technical drawings, or a combination — into a CAD brief. The brief is an internal note-taking scaffold; do not ask the user to fill it out, and do not require the user to provide JSON. If the user supplied JSON voluntarily, extract the same information but continue the workflow in prose notes and build123d source.
## Goal
Convert the request into an actionable modeling brief before writing source or running tools. Every input modality funnels into the same brief; the downstream workflow does not change.
The brief should answer:
- What is being modeled, and is it a part, assembly, modification, inspection task, or secondary output request?
- What dimensions and units are specified, and which missing dimensions are inferable?
- Which features are required?
- Which faces, axes, origins, joints, or interfaces control positioning?
- What output files are requested?
- What must be validated before success is reported?
When inputs conflict, dimensioned sources win over image proportions. When two dimensioned sources conflict — prose says one value, a drawing callout says another — flag the conflict instead of silently choosing.
## Reference images
An image without stated dimensions is design intent, not a spec:
- Establish scale from one stated dimension or a known object in frame; if neither exists and fit matters, that is the one clarification question to ask.
- Estimate remaining proportions from the image and record them as assumptions like any other inferred value.
- Distinguish reproduction ("model this part") from inspiration ("something like this") in the brief; reproduction raises fidelity expectations, inspiration leaves freedom.
- For reproduction, plan a snapshot from the reference image's viewpoint and compare it against the image during visual review.
## Technical drawings
A drawing is a dimensioned contract. Extract it systematically:
- Read the title block and notes first: units, projection convention, revision, disclaimers.
- Identify which view is which — front/top/side, sections, details, iso — and which model axes each maps to before extracting numbers. Trust callouts and view labels, not layout conventions. Section views are the source of truth for internal features: bores, counterbore and blind-hole depths, wall sections.
- Convert every dimension callout into a named parameter and a validation target. Multiplicity (`4X`), `TYP.`, and thread/counterbore/countersink callouts expand into features plus checks.
- Never scale undimensioned geometry off the image. Derive it from stated dimensions when constrained; otherwise assume and report.
- Cross-check features across views; when views disagree, prefer the dimensioned view and flag the conflict.
- Success for a drawing-driven model: every drawing dimension is either verified by `measure`/`refs` after generation or explicitly reported as not verified.
## Brief format
Use concise Markdown notes, not a user-facing structured schema:
```text
CAD brief:
- Model: <part or assembly name>
- Task type: <new part, assembly, modification, inspection, secondary output>
- Inputs: <reference images or drawing views used; omit when prose-only>
- Units: <explicit or assumed>
- Coordinate convention: <origin, base plane, up axis>
- Overall dimensions: <width/depth/height or equivalent>
- Functional features: <holes, slots, ribs, bosses, pockets, shells, text, etc.>
- Manufacturing assumptions: <only when relevant>
- Positioning/mating: <interfaces, datums, child placements, joints, alignment rules>
- Paths: <generator .py, STEP target, secondary outputs if requested>
- Validation targets: <bbox, solid count, labels, spec-driven measurements, refs>
- Assumptions: <only meaningful inferred choices>
```
## Example: simple part
User says:
```text
Make a 100 mm by 60 mm by 6 mm mounting plate with rounded corners, four M4 clearance holes 10 mm in from the corners, and a 20 by 12 mm rectangular cutout in the center.
```
Agent brief:
```text
CAD brief:
- Model: mounting_plate, single STEP part.
- Units: millimeters.
- Origin: center of plate; base plane XY; +Z is thickness direction.
- Body: rounded rectangular plate, 100 × 60 × 6 mm.
- Corner radius: not specified; assume 3 mm.
- Holes: four 4.5 mm M4 clearance through-holes, 10 mm in from each corner.
- Cutout: centered rectangular through-cut, 20 × 12 mm.
- Validation: one positive-volume solid, bbox 100 × 60 × 6 mm, four holes, one center cutout, label mounting_plate.
```
## Example: assembly
User says:
```text
Design a two-piece enclosure, 120 by 80 by 35 mm, with a lid that sits on top and four screw bosses aligned between base and lid.
```
Agent brief:
```text
CAD brief:
- Model: enclosure assembly with base and lid.
- Units: millimeters.
- Assembly origin: center of enclosure footprint; +Z upward.
- Base: hollow lower shell, exterior 120 × 80 mm footprint; height derived from total height minus lid thickness.
- Lid: separate plate on top; assume 3 mm lid thickness unless user gave another value.
- Bosses: four aligned screw bosses; assume M3 unless unspecified dimensions make this unsafe.
- Positioning: base top face and lid bottom face are mating datums; screw axes must align; native build123d joints may be used if they clarify reusable mount points or motion.
- Validation: labeled base and lid children, bbox near 120 x 80 x 35 mm, aligned hole/boss axes.
```
## Clarification policy
Ask one focused question only when the missing information affects fit, safety, compliance, or makes the part impossible to model. Otherwise proceed with assumptions and report them.
Ask when:
- No dimensions are provided for a physical object, and no scale reference exists in the supplied images.
- A mating interface is described but the mating geometry is unspecified.
- The part is safety-critical, load-bearing, pressure-bearing, medical, or compliance-bound.
- The requested output depends on an absent source file or missing imported geometry.
Do not ask when:
- A default clearance hole standard is sufficient.
- A cosmetic fillet radius can be safely assumed.
- Origin/orientation can be chosen and reported.
- The user is asking for a conceptual first-pass CAD model.
## Success criteria
A brief is ready for modeling when it contains enough information to define:
- source file path and STEP target path
- units and local coordinate system
- named parameters
- feature plan and labels
- expected bounding box or key measurements
@@ -0,0 +1,143 @@
# Inspection and validation
Read this file for every generated STEP artifact and whenever the user asks for geometry facts, references, dimensions, mating, diffing, or frame inspection.
## Principle
Deterministic geometry checks decide pass/fail; mandatory snapshot review (see `snapshot-review.md`) catches semantic errors the deterministic checks did not encode. Scale the deterministic checks to the user's spec: every dimension, clearance, or relationship the user specified — including dimensions taken from a technical drawing — must be verified with `measure`, `align`, or `frame`. The facts/planes/positioning baseline runs for every generated artifact regardless of spec.
## Tool
The launcher lives in the CAD skill directory:
```bash
python scripts/inspect {refs|diff|frame|measure|align|worker|batch} ...
```
Inspection targets resolve from the command cwd; pass cwd-relative target paths. Common data-output flags: `--format json|text` (default is machine-readable), `--quiet`, `--verbose`.
Accepted target forms:
```text
path/to/entry
path/to/entry.step
```
Selector refs are local to the STEP/CAD entry target passed to the command. They do not include file paths:
```text
#o1.2
#o1.2.f1
#f1
```
Pass selector refs as `#...` tokens. The STEP/CAD file path or entry target is a separate CLI argument.
## Validation sequence
1. Generation completed and the STEP/STP file exists.
2. `refs --facts --planes --positioning` confirms scale, labels, major planes, and placement-ready references. Run this for every generated artifact.
3. Spec-driven checks: `measure` for every user-specified dimension, offset, or clearance; `align` for interfaces that should be flush or centered; `frame` for orientation and occurrence-placement expectations; `diff` for modifications that could affect unrelated geometry.
4. Snapshot the primary STEP/STP per `snapshot-review.md`, then convert every visual concern into a deterministic geometry check before it becomes a validation claim.
## Reference discovery
Compact facts and planes:
```bash
python scripts/inspect refs path/to/model.step \
--facts --planes --positioning
```
Detailed selector inspection:
```bash
python scripts/inspect refs path/to/model.step '#selector' \
--detail --positioning
```
Topology enumeration, only when needed:
```bash
python scripts/inspect refs path/to/model.step --topology
```
Plane options:
```bash
--plane-coordinate-tolerance FLOAT
--plane-min-area-ratio FLOAT
--plane-limit INT
```
Use lower plane limits and compact facts for normal validation. Use topology enumeration only for selector discovery, complex debugging, or when a feature cannot be verified through facts/planes/measurements; it can be expensive on large models.
## Measurement checks
Use `measure` for bounding distances, clearances, offsets, part spacing, plate thickness, hole-to-face distances, and alignment verification.
```bash
python scripts/inspect measure path/to/model.step \
--from '#selector_a' \
--to '#selector_b' \
--axis x
```
Axis may be inferred when possible, but specify `x`, `y`, or `z` for deterministic checks.
## Alignment checks
Use `align` when two exported STEP references should be flush or centered. It returns a translation delta between the selected refs; apply any required correction in the build123d source (see `positioning.md`), regenerate, and re-inspect.
```bash
python scripts/inspect align path/to/assembly.step \
--moving '#moving_selector' \
--target '#target_selector' \
--mode flush \
--axis z
```
## Frame inspection
Use `frame` to validate occurrence transforms and selected-reference world frames:
```bash
python scripts/inspect frame path/to/model.step '#selector'
```
Frame output is useful for assemblies, part-local-to-world conversion, and placement debugging.
## Diff checks
For modification tasks, compare before and after artifacts:
```bash
python scripts/inspect diff path/to/before.step path/to/after.step --planes
```
Use diff when a repair, feature addition, or source edit could affect unrelated geometry.
## Validation report content
Report only checks that were actually run or directly supported by tool output. If an important selector was inspected, return the local selector ref beside the owning CAD Viewer link.
Use this structure:
```text
Validation:
- STEP generation: passed/partial/failed
- Solids/assembly: <counts and labels>
- Bounding box: <dimensions and units>
- Major planes/refs: <summary>
- Positioning: <frame/measure/align results if relevant>
- Feature checks: <holes, cutouts, bosses, etc.>
- Visual review: `$cad-viewer` viewer link returned; CAD `scripts/snapshot` PNG/GIF included or skipped with reason; follow-up geometry checks for any visual findings
```
Do not claim:
- structural safety
- process certification
- tolerance compliance
- manufacturability beyond geometric plausibility
unless the relevant analysis or manufacturing data was explicitly performed.
@@ -0,0 +1,138 @@
# CAD parameters
Read this file when the user asks to parameterize or animate a STEP model, or when designing or reviewing CAD source parameters, `.step.js` sidecar parameters, CAD Viewer controls, or animation controls.
## Principle
Parameters are part of the model contract. A good parameter makes design intent explicit, maps to named geometry or motion, stays inside a valid range, and gives both users and LLMs enough context to predict what changing it will do.
Prefer parameter logic that preserves the mechanism or part constraints over logic that only looks plausible from one camera angle.
## Parameter Brief
Before coding, write a compact internal parameter brief:
- What geometry or motion each parameter controls.
- Units, defaults, min/max, step size, and whether the value is dimensionless.
- Which named features, datums, pivots, axes, faces, or local selector refs each parameter affects.
- Which values are independent inputs and which are derived from constraints.
- What validation proves the parameter is correct.
For assemblies and mechanisms, identify fixed pivots, moving pivots, link lengths, gear ratios, axes, joint limits, and branch choices before creating controls.
## Naming
Use snake_case semantic names that describe intent, matching the build123d Python source convention:
- Prefer `wall_thickness`, `bearing_clearance`, `hinge_angle_deg`, `lid_open`, `gear_ratio`, `link_travel`.
- Avoid names like `offset2`, `magic_scale`, `fix_angle`, `slider_a`, unless the source model itself uses a meaningful matching term.
- Encode units in names only when the value could otherwise be ambiguous, such as `_deg`, `_sec`, or `_mm` suffixes.
- Keep sidecar parameter ids aligned with the Python source parameters they mirror, and keep source constants, manifest feature ids, UI labels, and comments aligned enough that an LLM can trace a control to geometry.
- Module schema field names such as `schemaVersion`, `manifest.step.path`, and `durationSeconds` are fixed by the step-module schema; the snake_case convention applies to the parameter and feature ids you define.
For STEP sidecars, strongly prefer an explicit target link in the module manifest:
```js
export default {
manifest: {
schemaVersion: 1,
step: {
path: "models/path/to/model.step"
}
}
};
```
`manifest.step.path` must be a workspace-relative path, never an absolute path, URL, or path with `..` segments. This link is provenance for humans and tools, not a freshness contract; do not add hashes or staleness checks to STEP parameter modules. Keep the sidecar named `.<step-stem>.step.js` when it lives next to its STEP file so existing viewers can fall back to the same-filename convention if `manifest.step.path` is absent.
## Defaults And Bounds
Defaults should produce a useful, valid model or pose. Bounds should protect the model from impossible, self-intersecting, or misleading states.
- Use physically valid ranges where possible: joint limits, positive dimensions, manufacturable wall thickness, realistic clearances.
- Clamp in code even when the UI already declares `min` and `max`.
- Make `step` match the useful precision of the underlying model, not just the UI.
- Use booleans for true binary state, selects for discrete modes, colors for style-only values, and numbers only for ordered quantities.
- Keep debug parameters available when useful, but label them as inspection controls if they do not represent real design degrees of freedom.
## Derive, Do Not Drift
Compute dependent values from the real constraints:
- Use pivots, axes, centers, bounds, and measured link lengths instead of eyeballed translations.
- Compose assembly transforms around the correct local datum or joint, not around visual centers unless that is the actual design datum.
- For linkages, solve the kinematics from fixed pivots and link lengths. Do not interpolate through impossible intermediate points.
- For gears, preserve pitch-circle relationships, tooth counts, and angular ratios instead of tuning rotations by sight.
- For repeated features, derive positions from count, pitch, radius, and pattern axes.
If a parameter changes a source-level CAD generator, regenerate STEP and validate the exported geometry. If a STEP sidecar changes only viewer-time presentation, say so in labels/descriptions when ambiguity matters.
## Features And Refs
Named features are the bridge between parameters and geometry.
- Label source parts and assembly children explicitly.
- Expose sidecar `manifest.features` with stable local refs such as `#o1.2`; keep file identity in `manifest.step.path`.
- Prefer feature ids like `lid`, `hinge_pin`, `input_gear`, `lower_rocker`, not occurrence ids as public names.
- In code, group constants and transforms by feature role so the logic reads like the mechanism.
- Resolve and inspect refs when a parameter targets a specific face, edge, part, pivot, or assembly child.
## Animations
Animation parameters should drive the smallest real degrees of freedom and derive everything else.
- Use one normalized travel parameter for a mechanism when possible, then derive all dependent transforms from it.
- Make loops exact: the final pose must equal the initial pose, or the animation should ping-pong through a periodic function.
- Do not blend between incompatible kinematic branches. Switch branches only at a physically valid tangent, over-center, or singular pose.
- Keep hinge centers, mating faces, gear contacts, belt paths, and slider axes coincident throughout the animation.
- Separate style controls from mechanism controls: colors, visibility, highlights, clip/explode, speed, play/pause, and scrub should not alter the mechanical truth.
- Preserve source STEP/GLB material colors by default. Only override colors, add color controls, or assign viewer-time color styles when the user explicitly asks for recoloring, presentation styling, or diagnostic color coding.
- Use comments for non-obvious kinematic choices, especially branch selection, sign conventions, datum origin, and derived ratios.
For STEP sidecars, use JavaScript for live CAD Viewer interaction and Three.js hooks. Use Python/build123d as the source of truth for regenerating geometry. Python may generate `.step.js` modules, but CAD Viewer controls should not imply regeneration unless that workflow exists.
## Controls
Expose controls that make the model understandable, not every constant.
- Numeric dimensions: slider plus number input when the range is bounded and interactive; number input when the range is broad or precision-heavy.
- Angles and normalized travel: sliders with clear min/max and units.
- Visibility, enablement, and optional details: switches.
- Discrete modes: select or segmented control.
- Colors: color controls only when explicitly requested for viewer styling; otherwise keep imported material colors.
- Animation: play/pause, scrub, loop, reset, and speed controls.
Use concise labels and descriptions. A good description says what changes and what stays constrained.
## Validation
Validate parameter behavior at representative values:
- Defaults.
- Min and max.
- Mid travel.
- Boundary or branch-change poses.
- Values involved in user-reported failures.
Use deterministic checks first:
- `scripts/inspect refs --facts --planes --positioning` for scale, labels, frames, and major datums.
- `scripts/inspect frame`, `measure`, or `align` for pivots, axes, mating faces, and distances.
- Source-level assertions for derived dimensions or joint limits when practical.
Use CAD `scripts/snapshot` review for visual semantics, following `snapshot-review.md` for packet sizing and PNG-vs-GIF mode selection:
- Review several parameter poses, with GIFs for motion/animation review.
- Compare sidecar enabled vs disabled when viewer-time presentation is involved.
- Check for disconnected hinges, drifting pivots, collisions, impossible branch blends, and looping jumps.
- Convert visual concerns into measurements or explicit geometric facts before calling them fixed.
## Common Failure Patterns
- Eyeballed keyframes that violate real link lengths or mating constraints.
- A UI parameter that controls a visual transform but is named like a geometry parameter.
- Interpolating between two valid poses through invalid intermediate geometry.
- Transforming a part around its bounding-box center instead of its hinge, mate, or local frame.
- Letting a debug scale or offset create collisions outside the real design envelope.
- Using absolute paths, URLs, parent-directory escapes, or stale renamed STEP paths in sidecar `manifest.step.path`.
- Hiding a geometry issue with color, transparency, camera angle, or exploded spacing.
@@ -0,0 +1,260 @@
# Positioning logic, joints, and mating
Read this file when geometry has mating interfaces, repeated features, assembly children, axes, datums, motion, or user-specified alignment. This is the authoritative reference for assembly positioning, part-local origins, build123d joints, explicit `Location` transforms, CLI `inspect align`, and positioning report content.
## Core rule
Positioning is authored in source and validated after generation. Do not position parts by visually dragging or by editing exported STEP geometry. Use build123d parameters, local coordinate systems, `Location` transforms, `Plane`/`Axis` datums, `cadpy.assembly.AssemblyHelper` relationships, source-level `Joint` objects when useful, and labeled assembly children.
## Terminology
Use these terms carefully:
- **AssemblyHelper** is the preferred generated-script wrapper from `cadpy.assembly`. It records semantic relationships such as `face_to_face`, `coaxial`, `revolute`, and `linear`, then realizes them with native build123d joints.
- **build123d joints** are source-level objects such as `RigidJoint`, `RevoluteJoint`, `LinearJoint`, `CylindricalJoint`, and `BallJoint`. They are attached to `Solid` or `Compound` objects and can reposition parts with `connect_to()`.
- **CLI `inspect align`** is a selector-pair validation tool. It computes a read-only translation delta between selected local refs in a STEP/CAD entry. It does not edit source code, patch exported STEP files, or represent an authored mate feature. This is the one place that distinction is defined; the rest of the skill assumes it.
- **Mating intent** is the design relationship: flush, centered, coaxial, offset, hinge-like, slider-like, or otherwise datum-driven.
Use `AssemblyHelper` and build123d joints to express and compute source assembly placement where appropriate, then use CLI inspection to validate the generated STEP.
## Preferred assembly structure
For assemblies, prefer a mate/joint-driven structure over arbitrary transforms:
```text
root component
→ part-local coordinate systems
→ named datums / joint locations
→ AssemblyHelper semantic relationships backed by native build123d joints
→ labeled Compound assembly with verbose native labels
→ refs/measure/frame/align validation
```
A numeric `Location(...)` should usually correspond to a stated datum, offset, clearance, screw axis, face contact, or joint relationship.
Group a functional unit — a bearing, a gearbox stage, a fastener set — into a sub-assembly node with `asm.add_module(name, children)` when it is placed, reasoned about, or repeated as a unit; nested occurrence refs such as `#o1.12.1` then stay meaningful.
## Part-local positioning
For each part, define a local coordinate convention before modeling:
```text
- Origin: center, base datum, mounting interface, or functional axis.
- XY plane: main sketch/base plane unless another datum is dominant.
- +Z: extrusion/up direction.
- Named dimensions: offsets, hole spacing, boss spacing, clearances.
- Datum features: mating faces, screw axes, centerlines, locating tabs, rails.
```
Good defaults:
- Symmetric standalone parts: origin at body center.
- Plates: origin at footprint center; thickness along Z.
- Enclosures: origin at footprint center; base/lid mating surfaces controlled by Z parameters.
- Shaft/knob/axisymmetric parts: origin on rotational axis.
- Mating adapter plates: origin on the primary mounting datum or center of the bolt pattern.
## Feature placement inside a part
Use named parameters and local coordinates:
```python
hole_offset_x = 30
hole_offset_y = 17.5
hole_positions = [
(-hole_offset_x, -hole_offset_y),
( hole_offset_x, -hole_offset_y),
(-hole_offset_x, hole_offset_y),
( hole_offset_x, hole_offset_y),
]
with Locations(*hole_positions):
Hole(radius=hole_diameter / 2)
```
Avoid untraceable placement constants inside geometry calls. Put all meaningful offsets into parameters.
## AssemblyHelper pattern
Use `AssemblyHelper` for generated assembly scripts. It keeps the LLM-facing code intent-focused while still using native build123d labels, `Joint` objects, and `Compound` assemblies.
```python
from build123d import *
from cadpy.assembly import AssemblyHelper
base_height = 30.0
lid_thickness = 3.0
gasket_gap = 0.5
asm = AssemblyHelper("enclosure")
base = asm.add(make_base(), "base")
lid = asm.add(make_lid(), "lid")
base_seat = asm.rigid_frame(
base,
"lid_seat",
Location((0, 0, base_height / 2)),
)
lid_underside = asm.rigid_frame(
lid,
"underside",
Location((0, 0, -lid_thickness / 2)),
)
asm.face_to_face(base_seat, lid_underside, offset=gasket_gap)
def gen_step():
return asm.build()
```
The fixed target is listed first and the moving target second. In the example above, the base stays fixed and the lid moves. The helper records the relationship in source and calls native build123d `connect_to()` under the hood; exported STEP contains the resolved static placement and native assembly labels, not persistent external constraints.
Use helper labels intentionally:
```python
standoff = asm.feature(Cylinder(radius=3.0, height=12.0), "m3_standoff", "front_left")
hinge_axis = asm.rigid_frame(lid, "hinge_axis", Location((0, -25, 0)))
```
Assembly labels name the root occurrence. `asm.add()` labels child component occurrences and their exported shape context. For repeated hardware or library parts, use role/location labels such as `front_left` and `rear_right` so STEP topology and viewer selections remain traceable after export.
Feature labels survive best when the labeled geometry remains a child shape in a `Compound`. Labels on boolean-subtracted or fused feature history are not reliable STEP feature history.
Use the frame method that matches native build123d joint inputs: `rigid_frame()` and `ball_frame()` take a `Location`; `revolute_frame()`, `linear_frame()`, and `cylindrical_frame()` take an `Axis` plus optional native range/reference arguments.
## Imported components
For purchased or downloaded parts (see `$step-parts`), import the STEP file and add it like any authored part:
```python
from build123d import import_step
servo = asm.add(import_step("models/parts/sg90_servo.step"), "servo")
```
Imported geometry was not authored here, so do not assume its origin or orientation. Derive mating frames from inspected geometry: run `refs --facts --planes --positioning` and `measure` against the imported part, then define `asm.rigid_frame(...)` locations from the measured faces, axes, and bolt patterns. Validate the resulting mate exactly like an authored one.
## When to use build123d joints
Use `AssemblyHelper`/build123d joints when assembly intent is clearer as a relationship between part datums than as a raw transform:
- lid-to-base, cover-to-frame, bracket-to-rail, flange-to-pipe, pin-to-hole, shaft-to-bearing
- hinge, slider, screw-like, cylindrical, ball/gimbal, or other motion-positioned assemblies
- repeated or library components that already expose joints
- source assemblies where a change to one dimension should recompute part placement
Direct `Location(...)` transforms are acceptable for simple static layouts when they are parameterized and documented, such as a row of identical spacers or a visual exploded view.
Raw build123d joints are acceptable for advanced cases not covered by `AssemblyHelper`, but preserve the same fixed-first directionality: call `connect_to()` on the fixed/root joint and pass the moving part's joint as `other`. `connect_to()` is a source-generation operation. It repositions the moving part for the generated model; it is not a persistent external constraint in the exported STEP file.
## Joint type selection
Use the simplest joint that expresses the source-level relationship:
- `RigidJoint` / `asm.rigid_frame()`: fixed placement, face-to-face seating, mounting datums, imported components with known interfaces.
- `RevoluteJoint` / `asm.revolute_frame()`: hinge or rotational pose; define with an `Axis` and drive with an angle parameter for a static STEP pose.
- `LinearJoint` / `asm.linear_frame()`: slider, latch, telescoping component; define with an `Axis` and drive with a position parameter.
- `CylindricalJoint` / `asm.cylindrical_frame()`: combined axial translation and rotation, such as screw-like or pin-in-slot relationships.
- `BallJoint` / `asm.ball_frame()`: gimbal or spherical orientation relationship; define with a `Location` and angular ranges.
When only final static placement matters and no meaningful joint datum exists, use explicit `Location` transforms and validate them.
## Assembly positioning workflow
1. Choose the fixed/root component.
2. Define part-local frames and datums before modeling child placement.
3. Identify functional datums such as mating faces, screw axes, hinge axes, sliding axes, locating tabs, gasket offsets, or contact planes.
4. Name source-level joints or mating datums on each child with `asm.rigid_frame()`, `asm.revolute_frame()`, `asm.linear_frame()`, or another helper frame method.
5. Use `AssemblyHelper` relationship methods where they improve source clarity, otherwise use parameterized `Location` transforms.
6. Build a labeled `Compound` assembly with `asm.build()`.
7. Generate the assembly through the Python source, not by re-importing the generated STEP (see `step-generation.md`):
```bash
python scripts/step path/to/assembly.py
python scripts/inspect refs path/to/assembly.step --facts --planes --positioning
```
## CLI alignment validation
After generation, select moving and target refs from the local selector refs returned by `refs --positioning` and compute deltas:
```bash
python scripts/inspect align path/to/assembly.step \
--moving '#moving_selector' \
--target '#target_selector' \
--mode flush \
--axis z
```
Use `--mode flush` for coplanar face alignment. Use `--mode center` for centerline, plane-center, or symmetrical alignment where supported by the selected references. If the returned delta is outside tolerance, apply a source-level correction (see below), regenerate, and rerun inspection.
## Frame validation
Use `frame` to inspect an occurrence or selector's world frame:
```bash
python scripts/inspect frame path/to/assembly.step '#selector'
```
Use this when:
- a child appears in the wrong orientation
- a mating face is offset in world coordinates
- an axis is expected to align with X/Y/Z
- repeated parts should share orientation
- a downstream task needs a stable coordinate frame
## Measurement validation
Use `measure` for scalar checks:
```bash
python scripts/inspect measure path/to/assembly.step \
--from '#selector_a' \
--to '#selector_b' \
--axis z
```
Examples:
- lid bottom face to base top face should be 0 mm for flush contact
- two screw axes should have matching X/Y positions
- bracket mounting face should sit a specified distance from a datum plane
- spacer height should equal requested offset
## Source-level positioning corrections
When a positioning check fails, fix one of these in source:
- child `Location` translation
- child `Location` rotation
- `AssemblyHelper` relationship fixed/moving order or offset
- build123d joint location or axis
- part-local origin convention
- feature offset parameter
- sketch plane
- workplane selection
- assembly hierarchy
- symmetric placement signs
Then regenerate. Do not patch the exported STEP directly.
## Reporting positioning
In the final response, report only checks that were run:
```text
Positioning/joints:
- source used RigidJoint lid_seat → underside
- base/lid Z mate flush, delta 0.00 mm
- screw boss axis alignment: checked in XY by measurement
- lid occurrence frame: +Z up, origin at assembly centerline
```
If no positioning-sensitive features exist, say:
```text
Positioning: not applicable beyond centered part-local origin.
```
If a mate or alignment was intended but not checked, say `not checked`; do not imply success.
@@ -0,0 +1,169 @@
# Repair loop
Read this file when generation, export, inspection, positioning, snapshot review, CAD Viewer setup, or documentation validation fails.
## Loop
1. Read the failing command output.
2. Classify the failure.
3. Make the smallest responsible source or command change.
4. Rerun the failed command.
5. Rerun any dependent validation checks.
6. Report remaining risk or deliberate deviations.
## Failure classes and fixes
### Source import or syntax failure
Likely causes:
- invalid Python syntax
- missing import
- wrong build123d symbol
- function not named `gen_step()`
- executable code outside the intended function has side effects
Fix:
- correct imports and syntax
- ensure `gen_step()` returns the STEP-ready shape or compound
- keep output paths in CLI commands, not inside `gen_step()`
### Invalid or missing geometry
Likely causes:
- open sketch
- subtractive profile outside target
- zero thickness
- boolean operation failed
- construction geometry used as exported geometry
Fix:
- close profiles intended to become faces
- verify dimensions are positive
- make subtractive tools pass through when through-cuts are intended
- simplify the failing feature and rebuild incrementally
### Fillet or chamfer failure
Likely causes:
- radius/length exceeds local geometry
- selected edges include tiny or unintended edges
- boolean operation created complex edge topology
Fix:
- reduce radius/length
- filter selected edges more narrowly
- apply fillets later in the model
- split edge groups by feature intent
### Wrong scale or bounding box
Likely causes:
- units mismatch
- mistaken diameter/radius
- extrusion direction or amount wrong
- part not centered as assumed
- direct imported STEP uses unexpected units
Fix:
- check parameter values
- inspect facts and planes
- measure critical extents
- correct source dimensions or import handling
### Missing feature
Likely causes:
- wrong `Mode.ADD`/`Mode.SUBTRACT`
- feature profile not inside target
- blind cut too shallow
- selector changed after prior operation
Fix:
- confirm feature mode
- increase cut length for through-cuts
- inspect topology or planes
- regenerate and measure/check feature-specific refs
### Selector fragility
Likely causes:
- arbitrary index selection
- topology changed after fillet or boolean
- similar faces/edges are indistinguishable
Fix:
- select by axis, plane, position, normal, or inspected reference
- use `refs --facts --planes --positioning` to rediscover stable references
- add construction datums or simplify operations if needed
### Positioning or joint mismatch
Likely causes: wrong part-local origin or datum, reversed `AssemblyHelper` fixed/moving order, `.connect_to()` moving the wrong part, inverted joint axis, sign errors in symmetric placement, an explicit `Location` not recomputed after a parameter change, or a joint defined in world coordinates when a part-local datum was intended.
Fix:
- inspect `refs --positioning`, then `frame` and `align` on the relevant selectors
- verify the source-level `AssemblyHelper` target order, joint labels, and `joint_location` definitions
- apply the smallest source correction from the list in `positioning.md` (Source-level positioning corrections)
- regenerate the assembly from the Python source and rerun the failed check
### CAD Viewer startup or link failure
Likely causes:
- Node/npm unavailable
- CAD Viewer app not built or cannot start
- active Viewer URL is missing the absolute `?dir=` for the project
- returned link is missing an absolute `file=` path or points outside `?dir=`
Fix:
- rerun `$cad-viewer` with the same absolute `?dir=` for the project and an absolute `file=` path for each artifact
- return one documented Viewer link per requested file
- if unresolved, report the startup failure and rely on CLI facts/measurements plus snapshots for validation
### CAD `scripts/snapshot` failure
Likely causes:
- target input path is wrong, missing, or not a STEP/STP file or same-stem Python generator
- adjacent CAD Viewer GLB/topology artifact missing
- invalid render flags
Fix:
- generate STEP first, then snapshot the primary `.step`/`.stp` artifact
- retry only with simpler supported snapshot jobs, starting with a single `view` output before wireframe display or `section`
- choose modes and packet size per `snapshot-review.md`
## Diff after repair
Use `diff` when the fix might have affected unrelated geometry:
```bash
python scripts/inspect diff path/to/before.step path/to/after.step --planes
```
## Reporting failed repairs
If a check cannot be repaired in the current environment, report:
```text
- what failed
- what was tried
- which artifact is still usable
- which validation claims cannot be made
- what the next source-level correction should be
```
@@ -0,0 +1,84 @@
# Snapshot review
Read this file when choosing saved CAD `scripts/snapshot` outputs for primary STEP/STP artifacts.
## Policy
Snapshot validation is mandatory. Every created or visibly updated primary STEP/STP part or assembly gets at least one reviewed PNG snapshot; deterministic checks passing is not a reason to skip. Use CAD `scripts/snapshot` rather than opening the viewer manually or using Playwright; snapshots are faster, lighter, more precise, and more agent-friendly. Use PNGs for static reviews and GIFs for motion/animation reviews, including STEP-module parameter animation.
Skip saved snapshots only when no visible geometry was created or updated, or no valid artifact exists:
- pure format/export requests where geometry is unchanged
- source changes that do not alter visible geometry
- inspection-only tasks (for example direct measurement questions) that create or update nothing
- failed Python or STEP generation before a valid artifact exists
When skipping, report the reason and the deterministic evidence that still ran.
Do not loop on snapshots. Rerender only when a source repair changed visible geometry or when a specific visual finding needs confirmation.
## Packet sizing
One PNG is enough for a simple static part. Use the small multi-view packet when semantic errors are plausible from shape complexity or prompt intent:
- assemblies or more than one body/part
- holes on multiple faces or multiple axes
- shells, internal cavities, bores, passages, open enclosures, or section-critical features
- ribs, gussets, bosses, standoffs, slots, cutouts, lightening holes, fins, blades, or repeated patterns
- source repairs after a geometry, boolean, selector, or feature failure
- prompts where "looks like the requested object" is part of the task
- deterministic checks pass but visible semantics are still uncertain
## Small packet
Prefer a single `view` JSON job with these outputs:
```json
{
"input": "models/part.step",
"mode": "view",
"outputs": [
{ "path": "/tmp/render/iso.png", "camera": "iso" },
{ "path": "/tmp/render/iso_opposite.png", "camera": { "direction": [-1, 1, -0.8] } },
{ "path": "/tmp/render/top_ortho.png", "camera": "top" },
{ "path": "/tmp/render/front_ortho.png", "camera": "front" }
],
"render": { "viewLabels": true, "padding": 0.12, "sizeProfile": "diagnostic" }
}
```
The two opposed isometric views guarantee every face appears in at least one image — rear, left, and bottom features are covered by default, not by suspicion. The top ortho is the primary pattern/symmetry check and the front ortho the profile check.
Set `input` to the primary STEP/STP artifact using a relative or absolute path. The snapshot CLI derives its internal render root from that input path. It defaults to `appearance: "workbench"` and `display.mode: "solid"`, matching CAD Viewer; labeled/section views default to 1600x1200 when dimensions are omitted. Use `render.sizeProfile: "assembly"` or `"assembly-large"` for complex assemblies that need 1800x1200 or 1920x1440. For CAD review packets, use still-image render modes `view` and `section`; set `display.mode` to `solid`, `transparent`, `hidden_edges`, `hidden_lines_removed`, or `wireframe` when the visual check benefits from explicit CAD linework.
Use `--focus '#o1.2' ...` to render only specific part or subassembly occurrence refs, or `--hide '#o1.2' ...` to omit them. Do not combine focus and hide in the same snapshot command or job. These filters accept occurrence refs only, not face, edge, vertex, or shape selectors.
The snapshot CLI appends one shared UTC seconds timestamp before each output file extension when saving a packet, so readable paths like `iso_solid.png` become names such as `iso_solid_20260527T163012Z.png`.
## Targeted additions
Add views only when the brief or a failure mode calls for them:
- reference-image reproduction: one snapshot from the reference image's viewpoint for side-by-side comparison
- `section`: shell, bore, internal cavity, passage, blind hole, enclosure, or wall/floor relationship
- `display.mode: "solid"`: shaded CAD view with explicit edge linework
- `display.mode: "rendered"`: shaded material view without edge overlay
- `display.mode: "transparent"`: overlap, collision, enclosure readability, or hidden contact checks when transparency adds information and wireframe is too noisy
- `display.mode: "hidden_edges"`: opaque shaded context with hidden/occluded CAD edges visible through solids
- `display.mode: "hidden_lines_removed"`: line-focused review where hidden/occluded edges should be suppressed
- `display.mode: "wireframe"`: internal overlap, hidden interference, or assembly collision suspicion when full triangle wire is useful
- labeled or annotated review: use supported CAD Viewer refs, selections, screenshots, or GUI review links
Exploded or labeled review is an intent, not a render mode. Satisfy it through supported CAD Viewer mechanisms, supported JSON job settings, or the GUI link.
## Diagnostic review
Visual review is diagnostic, not authoritative. Convert every visual concern into a follow-up geometry check before using it as a validation claim:
- hole pattern appears asymmetric -> measure hole centers and compare offsets
- lid, child part, or occurrence appears offset -> inspect frames and mating deltas
- gusset, boss, standoff, rib, or plate may be floating -> inspect solid count, labels, connectivity, contact, or relevant distances
- cavity, bore, or blind hole looks wrong -> run section review, then measure wall thickness, depth, or through-condition
- repeated pattern looks uneven -> measure pattern centers, angular spacing, or occurrence frames
Final reports should include the generated snapshot PNG/GIFs or the documented skip reason, and state which deterministic checks support any visual finding.
@@ -0,0 +1,59 @@
# STEP generation
Read this file when generating or regenerating STEP/STP artifacts from build123d Python source or from direct STEP/STP targets.
## Tool
The launcher lives in the CAD skill directory:
```bash
python scripts/step [--kind {part|assembly}] targets... [flags]
```
Use explicit target paths only; target paths resolve from the command cwd unless absolute. Do not rely on directory-wide generation.
Plain generated Python targets write sibling `.step` outputs. Use `-o`/`--output` only with one plain generated Python target, or use `SOURCE.py=OUTPUT.step` positional pairs for per-target custom outputs. Paired output paths resolve from the command cwd and are valid only for generated Python sources, not direct STEP/STP inputs. Do not put output paths in the `gen_step()` return value; the CLI owns output paths.
## Generated Python source
This is the default path when designing from scratch or modifying a generated model. Generated build123d sources define:
```python
def gen_step():
...
return step_ready_shape_or_labeled_compound
```
Generated Python targets infer their kind from the source metadata and `gen_step()` return value; pass the source path directly:
```bash
python scripts/step path/to/part.py
python scripts/step path/to/part.py -o path/to/custom.step
python scripts/step path/to/a.py=out/a.step path/to/b.py=out/b.step
python scripts/step path/to/assembly.py
```
Passing a generated assembly `.step` directly treats it as imported native STEP and loses source-level assembly composition; pass the `.py` assembly source. For generated build123d assemblies, prefer `cadpy.assembly.AssemblyHelper` in the Python source so native labels, named mate frames, and source-level relationships are preserved before STEP export (see `positioning.md`).
## Direct STEP/STP imports
Use a direct STEP/STP target when no generator exists (imported or downloaded STEP) or the user explicitly identifies a STEP/STP file as the target. The GLB/topology artifacts are then generated from the STEP file itself:
```bash
python scripts/step --kind part path/to/imported.step
```
Direct targets support the same mesh sidecar flags as generator targets; read `supported-exports.md` for STL and 3MF sidecars.
## Viewer artifacts
Every `scripts/step` run also writes hidden adjacent GLB/topology artifacts as part of the normal build. They power CAD Viewer review, `$cad-viewer` workflows, and `scripts/inspect` refs, and are not optional in the STEP workflow.
## After generation
- Confirm the process succeeded and the STEP file exists and is non-empty.
- Run the baseline inspection and any spec-driven checks per `inspection-and-validation.md`:
```bash
python scripts/inspect refs path/to/model.step --facts --planes --positioning
```
@@ -0,0 +1,75 @@
# Supported exports
Read this file when the user requests STL, 3MF, or native GLB output from CAD geometry. For 2D DXF output, use the `$dxf` skill; DXF uses a separate `gen_dxf()` source contract.
## Policy
STL, 3MF, and native GLB are mesh sidecars, not substitutes for STEP. Generate and validate STEP first, then export requested sidecars from the same `scripts/step` run. Do not treat sidecar renders as CAD validation; inspect and snapshot the primary STEP per the standard workflow.
Native GLB sidecars are ordinary glTF 2.0 binary files for external tools: Y-up, meter-scaled, and free of the CAD Viewer `STEP_topology` extension. Do not confuse them with the hidden `.<name>.step.glb` CAD Viewer topology artifact.
## Tool
Use `scripts/step` with a generated Python source:
```bash
python scripts/step path/to/model.py \
--stl meshes/model.stl \
--3mf meshes/model.3mf \
--glb meshes/model.glb
```
When a generator exists, use the generator form. Use direct STEP/STP targets only when the generator is unavailable or the user explicitly identifies that file as the target:
```bash
python scripts/step --kind part path/to/model.step \
--stl meshes/model.stl \
--3mf meshes/model.3mf \
--glb meshes/model.glb
```
Sidecar paths must be relative `.stl`, `.3mf`, or `.glb` paths and are resolved beside the STEP output.
## Mesh tolerance
The default mesh density is `0.02` linear deflection and `0.05` angular deflection.
Use these flags when the default mesh density is wrong for the part:
```bash
--mesh-tolerance FLOAT
--mesh-angular-tolerance FLOAT
```
Use tighter tolerances for small curved parts or visual fidelity. Use looser tolerances for large simple geometry when file size matters.
## Workflow
1. Generate STEP from `gen_step()` with the requested sidecar flag(s).
2. Run facts/planes/positioning inspection on the STEP.
3. Report the STEP and the requested sidecar files.
Example:
```bash
python scripts/step models/bracket.py \
--stl meshes/bracket.stl \
--glb meshes/bracket.glb \
--mesh-tolerance 0.2 \
--mesh-angular-tolerance 0.2
python scripts/inspect refs models/bracket.step --facts --planes --positioning
```
## Reporting
```text
Files:
- STEP: /absolute/project/models/bracket.step
- STL: /absolute/project/models/meshes/bracket.stl
- GLB: /absolute/project/models/meshes/bracket.glb
Validation:
- STEP geometry validated; STL/3MF/native GLB generated as requested sidecars.
- Primary STEP/STP snapshot packet run/skipped and why.
```
+2
View File
@@ -0,0 +1,2 @@
--editable ./scripts/packages/cadpy
playwright
@@ -0,0 +1,15 @@
from __future__ import annotations
import sys
from pathlib import Path
TOOL_DIR = Path(__file__).resolve().parent
tool_path = str(TOOL_DIR)
if tool_path not in sys.path:
sys.path.insert(0, tool_path)
from cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,15 @@
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 inspect_refs.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""Selector reference inspection helpers."""
@@ -0,0 +1,641 @@
from __future__ import annotations
import argparse
import contextlib
import io
import json
import shlex
import sys
from pathlib import Path
from typing import Sequence
if __package__ in {None, ""}:
package_dir = Path(__file__).resolve().parent
tool_dir = package_dir.parent
if str(tool_dir) not in sys.path:
sys.path.insert(0, str(tool_dir))
from cadpy.cli_logging import CliLogger
def _inspect_api():
if __package__ in {None, ""}:
from inspect_refs import inspect
else:
from . import inspect
return inspect
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="inspect",
description="Inspect selector refs, geometry facts, and measurements.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
" inspect measure STEP/foo.step --from '#f1' --to '#f2' --axis z\n"
" inspect align STEP/foo.step --moving '#f1' --target '#f2' --mode flush --axis z\n"
),
)
parser.add_argument(
"--verbose",
action="store_true",
default=False,
help="Show detailed progress and timing information.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
refs_parser = subparsers.add_parser(
"refs",
help="Resolve whole-entry or selector refs from generated GLB topology.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
" inspect refs STEP/foo.step '#f1' '#e2' --positioning\n"
" inspect refs STEP/foo.step --input-file /tmp/refs.txt --planes\n"
),
)
refs_parser.add_argument(
"inputs",
nargs="*",
help="STEP/CAD entry target followed by optional selector refs like #o1.2.f1.",
)
refs_parser.add_argument(
"--input-file",
type=Path,
help="Read token text from a file instead of CLI input or stdin.",
)
refs_parser.add_argument(
"--detail",
action="store_true",
help="Include detailed geometry facts for selected face/edge refs.",
)
refs_parser.add_argument(
"--facts",
action="store_true",
help="Include compact geometry facts for whole-entry refs and resolved selectors.",
)
refs_parser.add_argument(
"--positioning",
action="store_true",
help="Include placement-ready frame, point, plane, axis, and coordinate facts.",
)
refs_parser.add_argument(
"--planes",
action="store_true",
help="Include grouped major planar faces for each whole entry.",
)
_add_plane_report_arguments(refs_parser)
refs_parser.add_argument(
"--topology",
action="store_true",
help="Include full face/edge selector lists for whole-entry refs. Expensive on large topology GLBs.",
)
_add_output_arguments(refs_parser)
refs_parser.set_defaults(handler=run_refs)
diff_parser = subparsers.add_parser(
"diff",
help="Compare two CAD STEP refs and summarize selector-level changes.",
)
diff_parser.add_argument("left", help="Left CAD STEP path.")
diff_parser.add_argument("right", help="Right CAD STEP path.")
diff_parser.add_argument(
"--planes",
action="store_true",
help="Include major planar face groups for both sides.",
)
_add_plane_report_arguments(diff_parser)
_add_output_arguments(diff_parser)
diff_parser.set_defaults(handler=run_diff)
frame_parser = subparsers.add_parser(
"frame",
help="Return the world frame for an occurrence or selector's owning occurrence.",
)
frame_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
frame_parser.add_argument("selector", nargs="?", default="", help="Optional selector ref such as #o1.2.")
_add_output_arguments(frame_parser)
frame_parser.set_defaults(handler=run_frame)
measure_parser = subparsers.add_parser(
"measure",
help="Measure signed coordinate distance between two selectors in one STEP entry.",
)
measure_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
measure_parser.add_argument("--from", dest="from_selector", required=True, help="Moving/source selector ref.")
measure_parser.add_argument("--to", dest="to_selector", required=True, help="Target selector ref.")
measure_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to measure along. Inferred when possible.")
_add_output_arguments(measure_parser)
measure_parser.set_defaults(handler=run_measure)
align_parser = subparsers.add_parser(
"align",
help="Calculate a read-only translation delta for simple selector alignment.",
)
align_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
align_parser.add_argument("--moving", required=True, help="Moving/source selector ref.")
align_parser.add_argument("--target", required=True, help="Target selector ref.")
align_parser.add_argument("--mode", choices=("flush", "center"), default="flush", help="Alignment mode. Default: flush.")
align_parser.add_argument("--offset", type=float, default=0.0, help="Offset in mm. For flush, applies along target normal when axis-aligned.")
align_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to use for flush or one-axis center alignment.")
_add_output_arguments(align_parser)
align_parser.set_defaults(handler=run_align)
worker_parser = subparsers.add_parser(
"worker",
help="Run a persistent JSONL inspect worker.",
description=(
"Read JSONL requests from stdin and write one JSONL response per request. "
"Each request is an object with argv: [<inspect-subcommand>, ...] and optional id."
),
)
worker_parser.set_defaults(handler=run_worker)
batch_parser = subparsers.add_parser(
"batch",
help="Run JSONL inspect requests from stdin in one process.",
description=worker_parser.description,
)
batch_parser.set_defaults(handler=run_worker)
return parser
def _add_output_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--format", choices=("json", "text"), default="json", help="Output format. Default: json.")
parser.add_argument("--quiet", action="store_true", help="Reduce nonessential output.")
parser.add_argument(
"--verbose",
action="store_true",
default=argparse.SUPPRESS,
help="Include extra human-readable detail where available.",
)
def _add_plane_report_arguments(
parser: argparse.ArgumentParser,
*,
prefix: str = "plane-",
) -> None:
coordinate_flags = [f"--{prefix}coordinate-tolerance"]
area_flags = [f"--{prefix}min-area-ratio"]
limit_flags = [f"--{prefix}limit"]
parser.add_argument(
*coordinate_flags,
dest="plane_coordinate_tolerance",
type=float,
default=1e-3,
help="Merge planar face groups whose axis coordinate differs by at most this value. Default: 0.001",
)
parser.add_argument(
*area_flags,
dest="plane_min_area_ratio",
type=float,
default=0.05,
help="Drop planar groups smaller than this fraction of total planar area. Default: 0.05",
)
parser.add_argument(
*limit_flags,
dest="plane_limit",
type=int,
default=12,
help="Maximum number of plane groups to emit. Default: 12",
)
def run_refs(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
entry_target, refs_text = _read_refs_input(args)
result = inspect.inspect_cad_refs(
entry_target,
refs_text,
detail=bool(args.detail),
include_topology=bool(args.topology),
facts=bool(args.facts),
positioning=bool(args.positioning),
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"tokens": [],
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_refs_text)
return 0 if bool(result.get("ok")) else 2
def run_diff(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.diff_entry_targets(
args.left,
args.right,
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"left": {"cadPath": _safe_cad_path(args.left)},
"right": {"cadPath": _safe_cad_path(args.right)},
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_diff_text)
return 0 if bool(result.get("ok")) else 2
def run_frame(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.inspect_target_frame(args.entry, args.selector)
except inspect.CadRefError as exc:
result = {
"ok": False,
"target": args.entry,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_frame_text)
return 0 if bool(result.get("ok")) else 2
def run_measure(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
except inspect.CadRefError as exc:
result = {
"ok": False,
"entry": args.entry,
"from": args.from_selector,
"to": args.to_selector,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_measure_text)
return 0 if bool(result.get("ok")) else 2
def run_align(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.align_targets(
args.entry,
args.moving,
args.target,
mode=args.mode,
offset=float(args.offset),
axis=args.axis,
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"entry": args.entry,
"moving": args.moving,
"target": args.target,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_align_text)
return 0 if bool(result.get("ok")) else 2
def run_worker(args: argparse.Namespace) -> int:
_ = args
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
response = _worker_response(line)
print(json.dumps(response, separators=(",", ":")), flush=True)
return 0
def _worker_response(line: str) -> dict[str, object]:
request_id: object = None
try:
request = json.loads(line)
argv = _worker_request_argv(request)
if isinstance(request, dict):
request_id = request.get("id")
exit_code, result = inspect_command_result(argv)
except Exception as exc:
exit_code = 2
result = {
"ok": False,
"errors": [_exception_error_payload(exc)],
}
response: dict[str, object] = {
"ok": exit_code == 0,
"exitCode": exit_code,
"result": result,
}
if request_id is not None:
response["id"] = request_id
return response
def _worker_request_argv(request: object) -> list[str]:
if isinstance(request, dict):
raw_argv = request.get("argv")
else:
raw_argv = request
if isinstance(raw_argv, str):
return shlex.split(raw_argv)
if isinstance(raw_argv, list) and all(isinstance(item, (str, int, float)) for item in raw_argv):
return [str(item) for item in raw_argv]
raise ValueError("Worker request must be a JSON object with argv, a JSON argv array, or a shell-style argv string.")
def inspect_command_result(argv: Sequence[str]) -> tuple[int, dict[str, object]]:
command_argv = [str(item) for item in argv]
if not command_argv:
return 2, {"ok": False, "errors": [{"message": "empty inspect command"}]}
if command_argv[0] in {"worker", "batch"}:
return 2, {"ok": False, "errors": [{"message": f"Unsupported worker command: {command_argv[0]}"}]}
stderr = io.StringIO()
try:
parser = build_parser()
with contextlib.redirect_stderr(stderr):
args = parser.parse_args(command_argv)
except SystemExit as exc:
return _system_exit_result(exc, stderr=stderr.getvalue())
try:
if args.command == "refs":
if not args.inputs and not args.input_file:
raise _inspect_api().CadRefError("No STEP/CAD entry target provided.")
entry_target, refs_text = _read_refs_input(args)
inspect = _inspect_api()
result = inspect.inspect_cad_refs(
entry_target,
refs_text,
detail=bool(args.detail),
include_topology=bool(args.topology),
facts=bool(args.facts),
positioning=bool(args.positioning),
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
elif args.command == "diff":
inspect = _inspect_api()
result = inspect.diff_entry_targets(
args.left,
args.right,
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
elif args.command == "frame":
result = _inspect_api().inspect_target_frame(args.entry, args.selector)
elif args.command == "measure":
result = _inspect_api().measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
elif args.command == "align":
result = _inspect_api().align_targets(
args.entry,
args.moving,
args.target,
mode=args.mode,
offset=float(args.offset),
axis=args.axis,
)
else:
raise _inspect_api().CadRefError(f"Unsupported inspect command: {args.command}")
except _inspect_api().CadRefError as exc:
result = {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}
except Exception as exc:
result = {"ok": False, "errors": [_exception_error_payload(exc)]}
return (0 if bool(result.get("ok")) else 2), result
def _system_exit_result(exc: SystemExit, *, stderr: str = "") -> tuple[int, dict[str, object]]:
try:
exit_code = int(exc.code or 0)
except (TypeError, ValueError):
exit_code = 2
ok = exit_code == 0
message = stderr.strip() or str(exc)
return exit_code, {"ok": ok, "errors": [] if ok else [{"message": message}]}
def _exception_error_payload(exc: Exception) -> dict[str, object]:
inspect = _inspect_api()
if isinstance(exc, inspect.CadRefError):
return inspect.cad_ref_error_payload(exc)
return {
"type": type(exc).__name__,
"message": str(exc),
}
def _emit_result(args: argparse.Namespace, result: dict[str, object], text_formatter) -> None:
if getattr(args, "format", "json") == "text":
text = text_formatter(
result,
quiet=bool(getattr(args, "quiet", False)),
verbose=bool(getattr(args, "verbose", False)),
)
if text:
print(text)
return
indent = None if bool(getattr(args, "quiet", False)) else 2
print(json.dumps(result, indent=indent, sort_keys=False))
def _format_refs_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
lines: list[str] = []
for token in result.get("tokens", []):
if not isinstance(token, dict):
continue
summary = token.get("summary") if isinstance(token.get("summary"), dict) else {}
headline = f"{token.get('cadPath')} faces={summary.get('faceCount')} edges={summary.get('edgeCount')}"
lines.append(headline)
if quiet:
continue
entry_facts = token.get("entryFacts") if isinstance(token.get("entryFacts"), dict) else {}
if entry_facts:
lines.append(f" facts: {_format_entry_facts_text(entry_facts)}")
entry_positioning = token.get("entryPositioning") if isinstance(token.get("entryPositioning"), dict) else {}
if entry_positioning:
bbox_facts = entry_positioning.get("bboxFacts") if isinstance(entry_positioning.get("bboxFacts"), dict) else {}
if bbox_facts and bbox_facts != entry_facts:
lines.append(f" positioning: {_format_entry_facts_text(bbox_facts)}")
planes = token.get("planes") if isinstance(token.get("planes"), list) else []
if planes:
lines.extend(_format_planes_text(planes))
for selection in token.get("selections", []):
if isinstance(selection, dict):
lines.append(f" {selection.get('displaySelector')}: {selection.get('summary')}")
if verbose and selection.get("copyText"):
lines.append(f" {selection.get('copyText')}")
return "\n".join(lines)
def _format_number(value: object) -> str:
if isinstance(value, float):
return f"{value:.6g}"
return str(value)
def _format_vector(value: object) -> str:
if not isinstance(value, list):
return str(value)
return "[" + ", ".join(_format_number(component) for component in value) + "]"
def _format_entry_facts_text(facts: dict[str, object]) -> str:
parts: list[str] = []
for key in ("size", "center", "extentAxis", "diag", "kind"):
if key not in facts:
continue
value = facts.get(key)
if isinstance(value, list):
parts.append(f"{key}={_format_vector(value)}")
else:
parts.append(f"{key}={_format_number(value)}")
return " ".join(parts)
def _format_planes_text(planes: list[object], *, limit: int = 3) -> list[str]:
lines = [f" planes: {len(planes)} major groups"]
for plane in planes[:limit]:
if not isinstance(plane, dict):
continue
axis = plane.get("axis")
coordinate = _format_number(plane.get("coordinate"))
normal_sign = plane.get("normalSign")
face_count = plane.get("faceCount")
area = _format_number(plane.get("totalArea"))
lines.append(
f" {axis}={coordinate} normalSign={normal_sign} faces={face_count} area={area}"
)
if len(planes) > limit:
lines.append(f" ... {len(planes) - limit} more")
return lines
def _format_diff_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
diff = result.get("diff") if isinstance(result.get("diff"), dict) else {}
fields = ("topologyChanged", "geometryChanged", "bboxChanged", "kindChanged")
lines = [", ".join(f"{field}={diff.get(field)}" for field in fields)]
if not quiet:
lines.append(f"faceDelta={diff.get('faceCountDelta')} edgeDelta={diff.get('edgeCountDelta')}")
if verbose:
lines.append(f"sizeDelta={diff.get('sizeDelta')} centerDelta={diff.get('centerDelta')}")
return "\n".join(lines)
def _format_frame_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
frame = result.get("frame") if isinstance(result.get("frame"), dict) else {}
lines = [f"{result.get('copyText', result.get('cadPath'))} translation={frame.get('translation')}"]
if verbose and not quiet:
lines.append(f"localAxes={frame.get('localAxes')}")
return "\n".join(lines)
def _format_measure_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
measurement = result.get("measurement") if isinstance(result.get("measurement"), dict) else {}
lines = [
f"axis={result.get('axis')} signed={measurement.get('signedDistance')} absolute={measurement.get('absoluteDistance')}"
]
if verbose and not quiet:
lines.append(f"euclidean={measurement.get('euclideanDistance')} vector={measurement.get('vectorRelationship')}")
return "\n".join(lines)
def _format_align_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
alignment = result.get("alignment") if isinstance(result.get("alignment"), dict) else {}
lines = [f"mode={result.get('mode')} axis={result.get('axis')} translation={alignment.get('translationVector')}"]
if verbose and not quiet:
lines.append(f"transformTranslationDelta={alignment.get('transformTranslationDelta')}")
return "\n".join(lines)
def _format_errors(result: dict[str, object]) -> str:
errors = result.get("errors") if isinstance(result.get("errors"), list) else []
messages = [str(error.get("message")) for error in errors if isinstance(error, dict) and error.get("message")]
return "\n".join(messages) if messages else "error"
def _read_refs_input(args: argparse.Namespace) -> tuple[str, str]:
inspect = _inspect_api()
raw_inputs = [str(value) for value in getattr(args, "inputs", ()) if str(value).strip()]
if args.input_file:
if len(raw_inputs) != 1:
raise inspect.CadRefError("Pass exactly one STEP/CAD entry target with --input-file.")
try:
text = args.input_file.read_text(encoding="utf-8")
except OSError as exc:
raise inspect.CadRefError(f"Failed to read input file: {args.input_file}") from exc
entry_target = raw_inputs[0]
else:
if not raw_inputs:
raise inspect.CadRefError("No STEP/CAD entry target provided.")
entry_target = raw_inputs[0]
text = "\n".join(raw_inputs[1:])
try:
inspect.entry_target_from_target(entry_target)
except inspect.CadRefError as exc:
raise inspect.CadRefError(f"Invalid STEP/CAD entry target: {entry_target}") from exc
if not str(text).strip():
return entry_target, ""
nonempty_lines = [line.strip() for line in str(text).splitlines() if line.strip()]
for line in nonempty_lines:
parsed_tokens = inspect.syntax.parse_cad_tokens(line)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != line:
raise inspect.CadRefError(f"Invalid selector ref {line!r}; expected #o1.2, #f1, or #o1.2.f1.")
return entry_target, "\n".join(nonempty_lines)
def _safe_cad_path(target: str) -> str:
inspect = _inspect_api()
try:
return inspect.cad_path_from_target(target)
except inspect.CadRefError:
return str(target)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
command_label = str(getattr(args, "command", "inspect") or "inspect")
logger = CliLogger("scripts/inspect", verbose=bool(getattr(args, "verbose", False)))
try:
with logger.timed(command_label):
return int(args.handler(args))
except _inspect_api().CadRefError as exc:
_emit_result(args, {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}, _format_errors)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,988 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from cadpy import cad_ref_syntax as syntax
from cadpy.reporting import (
EntryReportOptions,
entry_facts_payload,
entry_positioning_payload,
entry_report_payload,
entry_summary_payload,
major_planes_payload,
)
from cadpy.selector_types import SelectorProfile
from cadpy.step_targets import (
CadRefError,
ResolvedStepTarget,
cad_path_from_target,
cad_ref_error_payload,
entry_target_from_target,
resolve_step_target,
step_path_from_target,
)
from cadpy import analysis
from cadpy import lookup
REPO_ROOT = Path.cwd().resolve()
@dataclass
class EntryContext:
cad_path: str
kind: str
source_path: Path
step_path: Path | None
manifest: dict[str, object]
selector_index: lookup.SelectorIndex | None
@dataclass
class TargetSelection:
context: EntryContext
selector_type: str
row: dict[str, object]
normalized_selector: str
display_selector: str
copy_text: str
EntryContextProvider = Callable[[str, SelectorProfile], EntryContext | None]
def inspect_cad_refs(
entry_target: str,
refs_text: str = "",
*,
detail: bool = False,
include_topology: bool = False,
facts: bool = False,
positioning: bool = False,
planes: bool = False,
plane_coordinate_tolerance: float = 1e-3,
plane_min_area_ratio: float = 0.05,
plane_limit: int = 12,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
entry = entry_target_from_target(entry_target)
parsed_tokens = _parse_entry_ref_tokens(entry.cad_path, refs_text)
if not parsed_tokens:
raise CadRefError(
"No selector ref found. Expected refs like #o<path>, #o<path>.s<n>, #o<path>.f<n>, "
"#o<path>.e<n>, #o<path>.v<n>, #m<n>, or #s<n>/#f<n>/#e<n>/#v<n> "
"for single-occurrence entries."
)
contexts: dict[str, EntryContext] = {}
errors: list[dict[str, object]] = []
token_results: list[dict[str, object]] = []
refs_required_by_cad_path: dict[str, bool] = {}
report_options = EntryReportOptions(
facts=facts,
positioning=positioning,
planes=planes,
topology=include_topology,
plane_coordinate_tolerance=plane_coordinate_tolerance,
plane_min_area_ratio=plane_min_area_ratio,
plane_limit=plane_limit,
)
for parsed in parsed_tokens:
selectors = parsed.selectors or ()
refs_required_by_cad_path[parsed.cad_path] = (
refs_required_by_cad_path.get(parsed.cad_path, False)
or bool(selectors)
or report_options.refs_required
)
for parsed in parsed_tokens:
context = contexts.get(parsed.cad_path)
if context is None:
try:
context = _load_entry_context(
parsed.cad_path,
profile=SelectorProfile.REFS if refs_required_by_cad_path.get(parsed.cad_path) else SelectorProfile.SUMMARY,
context_provider=context_provider,
)
except CadRefError as exc:
error = cad_ref_error_payload(exc)
error.setdefault("line", parsed.line)
error.setdefault("cadPath", parsed.cad_path)
error.setdefault("selector", None)
error.setdefault("kind", "input")
errors.append(error)
token_results.append(
{
"line": parsed.line,
"token": parsed.token,
"cadPath": parsed.cad_path,
"stepPath": "",
"summary": {},
"selections": [],
"warnings": [],
}
)
continue
contexts[parsed.cad_path] = context
token_result: dict[str, object] = {
"line": parsed.line,
"token": parsed.token,
"cadPath": parsed.cad_path,
"stepPath": _relative_to_repo(context.step_path) if context.step_path is not None else "",
"stepHash": context.manifest.get("stepHash"),
"summary": _entry_summary(context),
"selections": [],
"warnings": [],
}
report_payload = entry_report_payload(
context.manifest,
kind=context.kind,
options=report_options,
selector_index=context.selector_index,
)
token_result["summary"] = report_payload["summary"]
if facts and "entryFacts" in report_payload:
token_result["entryFacts"] = report_payload["entryFacts"]
if planes and "planes" in report_payload:
token_result["planes"] = report_payload["planes"]
if parsed.selectors:
for raw_selector in parsed.selectors:
selection, selection_error = _inspect_selector(
parsed.cad_path,
raw_selector,
context,
detail=detail,
facts=facts,
positioning=positioning,
)
token_result["selections"].append(selection)
if selection_error is not None:
errors.append(
{
"line": parsed.line,
"cadPath": parsed.cad_path,
"selector": raw_selector,
**selection_error,
}
)
else:
if include_topology and "topology" in report_payload:
token_result["topology"] = report_payload["topology"]
if positioning and "entryPositioning" in report_payload:
token_result["entryPositioning"] = report_payload["entryPositioning"]
token_results.append(token_result)
return {
"ok": not errors,
"tokens": token_results,
"errors": errors,
}
def _parse_entry_ref_tokens(cad_path: str, refs_text: str = "") -> list[syntax.ParsedToken]:
text = str(refs_text or "").strip()
if not text:
return [
syntax.ParsedToken(
line=1,
token=syntax.build_cad_token(cad_path),
cad_path=cad_path,
selectors=(),
)
]
tokens: list[syntax.ParsedToken] = []
for line_no, line in enumerate(text.splitlines(), start=1):
normalized_line = line.strip()
if not normalized_line:
continue
parsed_tokens = syntax.parse_cad_tokens(normalized_line)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != normalized_line:
raise CadRefError(f"Invalid selector ref {normalized_line!r}; expected #o1.2, #f1, #m1, or #o1.2.f1.")
parsed = parsed_tokens[0]
tokens.append(
syntax.ParsedToken(
line=line_no,
token=parsed.token,
cad_path=cad_path,
selectors=parsed.selectors,
)
)
return tokens
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def _load_entry_context(
cad_path: str,
*,
profile: SelectorProfile,
context_provider: EntryContextProvider | None = None,
) -> EntryContext:
if context_provider is not None:
context = context_provider(cad_path, profile)
if context is not None:
return context
target = resolve_step_target(cad_path)
return _load_step_context(target, profile=profile)
def _load_step_context(
target: ResolvedStepTarget,
*,
profile: SelectorProfile,
) -> EntryContext:
from cadpy.step_artifacts import ensure_step_topology_artifact
artifact = ensure_step_topology_artifact(
target,
require_selector=(profile != SelectorProfile.SUMMARY),
)
if artifact.selector_bundle is None:
manifest = artifact.manifest
selector_index = None
else:
manifest = artifact.selector_bundle.manifest
selector_index = lookup.build_selector_index(manifest, buffers=artifact.selector_bundle.buffers)
resolved_kind = _entry_kind_from_manifest(
manifest,
fallback=_entry_kind_from_manifest(artifact.manifest, fallback=target.kind),
)
return EntryContext(
cad_path=target.cad_path,
kind=resolved_kind,
source_path=target.source_path,
step_path=target.step_path,
manifest=manifest,
selector_index=selector_index,
)
def _entry_kind_from_manifest(manifest: dict[str, object], *, fallback: str) -> str:
entry_kind = str(manifest.get("entryKind") or "").strip().lower()
if entry_kind in {"part", "assembly"}:
return entry_kind
assembly = manifest.get("assembly")
if isinstance(assembly, dict):
return "assembly"
return fallback if fallback in {"part", "assembly"} else "part"
def _entry_summary(context: EntryContext) -> dict[str, object]:
return entry_summary_payload(
context.manifest,
kind=context.kind,
selector_index=context.selector_index,
)
def _selection_label(selector_type: str, display_selector: str) -> str:
noun = {
"occurrence": "Occurrence",
"shape": "Shape",
"face": "Face",
"edge": "Edge",
"vertex": "Corner",
}.get(selector_type, "Reference")
return f"{noun} {display_selector}"
def _selection_summary(selector_type: str, row: dict[str, object]) -> str:
if selector_type == "occurrence":
name = str(row.get("name") or row.get("sourceName") or "").strip()
return name or str(row.get("id") or "")
if selector_type == "shape":
kind = str(row.get("kind") or "shape")
volume = row.get("volume")
area = row.get("area")
if volume not in {None, ""}:
return f"{kind} volume={volume}"
if area not in {None, ""}:
return f"{kind} area={area}"
return kind
if selector_type == "face":
return f"{row.get('surfaceType')} area={row.get('area')}"
if selector_type == "edge":
return f"{row.get('curveType')} length={row.get('length')}"
return f"corner edges={row.get('edgeCount')}"
def _assembly_mate_rows(manifest: dict[str, object]) -> list[dict[str, object]]:
rows = manifest.get("assemblyMates")
if not isinstance(rows, list):
return []
return [dict(row) for row in rows if isinstance(row, dict)]
def _assembly_mate_by_selector(context: EntryContext, raw_selector: str) -> dict[str, object] | None:
normalized_selector = str(raw_selector or "").strip().replace("#", "", 1)
if not normalized_selector:
return None
for row in _assembly_mate_rows(context.manifest):
if str(row.get("id") or "").strip() == normalized_selector:
return row
return None
def _looks_like_assembly_mate_selector(raw_selector: str) -> bool:
selector = str(raw_selector or "").strip().replace("#", "", 1)
return len(selector) > 1 and selector[0] == "m" and selector[1:].isdigit()
def _assembly_mate_label(row: dict[str, object], selector: str) -> str:
return (
str(row.get("sourceLabel") or "").strip()
or str(row.get("name") or "").strip()
or str(row.get("label") or "").strip()
or selector
)
def _assembly_mate_summary(row: dict[str, object]) -> str:
relation = str(row.get("type") or row.get("relation") or "mate").strip() or "mate"
fixed = str(row.get("fixed") or "").strip()
moving = str(row.get("moving") or "").strip()
endpoints = f"{fixed} -> {moving}" if fixed and moving else fixed or moving
return " ".join(part for part in (relation, endpoints) if part)
def _assembly_mate_detail(row: dict[str, object]) -> dict[str, object]:
payload: dict[str, object] = {
"id": row.get("id"),
"label": row.get("label"),
"sourceLabel": row.get("sourceLabel"),
"type": row.get("type") or row.get("relation"),
"fixed": row.get("fixed"),
"moving": row.get("moving"),
}
for key in ("parameters", "fixedEndpoint", "movingEndpoint"):
if key in row:
payload[key] = row.get(key)
return payload
def _inspect_assembly_mate(
cad_path: str,
raw_selector: str,
context: EntryContext,
*,
detail: bool,
positioning: bool,
) -> tuple[dict[str, object], dict[str, object] | None]:
selector = str(raw_selector or "").strip().replace("#", "", 1)
row = _assembly_mate_by_selector(context, selector)
if row is None:
return (
{
"status": "error",
"selectorType": "mate" if _looks_like_assembly_mate_selector(selector) else "opaque",
"normalizedSelector": selector,
"displaySelector": selector,
},
{
"kind": "selector",
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
},
)
mate_id = str(row.get("id") or selector).strip() or selector
selection: dict[str, object] = {
"status": "resolved",
"selectorType": "mate",
"normalizedSelector": mate_id,
"displaySelector": mate_id,
"copyText": syntax.build_cad_token(cad_path, mate_id),
"label": f"Mate {_assembly_mate_label(row, mate_id)}",
"summary": _assembly_mate_summary(row),
}
if detail:
selection["detail"] = _assembly_mate_detail(row)
if positioning:
selection["positioning"] = {
"selectorType": "mate",
"selector": mate_id,
"fixedEndpoint": row.get("fixedEndpoint"),
"movingEndpoint": row.get("movingEndpoint"),
}
return selection, None
def _occurrence_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
occurrence_id = str(row.get("id") or "").strip()
child_rows = [
child
for child in selector_index.occurrences
if str(child.get("parentId") or "").strip() == occurrence_id
]
descendant_ids: list[str] = []
stack = list(reversed(child_rows))
while stack:
child = stack.pop()
child_id = str(child.get("id") or "").strip()
if child_id:
descendant_ids.append(lookup.display_selector(child_id, selector_index))
stack.extend(
grandchild
for grandchild in reversed(selector_index.occurrences)
if str(grandchild.get("parentId") or "").strip() == child_id
)
return {
"path": row.get("path"),
"name": row.get("name"),
"sourceName": row.get("sourceName"),
"parentId": lookup.display_selector(str(row.get("parentId") or ""), selector_index),
"childCount": len(child_rows),
"descendantOccurrenceIds": descendant_ids,
"transform": row.get("transform"),
"bbox": row.get("bbox"),
"shapeCount": row.get("shapeCount"),
"faceCount": row.get("faceCount"),
"edgeCount": row.get("edgeCount"),
"vertexCount": row.get("vertexCount"),
}
def _shape_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"kind": row.get("kind"),
"bbox": row.get("bbox"),
"center": row.get("center"),
"area": row.get("area"),
"volume": row.get("volume"),
"faceCount": row.get("faceCount"),
"edgeCount": row.get("edgeCount"),
"vertexCount": row.get("vertexCount"),
}
def _face_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_edges = [
lookup.display_selector(selector, selector_index)
for selector in lookup.face_adjacent_edge_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"surfaceType": row.get("surfaceType"),
"area": row.get("area"),
"center": row.get("center"),
"normal": row.get("normal"),
"bbox": row.get("bbox"),
"params": row.get("params"),
"adjacentEdgeSelectors": adjacent_edges,
}
def _edge_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_faces = [
lookup.display_selector(selector, selector_index)
for selector in lookup.edge_adjacent_face_selectors(row, selector_index)
]
adjacent_vertices = [
lookup.display_selector(selector, selector_index)
for selector in lookup.edge_adjacent_vertex_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"curveType": row.get("curveType"),
"length": row.get("length"),
"center": row.get("center"),
"bbox": row.get("bbox"),
"params": row.get("params"),
"adjacentFaceSelectors": adjacent_faces,
"adjacentVertexSelectors": adjacent_vertices,
}
def _vertex_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_edges = [
lookup.display_selector(selector, selector_index)
for selector in lookup.vertex_adjacent_edge_selectors(row, selector_index)
]
adjacent_faces = [
lookup.display_selector(selector, selector_index)
for selector in lookup.vertex_adjacent_face_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"center": row.get("center"),
"bbox": row.get("bbox"),
"adjacentEdgeSelectors": adjacent_edges,
"adjacentFaceSelectors": adjacent_faces,
}
def _inspect_selector(
cad_path: str,
raw_selector: str,
context: EntryContext,
*,
detail: bool,
facts: bool,
positioning: bool,
) -> tuple[dict[str, object], dict[str, object] | None]:
parsed_selector = syntax.parse_selector(raw_selector)
if parsed_selector is None:
return (
{
"status": "error",
"selectorType": "unknown",
"normalizedSelector": raw_selector,
"displaySelector": raw_selector,
},
{
"kind": "selector",
"message": f"Unsupported selector '{raw_selector}'.",
},
)
if parsed_selector.selector_type == "opaque":
mate_selection, mate_error = _inspect_assembly_mate(
cad_path,
raw_selector,
context,
detail=detail,
positioning=positioning,
)
if mate_error is None or _looks_like_assembly_mate_selector(raw_selector):
return mate_selection, mate_error
if context.selector_index is None:
raise CadRefError(f"Selector index unavailable for {cad_path}")
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or parsed_selector.canonical
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
if lookup_result is None:
return (
{
"status": "error",
"selectorType": parsed_selector.selector_type,
"normalizedSelector": normalized_selector,
"displaySelector": display_selector,
},
{
"kind": "selector",
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
},
)
selector_type, row = lookup_result
selection: dict[str, object] = {
"status": "resolved",
"selectorType": selector_type,
"normalizedSelector": normalized_selector,
"displaySelector": display_selector,
"copyText": syntax.build_cad_token(cad_path, display_selector),
"label": _selection_label(selector_type, display_selector),
"summary": _selection_summary(selector_type, row),
}
if detail:
if selector_type == "occurrence":
selection["detail"] = _occurrence_detail(row, context.selector_index)
elif selector_type == "shape":
selection["detail"] = _shape_detail(row, context.selector_index)
elif selector_type == "face":
selection["detail"] = _face_detail(row, context.selector_index)
elif selector_type == "edge":
selection["detail"] = _edge_detail(row, context.selector_index)
elif selector_type == "vertex":
selection["detail"] = _vertex_detail(row, context.selector_index)
if facts:
selection["geometryFacts"] = analysis.geometry_facts_for_row(selector_type, row, context.selector_index)
if positioning:
selection["positioning"] = analysis.positioning_facts_for_row(selector_type, row, context.selector_index)
return selection, None
def _entry_facts(context: EntryContext) -> dict[str, object]:
return entry_facts_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
def _entry_positioning(context: EntryContext) -> dict[str, object]:
return entry_positioning_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
def load_entry_context_for_target(
target: str,
*,
profile: SelectorProfile = SelectorProfile.REFS,
context_provider: EntryContextProvider | None = None,
) -> EntryContext:
return _load_entry_context(cad_path_from_target(target), profile=profile, context_provider=context_provider)
def _parse_single_target_token(entry_target_text: str, selector: str = "") -> syntax.ParsedToken:
entry_target = entry_target_from_target(entry_target_text)
selector_text = str(selector or "").strip()
if not selector_text:
selectors: tuple[str, ...] = ()
else:
parsed_tokens = syntax.parse_cad_tokens(selector_text)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != selector_text:
raise CadRefError(f"Expected exactly one selector ref such as #o1.2.f1; got {selector!r}.")
selectors = parsed_tokens[0].selectors
return syntax.ParsedToken(
line=1,
token=syntax.build_cad_token(entry_target.cad_path, ",".join(selectors)),
cad_path=entry_target.cad_path,
selectors=selectors,
)
def _default_root_occurrence(index: lookup.SelectorIndex) -> dict[str, object]:
roots = [
row
for row in index.occurrences
if row.get("parentId") in {None, ""}
]
if len(roots) == 1:
return roots[0]
if len(index.occurrences) == 1:
return index.occurrences[0]
raise CadRefError("Target has no selector and does not have exactly one root occurrence.")
def resolve_target_selection(
entry_target: str,
selector: str = "",
*,
default_root_occurrence: bool = False,
context_provider: EntryContextProvider | None = None,
) -> TargetSelection:
parsed = _parse_single_target_token(entry_target, selector)
context = _load_entry_context(parsed.cad_path, profile=SelectorProfile.REFS, context_provider=context_provider)
if context.selector_index is None:
raise CadRefError(f"Selector index unavailable for {parsed.cad_path}")
selectors = parsed.selectors
if not selectors:
if not default_root_occurrence:
raise CadRefError(f"Expected a selector ref for target {entry_target!r}.")
row = _default_root_occurrence(context.selector_index)
normalized_selector = str(row.get("id") or "")
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
return TargetSelection(
context=context,
selector_type="occurrence",
row=row,
normalized_selector=normalized_selector,
display_selector=display_selector,
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
)
if len(selectors) != 1:
raise CadRefError(f"Expected exactly one selector in target {entry_target!r}.")
raw_selector = selectors[0]
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
if lookup_result is None:
raise CadRefError(f"Selector '{raw_selector}' did not resolve against {parsed.cad_path}.")
selector_type, row = lookup_result
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or raw_selector
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
return TargetSelection(
context=context,
selector_type=selector_type,
row=row,
normalized_selector=normalized_selector,
display_selector=display_selector,
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
)
def _selection_positioning_payload(selection: TargetSelection) -> dict[str, object]:
return analysis.positioning_facts_for_row(
selection.selector_type,
selection.row,
selection.context.selector_index,
)
def _selection_result_payload(selection: TargetSelection) -> dict[str, object]:
return {
"cadPath": selection.context.cad_path,
"stepPath": _relative_to_repo(selection.context.step_path) if selection.context.step_path is not None else "",
"selectorType": selection.selector_type,
"normalizedSelector": selection.normalized_selector,
"displaySelector": selection.display_selector,
"copyText": selection.copy_text,
}
def _axis_or_infer(axis: str | None, *positioning: dict[str, object]) -> str:
if axis is not None and str(axis).strip():
normalized = str(axis).strip().lower()
if normalized not in analysis.AXIS_INDEX:
raise CadRefError(f"Axis must be one of x, y, z; got {axis!r}.")
return normalized
inferred = analysis.infer_positioning_axis(*positioning)
if inferred is None:
raise CadRefError("Could not infer a shared axis; pass --axis x, --axis y, or --axis z.")
return inferred
def _primary_vector(positioning: dict[str, object]) -> object:
for key in ("normal", "direction", "axisVector"):
value = positioning.get(key)
if value is not None and value != "":
return value
return None
def inspect_target_frame(
entry_target: str,
selector: str = "",
*,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
selection = resolve_target_selection(entry_target, selector, default_root_occurrence=True, context_provider=context_provider)
if selection.selector_type == "occurrence":
occurrence_row = selection.row
else:
occurrence_id = str(selection.row.get("occurrenceId") or "")
occurrence_row = selection.context.selector_index.occurrence_by_id.get(occurrence_id) if selection.context.selector_index else None
if occurrence_row is None:
raise CadRefError(f"Target {entry_target!r} does not resolve to an occurrence-backed selector.")
frame = analysis.positioning_facts_for_row("occurrence", occurrence_row, selection.context.selector_index)
result = {
"ok": True,
**_selection_result_payload(selection),
"frame": frame,
}
if selection.selector_type != "occurrence":
result["selectionPositioning"] = _selection_positioning_payload(selection)
return result
def measure_targets(
entry_target: str,
from_selector: str,
to_selector: str,
*,
axis: str | None = None,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
from_selection = resolve_target_selection(entry_target, from_selector, default_root_occurrence=True, context_provider=context_provider)
to_selection = resolve_target_selection(entry_target, to_selector, default_root_occurrence=True, context_provider=context_provider)
from_positioning = _selection_positioning_payload(from_selection)
to_positioning = _selection_positioning_payload(to_selection)
resolved_axis = _axis_or_infer(axis, from_positioning, to_positioning)
from_coordinate = analysis.positioning_coordinate(from_positioning, resolved_axis)
to_coordinate = analysis.positioning_coordinate(to_positioning, resolved_axis)
if from_coordinate is None or to_coordinate is None:
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both targets.")
from_point = analysis.positioning_point(from_positioning)
to_point = analysis.positioning_point(to_positioning)
euclidean_distance = None
if from_point is not None and to_point is not None:
euclidean_distance = sum(
(float(to_point[index]) - float(from_point[index])) ** 2
for index in range(3)
) ** 0.5
vector_relationship = analysis.vector_relationship(
_primary_vector(from_positioning),
_primary_vector(to_positioning),
)
signed_distance = float(to_coordinate[0] - from_coordinate[0])
return {
"ok": True,
"axis": resolved_axis,
"from": {
**_selection_result_payload(from_selection),
"positioning": from_positioning,
"coordinate": from_coordinate[0],
"coordinateSource": from_coordinate[1],
},
"to": {
**_selection_result_payload(to_selection),
"positioning": to_positioning,
"coordinate": to_coordinate[0],
"coordinateSource": to_coordinate[1],
},
"measurement": {
"signedDistance": signed_distance,
"absoluteDistance": abs(signed_distance),
"euclideanDistance": euclidean_distance,
"vectorRelationship": vector_relationship,
},
}
def align_targets(
entry_target: str,
moving_selector: str,
target_selector: str,
*,
mode: str = "flush",
offset: float = 0.0,
axis: str | None = None,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
moving_selection = resolve_target_selection(
entry_target,
moving_selector,
default_root_occurrence=True,
context_provider=context_provider,
)
target_selection = resolve_target_selection(
entry_target,
target_selector,
default_root_occurrence=True,
context_provider=context_provider,
)
moving_positioning = _selection_positioning_payload(moving_selection)
target_positioning = _selection_positioning_payload(target_selection)
normalized_mode = str(mode or "flush").strip().lower()
if normalized_mode not in {"flush", "center"}:
raise CadRefError(f"Unsupported alignment mode {mode!r}; expected 'flush' or 'center'.")
if normalized_mode == "center":
moving_point = analysis.positioning_point(moving_positioning)
target_point = analysis.positioning_point(target_positioning)
if moving_point is None or target_point is None:
raise CadRefError("Center alignment requires both targets to expose a point, center, origin, or translation.")
translation_vector = [float(target_point[index]) - float(moving_point[index]) for index in range(3)]
resolved_axis = None
if axis is not None and str(axis).strip():
resolved_axis = _axis_or_infer(axis)
axis_index = analysis.AXIS_INDEX[resolved_axis]
translation_vector = [
value if index == axis_index else 0.0
for index, value in enumerate(translation_vector)
]
else:
resolved_axis = _axis_or_infer(axis, moving_positioning, target_positioning)
moving_coordinate = analysis.positioning_coordinate(moving_positioning, resolved_axis)
target_coordinate = analysis.positioning_coordinate(target_positioning, resolved_axis)
if moving_coordinate is None or target_coordinate is None:
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both alignment targets.")
offset_sign = 1
target_alignment = target_positioning.get("axisAlignment")
if isinstance(target_alignment, dict) and target_alignment.get("axis") == resolved_axis:
offset_sign = int(target_alignment.get("sign") or 1)
distance = float(target_coordinate[0] + (float(offset) * offset_sign) - moving_coordinate[0])
translation_vector = [0.0, 0.0, 0.0]
translation_vector[analysis.AXIS_INDEX[resolved_axis]] = distance
vector_relationship = analysis.vector_relationship(
_primary_vector(moving_positioning),
_primary_vector(target_positioning),
)
rotation_required = None
if vector_relationship is not None:
rotation_required = vector_relationship.get("relation") != "opposed"
return {
"ok": True,
"mode": normalized_mode,
"axis": resolved_axis,
"offset": float(offset),
"moving": {
**_selection_result_payload(moving_selection),
"positioning": moving_positioning,
},
"target": {
**_selection_result_payload(target_selection),
"positioning": target_positioning,
},
"alignment": {
"translationVector": translation_vector,
"transformTranslationDelta": {
"3": translation_vector[0],
"7": translation_vector[1],
"11": translation_vector[2],
},
"vectorRelationship": vector_relationship,
"rotationRequired": rotation_required,
},
}
def diff_entry_targets(
left_target: str,
right_target: str,
*,
planes: bool = False,
plane_coordinate_tolerance: float = 1e-3,
plane_min_area_ratio: float = 0.05,
plane_limit: int = 12,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
left_context = load_entry_context_for_target(
left_target,
profile=SelectorProfile.REFS,
context_provider=context_provider,
)
right_context = load_entry_context_for_target(
right_target,
profile=SelectorProfile.REFS,
context_provider=context_provider,
)
diff_payload = analysis.selector_manifest_diff(left_context.manifest, right_context.manifest)
bbox_left = left_context.manifest.get("bbox")
bbox_right = right_context.manifest.get("bbox")
size_left = analysis.bbox_size(bbox_left)
size_right = analysis.bbox_size(bbox_right)
center_left = analysis.bbox_center(bbox_left)
center_right = analysis.bbox_center(bbox_right)
result: dict[str, object] = {
"ok": True,
"left": {
"cadPath": left_context.cad_path,
"kind": left_context.kind,
"stepPath": _relative_to_repo(left_context.step_path) if left_context.step_path is not None else "",
"summary": _entry_summary(left_context),
"entryFacts": _entry_facts(left_context),
},
"right": {
"cadPath": right_context.cad_path,
"kind": right_context.kind,
"stepPath": _relative_to_repo(right_context.step_path) if right_context.step_path is not None else "",
"summary": _entry_summary(right_context),
"entryFacts": _entry_facts(right_context),
},
"diff": {
"kindChanged": left_context.kind != right_context.kind,
**diff_payload,
"sizeDelta": (
[float(size_right[index] - size_left[index]) for index in range(3)]
if size_left is not None and size_right is not None
else None
),
"centerDelta": (
[float(center_right[index] - center_left[index]) for index in range(3)]
if center_left is not None and center_right is not None
else None
),
},
}
if planes and left_context.selector_index is not None and right_context.selector_index is not None:
report_options = EntryReportOptions(
planes=True,
plane_coordinate_tolerance=plane_coordinate_tolerance,
plane_min_area_ratio=plane_min_area_ratio,
plane_limit=plane_limit,
)
result["diff"]["leftMajorPlanes"] = major_planes_payload(left_context.selector_index, report_options)
result["diff"]["rightMajorPlanes"] = major_planes_payload(right_context.selector_index, report_options)
return result
+1
View File
@@ -0,0 +1 @@
../../../../packages/cadpy
@@ -0,0 +1 @@
"""Wrapper package for the CAD skill snapshot command."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD snapshot render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
</head>
<body>
<script type="module" src="/snapshot-render.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
"""Unified STEP generation CLI."""
@@ -0,0 +1,16 @@
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())
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from cadpy.catalog import StepImportOptions
from cadpy.metadata import normalize_mesh_numeric
def generate_step_targets(*args, **kwargs):
from cadpy.generation import generate_step_targets as generate
return generate(*args, **kwargs)
def targets_include_output_pairs(targets) -> bool:
from cadpy.generation import targets_include_output_pairs as includes_pairs
return includes_pairs(targets)
def _normalize_cli_numeric(value: object, *, field_name: str, parser: argparse.ArgumentParser) -> float | None:
try:
return normalize_mesh_numeric(value, field_name=field_name)
except ValueError as exc:
parser.error(str(exc))
return None
def _add_step_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"targets",
nargs="+",
help="Explicit Python generator, STEP/STP file path, or SOURCE.py=OUTPUT.step pair to generate.",
)
parser.add_argument(
"--kind",
choices=("part", "assembly"),
help="Required for direct STEP/STP targets. Generated Python targets infer kind from gen_step().",
)
parser.add_argument(
"-o",
"--output",
metavar="PATH",
help="Write the generated STEP file to this path. Valid only with one plain generated Python target.",
)
parser.add_argument(
"--stl",
metavar="OUTPUT",
help="Export an STL sidecar to this relative .stl path.",
)
parser.add_argument(
"--3mf",
dest="three_mf",
metavar="OUTPUT",
help="Export a 3MF sidecar to this relative .3mf path.",
)
parser.add_argument(
"--glb",
metavar="OUTPUT",
help="Export a native Y-up GLB sidecar to this relative .glb path.",
)
parser.add_argument(
"--skip-step-write",
action="store_true",
help="For Python gen_step() targets, generate the hidden GLB/topology artifact without writing STEP.",
)
parser.add_argument(
"--force",
action="store_true",
help="Regenerate STEP and hidden CAD Viewer GLB/topology outputs even when current artifacts match.",
)
parser.add_argument(
"--mesh-tolerance",
type=float,
help="Positive shared mesh linear deflection for GLB/topology, native GLB, STL, and 3MF artifacts.",
)
parser.add_argument(
"--mesh-angular-tolerance",
type=float,
help="Positive shared mesh angular deflection for GLB/topology, native GLB, STL, and 3MF artifacts.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed progress and timing information.",
)
def _step_import_options_from_args(args: argparse.Namespace, *, parser: argparse.ArgumentParser) -> StepImportOptions:
return StepImportOptions(
stl=args.stl,
three_mf=args.three_mf,
glb=args.glb,
mesh_tolerance=_normalize_cli_numeric(
args.mesh_tolerance,
field_name="mesh_tolerance",
parser=parser,
),
mesh_angular_tolerance=_normalize_cli_numeric(
args.mesh_angular_tolerance,
field_name="mesh_angular_tolerance",
parser=parser,
),
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="scripts/step",
description="Generate explicit CAD STEP targets and their CAD Viewer artifacts.",
)
_add_step_arguments(parser)
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_step_targets(
args.targets,
direct_step_kind=args.kind,
step_options=_step_import_options_from_args(args, parser=parser),
output=args.output,
skip_step_write=bool(args.skip_step_write),
force=bool(args.force),
verbose=bool(args.verbose),
)
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
---
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.
@@ -0,0 +1,4 @@
interface:
display_name: "DXF"
short_description: "Generate and validate 2D DXF drawings."
default_prompt: "Use $dxf to create, regenerate, and validate explicit DXF drawing files from gen_dxf() Python sources, handing outputs to $cad-viewer when available."
+2
View File
@@ -0,0 +1,2 @@
--editable ./scripts/packages/cadpy
ezdxf
@@ -0,0 +1 @@
"""DXF generation CLI."""
@@ -0,0 +1,16 @@
from __future__ import annotations
import sys
from pathlib import Path
if __package__ in {None, ""}:
tool_dir = Path(__file__).resolve().parent
if str(tool_dir) not in sys.path:
sys.path.insert(0, str(tool_dir))
from cli import main
else:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
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())
@@ -0,0 +1,411 @@
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
View File
@@ -0,0 +1 @@
../../../../packages/cadpy
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+137
View File
@@ -0,0 +1,137 @@
---
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.
@@ -0,0 +1,4 @@
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."
@@ -0,0 +1,42 @@
# 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.
@@ -0,0 +1,81 @@
# 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
@@ -0,0 +1,741 @@
#!/usr/bin/env python3
"""Dry-run-first helpers for generating and validating plain FDM G-code."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
DIRECT_MESH_EXTENSIONS = {".stl", ".obj", ".3mf"}
CONVERT_TO_STL_EXTENSIONS = {".ply", ".glb", ".gltf"}
UNSUPPORTED_EXTENSIONS = {".step", ".stp", ".dxf", ".svg", ".urdf", ".sdf"}
SLICED_BAMBU_PLATE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
PREFERRED_BACKEND_ORDER = ["orcaslicer", "prusa-slicer", "curaengine"]
BACKEND_EXECUTABLES = {
"orcaslicer": ["OrcaSlicer", "orca-slicer", "orcaslicer"],
"prusa-slicer": ["prusa-slicer", "PrusaSlicer", "prusa-slicer-console"],
"curaengine": ["CuraEngine", "curaengine"],
}
BACKEND_APP_GLOBS = {
"orcaslicer": ["OrcaSlicer*.app/Contents/MacOS/OrcaSlicer"],
"prusa-slicer": ["PrusaSlicer*.app/Contents/MacOS/PrusaSlicer"],
}
BACKEND_ENV_VARS = {
"orcaslicer": "ORCASLICER_BIN",
"prusa-slicer": "PRUSASLICER_BIN",
"curaengine": "CURAENGINE_BIN",
}
BAMBU_EXECUTABLES = ["bambu-studio", "BambuStudio"]
SUPPORTED_GCODE_COMMANDS = {
"G0",
"G1",
"G2",
"G3",
"G4",
"G21",
"G28",
"G29",
"G90",
"G91",
"G92",
"M18",
"M73",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M117",
"M118",
"M140",
"M190",
"M201",
"M203",
"M204",
"M205",
"M220",
"M221",
"M400",
"M500",
"M501",
"M900",
}
class GCodeToolError(RuntimeError):
"""Raised for expected user-facing workflow errors."""
@dataclass(frozen=True)
class BackendDiscovery:
id: str
available: bool
path: str | None
preferred: bool
reason: str = ""
@dataclass(frozen=True)
class GCodeProfile:
backend: str
native_config: str
native_settings: tuple[str, ...]
native_filaments: tuple[str, ...]
machine_name: str
bed_size_mm: tuple[float, float]
z_height_mm: float
motion_bounds_mm: dict[str, tuple[float, float]]
filament_type: str
nozzle_temp_c: float
bed_temp_c: float
@dataclass(frozen=True)
class InputInspection:
path: str
extension: str
size_bytes: int
supported: bool
direct_to_slicer: bool
needs_stl_conversion: bool
already_sliced_bambu: bool
status: str
def tail_text(value: str, *, max_lines: int = 80) -> str:
lines = value.splitlines()
if len(lines) <= max_lines:
return value
return "\n".join(lines[-max_lines:])
def find_executable(names: list[str], *, search_path: str | None = None) -> str | None:
for name in names:
found = shutil.which(name, path=search_path)
if found:
return found
return None
def find_app_executable(backend: str) -> str | None:
applications = Path("/Applications")
if not applications.exists():
return None
for pattern in BACKEND_APP_GLOBS.get(backend, []):
for path in sorted(applications.glob(pattern)):
if path.is_file():
return str(path)
return None
def find_backend_executable(backend: str, *, search_path: str | None = None) -> str | None:
env_value = os.environ.get(BACKEND_ENV_VARS[backend], "").strip()
if env_value:
return env_value
return find_executable(BACKEND_EXECUTABLES[backend], search_path=search_path) or find_app_executable(backend)
def discover_backends(*, search_path: str | None = None) -> list[BackendDiscovery]:
discoveries: list[BackendDiscovery] = []
for backend_id in PREFERRED_BACKEND_ORDER:
path = find_backend_executable(backend_id, search_path=search_path)
discoveries.append(
BackendDiscovery(
id=backend_id,
available=bool(path),
path=path or None,
preferred=True,
)
)
return discoveries
def discover_bambu_studio(*, search_path: str | None = None) -> list[BackendDiscovery]:
paths: list[str] = []
env_value = os.environ.get("BAMBU_STUDIO_BIN", "").strip()
if env_value:
paths.append(env_value)
which_value = find_executable(BAMBU_EXECUTABLES, search_path=search_path)
if which_value:
paths.append(which_value)
applications = Path("/Applications")
if applications.exists():
for pattern in ("BambuStudio*.app/Contents/MacOS/BambuStudio", "Bambu Studio*.app/Contents/MacOS/BambuStudio"):
paths.extend(str(path) for path in applications.glob(pattern))
unique_paths = sorted(dict.fromkeys(paths))
if not unique_paths:
return [
BackendDiscovery(
id="bambu-studio",
available=False,
path=None,
preferred=False,
reason="not preferred because Bambu Studio CLI has shown macOS export instability",
)
]
return [
BackendDiscovery(
id="bambu-studio",
available=True,
path=path,
preferred=False,
reason="available but not preferred because Bambu Studio CLI has shown macOS export instability",
)
for path in unique_paths
]
def discovery_report(*, search_path: str | None = None) -> dict[str, Any]:
backends = discover_backends(search_path=search_path)
not_preferred = discover_bambu_studio(search_path=search_path)
return {
"preferred_order": PREFERRED_BACKEND_ORDER,
"backends": [asdict(item) for item in backends],
"not_preferred": [asdict(item) for item in not_preferred],
}
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError as exc:
raise GCodeToolError(f"Profile does not exist: {path}") from exc
except json.JSONDecodeError as exc:
raise GCodeToolError(f"Profile is not valid JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise GCodeToolError("Profile JSON must be an object.")
return data
def require_number(value: Any, label: str) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise GCodeToolError(f"Profile field {label} must be a number.")
number = float(value)
if number <= 0:
raise GCodeToolError(f"Profile field {label} must be greater than zero.")
return number
def require_absolute_file(path_value: str, label: str) -> str:
profile_path = Path(path_value).expanduser()
if not profile_path.is_absolute():
raise GCodeToolError(f"Profile field {label} must be an absolute path.")
if not profile_path.is_file():
raise GCodeToolError(f"Profile {label} does not exist: {profile_path}")
return str(profile_path)
def optional_path_list(data: dict[str, Any], field: str) -> tuple[str, ...]:
value = data.get(field)
if value is None:
return ()
if isinstance(value, str):
values = [value]
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
values = value
else:
raise GCodeToolError(f"Profile field {field} must be a string path or list of string paths.")
paths = [item.strip() for item in values if item.strip()]
if not paths:
raise GCodeToolError(f"Profile field {field} cannot be empty when provided.")
return tuple(require_absolute_file(path, field) for path in paths)
def parse_axis_bounds(value: Any, label: str) -> tuple[float, float]:
if (
not isinstance(value, list)
or len(value) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value)
):
raise GCodeToolError(f"Profile field {label} must be [min, max].")
lower = float(value[0])
upper = float(value[1])
if lower >= upper:
raise GCodeToolError(f"Profile field {label} must have min less than max.")
return lower, upper
def load_profile(path: Path) -> GCodeProfile:
data = load_json(path)
backend = str(data.get("backend") or "").strip().lower()
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Profile backend must be one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
native_config = str(data.get("native_config") or "").strip()
if not native_config:
raise GCodeToolError("Profile field native_config is required.")
native_config = require_absolute_file(native_config, "native_config")
native_settings = optional_path_list(data, "native_settings") or (native_config,)
native_filaments = optional_path_list(data, "native_filaments")
machine = data.get("machine")
if not isinstance(machine, dict):
raise GCodeToolError("Profile field machine must be an object.")
machine_name = str(machine.get("name") or "").strip()
if not machine_name:
raise GCodeToolError("Profile field machine.name is required.")
bed_size = machine.get("bed_size_mm")
if (
not isinstance(bed_size, list)
or len(bed_size) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in bed_size)
):
raise GCodeToolError("Profile field machine.bed_size_mm must be [width, depth].")
bed_x = require_number(bed_size[0], "machine.bed_size_mm[0]")
bed_y = require_number(bed_size[1], "machine.bed_size_mm[1]")
z_height = require_number(machine.get("z_height_mm"), "machine.z_height_mm")
motion_bounds = {
"x": (0.0, bed_x),
"y": (0.0, bed_y),
"z": (0.0, z_height),
}
if "motion_bounds_mm" in machine:
raw_bounds = machine.get("motion_bounds_mm")
if not isinstance(raw_bounds, dict):
raise GCodeToolError("Profile field machine.motion_bounds_mm must be an object.")
for axis in ("x", "y", "z"):
if axis in raw_bounds:
motion_bounds[axis] = parse_axis_bounds(raw_bounds[axis], f"machine.motion_bounds_mm.{axis}")
filament = data.get("filament")
if not isinstance(filament, dict):
raise GCodeToolError("Profile field filament must be an object.")
filament_type = str(filament.get("type") or "").strip()
if not filament_type:
raise GCodeToolError("Profile field filament.type is required.")
nozzle_temp = require_number(filament.get("nozzle_temp_c"), "filament.nozzle_temp_c")
bed_temp = require_number(filament.get("bed_temp_c"), "filament.bed_temp_c")
return GCodeProfile(
backend=backend,
native_config=native_config,
native_settings=native_settings,
native_filaments=native_filaments,
machine_name=machine_name,
bed_size_mm=(bed_x, bed_y),
z_height_mm=z_height,
motion_bounds_mm=motion_bounds,
filament_type=filament_type,
nozzle_temp_c=nozzle_temp,
bed_temp_c=bed_temp,
)
def is_sliced_bambu_3mf(path: Path) -> bool:
try:
with zipfile.ZipFile(path) as archive:
return any(SLICED_BAMBU_PLATE_RE.match(info.filename) for info in archive.infolist())
except zipfile.BadZipFile:
return False
def inspect_input(path: Path) -> InputInspection:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"Input file does not exist: {resolved}")
extension = resolved.suffix.lower()
if extension in UNSUPPORTED_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"{extension} is out of scope for this skill. Supported v1 mesh inputs: {supported}.")
if extension not in DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"Unsupported input extension {extension or '<none>'}. Supported v1 mesh inputs: {supported}.")
already_sliced = extension == ".3mf" and is_sliced_bambu_3mf(resolved)
if already_sliced:
status = "already_sliced_bambu_3mf"
elif extension in DIRECT_MESH_EXTENSIONS:
status = "direct_to_slicer"
else:
status = "requires_stl_conversion"
return InputInspection(
path=str(resolved),
extension=extension,
size_bytes=resolved.stat().st_size,
supported=True,
direct_to_slicer=extension in DIRECT_MESH_EXTENSIONS and not already_sliced,
needs_stl_conversion=extension in CONVERT_TO_STL_EXTENSIONS,
already_sliced_bambu=already_sliced,
status=status,
)
def backend_path(backend: str, *, search_path: str | None = None) -> str:
path = find_backend_executable(backend, search_path=search_path)
if not path:
names = ", ".join(BACKEND_EXECUTABLES[backend])
raise GCodeToolError(f"Backend {backend} is not installed or not on PATH. Checked: {names}.")
return path
def build_backend_command(
*,
backend: str,
executable: str,
slicer_input: Path,
output: Path,
profile: GCodeProfile,
) -> list[str]:
if backend == "orcaslicer":
command = [
executable,
"--load-settings",
";".join(profile.native_settings),
]
if profile.native_filaments:
command.extend(["--load-filaments", ";".join(profile.native_filaments)])
command.extend(
[
"--outputdir",
str(output.parent),
"--slice",
"0",
str(slicer_input),
]
)
return command
if backend == "prusa-slicer":
return [
executable,
"--load",
profile.native_config,
"--export-gcode",
"--output",
str(output),
str(slicer_input),
]
if backend == "curaengine":
return [
executable,
"slice",
"-j",
profile.native_config,
"-l",
str(slicer_input),
"-o",
str(output),
]
raise GCodeToolError(f"Unsupported backend: {backend}")
def generated_gcode_candidate(output: Path, slicer_input: Path, existing_gcodes: set[Path]) -> Path | None:
expected_by_input = output.parent / f"{slicer_input.stem}.gcode"
if expected_by_input.is_file() and expected_by_input.resolve() != output.resolve():
return expected_by_input
generated = [
path
for path in output.parent.glob("*.gcode")
if path.resolve() not in existing_gcodes and path.resolve() != output.resolve()
]
if len(generated) == 1:
return generated[0]
return None
def build_slice_plan(args: argparse.Namespace, *, search_path: str | None = None, converted_input: Path | None = None) -> dict[str, Any]:
profile = load_profile(Path(args.profile).expanduser())
inspection = inspect_input(Path(args.input))
if inspection.already_sliced_bambu:
raise GCodeToolError("Input is already a sliced Bambu .gcode.3mf. Refusing to re-slice it.")
backend = profile.backend if args.backend == "auto" else args.backend
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Backend must be auto or one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
executable = backend_path(backend, search_path=search_path)
input_path = Path(inspection.path)
slicer_input = converted_input or input_path
if inspection.needs_stl_conversion and converted_input is None:
slicer_input = Path(tempfile.gettempdir()) / f"{input_path.stem}.converted.stl"
output = Path(args.output).expanduser().resolve()
command = build_backend_command(
backend=backend,
executable=executable,
slicer_input=slicer_input,
output=output,
profile=profile,
)
return {
"dry_run": not args.execute,
"backend": backend,
"executable": executable,
"command": command,
"input": asdict(inspection),
"output": str(output),
"profile": asdict(profile),
"conversion": {
"required": inspection.needs_stl_conversion,
"slicer_input": str(slicer_input),
"note": "Requires trimesh at execution time." if inspection.needs_stl_conversion else "",
},
}
def convert_mesh_to_stl(input_path: Path, output_path: Path) -> None:
try:
import trimesh # type: ignore
except ImportError as exc:
raise GCodeToolError(
"trimesh is required to convert .ply/.glb/.gltf inputs to temporary STL. "
"Install it in the active Python environment or provide .stl/.obj/.3mf directly."
) from exc
loaded = trimesh.load(str(input_path))
if getattr(loaded, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty: {input_path}")
if hasattr(loaded, "dump"):
mesh = loaded.dump(concatenate=True)
else:
mesh = loaded
if getattr(mesh, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty after scene conversion: {input_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
mesh.export(str(output_path), file_type="stl")
def execute_slice(args: argparse.Namespace, *, search_path: str | None = None) -> tuple[int, dict[str, Any]]:
input_inspection = inspect_input(Path(args.input))
output = Path(args.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="gcode-slice-") as tmp:
converted_input = None
if input_inspection.needs_stl_conversion:
converted_input = Path(tmp) / f"{Path(input_inspection.path).stem}.stl"
convert_mesh_to_stl(Path(input_inspection.path), converted_input)
plan = build_slice_plan(args, search_path=search_path, converted_input=converted_input)
slicer_input = Path(plan["conversion"]["slicer_input"])
existing_gcodes = {path.resolve() for path in output.parent.glob("*.gcode")}
result = subprocess.run(plan["command"], check=False, capture_output=True, text=True)
plan["returncode"] = result.returncode
if result.stdout:
plan["stdout_tail"] = tail_text(result.stdout)
if result.stderr:
plan["stderr_tail"] = tail_text(result.stderr)
if result.returncode != 0:
return result.returncode, plan
if not output.is_file():
candidate = generated_gcode_candidate(output, slicer_input, existing_gcodes)
if candidate is None:
plan["error"] = "Slicer exited successfully but did not create the requested output file."
return 1, plan
candidate.replace(output)
plan["renamed_output_from"] = str(candidate)
plan["generated"] = {"path": str(output), "size_bytes": output.stat().st_size}
return 0, plan
def strip_gcode_comment(line: str) -> str:
return line.split(";", 1)[0].strip()
def parse_command(line: str) -> str:
stripped = strip_gcode_comment(line)
if not stripped:
return ""
first = stripped.split(None, 1)[0].upper()
if re.fullmatch(r"[GMT]\d+(?:\.\d+)?", first):
if "." in first:
first = first.split(".", 1)[0]
return first
return first
def parse_numeric_tokens(line: str) -> dict[str, float]:
stripped = strip_gcode_comment(line).upper()
tokens: dict[str, float] = {}
for key, value in re.findall(r"([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))", stripped):
try:
tokens[key] = float(value)
except ValueError:
continue
return tokens
def validate_gcode_file(path: Path, profile: GCodeProfile) -> dict[str, Any]:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"G-code file does not exist: {resolved}")
text = resolved.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errors: list[str] = []
warnings: list[str] = []
unknown_commands: dict[str, int] = {}
stats = {
"lines": len(lines),
"non_comment_lines": 0,
"movement_commands": 0,
"extrusion_moves": 0,
"temperature_commands": 0,
}
if not text.strip():
errors.append("G-code file is empty.")
absolute_positioning = True
x_min, x_max = profile.motion_bounds_mm["x"]
y_min, y_max = profile.motion_bounds_mm["y"]
z_min, z_max = profile.motion_bounds_mm["z"]
for line_number, line in enumerate(lines, start=1):
command = parse_command(line)
if not command:
continue
stats["non_comment_lines"] += 1
tokens = parse_numeric_tokens(line)
if command not in SUPPORTED_GCODE_COMMANDS and not re.fullmatch(r"T\d+", command):
unknown_commands.setdefault(command, line_number)
if command == "G90":
absolute_positioning = True
elif command == "G91":
absolute_positioning = False
if "Relative positioning found; XYZ bounds validation is skipped while relative mode is active." not in warnings:
warnings.append("Relative positioning found; XYZ bounds validation is skipped while relative mode is active.")
if command in {"G0", "G1", "G2", "G3"}:
stats["movement_commands"] += 1
if command == "G1" and "E" in tokens:
stats["extrusion_moves"] += 1
if absolute_positioning:
if "X" in tokens and not x_min <= tokens["X"] <= x_max:
errors.append(f"Line {line_number}: X={tokens['X']} is outside X motion range {x_min}..{x_max} mm.")
if "Y" in tokens and not y_min <= tokens["Y"] <= y_max:
errors.append(f"Line {line_number}: Y={tokens['Y']} is outside Y motion range {y_min}..{y_max} mm.")
if "Z" in tokens and not z_min <= tokens["Z"] <= z_max:
errors.append(f"Line {line_number}: Z={tokens['Z']} is outside Z motion range {z_min}..{z_max} mm.")
if command in {"M104", "M109", "M140", "M190"}:
stats["temperature_commands"] += 1
if stats["movement_commands"] == 0:
errors.append("No G0/G1/G2/G3 movement commands found.")
if stats["extrusion_moves"] == 0:
errors.append("No extrusion moves found.")
if stats["temperature_commands"] == 0:
errors.append("No nozzle or bed temperature commands found.")
if unknown_commands:
sample = ", ".join(f"{cmd} line {line}" for cmd, line in sorted(unknown_commands.items())[:12])
warnings.append(f"Unknown or unsupported commands were left unchanged: {sample}.")
return {
"ok": not errors,
"path": str(resolved),
"profile": asdict(profile),
"errors": errors,
"warnings": warnings,
"stats": stats,
}
def print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
def discover_main(args: argparse.Namespace) -> int:
print_json(discovery_report(search_path=args.search_path))
return 0
def inspect_main(args: argparse.Namespace) -> int:
inspection = inspect_input(Path(args.input))
payload = asdict(inspection)
if args.json:
print_json(payload)
else:
print(f"{payload['status']}: {payload['path']}")
return 0
def slice_main(args: argparse.Namespace) -> int:
if args.execute and args.dry_run:
raise GCodeToolError("--execute and --dry-run cannot be used together.")
if not args.execute:
plan = build_slice_plan(args, search_path=args.search_path)
plan["dry_run"] = True
print_json(plan)
return 0
code, result = execute_slice(args, search_path=args.search_path)
result["dry_run"] = False
print_json(result)
return code
def validate_main(args: argparse.Namespace) -> int:
profile = load_profile(Path(args.profile).expanduser())
result = validate_gcode_file(Path(args.gcode), profile)
if args.json:
print_json(result)
else:
status = "ok" if result["ok"] else "failed"
print(f"Validation {status}: {result['path']}")
for error in result["errors"]:
print(f"error: {error}")
for warning in result["warnings"]:
print(f"warning: {warning}")
return 0 if result["ok"] else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
discover = subparsers.add_parser("discover", help="Report installed slicer backends.")
discover.add_argument("--search-path", help=argparse.SUPPRESS)
discover.set_defaults(func=discover_main)
inspect = subparsers.add_parser("inspect", help="Classify a mesh input before slicing.")
inspect.add_argument("--input", required=True)
inspect.add_argument("--json", action="store_true")
inspect.set_defaults(func=inspect_main)
slice_parser = subparsers.add_parser("slice", help="Build or run a slicer CLI command.")
slice_parser.add_argument("--input", required=True)
slice_parser.add_argument("--output", required=True)
slice_parser.add_argument("--profile", required=True)
slice_parser.add_argument("--backend", default="auto", choices=["auto", *PREFERRED_BACKEND_ORDER])
slice_parser.add_argument("--dry-run", action="store_true", help="Print the planned slicer command without running it.")
slice_parser.add_argument("--execute", action="store_true", help="Run the slicer command and write local .gcode.")
slice_parser.add_argument("--search-path", help=argparse.SUPPRESS)
slice_parser.set_defaults(func=slice_main)
validate = subparsers.add_parser("validate", help="Statically validate generated G-code.")
validate.add_argument("--gcode", required=True)
validate.add_argument("--profile", required=True)
validate.add_argument("--json", action="store_true")
validate.set_defaults(func=validate_main)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except GCodeToolError as exc:
print_json({"ok": False, "error": str(exc)})
return 2
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake and Gradient Control Laboratories
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+169
View File
@@ -0,0 +1,169 @@
---
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.
@@ -0,0 +1,4 @@
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."
+22
View File
@@ -0,0 +1,22 @@
#!/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;
@@ -0,0 +1,394 @@
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,
};
}
@@ -0,0 +1 @@
../../../../packages/implicitjs
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const packageCli = path.join(scriptsDir, "packages", "implicitjs", "scripts", "snapshot.mjs");
const { runRenderCli } = await import(pathToFileURL(packageCli).href).catch((error) => {
process.stderr.write(
`Missing implicitjs snapshot CLI at ${packageCli}. Restore the development symlink layout or rebuild the production skill bundle.\n${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
});
process.exitCode = await runRenderCli(process.argv.slice(2), {
cwd: process.cwd(),
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+104
View File
@@ -0,0 +1,104 @@
---
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`
@@ -0,0 +1,4 @@
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."
@@ -0,0 +1,136 @@
# 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.
@@ -0,0 +1,115 @@
# 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.
```
@@ -0,0 +1,148 @@
# 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.
@@ -0,0 +1,87 @@
# 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/`
@@ -0,0 +1,55 @@
# 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.
@@ -0,0 +1,151 @@
# 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"},
}
```
@@ -0,0 +1,56 @@
# 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.
@@ -0,0 +1,76 @@
# 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.
@@ -0,0 +1,115 @@
# 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.
@@ -0,0 +1,79 @@
# 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.
@@ -0,0 +1,98 @@
# 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.
@@ -0,0 +1,154 @@
# SDF validation
Generation validates every `gen_sdf()` result before writing. This validation is dependency-light and intended to catch common structural errors. It is not a replacement for libsdformat, Gazebo, or target-simulator validation.
## Validation model
The validator should produce structured diagnostics with severities:
- `error`: invalid or unsafe enough to block writing output;
- `warning`: likely problem or unverified simulator behavior; output can be written unless `--strict` is used;
- `info`: assumption, skipped check, or useful context.
`--strict` treats warnings as failures.
## Bundled checks
### Root and document shape
The runtime should check that:
- the root element is `<sdf>`;
- the root has a non-empty `version` attribute;
- the version looks like `major.minor`;
- the document contains meaningful SDF content such as a model, world, actor, light, include, or plugin;
- structurally valid pure world files are accepted even when they contain no inline model.
### Names and scopes
The runtime should check that:
- world names are non-empty and unique at root scope;
- root model names are non-empty and unique;
- model link, joint, frame, sensor, visual, and collision names are non-empty where required and unique within their owner scope;
- duplicate names are reported with a path and scope.
### Poses
The runtime should check all `<pose>` elements:
- default `rotation_format="euler_rpy"` has exactly six finite values;
- `rotation_format="quat_xyzw"` has exactly seven finite values;
- unsupported `rotation_format` is an error;
- quaternion values are approximately normalized;
- `degrees="true"` is a warning unless strict mode is enabled;
- nontrivial omitted `relative_to` is a warning;
- `relative_to` resolves within local scope when possible;
- nested `::` references have valid syntax and resolve when the local tree is available.
### Frames
The runtime should check that:
- `<frame name="...">` has a non-empty unique name in its scope;
- `attached_to`, when present, resolves locally when possible;
- frame attachment chains do not cycle;
- unresolved nested or external frame references are reported as warnings when local validation cannot prove them invalid.
### Joints
Known SDF 1.12 joint types:
```text
continuous, revolute, gearbox, revolute2, prismatic, ball, screw, universal, fixed
```
The runtime should check that:
- joint type is non-empty and known;
- `<parent>` and `<child>` text exists;
- `world` is allowed as parent but not child;
- unscoped parent/child references exist in the same model;
- `axis` and `axis2` vectors are finite, nonzero, and normalized;
- `axis2` is used only where the joint type supports a second axis;
- `expressed_in` resolves when local resolution is possible;
- limit and dynamics values are finite or documented infinities where SDFormat permits them;
- finite lower limits do not exceed finite upper limits;
- continuous joints with fake finite position limits produce a warning.
### Geometry and mesh URIs
The runtime should check that:
- each visual/collision owner has one geometry element;
- each geometry has exactly one known primitive or mesh child when possible;
- box size has 3 positive finite values;
- cylinder radius and length are positive and finite;
- sphere radius is positive and finite;
- plane size has 2 positive finite values;
- mesh URI values are non-empty;
- mesh scale has 3 positive finite values when present;
- local mesh references resolve relative to the generated `.sdf` location;
- known external URI schemes such as `model://`, `package://`, `fuel://`, `http://`, and `https://` are accepted without local filesystem resolution.
### Inertials
The runtime should check that:
- mass is positive and finite;
- inertial pose is valid when present;
- inertia tensor components are finite;
- inertia matrix is positive semidefinite within tolerance;
- missing inertial data on dynamic physical links is at least a warning;
- frame-like or static links can omit inertials when documented.
### Sensors and plugins
The runtime should check that:
- sensor names are non-empty and unique within owner scope;
- sensor `type` is non-empty;
- sensor `update_rate`, when present, is finite and non-negative;
- sensor pose is valid;
- plugin filename is non-empty;
- plugin name, when present, is non-empty;
- arbitrary simulator-specific plugin schemas are not invented by the validator.
Plugin filenames and parameters can pass bundled validation and still fail in the target simulator. Use smoke tests.
### CAD Viewer review
CAD Viewer treats SDF plugins, sensors, lights, includes, and nested models as static metadata. The bundled validator checks generic structure only; it does not validate Explorer-only motion contracts or execute simulator plugins.
After generated `.sdf` files are created or modified, hand explicit paths to `$cad-viewer` for live viewer links when available.
This plugin is for CAD Viewer visualization and review. It is not a Gazebo physics/controller plugin and should not be represented as simulator runtime behavior.
## External checks
When Gazebo tooling is available, run:
```bash
gz sdf --check path/to/file.sdf
```
The CLI option should be:
```bash
python scripts/sdf path/to/source.py --gz-check auto
```
External checks should be recorded in the diagnostics report. A skipped optional check is not a bundled-validation failure unless the user requested `--gz-check required`.
## SDF validity vs project policy
Separate these categories:
| Category | Examples |
|---|---|
| SDF structural validity | root `<sdf>`, version, legal element shape, non-empty names, references |
| Numeric plausibility | finite poses, positive dimensions, positive mass, normalized axes, PSD inertia |
| Simulator compatibility | libsdformat version, supported joint types, plugin availability, sensor support |
| Project policy | mesh location, preferred URI style, STL/DAE preference, collision simplification, no unresolved external URIs |
Do not reject valid SDF merely because it violates a project policy unless the task or repository requires that policy. Prefer warnings and strict-mode controls.
+1
View File
@@ -0,0 +1 @@
--editable ./scripts/packages/cadpy_metadata
@@ -0,0 +1 @@
../../../../packages/cadpy_metadata
@@ -0,0 +1 @@
"""SDFormat/SDF generation and validation helpers."""
@@ -0,0 +1,16 @@
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())
@@ -0,0 +1,405 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import math
import xml.etree.ElementTree as ET
Number = int | float
PoseValues = Sequence[Number] | ET.Element | None
def fmt_float(value: object) -> str:
number = _finite_float(value, "value")
if number == 0:
return "0"
if 1e-4 <= abs(number) < 1e9:
text_value = f"{number:.12f}".rstrip("0").rstrip(".")
return text_value or "0"
return f"{number:.12g}"
def fmt_vector(values: Sequence[object]) -> str:
return " ".join(fmt_float(value) for value in values)
def text(parent: ET.Element, tag: str, value: object, attrib: Mapping[str, object] | None = None) -> ET.Element:
child = ET.SubElement(parent, tag, _string_attrib(attrib))
child.text = str(value)
return child
def sdf_root(version: str = "1.12") -> ET.Element:
version_text = str(version or "").strip()
if not version_text:
raise ValueError("SDF version must be non-empty")
return ET.Element("sdf", {"version": version_text})
def world(parent: ET.Element, name: str) -> ET.Element:
return ET.SubElement(parent, "world", {"name": _required_name(name, "world")})
def model(
parent: ET.Element,
name: str,
*,
static: bool | None = None,
pose: PoseValues = None,
) -> ET.Element:
model_element = ET.SubElement(parent, "model", {"name": _required_name(name, "model")})
if static is not None:
text(model_element, "static", "true" if static else "false")
_append_pose(model_element, pose)
return model_element
def frame(
parent: ET.Element,
name: str,
*,
attached_to: str | None = None,
pose: PoseValues = None,
) -> ET.Element:
attrib = {"name": _required_name(name, "frame")}
if attached_to:
attrib["attached_to"] = str(attached_to)
frame_element = ET.SubElement(parent, "frame", attrib)
_append_pose(frame_element, pose)
return frame_element
def link(parent: ET.Element, name: str, *, pose: PoseValues = None, inertial: Mapping[str, object] | None = None) -> ET.Element:
link_element = ET.SubElement(parent, "link", {"name": _required_name(name, "link")})
_append_pose(link_element, pose)
if inertial:
inertial_element = inertial_helper(
link_element,
inertial["mass"],
inertial["inertia"],
pose=inertial.get("pose"),
)
del inertial_element
return link_element
def joint(
parent: ET.Element,
name: str,
joint_type: str,
parent_link: str,
child_link: str,
*,
pose: PoseValues = None,
axis_xyz: Sequence[object] | None = None,
axis_expressed_in: str | None = None,
axis2_xyz: Sequence[object] | None = None,
limits: Mapping[str, object] | None = None,
) -> ET.Element:
joint_element = ET.SubElement(
parent,
"joint",
{
"name": _required_name(name, "joint"),
"type": _required_name(joint_type, "joint type"),
},
)
text(joint_element, "parent", _required_name(parent_link, "joint parent"))
text(joint_element, "child", _required_name(child_link, "joint child"))
_append_pose(joint_element, pose)
if axis_xyz is not None:
axis_element = axis(joint_element, axis_xyz, expressed_in=axis_expressed_in)
if limits:
_limit(axis_element, limits)
if axis2_xyz is not None:
axis2_element = axis(joint_element, axis2_xyz, expressed_in=axis_expressed_in, tag="axis2")
if limits and "axis2" in limits and isinstance(limits["axis2"], Mapping):
_limit(axis2_element, limits["axis2"])
return joint_element
def pose(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 0),
rpy: Sequence[object] = (0, 0, 0),
*,
relative_to: str | None = None,
rotation_format: str = "euler_rpy",
degrees: bool = False,
) -> ET.Element:
if rotation_format != "euler_rpy":
raise ValueError("pose() supports rotation_format='euler_rpy'; use quat_pose() for quaternions")
values = [*_vector(xyz, 3, "pose xyz"), *_vector(rpy, 3, "pose rpy")]
attrib: dict[str, object] = {}
if relative_to:
attrib["relative_to"] = str(relative_to)
if degrees:
attrib["degrees"] = "true"
return text(parent, "pose", fmt_vector(values), attrib)
def quat_pose(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 0),
quat_xyzw: Sequence[object] = (0, 0, 0, 1),
*,
relative_to: str | None = None,
) -> ET.Element:
quat = _vector(quat_xyzw, 4, "pose quaternion")
if _norm(quat) == 0:
raise ValueError("pose quaternion must be nonzero")
attrib: dict[str, object] = {"rotation_format": "quat_xyzw"}
if relative_to:
attrib["relative_to"] = str(relative_to)
return text(parent, "pose", fmt_vector([*_vector(xyz, 3, "pose xyz"), *quat]), attrib)
def axis(
parent: ET.Element,
xyz: Sequence[object] = (0, 0, 1),
*,
expressed_in: str | None = None,
tag: str = "axis",
) -> ET.Element:
values = _vector(xyz, 3, f"{tag} xyz")
if _norm(values) == 0:
raise ValueError(f"{tag} vector must be nonzero")
axis_element = ET.SubElement(parent, tag)
attrib = {"expressed_in": str(expressed_in)} if expressed_in else None
text(axis_element, "xyz", fmt_vector(values), attrib)
return axis_element
def visual(
parent: ET.Element,
name: str,
*,
pose: PoseValues = None,
geometry_builder: Callable[[ET.Element], object] | None = None,
) -> ET.Element:
owner = ET.SubElement(parent, "visual", {"name": _required_name(name, "visual")})
_append_pose(owner, pose)
_call_geometry_builder(owner, geometry_builder)
return owner
def collision(
parent: ET.Element,
name: str,
*,
pose: PoseValues = None,
geometry_builder: Callable[[ET.Element], object] | None = None,
) -> ET.Element:
owner = ET.SubElement(parent, "collision", {"name": _required_name(name, "collision")})
_append_pose(owner, pose)
_call_geometry_builder(owner, geometry_builder)
return owner
def box(parent: ET.Element, size_xyz: Sequence[object]) -> ET.Element:
values = _positive_vector(size_xyz, 3, "box size")
box_element = ET.SubElement(_geometry_parent(parent), "box")
text(box_element, "size", fmt_vector(values))
return box_element
def sphere(parent: ET.Element, radius: object) -> ET.Element:
sphere_element = ET.SubElement(_geometry_parent(parent), "sphere")
text(sphere_element, "radius", fmt_float(_positive_float(radius, "sphere radius")))
return sphere_element
def cylinder(parent: ET.Element, radius: object, length: object) -> ET.Element:
cylinder_element = ET.SubElement(_geometry_parent(parent), "cylinder")
text(cylinder_element, "radius", fmt_float(_positive_float(radius, "cylinder radius")))
text(cylinder_element, "length", fmt_float(_positive_float(length, "cylinder length")))
return cylinder_element
def capsule(parent: ET.Element, radius: object, length: object) -> ET.Element:
capsule_element = ET.SubElement(_geometry_parent(parent), "capsule")
text(capsule_element, "radius", fmt_float(_positive_float(radius, "capsule radius")))
text(capsule_element, "length", fmt_float(_positive_float(length, "capsule length")))
return capsule_element
def mesh(parent: ET.Element, uri: str, *, scale: Sequence[object] | None = None) -> ET.Element:
uri_text = str(uri or "").strip()
if not uri_text:
raise ValueError("mesh uri must be non-empty")
mesh_element = ET.SubElement(_geometry_parent(parent), "mesh")
text(mesh_element, "uri", uri_text)
if scale is not None:
text(mesh_element, "scale", fmt_vector(_positive_vector(scale, 3, "mesh scale")))
return mesh_element
def inertial(parent: ET.Element, mass: object, inertia: Mapping[str, object] | Sequence[object], *, pose: PoseValues = None) -> ET.Element:
return inertial_helper(parent, mass, inertia, pose=pose)
def sensor(
parent: ET.Element,
name: str,
sensor_type: str,
*,
pose: PoseValues = None,
topic: str | None = None,
update_rate: object | None = None,
) -> ET.Element:
sensor_element = ET.SubElement(
parent,
"sensor",
{"name": _required_name(name, "sensor"), "type": _required_name(sensor_type, "sensor type")},
)
_append_pose(sensor_element, pose)
if topic:
text(sensor_element, "topic", topic)
if update_rate is not None:
rate = _finite_float(update_rate, "sensor update_rate")
if rate < 0:
raise ValueError("sensor update_rate must be non-negative")
text(sensor_element, "update_rate", fmt_float(rate))
return sensor_element
def plugin(
parent: ET.Element,
name: str,
filename: str,
params: Mapping[str, object] | None = None,
**extra_params: object,
) -> ET.Element:
plugin_element = ET.SubElement(
parent,
"plugin",
{
"name": _required_name(name, "plugin"),
"filename": _required_name(filename, "plugin filename"),
},
)
for key, value in {**dict(params or {}), **extra_params}.items():
text(plugin_element, str(key), value)
return plugin_element
def include(parent: ET.Element, uri: str, *, name: str | None = None, pose: PoseValues = None) -> ET.Element:
include_element = ET.SubElement(parent, "include")
text(include_element, "uri", _required_name(uri, "include uri"))
if name:
text(include_element, "name", name)
_append_pose(include_element, pose)
return include_element
def inertial_helper(
parent: ET.Element,
mass: object,
inertia: Mapping[str, object] | Sequence[object],
*,
pose: PoseValues = None,
) -> ET.Element:
inertial_element = ET.SubElement(parent, "inertial")
_append_pose(inertial_element, pose)
text(inertial_element, "mass", fmt_float(_positive_float(mass, "inertial mass")))
inertia_element = ET.SubElement(inertial_element, "inertia")
for key, value in _inertia_items(inertia).items():
text(inertia_element, key, fmt_float(_finite_float(value, f"inertia {key}")))
return inertial_element
def _limit(parent: ET.Element, values: Mapping[str, object]) -> ET.Element:
limit_element = ET.SubElement(parent, "limit")
for key in ("lower", "upper", "effort", "velocity"):
if key in values:
text(limit_element, key, fmt_float(_finite_float(values[key], f"limit {key}")))
return limit_element
def _append_pose(parent: ET.Element, value: PoseValues) -> None:
if value is None:
return
if isinstance(value, ET.Element):
parent.append(value)
return
if len(value) == 6:
pose(parent, xyz=value[:3], rpy=value[3:])
return
if len(value) == 7:
quat_pose(parent, xyz=value[:3], quat_xyzw=value[3:])
return
raise ValueError("pose values must contain 6 Euler values or 7 quaternion values")
def _call_geometry_builder(
owner: ET.Element,
geometry_builder: Callable[[ET.Element], object] | None,
) -> None:
if geometry_builder is None:
return
geometry_builder(_geometry_parent(owner))
def _geometry_parent(parent: ET.Element) -> ET.Element:
if _local_name(parent.tag) == "geometry":
return parent
if _local_name(parent.tag) in {"visual", "collision"}:
existing = next((child for child in list(parent) if _local_name(child.tag) == "geometry"), None)
return existing if existing is not None else ET.SubElement(parent, "geometry")
return ET.SubElement(parent, "geometry")
def _inertia_items(inertia: Mapping[str, object] | Sequence[object]) -> dict[str, object]:
keys = ("ixx", "iyy", "izz", "ixy", "ixz", "iyz")
if isinstance(inertia, Mapping):
missing = [key for key in keys if key not in inertia]
if missing:
raise ValueError(f"inertia is missing component(s): {', '.join(missing)}")
return {key: inertia[key] for key in keys}
if len(inertia) != 6:
raise ValueError("inertia sequence must contain ixx, iyy, izz, ixy, ixz, iyz")
return dict(zip(keys, inertia))
def _vector(values: Sequence[object], expected_len: int, label: str) -> list[float]:
if len(values) != expected_len:
raise ValueError(f"{label} must contain {expected_len} values")
return [_finite_float(value, label) for value in values]
def _positive_vector(values: Sequence[object], expected_len: int, label: str) -> list[float]:
return [_positive_float(value, label) for value in _vector(values, expected_len, label)]
def _finite_float(value: object, label: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label} must be numeric") from exc
if not math.isfinite(number):
raise ValueError(f"{label} must be finite")
return number
def _positive_float(value: object, label: str) -> float:
number = _finite_float(value, label)
if number <= 0:
raise ValueError(f"{label} must be positive")
return number
def _norm(values: Sequence[float]) -> float:
return math.sqrt(sum(value * value for value in values))
def _required_name(value: object, label: str) -> str:
text_value = str(value or "").strip()
if not text_value:
raise ValueError(f"{label} must be non-empty")
return text_value
def _string_attrib(attrib: Mapping[str, object] | None) -> dict[str, str]:
return {str(key): str(value) for key, value in dict(attrib or {}).items() if value is not None}
def _local_name(tag: object) -> str:
return str(tag).rsplit("}", 1)[-1]
+387
View File
@@ -0,0 +1,387 @@
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())
@@ -0,0 +1,55 @@
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
@@ -0,0 +1,61 @@
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)
@@ -0,0 +1,309 @@
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()
@@ -0,0 +1,846 @@
from __future__ import annotations
from collections.abc import Mapping
import math
from pathlib import Path
import re
from urllib.parse import unquote, urlparse
import xml.etree.ElementTree as ET
from .findings import ValidationResult, format_findings
EXTERNAL_URI_SCHEMES = {"model", "package", "http", "https", "fuel"}
COMMON_JOINT_TYPES = {
"ball",
"continuous",
"fixed",
"gearbox",
"prismatic",
"revolute",
"revolute2",
"screw",
"universal",
}
SECOND_AXIS_JOINT_TYPES = {"revolute2", "universal"}
BOOLEAN_VALUES = {"0", "1", "true", "false", "yes", "no", "on", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
POSE_TOLERANCE = 1e-12
UNIT_TOLERANCE = 1e-6
PSD_TOLERANCE = 1e-9
def validate_sdf_xml(
xml_text: str,
*,
source_path: Path,
base_dir: Path | None = None,
metadata: Mapping[str, object] | None = None,
) -> ValidationResult:
result = ValidationResult()
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
result.add(
"error",
"invalid_xml",
f"{_display_path(source_path)} could not be parsed as SDF XML: {exc}",
path="/",
)
return result
result.extend(validate_sdf_root(root, source_path=source_path, base_dir=base_dir, metadata=metadata))
return result
def validate_sdf_root(
root: ET.Element,
*,
source_path: Path,
base_dir: Path | None = None,
metadata: Mapping[str, object] | None = None,
) -> ValidationResult:
del metadata
result = ValidationResult()
resolved_path = source_path.resolve()
resolved_base_dir = Path(base_dir).resolve() if base_dir is not None else resolved_path.parent
if _local_name(root.tag) != "sdf":
result.add(
"error",
"invalid_root",
f"{_display_path(resolved_path)} root element must be <sdf>",
path="/",
)
return result
version = str(root.attrib.get("version") or "").strip()
if not version:
result.add(
"error",
"missing_version",
f"{_display_path(resolved_path)} SDF version is required",
path="/sdf",
)
elif not re.match(r"^\d+\.\d+(?:\.\d+)?$", version):
result.add(
"warning",
"unusual_version",
f"SDF version {version!r} does not look like major.minor",
path="/sdf",
)
meaningful_tags = {"model", "world", "actor", "light", "include", "plugin"}
if not any(_local_name(child.tag) in meaningful_tags for child in list(root)):
result.add(
"error",
"empty_document",
f"{_display_path(resolved_path)} must define at least one <model>, <world>, or other SDF content",
path="/sdf",
)
_check_unique_named(_children(root, "world"), result, "/sdf", "world")
_check_unique_named(_children(root, "model"), result, "/sdf", "model")
for include_element in _children(root, "include"):
_validate_include(include_element, result, "/sdf/include")
for plugin_element in _children(root, "plugin"):
_validate_plugin(plugin_element, result, "/sdf/plugin")
for world_element in _children(root, "world"):
_validate_world(world_element, result, resolved_base_dir)
for model_element in _children(root, "model"):
_validate_model(model_element, result, resolved_base_dir, parent_targets={"world"})
return result
def raise_for_validation_errors(result: ValidationResult, *, strict: bool = False) -> None:
findings = result.errors + (result.warnings if strict else [])
if findings:
from .source import SdfSourceError
raise SdfSourceError(format_findings(findings))
def _validate_world(world_element: ET.Element, result: ValidationResult, base_dir: Path) -> None:
world_name = _required_name(world_element, result, _path("world", world_element), "world")
world_path = _path("world", world_element, fallback="/sdf/world")
if not world_name:
world_name = "world"
targets = {"world", world_name}
_check_unique_named(_children(world_element, "model"), result, world_path, "model")
_check_unique_named(_children(world_element, "frame"), result, world_path, "frame")
frames = _children(world_element, "frame")
frame_names = _names(frames)
targets.update(frame_names)
for frame_element in frames:
_validate_frame(frame_element, result, targets, world_path)
_validate_frame_cycles(frames, result, world_path)
for pose_element in _children(world_element, "pose"):
_validate_pose(pose_element, result, f"{world_path}/pose", targets)
for include_element in _children(world_element, "include"):
_validate_include(include_element, result, f"{world_path}/include")
for plugin_element in _children(world_element, "plugin"):
_validate_plugin(plugin_element, result, f"{world_path}/plugin")
for model_element in _children(world_element, "model"):
_validate_model(model_element, result, base_dir, parent_targets=targets)
def _validate_model(
model_element: ET.Element,
result: ValidationResult,
base_dir: Path,
*,
parent_targets: set[str],
) -> None:
model_name = _required_name(model_element, result, _path("model", model_element), "model")
model_path = _path("model", model_element, fallback="/sdf/model")
if not model_name:
model_name = "model"
link_elements = _children(model_element, "link")
joint_elements = _children(model_element, "joint")
frame_elements = _children(model_element, "frame")
nested_model_elements = _children(model_element, "model")
plugin_elements = _children(model_element, "plugin")
_check_unique_named(link_elements, result, model_path, "link")
_check_unique_named(joint_elements, result, model_path, "joint")
_check_unique_named(frame_elements, result, model_path, "frame")
_check_unique_named(nested_model_elements, result, model_path, "model")
link_names = set(_names(link_elements))
frame_names = set(_names(frame_elements))
nested_model_names = set(_names(nested_model_elements))
local_targets = {
"world",
"__model__",
model_name,
*parent_targets,
*link_names,
*frame_names,
*nested_model_names,
}
for pose_element in _children(model_element, "pose"):
_validate_pose(pose_element, result, f"{model_path}/pose", local_targets)
for static_element in _children(model_element, "static"):
_validate_boolean_text(static_element, result, f"{model_path}/static", "static")
for frame_element in frame_elements:
_validate_frame(frame_element, result, local_targets, model_path)
_validate_frame_cycles(frame_elements, result, model_path)
child_links = _joint_child_links(joint_elements)
for link_element in link_elements:
_validate_link(link_element, result, base_dir, local_targets, model_path)
for joint_element in joint_elements:
_validate_joint(joint_element, result, link_names, local_targets, model_path)
for include_element in _children(model_element, "include"):
_validate_include(include_element, result, f"{model_path}/include")
for plugin_element in plugin_elements:
_validate_plugin(plugin_element, result, f"{model_path}/plugin")
if not _model_is_static(model_element):
referenced_links = set(child_links)
referenced_links.update(_joint_parent_links(joint_elements) - {"world"})
for link_element in link_elements:
name = _name(link_element)
if (
name
and name in referenced_links
and _children(link_element, "collision")
and not _children(link_element, "inertial")
):
result.add(
"warning",
"missing_inertial",
f"dynamic link {name!r} has collision geometry but no inertial data",
path=f"{model_path}/link[@name='{name}']",
)
for nested_model in nested_model_elements:
_validate_model(nested_model, result, base_dir, parent_targets=local_targets)
def _validate_link(
link_element: ET.Element,
result: ValidationResult,
base_dir: Path,
targets: set[str],
model_path: str,
) -> None:
link_name = _required_name(link_element, result, f"{model_path}/link", "link")
link_path = f"{model_path}/link[@name='{link_name}']" if link_name else f"{model_path}/link"
for pose_element in _children(link_element, "pose"):
_validate_pose(pose_element, result, f"{link_path}/pose", targets)
_check_unique_named(_children(link_element, "visual"), result, link_path, "visual")
_check_unique_named(_children(link_element, "collision"), result, link_path, "collision")
_check_unique_named(_children(link_element, "sensor"), result, link_path, "sensor")
visual_meshes: set[str] = set()
for visual_element in _children(link_element, "visual"):
visual_meshes.update(_validate_geometry_owner(visual_element, result, base_dir, targets, f"{link_path}/visual"))
for collision_element in _children(link_element, "collision"):
collision_meshes = _validate_geometry_owner(
collision_element,
result,
base_dir,
targets,
f"{link_path}/collision",
)
for mesh_uri in collision_meshes:
if mesh_uri in visual_meshes:
result.add(
"warning",
"collision_reuses_visual_mesh",
f"collision geometry reuses visual mesh URI {mesh_uri!r}",
path=f"{link_path}/collision",
hint="Use simplified collision geometry for physics when possible.",
)
for inertial_element in _children(link_element, "inertial"):
_validate_inertial(inertial_element, result, targets, f"{link_path}/inertial")
for sensor_element in _children(link_element, "sensor"):
_validate_sensor(sensor_element, result, targets, f"{link_path}/sensor")
def _validate_frame(frame_element: ET.Element, result: ValidationResult, targets: set[str], owner_path: str) -> None:
frame_name = _required_name(frame_element, result, f"{owner_path}/frame", "frame")
frame_path = f"{owner_path}/frame[@name='{frame_name}']" if frame_name else f"{owner_path}/frame"
attached_to = str(frame_element.attrib.get("attached_to") or "").strip()
if attached_to:
_check_reference(attached_to, result, frame_path, "attached_to", targets)
for pose_element in _children(frame_element, "pose"):
_validate_pose(pose_element, result, f"{frame_path}/pose", targets)
def _validate_joint(
joint_element: ET.Element,
result: ValidationResult,
link_names: set[str],
targets: set[str],
model_path: str,
) -> None:
joint_name = _required_name(joint_element, result, f"{model_path}/joint", "joint")
joint_path = f"{model_path}/joint[@name='{joint_name}']" if joint_name else f"{model_path}/joint"
joint_type = str(joint_element.attrib.get("type") or "").strip()
if not joint_type:
result.add("error", "missing_joint_type", f"{joint_path} type is required", path=joint_path)
elif joint_type not in COMMON_JOINT_TYPES:
result.add("error", "unknown_joint_type", f"unsupported SDF joint type {joint_type!r}", path=joint_path)
parent_link = _required_child_text(joint_element, "parent", result, f"{joint_path}/parent", "joint parent")
child_link = _required_child_text(joint_element, "child", result, f"{joint_path}/child", "joint child")
if parent_link:
_validate_link_reference(parent_link, result, link_names, f"{joint_path}/parent", allow_world=True)
if child_link:
_validate_link_reference(child_link, result, link_names, f"{joint_path}/child", allow_world=False)
for pose_element in _children(joint_element, "pose"):
_validate_pose(pose_element, result, f"{joint_path}/pose", targets)
axis_elements = _children(joint_element, "axis")
axis2_elements = _children(joint_element, "axis2")
if joint_type in {"continuous", "revolute", "prismatic"} and not axis_elements:
result.add(
"warning",
"missing_joint_axis",
f"{joint_type} joint {joint_name!r} does not declare an explicit axis",
path=joint_path,
)
for axis_element in axis_elements:
_validate_axis(axis_element, result, targets, f"{joint_path}/axis", joint_type=joint_type, axis_name="axis")
for axis2_element in axis2_elements:
if joint_type not in SECOND_AXIS_JOINT_TYPES:
result.add(
"error",
"unsupported_axis2",
f"joint type {joint_type!r} does not support axis2 in the lightweight validator",
path=f"{joint_path}/axis2",
)
_validate_axis(axis2_element, result, targets, f"{joint_path}/axis2", joint_type=joint_type, axis_name="axis2")
def _validate_axis(
axis_element: ET.Element,
result: ValidationResult,
targets: set[str],
axis_path: str,
*,
joint_type: str,
axis_name: str,
) -> None:
del axis_name
xyz_element = _first_child(axis_element, "xyz")
if xyz_element is None:
result.add("error", "missing_axis_xyz", "joint axis must contain <xyz>", path=axis_path)
else:
values = _parse_number_text(str(xyz_element.text or ""), 3, result, f"{axis_path}/xyz", "axis xyz")
if values is not None:
norm = _norm(values)
if norm == 0:
result.add("error", "zero_axis", "joint axis vector must be nonzero", path=f"{axis_path}/xyz")
elif abs(norm - 1.0) > UNIT_TOLERANCE:
result.add(
"warning",
"non_unit_axis",
f"joint axis vector length is {norm:.6g}, not 1",
path=f"{axis_path}/xyz",
)
expressed_in = str(xyz_element.attrib.get("expressed_in") or axis_element.attrib.get("expressed_in") or "").strip()
if expressed_in:
_check_reference(expressed_in, result, f"{axis_path}/xyz", "expressed_in", targets)
for limit_element in _children(axis_element, "limit"):
lower = _optional_number_child(limit_element, "lower", result, f"{axis_path}/limit/lower", allow_infinite=True)
upper = _optional_number_child(limit_element, "upper", result, f"{axis_path}/limit/upper", allow_infinite=True)
if lower is not None and upper is not None and lower > upper:
result.add("error", "invalid_joint_limit", "joint lower limit exceeds upper limit", path=f"{axis_path}/limit")
for tag in ("effort", "velocity", "stiffness", "dissipation"):
_optional_number_child(limit_element, tag, result, f"{axis_path}/limit/{tag}", allow_infinite=True)
if joint_type == "continuous" and (lower is not None or upper is not None):
result.add(
"warning",
"continuous_joint_limits",
"continuous joints usually should not use finite position limits",
path=f"{axis_path}/limit",
)
def _validate_geometry_owner(
owner: ET.Element,
result: ValidationResult,
base_dir: Path,
targets: set[str],
owner_path: str,
) -> set[str]:
owner_name = _required_name(owner, result, owner_path, _local_name(owner.tag))
path = f"{owner_path}[@name='{owner_name}']" if owner_name else owner_path
for pose_element in _children(owner, "pose"):
_validate_pose(pose_element, result, f"{path}/pose", targets)
geometry_elements = _children(owner, "geometry")
if len(geometry_elements) != 1:
result.add(
"error",
"invalid_geometry_count",
f"{_local_name(owner.tag)} must contain exactly one <geometry>",
path=path,
)
return set()
geometry = geometry_elements[0]
known_children = [
child for child in list(geometry) if _local_name(child.tag) in {"box", "sphere", "cylinder", "capsule", "plane", "mesh"}
]
if len(known_children) != 1:
result.add(
"error",
"invalid_geometry_shape",
"geometry must contain exactly one recognized primitive or mesh child",
path=f"{path}/geometry",
)
return set()
shape = known_children[0]
shape_name = _local_name(shape.tag)
if shape_name == "box":
_validate_positive_vector_child(shape, "size", 3, result, f"{path}/geometry/box/size")
elif shape_name == "sphere":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/sphere/radius")
elif shape_name == "cylinder":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/cylinder/radius")
_validate_positive_number_child(shape, "length", result, f"{path}/geometry/cylinder/length")
elif shape_name == "capsule":
_validate_positive_number_child(shape, "radius", result, f"{path}/geometry/capsule/radius")
_validate_positive_number_child(shape, "length", result, f"{path}/geometry/capsule/length")
elif shape_name == "plane":
_validate_positive_vector_child(shape, "size", 2, result, f"{path}/geometry/plane/size")
elif shape_name == "mesh":
uri = _required_child_text(shape, "uri", result, f"{path}/geometry/mesh/uri", "mesh uri")
_validate_positive_vector_child(shape, "scale", 3, result, f"{path}/geometry/mesh/scale", required=False)
if uri:
_validate_mesh_uri(uri, result, base_dir, f"{path}/geometry/mesh/uri")
return {uri}
return set()
def _validate_inertial(inertial_element: ET.Element, result: ValidationResult, targets: set[str], inertial_path: str) -> None:
for pose_element in _children(inertial_element, "pose"):
_validate_pose(pose_element, result, f"{inertial_path}/pose", targets)
mass = _optional_number_child(inertial_element, "mass", result, f"{inertial_path}/mass", required=True)
if mass is not None and mass <= 0:
result.add("error", "invalid_mass", "inertial mass must be positive", path=f"{inertial_path}/mass")
inertia_element = _first_child(inertial_element, "inertia")
if inertia_element is None:
result.add("warning", "missing_inertia_matrix", "inertial element has mass but no inertia matrix", path=inertial_path)
return
components: dict[str, float] = {}
for tag in ("ixx", "iyy", "izz", "ixy", "ixz", "iyz"):
value = _optional_number_child(inertia_element, tag, result, f"{inertial_path}/inertia/{tag}", required=True)
if value is not None:
components[tag] = value
if len(components) != 6:
return
if not _inertia_is_psd(components):
result.add(
"error",
"invalid_inertia_matrix",
"inertia matrix must be positive semidefinite within tolerance",
path=f"{inertial_path}/inertia",
)
def _validate_sensor(sensor_element: ET.Element, result: ValidationResult, targets: set[str], sensor_path: str) -> None:
sensor_name = _required_name(sensor_element, result, sensor_path, "sensor")
path = f"{sensor_path}[@name='{sensor_name}']" if sensor_name else sensor_path
sensor_type = str(sensor_element.attrib.get("type") or "").strip()
if not sensor_type:
result.add("error", "missing_sensor_type", "sensor type is required", path=path)
for pose_element in _children(sensor_element, "pose"):
_validate_pose(pose_element, result, f"{path}/pose", targets)
update_rate = _optional_number_child(sensor_element, "update_rate", result, f"{path}/update_rate")
if update_rate is not None and update_rate < 0:
result.add("error", "invalid_sensor_update_rate", "sensor update_rate must be non-negative", path=f"{path}/update_rate")
def _validate_plugin(plugin_element: ET.Element, result: ValidationResult, plugin_path: str) -> None:
filename = str(plugin_element.attrib.get("filename") or "").strip()
if not filename:
result.add("error", "missing_plugin_filename", "plugin filename is required", path=plugin_path)
if "name" in plugin_element.attrib and not str(plugin_element.attrib.get("name") or "").strip():
result.add("error", "missing_plugin_name", "plugin name is empty", path=plugin_path)
elif "name" not in plugin_element.attrib:
result.add("warning", "missing_plugin_name", "plugin name is omitted", path=plugin_path)
def _validate_include(include_element: ET.Element, result: ValidationResult, include_path: str) -> None:
_required_child_text(include_element, "uri", result, f"{include_path}/uri", "include uri")
def _validate_pose(pose_element: ET.Element, result: ValidationResult, pose_path: str, targets: set[str]) -> None:
rotation_format = str(pose_element.attrib.get("rotation_format") or "euler_rpy").strip()
if rotation_format == "euler_rpy":
values = _parse_number_text(str(pose_element.text or ""), 6, result, pose_path, "pose")
elif rotation_format == "quat_xyzw":
values = _parse_number_text(str(pose_element.text or ""), 7, result, pose_path, "pose")
if values is not None:
quat = values[3:]
norm = _norm(quat)
if norm == 0:
result.add("error", "zero_quaternion", "quaternion pose has zero norm", path=pose_path)
elif abs(norm - 1.0) > UNIT_TOLERANCE:
result.add("warning", "non_unit_quaternion", f"quaternion pose norm is {norm:.6g}, not 1", path=pose_path)
else:
result.add("error", "unsupported_rotation_format", f"unsupported pose rotation_format {rotation_format!r}", path=pose_path)
values = None
degrees = str(pose_element.attrib.get("degrees") or "").strip().lower()
if degrees and degrees not in BOOLEAN_VALUES:
result.add("error", "invalid_pose_degrees", "pose degrees attribute must be boolean-like", path=pose_path)
elif degrees in TRUE_VALUES:
result.add("warning", "pose_uses_degrees", "pose uses degrees=true; SDF defaults to radians", path=pose_path)
relative_to = str(pose_element.attrib.get("relative_to") or "").strip()
if values is not None and _nontrivial_pose(values) and not relative_to:
result.add(
"warning",
"pose_missing_relative_to",
"nontrivial pose omits explicit relative_to",
path=pose_path,
)
if relative_to:
_check_reference(relative_to, result, pose_path, "relative_to", targets)
def _validate_frame_cycles(frames: list[ET.Element], result: ValidationResult, owner_path: str) -> None:
frame_names = set(_names(frames))
edges: dict[str, str] = {}
for frame in frames:
name = _name(frame)
target = str(frame.attrib.get("attached_to") or "").strip()
if not target:
pose_element = _first_child(frame, "pose")
target = str(pose_element.attrib.get("relative_to") or "").strip() if pose_element is not None else ""
if name and target in frame_names:
edges[name] = target
visiting: set[str] = set()
visited: set[str] = set()
def visit(frame_name: str, stack: list[str]) -> None:
if frame_name in visited:
return
if frame_name in visiting:
cycle = " -> ".join([*stack, frame_name])
result.add("error", "frame_cycle", f"frame graph contains a cycle: {cycle}", path=f"{owner_path}/frame")
return
visiting.add(frame_name)
target = edges.get(frame_name)
if target:
visit(target, [*stack, frame_name])
visiting.remove(frame_name)
visited.add(frame_name)
for frame_name in edges:
visit(frame_name, [])
def _validate_mesh_uri(uri: str, result: ValidationResult, base_dir: Path, uri_path: str) -> None:
parsed = urlparse(uri)
if parsed.scheme in EXTERNAL_URI_SCHEMES:
return
if parsed.scheme and parsed.scheme != "file":
result.add(
"warning",
"unresolved_mesh_uri_scheme",
f"mesh URI scheme {parsed.scheme!r} is not resolved by bundled validation",
path=uri_path,
)
return
mesh_path = Path(unquote(parsed.path)).resolve() if parsed.scheme == "file" else (base_dir / uri).resolve()
if not mesh_path.is_file():
result.add(
"error",
"missing_mesh_file",
f"references missing mesh file: {uri!r}",
path=uri_path,
)
def _validate_link_reference(
link_ref: str,
result: ValidationResult,
link_names: set[str],
path: str,
*,
allow_world: bool,
) -> None:
if link_ref == "world":
if allow_world:
return
result.add("error", "invalid_joint_child", "joint child may not be world", path=path)
return
if "::" in link_ref:
result.add(
"warning",
"scoped_link_reference_unchecked",
f"scoped link reference {link_ref!r} was not fully resolved by bundled validation",
path=path,
)
return
if link_ref not in link_names:
result.add("error", "missing_link_reference", f"references missing link {link_ref!r}", path=path)
def _check_reference(ref: str, result: ValidationResult, path: str, attr: str, targets: set[str]) -> None:
if "::" in ref:
if any(part == "" for part in ref.split("::")):
result.add("error", "invalid_scoped_reference", f"{attr} has malformed scoped reference {ref!r}", path=path)
else:
result.add(
"warning",
"scoped_reference_unchecked",
f"{attr} scoped reference {ref!r} was not fully resolved by bundled validation",
path=path,
)
return
if ref not in targets:
result.add("error", "unresolved_reference", f"{attr} references unknown frame or link {ref!r}", path=path)
def _check_unique_named(elements: list[ET.Element], result: ValidationResult, scope_path: str, label: str) -> None:
seen: set[str] = set()
duplicates: set[str] = set()
for element in elements:
name = _required_name(element, result, f"{scope_path}/{label}", label)
if not name:
continue
if name in seen:
duplicates.add(name)
seen.add(name)
if duplicates:
duplicate_text = ", ".join(repr(item) for item in sorted(duplicates))
result.add(
"error",
"duplicate_names",
f"{label} names contain duplicates {duplicate_text}",
path=f"{scope_path}/{label}",
)
def _validate_positive_vector_child(
parent: ET.Element,
tag: str,
expected_len: int,
result: ValidationResult,
path: str,
*,
required: bool = True,
) -> list[float] | None:
child = _first_child(parent, tag)
if child is None:
if required:
result.add("error", "missing_numeric_vector", f"{tag} is required", path=path)
return None
values = _parse_number_text(str(child.text or ""), expected_len, result, path, tag)
if values is None:
return None
for value in values:
if value <= 0:
result.add("error", "invalid_dimension", f"{tag} values must be positive", path=path)
break
return values
def _validate_positive_number_child(parent: ET.Element, tag: str, result: ValidationResult, path: str) -> float | None:
value = _optional_number_child(parent, tag, result, path, required=True)
if value is not None and value <= 0:
result.add("error", "invalid_dimension", f"{tag} must be positive", path=path)
return value
def _optional_number_child(
parent: ET.Element,
tag: str,
result: ValidationResult,
path: str,
*,
required: bool = False,
allow_infinite: bool = False,
) -> float | None:
child = _first_child(parent, tag)
if child is None or str(child.text or "").strip() == "":
if required:
result.add("error", "missing_number", f"{tag} is required", path=path)
return None
return _parse_optional_number(str(child.text or ""), result, path, tag, required=required, allow_infinite=allow_infinite)
def _parse_optional_number(
value: str,
result: ValidationResult,
path: str,
label: str,
*,
required: bool = False,
allow_infinite: bool = False,
) -> float | None:
value = str(value or "").strip()
if not value:
if required:
result.add("error", "missing_number", f"{label} is required", path=path)
return None
try:
number = float(value)
except ValueError:
result.add("error", "invalid_number", f"{label} must be numeric", path=path)
return None
if allow_infinite:
if math.isnan(number):
result.add("error", "invalid_number", f"{label} must not be NaN", path=path)
return None
elif not math.isfinite(number):
result.add("error", "invalid_number", f"{label} must be finite", path=path)
return None
return number
def _parse_number_text(
text: str,
expected_len: int,
result: ValidationResult,
path: str,
label: str,
) -> list[float] | None:
parts = str(text or "").split()
if len(parts) != expected_len:
result.add("error", "invalid_numeric_vector", f"{label} must contain {expected_len} numeric values", path=path)
return None
values: list[float] = []
for part in parts:
try:
value = float(part)
except ValueError:
result.add("error", "invalid_number", f"{label} contains a non-numeric value {part!r}", path=path)
return None
if not math.isfinite(value):
result.add("error", "invalid_number", f"{label} values must be finite", path=path)
return None
values.append(value)
return values
def _validate_boolean_text(element: ET.Element, result: ValidationResult, path: str, label: str) -> None:
value = str(element.text or "").strip().lower()
if value and value not in BOOLEAN_VALUES:
result.add("error", "invalid_boolean", f"{label} must be boolean-like", path=path)
def _required_name(element: ET.Element, result: ValidationResult, path: str, label: str) -> str:
name = _name(element)
if not name:
result.add("error", "missing_name", f"{label} name is required", path=path)
return name
def _required_child_text(
parent: ET.Element,
tag: str,
result: ValidationResult,
path: str,
label: str,
) -> str:
value = _child_text(parent, tag)
if not value:
result.add("error", "missing_child_text", f"{label} is required", path=path)
return value
def _joint_child_links(joints: list[ET.Element]) -> set[str]:
return {_child_text(joint, "child") for joint in joints if _child_text(joint, "child") and "::" not in _child_text(joint, "child")}
def _joint_parent_links(joints: list[ET.Element]) -> set[str]:
return {_child_text(joint, "parent") for joint in joints if _child_text(joint, "parent") and "::" not in _child_text(joint, "parent")}
def _model_is_static(model_element: ET.Element) -> bool:
value = _child_text(model_element, "static").lower()
return value in TRUE_VALUES
def _inertia_is_psd(values: dict[str, float]) -> bool:
ixx = values["ixx"]
iyy = values["iyy"]
izz = values["izz"]
ixy = values["ixy"]
ixz = values["ixz"]
iyz = values["iyz"]
minor_xy = (ixx * iyy) - (ixy * ixy)
minor_xz = (ixx * izz) - (ixz * ixz)
minor_yz = (iyy * izz) - (iyz * iyz)
det = (ixx * iyy * izz) + (2 * ixy * ixz * iyz) - (ixx * iyz * iyz) - (iyy * ixz * ixz) - (izz * ixy * ixy)
return (
ixx >= -PSD_TOLERANCE
and iyy >= -PSD_TOLERANCE
and izz >= -PSD_TOLERANCE
and minor_xy >= -PSD_TOLERANCE
and minor_xz >= -PSD_TOLERANCE
and minor_yz >= -PSD_TOLERANCE
and det >= -PSD_TOLERANCE
)
def _nontrivial_pose(values: list[float]) -> bool:
return any(abs(value) > POSE_TOLERANCE for value in values)
def _norm(values: list[float]) -> float:
return math.sqrt(sum(value * value for value in values))
def _children(parent: ET.Element, tag_name: str) -> list[ET.Element]:
return [child for child in list(parent) if _local_name(child.tag) == tag_name]
def _first_child(parent: ET.Element, tag_name: str) -> ET.Element | None:
return next(iter(_children(parent, tag_name)), None)
def _child_text(parent: ET.Element, tag_name: str) -> str:
child = _first_child(parent, tag_name)
return str(child.text if child is not None else "").strip()
def _name(element: ET.Element) -> str:
return str(element.attrib.get("name") or "").strip()
def _names(elements: list[ET.Element]) -> list[str]:
return [_name(element) for element in elements if _name(element)]
def _local_name(tag: object) -> str:
return str(tag).rsplit("}", 1)[-1]
def _path(tag: str, element: ET.Element, *, fallback: str | None = None) -> str:
name = _name(element)
if name:
return f"/sdf/{tag}[@name='{name}']"
return fallback or f"/sdf/{tag}"
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+122
View File
@@ -0,0 +1,122 @@
---
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`
@@ -0,0 +1,4 @@
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."
@@ -0,0 +1,40 @@
# 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.
@@ -0,0 +1,42 @@
# 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`.

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