Import CAD projects and cad-router v1
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
../../../viewer
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../../packages/cadpy
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user